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:
Till JS 2026-03-15 08:12:42 +01:00
parent 402e135179
commit bd1178edf8
125 changed files with 14626 additions and 831 deletions

View file

@ -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
# ============================================

View file

@ -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 };

View file

@ -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 };

View file

@ -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 };

View file

@ -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 };

View file

@ -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 };

View file

@ -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 };

View file

@ -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);

View file

@ -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 };

View file

@ -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 };

View file

@ -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

View file

@ -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 };

View file

@ -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 };

View file

@ -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 };

View file

@ -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 };

View file

@ -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 };

View file

@ -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
View 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
View 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
View file

@ -0,0 +1,4 @@
dist/
node_modules/
.env
*.tsbuildinfo

View file

@ -0,0 +1,3 @@
import { createDrizzleConfig } from '@manacore/shared-drizzle-config';
export default createDrizzleConfig({ dbName: 'traces' });

View 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
}
}

View 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"
}
}

View 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 {}

View 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);
}
}

View 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 {}

View 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;
}
}

View 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>;

View 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();
}
}

View 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;

View 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);
}
}

View 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 {}

View 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 };
}
}

View 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);
}
}

View 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 {}

View 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);
}
}

View 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'],
});

View 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);
}
}

View 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 {}

View 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 };
}
}

View 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);
}
}

View 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 {}

View 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));
}
}

View 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
View 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*

View 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.
![Standortverlauf App](https://via.placeholder.com/800x400?text=Standortverlauf+App)
## 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
View file

@ -0,0 +1,2 @@
// @ts-ignore
/// <reference types="nativewind/types" />

View 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"
}
}
}
}

View 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>
);
}

View file

@ -0,0 +1,5 @@
import { Stack } from 'expo-router';
export default function CitiesLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View 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>
);
}

View file

@ -0,0 +1,5 @@
import { Stack } from 'expo-router';
export default function GuidesLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View 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>
);
}

View 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>
);
}

View 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,
},
});

View 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,
},
});

View 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,
},
});

View 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',
},
});

View 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>
);
}

View 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,
},
});

View 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;
}
}`;

View 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]`,
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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',
},
});

View 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',
},
});

View 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',
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -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

View file

@ -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

View 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"
}
}

View file

@ -0,0 +1,10 @@
module.exports = function (api) {
api.cache(true);
const plugins = [];
return {
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'],
plugins,
};
};

View 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"
}
}

View 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',
};

View 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,
},
});

View 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',
},
});

View 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,
},
});

View 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
},
});

View 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',
},
});

View 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',
},
});

View 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,
},
});

View 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,
},
});

View 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,
},
});

View 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,
},
});

View 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',
},
});

View 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>
);
});

View 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>
);
};

View 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',
},
});

View 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>
);
};

View 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',
},
});

View 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',
},
});

View 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": {}
}
}

View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View 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' });

View 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.

View 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