mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
feat(traces): integrate traces app into monorepo with NestJS backend and AI city guides
Restructure standalone traces app into monorepo pattern with mobile + backend + shared types. Add NestJS backend with Drizzle ORM schema for locations, cities, places, POIs, and AI guides. Add mobile sync layer, cities tab, and guide generation UI. Fix pre-existing type errors across mobile codebase, matrix-mana-bot (sendDirectMessage), llm-playground, and all web auth stores (signUp call signature). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
402e135179
commit
bd1178edf8
125 changed files with 14626 additions and 831 deletions
|
|
@ -345,6 +345,13 @@ PLANTA_S3_PUBLIC_URL=http://localhost:9000/planta-storage
|
|||
# Google Gemini API for plant vision analysis
|
||||
PLANTA_GEMINI_API_KEY=AIzaSyC_-hPWpVttTlqJdU4jbXR5H0OAnRi2LgI
|
||||
|
||||
# ============================================
|
||||
# TRACES PROJECT
|
||||
# ============================================
|
||||
|
||||
TRACES_BACKEND_PORT=3026
|
||||
TRACES_DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/traces
|
||||
|
||||
# ============================================
|
||||
# SKILLTREE PROJECT
|
||||
# ============================================
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ export const authStore = {
|
|||
try {
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
const result = await authService.signUp(email, password, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ export const authStore = {
|
|||
try {
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
const result = await authService.signUp(email, password, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ export const authStore = {
|
|||
try {
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
const result = await authService.signUp(email, password, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ export const authStore = {
|
|||
try {
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
const result = await authService.signUp(email, password, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ export const authStore = {
|
|||
|
||||
try {
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
const result = await authService.signUp(email, password, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ export const authStore = {
|
|||
try {
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
const result = await authService.signUp(email, password, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ export const authStore = {
|
|||
async signUp(email: string, password: string) {
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
const result = await authService.signUp(email, password, sourceAppUrl);
|
||||
if (result.success && !result.needsVerification) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = toManaUser(userData);
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ export const authStore = {
|
|||
try {
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
const result = await authService.signUp(email, password, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ export const authStore = {
|
|||
|
||||
try {
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
const result = await authService.signUp(email, password, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ export const authStore = {
|
|||
loading = true;
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
const result = await authService.signUp(email, password, sourceAppUrl);
|
||||
|
||||
if (result.success) {
|
||||
// Auto-login after signup
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ export const authStore = {
|
|||
try {
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
const result = await authService.signUp(email, password, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
|
|||
|
|
@ -130,7 +130,7 @@ export const auth = {
|
|||
try {
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
const result = await authService.signUp(email, password, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ export const authStore = {
|
|||
|
||||
try {
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
const result = await authService.signUp(email, password, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ export const authStore = {
|
|||
|
||||
try {
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
const result = await authService.signUp(email, password, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ export const authStore = {
|
|||
try {
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
const result = await authService.signUp(email, password, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ export const authStore = {
|
|||
try {
|
||||
// Pass the current app URL for post-verification redirect
|
||||
const sourceAppUrl = browser ? window.location.origin : undefined;
|
||||
const result = await authService.signUp(email, password, undefined, sourceAppUrl);
|
||||
const result = await authService.signUp(email, password, sourceAppUrl);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
|
|
|
|||
25
apps/traces/.gitignore
vendored
Normal file
25
apps/traces/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
npm-debug.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
# expo router
|
||||
expo-env.d.ts
|
||||
|
||||
# firebase/supabase/vexo
|
||||
.env
|
||||
|
||||
ios
|
||||
android
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Temporary files created by Metro to check the health of the file watcher
|
||||
.metro-health-check*
|
||||
76
apps/traces/CLAUDE.md
Normal file
76
apps/traces/CLAUDE.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# CLAUDE.md - Traces
|
||||
|
||||
GPS tracking app with AI city guides. Location tracking runs locally via AsyncStorage, with optional backend sync.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/traces/
|
||||
├── package.json # Orchestrator (name: traces)
|
||||
├── apps/
|
||||
│ ├── backend/ # @traces/backend (NestJS, Port 3026)
|
||||
│ │ └── src/
|
||||
│ │ ├── main.ts
|
||||
│ │ ├── app.module.ts
|
||||
│ │ ├── db/ # Drizzle schema + connection
|
||||
│ │ ├── location/ # GPS sync endpoint
|
||||
│ │ ├── city/ # City CRUD + visit stats
|
||||
│ │ ├── place/ # Saved places CRUD
|
||||
│ │ ├── poi/ # Points of Interest
|
||||
│ │ └── guide/ # AI city guide pipeline
|
||||
│ └── mobile/ # @traces/mobile (Expo SDK 54)
|
||||
│ ├── app/ # Expo Router screens
|
||||
│ ├── components/ # UI components
|
||||
│ └── utils/ # Services (location, sync, api)
|
||||
└── packages/
|
||||
└── traces-types/ # @traces/types (shared interfaces)
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
pnpm dev:traces:mobile # Start Expo app
|
||||
pnpm dev:traces:backend # Start NestJS backend
|
||||
pnpm dev:traces:full # Start auth + backend + mobile
|
||||
|
||||
# Database
|
||||
pnpm traces:db:push # Push Drizzle schema
|
||||
pnpm traces:db:studio # Open Drizzle Studio
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Mobile**: Offline-first. All GPS data in AsyncStorage. Sync is additive.
|
||||
- **Backend**: NestJS + Drizzle ORM + PostgreSQL. Auth via ManaCoreModule.
|
||||
- **AI Guides**: Uses mana-search for POI discovery, mana-llm for narratives.
|
||||
- **Credits**: 5 base + 2 per POI consumed via CreditClientService.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/v1/locations/sync` | POST | Batch sync from mobile |
|
||||
| `/api/v1/locations` | GET | Query locations |
|
||||
| `/api/v1/cities` | GET | User's visited cities |
|
||||
| `/api/v1/cities/:id` | GET | City detail + stats |
|
||||
| `/api/v1/places` | GET/POST | List/create places |
|
||||
| `/api/v1/places/:id` | PUT/DELETE | Update/delete place |
|
||||
| `/api/v1/pois` | GET | Nearby POIs |
|
||||
| `/api/v1/pois/:id` | GET | POI detail |
|
||||
| `/api/v1/guides/generate` | POST | Generate AI guide |
|
||||
| `/api/v1/guides` | GET | User's guides |
|
||||
| `/api/v1/guides/:id` | GET/DELETE | Guide detail/delete |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Backend: `PORT=3026`, `DATABASE_URL`, `MANA_CORE_AUTH_URL`, `MANA_LLM_URL`, `MANA_SEARCH_URL`
|
||||
Mobile: `EXPO_PUBLIC_TRACES_BACKEND_URL`, `EXPO_PUBLIC_MANA_CORE_AUTH_URL`
|
||||
|
||||
## Mobile Navigation (5 tabs)
|
||||
|
||||
1. **Tracking** - Live GPS tracking + map
|
||||
2. **Orte** - Saved places, cities, countries
|
||||
3. **Karte** - Full-screen map view
|
||||
4. **Städte** - Visited cities with stats
|
||||
5. **Führungen** - AI-generated city guides
|
||||
4
apps/traces/apps/backend/.gitignore
vendored
Normal file
4
apps/traces/apps/backend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
dist/
|
||||
node_modules/
|
||||
.env
|
||||
*.tsbuildinfo
|
||||
3
apps/traces/apps/backend/drizzle.config.ts
Normal file
3
apps/traces/apps/backend/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
|
||||
|
||||
export default createDrizzleConfig({ dbName: 'traces' });
|
||||
11
apps/traces/apps/backend/nest-cli.json
Normal file
11
apps/traces/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": false,
|
||||
"assets": [],
|
||||
"watchAssets": false,
|
||||
"webpack": true
|
||||
}
|
||||
}
|
||||
52
apps/traces/apps/backend/package.json
Normal file
52
apps/traces/apps/backend/package.json
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"name": "@traces/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"dev": "nest start --watch",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"type-check": "tsc --noEmit",
|
||||
"migration:generate": "drizzle-kit generate",
|
||||
"migration:run": "tsx src/db/migrate.ts",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/nestjs-integration": "workspace:*",
|
||||
"@manacore/shared-nestjs-auth": "workspace:*",
|
||||
"@manacore/shared-nestjs-health": "workspace:*",
|
||||
"@manacore/shared-nestjs-metrics": "workspace:*",
|
||||
"@manacore/shared-nestjs-setup": "workspace:*",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@traces/types": "workspace:*",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"postgres": "^3.4.5",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@manacore/shared-drizzle-config": "workspace:*",
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
41
apps/traces/apps/backend/src/app.module.ts
Normal file
41
apps/traces/apps/backend/src/app.module.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { MetricsModule } from '@manacore/shared-nestjs-metrics';
|
||||
import { ManaCoreModule } from '@manacore/nestjs-integration';
|
||||
import { HealthModule } from '@manacore/shared-nestjs-health';
|
||||
import { DatabaseModule } from './db/database.module';
|
||||
import { LocationModule } from './location/location.module';
|
||||
import { CityModule } from './city/city.module';
|
||||
import { PlaceModule } from './place/place.module';
|
||||
import { PoiModule } from './poi/poi.module';
|
||||
import { GuideModule } from './guide/guide.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
ManaCoreModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
appId: configService.get<string>('APP_ID', 'traces'),
|
||||
serviceKey: configService.get<string>('MANA_CORE_SERVICE_KEY', ''),
|
||||
debug: configService.get('NODE_ENV') === 'development',
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
MetricsModule.register({
|
||||
prefix: 'traces_',
|
||||
excludePaths: ['/health'],
|
||||
}),
|
||||
DatabaseModule,
|
||||
LocationModule,
|
||||
CityModule,
|
||||
PlaceModule,
|
||||
PoiModule,
|
||||
GuideModule,
|
||||
HealthModule.forRoot({ serviceName: 'traces-backend' }),
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
20
apps/traces/apps/backend/src/city/city.controller.ts
Normal file
20
apps/traces/apps/backend/src/city/city.controller.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser } from '@manacore/shared-nestjs-auth';
|
||||
import type { CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { CityService } from './city.service';
|
||||
|
||||
@Controller('cities')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class CityController {
|
||||
constructor(private readonly cityService: CityService) {}
|
||||
|
||||
@Get()
|
||||
async getCities(@CurrentUser() user: CurrentUserData) {
|
||||
return this.cityService.getUserCities(user.userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getCityDetail(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
return this.cityService.getCityDetail(user.userId, id);
|
||||
}
|
||||
}
|
||||
10
apps/traces/apps/backend/src/city/city.module.ts
Normal file
10
apps/traces/apps/backend/src/city/city.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { CityController } from './city.controller';
|
||||
import { CityService } from './city.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CityController],
|
||||
providers: [CityService],
|
||||
exports: [CityService],
|
||||
})
|
||||
export class CityModule {}
|
||||
147
apps/traces/apps/backend/src/city/city.service.ts
Normal file
147
apps/traces/apps/backend/src/city/city.service.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import type { Database } from '../db/connection';
|
||||
import { cities, cityVisits } from '../db/schema';
|
||||
import type { NewCity } from '../db/schema';
|
||||
|
||||
@Injectable()
|
||||
export class CityService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {}
|
||||
|
||||
async findOrCreateCity(data: {
|
||||
name: string;
|
||||
country: string;
|
||||
countryCode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}) {
|
||||
// Try to find existing city
|
||||
const existing = await this.db
|
||||
.select()
|
||||
.from(cities)
|
||||
.where(and(eq(cities.name, data.name), eq(cities.countryCode, data.countryCode)))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
return existing[0];
|
||||
}
|
||||
|
||||
// Create new city
|
||||
const [city] = await this.db
|
||||
.insert(cities)
|
||||
.values({
|
||||
name: data.name,
|
||||
country: data.country,
|
||||
countryCode: data.countryCode,
|
||||
latitude: data.latitude,
|
||||
longitude: data.longitude,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
|
||||
// Handle race condition: if insert was no-op, re-fetch
|
||||
if (!city) {
|
||||
const [existing] = await this.db
|
||||
.select()
|
||||
.from(cities)
|
||||
.where(and(eq(cities.name, data.name), eq(cities.countryCode, data.countryCode)))
|
||||
.limit(1);
|
||||
return existing;
|
||||
}
|
||||
|
||||
return city;
|
||||
}
|
||||
|
||||
async upsertCityVisit(userId: string, cityId: string, visitDate: Date) {
|
||||
const existing = await this.db
|
||||
.select()
|
||||
.from(cityVisits)
|
||||
.where(and(eq(cityVisits.userId, userId), eq(cityVisits.cityId, cityId)))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
const visit = existing[0];
|
||||
await this.db
|
||||
.update(cityVisits)
|
||||
.set({
|
||||
lastVisitAt: visitDate > visit.lastVisitAt ? visitDate : visit.lastVisitAt,
|
||||
firstVisitAt: visitDate < visit.firstVisitAt ? visitDate : visit.firstVisitAt,
|
||||
visitCount: sql`${cityVisits.visitCount} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(cityVisits.id, visit.id));
|
||||
} else {
|
||||
await this.db.insert(cityVisits).values({
|
||||
userId,
|
||||
cityId,
|
||||
firstVisitAt: visitDate,
|
||||
lastVisitAt: visitDate,
|
||||
visitCount: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getUserCities(userId: string) {
|
||||
const results = await this.db
|
||||
.select({
|
||||
visitId: cityVisits.id,
|
||||
firstVisitAt: cityVisits.firstVisitAt,
|
||||
lastVisitAt: cityVisits.lastVisitAt,
|
||||
totalDurationMs: cityVisits.totalDurationMs,
|
||||
visitCount: cityVisits.visitCount,
|
||||
city: {
|
||||
id: cities.id,
|
||||
name: cities.name,
|
||||
country: cities.country,
|
||||
countryCode: cities.countryCode,
|
||||
latitude: cities.latitude,
|
||||
longitude: cities.longitude,
|
||||
},
|
||||
})
|
||||
.from(cityVisits)
|
||||
.innerJoin(cities, eq(cityVisits.cityId, cities.id))
|
||||
.where(eq(cityVisits.userId, userId))
|
||||
.orderBy(cityVisits.lastVisitAt);
|
||||
|
||||
return results.map((r) => ({
|
||||
id: r.visitId,
|
||||
city: r.city,
|
||||
firstVisitAt: r.firstVisitAt.toISOString(),
|
||||
lastVisitAt: r.lastVisitAt.toISOString(),
|
||||
totalDurationMs: r.totalDurationMs,
|
||||
visitCount: r.visitCount,
|
||||
}));
|
||||
}
|
||||
|
||||
async getCityDetail(userId: string, cityId: string) {
|
||||
const [city] = await this.db.select().from(cities).where(eq(cities.id, cityId)).limit(1);
|
||||
|
||||
if (!city) {
|
||||
throw new NotFoundException('City not found');
|
||||
}
|
||||
|
||||
const [visit] = await this.db
|
||||
.select()
|
||||
.from(cityVisits)
|
||||
.where(and(eq(cityVisits.userId, userId), eq(cityVisits.cityId, cityId)))
|
||||
.limit(1);
|
||||
|
||||
return {
|
||||
city,
|
||||
visit: visit
|
||||
? {
|
||||
firstVisitAt: visit.firstVisitAt.toISOString(),
|
||||
lastVisitAt: visit.lastVisitAt.toISOString(),
|
||||
totalDurationMs: visit.totalDurationMs,
|
||||
visitCount: visit.visitCount,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async getCityById(id: string) {
|
||||
const [city] = await this.db.select().from(cities).where(eq(cities.id, id)).limit(1);
|
||||
return city || null;
|
||||
}
|
||||
}
|
||||
36
apps/traces/apps/backend/src/db/connection.ts
Normal file
36
apps/traces/apps/backend/src/db/connection.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import * as schema from './schema';
|
||||
|
||||
const postgres = require('postgres');
|
||||
|
||||
let connection: ReturnType<typeof postgres> | null = null;
|
||||
let db: ReturnType<typeof drizzle> | null = null;
|
||||
|
||||
export function getConnection(databaseUrl: string) {
|
||||
if (!connection) {
|
||||
connection = postgres(databaseUrl, {
|
||||
max: 10,
|
||||
idle_timeout: 20,
|
||||
connect_timeout: 10,
|
||||
});
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
export function getDb(databaseUrl: string) {
|
||||
if (!db) {
|
||||
const conn = getConnection(databaseUrl);
|
||||
db = drizzle(conn, { schema });
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function closeConnection() {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
connection = null;
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
||||
export type Database = ReturnType<typeof getDb>;
|
||||
30
apps/traces/apps/backend/src/db/database.module.ts
Normal file
30
apps/traces/apps/backend/src/db/database.module.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { Module, Global } from '@nestjs/common';
|
||||
import type { OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { getDb, closeConnection } from './connection';
|
||||
import type { Database } from './connection';
|
||||
|
||||
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: DATABASE_CONNECTION,
|
||||
useFactory: (configService: ConfigService): Database => {
|
||||
const databaseUrl = configService.get<string>('DATABASE_URL');
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is not set');
|
||||
}
|
||||
return getDb(databaseUrl);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: [DATABASE_CONNECTION],
|
||||
})
|
||||
export class DatabaseModule implements OnModuleDestroy {
|
||||
async onModuleDestroy() {
|
||||
await closeConnection();
|
||||
}
|
||||
}
|
||||
231
apps/traces/apps/backend/src/db/schema.ts
Normal file
231
apps/traces/apps/backend/src/db/schema.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
doublePrecision,
|
||||
timestamp,
|
||||
integer,
|
||||
pgEnum,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
|
||||
// ============================================
|
||||
// Enums
|
||||
// ============================================
|
||||
|
||||
export const locationSourceEnum = pgEnum('location_source', [
|
||||
'foreground',
|
||||
'background',
|
||||
'manual',
|
||||
'photo-import',
|
||||
]);
|
||||
|
||||
export const deviceMotionEnum = pgEnum('device_motion', [
|
||||
'stationary',
|
||||
'walking',
|
||||
'driving',
|
||||
'unknown',
|
||||
]);
|
||||
|
||||
export const poiCategoryEnum = pgEnum('poi_category', [
|
||||
'building',
|
||||
'monument',
|
||||
'church',
|
||||
'museum',
|
||||
'palace',
|
||||
'bridge',
|
||||
'park',
|
||||
'square',
|
||||
'sculpture',
|
||||
'fountain',
|
||||
'historic_site',
|
||||
'other',
|
||||
]);
|
||||
|
||||
export const guideStatusEnum = pgEnum('guide_status', ['generating', 'ready', 'error']);
|
||||
|
||||
// ============================================
|
||||
// Tables
|
||||
// ============================================
|
||||
|
||||
export const locations = pgTable(
|
||||
'locations',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: text('user_id').notNull(),
|
||||
latitude: doublePrecision('latitude').notNull(),
|
||||
longitude: doublePrecision('longitude').notNull(),
|
||||
recordedAt: timestamp('recorded_at', { withTimezone: true }).notNull(),
|
||||
accuracy: doublePrecision('accuracy'),
|
||||
altitude: doublePrecision('altitude'),
|
||||
speed: doublePrecision('speed'),
|
||||
source: locationSourceEnum('source').default('foreground'),
|
||||
deviceMotion: deviceMotionEnum('device_motion'),
|
||||
addressFormatted: text('address_formatted'),
|
||||
street: text('street'),
|
||||
houseNumber: text('house_number'),
|
||||
city: text('city'),
|
||||
postalCode: text('postal_code'),
|
||||
country: text('country'),
|
||||
countryCode: text('country_code'),
|
||||
cityId: uuid('city_id').references(() => cities.id),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('locations_user_id_idx').on(table.userId),
|
||||
index('locations_recorded_at_idx').on(table.recordedAt),
|
||||
index('locations_city_id_idx').on(table.cityId),
|
||||
index('locations_user_recorded_idx').on(table.userId, table.recordedAt),
|
||||
]
|
||||
);
|
||||
|
||||
export const cities = pgTable(
|
||||
'cities',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
country: text('country').notNull(),
|
||||
countryCode: text('country_code').notNull(),
|
||||
latitude: doublePrecision('latitude').notNull(),
|
||||
longitude: doublePrecision('longitude').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [uniqueIndex('cities_name_country_code_idx').on(table.name, table.countryCode)]
|
||||
);
|
||||
|
||||
export const cityVisits = pgTable(
|
||||
'city_visits',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: text('user_id').notNull(),
|
||||
cityId: uuid('city_id')
|
||||
.notNull()
|
||||
.references(() => cities.id, { onDelete: 'cascade' }),
|
||||
firstVisitAt: timestamp('first_visit_at', { withTimezone: true }).notNull(),
|
||||
lastVisitAt: timestamp('last_visit_at', { withTimezone: true }).notNull(),
|
||||
totalDurationMs: integer('total_duration_ms').default(0).notNull(),
|
||||
visitCount: integer('visit_count').default(1).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('city_visits_user_city_idx').on(table.userId, table.cityId),
|
||||
index('city_visits_user_id_idx').on(table.userId),
|
||||
]
|
||||
);
|
||||
|
||||
export const places = pgTable(
|
||||
'places',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: text('user_id').notNull(),
|
||||
name: text('name').notNull(),
|
||||
latitude: doublePrecision('latitude').notNull(),
|
||||
longitude: doublePrecision('longitude').notNull(),
|
||||
radiusMeters: integer('radius_meters').default(100).notNull(),
|
||||
addressFormatted: text('address_formatted'),
|
||||
cityId: uuid('city_id').references(() => cities.id),
|
||||
visitCount: integer('visit_count').default(0).notNull(),
|
||||
totalDurationMs: integer('total_duration_ms').default(0).notNull(),
|
||||
firstVisitAt: timestamp('first_visit_at', { withTimezone: true }),
|
||||
lastVisitAt: timestamp('last_visit_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('places_user_id_idx').on(table.userId),
|
||||
index('places_city_id_idx').on(table.cityId),
|
||||
]
|
||||
);
|
||||
|
||||
export const pois = pgTable(
|
||||
'pois',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
latitude: doublePrecision('latitude').notNull(),
|
||||
longitude: doublePrecision('longitude').notNull(),
|
||||
category: poiCategoryEnum('category').default('other').notNull(),
|
||||
cityId: uuid('city_id')
|
||||
.notNull()
|
||||
.references(() => cities.id),
|
||||
imageUrl: text('image_url'),
|
||||
sourceUrls: text('source_urls').array(),
|
||||
aiSummary: text('ai_summary'),
|
||||
aiSummaryLanguage: text('ai_summary_language'),
|
||||
aiSummaryGeneratedAt: timestamp('ai_summary_generated_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('pois_city_id_idx').on(table.cityId),
|
||||
index('pois_lat_lng_idx').on(table.latitude, table.longitude),
|
||||
]
|
||||
);
|
||||
|
||||
export const guides = pgTable(
|
||||
'guides',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: text('user_id').notNull(),
|
||||
cityId: uuid('city_id')
|
||||
.notNull()
|
||||
.references(() => cities.id),
|
||||
title: text('title').notNull(),
|
||||
description: text('description'),
|
||||
status: guideStatusEnum('status').default('generating').notNull(),
|
||||
routePolyline: text('route_polyline'),
|
||||
estimatedDurationMin: integer('estimated_duration_min'),
|
||||
distanceMeters: integer('distance_meters'),
|
||||
language: text('language').default('de').notNull(),
|
||||
creditsCost: integer('credits_cost'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('guides_user_id_idx').on(table.userId),
|
||||
index('guides_city_id_idx').on(table.cityId),
|
||||
]
|
||||
);
|
||||
|
||||
export const guidePois = pgTable(
|
||||
'guide_pois',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
guideId: uuid('guide_id')
|
||||
.notNull()
|
||||
.references(() => guides.id, { onDelete: 'cascade' }),
|
||||
poiId: uuid('poi_id')
|
||||
.notNull()
|
||||
.references(() => pois.id),
|
||||
sortOrder: integer('sort_order').notNull(),
|
||||
aiNarrative: text('ai_narrative'),
|
||||
narrativeLanguage: text('narrative_language').default('de'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('guide_pois_guide_id_idx').on(table.guideId),
|
||||
index('guide_pois_poi_id_idx').on(table.poiId),
|
||||
]
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Type Exports
|
||||
// ============================================
|
||||
|
||||
export type Location = typeof locations.$inferSelect;
|
||||
export type NewLocation = typeof locations.$inferInsert;
|
||||
export type City = typeof cities.$inferSelect;
|
||||
export type NewCity = typeof cities.$inferInsert;
|
||||
export type CityVisit = typeof cityVisits.$inferSelect;
|
||||
export type NewCityVisit = typeof cityVisits.$inferInsert;
|
||||
export type Place = typeof places.$inferSelect;
|
||||
export type NewPlace = typeof places.$inferInsert;
|
||||
export type Poi = typeof pois.$inferSelect;
|
||||
export type NewPoi = typeof pois.$inferInsert;
|
||||
export type Guide = typeof guides.$inferSelect;
|
||||
export type NewGuide = typeof guides.$inferInsert;
|
||||
export type GuidePoi = typeof guidePois.$inferSelect;
|
||||
export type NewGuidePoi = typeof guidePois.$inferInsert;
|
||||
31
apps/traces/apps/backend/src/guide/guide.controller.ts
Normal file
31
apps/traces/apps/backend/src/guide/guide.controller.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Controller, Get, Post, Delete, Param, Body, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser } from '@manacore/shared-nestjs-auth';
|
||||
import type { CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { GuideService } from './guide.service';
|
||||
import type { GenerateGuideRequest } from '@traces/types';
|
||||
|
||||
@Controller('guides')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class GuideController {
|
||||
constructor(private readonly guideService: GuideService) {}
|
||||
|
||||
@Post('generate')
|
||||
async generateGuide(@CurrentUser() user: CurrentUserData, @Body() body: GenerateGuideRequest) {
|
||||
return this.guideService.generateGuide(user.userId, body);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async getGuides(@CurrentUser() user: CurrentUserData) {
|
||||
return this.guideService.getUserGuides(user.userId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getGuideDetail(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
return this.guideService.getGuideDetail(user.userId, id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deleteGuide(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
return this.guideService.deleteGuide(user.userId, id);
|
||||
}
|
||||
}
|
||||
13
apps/traces/apps/backend/src/guide/guide.module.ts
Normal file
13
apps/traces/apps/backend/src/guide/guide.module.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { GuideController } from './guide.controller';
|
||||
import { GuideService } from './guide.service';
|
||||
import { CityModule } from '../city/city.module';
|
||||
import { PoiModule } from '../poi/poi.module';
|
||||
|
||||
@Module({
|
||||
imports: [CityModule, PoiModule],
|
||||
controllers: [GuideController],
|
||||
providers: [GuideService],
|
||||
exports: [GuideService],
|
||||
})
|
||||
export class GuideModule {}
|
||||
402
apps/traces/apps/backend/src/guide/guide.service.ts
Normal file
402
apps/traces/apps/backend/src/guide/guide.service.ts
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
import { Injectable, Inject, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { CreditClientService } from '@manacore/nestjs-integration';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import type { Database } from '../db/connection';
|
||||
import { guides, guidePois, pois, cities } from '../db/schema';
|
||||
import { CityService } from '../city/city.service';
|
||||
import { PoiService } from '../poi/poi.service';
|
||||
import type { GenerateGuideRequest } from '@traces/types';
|
||||
|
||||
@Injectable()
|
||||
export class GuideService {
|
||||
private readonly logger = new Logger(GuideService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private readonly db: Database,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly cityService: CityService,
|
||||
private readonly poiService: PoiService,
|
||||
private readonly creditClient: CreditClientService
|
||||
) {}
|
||||
|
||||
async generateGuide(userId: string, request: GenerateGuideRequest) {
|
||||
const city = await this.cityService.getCityById(request.cityId);
|
||||
if (!city) throw new NotFoundException('City not found');
|
||||
|
||||
const language = request.language || 'de';
|
||||
const maxPois = request.maxPois || 8;
|
||||
|
||||
// Calculate credit cost: 5 base + 2 per POI
|
||||
const estimatedCost = 5 + 2 * maxPois;
|
||||
|
||||
// Consume credits before starting generation
|
||||
await this.creditClient.consumeCredits(
|
||||
userId,
|
||||
'guide_generation',
|
||||
estimatedCost,
|
||||
`City guide: ${city.name}`
|
||||
);
|
||||
|
||||
// Create guide record in 'generating' state
|
||||
const [guide] = await this.db
|
||||
.insert(guides)
|
||||
.values({
|
||||
userId,
|
||||
cityId: city.id,
|
||||
title: `Stadtführung: ${city.name}`,
|
||||
status: 'generating',
|
||||
language,
|
||||
creditsCost: estimatedCost,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Start async generation pipeline
|
||||
this.runGenerationPipeline(guide.id, city, language, maxPois, request).catch((err) => {
|
||||
this.logger.error(`Guide generation failed for ${guide.id}:`, err);
|
||||
this.db
|
||||
.update(guides)
|
||||
.set({ status: 'error', updatedAt: new Date() })
|
||||
.where(eq(guides.id, guide.id))
|
||||
.catch(() => {});
|
||||
});
|
||||
|
||||
return {
|
||||
id: guide.id,
|
||||
status: 'generating',
|
||||
creditsCost: estimatedCost,
|
||||
};
|
||||
}
|
||||
|
||||
private async runGenerationPipeline(
|
||||
guideId: string,
|
||||
city: typeof cities.$inferSelect,
|
||||
language: string,
|
||||
maxPois: number,
|
||||
request: GenerateGuideRequest
|
||||
) {
|
||||
const manaSearchUrl = this.configService.get<string>('MANA_SEARCH_URL');
|
||||
const manaLlmUrl = this.configService.get<string>('MANA_LLM_URL');
|
||||
|
||||
// Step 1: POI Discovery via mana-search
|
||||
this.logger.log(`[${guideId}] Step 1: POI Discovery for ${city.name}`);
|
||||
const searchQueries = [
|
||||
`${city.name} Sehenswürdigkeiten Architektur Geschichte`,
|
||||
`${city.name} historic buildings monuments Wikipedia`,
|
||||
`${city.name} must see landmarks tourist attractions`,
|
||||
];
|
||||
|
||||
const discoveredPois: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
category: string;
|
||||
sourceUrls: string[];
|
||||
}> = [];
|
||||
|
||||
if (manaSearchUrl) {
|
||||
for (const query of searchQueries) {
|
||||
try {
|
||||
const searchResponse = await fetch(`${manaSearchUrl}/api/v1/search`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
options: { categories: ['general'], limit: 10 },
|
||||
}),
|
||||
});
|
||||
|
||||
if (searchResponse.ok) {
|
||||
const { results } = await searchResponse.json();
|
||||
// Extract POI names from search results (simplified - real implementation
|
||||
// would parse structured data)
|
||||
for (const result of results || []) {
|
||||
// Results will be used for content enrichment
|
||||
this.logger.debug(`Search result: ${result.title}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Search failed for query "${query}":`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Create POI records (for now, use any existing POIs near the city)
|
||||
this.logger.log(`[${guideId}] Step 2: Finding POIs near ${city.name}`);
|
||||
const nearbyPois = await this.poiService.findNearby({
|
||||
lat: city.latitude,
|
||||
lng: city.longitude,
|
||||
radiusMeters: request.radiusMeters || 2000,
|
||||
cityId: city.id,
|
||||
limit: maxPois,
|
||||
});
|
||||
|
||||
// Step 3: Enrich POIs with AI summaries
|
||||
this.logger.log(`[${guideId}] Step 3: Content enrichment`);
|
||||
if (manaLlmUrl) {
|
||||
for (const poi of nearbyPois) {
|
||||
if (!poi.aiSummary) {
|
||||
try {
|
||||
const prompt =
|
||||
language === 'de'
|
||||
? `Schreibe eine 200-Wort-Zusammenfassung über "${poi.name}" in ${city.name}. Fokus auf Baugeschichte, Architekturstil und interessante Anekdoten.`
|
||||
: `Write a 200-word summary about "${poi.name}" in ${city.name}. Focus on architectural history, style, and interesting anecdotes.`;
|
||||
|
||||
const llmResponse = await fetch(`${manaLlmUrl}/api/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
model: 'default',
|
||||
max_tokens: 500,
|
||||
}),
|
||||
});
|
||||
|
||||
if (llmResponse.ok) {
|
||||
const data = await llmResponse.json();
|
||||
const summary = data.choices?.[0]?.message?.content;
|
||||
if (summary) {
|
||||
await this.poiService.updateAiSummary(poi.id, summary, language);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`AI summary failed for POI ${poi.name}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Route generation (nearest-neighbor from city center)
|
||||
this.logger.log(`[${guideId}] Step 4: Route generation`);
|
||||
const sortedPois = this.sortPoisByNearestNeighbor(nearbyPois, city.latitude, city.longitude);
|
||||
|
||||
let totalDistance = 0;
|
||||
for (let i = 1; i < sortedPois.length; i++) {
|
||||
totalDistance += this.haversineDistance(
|
||||
sortedPois[i - 1].latitude,
|
||||
sortedPois[i - 1].longitude,
|
||||
sortedPois[i].latitude,
|
||||
sortedPois[i].longitude
|
||||
);
|
||||
}
|
||||
|
||||
const estimatedDurationMin = Math.ceil(totalDistance / (4000 / 60)); // 4 km/h walking
|
||||
const routePolyline = JSON.stringify(sortedPois.map((p) => [p.latitude, p.longitude]));
|
||||
|
||||
// Step 5: Generate narratives
|
||||
this.logger.log(`[${guideId}] Step 5: Narrative assembly`);
|
||||
const guidePoiRecords: Array<{
|
||||
poiId: string;
|
||||
sortOrder: number;
|
||||
aiNarrative: string | null;
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < sortedPois.length; i++) {
|
||||
const poi = sortedPois[i];
|
||||
let narrative: string | null = null;
|
||||
|
||||
if (manaLlmUrl) {
|
||||
try {
|
||||
const prevStation = i > 0 ? sortedPois[i - 1].name : 'Startpunkt';
|
||||
const distanceToPrev =
|
||||
i > 0
|
||||
? Math.round(
|
||||
this.haversineDistance(
|
||||
sortedPois[i - 1].latitude,
|
||||
sortedPois[i - 1].longitude,
|
||||
poi.latitude,
|
||||
poi.longitude
|
||||
)
|
||||
)
|
||||
: 0;
|
||||
|
||||
const prompt =
|
||||
language === 'de'
|
||||
? `Du bist ein erfahrener Stadtführer in ${city.name}. Schreibe einen kurzen, lebendigen Stadtführer-Text (80-120 Wörter) über "${poi.name}" als Station ${i + 1} einer Stadtführung. ${i > 0 ? `Die vorherige Station war "${prevStation}" (${distanceToPrev}m entfernt).` : 'Dies ist die erste Station.'} Erwähne architektonische Details und eine interessante Anekdote.`
|
||||
: `You are an experienced city guide in ${city.name}. Write a short, vivid guide text (80-120 words) about "${poi.name}" as station ${i + 1} of a walking tour. ${i > 0 ? `The previous station was "${prevStation}" (${distanceToPrev}m away).` : 'This is the first station.'} Mention architectural details and an interesting anecdote.`;
|
||||
|
||||
const llmResponse = await fetch(`${manaLlmUrl}/api/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
model: 'default',
|
||||
max_tokens: 300,
|
||||
}),
|
||||
});
|
||||
|
||||
if (llmResponse.ok) {
|
||||
const data = await llmResponse.json();
|
||||
narrative = data.choices?.[0]?.message?.content || null;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Narrative generation failed for POI ${poi.name}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
guidePoiRecords.push({
|
||||
poiId: poi.id,
|
||||
sortOrder: i,
|
||||
aiNarrative: narrative,
|
||||
});
|
||||
}
|
||||
|
||||
// Save guide POIs
|
||||
if (guidePoiRecords.length > 0) {
|
||||
await this.db.insert(guidePois).values(
|
||||
guidePoiRecords.map((r) => ({
|
||||
guideId,
|
||||
poiId: r.poiId,
|
||||
sortOrder: r.sortOrder,
|
||||
aiNarrative: r.aiNarrative,
|
||||
narrativeLanguage: language,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Update guide to ready
|
||||
const title = language === 'de' ? `Stadtführung: ${city.name}` : `City Guide: ${city.name}`;
|
||||
|
||||
await this.db
|
||||
.update(guides)
|
||||
.set({
|
||||
status: 'ready',
|
||||
title,
|
||||
description:
|
||||
language === 'de'
|
||||
? `${sortedPois.length} Stationen, ca. ${estimatedDurationMin} Min.`
|
||||
: `${sortedPois.length} stations, approx. ${estimatedDurationMin} min.`,
|
||||
routePolyline,
|
||||
estimatedDurationMin,
|
||||
distanceMeters: Math.round(totalDistance),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(guides.id, guideId));
|
||||
|
||||
this.logger.log(
|
||||
`[${guideId}] Guide generation complete: ${sortedPois.length} POIs, ${Math.round(totalDistance)}m`
|
||||
);
|
||||
}
|
||||
|
||||
private sortPoisByNearestNeighbor(
|
||||
poisList: Array<{ id: string; latitude: number; longitude: number; [key: string]: any }>,
|
||||
startLat: number,
|
||||
startLng: number
|
||||
) {
|
||||
const remaining = [...poisList];
|
||||
const sorted: typeof remaining = [];
|
||||
let currentLat = startLat;
|
||||
let currentLng = startLng;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
let nearestIdx = 0;
|
||||
let nearestDist = Infinity;
|
||||
|
||||
for (let i = 0; i < remaining.length; i++) {
|
||||
const dist = this.haversineDistance(
|
||||
currentLat,
|
||||
currentLng,
|
||||
remaining[i].latitude,
|
||||
remaining[i].longitude
|
||||
);
|
||||
if (dist < nearestDist) {
|
||||
nearestDist = dist;
|
||||
nearestIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
const nearest = remaining.splice(nearestIdx, 1)[0];
|
||||
sorted.push(nearest);
|
||||
currentLat = nearest.latitude;
|
||||
currentLng = nearest.longitude;
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}
|
||||
|
||||
private haversineDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const R = 6371000;
|
||||
const φ1 = (lat1 * Math.PI) / 180;
|
||||
const φ2 = (lat2 * Math.PI) / 180;
|
||||
const Δφ = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const Δλ = ((lon2 - lon1) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
|
||||
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
async getUserGuides(userId: string) {
|
||||
const results = await this.db
|
||||
.select({
|
||||
guide: guides,
|
||||
cityName: cities.name,
|
||||
cityCountry: cities.country,
|
||||
})
|
||||
.from(guides)
|
||||
.innerJoin(cities, eq(guides.cityId, cities.id))
|
||||
.where(eq(guides.userId, userId))
|
||||
.orderBy(desc(guides.createdAt));
|
||||
|
||||
return results.map((r) => ({
|
||||
id: r.guide.id,
|
||||
title: r.guide.title,
|
||||
description: r.guide.description,
|
||||
status: r.guide.status,
|
||||
cityName: r.cityName,
|
||||
cityCountry: r.cityCountry,
|
||||
estimatedDurationMin: r.guide.estimatedDurationMin,
|
||||
distanceMeters: r.guide.distanceMeters,
|
||||
language: r.guide.language,
|
||||
creditsCost: r.guide.creditsCost,
|
||||
createdAt: r.guide.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async getGuideDetail(userId: string, guideId: string) {
|
||||
const [guide] = await this.db.select().from(guides).where(eq(guides.id, guideId)).limit(1);
|
||||
|
||||
if (!guide) throw new NotFoundException('Guide not found');
|
||||
if (guide.userId !== userId) throw new ForbiddenException();
|
||||
|
||||
const city = await this.cityService.getCityById(guide.cityId);
|
||||
|
||||
const guidePoiResults = await this.db
|
||||
.select({
|
||||
guidePoi: guidePois,
|
||||
poi: pois,
|
||||
})
|
||||
.from(guidePois)
|
||||
.innerJoin(pois, eq(guidePois.poiId, pois.id))
|
||||
.where(eq(guidePois.guideId, guideId))
|
||||
.orderBy(guidePois.sortOrder);
|
||||
|
||||
return {
|
||||
...guide,
|
||||
createdAt: guide.createdAt.toISOString(),
|
||||
updatedAt: guide.updatedAt.toISOString(),
|
||||
city,
|
||||
pois: guidePoiResults.map((r) => ({
|
||||
id: r.guidePoi.id,
|
||||
sortOrder: r.guidePoi.sortOrder,
|
||||
aiNarrative: r.guidePoi.aiNarrative,
|
||||
narrativeLanguage: r.guidePoi.narrativeLanguage,
|
||||
poi: r.poi,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteGuide(userId: string, guideId: string) {
|
||||
const [guide] = await this.db.select().from(guides).where(eq(guides.id, guideId)).limit(1);
|
||||
|
||||
if (!guide) throw new NotFoundException('Guide not found');
|
||||
if (guide.userId !== userId) throw new ForbiddenException();
|
||||
|
||||
// guide_pois cascade-deleted automatically
|
||||
await this.db.delete(guides).where(eq(guides.id, guideId));
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
21
apps/traces/apps/backend/src/location/location.controller.ts
Normal file
21
apps/traces/apps/backend/src/location/location.controller.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { Controller, Post, Get, Body, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser } from '@manacore/shared-nestjs-auth';
|
||||
import type { CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { LocationService } from './location.service';
|
||||
import type { LocationSyncRequest, LocationQueryParams } from '@traces/types';
|
||||
|
||||
@Controller('locations')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class LocationController {
|
||||
constructor(private readonly locationService: LocationService) {}
|
||||
|
||||
@Post('sync')
|
||||
async syncLocations(@CurrentUser() user: CurrentUserData, @Body() body: LocationSyncRequest) {
|
||||
return this.locationService.syncLocations(user.userId, body.locations);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async getLocations(@CurrentUser() user: CurrentUserData, @Query() query: LocationQueryParams) {
|
||||
return this.locationService.getLocations(user.userId, query);
|
||||
}
|
||||
}
|
||||
12
apps/traces/apps/backend/src/location/location.module.ts
Normal file
12
apps/traces/apps/backend/src/location/location.module.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { LocationController } from './location.controller';
|
||||
import { LocationService } from './location.service';
|
||||
import { CityModule } from '../city/city.module';
|
||||
|
||||
@Module({
|
||||
imports: [CityModule],
|
||||
controllers: [LocationController],
|
||||
providers: [LocationService],
|
||||
exports: [LocationService],
|
||||
})
|
||||
export class LocationModule {}
|
||||
100
apps/traces/apps/backend/src/location/location.service.ts
Normal file
100
apps/traces/apps/backend/src/location/location.service.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { eq, and, gte, lte, desc } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import type { Database } from '../db/connection';
|
||||
import { locations } from '../db/schema';
|
||||
import { CityService } from '../city/city.service';
|
||||
import type { LocationSyncItem, LocationQueryParams } from '@traces/types';
|
||||
|
||||
@Injectable()
|
||||
export class LocationService {
|
||||
constructor(
|
||||
@Inject(DATABASE_CONNECTION) private readonly db: Database,
|
||||
private readonly cityService: CityService
|
||||
) {}
|
||||
|
||||
async syncLocations(userId: string, items: LocationSyncItem[]) {
|
||||
let synced = 0;
|
||||
let duplicates = 0;
|
||||
|
||||
for (const item of items) {
|
||||
// Check for duplicate by original ID
|
||||
const existing = await this.db
|
||||
.select({ id: locations.id })
|
||||
.from(locations)
|
||||
.where(eq(locations.id, item.id))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
duplicates++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Auto-detect city from address
|
||||
let cityId: string | undefined;
|
||||
if (item.city && item.countryCode) {
|
||||
const city = await this.cityService.findOrCreateCity({
|
||||
name: item.city,
|
||||
country: item.country || item.city,
|
||||
countryCode: item.countryCode,
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
});
|
||||
cityId = city.id;
|
||||
|
||||
// Upsert city visit
|
||||
await this.cityService.upsertCityVisit(userId, city.id, new Date(item.recordedAt));
|
||||
}
|
||||
|
||||
await this.db.insert(locations).values({
|
||||
id: item.id,
|
||||
userId,
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
recordedAt: new Date(item.recordedAt),
|
||||
accuracy: item.accuracy,
|
||||
altitude: item.altitude,
|
||||
speed: item.speed,
|
||||
source: item.source,
|
||||
deviceMotion: item.deviceMotion,
|
||||
addressFormatted: item.addressFormatted,
|
||||
street: item.street,
|
||||
houseNumber: item.houseNumber,
|
||||
city: item.city,
|
||||
postalCode: item.postalCode,
|
||||
country: item.country,
|
||||
countryCode: item.countryCode,
|
||||
cityId,
|
||||
});
|
||||
|
||||
synced++;
|
||||
}
|
||||
|
||||
return { synced, duplicates };
|
||||
}
|
||||
|
||||
async getLocations(userId: string, params: LocationQueryParams) {
|
||||
const conditions = [eq(locations.userId, userId)];
|
||||
|
||||
if (params.cityId) {
|
||||
conditions.push(eq(locations.cityId, params.cityId));
|
||||
}
|
||||
if (params.from) {
|
||||
conditions.push(gte(locations.recordedAt, new Date(params.from)));
|
||||
}
|
||||
if (params.to) {
|
||||
conditions.push(lte(locations.recordedAt, new Date(params.to)));
|
||||
}
|
||||
|
||||
const limit = params.limit ? Math.min(params.limit, 1000) : 100;
|
||||
const offset = params.offset || 0;
|
||||
|
||||
return this.db
|
||||
.select()
|
||||
.from(locations)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(locations.recordedAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
}
|
||||
8
apps/traces/apps/backend/src/main.ts
Normal file
8
apps/traces/apps/backend/src/main.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { bootstrapApp } from '@manacore/shared-nestjs-setup';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
bootstrapApp(AppModule, {
|
||||
defaultPort: 3026,
|
||||
serviceName: 'Traces',
|
||||
additionalCorsOrigins: ['http://localhost:5173', 'http://localhost:8081'],
|
||||
});
|
||||
35
apps/traces/apps/backend/src/place/place.controller.ts
Normal file
35
apps/traces/apps/backend/src/place/place.controller.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser } from '@manacore/shared-nestjs-auth';
|
||||
import type { CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { PlaceService } from './place.service';
|
||||
import type { CreatePlaceRequest, UpdatePlaceRequest } from '@traces/types';
|
||||
|
||||
@Controller('places')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PlaceController {
|
||||
constructor(private readonly placeService: PlaceService) {}
|
||||
|
||||
@Get()
|
||||
async getPlaces(@CurrentUser() user: CurrentUserData) {
|
||||
return this.placeService.getUserPlaces(user.userId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async createPlace(@CurrentUser() user: CurrentUserData, @Body() body: CreatePlaceRequest) {
|
||||
return this.placeService.createPlace(user.userId, body);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async updatePlace(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') id: string,
|
||||
@Body() body: UpdatePlaceRequest
|
||||
) {
|
||||
return this.placeService.updatePlace(user.userId, id, body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async deletePlace(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
|
||||
return this.placeService.deletePlace(user.userId, id);
|
||||
}
|
||||
}
|
||||
12
apps/traces/apps/backend/src/place/place.module.ts
Normal file
12
apps/traces/apps/backend/src/place/place.module.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { PlaceController } from './place.controller';
|
||||
import { PlaceService } from './place.service';
|
||||
import { CityModule } from '../city/city.module';
|
||||
|
||||
@Module({
|
||||
imports: [CityModule],
|
||||
controllers: [PlaceController],
|
||||
providers: [PlaceService],
|
||||
exports: [PlaceService],
|
||||
})
|
||||
export class PlaceModule {}
|
||||
73
apps/traces/apps/backend/src/place/place.service.ts
Normal file
73
apps/traces/apps/backend/src/place/place.service.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { Injectable, Inject, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import type { Database } from '../db/connection';
|
||||
import { places, cities } from '../db/schema';
|
||||
import type { CreatePlaceRequest, UpdatePlaceRequest } from '@traces/types';
|
||||
|
||||
@Injectable()
|
||||
export class PlaceService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {}
|
||||
|
||||
async getUserPlaces(userId: string) {
|
||||
const results = await this.db
|
||||
.select({
|
||||
place: places,
|
||||
cityName: cities.name,
|
||||
})
|
||||
.from(places)
|
||||
.leftJoin(cities, eq(places.cityId, cities.id))
|
||||
.where(eq(places.userId, userId));
|
||||
|
||||
return results.map((r) => ({
|
||||
...r.place,
|
||||
cityName: r.cityName,
|
||||
firstVisitAt: r.place.firstVisitAt?.toISOString(),
|
||||
lastVisitAt: r.place.lastVisitAt?.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async createPlace(userId: string, data: CreatePlaceRequest) {
|
||||
const [place] = await this.db
|
||||
.insert(places)
|
||||
.values({
|
||||
userId,
|
||||
name: data.name,
|
||||
latitude: data.latitude,
|
||||
longitude: data.longitude,
|
||||
radiusMeters: data.radiusMeters || 100,
|
||||
addressFormatted: data.addressFormatted,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return place;
|
||||
}
|
||||
|
||||
async updatePlace(userId: string, id: string, data: UpdatePlaceRequest) {
|
||||
const [existing] = await this.db.select().from(places).where(eq(places.id, id)).limit(1);
|
||||
|
||||
if (!existing) throw new NotFoundException('Place not found');
|
||||
if (existing.userId !== userId) throw new ForbiddenException();
|
||||
|
||||
const [updated] = await this.db
|
||||
.update(places)
|
||||
.set({
|
||||
...data,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(places.id, id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deletePlace(userId: string, id: string) {
|
||||
const [existing] = await this.db.select().from(places).where(eq(places.id, id)).limit(1);
|
||||
|
||||
if (!existing) throw new NotFoundException('Place not found');
|
||||
if (existing.userId !== userId) throw new ForbiddenException();
|
||||
|
||||
await this.db.delete(places).where(eq(places.id, id));
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
20
apps/traces/apps/backend/src/poi/poi.controller.ts
Normal file
20
apps/traces/apps/backend/src/poi/poi.controller.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '@manacore/shared-nestjs-auth';
|
||||
import { PoiService } from './poi.service';
|
||||
import type { NearbyPoiQueryParams } from '@traces/types';
|
||||
|
||||
@Controller('pois')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PoiController {
|
||||
constructor(private readonly poiService: PoiService) {}
|
||||
|
||||
@Get()
|
||||
async getNearbyPois(@Query() query: NearbyPoiQueryParams) {
|
||||
return this.poiService.findNearby(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getPoiDetail(@Param('id') id: string) {
|
||||
return this.poiService.getById(id);
|
||||
}
|
||||
}
|
||||
10
apps/traces/apps/backend/src/poi/poi.module.ts
Normal file
10
apps/traces/apps/backend/src/poi/poi.module.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { PoiController } from './poi.controller';
|
||||
import { PoiService } from './poi.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PoiController],
|
||||
providers: [PoiService],
|
||||
exports: [PoiService],
|
||||
})
|
||||
export class PoiModule {}
|
||||
105
apps/traces/apps/backend/src/poi/poi.service.ts
Normal file
105
apps/traces/apps/backend/src/poi/poi.service.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { Injectable, Inject, NotFoundException } from '@nestjs/common';
|
||||
import { eq, and, sql } from 'drizzle-orm';
|
||||
import { DATABASE_CONNECTION } from '../db/database.module';
|
||||
import type { Database } from '../db/connection';
|
||||
import { pois } from '../db/schema';
|
||||
import type { NearbyPoiQueryParams } from '@traces/types';
|
||||
|
||||
@Injectable()
|
||||
export class PoiService {
|
||||
constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {}
|
||||
|
||||
async findNearby(params: NearbyPoiQueryParams) {
|
||||
const { lat, lng, radiusMeters = 2000, cityId, category, limit = 20 } = params;
|
||||
|
||||
// Haversine distance calculation in SQL (returns meters)
|
||||
const distanceExpr = sql`(
|
||||
6371000 * acos(
|
||||
cos(radians(${lat})) * cos(radians(${pois.latitude})) *
|
||||
cos(radians(${pois.longitude}) - radians(${lng})) +
|
||||
sin(radians(${lat})) * sin(radians(${pois.latitude}))
|
||||
)
|
||||
)`;
|
||||
|
||||
const conditions = [sql`${distanceExpr} < ${radiusMeters}`];
|
||||
|
||||
if (cityId) {
|
||||
conditions.push(eq(pois.cityId, cityId));
|
||||
}
|
||||
if (category) {
|
||||
conditions.push(eq(pois.category, category));
|
||||
}
|
||||
|
||||
const results = await this.db
|
||||
.select({
|
||||
poi: pois,
|
||||
distance: distanceExpr.as('distance'),
|
||||
})
|
||||
.from(pois)
|
||||
.where(and(...conditions))
|
||||
.orderBy(sql`distance`)
|
||||
.limit(Math.min(limit, 50));
|
||||
|
||||
return results.map((r) => ({
|
||||
...r.poi,
|
||||
distance: Math.round(r.distance as number),
|
||||
}));
|
||||
}
|
||||
|
||||
async getById(id: string) {
|
||||
const [poi] = await this.db.select().from(pois).where(eq(pois.id, id)).limit(1);
|
||||
|
||||
if (!poi) throw new NotFoundException('POI not found');
|
||||
return poi;
|
||||
}
|
||||
|
||||
async findOrCreatePoi(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
category: string;
|
||||
cityId: string;
|
||||
sourceUrls?: string[];
|
||||
}) {
|
||||
// Check for existing POI within ~50m
|
||||
const nearby = await this.findNearby({
|
||||
lat: data.latitude,
|
||||
lng: data.longitude,
|
||||
radiusMeters: 50,
|
||||
cityId: data.cityId,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (nearby.length > 0) {
|
||||
return nearby[0];
|
||||
}
|
||||
|
||||
const [poi] = await this.db
|
||||
.insert(pois)
|
||||
.values({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
latitude: data.latitude,
|
||||
longitude: data.longitude,
|
||||
category: data.category as any,
|
||||
cityId: data.cityId,
|
||||
sourceUrls: data.sourceUrls,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return poi;
|
||||
}
|
||||
|
||||
async updateAiSummary(poiId: string, summary: string, language: string) {
|
||||
await this.db
|
||||
.update(pois)
|
||||
.set({
|
||||
aiSummary: summary,
|
||||
aiSummaryLanguage: language,
|
||||
aiSummaryGeneratedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(pois.id, poiId));
|
||||
}
|
||||
}
|
||||
27
apps/traces/apps/backend/tsconfig.json
Normal file
27
apps/traces/apps/backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"rootDir": "./src",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
25
apps/traces/apps/mobile/.gitignore
vendored
Normal file
25
apps/traces/apps/mobile/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
npm-debug.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
# expo router
|
||||
expo-env.d.ts
|
||||
|
||||
# firebase/supabase/vexo
|
||||
.env
|
||||
|
||||
ios
|
||||
android
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Temporary files created by Metro to check the health of the file watcher
|
||||
.metro-health-check*
|
||||
290
apps/traces/apps/mobile/README.md
Normal file
290
apps/traces/apps/mobile/README.md
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
# Standortverlauf App
|
||||
|
||||
Eine Expo React Native App, die den Standortverlauf des Nutzers aufzeichnet und auf einer Karte darstellt. Alle Daten werden lokal auf dem Gerät gespeichert.
|
||||
|
||||

|
||||
|
||||
## Funktionen
|
||||
|
||||
### 🗺️ Echtzeit-Standortverfolgung
|
||||
- Verfolge deinen aktuellen Standort in Echtzeit auf einer interaktiven Karte
|
||||
- Visualisiere deinen Bewegungsverlauf als Linie auf der Karte
|
||||
- Automatische Zentrierung auf deinen aktuellen Standort (optional)
|
||||
- Dark Mode Unterstützung für angenehme Nutzung bei Nacht
|
||||
|
||||
### 📝 Detaillierter Standortverlauf
|
||||
- Chronologische Liste aller aufgezeichneten Standorte
|
||||
- Detaillierte Informationen zu jedem Standort:
|
||||
- Datum und Uhrzeit (mit verbesserter Zeitstempel-Verwaltung)
|
||||
- Genaue Koordinaten (Breiten- und Längengrad)
|
||||
- Genauigkeit der Standortbestimmung
|
||||
- Geschwindigkeit (falls verfügbar)
|
||||
- Adressinformationen mit formatierter Darstellung (optional)
|
||||
- Eindeutige ID für jeden Standort
|
||||
- Erweiterte Metadaten (Quelle, Verbindungstyp, Batteriestand)
|
||||
- Qualitätsindikatoren für Standortgenauigkeit
|
||||
- Intelligente Standortkonsolidierung:
|
||||
- Zusammenfassung von nahe beieinanderliegenden Standorten
|
||||
- Berechnung der Verweildauer an jedem Ort
|
||||
- Anzeige der Anzahl der zusammengefassten Datenpunkte
|
||||
- Umschaltbar zwischen detaillierter und konsolidierter Ansicht
|
||||
- Automatische Migration bestehender Daten
|
||||
|
||||
### 🔍 Umfangreiches Logging und Diagnose
|
||||
- Detaillierte Logs aller App-Aktivitäten
|
||||
- Überwachung der Hintergrund- und Vordergrundaktualisierungen
|
||||
- Fehlerdiagnose und Problembehandlung
|
||||
- Visualisierung von Standortgenauigkeit und Tracking-Intervallen
|
||||
|
||||
### 🔒 Datenschutz und Sicherheit
|
||||
- Alle Daten werden ausschließlich lokal auf deinem Gerät gespeichert
|
||||
- Keine Übertragung von Standortdaten an externe Server
|
||||
- Volle Kontrolle über deine Daten mit der Möglichkeit, den Verlauf jederzeit zu löschen
|
||||
- Option zum Ein-/Ausschalten der Adressspeicherung
|
||||
|
||||
### ⚙️ Anpassbare Tracking-Einstellungen
|
||||
- Starte und stoppe das Tracking nach Bedarf
|
||||
- Mehrere Tracking-Intervalle zur Auswahl:
|
||||
- 5 Minuten (hohe Genauigkeit, höherer Akkuverbrauch)
|
||||
- 1 Stunde (mittlere Genauigkeit, moderater Akkuverbrauch)
|
||||
- 3 Stunden (niedrigere Genauigkeit, geringer Akkuverbrauch)
|
||||
- 6 Stunden (niedrige Genauigkeit, minimaler Akkuverbrauch)
|
||||
- Auswählbare Genauigkeitsstufen:
|
||||
- Niedrigste (1-3 km, geringster Akkuverbrauch)
|
||||
- Niedrig (500m-1km)
|
||||
- Mittel (100-500m, empfohlen)
|
||||
- Hoch (20-100m)
|
||||
- Höchste (0-20m, höchster Akkuverbrauch)
|
||||
- Distanz-basierte Aktualisierung: Automatische Anpassung je nach Intervall
|
||||
|
||||
## Technische Details
|
||||
|
||||
### Verwendete Technologien
|
||||
- **Expo**: Framework für die einfache Entwicklung von React Native Apps
|
||||
- **React Native**: Cross-Platform Framework für native mobile Apps
|
||||
- **Expo Router**: Für die Navigation zwischen den Screens
|
||||
- **Expo Location**: Für den Zugriff auf die Standortdaten des Geräts
|
||||
- **React Native Maps**: Für die Kartenansicht und -interaktion
|
||||
- **AsyncStorage**: Für die lokale Datenspeicherung
|
||||
|
||||
### Architektur
|
||||
|
||||
Die App ist in mehrere Komponenten und Dienste unterteilt:
|
||||
|
||||
#### Hauptkomponenten
|
||||
- **LocationMap**: Zeigt die Karte mit dem aktuellen Standort und dem Bewegungsverlauf
|
||||
- **TrackingControls**: Bietet Steuerelemente zum Starten/Stoppen des Trackings und Löschen des Verlaufs
|
||||
- **LocationHistoryList**: Zeigt den Standortverlauf in einer detaillierten Liste
|
||||
- **ConsolidatedLocationList**: Zeigt den zusammengefassten Standortverlauf
|
||||
- **LogsList**: Visualisiert die App-Logs mit Farbkodierung je nach Schweregrad
|
||||
- **ThemeWrapper**: Sorgt für die konsistente Darstellung im Light- und Dark-Mode
|
||||
|
||||
#### Dienste
|
||||
- **locationService**: Enthält alle Funktionen für das Standort-Tracking und die lokale Speicherung:
|
||||
- `requestLocationPermissions`: Anfordern der Standortberechtigungen
|
||||
- `getCurrentLocation`: Abrufen des aktuellen Standorts mit erweiterten Metadaten
|
||||
- `startLocationTracking`: Starten der kontinuierlichen Standortverfolgung
|
||||
- `saveLocationToHistory`: Speichern eines Standorts im Verlauf mit automatischer Migration
|
||||
- `getLocationHistory`: Abrufen des gesamten Standortverlaufs mit Migrationssupport
|
||||
- `clearLocationHistory`: Löschen des gesamten Standortverlaufs
|
||||
- `getAccuracyLevel`/`saveAccuracyLevel`: Verwalten der Genauigkeitseinstellung
|
||||
- `getDefaultInterval`/`saveDefaultInterval`: Verwalten des Standard-Tracking-Intervalls
|
||||
- **LocationData Interface**: Erweiterte Datenstruktur mit:
|
||||
- Eindeutige IDs für jeden Standort
|
||||
- Strukturierte Zeitstempel (ISO String + Unix Millisekunden)
|
||||
- Erweiterte Adressinformationen mit formatierter Darstellung
|
||||
- Metadaten (Quelle, Batteriestand, Verbindungstyp, Bewegungsstatus)
|
||||
- Qualitätsindikatoren (Genauigkeitslevel, horizontale/vertikale Genauigkeit)
|
||||
- Legacy-Support für bestehende Daten
|
||||
- **logService**: Protokolliert alle App-Aktivitäten und Fehler:
|
||||
- `logInfo`/`logWarning`/`logError`: Protokollieren von Nachrichten mit verschiedenen Schweregraden
|
||||
- `getStoredLogs`: Abrufen aller gespeicherten Logs
|
||||
- `clearLogs`: Löschen aller Protokolle
|
||||
- **locationHelper**: Hilft bei der Analyse und Zusammenfassung von Standortdaten:
|
||||
- `consolidateLocationsByProximity`: Fasst nahe beieinander liegende Standorte zusammen (kompatibel mit neuer Datenstruktur)
|
||||
- `getDistanceBetweenCoordinates`: Berechnet die Entfernung zwischen zwei Koordinaten
|
||||
- `formatDuration`: Formatiert Zeitspannen in benutzerfreundlicher Weise
|
||||
- Unterstützung für sowohl neue als auch Legacy-Adressformate
|
||||
- **backgroundLocationTask**: Verarbeitung im Hintergrund mit erweiterten Metadaten:
|
||||
- Generierung von UUIDs für Standorte
|
||||
- Erfassung von Verbindungstyp und Batteriestand
|
||||
- Kompatibilität mit neuer LocationData-Struktur
|
||||
|
||||
#### Navigation
|
||||
- **Tabs**: Die App verwendet eine Tab-Navigation mit drei Haupttabs:
|
||||
- **Karte**: Zeigt die Kartenansicht mit Tracking-Steuerung
|
||||
- **Verlauf**: Zeigt die chronologische Liste aller aufgezeichneten Standorte mit Umschaltmöglichkeit zur konsolidierten Ansicht
|
||||
- **Logs**: Zeigt detaillierte Protokolle aller App-Aktivitäten
|
||||
|
||||
## Installation und Einrichtung
|
||||
|
||||
### Voraussetzungen
|
||||
- Node.js (v14 oder höher)
|
||||
- npm oder yarn
|
||||
- Expo CLI (`npm install -g expo-cli`)
|
||||
- iOS Simulator oder Android Emulator (optional für lokale Tests)
|
||||
- Physisches Gerät mit Expo Go App (empfohlen für Standortfunktionen)
|
||||
|
||||
### Installation
|
||||
|
||||
1. Repository klonen oder herunterladen
|
||||
```bash
|
||||
git clone https://github.com/username/standortverlauf.git
|
||||
cd standortverlauf
|
||||
```
|
||||
|
||||
2. Abhängigkeiten installieren
|
||||
```bash
|
||||
npm install
|
||||
# oder
|
||||
yarn install
|
||||
```
|
||||
|
||||
3. App starten
|
||||
```bash
|
||||
npx expo start
|
||||
```
|
||||
|
||||
4. QR-Code mit der Expo Go App scannen oder auf iOS/Android Simulator starten
|
||||
|
||||
### Berechtigungen
|
||||
|
||||
Die App benötigt folgende Berechtigungen:
|
||||
- **Standort**: Für die Standortverfolgung (wird beim ersten Start angefragt)
|
||||
|
||||
## Nutzung
|
||||
|
||||
### Standortverfolgung starten
|
||||
1. Öffne die App und navigiere zum "Karte"-Tab
|
||||
2. Tippe auf "Tracking starten"
|
||||
3. Erteile die Standortberechtigung, wenn du dazu aufgefordert wirst
|
||||
4. Dein aktueller Standort wird auf der Karte angezeigt und kontinuierlich aktualisiert
|
||||
|
||||
### Standortverlauf anzeigen
|
||||
1. Navigiere zum "Verlauf"-Tab
|
||||
2. Hier siehst du eine chronologische Liste aller aufgezeichneten Standorte
|
||||
3. Tippe auf einen Eintrag, um zu diesem Standort auf der Karte zu navigieren
|
||||
4. Nutze den Umschalter oben, um zwischen der detaillierten und der konsolidierten Ansicht zu wechseln
|
||||
|
||||
### Konsolidierte Standorte anzeigen
|
||||
1. Navigiere zum "Verlauf"-Tab
|
||||
2. Tippe auf den "Zusammengefasst"-Button im oberen Bereich
|
||||
3. Die App zeigt nun zusammengefasste Standorte mit Verweildauer und Anzahl der Datenpunkte
|
||||
4. Die Zahl im orangefarbenen Kreis zeigt die Gesamtzahl der konsolidierten Standorte an
|
||||
|
||||
### App-Logs einsehen
|
||||
1. Navigiere zum "Logs"-Tab
|
||||
2. Hier siehst du farblich kodierte Einträge für alle App-Aktivitäten:
|
||||
- Grün: Informationen
|
||||
- Orange: Warnungen
|
||||
- Rot: Fehler
|
||||
3. Die Logs werden automatisch aktualisiert und zeigen Details zu Standortverfolgung, Intervallen und mehr
|
||||
|
||||
### Standortverlauf löschen
|
||||
1. Navigiere zum "Karte"-Tab
|
||||
2. Tippe auf "Verlauf löschen"
|
||||
3. Bestätige die Löschung im Dialog
|
||||
|
||||
### Einstellungen anpassen
|
||||
1. Tippe auf das Zahnrad-Symbol in der oberen linken Ecke
|
||||
2. Passe folgende Einstellungen an:
|
||||
- Erscheinungsbild (Dark Mode ein-/ausschalten)
|
||||
- Datenschutz (Adressen speichern ein-/ausschalten)
|
||||
- Tracking-Einstellungen (Standard-Intervall und Genauigkeit)
|
||||
|
||||
## Anpassung und Weiterentwicklung
|
||||
|
||||
Die App kann leicht an deine Bedürfnisse angepasst werden:
|
||||
|
||||
### Tracking-Intervall und Genauigkeit anpassen
|
||||
Verwende die Einstellungsseite, um die Tracking-Parameter anzupassen:
|
||||
|
||||
1. Tippe auf das Zahnrad-Symbol in der oberen linken Ecke
|
||||
2. Wähle unter "Tracking-Einstellungen" die gewünschten Optionen:
|
||||
- Standard-Intervall: Bestimmt wie oft ein neuer Standort aufgezeichnet wird
|
||||
- Genauigkeit: Bestimmt die Präzision der Standortermittlung
|
||||
|
||||
Die App passt die zugrunde liegenden Parameter automatisch an:
|
||||
|
||||
```javascript
|
||||
// Die App verwendet nun diese Parameter basierend auf den Einstellungen
|
||||
const subscription = await startLocationTracking(
|
||||
onLocationUpdateCallback,
|
||||
selectedInterval, // Der in den Einstellungen gewählte Intervall
|
||||
distanceInterval // Wird automatisch basierend auf dem Intervall angepasst
|
||||
);
|
||||
```
|
||||
|
||||
### Kartenansicht anpassen
|
||||
Die Kartenansicht kann in der Datei `components/LocationMap.tsx` angepasst werden.
|
||||
|
||||
### Datenspeicherung erweitern
|
||||
Die App verwendet jetzt eine erweiterte `LocationData`-Struktur. Um zusätzliche Daten zu speichern, kannst du das Interface in `utils/locationService.ts` erweitern:
|
||||
|
||||
```typescript
|
||||
export interface LocationData {
|
||||
id: string; // Eindeutige UUID
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
timestamps: {
|
||||
recorded: string; // ISO 8601 String
|
||||
recordedMs: number; // Unix Millisekunden
|
||||
};
|
||||
// ... weitere Felder
|
||||
metadata: {
|
||||
source: 'foreground' | 'background' | 'manual';
|
||||
batteryLevel?: number;
|
||||
connectionType?: 'wifi' | 'cellular' | 'none';
|
||||
deviceMotion?: 'stationary' | 'walking' | 'driving' | 'unknown';
|
||||
// Hier können weitere Metadaten hinzugefügt werden
|
||||
};
|
||||
quality: {
|
||||
accuracyLevel: AccuracyLevel;
|
||||
horizontalAccuracy: number;
|
||||
verticalAccuracy?: number;
|
||||
isSignificantLocation: boolean;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Standortkonsolidierung anpassen
|
||||
Um den Radius für die Standortkonsolidierung anzupassen, ändere den `consolidationRadius`-Parameter in der Datei `app/(tabs)/two.tsx`:
|
||||
|
||||
```javascript
|
||||
const [consolidationRadius, setConsolidationRadius] = useState(100); // Radius in Metern
|
||||
```
|
||||
|
||||
Ein größerer Radius fasst mehr Standorte zusammen, während ein kleinerer Radius zu einer feineren Aufteilung führt.
|
||||
|
||||
## Fehlerbehebung
|
||||
|
||||
### Standort wird nicht angezeigt
|
||||
- Stelle sicher, dass die Standortberechtigung erteilt wurde
|
||||
- Überprüfe, ob die Standortdienste auf deinem Gerät aktiviert sind
|
||||
- Bei Verwendung eines Emulators: Simuliere einen Standort
|
||||
|
||||
### App stürzt ab oder reagiert nicht
|
||||
- Überprüfe die Konsolenausgabe auf Fehlermeldungen
|
||||
- Stelle sicher, dass alle Abhängigkeiten korrekt installiert sind
|
||||
- Versuche, die App neu zu starten
|
||||
|
||||
### Migration bestehender Daten
|
||||
- Die App migriert automatisch bestehende Standortdaten beim ersten Laden nach dem Update
|
||||
- Migrationsstatus wird in den Logs angezeigt (grüne Info-Meldungen)
|
||||
- Bei Problemen mit der Migration können die Standortdaten über "Verlauf löschen" zurückgesetzt werden
|
||||
|
||||
### TypeScript/Lint Fehler
|
||||
- Führe `npm run lint` aus, um Code-Qualitätsprobleme zu identifizieren
|
||||
- Führe `npm run format` aus, um automatische Formatierungskorrekturen anzuwenden
|
||||
|
||||
## Lizenz
|
||||
|
||||
Diese App ist unter der MIT-Lizenz lizenziert. Siehe die LICENSE-Datei für Details.
|
||||
|
||||
## Kontakt
|
||||
|
||||
Bei Fragen oder Problemen erstelle bitte ein Issue im GitHub-Repository oder kontaktiere den Entwickler direkt.
|
||||
|
||||
---
|
||||
|
||||
Entwickelt mit ❤️ und Expo React Native
|
||||
2
apps/traces/apps/mobile/app-env.d.ts
vendored
Normal file
2
apps/traces/apps/mobile/app-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// @ts-ignore
|
||||
/// <reference types="nativewind/types" />
|
||||
90
apps/traces/apps/mobile/app.json
Normal file
90
apps/traces/apps/mobile/app.json
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
{
|
||||
"expo": {
|
||||
"name": "Traces",
|
||||
"slug": "locations",
|
||||
"version": "1.0.0",
|
||||
"scheme": "traces",
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-location",
|
||||
{
|
||||
"locationAlwaysAndWhenInUsePermission": "Diese App benötigt Zugriff auf Ihren Standort, auch im Hintergrund, um standortbezogene Funktionen anzubieten.",
|
||||
"locationAlwaysPermission": "Diese App benötigt Zugriff auf Ihren Standort im Hintergrund, um standortbezogene Funktionen anzubieten.",
|
||||
"locationWhenInUsePermission": "Diese App benötigt Zugriff auf Ihren Standort, um standortbezogene Funktionen anzubieten."
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-media-library",
|
||||
{
|
||||
"photosPermission": "Diese App benötigt Zugriff auf Ihre Fotos, um GPS-Daten aus Bildern zu extrahieren und Ihre Reise-Historie zu importieren.",
|
||||
"savePhotosPermission": "Diese App speichert keine Fotos, sondern liest nur GPS-Metadaten.",
|
||||
"isAccessMediaLocationEnabled": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"tsconfigPaths": true
|
||||
},
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.memoro.locations",
|
||||
"icon": "./assets/traces.icon",
|
||||
"infoPlist": {
|
||||
"NSLocationWhenInUseUsageDescription": "Diese App benötigt Zugriff auf Ihren Standort, um standortbezogene Funktionen anzubieten.",
|
||||
"NSLocationAlwaysAndWhenInUseUsageDescription": "Diese App benötigt Zugriff auf Ihren Standort, auch im Hintergrund, um standortbezogene Funktionen anzubieten.",
|
||||
"NSLocationAlwaysUsageDescription": "Diese App benötigt Zugriff auf Ihren Standort, um standortbezogene Funktionen anzubieten.",
|
||||
"NSPhotoLibraryUsageDescription": "Diese App benötigt Zugriff auf Ihre Fotos, um GPS-Daten aus Bildern zu extrahieren.",
|
||||
"UIBackgroundModes": ["location", "fetch", "processing"],
|
||||
"BGTaskSchedulerPermittedIdentifiers": [
|
||||
"com.memoro.locations.locationupdatetask",
|
||||
"com.memoro.locations.locationprocessingtask"
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"usesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "com.memoro.locations",
|
||||
"permissions": [
|
||||
"ACCESS_COARSE_LOCATION",
|
||||
"ACCESS_FINE_LOCATION",
|
||||
"ACCESS_BACKGROUND_LOCATION",
|
||||
"ACCESS_MEDIA_LOCATION",
|
||||
"READ_EXTERNAL_STORAGE",
|
||||
"READ_MEDIA_IMAGES",
|
||||
"FOREGROUND_SERVICE",
|
||||
"FOREGROUND_SERVICE_LOCATION"
|
||||
]
|
||||
},
|
||||
"owner": "memoro",
|
||||
"extra": {
|
||||
"router": {
|
||||
"origin": false
|
||||
},
|
||||
"eas": {
|
||||
"projectId": "555a9045-475c-4226-a237-3ffe5366e446"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
32
apps/traces/apps/mobile/app/(tabs)/_layout.tsx
Normal file
32
apps/traces/apps/mobile/app/(tabs)/_layout.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { NativeTabs, Icon, Label } from 'expo-router/unstable-native-tabs';
|
||||
|
||||
export default function TabLayout() {
|
||||
return (
|
||||
<NativeTabs>
|
||||
<NativeTabs.Trigger name="index">
|
||||
<Icon sf="location.fill" />
|
||||
<Label>Tracking</Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="places">
|
||||
<Icon sf="mappin.and.ellipse" />
|
||||
<Label>Orte</Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="map">
|
||||
<Icon sf="map.fill" />
|
||||
<Label>Karte</Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="cities">
|
||||
<Icon sf="building.2.fill" />
|
||||
<Label>Städte</Label>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="guides">
|
||||
<Icon sf="book.fill" />
|
||||
<Label>Führungen</Label>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
);
|
||||
}
|
||||
5
apps/traces/apps/mobile/app/(tabs)/cities/_layout.tsx
Normal file
5
apps/traces/apps/mobile/app/(tabs)/cities/_layout.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function CitiesLayout() {
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
140
apps/traces/apps/mobile/app/(tabs)/cities/index.tsx
Normal file
140
apps/traces/apps/mobile/app/(tabs)/cities/index.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { View, Text, FlatList, Pressable, ActivityIndicator } from 'react-native';
|
||||
import { useFocusEffect, router } from 'expo-router';
|
||||
|
||||
import { useTheme } from '../../../utils/themeContext';
|
||||
import { getCitiesFromLocations, type CityVisit } from '../../../utils/placeService';
|
||||
import { apiFetch, getAuthToken } from '../../../utils/apiClient';
|
||||
import { SettingsButton } from '../../../components/SettingsButton';
|
||||
import type { CityVisitResponse } from '@traces/types';
|
||||
|
||||
export default function CitiesScreen() {
|
||||
const { isDarkMode } = useTheme();
|
||||
const [cities, setCities] = useState<(CityVisit | CityVisitResponse)[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isOnline, setIsOnline] = useState(false);
|
||||
|
||||
const loadCities = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Try backend first
|
||||
const token = await getAuthToken();
|
||||
if (token) {
|
||||
try {
|
||||
const backendCities = await apiFetch<CityVisitResponse[]>('/api/v1/cities');
|
||||
setCities(backendCities);
|
||||
setIsOnline(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
} catch {
|
||||
// Fall back to local
|
||||
}
|
||||
}
|
||||
|
||||
// Offline fallback: use local data
|
||||
const localCities = await getCitiesFromLocations();
|
||||
setCities(localCities);
|
||||
setIsOnline(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to load cities:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
loadCities();
|
||||
}, [loadCities])
|
||||
);
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
const hours = Math.floor(ms / (1000 * 60 * 60));
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days > 0) return `${days} Tag${days > 1 ? 'e' : ''}`;
|
||||
if (hours > 0) return `${hours} Std.`;
|
||||
const minutes = Math.floor(ms / (1000 * 60));
|
||||
return `${minutes} Min.`;
|
||||
};
|
||||
|
||||
const getCityName = (item: CityVisit | CityVisitResponse): string => {
|
||||
if ('city' in item && typeof item.city === 'object')
|
||||
return (item as CityVisitResponse).city.name;
|
||||
return (item as CityVisit).city;
|
||||
};
|
||||
|
||||
const getVisitCount = (item: CityVisit | CityVisitResponse): number => {
|
||||
return item.visitCount;
|
||||
};
|
||||
|
||||
const getDuration = (item: CityVisit | CityVisitResponse): number => {
|
||||
if ('totalDurationMs' in item) return item.totalDurationMs;
|
||||
return (item as CityVisit).totalDuration;
|
||||
};
|
||||
|
||||
const getCityId = (item: CityVisit | CityVisitResponse): string | undefined => {
|
||||
if ('city' in item && typeof item.city === 'object') return (item as CityVisitResponse).city.id;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const renderCity = ({ item }: { item: CityVisit | CityVisitResponse }) => (
|
||||
<Pressable
|
||||
className={`mx-4 mb-3 rounded-xl p-4 ${isDarkMode ? 'bg-gray-800' : 'bg-white'}`}
|
||||
style={{ shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 4, elevation: 2 }}
|
||||
onPress={() => {
|
||||
const cityId = getCityId(item);
|
||||
if (cityId) {
|
||||
router.push({ pathname: '/city-detail', params: { id: cityId } });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text className={`text-lg font-semibold ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||
{getCityName(item)}
|
||||
</Text>
|
||||
<View className="mt-2 flex-row justify-between">
|
||||
<Text className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
{getVisitCount(item)} Besuch{getVisitCount(item) !== 1 ? 'e' : ''}
|
||||
</Text>
|
||||
<Text className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
{formatDuration(getDuration(item))}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
return (
|
||||
<View className={`flex-1 ${isDarkMode ? 'bg-gray-900' : 'bg-gray-100'}`}>
|
||||
<View className="flex-row items-center justify-between px-4 pb-2 pt-16">
|
||||
<Text className={`text-2xl font-bold ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||
Städte
|
||||
</Text>
|
||||
<SettingsButton />
|
||||
</View>
|
||||
|
||||
{!isOnline && (
|
||||
<View className="mx-4 mb-2 rounded-lg bg-yellow-100 px-3 py-1.5">
|
||||
<Text className="text-xs text-yellow-800">Offline-Modus (lokale Daten)</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
) : cities.length === 0 ? (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<Text className={`text-center text-lg ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
Noch keine Städte erkannt. Starte das Tracking, um besuchte Städte zu sehen.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={cities}
|
||||
renderItem={renderCity}
|
||||
keyExtractor={(item, index) => getCityId(item) || `city-${index}`}
|
||||
contentContainerStyle={{ paddingTop: 8, paddingBottom: 20 }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
5
apps/traces/apps/mobile/app/(tabs)/guides/_layout.tsx
Normal file
5
apps/traces/apps/mobile/app/(tabs)/guides/_layout.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function GuidesLayout() {
|
||||
return <Stack screenOptions={{ headerShown: false }} />;
|
||||
}
|
||||
137
apps/traces/apps/mobile/app/(tabs)/guides/index.tsx
Normal file
137
apps/traces/apps/mobile/app/(tabs)/guides/index.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { View, Text, FlatList, Pressable, ActivityIndicator } from 'react-native';
|
||||
import { useFocusEffect, router } from 'expo-router';
|
||||
|
||||
import { useTheme } from '../../../utils/themeContext';
|
||||
import { apiFetch, getAuthToken } from '../../../utils/apiClient';
|
||||
import { SettingsButton } from '../../../components/SettingsButton';
|
||||
import type { GuideResponse } from '@traces/types';
|
||||
|
||||
export default function GuidesScreen() {
|
||||
const { isDarkMode } = useTheme();
|
||||
const [guides, setGuides] = useState<GuideResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
const loadGuides = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
setIsAuthenticated(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setIsAuthenticated(true);
|
||||
|
||||
const result = await apiFetch<GuideResponse[]>('/api/v1/guides');
|
||||
setGuides(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to load guides:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
loadGuides();
|
||||
}, [loadGuides])
|
||||
);
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'generating':
|
||||
return { text: 'Wird erstellt...', color: 'text-yellow-600' };
|
||||
case 'ready':
|
||||
return { text: 'Bereit', color: 'text-green-600' };
|
||||
case 'error':
|
||||
return { text: 'Fehler', color: 'text-red-600' };
|
||||
default:
|
||||
return { text: status, color: 'text-gray-500' };
|
||||
}
|
||||
};
|
||||
|
||||
const renderGuide = ({ item }: { item: GuideResponse }) => {
|
||||
const status = getStatusLabel(item.status);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
className={`mx-4 mb-3 rounded-xl p-4 ${isDarkMode ? 'bg-gray-800' : 'bg-white'}`}
|
||||
style={{ shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 4, elevation: 2 }}
|
||||
onPress={() => {
|
||||
if (item.status === 'ready') {
|
||||
router.push({ pathname: '/guide-detail', params: { id: item.id } });
|
||||
}
|
||||
}}
|
||||
disabled={item.status !== 'ready'}
|
||||
>
|
||||
<View className="flex-row items-start justify-between">
|
||||
<Text
|
||||
className={`flex-1 text-lg font-semibold ${isDarkMode ? 'text-white' : 'text-gray-900'}`}
|
||||
>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text className={`ml-2 text-xs font-medium ${status.color}`}>{status.text}</Text>
|
||||
</View>
|
||||
|
||||
{item.description && (
|
||||
<Text className={`mt-1 text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
{item.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<View className="mt-2 flex-row justify-between">
|
||||
{item.estimatedDurationMin && (
|
||||
<Text className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
~{item.estimatedDurationMin} Min.
|
||||
</Text>
|
||||
)}
|
||||
{item.distanceMeters && (
|
||||
<Text className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
{item.distanceMeters >= 1000
|
||||
? `${(item.distanceMeters / 1000).toFixed(1)} km`
|
||||
: `${item.distanceMeters}m`}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View className={`flex-1 ${isDarkMode ? 'bg-gray-900' : 'bg-gray-100'}`}>
|
||||
<View className="flex-row items-center justify-between px-4 pb-2 pt-16">
|
||||
<Text className={`text-2xl font-bold ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||
Führungen
|
||||
</Text>
|
||||
<SettingsButton />
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
<View className="flex-1 items-center justify-center">
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
) : !isAuthenticated ? (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<Text className={`text-center text-lg ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
Melde dich an, um KI-Stadtführungen zu erstellen und zu sehen.
|
||||
</Text>
|
||||
</View>
|
||||
) : guides.length === 0 ? (
|
||||
<View className="flex-1 items-center justify-center px-8">
|
||||
<Text className={`text-center text-lg ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
Noch keine Führungen. Gehe zu einer Stadt und erstelle deine erste KI-Stadtführung.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={guides}
|
||||
renderItem={renderGuide}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={{ paddingTop: 8, paddingBottom: 20 }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
28
apps/traces/apps/mobile/app/(tabs)/history/_layout.tsx
Normal file
28
apps/traces/apps/mobile/app/(tabs)/history/_layout.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Stack } from 'expo-router';
|
||||
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export default function HistoryLayout() {
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: isDarkMode ? '#1E1E1E' : '#FFFFFF',
|
||||
},
|
||||
headerTintColor: isDarkMode ? '#FFFFFF' : '#000000',
|
||||
headerTitleStyle: {
|
||||
color: isDarkMode ? '#FFFFFF' : '#000000',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Verlauf',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
122
apps/traces/apps/mobile/app/(tabs)/history/index.tsx
Normal file
122
apps/traces/apps/mobile/app/(tabs)/history/index.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import { useRouter, useNavigation } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { StyleSheet, View, Pressable } from 'react-native';
|
||||
|
||||
import { ConsolidatedLocationList } from '~/components/ConsolidatedLocationList';
|
||||
import { LocationHistoryList } from '~/components/LocationHistoryList';
|
||||
import { SegmentedControl, SegmentedControlOption } from '~/components/SegmentedControl';
|
||||
import { ThemeWrapper } from '~/components/ThemeWrapper';
|
||||
import { ConsolidatedLocation, consolidateLocationsByProximity } from '~/utils/locationHelper';
|
||||
import { LocationData, getLocationHistory } from '~/utils/locationService';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export default function HistoryScreen() {
|
||||
const [locationHistory, setLocationHistory] = useState<LocationData[]>([]);
|
||||
const [consolidatedLocations, setConsolidatedLocations] = useState<ConsolidatedLocation[]>([]);
|
||||
const [showConsolidated, setShowConsolidated] = useState(false);
|
||||
const [consolidationRadius, setConsolidationRadius] = useState(100);
|
||||
const router = useRouter();
|
||||
|
||||
const segmentedOptions: SegmentedControlOption[] = [
|
||||
{ value: 'all', label: 'Alle Standorte', icon: 'list' },
|
||||
{
|
||||
value: 'consolidated',
|
||||
label: 'Zusammengefasst',
|
||||
icon: 'compress',
|
||||
badge: consolidatedLocations.length,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadLocationHistory();
|
||||
const interval = setInterval(loadLocationHistory, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (locationHistory.length > 0) {
|
||||
const consolidated = consolidateLocationsByProximity(locationHistory, consolidationRadius);
|
||||
setConsolidatedLocations(consolidated);
|
||||
} else {
|
||||
setConsolidatedLocations([]);
|
||||
}
|
||||
}, [locationHistory, consolidationRadius]);
|
||||
|
||||
const loadLocationHistory = async () => {
|
||||
const history = await getLocationHistory();
|
||||
setLocationHistory(history);
|
||||
};
|
||||
|
||||
const handleLocationPress = (location: LocationData) => {
|
||||
router.navigate('/');
|
||||
};
|
||||
|
||||
const handleConsolidatedLocationPress = (location: ConsolidatedLocation) => {
|
||||
router.navigate('/');
|
||||
};
|
||||
|
||||
const { isDarkMode, colors } = useTheme();
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<Pressable
|
||||
onPress={() => router.push('/settings')}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed ? 0.5 : 1,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
})}
|
||||
>
|
||||
<FontAwesome name="gear" size={24} color={isDarkMode ? '#FFFFFF' : colors.primary} />
|
||||
</Pressable>
|
||||
),
|
||||
headerRightContainerStyle: {
|
||||
paddingRight: 8,
|
||||
},
|
||||
});
|
||||
}, [isDarkMode, navigation, router]);
|
||||
|
||||
return (
|
||||
<ThemeWrapper>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.listContainer}>
|
||||
{showConsolidated ? (
|
||||
<ConsolidatedLocationList
|
||||
consolidatedLocations={consolidatedLocations}
|
||||
onItemPress={handleConsolidatedLocationPress}
|
||||
onDelete={loadLocationHistory}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
) : (
|
||||
<LocationHistoryList
|
||||
locationHistory={locationHistory}
|
||||
onItemPress={handleLocationPress}
|
||||
onDelete={loadLocationHistory}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<SegmentedControl
|
||||
options={segmentedOptions}
|
||||
activeValue={showConsolidated ? 'consolidated' : 'all'}
|
||||
onChange={(value) => setShowConsolidated(value === 'consolidated')}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</View>
|
||||
</ThemeWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
listContainer: {
|
||||
flex: 1,
|
||||
paddingBottom: 80,
|
||||
},
|
||||
});
|
||||
243
apps/traces/apps/mobile/app/(tabs)/index.tsx
Normal file
243
apps/traces/apps/mobile/app/(tabs)/index.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import * as Location from 'expo-location';
|
||||
import { Stack, useRouter, Link } from 'expo-router';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { StyleSheet, View, Alert, Pressable } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { HeaderButton } from '~/components/HeaderButton';
|
||||
import { LocationMap } from '~/components/LocationMap';
|
||||
import { SettingsButton } from '~/components/SettingsButton';
|
||||
import { ThemeWrapper } from '~/components/ThemeWrapper';
|
||||
import { TrackingControls, TRACKING_INTERVALS } from '~/components/TrackingControls';
|
||||
import { stopBackgroundLocationTask } from '~/utils/backgroundLocationTask';
|
||||
import {
|
||||
DEFAULT_INTERVAL_KEY,
|
||||
getDefaultInterval,
|
||||
LocationData,
|
||||
requestLocationPermissions,
|
||||
getCurrentLocation,
|
||||
startLocationTracking,
|
||||
getLocationHistory,
|
||||
} from '~/utils/locationService';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const [currentLocation, setCurrentLocation] = useState<LocationData | null>(null);
|
||||
const [locationHistory, setLocationHistory] = useState<LocationData[]>([]);
|
||||
const [isTracking, setIsTracking] = useState(false);
|
||||
const [selectedInterval, setSelectedInterval] = useState(TRACKING_INTERVALS[0].value);
|
||||
const locationSubscription = useRef<Location.LocationSubscription | null>(null);
|
||||
|
||||
// Sofortige Berechtigungsanfrage beim Laden
|
||||
useEffect(() => {
|
||||
const requestPermissions = async () => {
|
||||
try {
|
||||
// Zuerst direkt die Vordergrund-Berechtigung anfordern
|
||||
const foreground = await Location.requestForegroundPermissionsAsync();
|
||||
|
||||
if (foreground.status === 'granted') {
|
||||
// Wenn Vordergrund genehmigt, dann Hintergrund anfragen
|
||||
await Location.requestBackgroundPermissionsAsync();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error requesting initial permissions:', error);
|
||||
}
|
||||
};
|
||||
|
||||
requestPermissions();
|
||||
}, []);
|
||||
|
||||
// Load location history on mount
|
||||
useEffect(() => {
|
||||
loadLocationHistory();
|
||||
loadDefaultInterval();
|
||||
|
||||
// Get initial location
|
||||
getCurrentLocation().then((location) => {
|
||||
if (location) {
|
||||
setCurrentLocation(location);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Clean up subscription when component unmounts
|
||||
if (locationSubscription.current) {
|
||||
locationSubscription.current.remove();
|
||||
}
|
||||
|
||||
// Stoppe auch die Hintergrund-Standortverfolgung beim Beenden
|
||||
stopBackgroundLocationTask().catch((err) =>
|
||||
console.error('Fehler beim Stoppen der Hintergrund-Standortverfolgung:', err)
|
||||
);
|
||||
};
|
||||
}, []); // Nur beim Mount ausführen
|
||||
|
||||
// Separater useEffect für History-Updates während Tracking
|
||||
useEffect(() => {
|
||||
if (!isTracking) return;
|
||||
|
||||
// Intervall zum regelmäßigen Aktualisieren der Standorthistorie
|
||||
const historyUpdateInterval = setInterval(() => {
|
||||
loadLocationHistory();
|
||||
}, 3000); // Alle 3 Sekunden aktualisieren wenn Tracking aktiv (für bessere UI-Updates)
|
||||
|
||||
return () => {
|
||||
clearInterval(historyUpdateInterval);
|
||||
};
|
||||
}, [isTracking]);
|
||||
|
||||
const loadLocationHistory = async () => {
|
||||
const history = await getLocationHistory();
|
||||
setLocationHistory(history);
|
||||
};
|
||||
|
||||
// Lade den Standard-Intervall
|
||||
const loadDefaultInterval = async () => {
|
||||
try {
|
||||
const interval = await getDefaultInterval();
|
||||
if (interval !== null) {
|
||||
setSelectedInterval(interval);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des Standard-Intervalls:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartTracking = async (interval: number = TRACKING_INTERVALS[0].value) => {
|
||||
const hasPermission = await requestLocationPermissions();
|
||||
if (!hasPermission) {
|
||||
Alert.alert(
|
||||
'Standort-Berechtigung benötigt',
|
||||
'Diese App benötigt Zugriff auf deinen Standort, um deine Bewegungen zu verfolgen.',
|
||||
[{ text: 'OK' }]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedInterval(interval);
|
||||
|
||||
// Bestimme die Distanz basierend auf dem Intervall
|
||||
let distanceInterval = 10; // Standard: 10 Meter
|
||||
|
||||
if (interval >= 3 * 60 * 60 * 1000) {
|
||||
// 3 Stunden oder mehr
|
||||
distanceInterval = 100;
|
||||
} else if (interval >= 60 * 60 * 1000) {
|
||||
// 1 Stunde oder mehr
|
||||
distanceInterval = 50;
|
||||
}
|
||||
|
||||
const subscription = await startLocationTracking(
|
||||
(location) => {
|
||||
setCurrentLocation(location);
|
||||
// Sofortige Aktualisierung der History bei neuen Standorten
|
||||
setTimeout(() => loadLocationHistory(), 500); // Kurze Verzögerung damit Speichervorgang abgeschlossen ist
|
||||
},
|
||||
interval, // Intervall aus der Auswahl
|
||||
distanceInterval // Distanz basierend auf dem Intervall
|
||||
);
|
||||
|
||||
if (subscription) {
|
||||
locationSubscription.current = subscription;
|
||||
setIsTracking(true);
|
||||
// Sofortiges Update der History nach Tracking-Start
|
||||
setTimeout(() => loadLocationHistory(), 1000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStopTracking = async () => {
|
||||
if (locationSubscription.current) {
|
||||
locationSubscription.current.remove();
|
||||
locationSubscription.current = null;
|
||||
}
|
||||
|
||||
// Stoppe auch die Hintergrund-Standortverfolgung
|
||||
await stopBackgroundLocationTask();
|
||||
|
||||
setIsTracking(false);
|
||||
};
|
||||
|
||||
const { isDarkMode } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<ThemeWrapper>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Tracking',
|
||||
headerTransparent: false,
|
||||
headerBlurEffect: undefined,
|
||||
headerStyle: {
|
||||
backgroundColor: isDarkMode ? '#1E1E1E' : '#FFFFFF',
|
||||
},
|
||||
headerShadowVisible: false,
|
||||
headerTintColor: isDarkMode ? '#FFFFFF' : '#000000',
|
||||
headerRight: () => (
|
||||
<Link href="/modal" asChild>
|
||||
<HeaderButton />
|
||||
</Link>
|
||||
),
|
||||
headerLeft: () => (
|
||||
<View style={{ paddingLeft: 16 }}>
|
||||
<SettingsButton />
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
isDarkMode && { backgroundColor: '#121212' },
|
||||
{
|
||||
paddingBottom: Math.max(insets.bottom, 16),
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: Math.max(insets.top, 16),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.mapContainer,
|
||||
isDarkMode && { borderColor: '#333333' },
|
||||
{ overflow: 'hidden' },
|
||||
]}
|
||||
>
|
||||
<LocationMap
|
||||
currentLocation={currentLocation}
|
||||
locationHistory={locationHistory}
|
||||
isTracking={isTracking}
|
||||
isDarkMode={isDarkMode}
|
||||
locationCount={locationHistory.length}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ marginBottom: insets.bottom + 32 }}>
|
||||
<TrackingControls
|
||||
isTracking={isTracking}
|
||||
onStartTracking={handleStartTracking}
|
||||
onStopTracking={handleStopTracking}
|
||||
locationCount={locationHistory.length}
|
||||
selectedInterval={selectedInterval}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ThemeWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
mapContainer: {
|
||||
flex: 1,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: '#e0e0e0',
|
||||
marginBottom: 16,
|
||||
},
|
||||
});
|
||||
80
apps/traces/apps/mobile/app/(tabs)/logs.tsx
Normal file
80
apps/traces/apps/mobile/app/(tabs)/logs.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import { Stack, Link } from 'expo-router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { StyleSheet, View, Pressable } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { LogsList } from '~/components/LogsList';
|
||||
import { SettingsButton } from '~/components/SettingsButton';
|
||||
import { ThemeWrapper } from '~/components/ThemeWrapper';
|
||||
import { getStoredLogs } from '~/utils/logService';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export interface LogEntry {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
level: 'info' | 'warning' | 'error';
|
||||
message: string;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
export default function LogsScreen() {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const { isDarkMode } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs();
|
||||
|
||||
// Set up a listener to refresh logs every 5 seconds
|
||||
const interval = setInterval(loadLogs, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const loadLogs = async () => {
|
||||
const storedLogs = await getStoredLogs();
|
||||
setLogs(storedLogs);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeWrapper>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Logs',
|
||||
headerTransparent: false,
|
||||
headerBlurEffect: undefined,
|
||||
headerStyle: {
|
||||
backgroundColor: isDarkMode ? '#1E1E1E' : '#FFFFFF',
|
||||
},
|
||||
headerShadowVisible: false,
|
||||
headerTintColor: isDarkMode ? '#FFFFFF' : '#000000',
|
||||
headerLeft: () => (
|
||||
<View style={{ paddingLeft: 16 }}>
|
||||
<SettingsButton />
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
isDarkMode && { backgroundColor: '#121212' },
|
||||
{
|
||||
paddingBottom: Math.max(insets.bottom, 16),
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 16,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<LogsList logs={logs} isDarkMode={isDarkMode} />
|
||||
</View>
|
||||
</ThemeWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
737
apps/traces/apps/mobile/app/(tabs)/map.tsx
Normal file
737
apps/traces/apps/mobile/app/(tabs)/map.tsx
Normal file
|
|
@ -0,0 +1,737 @@
|
|||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import { Stack, Link } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { StyleSheet, View, Pressable, ScrollView, Text, TouchableOpacity } from 'react-native';
|
||||
import MapView, { Marker, Polyline } from 'react-native-maps';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { SettingsButton } from '~/components/SettingsButton';
|
||||
import { ThemeWrapper } from '~/components/ThemeWrapper';
|
||||
import { LocationData, getLocationHistory } from '~/utils/locationService';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export type TimeFilter = 'today' | 'week' | 'month' | 'year' | 'all';
|
||||
|
||||
interface TimeFilterOption {
|
||||
id: TimeFilter;
|
||||
label: string;
|
||||
icon: React.ComponentProps<typeof FontAwesome>['name'];
|
||||
}
|
||||
|
||||
const TIME_FILTERS: TimeFilterOption[] = [
|
||||
{ id: 'today', label: 'Heute', icon: 'calendar-o' },
|
||||
{ id: 'week', label: 'Woche', icon: 'calendar' },
|
||||
{ id: 'month', label: 'Monat', icon: 'calendar' },
|
||||
{ id: 'year', label: 'Jahr', icon: 'calendar' },
|
||||
{ id: 'all', label: 'Alle', icon: 'globe' },
|
||||
];
|
||||
|
||||
export default function MapOverviewScreen() {
|
||||
const { isDarkMode, colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [locationHistory, setLocationHistory] = useState<LocationData[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedTimeFilter, setSelectedTimeFilter] = useState<TimeFilter>('all');
|
||||
const [showRoute, setShowRoute] = useState(true);
|
||||
const [showHeatmap, setShowHeatmap] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadLocationHistory();
|
||||
|
||||
// Auto-Update alle 30 Sekunden
|
||||
const interval = setInterval(loadLocationHistory, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const loadLocationHistory = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const history = await getLocationHistory();
|
||||
setLocationHistory(history);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des Standortverlaufs:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter locations based on selected time range
|
||||
const getFilteredLocations = (): LocationData[] => {
|
||||
if (selectedTimeFilter === 'all') return locationHistory;
|
||||
|
||||
const now = Date.now();
|
||||
let cutoffTime = 0;
|
||||
|
||||
switch (selectedTimeFilter) {
|
||||
case 'today':
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
cutoffTime = today.getTime();
|
||||
break;
|
||||
case 'week':
|
||||
cutoffTime = now - 7 * 24 * 60 * 60 * 1000;
|
||||
break;
|
||||
case 'month':
|
||||
cutoffTime = now - 30 * 24 * 60 * 60 * 1000;
|
||||
break;
|
||||
case 'year':
|
||||
cutoffTime = now - 365 * 24 * 60 * 60 * 1000;
|
||||
break;
|
||||
}
|
||||
|
||||
return locationHistory.filter((loc) => {
|
||||
const timestamp = loc.timestamps?.recordedMs || loc.timestamp || 0;
|
||||
return timestamp >= cutoffTime;
|
||||
});
|
||||
};
|
||||
|
||||
// Cluster nearby locations for heatmap view
|
||||
const clusterLocations = (
|
||||
locations: LocationData[],
|
||||
radiusKm: number = 0.1
|
||||
): Array<{
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
count: number;
|
||||
locations: LocationData[];
|
||||
}> => {
|
||||
const clusters: Array<{
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
count: number;
|
||||
locations: LocationData[];
|
||||
}> = [];
|
||||
|
||||
locations.forEach((loc) => {
|
||||
const existingCluster = clusters.find((cluster) => {
|
||||
const distance = calculateDistance(
|
||||
loc.latitude,
|
||||
loc.longitude,
|
||||
cluster.latitude,
|
||||
cluster.longitude
|
||||
);
|
||||
return distance <= radiusKm;
|
||||
});
|
||||
|
||||
if (existingCluster) {
|
||||
existingCluster.count++;
|
||||
existingCluster.locations.push(loc);
|
||||
const totalLat = existingCluster.locations.reduce((sum, l) => sum + l.latitude, 0);
|
||||
const totalLng = existingCluster.locations.reduce((sum, l) => sum + l.longitude, 0);
|
||||
existingCluster.latitude = totalLat / existingCluster.locations.length;
|
||||
existingCluster.longitude = totalLng / existingCluster.locations.length;
|
||||
} else {
|
||||
clusters.push({
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
count: 1,
|
||||
locations: [loc],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return clusters;
|
||||
};
|
||||
|
||||
// Calculate distance between two coordinates (Haversine formula)
|
||||
const calculateDistance = (lat1: number, lon1: number, lat2: number, lon2: number): number => {
|
||||
const R = 6371; // Earth's radius in km
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos((lat1 * Math.PI) / 180) *
|
||||
Math.cos((lat2 * Math.PI) / 180) *
|
||||
Math.sin(dLon / 2) *
|
||||
Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
};
|
||||
|
||||
// Get color intensity based on cluster count
|
||||
const getHeatmapColor = (count: number, maxCount: number): string => {
|
||||
const intensity = Math.min(count / maxCount, 1);
|
||||
if (intensity < 0.3) return '#4CAF50'; // Green
|
||||
if (intensity < 0.6) return '#FF9800'; // Orange
|
||||
return '#F44336'; // Red
|
||||
};
|
||||
|
||||
const filteredLocations = getFilteredLocations();
|
||||
const locationClusters = clusterLocations(filteredLocations);
|
||||
const maxClusterCount = Math.max(...locationClusters.map((c) => c.count), 1);
|
||||
|
||||
// Berechne die Region, die alle Standorte umfasst
|
||||
const getRegionForLocations = () => {
|
||||
const locations = filteredLocations.length > 0 ? filteredLocations : locationHistory;
|
||||
|
||||
if (locations.length === 0) {
|
||||
// Standard-Region (Apple Campus)
|
||||
return {
|
||||
latitude: 37.33233141,
|
||||
longitude: -122.0312186,
|
||||
latitudeDelta: 0.01,
|
||||
longitudeDelta: 0.01,
|
||||
};
|
||||
}
|
||||
|
||||
if (locations.length === 1) {
|
||||
// Einzelner Standort
|
||||
const location = locations[0];
|
||||
return {
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
latitudeDelta: 0.01,
|
||||
longitudeDelta: 0.01,
|
||||
};
|
||||
}
|
||||
|
||||
// Mehrere Standorte - berechne Bounding Box
|
||||
const lats = locations.map((loc) => loc.latitude);
|
||||
const lngs = locations.map((loc) => loc.longitude);
|
||||
|
||||
const minLat = Math.min(...lats);
|
||||
const maxLat = Math.max(...lats);
|
||||
const minLng = Math.min(...lngs);
|
||||
const maxLng = Math.max(...lngs);
|
||||
|
||||
const centerLat = (minLat + maxLat) / 2;
|
||||
const centerLng = (minLng + maxLng) / 2;
|
||||
|
||||
// Füge etwas Padding hinzu
|
||||
const latDelta = (maxLat - minLat) * 1.2 || 0.01;
|
||||
const lngDelta = (maxLng - minLng) * 1.2 || 0.01;
|
||||
|
||||
return {
|
||||
latitude: centerLat,
|
||||
longitude: centerLng,
|
||||
latitudeDelta: Math.max(latDelta, 0.01),
|
||||
longitudeDelta: Math.max(lngDelta, 0.01),
|
||||
};
|
||||
};
|
||||
|
||||
// Erstelle Koordinaten für die Polyline (chronologischer Pfad)
|
||||
const getPolylineCoordinates = () => {
|
||||
return filteredLocations
|
||||
.sort((a, b) => {
|
||||
const aTime = a.timestamps?.recordedMs || a.timestamp || 0;
|
||||
const bTime = b.timestamps?.recordedMs || b.timestamp || 0;
|
||||
return aTime - bTime;
|
||||
})
|
||||
.map((location) => ({
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeWrapper>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: 'Karte',
|
||||
headerTransparent: false,
|
||||
headerBlurEffect: undefined,
|
||||
headerStyle: {
|
||||
backgroundColor: isDarkMode ? '#1E1E1E' : '#FFFFFF',
|
||||
},
|
||||
headerShadowVisible: false,
|
||||
headerTintColor: isDarkMode ? '#FFFFFF' : '#000000',
|
||||
headerLeft: () => (
|
||||
<View style={{ paddingLeft: 16 }}>
|
||||
<SettingsButton />
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<View style={styles.container}>
|
||||
<MapView
|
||||
style={styles.map}
|
||||
region={getRegionForLocations()}
|
||||
mapType={isDarkMode ? 'mutedStandard' : 'standard'}
|
||||
showsUserLocation
|
||||
showsMyLocationButton={false}
|
||||
showsCompass
|
||||
showsScale
|
||||
userInterfaceStyle={isDarkMode ? 'dark' : 'light'}
|
||||
customMapStyle={isDarkMode ? darkMapStyle : undefined}
|
||||
>
|
||||
{/* Polyline für den Bewegungspfad */}
|
||||
{showRoute && filteredLocations.length > 1 && (
|
||||
<Polyline
|
||||
coordinates={getPolylineCoordinates()}
|
||||
strokeColor={colors.primary}
|
||||
strokeWidth={3}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Marker - Heatmap oder normale Ansicht */}
|
||||
{showHeatmap
|
||||
? // Heatmap: Cluster anzeigen
|
||||
locationClusters.map((cluster, index) => (
|
||||
<Marker
|
||||
key={`cluster-${index}`}
|
||||
coordinate={{
|
||||
latitude: cluster.latitude,
|
||||
longitude: cluster.longitude,
|
||||
}}
|
||||
pinColor={getHeatmapColor(cluster.count, maxClusterCount)}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.clusterMarker,
|
||||
{
|
||||
backgroundColor: getHeatmapColor(cluster.count, maxClusterCount),
|
||||
width: 30 + cluster.count * 3,
|
||||
height: 30 + cluster.count * 3,
|
||||
borderRadius: (30 + cluster.count * 3) / 2,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={styles.clusterText}>{cluster.count}</Text>
|
||||
</View>
|
||||
</Marker>
|
||||
))
|
||||
: // Normale Ansicht: Einzelne Marker
|
||||
filteredLocations.map((location, index) => (
|
||||
<Marker
|
||||
key={`location-${location.timestamp}-${index}`}
|
||||
coordinate={{
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
}}
|
||||
title={`Standort ${index + 1}`}
|
||||
description={new Date(
|
||||
location.timestamps?.recordedMs || location.timestamp || 0
|
||||
).toLocaleString('de-DE')}
|
||||
pinColor={colors.primary}
|
||||
opacity={0.6}
|
||||
/>
|
||||
))}
|
||||
</MapView>
|
||||
|
||||
{/* Time Filter */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.filterScrollView}
|
||||
contentContainerStyle={styles.filterContainer}
|
||||
>
|
||||
{TIME_FILTERS.map((filter) => (
|
||||
<TouchableOpacity
|
||||
key={filter.id}
|
||||
style={[
|
||||
styles.filterButton,
|
||||
selectedTimeFilter === filter.id && {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
isDarkMode &&
|
||||
selectedTimeFilter !== filter.id && {
|
||||
backgroundColor: '#333',
|
||||
},
|
||||
]}
|
||||
onPress={() => setSelectedTimeFilter(filter.id)}
|
||||
>
|
||||
<FontAwesome
|
||||
name={filter.icon}
|
||||
size={16}
|
||||
color={selectedTimeFilter === filter.id ? 'white' : isDarkMode ? '#AAA' : '#666'}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.filterText,
|
||||
selectedTimeFilter === filter.id && { color: 'white', fontWeight: 'bold' },
|
||||
isDarkMode && selectedTimeFilter !== filter.id && { color: '#AAA' },
|
||||
]}
|
||||
>
|
||||
{filter.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{/* View Mode Controls */}
|
||||
<View style={styles.viewControls}>
|
||||
{/* Route Toggle */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.controlButton,
|
||||
isDarkMode && {
|
||||
backgroundColor: '#333',
|
||||
},
|
||||
]}
|
||||
onPress={() => setShowRoute(!showRoute)}
|
||||
>
|
||||
<FontAwesome
|
||||
name={showRoute ? 'eye' : 'eye-slash'}
|
||||
size={22}
|
||||
color={showRoute ? colors.primary : '#999'}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Heatmap Toggle */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.controlButton,
|
||||
showHeatmap && { backgroundColor: colors.primary },
|
||||
isDarkMode &&
|
||||
!showHeatmap && {
|
||||
backgroundColor: '#333',
|
||||
},
|
||||
]}
|
||||
onPress={() => setShowHeatmap(!showHeatmap)}
|
||||
>
|
||||
<FontAwesome name="fire" size={22} color={showHeatmap ? 'white' : '#999'} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Location Count Badge */}
|
||||
<View
|
||||
style={[
|
||||
styles.locationCountBadge,
|
||||
isDarkMode && {
|
||||
backgroundColor: 'rgba(40, 40, 40, 0.9)',
|
||||
shadowColor: '#000000',
|
||||
shadowOpacity: 0.5,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<FontAwesome
|
||||
name="map-marker"
|
||||
size={16}
|
||||
color={colors.primary}
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
<Text style={[styles.locationCountText, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
{filteredLocations.length}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ThemeWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Dark Mode Map Style (minimalistisch)
|
||||
const darkMapStyle = [
|
||||
{
|
||||
elementType: 'geometry',
|
||||
stylers: [
|
||||
{
|
||||
color: '#1d2c4d',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
elementType: 'labels.text.fill',
|
||||
stylers: [
|
||||
{
|
||||
color: '#8ec3b9',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
elementType: 'labels.text.stroke',
|
||||
stylers: [
|
||||
{
|
||||
color: '#1a3646',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
featureType: 'administrative.country',
|
||||
elementType: 'geometry.stroke',
|
||||
stylers: [
|
||||
{
|
||||
color: '#4b6878',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
featureType: 'administrative.land_parcel',
|
||||
elementType: 'labels.text.fill',
|
||||
stylers: [
|
||||
{
|
||||
color: '#64779f',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
featureType: 'administrative.province',
|
||||
elementType: 'geometry.stroke',
|
||||
stylers: [
|
||||
{
|
||||
color: '#4b6878',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
featureType: 'landscape.man_made',
|
||||
elementType: 'geometry.stroke',
|
||||
stylers: [
|
||||
{
|
||||
color: '#334e87',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
featureType: 'landscape.natural',
|
||||
elementType: 'geometry',
|
||||
stylers: [
|
||||
{
|
||||
color: '#023e58',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
featureType: 'poi',
|
||||
elementType: 'geometry',
|
||||
stylers: [
|
||||
{
|
||||
color: '#283d6a',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
featureType: 'poi',
|
||||
elementType: 'labels.text.fill',
|
||||
stylers: [
|
||||
{
|
||||
color: '#6f9ba5',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
featureType: 'poi',
|
||||
elementType: 'labels.text.stroke',
|
||||
stylers: [
|
||||
{
|
||||
color: '#1d2c4d',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
featureType: 'poi.park',
|
||||
elementType: 'geometry.fill',
|
||||
stylers: [
|
||||
{
|
||||
color: '#023e58',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
featureType: 'poi.park',
|
||||
elementType: 'labels.text.fill',
|
||||
stylers: [
|
||||
{
|
||||
color: '#3C7680',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
featureType: 'road',
|
||||
elementType: 'geometry',
|
||||
stylers: [
|
||||
{
|
||||
color: '#304a7d',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
featureType: 'road',
|
||||
elementType: 'labels.text.fill',
|
||||
stylers: [
|
||||
{
|
||||
color: '#98a5be',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
featureType: 'road',
|
||||
elementType: 'labels.text.stroke',
|
||||
stylers: [
|
||||
{
|
||||
color: '#1d2c4d',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
featureType: 'road.highway',
|
||||
elementType: 'geometry',
|
||||
stylers: [
|
||||
{
|
||||
color: '#2c6675',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
featureType: 'road.highway',
|
||||
elementType: 'geometry.stroke',
|
||||
stylers: [
|
||||
{
|
||||
color: '#255763',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
featureType: 'road.highway',
|
||||
elementType: 'labels.text.fill',
|
||||
stylers: [
|
||||
{
|
||||
color: '#b0d5ce',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
featureType: 'road.highway',
|
||||
elementType: 'labels.text.stroke',
|
||||
stylers: [
|
||||
{
|
||||
color: '#023e58',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
featureType: 'transit',
|
||||
elementType: 'labels.text.fill',
|
||||
stylers: [
|
||||
{
|
||||
color: '#98a5be',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
featureType: 'transit',
|
||||
elementType: 'labels.text.stroke',
|
||||
stylers: [
|
||||
{
|
||||
color: '#1d2c4d',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
featureType: 'transit.line',
|
||||
elementType: 'geometry.fill',
|
||||
stylers: [
|
||||
{
|
||||
color: '#283d6a',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
featureType: 'transit.station',
|
||||
elementType: 'geometry',
|
||||
stylers: [
|
||||
{
|
||||
color: '#3a4762',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
featureType: 'water',
|
||||
elementType: 'geometry',
|
||||
stylers: [
|
||||
{
|
||||
color: '#0e1626',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
featureType: 'water',
|
||||
elementType: 'labels.text.fill',
|
||||
stylers: [
|
||||
{
|
||||
color: '#4e6d70',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
map: {
|
||||
flex: 1,
|
||||
},
|
||||
filterScrollView: {
|
||||
position: 'absolute',
|
||||
bottom: 100,
|
||||
left: 0,
|
||||
right: 0,
|
||||
maxHeight: 60,
|
||||
},
|
||||
filterContainer: {
|
||||
paddingHorizontal: 12,
|
||||
gap: 10,
|
||||
},
|
||||
filterButton: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderRadius: 24,
|
||||
paddingHorizontal: 18,
|
||||
paddingVertical: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 2,
|
||||
elevation: 3,
|
||||
},
|
||||
filterText: {
|
||||
fontSize: 15,
|
||||
color: '#666',
|
||||
fontWeight: '600',
|
||||
},
|
||||
viewControls: {
|
||||
position: 'absolute',
|
||||
bottom: 170,
|
||||
right: 16,
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
},
|
||||
controlButton: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderRadius: 28,
|
||||
width: 56,
|
||||
height: 56,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 3,
|
||||
elevation: 4,
|
||||
},
|
||||
clusterMarker: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
opacity: 0.8,
|
||||
borderWidth: 2,
|
||||
borderColor: 'white',
|
||||
},
|
||||
clusterText: {
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 14,
|
||||
},
|
||||
locationCountBadge: {
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
locationCountText: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#000',
|
||||
},
|
||||
});
|
||||
28
apps/traces/apps/mobile/app/(tabs)/places/_layout.tsx
Normal file
28
apps/traces/apps/mobile/app/(tabs)/places/_layout.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Stack } from 'expo-router';
|
||||
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export default function PlacesLayout() {
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: isDarkMode ? '#1E1E1E' : '#FFFFFF',
|
||||
},
|
||||
headerTintColor: isDarkMode ? '#FFFFFF' : '#000000',
|
||||
headerTitleStyle: {
|
||||
color: isDarkMode ? '#FFFFFF' : '#000000',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Orte',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
182
apps/traces/apps/mobile/app/(tabs)/places/index.tsx
Normal file
182
apps/traces/apps/mobile/app/(tabs)/places/index.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import { useRouter, useNavigation } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { StyleSheet, View, Alert, Pressable } from 'react-native';
|
||||
|
||||
import { CitiesList } from '~/components/CitiesList';
|
||||
import { CountriesList } from '~/components/CountriesList';
|
||||
import { PlacesList } from '~/components/PlacesList';
|
||||
import { SegmentedControl, SegmentedControlOption } from '~/components/SegmentedControl';
|
||||
import { ThemeWrapper } from '~/components/ThemeWrapper';
|
||||
import { Place, ConsolidatedLocation } from '~/utils/locationHelper';
|
||||
import {
|
||||
getSavedPlaces,
|
||||
getFrequentLocations,
|
||||
createPlaceFromLocation,
|
||||
getCitiesFromLocations,
|
||||
getCountriesFromLocations,
|
||||
CityVisit,
|
||||
CountryVisit,
|
||||
} from '~/utils/placeService';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export default function PlacesScreen() {
|
||||
const { isDarkMode, colors } = useTheme();
|
||||
const router = useRouter();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const [savedPlaces, setSavedPlaces] = useState<Place[]>([]);
|
||||
const [frequentLocations, setFrequentLocations] = useState<(ConsolidatedLocation | Place)[]>([]);
|
||||
const [cities, setCities] = useState<CityVisit[]>([]);
|
||||
const [countries, setCountries] = useState<CountryVisit[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'frequent' | 'cities' | 'countries'>('frequent');
|
||||
|
||||
const segmentedOptions: SegmentedControlOption[] = [
|
||||
{ value: 'frequent', label: 'Orte', icon: 'map-marker' },
|
||||
{ value: 'cities', label: 'Städte', icon: 'building' },
|
||||
{ value: 'countries', label: 'Länder', icon: 'globe' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const places = await getSavedPlaces();
|
||||
setSavedPlaces(places);
|
||||
|
||||
const frequentLocs = await getFrequentLocations(1);
|
||||
|
||||
const filteredFrequentLocations = frequentLocs.filter((loc) => {
|
||||
return !places.some(
|
||||
(place) =>
|
||||
Math.abs(place.latitude - loc.latitude) < 0.0005 &&
|
||||
Math.abs(place.longitude - loc.longitude) < 0.0005
|
||||
);
|
||||
});
|
||||
|
||||
const allLocations = [...places, ...filteredFrequentLocations];
|
||||
setFrequentLocations(allLocations);
|
||||
|
||||
// Lade Städte
|
||||
const citiesData = await getCitiesFromLocations();
|
||||
setCities(citiesData);
|
||||
|
||||
// Lade Länder
|
||||
const countriesData = await getCountriesFromLocations();
|
||||
setCountries(countriesData);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Orte:', error);
|
||||
Alert.alert('Fehler', 'Die Orte konnten nicht geladen werden.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlacePress = (place: Place | ConsolidatedLocation) => {
|
||||
if ('id' in place) {
|
||||
router.push({
|
||||
pathname: '/place-details',
|
||||
params: { placeId: place.id },
|
||||
});
|
||||
} else {
|
||||
Alert.alert(
|
||||
'Ort erstellen',
|
||||
'Möchtest du aus diesem häufig besuchten Ort einen benannten Ort erstellen?',
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Erstellen',
|
||||
onPress: () => handleAddPlace(place),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddPlace = (location: ConsolidatedLocation) => {
|
||||
Alert.prompt(
|
||||
'Neuer Ort',
|
||||
'Wie soll dieser Ort heißen?',
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Erstellen',
|
||||
onPress: async (name: string | undefined) => {
|
||||
if (name && name.trim()) {
|
||||
const newPlace = createPlaceFromLocation(location, name.trim());
|
||||
const { savePlace } = require('~/utils/placeService');
|
||||
await savePlace(newPlace);
|
||||
await loadData();
|
||||
} else {
|
||||
Alert.alert('Fehler', 'Bitte gib einen Namen für den Ort ein.');
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
'plain-text'
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerRight: () => (
|
||||
<Pressable
|
||||
onPress={() => router.push('/settings')}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed ? 0.5 : 1,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
})}
|
||||
>
|
||||
<FontAwesome name="gear" size={24} color={isDarkMode ? '#FFFFFF' : colors.primary} />
|
||||
</Pressable>
|
||||
),
|
||||
headerRightContainerStyle: {
|
||||
paddingRight: 8,
|
||||
},
|
||||
});
|
||||
}, [isDarkMode, navigation, router]);
|
||||
|
||||
return (
|
||||
<ThemeWrapper>
|
||||
<View style={[styles.container, isDarkMode && { backgroundColor: '#121212' }]}>
|
||||
<View style={styles.contentContainer}>
|
||||
{activeTab === 'frequent' ? (
|
||||
<PlacesList
|
||||
places={frequentLocations}
|
||||
onItemPress={handlePlacePress}
|
||||
onAddPlace={handleAddPlace}
|
||||
showAddButton
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
) : activeTab === 'cities' ? (
|
||||
<CitiesList cities={cities} isDarkMode={isDarkMode} />
|
||||
) : (
|
||||
<CountriesList countries={countries} isDarkMode={isDarkMode} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
<SegmentedControl
|
||||
options={segmentedOptions}
|
||||
activeValue={activeTab}
|
||||
onChange={(value) => setActiveTab(value as 'frequent' | 'cities' | 'countries')}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</View>
|
||||
</ThemeWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
paddingBottom: 80,
|
||||
},
|
||||
});
|
||||
57
apps/traces/apps/mobile/app/+html.tsx
Normal file
57
apps/traces/apps/mobile/app/+html.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { ScrollViewStyleReset } from 'expo-router/html';
|
||||
|
||||
// This file is web-only and used to configure the root HTML for every
|
||||
// web page during static rendering.
|
||||
// The contents of this function only run in Node.js environments and
|
||||
// do not have access to the DOM or browser APIs.
|
||||
export default function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
|
||||
{/*
|
||||
This viewport disables scaling which makes the mobile website act more like a native app.
|
||||
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
*/}
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
|
||||
/>
|
||||
{/*
|
||||
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
|
||||
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
|
||||
*/}
|
||||
<ScrollViewStyleReset />
|
||||
|
||||
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
|
||||
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
|
||||
{/* Add any additional <head> elements that you want globally available on web... */}
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
const responsiveBackground = `
|
||||
body {
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #121212;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Add additional dark mode styles for web */
|
||||
.dark-mode-text {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dark-mode-bg {
|
||||
background-color: #1E1E1E;
|
||||
}
|
||||
}`;
|
||||
23
apps/traces/apps/mobile/app/+not-found.tsx
Normal file
23
apps/traces/apps/mobile/app/+not-found.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { Link, Stack } from 'expo-router';
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<View className={styles.container}>
|
||||
<Text className={styles.title}>This screen doesn't exist.</Text>
|
||||
<Link href="/" className={styles.link}>
|
||||
<Text className={styles.linkText}>Go to home screen!</Text>
|
||||
</Link>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: `items-center flex-1 justify-center p-5`,
|
||||
title: `text-xl font-bold`,
|
||||
link: `mt-4 pt-4`,
|
||||
linkText: `text-base text-[#2e78b7]`,
|
||||
};
|
||||
76
apps/traces/apps/mobile/app/_layout.tsx
Normal file
76
apps/traces/apps/mobile/app/_layout.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import '../global.css';
|
||||
import { Stack } from 'expo-router';
|
||||
import { useEffect } from 'react';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
|
||||
import { ThemeWrapper } from '~/components/ThemeWrapper';
|
||||
import { registerBackgroundTasks } from '~/utils/registerBackgroundTasks';
|
||||
import { startAutoSync } from '~/utils/syncService';
|
||||
import { ThemeProvider } from '~/utils/themeContext';
|
||||
|
||||
export const unstable_settings = {
|
||||
// Ensure that reloading on `/modal` keeps a back button present.
|
||||
initialRouteName: '(tabs)',
|
||||
};
|
||||
|
||||
export default function RootLayout() {
|
||||
// Registriere Hintergrundaufgaben beim App-Start
|
||||
useEffect(() => {
|
||||
registerBackgroundTasks().catch((error) =>
|
||||
console.error('Fehler beim Initialisieren der Hintergrundaufgaben:', error)
|
||||
);
|
||||
|
||||
// Start auto-sync (syncs to backend when logged in)
|
||||
startAutoSync();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<ThemeProvider>
|
||||
{({ isDarkMode }) => (
|
||||
<ThemeWrapper>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
// Use transparent background to allow the ThemeWrapper color to show through
|
||||
backgroundColor: isDarkMode ? '#1E1E1E' : 'transparent',
|
||||
},
|
||||
headerTintColor: isDarkMode ? '#FFFFFF' : '#000000',
|
||||
headerTitleStyle: {
|
||||
color: isDarkMode ? '#FFFFFF' : '#000000',
|
||||
},
|
||||
contentStyle: {
|
||||
backgroundColor: isDarkMode ? '#121212' : '#FFFFFF',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="modal"
|
||||
options={{
|
||||
presentation: 'modal',
|
||||
title: 'Über die App',
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="settings"
|
||||
options={{ title: 'Einstellungen', headerBackTitle: 'Zurück' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="city-detail"
|
||||
options={{ title: 'Stadt-Details', headerBackTitle: 'Zurück' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="guide-detail"
|
||||
options={{ title: 'Stadtführung', headerBackTitle: 'Zurück' }}
|
||||
/>
|
||||
</Stack>
|
||||
</ThemeWrapper>
|
||||
)}
|
||||
</ThemeProvider>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
220
apps/traces/apps/mobile/app/city-detail.tsx
Normal file
220
apps/traces/apps/mobile/app/city-detail.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { View, Text, ScrollView, Pressable, ActivityIndicator, Alert } from 'react-native';
|
||||
import { useLocalSearchParams, router } from 'expo-router';
|
||||
|
||||
import { useTheme } from '../utils/themeContext';
|
||||
import { apiFetch } from '../utils/apiClient';
|
||||
import type { CityResponse, PlaceResponse, GenerateGuideRequest } from '@traces/types';
|
||||
|
||||
interface CityDetail {
|
||||
city: CityResponse;
|
||||
visit: {
|
||||
firstVisitAt: string;
|
||||
lastVisitAt: string;
|
||||
totalDurationMs: number;
|
||||
visitCount: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default function CityDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { isDarkMode } = useTheme();
|
||||
const [detail, setDetail] = useState<CityDetail | null>(null);
|
||||
const [places, setPlaces] = useState<PlaceResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [cityDetail, allPlaces] = await Promise.all([
|
||||
apiFetch<CityDetail>(`/api/v1/cities/${id}`),
|
||||
apiFetch<PlaceResponse[]>('/api/v1/places'),
|
||||
]);
|
||||
setDetail(cityDetail);
|
||||
setPlaces(allPlaces.filter((p) => p.cityId === id));
|
||||
} catch (error) {
|
||||
console.error('Failed to load city detail:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handleGenerateGuide = async () => {
|
||||
if (!id) return;
|
||||
|
||||
Alert.alert(
|
||||
'Stadtführung erstellen',
|
||||
'Es werden Credits verbraucht (ca. 21 Credits für 8 POIs). Fortfahren?',
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Erstellen',
|
||||
onPress: async () => {
|
||||
setGenerating(true);
|
||||
try {
|
||||
const request: GenerateGuideRequest = {
|
||||
cityId: id,
|
||||
radiusMeters: 2000,
|
||||
language: 'de',
|
||||
maxPois: 8,
|
||||
};
|
||||
await apiFetch('/api/v1/guides/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
Alert.alert(
|
||||
'Erfolg',
|
||||
'Die Stadtführung wird erstellt. Du findest sie im Tab "Führungen".'
|
||||
);
|
||||
} catch (error: any) {
|
||||
Alert.alert('Fehler', error.message || 'Führung konnte nicht erstellt werden.');
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const formatDate = (iso: string) => {
|
||||
return new Date(iso).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
const hours = Math.floor(ms / (1000 * 60 * 60));
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days > 0) return `${days} Tag${days > 1 ? 'e' : ''}, ${hours % 24} Std.`;
|
||||
if (hours > 0) return `${hours} Stunden`;
|
||||
return `${Math.floor(ms / (1000 * 60))} Minuten`;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View
|
||||
className={`flex-1 items-center justify-center ${isDarkMode ? 'bg-gray-900' : 'bg-gray-100'}`}
|
||||
>
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<View
|
||||
className={`flex-1 items-center justify-center ${isDarkMode ? 'bg-gray-900' : 'bg-gray-100'}`}
|
||||
>
|
||||
<Text className={isDarkMode ? 'text-gray-400' : 'text-gray-500'}>Stadt nicht gefunden</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView className={`flex-1 ${isDarkMode ? 'bg-gray-900' : 'bg-gray-100'}`}>
|
||||
<View className="px-4 pb-8 pt-16">
|
||||
<Pressable onPress={() => router.back()}>
|
||||
<Text className="mb-4 text-primary">← Zurück</Text>
|
||||
</Pressable>
|
||||
|
||||
<Text className={`text-3xl font-bold ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||
{detail.city.name}
|
||||
</Text>
|
||||
<Text className={`mt-1 text-lg ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
{detail.city.country}
|
||||
</Text>
|
||||
|
||||
{/* Visit Stats */}
|
||||
{detail.visit && (
|
||||
<View className={`mt-6 rounded-xl p-4 ${isDarkMode ? 'bg-gray-800' : 'bg-white'}`}>
|
||||
<Text
|
||||
className={`mb-3 text-lg font-semibold ${isDarkMode ? 'text-white' : 'text-gray-900'}`}
|
||||
>
|
||||
Besuchsstatistiken
|
||||
</Text>
|
||||
<View className="flex-row justify-between">
|
||||
<View>
|
||||
<Text className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
Besuche
|
||||
</Text>
|
||||
<Text
|
||||
className={`text-xl font-bold ${isDarkMode ? 'text-white' : 'text-gray-900'}`}
|
||||
>
|
||||
{detail.visit.visitCount}
|
||||
</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
Gesamtdauer
|
||||
</Text>
|
||||
<Text
|
||||
className={`text-xl font-bold ${isDarkMode ? 'text-white' : 'text-gray-900'}`}
|
||||
>
|
||||
{formatDuration(detail.visit.totalDurationMs)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="mt-3 flex-row justify-between">
|
||||
<Text className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
Erster Besuch: {formatDate(detail.visit.firstVisitAt)}
|
||||
</Text>
|
||||
<Text className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
Letzter: {formatDate(detail.visit.lastVisitAt)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Places in this city */}
|
||||
{places.length > 0 && (
|
||||
<View className="mt-6">
|
||||
<Text
|
||||
className={`mb-3 text-lg font-semibold ${isDarkMode ? 'text-white' : 'text-gray-900'}`}
|
||||
>
|
||||
Orte in {detail.city.name}
|
||||
</Text>
|
||||
{places.map((place) => (
|
||||
<View
|
||||
key={place.id}
|
||||
className={`mb-2 rounded-lg p-3 ${isDarkMode ? 'bg-gray-800' : 'bg-white'}`}
|
||||
>
|
||||
<Text className={`font-medium ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||
{place.name}
|
||||
</Text>
|
||||
{place.addressFormatted && (
|
||||
<Text
|
||||
className={`mt-0.5 text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
|
||||
>
|
||||
{place.addressFormatted}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Generate Guide Button */}
|
||||
<Pressable
|
||||
className={`mt-6 items-center rounded-xl p-4 ${generating ? 'bg-gray-400' : 'bg-primary'}`}
|
||||
onPress={handleGenerateGuide}
|
||||
disabled={generating}
|
||||
>
|
||||
{generating ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : (
|
||||
<Text className="text-lg font-semibold text-white">Stadtführung erstellen</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
178
apps/traces/apps/mobile/app/guide-detail.tsx
Normal file
178
apps/traces/apps/mobile/app/guide-detail.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { View, Text, ScrollView, Pressable, ActivityIndicator, Platform } from 'react-native';
|
||||
import { useLocalSearchParams, router } from 'expo-router';
|
||||
|
||||
import { useTheme } from '../utils/themeContext';
|
||||
import { apiFetch } from '../utils/apiClient';
|
||||
import type { GuideDetailResponse } from '@traces/types';
|
||||
|
||||
const POI_CATEGORY_ICONS: Record<string, string> = {
|
||||
building: '🏛️',
|
||||
monument: '🗽',
|
||||
church: '⛪',
|
||||
museum: '🏛️',
|
||||
palace: '🏰',
|
||||
bridge: '🌉',
|
||||
park: '🌳',
|
||||
square: '🏛️',
|
||||
sculpture: '🎨',
|
||||
fountain: '⛲',
|
||||
historic_site: '📜',
|
||||
other: '📍',
|
||||
};
|
||||
|
||||
export default function GuideDetailScreen() {
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { isDarkMode } = useTheme();
|
||||
const [guide, setGuide] = useState<GuideDetailResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadGuide = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await apiFetch<GuideDetailResponse>(`/api/v1/guides/${id}`);
|
||||
setGuide(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to load guide:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
loadGuide();
|
||||
}, [loadGuide]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View
|
||||
className={`flex-1 items-center justify-center ${isDarkMode ? 'bg-gray-900' : 'bg-gray-100'}`}
|
||||
>
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!guide) {
|
||||
return (
|
||||
<View
|
||||
className={`flex-1 items-center justify-center ${isDarkMode ? 'bg-gray-900' : 'bg-gray-100'}`}
|
||||
>
|
||||
<Text className={isDarkMode ? 'text-gray-400' : 'text-gray-500'}>
|
||||
Führung nicht gefunden
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView className={`flex-1 ${isDarkMode ? 'bg-gray-900' : 'bg-gray-100'}`}>
|
||||
<View className="px-4 pb-8 pt-16">
|
||||
<Pressable onPress={() => router.back()}>
|
||||
<Text className="mb-4 text-primary">← Zurück</Text>
|
||||
</Pressable>
|
||||
|
||||
{/* Header */}
|
||||
<Text className={`text-2xl font-bold ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||
{guide.title}
|
||||
</Text>
|
||||
{guide.description && (
|
||||
<Text className={`mt-1 ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
{guide.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<View className={`mt-4 flex-row rounded-xl p-3 ${isDarkMode ? 'bg-gray-800' : 'bg-white'}`}>
|
||||
{guide.estimatedDurationMin && (
|
||||
<View className="flex-1 items-center">
|
||||
<Text className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
Dauer
|
||||
</Text>
|
||||
<Text className={`text-lg font-bold ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||
~{guide.estimatedDurationMin} Min.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{guide.distanceMeters && (
|
||||
<View className="flex-1 items-center">
|
||||
<Text className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
Distanz
|
||||
</Text>
|
||||
<Text className={`text-lg font-bold ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||
{guide.distanceMeters >= 1000
|
||||
? `${(guide.distanceMeters / 1000).toFixed(1)} km`
|
||||
: `${guide.distanceMeters}m`}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View className="flex-1 items-center">
|
||||
<Text className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
Stationen
|
||||
</Text>
|
||||
<Text className={`text-lg font-bold ${isDarkMode ? 'text-white' : 'text-gray-900'}`}>
|
||||
{guide.pois.length}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* POI Cards */}
|
||||
<View className="mt-6">
|
||||
{guide.pois.map((guidePoi, index) => (
|
||||
<View
|
||||
key={guidePoi.id}
|
||||
className={`mb-4 rounded-xl p-4 ${isDarkMode ? 'bg-gray-800' : 'bg-white'}`}
|
||||
style={{ shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 4, elevation: 2 }}
|
||||
>
|
||||
{/* Station header */}
|
||||
<View className="flex-row items-center">
|
||||
<View className="mr-3 h-8 w-8 items-center justify-center rounded-full bg-primary">
|
||||
<Text className="text-sm font-bold text-white">{index + 1}</Text>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text
|
||||
className={`text-lg font-semibold ${isDarkMode ? 'text-white' : 'text-gray-900'}`}
|
||||
>
|
||||
{POI_CATEGORY_ICONS[guidePoi.poi.category] || '📍'} {guidePoi.poi.name}
|
||||
</Text>
|
||||
<Text className={`text-xs ${isDarkMode ? 'text-gray-500' : 'text-gray-400'}`}>
|
||||
{guidePoi.poi.category.replace('_', ' ')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Narrative */}
|
||||
{guidePoi.aiNarrative && (
|
||||
<Text
|
||||
className={`mt-3 text-sm leading-5 ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}
|
||||
>
|
||||
{guidePoi.aiNarrative}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* POI AI Summary */}
|
||||
{guidePoi.poi.aiSummary && (
|
||||
<View
|
||||
className={`mt-3 rounded-lg p-3 ${isDarkMode ? 'bg-gray-700' : 'bg-gray-50'}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-xs font-medium ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
|
||||
>
|
||||
Hintergrund
|
||||
</Text>
|
||||
<Text
|
||||
className={`mt-1 text-xs leading-4 ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}
|
||||
numberOfLines={5}
|
||||
>
|
||||
{guidePoi.poi.aiSummary}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
172
apps/traces/apps/mobile/app/modal.tsx
Normal file
172
apps/traces/apps/mobile/app/modal.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import { Link } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { Platform, StyleSheet, Text, View, ScrollView, TouchableOpacity } from 'react-native';
|
||||
|
||||
import { ThemeWrapper } from '~/components/ThemeWrapper';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export default function Modal() {
|
||||
const { isDarkMode, colors } = useTheme();
|
||||
|
||||
return (
|
||||
<ThemeWrapper>
|
||||
<ScrollView
|
||||
style={[styles.container, isDarkMode && { backgroundColor: '#121212' }]}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<FontAwesome name="map-marker" size={48} color={colors.primary} style={styles.icon} />
|
||||
<Text style={[styles.title, isDarkMode && { color: '#FFFFFF' }]}>Standortverlauf</Text>
|
||||
<Text style={[styles.subtitle, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
Deine Bewegungen im Blick
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
Über diese App
|
||||
</Text>
|
||||
<Text style={[styles.paragraph, isDarkMode && { color: '#DDDDDD' }]}>
|
||||
Diese App ermöglicht es dir, deinen Standortverlauf aufzuzeichnen und auf einer Karte
|
||||
darzustellen. So kannst du nachvollziehen, wie du dich durch die Welt bewegst.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, isDarkMode && { color: '#FFFFFF' }]}>Funktionen</Text>
|
||||
<View style={styles.featureItem}>
|
||||
<FontAwesome name="map" size={24} color={colors.primary} style={styles.featureIcon} />
|
||||
<View style={styles.featureText}>
|
||||
<Text style={[styles.featureTitle, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
Echtzeit-Tracking
|
||||
</Text>
|
||||
<Text style={[styles.featureDescription, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
Verfolge deinen aktuellen Standort in Echtzeit auf der Karte.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.featureItem}>
|
||||
<FontAwesome
|
||||
name="history"
|
||||
size={24}
|
||||
color={colors.primary}
|
||||
style={styles.featureIcon}
|
||||
/>
|
||||
<View style={styles.featureText}>
|
||||
<Text style={[styles.featureTitle, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
Standortverlauf
|
||||
</Text>
|
||||
<Text style={[styles.featureDescription, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
Sieh dir deinen kompletten Bewegungsverlauf mit Details zu jedem Standort an.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.featureItem}>
|
||||
<FontAwesome name="lock" size={24} color={colors.primary} style={styles.featureIcon} />
|
||||
<View style={styles.featureText}>
|
||||
<Text style={[styles.featureTitle, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
Lokale Speicherung
|
||||
</Text>
|
||||
<Text style={[styles.featureDescription, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
Alle deine Daten werden nur lokal auf deinem Gerät gespeichert.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, isDarkMode && { color: '#FFFFFF' }]}>Datenschutz</Text>
|
||||
<Text style={[styles.paragraph, isDarkMode && { color: '#DDDDDD' }]}>
|
||||
Deine Standortdaten werden ausschließlich lokal auf deinem Gerät gespeichert und nicht
|
||||
an externe Server übertragen. Du behältst die volle Kontrolle über deine Daten.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Link href="/" asChild>
|
||||
<TouchableOpacity style={[styles.button, { backgroundColor: colors.primary }]}>
|
||||
<Text style={styles.buttonText}>Zurück zur App</Text>
|
||||
</TouchableOpacity>
|
||||
</Link>
|
||||
</ScrollView>
|
||||
<StatusBar style={isDarkMode ? 'light' : Platform.OS === 'ios' ? 'dark' : 'auto'} />
|
||||
</ThemeWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
contentContainer: {
|
||||
padding: 24,
|
||||
paddingBottom: 48,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
icon: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
marginTop: 4,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 12,
|
||||
},
|
||||
paragraph: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
color: '#444',
|
||||
},
|
||||
featureItem: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
featureIcon: {
|
||||
marginRight: 16,
|
||||
marginTop: 2,
|
||||
},
|
||||
featureText: {
|
||||
flex: 1,
|
||||
},
|
||||
featureTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 4,
|
||||
},
|
||||
featureDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
lineHeight: 20,
|
||||
},
|
||||
button: {
|
||||
// backgroundColor set dynamically via colors.primary
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
marginTop: 16,
|
||||
},
|
||||
buttonText: {
|
||||
color: 'white',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
149
apps/traces/apps/mobile/app/place-details.tsx
Normal file
149
apps/traces/apps/mobile/app/place-details.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { useLocalSearchParams, Stack, useRouter } from 'expo-router';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { StyleSheet, View, Text, ActivityIndicator } from 'react-native';
|
||||
|
||||
import { PlaceDetail } from '~/components/PlaceDetail';
|
||||
import { ThemeWrapper } from '~/components/ThemeWrapper';
|
||||
import { Place } from '~/utils/locationHelper';
|
||||
import { getSavedPlaces, savePlace, deletePlace } from '~/utils/placeService';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export default function PlaceDetailsScreen() {
|
||||
const { isDarkMode, colors } = useTheme();
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams();
|
||||
const { placeId, newPlace } = params;
|
||||
|
||||
const [place, setPlace] = useState<Place | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadPlace();
|
||||
}, [placeId]);
|
||||
|
||||
const loadPlace = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Wenn es ein neuer Ort ist (von der Places-Seite weitergeleitet)
|
||||
if (newPlace) {
|
||||
// Parse das Place-Objekt aus den Parametern
|
||||
const parsedPlace = JSON.parse(newPlace as string);
|
||||
|
||||
// Konvertiere das addresses-Array zurück zu einem Set
|
||||
const placeWithAddressSet: Place = {
|
||||
...parsedPlace,
|
||||
addresses: new Set(parsedPlace.addresses || []),
|
||||
};
|
||||
|
||||
setPlace(placeWithAddressSet);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Andernfalls lade den Ort aus dem Speicher
|
||||
if (placeId) {
|
||||
const places = await getSavedPlaces();
|
||||
const foundPlace = places.find((p) => p.id === placeId);
|
||||
|
||||
if (foundPlace) {
|
||||
setPlace(foundPlace);
|
||||
} else {
|
||||
// Ort nicht gefunden, zurück zur Orte-Seite
|
||||
console.error('Ort mit ID nicht gefunden:', placeId);
|
||||
router.back();
|
||||
}
|
||||
} else {
|
||||
// Keine ID angegeben, zurück zur Orte-Seite
|
||||
router.back();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des Ortes:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (updatedPlace: Place) => {
|
||||
try {
|
||||
const success = await savePlace(updatedPlace);
|
||||
|
||||
if (success) {
|
||||
// Zurück zur Orte-Seite
|
||||
router.back();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Speichern des Ortes:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (placeId: string) => {
|
||||
try {
|
||||
const success = await deletePlace(placeId);
|
||||
|
||||
if (success) {
|
||||
// Zurück zur Orte-Seite
|
||||
router.back();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen des Ortes:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeWrapper>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
title: place ? place.name : 'Ortdetails',
|
||||
headerTintColor: isDarkMode ? '#FFFFFF' : '#000000',
|
||||
}}
|
||||
/>
|
||||
|
||||
<View style={[styles.container, isDarkMode && { backgroundColor: '#121212' }]}>
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={[styles.loadingText, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
Lade Ortdetails...
|
||||
</Text>
|
||||
</View>
|
||||
) : place ? (
|
||||
<PlaceDetail place={place} onSave={handleSave} onDelete={handleDelete} />
|
||||
) : (
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={[styles.errorText, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
Dieser Ort konnte nicht geladen werden.
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ThemeWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 16,
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
746
apps/traces/apps/mobile/app/settings.tsx
Normal file
746
apps/traces/apps/mobile/app/settings.tsx
Normal file
|
|
@ -0,0 +1,746 @@
|
|||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { Link } from 'expo-router';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
Text,
|
||||
Switch,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
TouchableWithoutFeedback,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
|
||||
import { PhotoImportModal } from '~/components/PhotoImportModal';
|
||||
import { ThemeVariantPicker } from '~/components/ThemeVariantPicker';
|
||||
import { ThemeWrapper } from '~/components/ThemeWrapper';
|
||||
import { TRACKING_INTERVALS } from '~/components/TrackingControls';
|
||||
import {
|
||||
saveDefaultInterval,
|
||||
getDefaultInterval,
|
||||
SAVE_ADDRESS_KEY,
|
||||
getAccuracyLevel,
|
||||
saveAccuracyLevel,
|
||||
AccuracyLevel,
|
||||
accuracyDescriptions,
|
||||
clearLocationHistory,
|
||||
} from '~/utils/locationService';
|
||||
import { syncLocations, getLastSyncTimestamp } from '~/utils/syncService';
|
||||
import { getAuthToken } from '~/utils/apiClient';
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
// Keine Konstanten-Definition mehr notwendig, da sie aus locationService importiert werden
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const [saveAddressEnabled, setSaveAddressEnabled] = useState(true);
|
||||
const [defaultInterval, setDefaultInterval] = useState<number | null>(null);
|
||||
const [showIntervalModal, setShowIntervalModal] = useState(false);
|
||||
const [showAccuracyModal, setShowAccuracyModal] = useState(false);
|
||||
const [showPhotoImportModal, setShowPhotoImportModal] = useState(false);
|
||||
const [accuracyLevel, setAccuracyLevel] = useState<AccuracyLevel>(AccuracyLevel.Balanced);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [lastSyncText, setLastSyncText] = useState<string>('Noch nie');
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
|
||||
// Lade die Einstellungen beim Start
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
// Lade die Einstellungen aus dem AsyncStorage
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const savedAddressValue = await AsyncStorage.getItem(SAVE_ADDRESS_KEY);
|
||||
// Wenn kein Wert gespeichert ist, verwende true als Standard (opt-in)
|
||||
setSaveAddressEnabled(savedAddressValue === null ? true : savedAddressValue === 'true');
|
||||
|
||||
// Lade den Standard-Intervall
|
||||
const interval = await getDefaultInterval();
|
||||
setDefaultInterval(interval);
|
||||
|
||||
// Lade die Genauigkeitseinstellung
|
||||
const accuracy = await getAccuracyLevel();
|
||||
setAccuracyLevel(accuracy);
|
||||
|
||||
// Lade Sync-Status
|
||||
const token = await getAuthToken();
|
||||
setIsLoggedIn(!!token);
|
||||
const lastSync = await getLastSyncTimestamp();
|
||||
if (lastSync) {
|
||||
const date = new Date(lastSync);
|
||||
setLastSyncText(
|
||||
date.toLocaleDateString('de-DE') +
|
||||
' ' +
|
||||
date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Einstellungen:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Speichere die Einstellungen im AsyncStorage
|
||||
const saveSetting = async (key: string, value: boolean) => {
|
||||
try {
|
||||
await AsyncStorage.setItem(key, value.toString());
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Speichern der Einstellungen:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle-Handler für die Adress-Speicherung
|
||||
const toggleSaveAddress = () => {
|
||||
const newValue = !saveAddressEnabled;
|
||||
setSaveAddressEnabled(newValue);
|
||||
saveSetting(SAVE_ADDRESS_KEY, newValue);
|
||||
};
|
||||
|
||||
// Handler für die Auswahl des Standardintervalls
|
||||
const handleSelectInterval = async (interval: number | null) => {
|
||||
setDefaultInterval(interval);
|
||||
setShowIntervalModal(false);
|
||||
|
||||
// Speichern des Intervalls
|
||||
await saveDefaultInterval(interval);
|
||||
};
|
||||
|
||||
// Handler für die Auswahl der Genauigkeit
|
||||
const handleSelectAccuracy = async (level: AccuracyLevel) => {
|
||||
setAccuracyLevel(level);
|
||||
setShowAccuracyModal(false);
|
||||
|
||||
// Speichern der Genauigkeitseinstellung
|
||||
await saveAccuracyLevel(level);
|
||||
};
|
||||
|
||||
// Formatiere den Intervalltext
|
||||
const getIntervalText = () => {
|
||||
if (defaultInterval === null) {
|
||||
return 'Nicht festgelegt';
|
||||
}
|
||||
const selectedInterval = TRACKING_INTERVALS.find(
|
||||
(interval) => interval.value === defaultInterval
|
||||
);
|
||||
return selectedInterval ? selectedInterval.label : 'Nicht festgelegt';
|
||||
};
|
||||
|
||||
// Hole die Beschreibung für die aktuelle Genauigkeitsstufe
|
||||
const getAccuracyText = () => {
|
||||
return accuracyDescriptions[accuracyLevel] || 'Mittel (Standard)';
|
||||
};
|
||||
|
||||
// Handler für das Löschen des Verlaufs
|
||||
const handleClearHistory = async () => {
|
||||
Alert.alert(
|
||||
'Verlauf löschen',
|
||||
'Möchtest du wirklich deinen gesamten Standortverlauf löschen?',
|
||||
[
|
||||
{ text: 'Abbrechen', style: 'cancel' },
|
||||
{
|
||||
text: 'Löschen',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await clearLocationHistory();
|
||||
Alert.alert('Erfolg', 'Standortverlauf wurde gelöscht.');
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const { isDarkMode, toggleTheme, themeVariant, setThemeVariant, colors } = useTheme();
|
||||
|
||||
// Dynamic styles based on theme
|
||||
const getThemeStyles = () => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: isDarkMode ? '#121212' : '#f5f5f5',
|
||||
},
|
||||
section: {
|
||||
backgroundColor: isDarkMode ? '#1E1E1E' : 'white',
|
||||
shadowColor: isDarkMode ? '#000' : '#000',
|
||||
shadowOpacity: isDarkMode ? 0.3 : 0.1,
|
||||
},
|
||||
sectionTitle: {
|
||||
color: isDarkMode ? '#E0E0E0' : '#333',
|
||||
},
|
||||
settingTitle: {
|
||||
color: isDarkMode ? '#E0E0E0' : '#333',
|
||||
},
|
||||
settingDescription: {
|
||||
color: isDarkMode ? '#A0A0A0' : '#666',
|
||||
},
|
||||
infoText: {
|
||||
color: isDarkMode ? '#A0A0A0' : '#666',
|
||||
},
|
||||
valueContainer: {
|
||||
backgroundColor: isDarkMode ? '#2A2A2A' : '#f0f0f0',
|
||||
},
|
||||
valueText: {
|
||||
color: isDarkMode ? '#E0E0E0' : '#333',
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: isDarkMode ? '#1E1E1E' : 'white',
|
||||
},
|
||||
modalTitle: {
|
||||
color: isDarkMode ? '#E0E0E0' : '#333',
|
||||
},
|
||||
modalDescription: {
|
||||
color: isDarkMode ? '#A0A0A0' : '#666',
|
||||
},
|
||||
intervalOption: {
|
||||
backgroundColor: isDarkMode ? '#2A2A2A' : '#f5f5f5',
|
||||
},
|
||||
selectedInterval: {
|
||||
backgroundColor: isDarkMode ? '#3A3A7A' : '#e0f0ff',
|
||||
borderColor: isDarkMode ? '#6366f1' : '#4630EB',
|
||||
},
|
||||
intervalLabel: {
|
||||
color: isDarkMode ? '#E0E0E0' : '#333',
|
||||
},
|
||||
intervalDescription: {
|
||||
color: isDarkMode ? '#A0A0A0' : '#666',
|
||||
},
|
||||
});
|
||||
|
||||
const themeStyles = getThemeStyles();
|
||||
|
||||
return (
|
||||
<ThemeWrapper>
|
||||
<SafeAreaView style={[styles.container, themeStyles.container]}>
|
||||
<ScrollView>
|
||||
<View style={[styles.section, themeStyles.section]}>
|
||||
<Text style={[styles.sectionTitle, themeStyles.sectionTitle]}>Erscheinungsbild</Text>
|
||||
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.settingTextContainer}>
|
||||
<Text style={[styles.settingTitle, themeStyles.settingTitle]}>Dark Mode</Text>
|
||||
<Text style={[styles.settingDescription, themeStyles.settingDescription]}>
|
||||
Wechsle zwischen hellem und dunklem Erscheinungsbild.
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity onPress={toggleTheme} style={styles.themeToggle}>
|
||||
<FontAwesome
|
||||
name={isDarkMode ? 'moon-o' : 'sun-o'}
|
||||
size={24}
|
||||
color={isDarkMode ? '#FFFFFF' : colors.primary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.section, themeStyles.section]}>
|
||||
<Text style={[styles.sectionTitle, themeStyles.sectionTitle]}>Farbschema</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.settingDescription,
|
||||
themeStyles.settingDescription,
|
||||
{ marginBottom: 16 },
|
||||
]}
|
||||
>
|
||||
Wähle ein Farbschema für die App.
|
||||
</Text>
|
||||
<ThemeVariantPicker
|
||||
selectedVariant={themeVariant}
|
||||
onChange={setThemeVariant}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[styles.section, themeStyles.section]}>
|
||||
<Text style={[styles.sectionTitle, themeStyles.sectionTitle]}>Datenschutz</Text>
|
||||
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.settingTextContainer}>
|
||||
<Text style={[styles.settingTitle, themeStyles.settingTitle]}>
|
||||
Adressen speichern
|
||||
</Text>
|
||||
<Text style={[styles.settingDescription, themeStyles.settingDescription]}>
|
||||
Wenn aktiviert, werden zu jedem Standort die entsprechenden Adressinformationen
|
||||
(Stadt, Straße, Hausnummer) gespeichert.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={saveAddressEnabled}
|
||||
onValueChange={toggleSaveAddress}
|
||||
trackColor={{ false: '#767577', true: isDarkMode ? '#6366f1' : '#81b0ff' }}
|
||||
thumbColor={
|
||||
saveAddressEnabled
|
||||
? isDarkMode
|
||||
? '#8F90FB'
|
||||
: '#4630EB'
|
||||
: isDarkMode
|
||||
? '#d0d0d0'
|
||||
: '#f4f3f4'
|
||||
}
|
||||
ios_backgroundColor={isDarkMode ? '#3A3A3A' : '#eaeaea'}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.section, themeStyles.section]}>
|
||||
<Text style={[styles.sectionTitle, themeStyles.sectionTitle]}>
|
||||
Tracking-Einstellungen
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity style={styles.settingItem} onPress={() => setShowIntervalModal(true)}>
|
||||
<View style={styles.settingTextContainer}>
|
||||
<Text style={[styles.settingTitle, themeStyles.settingTitle]}>
|
||||
Standard-Intervall
|
||||
</Text>
|
||||
<Text style={[styles.settingDescription, themeStyles.settingDescription]}>
|
||||
Wenn festgelegt, wird dieser Intervall automatisch verwendet, ohne dass ein Modal
|
||||
angezeigt wird.
|
||||
</Text>
|
||||
<View style={styles.currentSettingContainer}>
|
||||
<FontAwesome
|
||||
name="clock-o"
|
||||
size={14}
|
||||
color={colors.primary}
|
||||
style={styles.settingIcon}
|
||||
/>
|
||||
<Text style={[styles.currentSettingText, { color: colors.primary }]}>
|
||||
{getIntervalText()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<FontAwesome name="chevron-right" size={16} color={isDarkMode ? '#666666' : '#999'} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.settingItem} onPress={() => setShowAccuracyModal(true)}>
|
||||
<View style={styles.settingTextContainer}>
|
||||
<Text style={[styles.settingTitle, themeStyles.settingTitle]}>Genauigkeit</Text>
|
||||
<Text style={[styles.settingDescription, themeStyles.settingDescription]}>
|
||||
Bestimmt die Präzision der Standortermittlung. Höhere Genauigkeit verbraucht mehr
|
||||
Akku.
|
||||
</Text>
|
||||
<View style={styles.currentSettingContainer}>
|
||||
<FontAwesome
|
||||
name="crosshairs"
|
||||
size={14}
|
||||
color={colors.primary}
|
||||
style={styles.settingIcon}
|
||||
/>
|
||||
<Text style={[styles.currentSettingText, { color: colors.primary }]}>
|
||||
{getAccuracyText()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<FontAwesome name="chevron-right" size={16} color={isDarkMode ? '#666666' : '#999'} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={[styles.section, themeStyles.section]}>
|
||||
<Text style={[styles.sectionTitle, themeStyles.sectionTitle]}>Synchronisierung</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.settingItem}
|
||||
onPress={async () => {
|
||||
if (isSyncing) return;
|
||||
setIsSyncing(true);
|
||||
try {
|
||||
const result = await syncLocations();
|
||||
if (result) {
|
||||
Alert.alert(
|
||||
'Sync abgeschlossen',
|
||||
`${result.synced} Standorte synchronisiert${result.duplicates > 0 ? ` (${result.duplicates} bereits vorhanden)` : ''}.`
|
||||
);
|
||||
const lastSync = await getLastSyncTimestamp();
|
||||
if (lastSync) {
|
||||
const date = new Date(lastSync);
|
||||
setLastSyncText(
|
||||
date.toLocaleDateString('de-DE') +
|
||||
' ' +
|
||||
date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||
);
|
||||
}
|
||||
} else {
|
||||
Alert.alert('Hinweis', 'Sync nicht möglich. Bitte anmelden.');
|
||||
}
|
||||
} catch (error: any) {
|
||||
Alert.alert('Fehler', error.message || 'Sync fehlgeschlagen.');
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
}}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
<View style={styles.settingTextContainer}>
|
||||
<Text style={[styles.settingTitle, { color: colors.primary }]}>
|
||||
{isSyncing ? 'Synchronisiere...' : 'Jetzt synchronisieren'}
|
||||
</Text>
|
||||
<Text style={[styles.settingDescription, themeStyles.settingDescription]}>
|
||||
Standortdaten mit dem Server synchronisieren.{'\n'}
|
||||
Letzter Sync: {lastSyncText}
|
||||
</Text>
|
||||
{!isLoggedIn && (
|
||||
<Text
|
||||
style={[
|
||||
styles.settingDescription,
|
||||
{ color: isDarkMode ? '#FF6B6B' : '#F44336', marginTop: 4 },
|
||||
]}
|
||||
>
|
||||
Nicht angemeldet - Sync nicht verfügbar
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<FontAwesome name="refresh" size={20} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={[styles.section, themeStyles.section]}>
|
||||
<Text style={[styles.sectionTitle, themeStyles.sectionTitle]}>Daten</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.settingItem}
|
||||
onPress={() => setShowPhotoImportModal(true)}
|
||||
>
|
||||
<View style={styles.settingTextContainer}>
|
||||
<Text style={[styles.settingTitle, { color: colors.primary }]}>
|
||||
📸 Fotos importieren
|
||||
</Text>
|
||||
<Text style={[styles.settingDescription, themeStyles.settingDescription]}>
|
||||
GPS-Daten aus Fotos extrahieren und als Location-Einträge importieren.
|
||||
</Text>
|
||||
</View>
|
||||
<FontAwesome name="camera" size={20} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.settingItem} onPress={handleClearHistory}>
|
||||
<View style={styles.settingTextContainer}>
|
||||
<Text style={[styles.settingTitle, { color: isDarkMode ? '#FF6B6B' : '#F44336' }]}>
|
||||
Verlauf löschen
|
||||
</Text>
|
||||
<Text style={[styles.settingDescription, themeStyles.settingDescription]}>
|
||||
Löscht alle aufgezeichneten Standortdaten unwiderruflich.
|
||||
</Text>
|
||||
</View>
|
||||
<FontAwesome name="trash-o" size={20} color={isDarkMode ? '#FF6B6B' : '#F44336'} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={[styles.section, themeStyles.section]}>
|
||||
<Text style={[styles.sectionTitle, themeStyles.sectionTitle]}>Erweitert</Text>
|
||||
|
||||
<Link href="/logs" asChild>
|
||||
<TouchableOpacity style={styles.settingItem}>
|
||||
<View style={styles.settingTextContainer}>
|
||||
<Text style={[styles.settingTitle, themeStyles.settingTitle]}>Logs anzeigen</Text>
|
||||
<Text style={[styles.settingDescription, themeStyles.settingDescription]}>
|
||||
Zeige App-Logs und Debug-Informationen an.
|
||||
</Text>
|
||||
</View>
|
||||
<FontAwesome
|
||||
name="chevron-right"
|
||||
size={16}
|
||||
color={isDarkMode ? '#666666' : '#999'}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</Link>
|
||||
</View>
|
||||
|
||||
<View style={[styles.section, themeStyles.section]}>
|
||||
<Text style={[styles.sectionTitle, themeStyles.sectionTitle]}>Über die App</Text>
|
||||
<View style={styles.infoContainer}>
|
||||
<Text style={[styles.infoText, themeStyles.infoText]}>
|
||||
Diese App zeichnet deinen Standortverlauf auf und ermöglicht es dir, deine
|
||||
Bewegungen nachzuverfolgen.
|
||||
</Text>
|
||||
<Text style={[styles.infoText, themeStyles.infoText]}>Version 1.0.0</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
<StatusBar style={isDarkMode ? 'light' : 'dark'} />
|
||||
|
||||
{/* Photo Import Modal */}
|
||||
<PhotoImportModal
|
||||
visible={showPhotoImportModal}
|
||||
onClose={() => setShowPhotoImportModal(false)}
|
||||
onImportComplete={(count) => {
|
||||
console.log(`${count} Fotos importiert`);
|
||||
// Optional: Reload location history or trigger update
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Modal für die Intervallauswahl */}
|
||||
<Modal
|
||||
visible={showIntervalModal}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowIntervalModal(false)}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={() => setShowIntervalModal(false)}>
|
||||
<View style={styles.modalOverlay}>
|
||||
<TouchableWithoutFeedback>
|
||||
<View style={[styles.modalContent, themeStyles.modalContent]}>
|
||||
<Text style={[styles.modalTitle, themeStyles.modalTitle]}>
|
||||
Standard-Intervall wählen
|
||||
</Text>
|
||||
<Text style={[styles.modalDescription, themeStyles.modalDescription]}>
|
||||
Wähle einen Intervall, der standardmäßig verwendet werden soll:
|
||||
</Text>
|
||||
|
||||
{TRACKING_INTERVALS.map((interval) => (
|
||||
<TouchableOpacity
|
||||
key={interval.value}
|
||||
style={[
|
||||
styles.intervalOption,
|
||||
themeStyles.intervalOption,
|
||||
defaultInterval === interval.value && styles.selectedInterval,
|
||||
defaultInterval === interval.value && themeStyles.selectedInterval,
|
||||
]}
|
||||
onPress={() => handleSelectInterval(interval.value)}
|
||||
>
|
||||
<View>
|
||||
<Text style={[styles.intervalLabel, themeStyles.intervalLabel]}>
|
||||
{interval.label}
|
||||
</Text>
|
||||
<Text style={[styles.intervalDescription, themeStyles.intervalDescription]}>
|
||||
{interval.description}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.intervalOption,
|
||||
themeStyles.intervalOption,
|
||||
defaultInterval === null && styles.selectedInterval,
|
||||
defaultInterval === null && themeStyles.selectedInterval,
|
||||
]}
|
||||
onPress={() => handleSelectInterval(null)}
|
||||
>
|
||||
<View>
|
||||
<Text style={[styles.intervalLabel, themeStyles.intervalLabel]}>
|
||||
Immer nachfragen
|
||||
</Text>
|
||||
<Text style={[styles.intervalDescription, themeStyles.intervalDescription]}>
|
||||
Zeige immer das Auswahl-Modal an
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.closeButton,
|
||||
{ backgroundColor: isDarkMode ? '#6366f1' : '#4630EB' },
|
||||
]}
|
||||
onPress={() => setShowIntervalModal(false)}
|
||||
>
|
||||
<Text style={styles.closeButtonText}>Schließen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</Modal>
|
||||
|
||||
{/* Modal für die Genauigkeitsauswahl */}
|
||||
<Modal
|
||||
visible={showAccuracyModal}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowAccuracyModal(false)}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={() => setShowAccuracyModal(false)}>
|
||||
<View style={styles.modalOverlay}>
|
||||
<TouchableWithoutFeedback>
|
||||
<View style={[styles.modalContent, themeStyles.modalContent]}>
|
||||
<Text style={[styles.modalTitle, themeStyles.modalTitle]}>
|
||||
Genauigkeit wählen
|
||||
</Text>
|
||||
<Text style={[styles.modalDescription, themeStyles.modalDescription]}>
|
||||
Wähle die gewünschte Genauigkeit für die Standortermittlung:
|
||||
</Text>
|
||||
|
||||
{Object.values(AccuracyLevel).map((level) => (
|
||||
<TouchableOpacity
|
||||
key={level}
|
||||
style={[
|
||||
styles.intervalOption,
|
||||
themeStyles.intervalOption,
|
||||
accuracyLevel === level && styles.selectedInterval,
|
||||
accuracyLevel === level && themeStyles.selectedInterval,
|
||||
]}
|
||||
onPress={() => handleSelectAccuracy(level as AccuracyLevel)}
|
||||
>
|
||||
<View>
|
||||
<Text style={[styles.intervalLabel, themeStyles.intervalLabel]}>
|
||||
{level === AccuracyLevel.Balanced && '✓ '}
|
||||
{level.charAt(0).toUpperCase() + level.slice(1)}
|
||||
</Text>
|
||||
<Text style={[styles.intervalDescription, themeStyles.intervalDescription]}>
|
||||
{accuracyDescriptions[level]}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.closeButton,
|
||||
{ backgroundColor: isDarkMode ? '#6366f1' : '#4630EB' },
|
||||
]}
|
||||
onPress={() => setShowAccuracyModal(false)}
|
||||
>
|
||||
<Text style={styles.closeButtonText}>Schließen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</Modal>
|
||||
</SafeAreaView>
|
||||
</ThemeWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
section: {
|
||||
marginVertical: 10,
|
||||
marginHorizontal: 16,
|
||||
borderRadius: 10,
|
||||
padding: 16,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
themeToggle: {
|
||||
padding: 8,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 16,
|
||||
color: '#333',
|
||||
},
|
||||
settingItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 12,
|
||||
},
|
||||
settingTextContainer: {
|
||||
flex: 1,
|
||||
marginRight: 16,
|
||||
},
|
||||
settingTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
color: '#333',
|
||||
},
|
||||
settingDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
lineHeight: 20,
|
||||
marginBottom: 8,
|
||||
},
|
||||
currentSettingContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 2,
|
||||
},
|
||||
settingIcon: {
|
||||
marginRight: 6,
|
||||
},
|
||||
currentSettingText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
},
|
||||
infoContainer: {
|
||||
paddingVertical: 8,
|
||||
},
|
||||
infoText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 8,
|
||||
lineHeight: 20,
|
||||
},
|
||||
valueContainer: {
|
||||
backgroundColor: '#f0f0f0',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 6,
|
||||
minWidth: 100,
|
||||
alignItems: 'center',
|
||||
},
|
||||
valueText: {
|
||||
fontSize: 14,
|
||||
color: '#333',
|
||||
fontWeight: '500',
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
color: '#333',
|
||||
},
|
||||
modalDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 16,
|
||||
},
|
||||
intervalOption: {
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
marginBottom: 8,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
selectedInterval: {
|
||||
backgroundColor: '#e0f0ff',
|
||||
borderColor: '#4630EB',
|
||||
borderWidth: 1,
|
||||
},
|
||||
intervalLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: '#333',
|
||||
marginBottom: 4,
|
||||
},
|
||||
intervalDescription: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
closeButton: {
|
||||
marginTop: 16,
|
||||
paddingVertical: 12,
|
||||
backgroundColor: '#4630EB',
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
closeButtonText: {
|
||||
color: 'white',
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
BIN
apps/traces/apps/mobile/assets/adaptive-icon.png
Normal file
BIN
apps/traces/apps/mobile/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/traces/apps/mobile/assets/favicon.png
Normal file
BIN
apps/traces/apps/mobile/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/traces/apps/mobile/assets/icon.png
Normal file
BIN
apps/traces/apps/mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
apps/traces/apps/mobile/assets/splash.png
Normal file
BIN
apps/traces/apps/mobile/assets/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M535.961 809.868C525.881 829.274 498.119 829.274 488.039 809.868L330.704 506.945C321.368 488.971 334.411 467.5 354.664 467.5L669.336 467.5C689.589 467.5 702.632 488.971 693.296 506.945L535.961 809.868Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 338 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="511.815" cy="391.815" r="224.815" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 172 B |
40
apps/traces/apps/mobile/assets/traces.icon/icon.json
Normal file
40
apps/traces/apps/mobile/assets/traces.icon/icon.json
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"fill": "system-dark",
|
||||
"groups": [
|
||||
{
|
||||
"layers": [
|
||||
{
|
||||
"fill": {
|
||||
"solid": "extended-srgb:0.20392,0.78039,0.34902,1.00000"
|
||||
},
|
||||
"image-name": "Traces App Icon 01.3.svg",
|
||||
"name": "Traces App Icon 01.3"
|
||||
},
|
||||
{
|
||||
"fill": {
|
||||
"solid": "srgb:0.20392,0.78039,0.34902,0.66500"
|
||||
},
|
||||
"image-name": "Traces App Icon 01.3-1.svg",
|
||||
"name": "Traces App Icon 01.3-1"
|
||||
}
|
||||
],
|
||||
"name": "Group",
|
||||
"position": {
|
||||
"scale": 1.08,
|
||||
"translation-in-points": [0, 20]
|
||||
},
|
||||
"shadow": {
|
||||
"kind": "neutral",
|
||||
"opacity": 0.5
|
||||
},
|
||||
"translucency": {
|
||||
"enabled": true,
|
||||
"value": 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms": {
|
||||
"circles": ["watchOS"],
|
||||
"squares": "shared"
|
||||
}
|
||||
}
|
||||
10
apps/traces/apps/mobile/babel.config.js
Normal file
10
apps/traces/apps/mobile/babel.config.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
const plugins = [];
|
||||
|
||||
return {
|
||||
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
|
||||
|
||||
plugins,
|
||||
};
|
||||
};
|
||||
40
apps/traces/apps/mobile/cesconfig.json
Normal file
40
apps/traces/apps/mobile/cesconfig.json
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"cesVersion": "2.14.1",
|
||||
"projectName": "locations",
|
||||
"packages": [
|
||||
{
|
||||
"name": "expo-router",
|
||||
"type": "navigation",
|
||||
"options": {
|
||||
"type": "tabs"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "nativewind",
|
||||
"type": "styling"
|
||||
},
|
||||
{
|
||||
"name": "supabase",
|
||||
"type": "authentication"
|
||||
}
|
||||
],
|
||||
"flags": {
|
||||
"noGit": false,
|
||||
"noInstall": false,
|
||||
"overwrite": false,
|
||||
"importAlias": true,
|
||||
"packageManager": "npm",
|
||||
"eas": true,
|
||||
"publish": false
|
||||
},
|
||||
"packageManager": {
|
||||
"type": "npm",
|
||||
"version": "10.7.0"
|
||||
},
|
||||
"os": {
|
||||
"type": "Darwin",
|
||||
"platform": "darwin",
|
||||
"arch": "arm64",
|
||||
"kernelVersion": "24.1.0"
|
||||
}
|
||||
}
|
||||
39
apps/traces/apps/mobile/components/Button.tsx
Normal file
39
apps/traces/apps/mobile/components/Button.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { forwardRef } from 'react';
|
||||
import { Text, TouchableOpacity, TouchableOpacityProps, View } from 'react-native';
|
||||
|
||||
import { useTheme } from '../utils/themeContext';
|
||||
|
||||
type ButtonProps = {
|
||||
title: string;
|
||||
variant?: 'primary' | 'secondary';
|
||||
} & TouchableOpacityProps;
|
||||
|
||||
export const Button = forwardRef<View, ButtonProps>(
|
||||
({ title, variant = 'primary', ...touchableProps }, ref) => {
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
// Dynamic button styles based on variant and theme
|
||||
const getButtonStyle = () => {
|
||||
if (variant === 'primary') {
|
||||
return 'bg-primary';
|
||||
} else {
|
||||
return 'bg-secondary';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
ref={ref}
|
||||
{...touchableProps}
|
||||
className={`${styles.button} ${getButtonStyle()} ${isDarkMode ? 'shadow-lg shadow-black/50' : 'shadow-md'} ${touchableProps.className}`}
|
||||
>
|
||||
<Text className={styles.buttonText}>{title}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const styles = {
|
||||
button: 'items-center rounded-[28px] p-4',
|
||||
buttonText: 'text-white text-lg font-semibold text-center',
|
||||
};
|
||||
208
apps/traces/apps/mobile/components/CitiesList.tsx
Normal file
208
apps/traces/apps/mobile/components/CitiesList.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import React from 'react';
|
||||
import { StyleSheet, View, Text, FlatList, TouchableOpacity } from 'react-native';
|
||||
|
||||
import { formatDuration } from '../utils/locationHelper';
|
||||
import { CityVisit } from '../utils/placeService';
|
||||
import { useTheme } from '../utils/themeContext';
|
||||
|
||||
interface CitiesListProps {
|
||||
cities: CityVisit[];
|
||||
onCityPress?: (city: CityVisit) => void;
|
||||
isDarkMode?: boolean;
|
||||
}
|
||||
|
||||
export const CitiesList: React.FC<CitiesListProps> = ({
|
||||
cities,
|
||||
onCityPress,
|
||||
isDarkMode = false,
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
const formatDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const renderItem = ({ item, index }: { item: CityVisit; index: number }) => {
|
||||
const formattedDuration = formatDuration(item.totalDuration);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.item,
|
||||
isDarkMode && {
|
||||
backgroundColor: '#1E1E1E',
|
||||
shadowColor: '#000000',
|
||||
shadowOpacity: 0.3,
|
||||
},
|
||||
]}
|
||||
onPress={() => onCityPress && onCityPress(item)}
|
||||
>
|
||||
<View style={[styles.indexContainer, isDarkMode && { backgroundColor: '#333333' }]}>
|
||||
<FontAwesome name="building" size={18} color={colors.primary} style={styles.iconStyle} />
|
||||
</View>
|
||||
|
||||
<View style={styles.contentContainer}>
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={[styles.cityName, isDarkMode && { color: '#FFFFFF' }]}>{item.city}</Text>
|
||||
<View style={[styles.countBadge, { backgroundColor: colors.primary }]}>
|
||||
<Text style={styles.countText}>{item.visitCount}x</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.locationCount, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
{item.locations.length} {item.locations.length === 1 ? 'Ort' : 'Orte'} besucht
|
||||
</Text>
|
||||
|
||||
<View style={styles.coordinatesRow}>
|
||||
<View style={styles.coordinateItem}>
|
||||
<Text style={[styles.coordinateLabel, isDarkMode && { color: '#888888' }]}>
|
||||
Gesamtdauer
|
||||
</Text>
|
||||
<Text style={[styles.coordinateValue, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
{formattedDuration}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.coordinateItem}>
|
||||
<Text style={[styles.coordinateLabel, isDarkMode && { color: '#888888' }]}>
|
||||
Letzter Besuch
|
||||
</Text>
|
||||
<Text style={[styles.coordinateValue, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
{formatDate(item.lastVisit)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.arrowContainer}>
|
||||
<FontAwesome name="chevron-right" size={16} color={isDarkMode ? '#666666' : '#ccc'} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={cities}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item, index) => `city-${item.city}-${index}`}
|
||||
contentContainerStyle={[styles.listContent, isDarkMode && { backgroundColor: '#121212' }]}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<FontAwesome name="building" size={48} color={isDarkMode ? '#444444' : '#ccc'} />
|
||||
<Text style={[styles.emptyText, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
Keine Städte gefunden
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtext, isDarkMode && { color: '#777777' }]}>
|
||||
Besuche verschiedene Städte, um sie hier anzuzeigen
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listContent: {
|
||||
padding: 16,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
item: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
padding: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
indexContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#f0f0f0',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
iconStyle: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
cityName: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
locationCount: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 8,
|
||||
},
|
||||
countBadge: {
|
||||
// backgroundColor set dynamically via colors.primary
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 12,
|
||||
minWidth: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
countText: {
|
||||
color: 'white',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
coordinatesRow: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 8,
|
||||
},
|
||||
coordinateItem: {
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
coordinateLabel: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginBottom: 2,
|
||||
},
|
||||
coordinateValue: {
|
||||
fontSize: 14,
|
||||
},
|
||||
arrowContainer: {
|
||||
justifyContent: 'center',
|
||||
paddingLeft: 8,
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 48,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#666',
|
||||
marginTop: 16,
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
textAlign: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
491
apps/traces/apps/mobile/components/ConsolidatedLocationList.tsx
Normal file
491
apps/traces/apps/mobile/components/ConsolidatedLocationList.tsx
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import React from 'react';
|
||||
import { StyleSheet, View, Text, FlatList, Alert, Pressable } from 'react-native';
|
||||
|
||||
import { ConsolidatedLocation, formatDuration } from '../utils/locationHelper';
|
||||
import { deleteLocationEntry } from '../utils/locationService';
|
||||
import { useTheme } from '../utils/themeContext';
|
||||
|
||||
interface ConsolidatedLocationListProps {
|
||||
consolidatedLocations: ConsolidatedLocation[];
|
||||
onItemPress?: (location: ConsolidatedLocation) => void;
|
||||
onDelete?: () => void;
|
||||
isDarkMode?: boolean;
|
||||
}
|
||||
|
||||
export const ConsolidatedLocationList: React.FC<ConsolidatedLocationListProps> = ({
|
||||
consolidatedLocations,
|
||||
onItemPress,
|
||||
onDelete,
|
||||
isDarkMode = false,
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
|
||||
const handleDelete = async (consolidatedLocation: ConsolidatedLocation, event: any) => {
|
||||
event.stopPropagation();
|
||||
const count = consolidatedLocation.originalLocations.length;
|
||||
Alert.alert(
|
||||
'Standorte löschen',
|
||||
`Möchtest du alle ${count} Standort${count > 1 ? 'e' : ''} dieser Gruppe wirklich löschen?`,
|
||||
[
|
||||
{
|
||||
text: 'Abbrechen',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Löschen',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
// Lösche alle Locations in dieser konsolidierten Gruppe
|
||||
for (const location of consolidatedLocation.originalLocations) {
|
||||
await deleteLocationEntry(location.id);
|
||||
}
|
||||
onDelete?.();
|
||||
} catch (error) {
|
||||
Alert.alert('Fehler', 'Die Einträge konnten nicht gelöscht werden.');
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
const formatDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// Eine nützliche Funktion zum Abrufen der primären Adresse
|
||||
const getPrimaryAddress = (location: ConsolidatedLocation): string => {
|
||||
if (location.addresses.size === 0) {
|
||||
return 'Keine Adressinformationen';
|
||||
}
|
||||
|
||||
// Einfach die erste Adresse zurückgeben
|
||||
return Array.from(location.addresses)[0];
|
||||
};
|
||||
|
||||
const renderItem = ({ item, index }: { item: ConsolidatedLocation; index: number }) => {
|
||||
// Formatiere den Zeitraum
|
||||
const timeRange = `${formatTime(item.startTimestamp)} - ${formatTime(item.endTimestamp)}`;
|
||||
const formattedDuration = formatDuration(item.duration);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.itemContainer,
|
||||
isDarkMode && {
|
||||
backgroundColor: '#1E1E1E',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.item, pressed && { opacity: 0.7 }]}
|
||||
onPress={() => onItemPress && onItemPress(item)}
|
||||
>
|
||||
<View style={[styles.indexContainer, isDarkMode && { backgroundColor: '#333333' }]}>
|
||||
<Text style={[styles.index, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
{consolidatedLocations.length - index}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.contentContainer}>
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={[styles.date, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
{formatDate(item.startTimestamp)}
|
||||
</Text>
|
||||
<View style={[styles.countBadge, { backgroundColor: colors.primary }]}>
|
||||
<Text style={styles.countText}>{item.count}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.timeRange, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
{timeRange} ({formattedDuration})
|
||||
</Text>
|
||||
|
||||
<View style={styles.coordinatesRow}>
|
||||
<View style={styles.coordinateItem}>
|
||||
<Text style={[styles.coordinateLabel, isDarkMode && { color: '#888888' }]}>
|
||||
Breitengrad
|
||||
</Text>
|
||||
<Text style={[styles.coordinateValue, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
{item.latitude.toFixed(6)}°
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.coordinateItem}>
|
||||
<Text style={[styles.coordinateLabel, isDarkMode && { color: '#888888' }]}>
|
||||
Längengrad
|
||||
</Text>
|
||||
<Text style={[styles.coordinateValue, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
{item.longitude.toFixed(6)}°
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{item.accuracy !== undefined && (
|
||||
<View style={styles.detailsRow}>
|
||||
<Text style={[styles.detailText, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
Genauigkeit: ~{item.accuracy.toFixed(1)}m
|
||||
</Text>
|
||||
{item.altitude !== undefined && (
|
||||
<Text style={[styles.detailText, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
Höhe: ~{item.altitude.toFixed(1)}m
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Aggregierte Metadaten für konsolidierte Standorte */}
|
||||
{item.originalLocations && item.originalLocations.length > 0 && (
|
||||
<View style={[styles.metadataContainer, isDarkMode && { borderTopColor: '#333333' }]}>
|
||||
{(() => {
|
||||
// Sammle eindeutige Quellen und Verbindungstypen
|
||||
const sources = new Set<string>();
|
||||
const connections = new Set<string>();
|
||||
const accuracyLevels = new Set<string>();
|
||||
let hasBattery = false;
|
||||
|
||||
const motions = new Set<string>();
|
||||
|
||||
item.originalLocations.forEach((loc) => {
|
||||
if (loc.metadata?.source) sources.add(loc.metadata.source);
|
||||
if (loc.metadata?.connectionType) connections.add(loc.metadata.connectionType);
|
||||
if (loc.metadata?.deviceMotion && loc.metadata.deviceMotion !== 'unknown')
|
||||
motions.add(loc.metadata.deviceMotion);
|
||||
if (loc.quality?.accuracyLevel) accuracyLevels.add(loc.quality.accuracyLevel);
|
||||
if (loc.metadata?.batteryLevel) hasBattery = true;
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.metadataRow}>
|
||||
{sources.size > 0 && (
|
||||
<View
|
||||
style={[
|
||||
styles.metadataTag,
|
||||
styles.aggregatedTag,
|
||||
isDarkMode && { backgroundColor: '#2A2A2A' },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.metadataTagText, isDarkMode && { color: '#CCCCCC' }]}
|
||||
>
|
||||
📊{' '}
|
||||
{Array.from(sources)
|
||||
.map((s) =>
|
||||
s === 'foreground' ? 'VG' : s === 'background' ? 'BG' : 'M'
|
||||
)
|
||||
.join('+')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{motions.size > 0 && (
|
||||
<View
|
||||
style={[
|
||||
styles.metadataTag,
|
||||
styles.aggregatedTag,
|
||||
isDarkMode && { backgroundColor: '#2A2A2A' },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.metadataTagText, isDarkMode && { color: '#CCCCCC' }]}
|
||||
>
|
||||
🏃{' '}
|
||||
{Array.from(motions)
|
||||
.map((m) =>
|
||||
m === 'stationary'
|
||||
? 'Still'
|
||||
: m === 'walking'
|
||||
? 'Gehen'
|
||||
: m === 'driving'
|
||||
? 'Fahren'
|
||||
: m
|
||||
)
|
||||
.join('+')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{connections.size > 0 && (
|
||||
<View
|
||||
style={[
|
||||
styles.metadataTag,
|
||||
styles.aggregatedTag,
|
||||
isDarkMode && { backgroundColor: '#2A2A2A' },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.metadataTagText, isDarkMode && { color: '#CCCCCC' }]}
|
||||
>
|
||||
🌐{' '}
|
||||
{Array.from(connections)
|
||||
.map((c) =>
|
||||
c === 'wifi' ? 'WiFi' : c === 'cellular' ? 'Mobil' : 'Offline'
|
||||
)
|
||||
.join('+')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{accuracyLevels.size > 0 && (
|
||||
<View
|
||||
style={[
|
||||
styles.metadataTag,
|
||||
styles.aggregatedTag,
|
||||
isDarkMode && { backgroundColor: '#2A2A2A' },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.metadataTagText, isDarkMode && { color: '#CCCCCC' }]}
|
||||
>
|
||||
📍 {Array.from(accuracyLevels).join(', ')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{hasBattery && (
|
||||
<View
|
||||
style={[
|
||||
styles.metadataTag,
|
||||
styles.aggregatedTag,
|
||||
isDarkMode && { backgroundColor: '#2A2A2A' },
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[styles.metadataTagText, isDarkMode && { color: '#CCCCCC' }]}
|
||||
>
|
||||
🔋 Verfügbar
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Adressinformationen anzeigen */}
|
||||
{item.addresses.size > 0 && (
|
||||
<View style={[styles.addressContainer, isDarkMode && { borderTopColor: '#333333' }]}>
|
||||
<Text style={[styles.addressTitle, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
Adresse:
|
||||
</Text>
|
||||
<Text style={[styles.addressText, isDarkMode && { color: '#DDDDDD' }]}>
|
||||
{getPrimaryAddress(item)}
|
||||
</Text>
|
||||
{item.addresses.size > 1 && (
|
||||
<Text style={[styles.addressCount, isDarkMode && { color: '#888888' }]}>
|
||||
+{item.addresses.size - 1} weitere Adressen
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.deleteIconButton, pressed && { opacity: 0.5 }]}
|
||||
onPress={(e) => handleDelete(item, e)}
|
||||
>
|
||||
<FontAwesome name="trash-o" size={20} color={colors.error || '#F44336'} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={[...consolidatedLocations].reverse()}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item, index) => `consolidated-${item.startTimestamp}-${index}`}
|
||||
contentContainerStyle={[styles.listContent, isDarkMode && { backgroundColor: '#121212' }]}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<FontAwesome name="map-marker" size={48} color={isDarkMode ? '#444444' : '#ccc'} />
|
||||
<Text style={[styles.emptyText, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
Keine Standortdaten vorhanden
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtext, isDarkMode && { color: '#777777' }]}>
|
||||
Starte das Tracking, um deine Bewegungen aufzuzeichnen
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listContent: {
|
||||
padding: 16,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
addressContainer: {
|
||||
marginTop: 8,
|
||||
paddingTop: 8,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#eee',
|
||||
},
|
||||
addressTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#666',
|
||||
marginBottom: 4,
|
||||
},
|
||||
addressText: {
|
||||
fontSize: 14,
|
||||
color: '#444',
|
||||
marginBottom: 2,
|
||||
},
|
||||
addressCount: {
|
||||
fontSize: 12,
|
||||
color: '#888',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
itemContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
item: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
indexContainer: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#f0f0f0',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
index: {
|
||||
fontWeight: 'bold',
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
date: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
timeRange: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 8,
|
||||
},
|
||||
countBadge: {
|
||||
// backgroundColor set dynamically via colors.primary
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 12,
|
||||
minWidth: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
countText: {
|
||||
color: 'white',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
coordinatesRow: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 8,
|
||||
},
|
||||
coordinateItem: {
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
coordinateLabel: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginBottom: 2,
|
||||
},
|
||||
coordinateValue: {
|
||||
fontSize: 14,
|
||||
},
|
||||
detailsRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
detailText: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginRight: 12,
|
||||
},
|
||||
metadataContainer: {
|
||||
marginTop: 8,
|
||||
paddingTop: 8,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#f0f0f0',
|
||||
},
|
||||
metadataRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: 4,
|
||||
},
|
||||
metadataTag: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 12,
|
||||
marginRight: 6,
|
||||
marginBottom: 4,
|
||||
},
|
||||
aggregatedTag: {
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
metadataTagText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
arrowContainer: {
|
||||
justifyContent: 'center',
|
||||
paddingLeft: 8,
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 48,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#666',
|
||||
marginTop: 16,
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
textAlign: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
deleteIconButton: {
|
||||
padding: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
226
apps/traces/apps/mobile/components/CountriesList.tsx
Normal file
226
apps/traces/apps/mobile/components/CountriesList.tsx
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import React from 'react';
|
||||
import { StyleSheet, View, Text, FlatList, TouchableOpacity } from 'react-native';
|
||||
|
||||
import { formatDuration } from '../utils/locationHelper';
|
||||
import { CountryVisit } from '../utils/placeService';
|
||||
import { useTheme } from '../utils/themeContext';
|
||||
|
||||
interface CountriesListProps {
|
||||
countries: CountryVisit[];
|
||||
onCountryPress?: (country: CountryVisit) => void;
|
||||
isDarkMode?: boolean;
|
||||
}
|
||||
|
||||
export const CountriesList: React.FC<CountriesListProps> = ({
|
||||
countries,
|
||||
onCountryPress,
|
||||
isDarkMode = false,
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
const formatDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const getCountryFlag = (countryCode?: string): string => {
|
||||
if (!countryCode || countryCode.length !== 2) return '🌍';
|
||||
|
||||
// Convert country code to flag emoji
|
||||
const codePoints = countryCode
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map((char) => 127397 + char.charCodeAt(0));
|
||||
return String.fromCodePoint(...codePoints);
|
||||
};
|
||||
|
||||
const renderItem = ({ item, index }: { item: CountryVisit; index: number }) => {
|
||||
const formattedDuration = formatDuration(item.totalDuration);
|
||||
const flag = getCountryFlag(item.countryCode);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.item,
|
||||
isDarkMode && {
|
||||
backgroundColor: '#1E1E1E',
|
||||
shadowColor: '#000000',
|
||||
shadowOpacity: 0.3,
|
||||
},
|
||||
]}
|
||||
onPress={() => onCountryPress && onCountryPress(item)}
|
||||
>
|
||||
<View style={[styles.flagContainer, isDarkMode && { backgroundColor: '#333333' }]}>
|
||||
<Text style={styles.flagEmoji}>{flag}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.contentContainer}>
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={[styles.countryName, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
{item.country}
|
||||
</Text>
|
||||
<View style={[styles.countBadge, { backgroundColor: colors.primary }]}>
|
||||
<Text style={styles.countText}>{item.visitCount}x</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.citiesCount, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
{item.cities.size} {item.cities.size === 1 ? 'Stadt' : 'Städte'} besucht
|
||||
</Text>
|
||||
|
||||
<View style={styles.coordinatesRow}>
|
||||
<View style={styles.coordinateItem}>
|
||||
<Text style={[styles.coordinateLabel, isDarkMode && { color: '#888888' }]}>
|
||||
Gesamtdauer
|
||||
</Text>
|
||||
<Text style={[styles.coordinateValue, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
{formattedDuration}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.coordinateItem}>
|
||||
<Text style={[styles.coordinateLabel, isDarkMode && { color: '#888888' }]}>
|
||||
Letzter Besuch
|
||||
</Text>
|
||||
<Text style={[styles.coordinateValue, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
{formatDate(item.lastVisit)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.arrowContainer}>
|
||||
<FontAwesome name="chevron-right" size={16} color={isDarkMode ? '#666666' : '#ccc'} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={countries}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item, index) => `country-${item.country}-${index}`}
|
||||
contentContainerStyle={[styles.listContent, isDarkMode && { backgroundColor: '#121212' }]}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyFlag}>🌍</Text>
|
||||
<Text style={[styles.emptyText, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
Keine Länder gefunden
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtext, isDarkMode && { color: '#777777' }]}>
|
||||
Besuche verschiedene Länder, um sie hier anzuzeigen
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listContent: {
|
||||
padding: 16,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
item: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
padding: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
flagContainer: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: '#f0f0f0',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
flagEmoji: {
|
||||
fontSize: 24,
|
||||
textAlign: 'center',
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
countryName: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
citiesCount: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 8,
|
||||
},
|
||||
countBadge: {
|
||||
// backgroundColor set dynamically via colors.primary
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 12,
|
||||
minWidth: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
countText: {
|
||||
color: 'white',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
coordinatesRow: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 8,
|
||||
},
|
||||
coordinateItem: {
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
coordinateLabel: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginBottom: 2,
|
||||
},
|
||||
coordinateValue: {
|
||||
fontSize: 14,
|
||||
},
|
||||
arrowContainer: {
|
||||
justifyContent: 'center',
|
||||
paddingLeft: 8,
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 48,
|
||||
},
|
||||
emptyFlag: {
|
||||
fontSize: 48,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#666',
|
||||
marginTop: 16,
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
textAlign: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
35
apps/traces/apps/mobile/components/HeaderButton.tsx
Normal file
35
apps/traces/apps/mobile/components/HeaderButton.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import FontAwesome from '@expo/vector-icons/FontAwesome';
|
||||
import { forwardRef } from 'react';
|
||||
import { Pressable, StyleSheet } from 'react-native';
|
||||
|
||||
import { useTheme } from '../utils/themeContext';
|
||||
|
||||
export const HeaderButton = forwardRef<typeof Pressable, { onPress?: () => void }>(
|
||||
({ onPress }, ref) => {
|
||||
const { isDarkMode, colors } = useTheme();
|
||||
|
||||
return (
|
||||
<Pressable onPress={onPress}>
|
||||
{({ pressed }) => (
|
||||
<FontAwesome
|
||||
name="info-circle"
|
||||
size={24}
|
||||
color={isDarkMode ? '#FFFFFF' : colors.primary}
|
||||
style={[
|
||||
styles.headerRight,
|
||||
{
|
||||
opacity: pressed ? 0.5 : 1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
headerRight: {
|
||||
marginRight: 30, // Further increased margin for better spacing
|
||||
},
|
||||
});
|
||||
473
apps/traces/apps/mobile/components/LocationHistoryList.tsx
Normal file
473
apps/traces/apps/mobile/components/LocationHistoryList.tsx
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import React from 'react';
|
||||
import { StyleSheet, View, Text, FlatList, Alert, Pressable } from 'react-native';
|
||||
|
||||
import { LocationData, deleteLocationEntry } from '../utils/locationService';
|
||||
import { useTheme } from '../utils/themeContext';
|
||||
|
||||
interface LocationHistoryListProps {
|
||||
locationHistory: LocationData[];
|
||||
onItemPress?: (location: LocationData) => void;
|
||||
onDelete?: () => void;
|
||||
isDarkMode?: boolean;
|
||||
}
|
||||
|
||||
export const LocationHistoryList: React.FC<LocationHistoryListProps> = ({
|
||||
locationHistory,
|
||||
onItemPress,
|
||||
onDelete,
|
||||
isDarkMode = false,
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
const getTimestamp = (location: LocationData): number => {
|
||||
return location.timestamps?.recordedMs || location.timestamp || 0;
|
||||
};
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
const weekday = date.toLocaleDateString('de-DE', { weekday: 'long' });
|
||||
const time = date.toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return `${weekday}, ${time} Uhr`;
|
||||
};
|
||||
|
||||
const handleDelete = async (location: LocationData, event: any) => {
|
||||
event.stopPropagation();
|
||||
Alert.alert('Eintrag löschen', 'Möchtest du diesen Standorteintrag wirklich löschen?', [
|
||||
{
|
||||
text: 'Abbrechen',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Löschen',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await deleteLocationEntry(location.id);
|
||||
onDelete?.();
|
||||
} catch (error) {
|
||||
Alert.alert('Fehler', 'Der Eintrag konnte nicht gelöscht werden.');
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const renderItem = ({ item, index }: { item: LocationData; index: number }) => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.itemContainer,
|
||||
isDarkMode && {
|
||||
backgroundColor: '#1E1E1E',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.item, pressed && { opacity: 0.7 }]}
|
||||
onPress={() => onItemPress && onItemPress(item)}
|
||||
>
|
||||
<View style={[styles.indexContainer, isDarkMode && { backgroundColor: '#333333' }]}>
|
||||
<Text style={[styles.index, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
{locationHistory.length - index}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.contentContainer}>
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={[styles.date, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
{formatDate(getTimestamp(item))}
|
||||
</Text>
|
||||
<Text style={[styles.time, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
{formatTime(getTimestamp(item))}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Adressinformationen anzeigen */}
|
||||
{item.address && (
|
||||
<View
|
||||
style={[styles.addressContainer, isDarkMode && { borderBottomColor: '#333333' }]}
|
||||
>
|
||||
{/* Show formatted address if available, otherwise fallback to components */}
|
||||
{item.address.formatted ? (
|
||||
<Text style={[styles.addressText, isDarkMode && { color: '#DDDDDD' }]}>
|
||||
{item.address.formatted}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
{item.address.components?.street && item.address.components?.houseNumber && (
|
||||
<Text style={[styles.addressText, isDarkMode && { color: '#DDDDDD' }]}>
|
||||
{item.address.components.street} {item.address.components.houseNumber}
|
||||
</Text>
|
||||
)}
|
||||
{item.address.components?.street && !item.address.components?.houseNumber && (
|
||||
<Text style={[styles.addressText, isDarkMode && { color: '#DDDDDD' }]}>
|
||||
{item.address.components.street}
|
||||
</Text>
|
||||
)}
|
||||
{item.address.components?.postalCode && item.address.components?.city && (
|
||||
<Text style={[styles.addressText, isDarkMode && { color: '#DDDDDD' }]}>
|
||||
{item.address.components.postalCode} {item.address.components.city}
|
||||
</Text>
|
||||
)}
|
||||
{!item.address.components?.postalCode && item.address.components?.city && (
|
||||
<Text style={[styles.addressText, isDarkMode && { color: '#DDDDDD' }]}>
|
||||
{item.address.components.city}
|
||||
</Text>
|
||||
)}
|
||||
{item.address.components?.country && (
|
||||
<Text style={[styles.addressText, isDarkMode && { color: '#DDDDDD' }]}>
|
||||
{item.address.components.country}
|
||||
</Text>
|
||||
)}
|
||||
{/* Legacy fallback */}
|
||||
{!item.address.components && (item.address as any).street && (
|
||||
<Text style={[styles.addressText, isDarkMode && { color: '#DDDDDD' }]}>
|
||||
{(item.address as any).street} {(item.address as any).streetNumber || ''}
|
||||
</Text>
|
||||
)}
|
||||
{!item.address.components && (item.address as any).city && (
|
||||
<Text style={[styles.addressText, isDarkMode && { color: '#DDDDDD' }]}>
|
||||
{(item.address as any).postalCode
|
||||
? `${(item.address as any).postalCode} `
|
||||
: ''}
|
||||
{(item.address as any).city}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.coordinatesRow}>
|
||||
<View style={styles.coordinateItem}>
|
||||
<Text style={[styles.coordinateLabel, isDarkMode && { color: '#888888' }]}>
|
||||
Breitengrad
|
||||
</Text>
|
||||
<Text style={[styles.coordinateValue, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
{item.latitude.toFixed(6)}°
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.coordinateItem}>
|
||||
<Text style={[styles.coordinateLabel, isDarkMode && { color: '#888888' }]}>
|
||||
Längengrad
|
||||
</Text>
|
||||
<Text style={[styles.coordinateValue, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
{item.longitude.toFixed(6)}°
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{/* Basis-Informationen */}
|
||||
<View style={styles.detailsRow}>
|
||||
{item.accuracy && (
|
||||
<Text style={[styles.detailText, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
Genauigkeit: {item.accuracy.toFixed(1)}m
|
||||
</Text>
|
||||
)}
|
||||
{item.speed !== undefined && item.speed !== null && (
|
||||
<Text style={[styles.detailText, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
Geschwindigkeit: {(item.speed * 3.6).toFixed(1)} km/h
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Neue Metadaten */}
|
||||
{item.metadata && (
|
||||
<View style={[styles.metadataContainer, isDarkMode && { borderTopColor: '#333333' }]}>
|
||||
<View style={styles.metadataRow}>
|
||||
{item.metadata.source && (
|
||||
<View
|
||||
style={[
|
||||
styles.metadataTag,
|
||||
styles.sourceTag,
|
||||
isDarkMode && { backgroundColor: '#1E3A5F' },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.metadataTagText, isDarkMode && { color: '#B3D9FF' }]}>
|
||||
{item.metadata.source === 'foreground'
|
||||
? '🔵 Vordergrund'
|
||||
: item.metadata.source === 'background'
|
||||
? '🟢 Hintergrund'
|
||||
: '🟡 Manuell'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{item.metadata.deviceMotion && item.metadata.deviceMotion !== 'unknown' && (
|
||||
<View
|
||||
style={[
|
||||
styles.metadataTag,
|
||||
styles.motionTag,
|
||||
isDarkMode && { backgroundColor: '#3A2A1E' },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.metadataTagText, isDarkMode && { color: '#E6C2A6' }]}>
|
||||
{item.metadata.deviceMotion === 'stationary'
|
||||
? '🧍 Stillstehend'
|
||||
: item.metadata.deviceMotion === 'walking'
|
||||
? '🚶 Gehend'
|
||||
: item.metadata.deviceMotion === 'driving'
|
||||
? '🚗 Fahrend'
|
||||
: '❓ Unbekannt'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{item.metadata.connectionType && (
|
||||
<View
|
||||
style={[
|
||||
styles.metadataTag,
|
||||
styles.connectionTag,
|
||||
isDarkMode && { backgroundColor: '#3A1E4F' },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.metadataTagText, isDarkMode && { color: '#E1BEE7' }]}>
|
||||
{item.metadata.connectionType === 'wifi'
|
||||
? '📶 WiFi'
|
||||
: item.metadata.connectionType === 'cellular'
|
||||
? '📱 Mobil'
|
||||
: '❌ Offline'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Qualitätsinformationen */}
|
||||
{item.quality && (
|
||||
<View style={styles.metadataRow}>
|
||||
<View
|
||||
style={[
|
||||
styles.metadataTag,
|
||||
styles.qualityTag,
|
||||
isDarkMode && { backgroundColor: '#1E3A1E' },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.metadataTagText, isDarkMode && { color: '#B3E5B3' }]}>
|
||||
📍 {item.quality.accuracyLevel || 'Standard'}
|
||||
</Text>
|
||||
</View>
|
||||
{item.metadata.batteryLevel && (
|
||||
<View
|
||||
style={[
|
||||
styles.metadataTag,
|
||||
styles.batteryTag,
|
||||
isDarkMode && { backgroundColor: '#4A3A1E' },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.metadataTagText, isDarkMode && { color: '#FFE0B3' }]}>
|
||||
🔋 {Math.round(item.metadata.batteryLevel * 100)}%
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ID für Debugging */}
|
||||
{item.id && (
|
||||
<Text style={[styles.idText, isDarkMode && { color: '#666666' }]}>
|
||||
ID: {item.id.substring(0, 8)}...
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.deleteIconButton, pressed && { opacity: 0.5 }]}
|
||||
onPress={(e) => handleDelete(item, e)}
|
||||
>
|
||||
<FontAwesome name="trash-o" size={20} color={colors.error || '#F44336'} />
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={[...locationHistory].reverse()}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item, index) => `location-${item.id || getTimestamp(item)}-${index}`}
|
||||
contentContainerStyle={[styles.listContent, isDarkMode && { backgroundColor: '#121212' }]}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<FontAwesome name="map-marker" size={48} color={isDarkMode ? '#444444' : '#ccc'} />
|
||||
<Text style={[styles.emptyText, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
Keine Standortdaten vorhanden
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtext, isDarkMode && { color: '#777777' }]}>
|
||||
Starte das Tracking, um deine Bewegungen aufzuzeichnen
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listContent: {
|
||||
padding: 16,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
addressContainer: {
|
||||
marginBottom: 8,
|
||||
paddingBottom: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#eee',
|
||||
},
|
||||
addressText: {
|
||||
fontSize: 14,
|
||||
color: '#444',
|
||||
marginBottom: 2,
|
||||
},
|
||||
itemContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
item: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
indexContainer: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#f0f0f0',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
index: {
|
||||
fontWeight: 'bold',
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
},
|
||||
date: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
time: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
coordinatesRow: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 8,
|
||||
},
|
||||
coordinateItem: {
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
coordinateLabel: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginBottom: 2,
|
||||
},
|
||||
coordinateValue: {
|
||||
fontSize: 14,
|
||||
},
|
||||
detailsRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
detailText: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginRight: 12,
|
||||
},
|
||||
metadataContainer: {
|
||||
marginTop: 8,
|
||||
paddingTop: 8,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#f0f0f0',
|
||||
},
|
||||
metadataRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: 4,
|
||||
},
|
||||
metadataTag: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 12,
|
||||
marginRight: 6,
|
||||
marginBottom: 4,
|
||||
},
|
||||
sourceTag: {
|
||||
backgroundColor: '#E3F2FD',
|
||||
},
|
||||
connectionTag: {
|
||||
backgroundColor: '#F3E5F5',
|
||||
},
|
||||
motionTag: {
|
||||
backgroundColor: '#FFF8E1',
|
||||
},
|
||||
qualityTag: {
|
||||
backgroundColor: '#E8F5E8',
|
||||
},
|
||||
batteryTag: {
|
||||
backgroundColor: '#FFF3E0',
|
||||
},
|
||||
metadataTagText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
idText: {
|
||||
fontSize: 9,
|
||||
color: '#999',
|
||||
fontFamily: 'monospace',
|
||||
marginTop: 4,
|
||||
},
|
||||
arrowContainer: {
|
||||
justifyContent: 'center',
|
||||
paddingLeft: 8,
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 48,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#666',
|
||||
marginTop: 16,
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
textAlign: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
deleteIconButton: {
|
||||
padding: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
259
apps/traces/apps/mobile/components/LocationMap.tsx
Normal file
259
apps/traces/apps/mobile/components/LocationMap.tsx
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { StyleSheet, View, Dimensions, TouchableOpacity, Text, Platform } from 'react-native';
|
||||
|
||||
import { WebMap } from './WebMap';
|
||||
import { LocationData } from '../utils/locationService';
|
||||
import { useTheme } from '../utils/themeContext';
|
||||
|
||||
// Nur auf nativen Plattformen importieren, nicht im Web
|
||||
let MapView: any, Marker: any, Polyline: any;
|
||||
if (Platform.OS !== 'web') {
|
||||
const Maps = require('react-native-maps');
|
||||
MapView = Maps.default;
|
||||
Marker = Maps.Marker;
|
||||
Polyline = Maps.Polyline;
|
||||
}
|
||||
|
||||
interface LocationMapProps {
|
||||
currentLocation: LocationData | null;
|
||||
locationHistory: LocationData[];
|
||||
onCenterMap?: () => void;
|
||||
isTracking: boolean;
|
||||
isDarkMode?: boolean;
|
||||
locationCount?: number;
|
||||
}
|
||||
|
||||
export const LocationMap: React.FC<LocationMapProps> = ({
|
||||
currentLocation,
|
||||
locationHistory,
|
||||
onCenterMap,
|
||||
isTracking,
|
||||
isDarkMode = false,
|
||||
locationCount = 0,
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
const mapRef = useRef<any>(null);
|
||||
const [mapRegion, setMapRegion] = useState({
|
||||
latitude: 37.78825, // Default location (will be overridden)
|
||||
longitude: -122.4324,
|
||||
latitudeDelta: 0.0922,
|
||||
longitudeDelta: 0.0421,
|
||||
});
|
||||
|
||||
// Update map region when current location changes
|
||||
useEffect(() => {
|
||||
if (currentLocation) {
|
||||
const newRegion = {
|
||||
latitude: currentLocation.latitude,
|
||||
longitude: currentLocation.longitude,
|
||||
latitudeDelta: 0.0122,
|
||||
longitudeDelta: 0.0061,
|
||||
};
|
||||
setMapRegion(newRegion);
|
||||
|
||||
// Only auto-center map when tracking is active
|
||||
if (isTracking) {
|
||||
mapRef.current?.animateToRegion(newRegion, 1000);
|
||||
}
|
||||
}
|
||||
}, [currentLocation, isTracking]);
|
||||
|
||||
// Wenn wir im Web sind, zeige die Web-Version der Karte
|
||||
if (Platform.OS === 'web') {
|
||||
return (
|
||||
<WebMap
|
||||
currentLocation={currentLocation}
|
||||
locationHistory={locationHistory}
|
||||
isTracking={isTracking}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Center map on current location
|
||||
const centerMap = () => {
|
||||
if (currentLocation && mapRef.current) {
|
||||
mapRef.current.animateToRegion(
|
||||
{
|
||||
latitude: currentLocation.latitude,
|
||||
longitude: currentLocation.longitude,
|
||||
latitudeDelta: 0.0122,
|
||||
longitudeDelta: 0.0061,
|
||||
},
|
||||
1000
|
||||
);
|
||||
if (onCenterMap) onCenterMap();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<MapView
|
||||
ref={mapRef}
|
||||
style={styles.map}
|
||||
initialRegion={mapRegion}
|
||||
showsUserLocation
|
||||
showsMyLocationButton={false}
|
||||
showsCompass
|
||||
rotateEnabled
|
||||
scrollEnabled
|
||||
zoomEnabled
|
||||
userInterfaceStyle={isDarkMode ? 'dark' : 'light'}
|
||||
>
|
||||
{/* Current location marker */}
|
||||
{currentLocation && (
|
||||
<Marker
|
||||
coordinate={{
|
||||
latitude: currentLocation.latitude,
|
||||
longitude: currentLocation.longitude,
|
||||
}}
|
||||
title="Current Location"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Path polyline */}
|
||||
{locationHistory.length > 1 && (
|
||||
<Polyline
|
||||
coordinates={locationHistory.map((loc) => ({
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
}))}
|
||||
strokeColor={colors.primary}
|
||||
strokeWidth={3}
|
||||
/>
|
||||
)}
|
||||
</MapView>
|
||||
|
||||
{/* Location count badge */}
|
||||
<View
|
||||
style={[
|
||||
styles.locationCountBadge,
|
||||
isDarkMode && {
|
||||
backgroundColor: 'rgba(40, 40, 40, 0.9)',
|
||||
shadowColor: '#000000',
|
||||
shadowOpacity: 0.5,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<FontAwesome
|
||||
name="map-marker"
|
||||
size={16}
|
||||
color={colors.primary}
|
||||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
<Text style={[styles.locationCountText, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
{locationCount}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Center map button */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.centerButton,
|
||||
isDarkMode && {
|
||||
backgroundColor: '#333333',
|
||||
shadowColor: '#000000',
|
||||
shadowOpacity: 0.5,
|
||||
},
|
||||
]}
|
||||
onPress={centerMap}
|
||||
>
|
||||
<FontAwesome name="location-arrow" size={24} color={isDarkMode ? 'white' : 'black'} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Tracking indicator */}
|
||||
{isTracking && (
|
||||
<View
|
||||
style={[
|
||||
styles.trackingIndicator,
|
||||
isDarkMode && {
|
||||
backgroundColor: 'rgba(40, 40, 40, 0.8)',
|
||||
shadowColor: '#000000',
|
||||
shadowOpacity: 0.5,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.trackingText, isDarkMode && { color: '#FFFFFF' }]}>Tracking</Text>
|
||||
<View style={styles.trackingDot} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
borderRadius: 8,
|
||||
},
|
||||
map: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
locationCountBadge: {
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
locationCountText: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#000',
|
||||
},
|
||||
centerButton: {
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 30,
|
||||
width: 50,
|
||||
height: 50,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
trackingIndicator: {
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 1.41,
|
||||
elevation: 2,
|
||||
},
|
||||
trackingText: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
marginRight: 6,
|
||||
},
|
||||
trackingDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#FF0000',
|
||||
},
|
||||
});
|
||||
183
apps/traces/apps/mobile/components/LogsList.tsx
Normal file
183
apps/traces/apps/mobile/components/LogsList.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import React from 'react';
|
||||
import { StyleSheet, View, Text, FlatList, TouchableOpacity } from 'react-native';
|
||||
|
||||
import { LogEntry } from '~/app/(tabs)/logs';
|
||||
import { useTheme } from '../utils/themeContext';
|
||||
|
||||
interface LogsListProps {
|
||||
logs: LogEntry[];
|
||||
isDarkMode?: boolean;
|
||||
}
|
||||
|
||||
export const LogsList: React.FC<LogsListProps> = ({ logs, isDarkMode = false }) => {
|
||||
const { colors } = useTheme();
|
||||
const formatDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const getLevelIcon = (
|
||||
level: string
|
||||
): { name: React.ComponentProps<typeof FontAwesome>['name']; color: string } => {
|
||||
switch (level) {
|
||||
case 'info':
|
||||
return { name: 'info-circle', color: colors.primary };
|
||||
case 'warning':
|
||||
return { name: 'exclamation-triangle', color: isDarkMode ? '#FFC107' : '#FF9800' };
|
||||
case 'error':
|
||||
return { name: 'exclamation-circle', color: isDarkMode ? '#F44336' : '#F44336' };
|
||||
default:
|
||||
return { name: 'circle', color: isDarkMode ? '#AAAAAA' : '#888888' };
|
||||
}
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: LogEntry }) => {
|
||||
const icon = getLevelIcon(item.level);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.item,
|
||||
isDarkMode && {
|
||||
backgroundColor: '#1E1E1E',
|
||||
shadowColor: '#000000',
|
||||
shadowOpacity: 0.3,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.iconContainer}>
|
||||
<FontAwesome name={icon.name} size={20} color={icon.color} />
|
||||
</View>
|
||||
<View style={styles.contentContainer}>
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={[styles.level, { color: icon.color }]}>{item.level.toUpperCase()}</Text>
|
||||
<Text style={[styles.timestamp, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
{formatDate(item.timestamp)} {formatTime(item.timestamp)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[styles.message, isDarkMode && { color: '#FFFFFF' }]}>{item.message}</Text>
|
||||
{item.details && (
|
||||
<View style={[styles.detailsContainer, isDarkMode && { backgroundColor: '#2A2A2A' }]}>
|
||||
<Text style={[styles.detailsText, isDarkMode && { color: '#BBBBBB' }]}>
|
||||
{typeof item.details === 'object'
|
||||
? JSON.stringify(item.details, null, 2)
|
||||
: String(item.details)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={[...logs].reverse()}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={[styles.listContent, isDarkMode && { backgroundColor: '#121212' }]}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<FontAwesome name="file-text-o" size={48} color={isDarkMode ? '#444444' : '#ccc'} />
|
||||
<Text style={[styles.emptyText, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
Keine Logs vorhanden
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtext, isDarkMode && { color: '#777777' }]}>
|
||||
Beim Start des Location-Trackings werden Logs generiert
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listContent: {
|
||||
padding: 16,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
item: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
padding: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 32,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
level: {
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
timestamp: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
message: {
|
||||
fontSize: 14,
|
||||
marginBottom: 8,
|
||||
},
|
||||
detailsContainer: {
|
||||
backgroundColor: '#f5f5f5',
|
||||
padding: 8,
|
||||
borderRadius: 4,
|
||||
marginTop: 4,
|
||||
},
|
||||
detailsText: {
|
||||
fontSize: 12,
|
||||
color: '#333',
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 48,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#666',
|
||||
marginTop: 16,
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
textAlign: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
489
apps/traces/apps/mobile/components/PhotoImportModal.tsx
Normal file
489
apps/traces/apps/mobile/components/PhotoImportModal.tsx
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
Image,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
|
||||
import {
|
||||
scanPhotosForGPS,
|
||||
importMultiplePhotos,
|
||||
PhotoWithGPS,
|
||||
ScanResult,
|
||||
} from '../utils/photoImportService';
|
||||
import { getLocationHistory, LocationData } from '../utils/locationService';
|
||||
import { useTheme } from '../utils/themeContext';
|
||||
|
||||
interface PhotoImportModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onImportComplete: (count: number) => void;
|
||||
}
|
||||
|
||||
export const PhotoImportModal: React.FC<PhotoImportModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onImportComplete,
|
||||
}) => {
|
||||
const { isDarkMode, colors } = useTheme();
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<string>>(new Set());
|
||||
const [importProgress, setImportProgress] = useState({ current: 0, total: 0 });
|
||||
|
||||
const handleScan = async () => {
|
||||
setIsScanning(true);
|
||||
try {
|
||||
const result = await scanPhotosForGPS({ limit: 100 });
|
||||
setScanResult(result);
|
||||
|
||||
// Alle Fotos mit GPS standardmäßig auswählen
|
||||
const allIds = new Set(result.photos.map((p) => p.id));
|
||||
setSelectedPhotos(allIds);
|
||||
} catch (error) {
|
||||
Alert.alert('Fehler', 'Konnte Fotos nicht scannen');
|
||||
} finally {
|
||||
setIsScanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!scanResult || selectedPhotos.size === 0) return;
|
||||
|
||||
setIsImporting(true);
|
||||
try {
|
||||
// Lade bestehende Locations für Duplikat-Check
|
||||
const existingLocations = await getLocationHistory();
|
||||
|
||||
// Filtere ausgewählte Fotos
|
||||
const photosToImport = scanResult.photos.filter((p) => selectedPhotos.has(p.id));
|
||||
|
||||
// Importiere Fotos
|
||||
const importedLocations = await importMultiplePhotos(
|
||||
photosToImport,
|
||||
existingLocations,
|
||||
(current, total) => {
|
||||
setImportProgress({ current, total });
|
||||
}
|
||||
);
|
||||
|
||||
// Speichere in AsyncStorage
|
||||
if (importedLocations.length > 0) {
|
||||
const LOCATION_HISTORY_KEY = 'location_history';
|
||||
const updatedHistory = [...existingLocations, ...importedLocations];
|
||||
await AsyncStorage.setItem(LOCATION_HISTORY_KEY, JSON.stringify(updatedHistory));
|
||||
}
|
||||
|
||||
// Erfolg
|
||||
Alert.alert(
|
||||
'Import erfolgreich',
|
||||
`${importedLocations.length} Foto-Locations importiert\n${photosToImport.length - importedLocations.length} Duplikate übersprungen`,
|
||||
[
|
||||
{
|
||||
text: 'OK',
|
||||
onPress: () => {
|
||||
onImportComplete(importedLocations.length);
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
} catch (error) {
|
||||
Alert.alert('Fehler', 'Import fehlgeschlagen');
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
setImportProgress({ current: 0, total: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
const togglePhoto = (photoId: string) => {
|
||||
const newSelection = new Set(selectedPhotos);
|
||||
if (newSelection.has(photoId)) {
|
||||
newSelection.delete(photoId);
|
||||
} else {
|
||||
newSelection.add(photoId);
|
||||
}
|
||||
setSelectedPhotos(newSelection);
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
if (!scanResult) return;
|
||||
const allIds = new Set(scanResult.photos.map((p) => p.id));
|
||||
setSelectedPhotos(allIds);
|
||||
};
|
||||
|
||||
const deselectAll = () => {
|
||||
setSelectedPhotos(new Set());
|
||||
};
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
return new Date(timestamp).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" presentationStyle="pageSheet">
|
||||
<View style={[styles.container, isDarkMode && { backgroundColor: '#121212' }]}>
|
||||
{/* Header */}
|
||||
<View style={[styles.header, isDarkMode && { borderBottomColor: '#333' }]}>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<FontAwesome name="times" size={24} color={isDarkMode ? '#FFF' : '#000'} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.title, isDarkMode && { color: '#FFF' }]}>📸 Fotos importieren</Text>
|
||||
<View style={{ width: 40 }} />
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollView style={styles.content}>
|
||||
{!scanResult ? (
|
||||
/* Scan starten */
|
||||
<View style={styles.startView}>
|
||||
<FontAwesome name="camera" size={64} color={isDarkMode ? '#666' : '#CCC'} />
|
||||
<Text style={[styles.startText, isDarkMode && { color: '#AAA' }]}>
|
||||
Scanne deine Fotos nach GPS-Daten
|
||||
</Text>
|
||||
<Text style={[styles.startSubtext, isDarkMode && { color: '#666' }]}>
|
||||
Die letzten 100 Fotos werden durchsucht
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.scanButton,
|
||||
{ backgroundColor: colors.primary },
|
||||
isScanning && styles.scanButtonDisabled,
|
||||
]}
|
||||
onPress={handleScan}
|
||||
disabled={isScanning}
|
||||
>
|
||||
{isScanning ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : (
|
||||
<>
|
||||
<FontAwesome name="search" size={18} color="white" />
|
||||
<Text style={styles.scanButtonText}>Fotos scannen</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
/* Ergebnisse */
|
||||
<>
|
||||
{/* Statistik */}
|
||||
<View style={[styles.statsContainer, isDarkMode && { backgroundColor: '#1E1E1E' }]}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statValue, isDarkMode && { color: '#FFF' }]}>
|
||||
{scanResult.totalPhotos}
|
||||
</Text>
|
||||
<Text style={[styles.statLabel, isDarkMode && { color: '#AAA' }]}>
|
||||
Fotos gescannt
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statValue, { color: colors.primary }]}>
|
||||
{scanResult.photosWithGPS}
|
||||
</Text>
|
||||
<Text style={[styles.statLabel, isDarkMode && { color: '#AAA' }]}>Mit GPS ✓</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statValue, { color: '#FF9800' }]}>
|
||||
{scanResult.photosWithoutGPS}
|
||||
</Text>
|
||||
<Text style={[styles.statLabel, isDarkMode && { color: '#AAA' }]}>Ohne GPS</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Auswahl-Buttons */}
|
||||
{scanResult.photosWithGPS > 0 && (
|
||||
<View style={styles.selectionButtons}>
|
||||
<TouchableOpacity onPress={selectAll} style={styles.selectionButton}>
|
||||
<Text
|
||||
style={[styles.selectionButtonText, isDarkMode && { color: colors.primary }]}
|
||||
>
|
||||
Alle auswählen
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={deselectAll} style={styles.selectionButton}>
|
||||
<Text style={[styles.selectionButtonText, isDarkMode && { color: '#F44336' }]}>
|
||||
Alle abwählen
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Foto-Liste */}
|
||||
{scanResult.photos.map((photo) => {
|
||||
const isSelected = selectedPhotos.has(photo.id);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={photo.id}
|
||||
style={[
|
||||
styles.photoItem,
|
||||
isSelected && styles.photoItemSelected,
|
||||
isDarkMode && {
|
||||
backgroundColor: isSelected ? '#1a3a1a' : '#1E1E1E',
|
||||
borderColor: isSelected ? colors.primary : '#333',
|
||||
},
|
||||
]}
|
||||
onPress={() => togglePhoto(photo.id)}
|
||||
>
|
||||
<Image source={{ uri: photo.uri }} style={styles.photoThumbnail} />
|
||||
<View style={styles.photoInfo}>
|
||||
<Text
|
||||
style={[styles.photoFilename, isDarkMode && { color: '#FFF' }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{photo.filename}
|
||||
</Text>
|
||||
<Text style={[styles.photoDate, isDarkMode && { color: '#AAA' }]}>
|
||||
📅 {formatDate(photo.creationTime)}
|
||||
</Text>
|
||||
<Text style={[styles.photoLocation, isDarkMode && { color: '#AAA' }]}>
|
||||
📍 {photo.location?.latitude?.toFixed(4) || '0.0000'},{' '}
|
||||
{photo.location?.longitude?.toFixed(4) || '0.0000'}
|
||||
</Text>
|
||||
</View>
|
||||
{isSelected && (
|
||||
<FontAwesome name="check-circle" size={24} color={colors.primary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
|
||||
{scanResult.photos.length === 0 && (
|
||||
<View style={styles.emptyState}>
|
||||
<FontAwesome name="frown-o" size={48} color={isDarkMode ? '#666' : '#CCC'} />
|
||||
<Text style={[styles.emptyText, isDarkMode && { color: '#AAA' }]}>
|
||||
Keine Fotos mit GPS-Daten gefunden
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Footer */}
|
||||
{scanResult && scanResult.photosWithGPS > 0 && (
|
||||
<View
|
||||
style={[
|
||||
styles.footer,
|
||||
isDarkMode && { backgroundColor: '#1E1E1E', borderTopColor: '#333' },
|
||||
]}
|
||||
>
|
||||
{isImporting && (
|
||||
<Text style={[styles.progressText, isDarkMode && { color: '#AAA' }]}>
|
||||
Importiere {importProgress.current} / {importProgress.total}...
|
||||
</Text>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.importButton,
|
||||
{ backgroundColor: colors.primary },
|
||||
(selectedPhotos.size === 0 || isImporting) && styles.importButtonDisabled,
|
||||
]}
|
||||
onPress={handleImport}
|
||||
disabled={selectedPhotos.size === 0 || isImporting}
|
||||
>
|
||||
{isImporting ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : (
|
||||
<>
|
||||
<FontAwesome name="download" size={18} color="white" />
|
||||
<Text style={styles.importButtonText}>
|
||||
{selectedPhotos.size} Foto{selectedPhotos.size !== 1 ? 's' : ''} importieren
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FFF',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E0E0E0',
|
||||
},
|
||||
closeButton: {
|
||||
padding: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
startView: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 80,
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
startText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginTop: 24,
|
||||
textAlign: 'center',
|
||||
},
|
||||
startSubtext: {
|
||||
fontSize: 14,
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
},
|
||||
scanButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
// backgroundColor set dynamically via colors.primary
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 32,
|
||||
borderRadius: 12,
|
||||
marginTop: 32,
|
||||
minWidth: 200,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
scanButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
scanButtonText: {
|
||||
color: 'white',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginLeft: 8,
|
||||
},
|
||||
statsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
padding: 20,
|
||||
backgroundColor: '#F5F5F5',
|
||||
margin: 16,
|
||||
borderRadius: 12,
|
||||
},
|
||||
statItem: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginTop: 4,
|
||||
},
|
||||
selectionButtons: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
selectionButton: {
|
||||
padding: 8,
|
||||
},
|
||||
selectionButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
// color set dynamically via colors.primary
|
||||
},
|
||||
photoItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 8,
|
||||
borderRadius: 8,
|
||||
borderWidth: 2,
|
||||
borderColor: '#E0E0E0',
|
||||
backgroundColor: 'white',
|
||||
},
|
||||
photoItemSelected: {
|
||||
// borderColor set dynamically via colors.primary
|
||||
backgroundColor: '#E8F5E9',
|
||||
},
|
||||
photoThumbnail: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 6,
|
||||
marginRight: 12,
|
||||
},
|
||||
photoInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
photoFilename: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
photoDate: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
marginBottom: 2,
|
||||
},
|
||||
photoLocation: {
|
||||
fontSize: 11,
|
||||
color: '#666',
|
||||
},
|
||||
emptyState: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
color: '#AAA',
|
||||
marginTop: 16,
|
||||
},
|
||||
footer: {
|
||||
padding: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#E0E0E0',
|
||||
backgroundColor: 'white',
|
||||
},
|
||||
progressText: {
|
||||
textAlign: 'center',
|
||||
marginBottom: 8,
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
},
|
||||
importButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
// backgroundColor set dynamically via colors.primary
|
||||
paddingVertical: 16,
|
||||
borderRadius: 12,
|
||||
},
|
||||
importButtonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
importButtonText: {
|
||||
color: 'white',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginLeft: 8,
|
||||
},
|
||||
});
|
||||
400
apps/traces/apps/mobile/components/PlaceDetail.tsx
Normal file
400
apps/traces/apps/mobile/components/PlaceDetail.tsx
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import Slider from '@react-native-community/slider';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
|
||||
import { ThemeWrapper } from './ThemeWrapper';
|
||||
import { Place, formatDuration } from '../utils/locationHelper';
|
||||
import { useTheme } from '../utils/themeContext';
|
||||
|
||||
interface PlaceDetailProps {
|
||||
place: Place;
|
||||
onSave: (updatedPlace: Place) => void;
|
||||
onDelete: (placeId: string) => void;
|
||||
}
|
||||
|
||||
export const PlaceDetail: React.FC<PlaceDetailProps> = ({ place, onSave, onDelete }) => {
|
||||
const { isDarkMode, colors } = useTheme();
|
||||
const [name, setName] = useState(place.name);
|
||||
const [customAddress, setCustomAddress] = useState(place.customAddress || '');
|
||||
const [radius, setRadius] = useState(place.radius || 100);
|
||||
|
||||
const formatDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
// Name darf nicht leer sein
|
||||
if (!name.trim()) {
|
||||
Alert.alert('Fehler', 'Bitte einen Namen für diesen Ort eingeben.');
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedPlace: Place = {
|
||||
...place,
|
||||
name,
|
||||
customAddress: customAddress.trim() || undefined,
|
||||
radius,
|
||||
};
|
||||
|
||||
onSave(updatedPlace);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
Alert.alert('Ort löschen', `Möchtest du "${place.name}" wirklich löschen?`, [
|
||||
{
|
||||
text: 'Abbrechen',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Löschen',
|
||||
style: 'destructive',
|
||||
onPress: () => onDelete(place.id),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
// Hilfsfunktion zur Formatierung der Koordinaten
|
||||
const formatCoordinate = (coord: number) => coord.toFixed(6);
|
||||
|
||||
// Ermittle primäre Adresse, wenn keine benutzerdefinierte vorhanden ist
|
||||
const getPrimaryAddress = (): string => {
|
||||
if (place.addresses.size === 0) {
|
||||
return 'Keine Adressinformationen';
|
||||
}
|
||||
return Array.from(place.addresses)[0];
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeWrapper>
|
||||
<ScrollView
|
||||
style={[styles.container, isDarkMode && { backgroundColor: '#121212' }]}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
>
|
||||
{/* Header-Bereich mit Name und Besuchen */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={[styles.label, isDarkMode && { color: '#CCCCCC' }]}>Ortsname</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isDarkMode && {
|
||||
backgroundColor: '#2C2C2C',
|
||||
color: '#FFFFFF',
|
||||
borderColor: '#444444',
|
||||
},
|
||||
]}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Name des Ortes"
|
||||
placeholderTextColor={isDarkMode ? '#777777' : '#999999'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statValue, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
{place.visitCount}
|
||||
</Text>
|
||||
<Text style={[styles.statLabel, isDarkMode && { color: '#AAAAAA' }]}>Besuche</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[styles.statValue, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
{formatDuration(place.totalDuration)}
|
||||
</Text>
|
||||
<Text style={[styles.statLabel, isDarkMode && { color: '#AAAAAA' }]}>Gesamtzeit</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Adresse-Bereich */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, isDarkMode && { color: '#CCCCCC' }]}>Adresse</Text>
|
||||
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isDarkMode && {
|
||||
backgroundColor: '#2C2C2C',
|
||||
color: '#FFFFFF',
|
||||
borderColor: '#444444',
|
||||
},
|
||||
]}
|
||||
value={customAddress}
|
||||
onChangeText={setCustomAddress}
|
||||
placeholder={getPrimaryAddress()}
|
||||
placeholderTextColor={isDarkMode ? '#777777' : '#999999'}
|
||||
multiline
|
||||
/>
|
||||
|
||||
{place.addresses.size > 0 && !customAddress && (
|
||||
<Text style={[styles.hintText, isDarkMode && { color: '#888888' }]}>
|
||||
Überschreibe die automatisch erkannte Adresse oder lasse das Feld leer, um die
|
||||
erkannte Adresse zu verwenden.
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Radius-Bereich */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, isDarkMode && { color: '#CCCCCC' }]}>
|
||||
Erkennungsradius
|
||||
</Text>
|
||||
|
||||
<Slider
|
||||
style={styles.slider}
|
||||
minimumValue={50}
|
||||
maximumValue={500}
|
||||
step={10}
|
||||
value={radius}
|
||||
onValueChange={setRadius}
|
||||
minimumTrackTintColor={colors.primary}
|
||||
maximumTrackTintColor={isDarkMode ? '#444444' : '#D1D1D1'}
|
||||
thumbTintColor={colors.primary}
|
||||
/>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
styles.radiusText,
|
||||
{ color: colors.primary },
|
||||
isDarkMode && { color: '#AAAAAA' },
|
||||
]}
|
||||
>
|
||||
{radius} Meter
|
||||
</Text>
|
||||
|
||||
<Text style={[styles.hintText, isDarkMode && { color: '#888888' }]}>
|
||||
Standorte innerhalb dieses Radius werden als Besuche an diesem Ort gezählt.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Details-Bereich */}
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, isDarkMode && { color: '#CCCCCC' }]}>Ortdetails</Text>
|
||||
|
||||
<View style={styles.detailsGrid}>
|
||||
<View style={styles.detailItem}>
|
||||
<Text style={[styles.detailLabel, isDarkMode && { color: '#888888' }]}>
|
||||
Breitengrad
|
||||
</Text>
|
||||
<Text style={[styles.detailValue, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
{formatCoordinate(place.latitude)}°
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.detailItem}>
|
||||
<Text style={[styles.detailLabel, isDarkMode && { color: '#888888' }]}>
|
||||
Längengrad
|
||||
</Text>
|
||||
<Text style={[styles.detailValue, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
{formatCoordinate(place.longitude)}°
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.detailItem}>
|
||||
<Text style={[styles.detailLabel, isDarkMode && { color: '#888888' }]}>
|
||||
Erster Besuch
|
||||
</Text>
|
||||
<Text style={[styles.detailValue, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
{formatDate(place.firstVisit)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.detailItem}>
|
||||
<Text style={[styles.detailLabel, isDarkMode && { color: '#888888' }]}>
|
||||
Letzter Besuch
|
||||
</Text>
|
||||
<Text style={[styles.detailValue, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
{formatDate(place.lastVisit)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{place.accuracy !== undefined && (
|
||||
<View style={styles.detailItem}>
|
||||
<Text style={[styles.detailLabel, isDarkMode && { color: '#888888' }]}>
|
||||
Durchschn. Genauigkeit
|
||||
</Text>
|
||||
<Text style={[styles.detailValue, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
~{place.accuracy.toFixed(1)}m
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{place.altitude !== undefined && (
|
||||
<View style={styles.detailItem}>
|
||||
<Text style={[styles.detailLabel, isDarkMode && { color: '#888888' }]}>
|
||||
Durchschn. Höhe
|
||||
</Text>
|
||||
<Text style={[styles.detailValue, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
~{place.altitude.toFixed(1)}m
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Action-Buttons */}
|
||||
<View style={styles.actionsContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.saveButton, { backgroundColor: colors.primary }]}
|
||||
onPress={handleSave}
|
||||
>
|
||||
<FontAwesome name="check" size={16} color="#FFF" style={styles.buttonIcon} />
|
||||
<Text style={styles.buttonText}>Speichern</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={[styles.button, styles.deleteButton]} onPress={handleDelete}>
|
||||
<FontAwesome name="trash" size={16} color="#FFF" style={styles.buttonIcon} />
|
||||
<Text style={styles.buttonText}>Löschen</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ThemeWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
padding: 16,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
header: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
marginBottom: 6,
|
||||
color: '#666',
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: '#E0E0E0',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
fontSize: 16,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
statsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
marginTop: 8,
|
||||
backgroundColor: '#F5F5F5',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
},
|
||||
statItem: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginTop: 4,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 12,
|
||||
color: '#333',
|
||||
},
|
||||
slider: {
|
||||
height: 40,
|
||||
marginBottom: 8,
|
||||
},
|
||||
radiusText: {
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
// color set dynamically via colors.primary
|
||||
marginBottom: 8,
|
||||
},
|
||||
hintText: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginTop: 8,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
detailsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginHorizontal: -8,
|
||||
},
|
||||
detailItem: {
|
||||
width: '50%',
|
||||
paddingHorizontal: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
detailLabel: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginBottom: 4,
|
||||
},
|
||||
detailValue: {
|
||||
fontSize: 16,
|
||||
color: '#333',
|
||||
},
|
||||
actionsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: 16,
|
||||
},
|
||||
button: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 8,
|
||||
flex: 1,
|
||||
marginHorizontal: 6,
|
||||
},
|
||||
saveButton: {
|
||||
// backgroundColor set dynamically via colors.primary
|
||||
},
|
||||
deleteButton: {
|
||||
backgroundColor: '#F44336',
|
||||
},
|
||||
buttonIcon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#FFFFFF',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
256
apps/traces/apps/mobile/components/PlacesList.tsx
Normal file
256
apps/traces/apps/mobile/components/PlacesList.tsx
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import React from 'react';
|
||||
import { StyleSheet, View, Text, FlatList, TouchableOpacity } from 'react-native';
|
||||
|
||||
import { ConsolidatedLocation, Place, formatDuration } from '../utils/locationHelper';
|
||||
import { useTheme } from '../utils/themeContext';
|
||||
|
||||
interface PlacesListProps {
|
||||
places: (Place | ConsolidatedLocation)[];
|
||||
onItemPress?: (place: Place | ConsolidatedLocation) => void;
|
||||
onAddPlace?: (place: ConsolidatedLocation) => void;
|
||||
showAddButton?: boolean;
|
||||
isDarkMode?: boolean;
|
||||
}
|
||||
|
||||
export const PlacesList: React.FC<PlacesListProps> = ({
|
||||
places,
|
||||
onItemPress,
|
||||
onAddPlace,
|
||||
showAddButton = false,
|
||||
isDarkMode = false,
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
const formatDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
// Eine nützliche Funktion zum Abrufen der primären Adresse
|
||||
const getPrimaryAddress = (place: Place | ConsolidatedLocation): string => {
|
||||
if ('customAddress' in place && place.customAddress) {
|
||||
return place.customAddress;
|
||||
}
|
||||
|
||||
if (place.addresses.size === 0) {
|
||||
return 'Keine Adressinformationen';
|
||||
}
|
||||
|
||||
// Einfach die erste Adresse zurückgeben
|
||||
return Array.from(place.addresses)[0];
|
||||
};
|
||||
|
||||
const renderItem = ({ item, index }: { item: Place | ConsolidatedLocation; index: number }) => {
|
||||
// Prüfe, ob der Eintrag ein Place oder ConsolidatedLocation ist
|
||||
const isPlace = 'name' in item;
|
||||
|
||||
// Formatiere den Zeitraum
|
||||
const formattedVisitCount = item.count;
|
||||
const formattedDuration = formatDuration(item.duration);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.item,
|
||||
isDarkMode && {
|
||||
backgroundColor: '#1E1E1E',
|
||||
shadowColor: '#000000',
|
||||
shadowOpacity: 0.3,
|
||||
},
|
||||
]}
|
||||
onPress={() => onItemPress && onItemPress(item)}
|
||||
>
|
||||
<View style={[styles.indexContainer, isDarkMode && { backgroundColor: '#333333' }]}>
|
||||
<Text style={[styles.index, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
{places.length - index}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.contentContainer}>
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={[styles.placeName, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
{isPlace ? item.name : `Häufiger Ort #${index + 1}`}
|
||||
</Text>
|
||||
<View style={[styles.countBadge, { backgroundColor: colors.primary }]}>
|
||||
<Text style={styles.countText}>{formattedVisitCount}x</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.address, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
{getPrimaryAddress(item)}
|
||||
</Text>
|
||||
|
||||
<View style={styles.coordinatesRow}>
|
||||
<View style={styles.coordinateItem}>
|
||||
<Text style={[styles.coordinateLabel, isDarkMode && { color: '#888888' }]}>
|
||||
Gesamtdauer
|
||||
</Text>
|
||||
<Text style={[styles.coordinateValue, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
{formattedDuration}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.coordinateItem}>
|
||||
<Text style={[styles.coordinateLabel, isDarkMode && { color: '#888888' }]}>
|
||||
Letzter Besuch
|
||||
</Text>
|
||||
<Text style={[styles.coordinateValue, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
{formatDate(item.endTimestamp)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{showAddButton && !isPlace && onAddPlace ? (
|
||||
<TouchableOpacity
|
||||
style={styles.addButton}
|
||||
onPress={() => onAddPlace(item as ConsolidatedLocation)}
|
||||
>
|
||||
<FontAwesome name="plus-circle" size={22} color={colors.primary} />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={styles.arrowContainer}>
|
||||
<FontAwesome name="chevron-right" size={16} color={isDarkMode ? '#666666' : '#ccc'} />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={places}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item, index) => {
|
||||
if ('id' in item) {
|
||||
return `place-${item.id}`;
|
||||
}
|
||||
return `frequent-${item.startTimestamp}-${index}`;
|
||||
}}
|
||||
contentContainerStyle={[styles.listContent, isDarkMode && { backgroundColor: '#121212' }]}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyContainer}>
|
||||
<FontAwesome name="map-marker" size={48} color={isDarkMode ? '#444444' : '#ccc'} />
|
||||
<Text style={[styles.emptyText, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
Keine häufigen Orte gefunden
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtext, isDarkMode && { color: '#777777' }]}>
|
||||
Besuche einen Ort häufiger, um ihn hier anzuzeigen
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listContent: {
|
||||
padding: 16,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
item: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
padding: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
indexContainer: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#f0f0f0',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
index: {
|
||||
fontWeight: 'bold',
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
placeName: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
address: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 8,
|
||||
},
|
||||
countBadge: {
|
||||
// backgroundColor set dynamically via colors.primary
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 12,
|
||||
minWidth: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
countText: {
|
||||
color: 'white',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
coordinatesRow: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 8,
|
||||
},
|
||||
coordinateItem: {
|
||||
flex: 1,
|
||||
marginRight: 8,
|
||||
},
|
||||
coordinateLabel: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginBottom: 2,
|
||||
},
|
||||
coordinateValue: {
|
||||
fontSize: 14,
|
||||
},
|
||||
arrowContainer: {
|
||||
justifyContent: 'center',
|
||||
paddingLeft: 8,
|
||||
},
|
||||
addButton: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: 40,
|
||||
paddingLeft: 8,
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 48,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#666',
|
||||
marginTop: 16,
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
textAlign: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
127
apps/traces/apps/mobile/components/SegmentedControl.tsx
Normal file
127
apps/traces/apps/mobile/components/SegmentedControl.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import React from 'react';
|
||||
import { StyleSheet, View, Text, TouchableOpacity } from 'react-native';
|
||||
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export interface SegmentedControlOption {
|
||||
value: string;
|
||||
label: string;
|
||||
icon: keyof typeof FontAwesome.glyphMap;
|
||||
badge?: number;
|
||||
}
|
||||
|
||||
interface SegmentedControlProps {
|
||||
options: SegmentedControlOption[];
|
||||
activeValue: string;
|
||||
onChange: (value: string) => void;
|
||||
isDarkMode?: boolean;
|
||||
}
|
||||
|
||||
export const SegmentedControl: React.FC<SegmentedControlProps> = ({
|
||||
options,
|
||||
activeValue,
|
||||
onChange,
|
||||
isDarkMode = false,
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={[styles.container, isDarkMode && { backgroundColor: colors.backgroundSecondary }]}>
|
||||
{options.map((option) => {
|
||||
const isActive = activeValue === option.value;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
style={[
|
||||
styles.button,
|
||||
isActive && [styles.activeButton, { backgroundColor: colors.primary }],
|
||||
isDarkMode && { backgroundColor: colors.backgroundTertiary },
|
||||
]}
|
||||
onPress={() => onChange(option.value)}
|
||||
>
|
||||
<FontAwesome
|
||||
name={option.icon}
|
||||
size={14}
|
||||
color={
|
||||
isActive ? '#FFFFFF' : isDarkMode ? colors.textSecondary : colors.textSecondary
|
||||
}
|
||||
style={styles.buttonIcon}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.buttonText,
|
||||
isActive && styles.activeButtonText,
|
||||
!isActive && { color: isDarkMode ? colors.textSecondary : colors.textSecondary },
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
{option.badge !== undefined && (
|
||||
<View style={[styles.badge, { backgroundColor: colors.warning }]}>
|
||||
<Text style={styles.badgeText}>{option.badge}</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: 'row',
|
||||
backgroundColor: 'white',
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 100,
|
||||
borderRadius: 8,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 8,
|
||||
padding: 4,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 12,
|
||||
borderRadius: 6,
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
activeButton: {
|
||||
// backgroundColor set dynamically via colors.primary
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 12,
|
||||
color: '#666666',
|
||||
fontWeight: '500',
|
||||
},
|
||||
activeButtonText: {
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
buttonIcon: {
|
||||
marginRight: 6,
|
||||
},
|
||||
badge: {
|
||||
// backgroundColor set dynamically via colors.warning
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
marginLeft: 6,
|
||||
},
|
||||
badgeText: {
|
||||
color: 'white',
|
||||
fontSize: 10,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
18
apps/traces/apps/mobile/components/SettingsButton.tsx
Normal file
18
apps/traces/apps/mobile/components/SettingsButton.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import { Link } from 'expo-router';
|
||||
import { forwardRef } from 'react';
|
||||
import { Pressable } from 'react-native';
|
||||
|
||||
import { useTheme } from '~/utils/themeContext';
|
||||
|
||||
export const SettingsButton = forwardRef<typeof Pressable>((props, ref) => {
|
||||
const { isDarkMode, colors } = useTheme();
|
||||
|
||||
return (
|
||||
<Link href="/settings" asChild>
|
||||
<Pressable style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, marginLeft: 30 })}>
|
||||
<FontAwesome name="gear" size={24} color={isDarkMode ? '#FFFFFF' : colors.primary} />
|
||||
</Pressable>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
30
apps/traces/apps/mobile/components/ThemeToggle.tsx
Normal file
30
apps/traces/apps/mobile/components/ThemeToggle.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
|
||||
import { useTheme } from '../utils/themeContext';
|
||||
|
||||
type ThemeToggleProps = {
|
||||
style?: any;
|
||||
};
|
||||
|
||||
export const ThemeToggle: React.FC<ThemeToggleProps> = ({ style }) => {
|
||||
const { isDarkMode, toggleTheme, colors } = useTheme();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={toggleTheme}
|
||||
style={({ pressed }) => ({ opacity: pressed ? 0.5 : 1, ...style })}
|
||||
>
|
||||
<View className="flex-row items-center">
|
||||
<FontAwesome
|
||||
name={isDarkMode ? 'moon-o' : 'sun-o'}
|
||||
size={24}
|
||||
color={isDarkMode ? '#FFFFFF' : colors.primary}
|
||||
/>
|
||||
<Text className={`ml-2 ${isDarkMode ? 'text-white' : 'text-black'}`}>
|
||||
{isDarkMode ? 'Dark Mode' : 'Light Mode'}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
149
apps/traces/apps/mobile/components/ThemeVariantPicker.tsx
Normal file
149
apps/traces/apps/mobile/components/ThemeVariantPicker.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import React from 'react';
|
||||
import { StyleSheet, View, Text, TouchableOpacity } from 'react-native';
|
||||
|
||||
import { ThemeVariant } from '~/utils/themeContext';
|
||||
|
||||
interface ThemeVariantPickerProps {
|
||||
selectedVariant: ThemeVariant;
|
||||
onChange: (variant: ThemeVariant) => void;
|
||||
isDarkMode?: boolean;
|
||||
}
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{
|
||||
variant: 'classic' as ThemeVariant,
|
||||
name: 'Classic',
|
||||
description: 'Natürlich & Grün',
|
||||
icon: 'leaf' as keyof typeof FontAwesome.glyphMap,
|
||||
primaryColor: '#4CAF50',
|
||||
},
|
||||
{
|
||||
variant: 'ocean' as ThemeVariant,
|
||||
name: 'Ocean',
|
||||
description: 'Navigation & Blau',
|
||||
icon: 'ship' as keyof typeof FontAwesome.glyphMap,
|
||||
primaryColor: '#2196F3',
|
||||
},
|
||||
{
|
||||
variant: 'sunset' as ThemeVariant,
|
||||
name: 'Sunset',
|
||||
description: 'Abenteuer & Energie',
|
||||
icon: 'sun-o' as keyof typeof FontAwesome.glyphMap,
|
||||
primaryColor: '#FF6B6B',
|
||||
},
|
||||
];
|
||||
|
||||
export const ThemeVariantPicker: React.FC<ThemeVariantPickerProps> = ({
|
||||
selectedVariant,
|
||||
onChange,
|
||||
isDarkMode = false,
|
||||
}) => {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{THEME_OPTIONS.map((option) => {
|
||||
const isSelected = selectedVariant === option.variant;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={option.variant}
|
||||
style={[
|
||||
styles.themeCard,
|
||||
isDarkMode && styles.themeCardDark,
|
||||
isSelected && styles.themeCardSelected,
|
||||
isSelected && { borderColor: option.primaryColor },
|
||||
]}
|
||||
onPress={() => onChange(option.variant)}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.iconContainer,
|
||||
{ backgroundColor: option.primaryColor + '20' }, // 20 = 12% opacity
|
||||
]}
|
||||
>
|
||||
<FontAwesome name={option.icon} size={28} color={option.primaryColor} />
|
||||
</View>
|
||||
|
||||
<View style={styles.textContainer}>
|
||||
<Text style={[styles.themeName, isDarkMode && styles.themeNameDark]}>
|
||||
{option.name}
|
||||
</Text>
|
||||
<Text style={[styles.themeDescription, isDarkMode && styles.themeDescriptionDark]}>
|
||||
{option.description}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{isSelected && (
|
||||
<View style={[styles.checkmark, { backgroundColor: option.primaryColor }]}>
|
||||
<FontAwesome name="check" size={14} color="#FFFFFF" />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
gap: 12,
|
||||
},
|
||||
themeCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
borderWidth: 2,
|
||||
borderColor: 'transparent',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
themeCardDark: {
|
||||
backgroundColor: '#2A2A2A',
|
||||
},
|
||||
themeCardSelected: {
|
||||
borderWidth: 2,
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 4,
|
||||
elevation: 4,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 16,
|
||||
},
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
themeName: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: '#333',
|
||||
marginBottom: 4,
|
||||
},
|
||||
themeNameDark: {
|
||||
color: '#E0E0E0',
|
||||
},
|
||||
themeDescription: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
themeDescriptionDark: {
|
||||
color: '#A0A0A0',
|
||||
},
|
||||
checkmark: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
21
apps/traces/apps/mobile/components/ThemeWrapper.tsx
Normal file
21
apps/traces/apps/mobile/components/ThemeWrapper.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { View } from 'react-native';
|
||||
|
||||
import { useTheme } from '../utils/themeContext';
|
||||
|
||||
type ThemeWrapperProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const ThemeWrapper: React.FC<ThemeWrapperProps> = ({ children, className = '' }) => {
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
// Apply dark mode class when isDarkMode is true
|
||||
return (
|
||||
<View
|
||||
className={`${isDarkMode ? 'dark bg-background-dark' : 'bg-background-light'} flex-1 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
457
apps/traces/apps/mobile/components/TrackingControls.tsx
Normal file
457
apps/traces/apps/mobile/components/TrackingControls.tsx
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
StyleSheet,
|
||||
View,
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
Modal,
|
||||
TouchableWithoutFeedback,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
|
||||
import { getDefaultInterval } from '../utils/locationService';
|
||||
import { useTheme } from '../utils/themeContext';
|
||||
|
||||
export interface TrackingInterval {
|
||||
label: string;
|
||||
value: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface TrackingControlsProps {
|
||||
isTracking: boolean;
|
||||
onStartTracking: (interval: number) => void;
|
||||
onStopTracking: () => void;
|
||||
locationCount: number;
|
||||
selectedInterval: number;
|
||||
isDarkMode?: boolean;
|
||||
}
|
||||
|
||||
export const TRACKING_INTERVALS: TrackingInterval[] = [
|
||||
// Sortierung: Längste Intervalle zuerst, Spaziergang am Ende
|
||||
{
|
||||
label: '🌙 Alle 6 Stunden',
|
||||
value: 6 * 60 * 60 * 1000, // 6 Stunden
|
||||
description: 'Tagesüberblick - Sehr geringer Akkuverbrauch',
|
||||
},
|
||||
{
|
||||
label: '📅 Alle 3 Stunden',
|
||||
value: 3 * 60 * 60 * 1000, // 3 Stunden
|
||||
description: 'Langzeit-Tracking - Minimaler Akkuverbrauch',
|
||||
},
|
||||
{
|
||||
label: '⏰ Stündlich',
|
||||
value: 60 * 60 * 1000, // 1 Stunde
|
||||
description: 'Grober Überblick - Geringer Akkuverbrauch',
|
||||
},
|
||||
{
|
||||
label: '🚗 Auto/Fahrrad',
|
||||
value: 5 * 60 * 1000, // 5 Minuten
|
||||
description: 'Für Fahrten - Moderate Genauigkeit (~12 Punkte/h)',
|
||||
},
|
||||
// SPAZIERGANG-MODI - Am Ende für schnellen Zugriff
|
||||
{
|
||||
label: '🏃 Spaziergang Normal',
|
||||
value: 60 * 1000, // 1 Minute
|
||||
description: 'Gute Balance - Mittlere Präzision (~60 Punkte/h)',
|
||||
},
|
||||
{
|
||||
label: '🚶 Spaziergang Detailliert',
|
||||
value: 30 * 1000, // 30 Sekunden
|
||||
description: 'Jede Straße erfassen - Höchste Präzision (~120 Punkte/h)',
|
||||
},
|
||||
];
|
||||
|
||||
export const TrackingControls: React.FC<TrackingControlsProps> = ({
|
||||
isTracking,
|
||||
onStartTracking,
|
||||
onStopTracking,
|
||||
locationCount,
|
||||
selectedInterval,
|
||||
isDarkMode = false,
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
const [showIntervalModal, setShowIntervalModal] = useState(false);
|
||||
const [defaultInterval, setDefaultInterval] = useState<number | null>(null);
|
||||
|
||||
const selectedIntervalObj =
|
||||
TRACKING_INTERVALS.find((interval) => interval.value === selectedInterval) ||
|
||||
TRACKING_INTERVALS[0];
|
||||
|
||||
// Lade den Standard-Intervall beim Start
|
||||
useEffect(() => {
|
||||
loadDefaultInterval();
|
||||
}, []);
|
||||
|
||||
// Lade den Standard-Intervall mit der neuen Funktion
|
||||
const loadDefaultInterval = async () => {
|
||||
try {
|
||||
const interval = await getDefaultInterval();
|
||||
if (interval !== null) {
|
||||
setDefaultInterval(interval);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des Standard-Intervalls:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartTracking = async () => {
|
||||
if (isTracking) return;
|
||||
|
||||
// Prüfe, ob ein Standard-Intervall festgelegt ist
|
||||
try {
|
||||
const interval = await getDefaultInterval();
|
||||
|
||||
if (interval !== null) {
|
||||
// Wenn ein Standard-Intervall festgelegt ist, verwende diesen ohne Modal anzuzeigen
|
||||
onStartTracking(interval);
|
||||
} else {
|
||||
// Wenn kein Standard-Intervall festgelegt ist, zeige das Modal an
|
||||
setShowIntervalModal(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des Standard-Intervalls:', error);
|
||||
// Im Fehlerfall zeige das Modal an
|
||||
setShowIntervalModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectInterval = (interval: number) => {
|
||||
setShowIntervalModal(false);
|
||||
if (!isTracking) {
|
||||
onStartTracking(interval);
|
||||
} else {
|
||||
// Wenn bereits am Tracken, stoppe und starte neu mit neuem Intervall
|
||||
onStopTracking();
|
||||
setTimeout(() => {
|
||||
onStartTracking(interval);
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{/* Quick-Switch Button - Immer sichtbar */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.quickSwitchButton,
|
||||
{ borderColor: colors.primary },
|
||||
isDarkMode && {
|
||||
backgroundColor: '#1E1E1E',
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
]}
|
||||
onPress={() => setShowIntervalModal(true)}
|
||||
>
|
||||
<View style={styles.quickSwitchContent}>
|
||||
<Text style={[styles.quickSwitchLabel, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
Tracking-Modus
|
||||
</Text>
|
||||
<View style={styles.quickSwitchValue}>
|
||||
<Text
|
||||
style={[styles.quickSwitchModeText, isDarkMode && { color: '#FFFFFF' }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{selectedIntervalObj.label}
|
||||
</Text>
|
||||
<FontAwesome
|
||||
name="exchange"
|
||||
size={16}
|
||||
color={colors.primary}
|
||||
style={{ marginLeft: 8 }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Aktuelles Intervall anzeigen wenn Tracking aktiv */}
|
||||
{isTracking && (
|
||||
<View
|
||||
style={[
|
||||
styles.activeTrackingInfo,
|
||||
isDarkMode && {
|
||||
backgroundColor: '#1E1E1E',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<FontAwesome name="circle" size={8} color={colors.primary} style={{ marginRight: 8 }} />
|
||||
<Text
|
||||
style={[
|
||||
styles.activeTrackingText,
|
||||
{ color: colors.primary },
|
||||
isDarkMode && { color: colors.primary },
|
||||
]}
|
||||
>
|
||||
Tracking aktiv
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isTracking ? (
|
||||
<TouchableOpacity style={[styles.button, styles.stopButton]} onPress={onStopTracking}>
|
||||
<FontAwesome name="stop" size={20} color="white" />
|
||||
<Text style={styles.buttonText}>Tracking stoppen</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={[styles.button, { backgroundColor: colors.primary }]}
|
||||
onPress={handleStartTracking}
|
||||
>
|
||||
<FontAwesome name="play" size={20} color="white" />
|
||||
<Text style={styles.buttonText}>Tracking starten</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Interval Selection Modal */}
|
||||
<Modal
|
||||
visible={showIntervalModal}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setShowIntervalModal(false)}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={() => setShowIntervalModal(false)}>
|
||||
<View style={styles.modalOverlay}>
|
||||
<TouchableWithoutFeedback>
|
||||
<View
|
||||
style={[
|
||||
styles.modalContent,
|
||||
isDarkMode && {
|
||||
backgroundColor: '#1E1E1E',
|
||||
shadowColor: '#000000',
|
||||
shadowOpacity: 0.5,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.modalTitle, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
{selectedIntervalObj.label}
|
||||
</Text>
|
||||
<Text style={[styles.modalSubtitle, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
{isTracking ? 'Tracking läuft - Modus wechseln?' : 'Tracking-Modus wählen'}
|
||||
</Text>
|
||||
|
||||
<ScrollView
|
||||
style={styles.modalScrollView}
|
||||
showsVerticalScrollIndicator={false}
|
||||
bounces={false}
|
||||
>
|
||||
{TRACKING_INTERVALS.map((interval) => {
|
||||
const isActive = interval.value === selectedInterval;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={interval.value}
|
||||
style={[
|
||||
styles.intervalOption,
|
||||
isActive && styles.intervalOptionActive,
|
||||
isDarkMode && {
|
||||
borderColor: isActive ? colors.primary : '#333333',
|
||||
backgroundColor: isActive ? '#1a3a1a' : '#2A2A2A',
|
||||
},
|
||||
]}
|
||||
onPress={() => handleSelectInterval(interval.value)}
|
||||
>
|
||||
<View style={styles.intervalHeader}>
|
||||
<Text
|
||||
style={[
|
||||
styles.intervalLabel,
|
||||
isDarkMode && { color: '#FFFFFF' },
|
||||
isActive && [styles.intervalLabelActive, { color: colors.primary }],
|
||||
]}
|
||||
>
|
||||
{interval.label}
|
||||
</Text>
|
||||
{isActive && (
|
||||
<FontAwesome name="check-circle" size={20} color={colors.primary} />
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.cancelButton}
|
||||
onPress={() => setShowIntervalModal(false)}
|
||||
>
|
||||
<Text style={[styles.cancelButtonText, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
Abbrechen
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
quickSwitchButton: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
borderWidth: 2,
|
||||
// borderColor set dynamically via colors.primary
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 4,
|
||||
},
|
||||
quickSwitchContent: {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
quickSwitchLabel: {
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
quickSwitchValue: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
quickSwitchModeText: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
flex: 1,
|
||||
},
|
||||
activeTrackingInfo: {
|
||||
backgroundColor: '#E8F5E9',
|
||||
borderRadius: 8,
|
||||
padding: 10,
|
||||
marginBottom: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
activeTrackingText: {
|
||||
fontSize: 14,
|
||||
// color set dynamically via colors.primary
|
||||
fontWeight: '600',
|
||||
},
|
||||
intervalContainer: {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3,
|
||||
elevation: 3,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
statsLabel: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 4,
|
||||
},
|
||||
statsValue: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
button: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 18,
|
||||
paddingHorizontal: 24,
|
||||
borderRadius: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 3 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 4,
|
||||
elevation: 4,
|
||||
minHeight: 56,
|
||||
},
|
||||
buttonText: {
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 17,
|
||||
marginLeft: 10,
|
||||
},
|
||||
startButton: {
|
||||
// backgroundColor set dynamically via colors.primary
|
||||
},
|
||||
stopButton: {
|
||||
backgroundColor: '#F44336',
|
||||
},
|
||||
clearButton: {
|
||||
backgroundColor: '#607D8B',
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: 'white',
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
padding: 20,
|
||||
width: '100%',
|
||||
maxHeight: '80%',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: -4 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
modalSubtitle: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginBottom: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
modalScrollView: {
|
||||
maxHeight: 400,
|
||||
},
|
||||
intervalOption: {
|
||||
borderWidth: 2,
|
||||
borderColor: '#e0e0e0',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
marginBottom: 10,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
intervalOptionActive: {
|
||||
// borderColor and backgroundColor set dynamically
|
||||
backgroundColor: '#E8F5E9',
|
||||
},
|
||||
intervalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
intervalLabel: {
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
},
|
||||
intervalLabelActive: {
|
||||
// color set dynamically via colors.primary
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
cancelButton: {
|
||||
marginTop: 8,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelButtonText: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
},
|
||||
});
|
||||
162
apps/traces/apps/mobile/components/WebMap.tsx
Normal file
162
apps/traces/apps/mobile/components/WebMap.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { FontAwesome } from '@expo/vector-icons';
|
||||
import React from 'react';
|
||||
import { StyleSheet, View, Text, Dimensions } from 'react-native';
|
||||
|
||||
import { LocationData } from '../utils/locationService';
|
||||
import { useTheme } from '../utils/themeContext';
|
||||
|
||||
interface WebMapProps {
|
||||
currentLocation: LocationData | null;
|
||||
locationHistory: LocationData[];
|
||||
isTracking: boolean;
|
||||
isDarkMode?: boolean;
|
||||
}
|
||||
|
||||
export const WebMap: React.FC<WebMapProps> = ({
|
||||
currentLocation,
|
||||
locationHistory,
|
||||
isTracking,
|
||||
isDarkMode = false,
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.mapPlaceholder, isDarkMode && { backgroundColor: '#1A1A1A' }]}>
|
||||
<FontAwesome name="map" size={64} color={isDarkMode ? '#444' : '#ccc'} />
|
||||
<Text style={[styles.title, isDarkMode && { color: '#FFFFFF' }]}>Kartenansicht</Text>
|
||||
<Text style={[styles.subtitle, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
Die Kartenansicht ist im Web-Browser nicht verfügbar.
|
||||
</Text>
|
||||
<Text style={[styles.description, isDarkMode && { color: '#888888' }]}>
|
||||
Bitte verwende die Expo Go App auf einem Mobilgerät, um die vollständige Funktionalität zu
|
||||
nutzen.
|
||||
</Text>
|
||||
|
||||
{isTracking && (
|
||||
<View
|
||||
style={[
|
||||
styles.trackingStatus,
|
||||
isDarkMode && { backgroundColor: 'rgba(76, 175, 80, 0.15)' },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.trackingText, { color: colors.primary }]}>Tracking aktiv</Text>
|
||||
<View style={[styles.trackingDot, { backgroundColor: colors.primary }]} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{currentLocation && (
|
||||
<View
|
||||
style={[
|
||||
styles.locationInfo,
|
||||
isDarkMode && {
|
||||
backgroundColor: '#333333',
|
||||
shadowColor: '#000000',
|
||||
shadowOpacity: 0.3,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.locationTitle, isDarkMode && { color: '#FFFFFF' }]}>
|
||||
Aktueller Standort:
|
||||
</Text>
|
||||
<Text style={[styles.locationText, isDarkMode && { color: '#CCCCCC' }]}>
|
||||
Breitengrad: {currentLocation.latitude.toFixed(6)}°
|
||||
</Text>
|
||||
<Text style={[styles.locationText, isDarkMode && { color: '#CCCCCC' }]}>
|
||||
Längengrad: {currentLocation.longitude.toFixed(6)}°
|
||||
</Text>
|
||||
{locationHistory.length > 0 && (
|
||||
<Text style={[styles.historyText, isDarkMode && { color: '#AAAAAA' }]}>
|
||||
{locationHistory.length} Standorte aufgezeichnet
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
borderRadius: 8,
|
||||
},
|
||||
mapPlaceholder: {
|
||||
width: Dimensions.get('window').width,
|
||||
height: '100%',
|
||||
backgroundColor: '#f5f5f5',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginTop: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
color: '#888',
|
||||
marginTop: 16,
|
||||
textAlign: 'center',
|
||||
maxWidth: 300,
|
||||
},
|
||||
trackingStatus: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 24,
|
||||
backgroundColor: 'rgba(76, 175, 80, 0.1)',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 20,
|
||||
},
|
||||
trackingText: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
// color set dynamically via colors.primary
|
||||
marginRight: 8,
|
||||
},
|
||||
trackingDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
// backgroundColor set dynamically via colors.primary
|
||||
},
|
||||
locationInfo: {
|
||||
marginTop: 24,
|
||||
backgroundColor: 'white',
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
width: '100%',
|
||||
maxWidth: 300,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
locationTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
},
|
||||
locationText: {
|
||||
fontSize: 14,
|
||||
color: '#444',
|
||||
marginBottom: 4,
|
||||
},
|
||||
historyText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
marginTop: 8,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
});
|
||||
30
apps/traces/apps/mobile/eas.json
Normal file
30
apps/traces/apps/mobile/eas.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"cli": {
|
||||
"version": ">= 16.0.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true,
|
||||
"ios": {
|
||||
"image": "latest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
3
apps/traces/apps/mobile/global.css
Normal file
3
apps/traces/apps/mobile/global.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
25
apps/traces/apps/mobile/metro.config.js
Normal file
25
apps/traces/apps/mobile/metro.config.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
const { withNativeWind } = require('nativewind/metro');
|
||||
const path = require('path');
|
||||
|
||||
// Get the project and workspace root directories
|
||||
const projectRoot = __dirname;
|
||||
const monorepoRoot = path.resolve(projectRoot, '../../../..');
|
||||
|
||||
/** @type {import('expo/metro-config').MetroConfig} */
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
|
||||
// Watch all files within the monorepo (needed for workspace packages like @traces/types)
|
||||
config.watchFolders = [path.resolve(projectRoot, '../../packages'), monorepoRoot + '/node_modules'];
|
||||
|
||||
// Let Metro know where to resolve packages and in what order
|
||||
config.resolver.nodeModulesPaths = [
|
||||
path.resolve(projectRoot, 'node_modules'),
|
||||
path.resolve(monorepoRoot, 'node_modules'),
|
||||
];
|
||||
|
||||
// Support .cjs and .mjs extensions
|
||||
config.resolver.sourceExts = [...config.resolver.sourceExts, 'cjs', 'mjs'];
|
||||
|
||||
module.exports = withNativeWind(config, { input: './global.css' });
|
||||
3
apps/traces/apps/mobile/nativewind-env.d.ts
vendored
Normal file
3
apps/traces/apps/mobile/nativewind-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/// <reference types="nativewind/types" />
|
||||
|
||||
// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.
|
||||
64
apps/traces/apps/mobile/package.json
Normal file
64
apps/traces/apps/mobile/package.json
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"name": "@traces/mobile",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"dev": "expo start --dev-client",
|
||||
"start": "expo start --dev-client",
|
||||
"ios": "expo run:ios",
|
||||
"android": "expo run:android",
|
||||
"build:dev": "eas build --profile development",
|
||||
"build:preview": "eas build --profile preview",
|
||||
"build:prod": "eas build --profile production",
|
||||
"prebuild": "expo prebuild",
|
||||
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
|
||||
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@traces/types": "workspace:*",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/slider": "5.0.1",
|
||||
"@react-navigation/native": "^7.0.3",
|
||||
"expo": "~54.0.0",
|
||||
"expo-background-fetch": "~14.0.7",
|
||||
"expo-background-task": "~1.0.8",
|
||||
"expo-constants": "~18.0.0",
|
||||
"expo-dev-client": "^6.0.13",
|
||||
"expo-dev-launcher": "^6.0.13",
|
||||
"expo-location": "~19.0.0",
|
||||
"expo-media-library": "~18.2.0",
|
||||
"expo-router": "~6.0.0",
|
||||
"expo-status-bar": "~3.0.0",
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-task-manager": "~14.0.0",
|
||||
"nativewind": "latest",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.4",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-maps": "1.20.1",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-web": "~0.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.0",
|
||||
"@types/react": "~19.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-universe": "^14.0.0",
|
||||
"prettier": "^3.5.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "universe/native",
|
||||
"root": true
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue