eksplicit mapping af envs
Some checks failed
Backend CI / test (push) Has been cancelled
Flutter CI / analyze-and-test (push) Has been cancelled

This commit is contained in:
Henrik Jess Nielsen
2026-05-12 18:21:25 +02:00
parent b7a435f8b9
commit 99e9b509a0
67 changed files with 8060 additions and 9 deletions

View File

@@ -0,0 +1,54 @@
name: Backend CI
on:
push:
paths:
- "backend/**"
pull_request:
paths:
- "backend/**"
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
services:
redis:
image: redis:7-alpine
ports: ["6379:6379"]
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: sighej
POSTGRES_USER: sighej
POSTGRES_PASSWORD: sighej
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: backend/requirements*.txt
- name: Install dependencies
run: pip install -r requirements-dev.txt
- name: Lint (ruff)
run: pip install ruff && ruff check .
- name: Run tests
env:
REDIS_URL: redis://localhost:6379
DATABASE_URL: postgresql+asyncpg://sighej:sighej@localhost:5432/sighej
run: pytest -v

View File

@@ -0,0 +1,34 @@
name: Flutter CI
on:
push:
paths:
- "app/**"
pull_request:
paths:
- "app/**"
jobs:
analyze-and-test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: app
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: "3.22.x"
channel: stable
cache: true
- name: Install dependencies
run: flutter pub get
- name: Analyze
run: flutter analyze --no-fatal-infos
- name: Run tests
run: flutter test

47
.gitignore vendored Normal file
View File

@@ -0,0 +1,47 @@
# Python
__pycache__/
*.py[cod]
*.pyo
.venv/
venv/
*.egg-info/
dist/
build/
.mypy_cache/
.pytest_cache/
.coverage
htmlcov/
# Environment
.env
.env.local
.env.*.local
# Node / Next.js
node_modules/
.next/
out/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Flutter / Dart
app/.dart_tool/
app/build/
app/.flutter-plugins
app/.flutter-plugins-dependencies
app/pubspec.lock
# macOS
.DS_Store
.AppleDouble
.LSOverride
# IDEs
.idea/
.vscode/
*.swp
*.swo
# Docker
*.log

72
Docs/ADMIN.md Normal file
View File

@@ -0,0 +1,72 @@
# Admin Dashboard — Social Proximity
## Purpose
The admin dashboard shows anonymised platform statistics — not to monitor users, but to understand if the nudge concept is working.
**What it shows:**
- How many sessions were started today / this week
- How many matches were made
- Which interest categories are most popular (no user-level breakdown)
- How many active sessions right now
**What it never shows:**
- Who matched with whom
- Where people were
- Individual user data of any kind
## Technology: Next.js (TypeScript)
Simple choice: TypeScript, server-side rendering, easy to deploy. No complex state management needed — stats are read-only and refreshed periodically.
## Project Structure
```
admin/
├── src/
│ └── app/
│ ├── layout.tsx
│ ├── page.tsx ← Main dashboard
│ └── api/
│ └── stats/
│ └── route.ts ← Proxy to backend /stats
├── components/
│ ├── StatCard.tsx ← Single metric card
│ ├── InterestChart.tsx ← Bar chart of top interest categories
│ └── ActivityChart.tsx ← Sessions/matches over time (7 days)
├── package.json
└── .env.local.example
```
## Dashboard Layout
```
┌─────────────────────────────────────────────────────┐
│ Social Proximity — Admin │
├──────────┬──────────┬──────────┬────────────────────┤
│ Sessions │ Matches │ Match % │ Active now │
│ today │ today │ │ │
├──────────┴──────────┴──────────┴────────────────────┤
│ Sessions & Matches — last 7 days (line chart) │
├─────────────────────────────────────────────────────┤
│ Top interest categories (bar chart) │
└─────────────────────────────────────────────────────┘
```
## Environment Variables
```env
BACKEND_URL=http://localhost:8000
```
## Running Locally
```bash
cd admin
npm install
npm run dev # runs on localhost:3000
```
## Access
In MVP the dashboard has no login. It should be deployed behind a reverse proxy with IP restriction or basic auth — not exposed publicly.

129
Docs/APP.md Normal file
View File

@@ -0,0 +1,129 @@
# Mobile App — Social Proximity
## Technology Choice: Flutter
We chose **Flutter** over React Native for this project because BLE scanning requires close hardware access. Flutter compiles to native ARM code and communicates with the platform via direct platform channels — no JavaScript bridge between the app logic and the BLE hardware.
| | Flutter (chosen) | React Native |
|---|---|---|
| Language | Dart | TypeScript |
| BLE access | Platform channels — native | Via JS bridge |
| Performance | Compiles to native | JS runtime overhead |
| BLE library | `flutter_blue_plus` | `react-native-ble-plx` |
| Platforms | Android + iOS from one codebase | Same |
**Dart** is straightforward to learn if you know TypeScript: strongly typed, OOP, async/await, null safety built in.
## BLE Library: `flutter_blue_plus`
`flutter_blue_plus` is the most actively maintained Flutter BLE library. It supports:
- Scanning for nearby BLE peripherals
- Advertising as a BLE peripheral (Android 5+, iOS limited)
- Reading/writing GATT characteristics
- Connection state management
**Required permissions:**
_Android (`AndroidManifest.xml`):_
```xml
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
```
_iOS (`Info.plist`):_
```xml
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Used to detect nearby people with shared interests.</string>
```
## BLE Flow
```
App starts
└─► Check BLE permissions (request if missing)
└─► Generate ephemeral BLE token (UUID v4, per-session)
└─► Register token + interests with backend (POST /session)
User enables "Open to talk"
└─► Start BLE advertising (token in manufacturer data)
└─► Start BLE scanning for other tokens
└─► Open WebSocket to backend (/ws/{token})
Nearby token detected
└─► Send detected token to backend (POST /match)
└─► If match: WebSocket nudge received
└─► Display nudge card to user
User disables "Open to talk" or closes app
└─► Stop advertising + scanning
└─► Close WebSocket
└─► Session expires on backend (Redis TTL)
```
## Project Structure
```
app/
├── lib/
│ ├── main.dart
│ ├── screens/
│ │ ├── onboarding_screen.dart ← Interest selection on first launch
│ │ ├── home_screen.dart ← "Open to talk" toggle + nudge display
│ │ └── settings_screen.dart ← Manage interests, reset session
│ ├── widgets/
│ │ ├── nudge_card.dart ← The nudge notification UI
│ │ ├── interest_chip.dart ← Selectable interest tag
│ │ └── open_toggle.dart ← Big friendly on/off toggle
│ ├── services/
│ │ ├── ble_service.dart ← BLE scan/advertise logic
│ │ ├── api_service.dart ← HTTP client (register, match)
│ │ └── ws_service.dart ← WebSocket client (nudge receiver)
│ └── models/
│ ├── interest.dart
│ ├── session.dart
│ └── nudge.dart
├── android/
├── ios/
└── pubspec.yaml
```
## Screens
### Onboarding (first launch)
- Choose interest categories (multi-select chips)
- Brief explanation of how the app works
- Consent acknowledgement
### Home
- Large "Open to talk" toggle — the primary interaction
- When active: scanning indicator
- Nudge card appears when a match is found
- Shows shared interests (no name, no face, no location)
- "Say hello" is just a reminder — no in-app chat
### Settings
- Manage interest categories
- Reset ephemeral identity
- Privacy information
## State Management
For MVP simplicity: use Flutter's built-in `Provider` or `Riverpod`.
Avoid complex state management (no BLoC in MVP).
## Running Locally
```bash
cd app
flutter pub get
flutter run # runs on connected device or emulator
flutter run -d android
flutter run -d ios
```
**Prerequisites:**
- Flutter SDK ≥ 3.x
- Android Studio (for Android emulator) or Xcode (for iOS simulator)
- Physical device recommended for BLE testing — emulators do not support BLE scanning

97
Docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,97 @@
# Architecture — Social Proximity
## System Overview
Social Proximity er bygget som et minimal monorepo med tre separate komponenter:
```
┌────────────────────────────────────────────────────────┐
│ Mobile App (Flutter) │
│ │
│ BLE Scanner ──► Match Screen ──► "Say hello" nudge │
└───────────────────────┬────────────────────────────────┘
│ HTTPS + WebSocket
┌────────────────────────────────────────────────────────┐
│ Backend (FastAPI) │
│ │
│ Match Engine ──► Session Store (Redis) ──► DB (PG) │
└───────────┬────────────────────────────────────────────┘
│ Internal API
┌────────────────────────────────────────────────────────┐
│ Admin Dashboard (Next.js) │
│ │
│ Anonymised stats — pings, sessions, interest counts │
└────────────────────────────────────────────────────────┘
```
## Components
### Mobile App
- **Technology:** Flutter (Dart) — cross-platform Android + iOS
- **BLE library:** `flutter_blue_plus`
- **Responsibility:** BLE scanning and advertisement, interest profile, consent toggle, nudge display
- **Does NOT:** store data persistently, track location, communicate with other users directly
### Backend
- **Technology:** FastAPI (Python) + WebSockets
- **Session store:** Redis — ephemeral sessions, no long-term identity storage
- **Database:** PostgreSQL — anonymised aggregate stats only
- **Responsibility:** receive BLE advertisement tokens, match users, push nudge via WebSocket, aggregate stats
### Admin Dashboard
- **Technology:** Next.js (TypeScript)
- **Responsibility:** display anonymised platform stats — no PII, no user-level data
- **Access:** Internal only (no public-facing auth in MVP)
## Data Flow
### Happy path — two users nudged to talk
```
User A opens app, selects interests, enables "open to talk"
└─► App generates ephemeral BLE token
└─► App advertises token via BLE
└─► App registers token + interests with backend (POST /session)
User B nearby detects User A's BLE token
└─► App sends token to backend (POST /match)
└─► Backend checks: does B's interest list overlap with A's?
└─► If overlap + both consent: backend sends WebSocket nudge to both
"Someone nearby also works with DevOps and enjoys hardstyle"
└─► Users see nudge, put phones down, say hello
└─► Session expires automatically (Redis TTL: 2 hours)
```
### Privacy properties
- BLE tokens are ephemeral — regenerated every session
- Backend never stores exact location or movement history
- Interest matching happens server-side — apps never see each other's raw profile
- After session expiry, all session data is deleted from Redis
## Infrastructure
| Environment | Platform |
|---|---|
| Local dev | Docker Compose (backend + PostgreSQL + Redis) |
| Production | Azure Container Apps or Kubernetes (i80.dk) |
## Repository Structure
```
SigHej/
├── README.md
├── Docs/ ← Technical documentation (this folder)
├── backend/ ← FastAPI application
├── app/ ← Flutter mobile app
├── admin/ ← Next.js admin dashboard
└── docker-compose.yml
```
See individual docs for each component:
- [BACKEND.md](./BACKEND.md)
- [APP.md](./APP.md)
- [ADMIN.md](./ADMIN.md)
- [TESTING.md](./TESTING.md)
- [DECISIONS.md](./DECISIONS.md)

185
Docs/BACKEND.md Normal file
View File

@@ -0,0 +1,185 @@
# Backend — Social Proximity
## Technology Stack
| Component | Choice | Reason |
|---|---|---|
| Framework | FastAPI (Python) | Async, typed, WebSocket support, fast to build |
| Session store | Redis | Ephemeral data, TTL-based expiry, low latency |
| Database | PostgreSQL | Aggregate stats only — structured, durable |
| Real-time | WebSockets (FastAPI native) | Push nudge to app without polling |
## Project Structure
```
backend/
├── app/
│ ├── main.py ← FastAPI app init, router registration
│ ├── api/
│ │ ├── session.py ← POST /session — register BLE token + interests
│ │ ├── match.py ← POST /match — attempt proximity match
│ │ ├── ws.py ← WebSocket /ws/{token} — real-time nudge channel
│ │ ├── stats.py ← GET /stats — anonymised aggregate stats (admin only)
│ │ └── health.py ← GET /health
│ ├── models/
│ │ ├── session.py ← Pydantic: SessionCreate, SessionResponse
│ │ ├── match.py ← Pydantic: MatchRequest, MatchResult
│ │ └── stats.py ← Pydantic: StatsResponse
│ ├── services/
│ │ ├── session_service.py ← create/get/expire sessions in Redis
│ │ ├── match_service.py ← interest overlap logic + nudge dispatch
│ │ └── stats_service.py ← aggregate stats queries (PostgreSQL)
│ └── core/
│ ├── config.py ← Settings (env vars via pydantic-settings)
│ ├── redis.py ← Redis connection pool
│ └── database.py ← SQLAlchemy async engine + session
├── tests/
│ ├── test_session.py
│ ├── test_match.py
│ └── test_stats.py
├── requirements.txt
├── requirements-dev.txt
├── Dockerfile
└── .env.example
```
## API Endpoints
### `POST /session`
Register a new ephemeral session when user enables "open to talk".
**Request:**
```json
{
"ble_token": "abc123",
"interests": ["devops", "hardstyle", "philosophy"]
}
```
**Response:**
```json
{
"session_id": "uuid",
"expires_at": "2024-01-01T12:00:00Z"
}
```
- BLE token stored in Redis with 2-hour TTL
- Interest list hashed before storage — raw interests never persisted to DB
---
### `POST /match`
Called when a user's app detects a nearby BLE token.
**Request:**
```json
{
"own_token": "abc123",
"detected_token": "xyz789"
}
```
**Response:**
```json
{
"match": true,
"shared_interests": ["devops", "hardstyle"],
"nudge_sent": true
}
```
- If match found: WebSocket nudge pushed to both parties
- Shared interests returned as category labels only — no raw profile data
---
### `WebSocket /ws/{ble_token}`
Persistent connection from the app. Used to receive real-time nudges.
**Nudge message (server → client):**
```json
{
"type": "nudge",
"shared_interests": ["devops", "hardstyle"],
"message": "Someone nearby shares your interest in devops and hardstyle."
}
```
---
### `GET /health`
Standard health check. Returns `200 OK` with service status.
---
### `GET /stats`
Anonymised aggregate stats for admin dashboard. Internal access only.
**Response:**
```json
{
"total_sessions_today": 142,
"total_matches_today": 38,
"top_interest_categories": ["tech", "music", "philosophy"],
"active_sessions_now": 12
}
```
## Data Model (Redis)
Sessions are stored as Redis hashes:
```
KEY: session:{ble_token}
VALUE: {
"session_id": "uuid",
"interests_hash": "sha256(...)",
"interest_categories": ["tech", "music"],
"created_at": "iso8601"
}
TTL: 7200 seconds (2 hours)
```
## Data Model (PostgreSQL)
Only aggregate counters — no user-level rows.
```sql
CREATE TABLE daily_stats (
date DATE PRIMARY KEY,
total_sessions INTEGER DEFAULT 0,
total_matches INTEGER DEFAULT 0
);
CREATE TABLE interest_category_counts (
date DATE,
category TEXT,
count INTEGER DEFAULT 0,
PRIMARY KEY (date, category)
);
```
## Environment Variables
```env
REDIS_URL=redis://localhost:6379
DATABASE_URL=postgresql+asyncpg://user:pass@localhost/sighej
SESSION_TTL_SECONDS=7200
ALLOWED_ORIGINS=http://localhost:3000
```
## Running Locally
```bash
cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000
```
Or via Docker Compose from root:
```bash
docker-compose up
```

78
Docs/DECISIONS.md Normal file
View File

@@ -0,0 +1,78 @@
# Architecture Decision Records — Social Proximity
This file tracks key architectural decisions made during the project.
Each decision includes context, what was decided, and the consequences.
---
## ADR-001: Flutter for the mobile app
**Date:** 2026-05-07
**Status:** Accepted
**Context:**
The app requires BLE scanning and advertising to detect nearby users. We needed a cross-platform framework (Android + iOS) that provides reliable, low-latency access to the BLE hardware stack.
**Decision:**
Use Flutter (Dart) with `flutter_blue_plus`.
Flutter compiles to native ARM code and communicates with the platform via direct platform channels — no JavaScript bridge. This gives more stable and predictable BLE behaviour than React Native's bridge-based architecture. `flutter_blue_plus` is the most actively maintained Flutter BLE library with full support for scanning, advertising, and GATT on both Android and iOS.
**Consequences:**
- Team needs to learn Dart (low barrier — similar to TypeScript)
- BLE integration testing requires physical devices, not emulators
- Build toolchain: Flutter SDK + Android Studio + Xcode
---
## ADR-002: FastAPI for the backend
**Date:** 2026-05-07
**Status:** Accepted
**Context:**
Backend needs to handle short-lived sessions, real-time WebSocket nudges, and serve anonymised stats. Should be fast to build and easy to maintain.
**Decision:**
Use FastAPI (Python) with async support, WebSockets, and Pydantic for request/response validation.
**Consequences:**
- Native async support fits the WebSocket and Redis use cases
- Pydantic models double as documentation and runtime validation
- Python aligns with DevOpsMCP tooling (complexity, type hints, PEP compliance)
---
## ADR-003: Redis for session storage (not PostgreSQL)
**Date:** 2026-05-07
**Status:** Accepted
**Context:**
User sessions are ephemeral by design — they expire after 2 hours and must never be reconstructed. We need fast reads/writes for the match engine.
**Decision:**
Store all active sessions in Redis with a 2-hour TTL. Use PostgreSQL only for aggregate counters (stats).
**Consequences:**
- Sessions are automatically deleted by Redis TTL — no manual cleanup needed
- No user-level data ever reaches the database
- Redis must be treated as infrastructure (not optional) — included in docker-compose
---
## ADR-004: No in-app messaging
**Date:** 2026-05-07
**Status:** Accepted
**Context:**
We want to nudge users into real, face-to-face conversation — not replace it with another chat interface.
**Decision:**
The app will never include a messaging feature. The nudge is the product. After a match, the app's job is done.
**Consequences:**
- Simpler backend (no message storage, no threads)
- Clearer privacy posture
- Risk: lower engagement metrics — accepted by design

103
Docs/TESTING.md Normal file
View File

@@ -0,0 +1,103 @@
# Testing & Code Quality — Social Proximity
## Scope
These rules apply to the **Python backend** (`backend/`). The Flutter app and Next.js admin have separate tooling.
Quality analysis runs via **DevOpsMCP** — a set of code analysis tools accessible through the MCP server at `https://devops-mcp.i80.dk`.
---
## When to Run What
| Tool | Trigger | Threshold |
|---|---|---|
| `check_pep_compliance` | Before every commit | Zero violations |
| `check_personal_standards` | Before every commit | Zero `error`-level findings |
| `analyze_complexity` | When writing functions > 20 lines | Max cyclomatic complexity: **8** |
| `add_type_hints` | When adding new functions | Min type hint coverage: **80%** |
| `analyze_missing_docstrings` | New classes and public functions | No missing docstrings on public API |
| `analyze_yaml` + `analyze_github_actions` | When editing Gitea CI/CD workflow files | Zero security violations |
---
## Coding Standards (Python backend)
### Enforced by `check_personal_standards`
- **No `subprocess` with `shell=True`** — use SDK clients instead
- **No bare `except:`** — always catch specific exceptions
- **No `%` or `.format()` string formatting** — use f-strings
- **No mutable default arguments** — use `None` and set inside function body
- **No hardcoded secrets** — use environment variables via `pydantic-settings`
### Enforced by `analyze_complexity`
- Max cyclomatic complexity per function: **8**
- Functions exceeding this must be refactored (extract helper functions)
### Enforced by `add_type_hints`
- All function parameters must have type annotations
- All function return types must be annotated
- Use `Optional[T]` or `T | None` — not bare `None` returns without annotation
---
## Workflow
```
1. Write code
2. Run check_personal_standards(file) → fix all 'error' findings
3. Run analyze_complexity(file) → refactor if any function > 8
4. Run add_type_hints(file, apply_changes=False) → review suggestions, apply manually
5. Run check_pep_compliance(file) → fix formatting
6. Commit
```
---
## Test Strategy (backend)
Tests live in `backend/tests/` and use **pytest**.
| Layer | What to test | Example |
|---|---|---|
| Unit | Service logic in isolation | `test_match_service.py` — interest overlap calculation |
| Integration | API endpoints with real Redis/DB | `test_session.py` — POST /session creates Redis key |
| Contract | Request/response shapes match Pydantic models | Via pytest + httpx TestClient |
**Run tests:**
```bash
cd backend
pytest tests/ -v
```
---
## Flutter App Testing
| Layer | Tool | Notes |
|---|---|---|
| Unit | `flutter test` | Test service logic, models |
| Widget | `flutter test` + `WidgetTester` | Test UI components in isolation |
| Integration | `flutter drive` | Requires physical device or emulator |
BLE scanning cannot be tested in emulators — use a physical device for BLE integration tests.
---
## DevOpsMCP Quick Reference
```python
# Upload file and run analysis
session = DevOpsMCP-start_project_session("sighej-backend", [
{"file_path": "main.py", "content": "<content>"}
])
DevOpsMCP-check_personal_standards(file_path="main.py")
DevOpsMCP-analyze_complexity(file_path="main.py")
DevOpsMCP-add_type_hints(file_path="main.py", apply_changes=False)
DevOpsMCP-check_pep_compliance(file_path="main.py")
DevOpsMCP-generate_tests(file_path="main.py")
```

21
Docs/colors.md Normal file
View File

@@ -0,0 +1,21 @@
Sunny Beach Day
Golden sand meets turquoise waves under a deep blue sky, kissed by coral and sunlit amber warmth.
Colors
~Charcoal Blue
#264653
Deep navy undertones blend with smoky steely hues for a sophisticated shade evoking midnight skies and modern elegance.
~Verdigris
#2a9d8f
Shimmering between blue and green, evokes serene tropical tides and the calm of sunlit, crystalline waters.
~Jasmine
#e9c46a
Mellow golden hue suggesting gentle charm, balancing gentle sweetness and cheerful sophistication in every space.
~Sandy Brown
#f4a261
Light orangey-brown radiating summer, beach sand, and sunsets, lending softness and energy to compositions.
~Burnt Peach
#e76f51
Fiery, robust shade bursts with energy, merging glowing embers and ripe fruit for a bold, unforgettable impression.
Psychology
Meaning
every

141
Makefile Normal file
View File

@@ -0,0 +1,141 @@
# SigHej — Makefile
# Shortcuts for backend (Python/FastAPI), admin (Next.js) og app (Flutter)
# Kræver: docker, docker compose, python 3.11+, node 20+, flutter
.DEFAULT_GOAL := help
# ─── Colours ───────────────────────────────────────────────────────────────────
CYAN := \033[0;36m
RESET := \033[0m
# ─── Hjælp ─────────────────────────────────────────────────────────────────────
.PHONY: help
help:
@echo ""
@echo " $(CYAN)SigHej — build targets$(RESET)"
@echo ""
@echo " $(CYAN)Dev environment$(RESET)"
@echo " make up Start alle services (docker compose)"
@echo " make down Stop og fjern containers"
@echo " make logs Tail logs fra alle services"
@echo " make ps Vis kørende services"
@echo ""
@echo " $(CYAN)Backend (FastAPI)$(RESET)"
@echo " make be-install Installer Python deps (venv)"
@echo " make be-dev Kør backend lokalt (uvicorn --reload)"
@echo " make be-test Kør pytest"
@echo " make be-lint Ruff check + format"
@echo " make be-fmt Auto-fix med ruff"
@echo ""
@echo " $(CYAN)Admin (Next.js)$(RESET)"
@echo " make adm-install npm install"
@echo " make adm-dev Kør admin lokalt (next dev)"
@echo " make adm-build Production build"
@echo " make adm-lint ESLint"
@echo ""
@echo " $(CYAN)App (Flutter)$(RESET)"
@echo " make app-get flutter pub get"
@echo " make app-analyze flutter analyze"
@echo " make app-test flutter test"
@echo " make app-android Build APK (debug)"
@echo " make app-ios Build iOS (debug, kræver macOS + Xcode)"
@echo " make app-setup Første gang: flutter create platform-dirs"
@echo ""
@echo " $(CYAN)CI / Full check$(RESET)"
@echo " make check be-lint + be-test + adm-lint + app-analyze"
@echo " make build Docker build af alle services"
@echo ""
# ─── Docker compose ────────────────────────────────────────────────────────────
.PHONY: up down logs ps build
up:
docker compose up -d
down:
docker compose down
logs:
docker compose logs -f
ps:
docker compose ps
build:
docker compose build
# ─── Backend ───────────────────────────────────────────────────────────────────
VENV := backend/.venv
PYTHON := $(VENV)/bin/python
PIP := $(VENV)/bin/pip
PYTEST := $(VENV)/bin/pytest
RUFF := $(VENV)/bin/ruff
$(VENV)/bin/activate: backend/requirements-dev.txt
python3 -m venv $(VENV)
$(PIP) install --upgrade pip
$(PIP) install -r backend/requirements-dev.txt
.PHONY: be-install be-dev be-test be-lint be-fmt
be-install: $(VENV)/bin/activate
be-dev: be-install
cd backend && $(abspath $(PYTHON)) -m uvicorn app.main:app --reload --port 8000
be-test: be-install
cd backend && $(abspath $(PYTEST)) -v
be-lint: be-install
$(RUFF) check backend/app backend/tests
be-fmt: be-install
$(RUFF) check --fix backend/app backend/tests
$(RUFF) format backend/app backend/tests
# ─── Admin (Next.js) ───────────────────────────────────────────────────────────
.PHONY: adm-install adm-dev adm-build adm-lint
adm-install:
cd admin && npm install
adm-dev: adm-install
cd admin && npm run dev
adm-build: adm-install
cd admin && npm run build
adm-lint: adm-install
cd admin && npm run lint
# ─── App (Flutter) ─────────────────────────────────────────────────────────────
.PHONY: app-setup app-get app-analyze app-test app-android app-ios
app-setup:
@echo "Kører flutter create for at generere platform-dirs..."
cd app && flutter create --platforms=android,ios --project-name sighej .
@echo "Genskaber vores platform-filer..."
cd app && git checkout android/app/src/main/AndroidManifest.xml ios/Runner/Info.plist 2>/dev/null || true
app-get:
cd app && flutter pub get
app-analyze: app-get
cd app && flutter analyze --no-fatal-infos
app-test: app-get
cd app && flutter test
app-android: app-get
cd app && flutter build apk --debug
app-ios: app-get
cd app && flutter build ios --debug --no-codesign
# ─── CI: fuld check ────────────────────────────────────────────────────────────
.PHONY: check
check: be-lint be-test adm-lint app-analyze
@echo ""
@echo " ✓ Alle checks bestået"
@echo ""

View File

@@ -1,6 +1,6 @@
# Social Proximity
> Human-first proximity networking powered by consent, shared interests, and conversational AI.
> A tool for nudging people into real, face-to-face conversation — powered by consent, shared interests, and conversational AI.
**Status:** Early concept / prototype phase
@@ -8,23 +8,25 @@
## Overview
Social Proximity is a privacy-focused mobile application that helps people naturally discover and connect with others nearby — through mutual consent and shared interests, not algorithms or swiping.
Social Proximity is a tool for nudging people into real, physical conversation — not a messaging app, not a dating platform, not a social network.
Using Bluetooth Low Energy (BLE), the app detects when two users are physically close — at a café, conference, coworking space, airport, or social event — and suggests low-pressure conversation starters based on what they have in common.
The app detects when two users with shared interests are physically close to each other — at a café, conference, coworking space, airport, or social event — and gives them a gentle nudge: *"you might want to say hello."* What happens next is entirely up to them, in person.
The goal is not tracking or dating. It's about reducing social friction and letting meaningful conversations happen organically.
The goal is to lower the barrier to the first word. Once the conversation starts, the app steps back.
---
## Core Idea
Instead of endless swiping, cold approaches, or awkward introductions, Social Proximity creates a subtle bridge between people already sharing the same physical space.
Most social apps pull people deeper into their screens. Social Proximity does the opposite.
Instead of endless swiping, cold approaches, or awkward introductions, it creates a subtle bridge between people already sharing the same physical space — and then nudges them to put the phone down and actually talk.
When proximity and mutual interest align, users might see something like:
> *"Someone nearby also works with DevOps and enjoys philosophy & hardstyle."*
If both users are open to connecting, the app reveals more and suggests natural conversation topics — no pressure, no commitment.
If both users are open to connecting, the app reveals a little more and suggests a conversation opener. The nudge is the product. The real-world conversation is the goal.
---
@@ -43,11 +45,13 @@ If both users are open to connecting, the app reveals more and suggests natural
Users can optionally share hobbies, professional interests, passions, and topics they enjoy discussing. Only matched interests are surfaced to the other person.
### 💬 AI Conversation Starters
The app generates natural icebreakers based on shared context, for example:
The app suggests a natural opener to help break the ice — then the real conversation takes over. For example:
- *"You both work with cloud infrastructure."*
- *"You both enjoy early hardstyle."*
- *"Maybe ask about Kubernetes homelabs."*
Once the nudge lands, the phone goes back in the pocket.
### 🔒 Privacy First
Designed from the ground up with privacy in mind:
- No live maps or exact locations
@@ -71,7 +75,9 @@ Social Proximity fits naturally wherever people gather:
## Vision
Technology should help humans connect more naturally — not isolate them further.
Most technology pulls people away from the room they're in. Social Proximity does the opposite.
It's a nudge tool — designed to create the small spark that starts a real conversation between two people who are already standing near each other. The app's job is to get out of the way as quickly as possible, and let the human moment take over.
Social Proximity aims to create small moments of serendipity in everyday life: the kind that used to happen by chance, now made possible through respectful, consent-driven social discovery.
@@ -84,7 +90,7 @@ Social Proximity aims to create small moments of serendipity in everyday life: t
| **Human-first** | The app should feel calm, respectful, and natural |
| **Consent-first** | No interaction happens without mutual participation |
| **Privacy-first** | Users should never feel monitored or exposed |
| **Minimalism** | No noisy notifications or addictive mechanics |
| **Minimalism** | The app nudges, then steps back — no feeds, no chat, no retention loops |
---

19
admin/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

14
admin/README.md Normal file
View File

@@ -0,0 +1,14 @@
# admin
Next.js admin dashboard for Social Proximity. Anonymised stats only.
See [Docs/ADMIN.md](../Docs/ADMIN.md) for full documentation.
## Quick start
```bash
npm install
npm run dev
```
Runs on `localhost:3000`. Requires backend running on port 8000.

9
admin/next.config.js Normal file
View File

@@ -0,0 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
env: {
API_URL: process.env.API_URL ?? "http://localhost:8000",
},
};
module.exports = nextConfig;

5324
admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
admin/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "sighej-admin",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "14.2.3",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"typescript": "^5",
"eslint": "^8",
"eslint-config-next": "14.2.3"
}
}

0
admin/public/.gitkeep Normal file
View File

13
admin/src/app/layout.tsx Normal file
View File

@@ -0,0 +1,13 @@
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body style={{ fontFamily: "system-ui, sans-serif", padding: "2rem", maxWidth: 800, margin: "0 auto" }}>
<header style={{ marginBottom: "2rem" }}>
<h1 style={{ margin: 0 }}>SigHej · Admin</h1>
<p style={{ color: "#666", marginTop: 4 }}>Anonymised usage stats no personal data</p>
</header>
<main>{children}</main>
</body>
</html>
);
}

52
admin/src/app/page.tsx Normal file
View File

@@ -0,0 +1,52 @@
import { StatCard } from "@/components/StatCard";
interface StatsData {
active_sessions_now: number;
total_sessions_today: number;
total_matches_today: number;
top_interest_categories: string[];
}
async function fetchStats(): Promise<StatsData | null> {
try {
const res = await fetch(`${process.env.API_URL}/stats`, { next: { revalidate: 30 } });
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
export default async function StatsPage() {
const stats = await fetchStats();
if (!stats) {
return <p> Could not reach backend. Is it running?</p>;
}
return (
<div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))",
gap: "1rem",
marginBottom: "2rem",
}}
>
<StatCard label="Active sessions" value={stats.active_sessions_now} />
<StatCard label="Sessions today" value={stats.total_sessions_today} />
<StatCard label="Matches today" value={stats.total_matches_today} />
</div>
<section>
<h2>Top interest categories</h2>
<ol>
{stats.top_interest_categories.map((interest) => (
<li key={interest}>{interest}</li>
))}
</ol>
</section>
</div>
);
}

View File

@@ -0,0 +1,20 @@
interface StatCardProps {
label: string;
value: number | string;
}
export function StatCard({ label, value }: StatCardProps) {
return (
<div
style={{
border: "1px solid #e0e0e0",
borderRadius: 8,
padding: "1.5rem",
textAlign: "center",
}}
>
<div style={{ fontSize: "2rem", fontWeight: 700 }}>{value}</div>
<div style={{ color: "#666", marginTop: 4 }}>{label}</div>
</div>
);
}

23
admin/tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

14
app/README.md Normal file
View File

@@ -0,0 +1,14 @@
# app
Flutter mobile app for Social Proximity.
See [Docs/APP.md](../Docs/APP.md) for full documentation.
## Quick start
```bash
flutter pub get
flutter run
```
Note: BLE scanning requires a physical device. Emulators do not support BLE.

View File

@@ -0,0 +1,9 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
- prefer_const_constructors
- prefer_const_literals_to_create_immutables
- avoid_print
- use_super_parameters
- prefer_single_quotes

View File

@@ -0,0 +1,59 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Internet (backend API calls) -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- BLE scanning (Android 611) -->
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<!-- Location required for BLE scan on Android ≤ 11 -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<!-- BLE scanning + advertising (Android 12+) -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<!-- System notifications (Android 13+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- BLE hardware feature -->
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true"/>
<application
android:label="SigHej"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<meta-data
android:name="flutterEmbedding"
android:value="2"/>
</application>
<!-- Queries needed for bluetooth_le on Android 11+ -->
<queries>
<intent>
<action android:name="android.bluetooth.IBluetooth.action.CONNECT"/>
</intent>
</queries>
</manifest>

67
app/ios/Runner/Info.plist Normal file
View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- BLE permissions -->
<key>NSBluetoothAlwaysUsageDescription</key>
<string>SigHej uses Bluetooth to detect nearby people who are open for conversation.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>SigHej uses Bluetooth to make you discoverable when you are open to talk.</string>
<!-- Location (required on older iOS for BLE scanning) -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>SigHej uses your location to detect nearby conversation partners via Bluetooth.</string>
<!-- Background BLE scanning -->
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
<string>bluetooth-peripheral</string>
</array>
<!-- Standard Flutter runner keys -->
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>SigHej</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>sighej</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>

35
app/lib/main.dart Normal file
View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sighej/screens/home_screen.dart';
import 'package:sighej/screens/profile_screen.dart';
import 'package:sighej/services/session_store.dart';
import 'package:sighej/theme.dart';
void main() {
runApp(
ChangeNotifierProvider(
create: (_) => SessionStore(),
child: const SigHejApp(),
),
);
}
class SigHejApp extends StatelessWidget {
const SigHejApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'SigHej',
debugShowCheckedModeBanner: false,
theme: buildTheme(),
darkTheme: buildTheme(dark: true),
themeMode: ThemeMode.system,
home: Consumer<SessionStore>(
builder: (context, store, _) => store.hasProfile
? const HomeScreen()
: const ProfileScreen(isSetup: true),
),
);
}
}

View File

@@ -0,0 +1,20 @@
class MatchResult {
final bool match;
final List<String> sharedInterests;
final bool nudgeSent;
const MatchResult({
required this.match,
required this.sharedInterests,
required this.nudgeSent,
});
factory MatchResult.fromJson(Map<String, dynamic> json) => MatchResult(
match: json['match'] as bool? ?? false,
sharedInterests: (json['shared_interests'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
[],
nudgeSent: json['nudge_sent'] as bool? ?? false,
);
}

View File

@@ -0,0 +1,268 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
import 'package:sighej/screens/profile_screen.dart';
import 'package:sighej/services/api_service.dart';
import 'package:sighej/services/session_store.dart';
// BLE service UUID that identifies SigHej devices.
const String kSigHejServiceUuid = '1248f5a0-0000-1000-8000-00805f9b34fb';
// Manufacturer ID used in BLE advertising data (Android).
const int kManufacturerId = 0x4E58; // "NX"
final FlutterLocalNotificationsPlugin _notifications =
FlutterLocalNotificationsPlugin();
const _androidChannel = AndroidNotificationDetails(
'sighej_nudge',
'SigHej Nudges',
channelDescription: 'Notifies when a nearby person shares your interests',
importance: Importance.high,
priority: Priority.high,
icon: '@mipmap/ic_launcher',
);
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
bool _openToTalk = false;
String? _lastNudge;
StreamSubscription? _bleSub;
// Track tokens we have already matched this session to avoid spam.
final Set<String> _matchedTokens = {};
@override
void initState() {
super.initState();
_initNotifications();
}
Future<void> _initNotifications() async {
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
const ios = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
await _notifications.initialize(
const InitializationSettings(android: android, iOS: ios),
);
}
@override
void dispose() {
_stopScanning();
super.dispose();
}
Future<void> _toggle(SessionStore store) async {
if (_openToTalk) {
_stopScanning();
return;
}
final granted = await _requestPermissions();
if (!granted) return;
await registerSession(
store.bleToken,
store.interests,
name: store.name,
tagline: store.tagline,
);
await _startAdvertising(store.bleToken);
FlutterBluePlus.startScan(timeout: const Duration(minutes: 120));
_bleSub = FlutterBluePlus.onScanResults.listen((results) async {
for (final r in results) {
final detected = _parseSigHejToken(r);
if (detected == null ||
detected == store.bleToken ||
_matchedTokens.contains(detected)) {
continue;
}
_matchedTokens.add(detected);
await _handlePotentialMatch(store.bleToken, detected, store);
}
});
setState(() => _openToTalk = true);
}
Future<void> _startAdvertising(String token) async {
final tokenBytes = Uint8List.fromList(token.codeUnits);
try {
await FlutterBluePlus.startAdvertising(
AdvertiseData(
serviceUuids: [Guid(kSigHejServiceUuid)],
manufacturerData: [ManufacturerData(kManufacturerId, tokenBytes)],
),
);
} catch (e) {
debugPrint('BLE advertise error: $e');
}
}
void _stopScanning() {
FlutterBluePlus.stopScan();
FlutterBluePlus.stopAdvertising();
_bleSub?.cancel();
_bleSub = null;
_matchedTokens.clear();
setState(() {
_openToTalk = false;
_lastNudge = null;
});
}
Future<void> _handlePotentialMatch(
String own, String detected, SessionStore store) async {
try {
final result = await reportMatch(own, detected);
if (result == null || !result.match) return;
final interests = result.sharedInterests;
final body = interests.isEmpty
? '${store.displayName} — nogen i nærheden er åben for en snak!'
: 'Fælles interesser: ${interests.join(', ')}';
await _showNudgeNotification('SigHej — sig hej!', body);
setState(() => _lastNudge = body);
} catch (e) {
debugPrint('Match error: $e');
}
}
Future<void> _showNudgeNotification(String title, String body) async {
const details = NotificationDetails(android: _androidChannel);
await _notifications.show(
body.hashCode,
title,
body,
details,
);
}
/// Parses a SigHej BLE token from a scan result.
/// Android: reads manufacturer data with key [kManufacturerId].
/// iOS fallback: reads service data for [kSigHejServiceUuid].
String? _parseSigHejToken(ScanResult result) {
final ad = result.advertisementData;
// Filter: only process SigHej advertisers.
final hasSigHejUuid = ad.serviceUuids
.map((g) => g.toString().toLowerCase())
.contains(kSigHejServiceUuid.toLowerCase());
if (!hasSigHejUuid) return null;
// Android: manufacturer data.
final mfr = ad.manufacturerData[kManufacturerId];
if (mfr != null && mfr.isNotEmpty) {
try {
return String.fromCharCodes(mfr);
} catch (_) {}
}
// iOS fallback: service data.
final svcData = ad.serviceData[Guid(kSigHejServiceUuid)];
if (svcData != null && svcData.isNotEmpty) {
try {
return String.fromCharCodes(svcData);
} catch (_) {}
}
return null;
}
Future<bool> _requestPermissions() async {
final perms = [
Permission.bluetooth,
Permission.bluetoothScan,
Permission.bluetoothAdvertise,
Permission.bluetoothConnect,
Permission.location,
Permission.notification,
];
final statuses = await perms.request();
return statuses.values.every((s) => s.isGranted || s.isLimited);
}
@override
Widget build(BuildContext context) {
final store = context.watch<SessionStore>();
return Scaffold(
appBar: AppBar(
title: const Text('SigHej'),
actions: [
IconButton(
icon: const Icon(Icons.person_outline),
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ProfileScreen()),
),
tooltip: 'Rediger profil',
),
],
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_openToTalk ? 'You\'re open to talk' : 'Tap to open up',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 32),
GestureDetector(
onTap: () => _toggle(store),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: 150,
height: 150,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _openToTalk
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surfaceContainerHighest,
),
child: Icon(
_openToTalk ? Icons.record_voice_over : Icons.mic_off,
size: 60,
color: _openToTalk
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
if (_lastNudge != null) ...[
const SizedBox(height: 40),
Card(
margin: const EdgeInsets.symmetric(horizontal: 24),
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
_lastNudge!,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
),
),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,201 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sighej/services/session_store.dart';
const List<String> kAvailableInterests = [
'Tech',
'Musik',
'Filosofi',
'Design',
'DevOps',
'Bøger',
'Gaming',
'Fitness',
'Kunst',
'Mad',
'Rejser',
'Videnskab',
'Iværksætteri',
'Film',
'Natur',
'Kodning',
'Podcast',
'Arkitektur',
'Klima',
'Sport',
];
class ProfileScreen extends StatefulWidget {
/// If [isSetup] is true, the screen is shown as first-run onboarding.
/// If false, it's opened from the home screen as "rediger profil".
final bool isSetup;
const ProfileScreen({super.key, this.isSetup = false});
@override
State<ProfileScreen> createState() => _ProfileScreenState();
}
class _ProfileScreenState extends State<ProfileScreen> {
late final TextEditingController _nameCtrl;
late final TextEditingController _taglineCtrl;
late final Set<String> _selected;
@override
void initState() {
super.initState();
final store = context.read<SessionStore>();
_nameCtrl = TextEditingController(text: store.name);
_taglineCtrl = TextEditingController(text: store.tagline);
_selected = Set<String>.from(store.interests);
}
@override
void dispose() {
_nameCtrl.dispose();
_taglineCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
await context.read<SessionStore>().saveProfile(
name: _nameCtrl.text.trim(),
tagline: _taglineCtrl.text.trim(),
interests: _selected.toList(),
);
if (mounted) Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final isSetup = widget.isSetup;
return Scaffold(
appBar: AppBar(
title: Text(isSetup ? 'Hvem er du?' : 'Din profil'),
automaticallyImplyLeading: !isSetup,
),
body: SafeArea(
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isSetup) ...[
Text(
'SigHej sender en diskret notifikation, når nogen i nærheden deler dine interesser. '
'Ingen profiler at swipe — bare et lille vink om at starte en samtale.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 28),
],
_SectionLabel('Kaldenavn', hint: 'Valgfrit — hvad vil du kaldes?'),
const SizedBox(height: 8),
TextField(
controller: _nameCtrl,
maxLength: 40,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
hintText: 'F.eks. "Henrik" eller "Tech-nerd"',
border: OutlineInputBorder(),
counterText: '',
),
),
const SizedBox(height: 24),
_SectionLabel(
'Hvad er du op til i dag?',
hint: 'Valgfrit — sæt tonen',
),
const SizedBox(height: 8),
TextField(
controller: _taglineCtrl,
maxLength: 80,
textCapitalization: TextCapitalization.sentences,
decoration: const InputDecoration(
hintText: 'F.eks. "Åben for en kaffesnak" eller "Op til at netværke"',
border: OutlineInputBorder(),
counterText: '',
),
),
const SizedBox(height: 24),
_SectionLabel(
'Hvad interesserer dig?',
hint: 'Vi finder folk med fælles interesser i nærheden',
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: kAvailableInterests.map((interest) {
final selected = _selected.contains(interest);
return FilterChip(
label: Text(interest),
selected: selected,
onSelected: (val) => setState(
() => val
? _selected.add(interest)
: _selected.remove(interest),
),
);
}).toList(),
),
const SizedBox(height: 16),
if (_selected.isEmpty)
Text(
'Vælg mindst ét emne for at bruge SigHej.',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 13,
),
),
],
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
child: FilledButton(
onPressed: _selected.isEmpty ? null : _save,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
),
child: Text(isSetup ? 'Kom i gang' : 'Gem'),
),
),
],
),
),
);
}
}
class _SectionLabel extends StatelessWidget {
final String label;
final String? hint;
const _SectionLabel(this.label, {this.hint});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.titleSmall),
if (hint != null) ...[
const SizedBox(height: 2),
Text(
hint!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
);
}
}

View File

@@ -0,0 +1,43 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:sighej/models/match_result.dart';
const _baseUrl = String.fromEnvironment('API_URL', defaultValue: 'http://10.0.2.2:8000');
/// Registers the user's profile with the backend session store.
Future<void> registerSession(
String bleToken,
List<String> interests, {
String name = '',
String tagline = '',
}) async {
final response = await http.post(
Uri.parse('$_baseUrl/session'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'ble_token': bleToken,
'interests': interests,
if (name.isNotEmpty) 'name': name,
if (tagline.isNotEmpty) 'tagline': tagline,
}),
);
if (response.statusCode != 200) {
throw Exception('Failed to register session: ${response.statusCode}');
}
}
/// Reports a detected BLE token to the backend and returns the match result.
/// Returns null if the backend returns a non-200 status (soft failure).
Future<MatchResult?> reportMatch(String ownToken, String detectedToken) async {
try {
final response = await http.post(
Uri.parse('$_baseUrl/match'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'own_token': ownToken, 'detected_token': detectedToken}),
);
if (response.statusCode != 200) return null;
return MatchResult.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
} catch (_) {
return null;
}
}

View File

@@ -0,0 +1,89 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/foundation.dart';
import 'package:uuid/uuid.dart';
/// Persists the user's profile (nickname, tagline, interests) and ephemeral BLE
/// token locally. No data is ever sent without explicit user consent (toggle).
class SessionStore extends ChangeNotifier {
static const _interestsKey = 'interests';
static const _tokenKey = 'ble_token';
static const _nameKey = 'profile_name';
static const _taglineKey = 'profile_tagline';
static const _profileDoneKey = 'profile_done';
List<String> _interests = [];
String _bleToken = '';
String _name = '';
String _tagline = '';
bool _profileDone = false;
List<String> get interests => List.unmodifiable(_interests);
String get bleToken => _bleToken;
String get name => _name;
String get tagline => _tagline;
/// True once the user has completed profile setup at least once.
bool get hasProfile => _profileDone;
/// Display name — falls back to "Anonym" if not set.
String get displayName => _name.isNotEmpty ? _name : 'Anonym';
SessionStore() {
_load();
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
_interests = prefs.getStringList(_interestsKey) ?? [];
_bleToken = prefs.getString(_tokenKey) ?? _newToken(prefs);
_name = prefs.getString(_nameKey) ?? '';
_tagline = prefs.getString(_taglineKey) ?? '';
_profileDone = prefs.getBool(_profileDoneKey) ?? false;
notifyListeners();
}
String _newToken(SharedPreferences prefs) {
final token = const Uuid().v4();
prefs.setString(_tokenKey, token);
return token;
}
/// Saves the full profile in one call. Marks profile as complete.
Future<void> saveProfile({
required String name,
required String tagline,
required List<String> interests,
}) async {
final prefs = await SharedPreferences.getInstance();
await Future.wait([
prefs.setString(_nameKey, name),
prefs.setString(_taglineKey, tagline),
prefs.setStringList(_interestsKey, interests),
prefs.setBool(_profileDoneKey, true),
]);
_name = name;
_tagline = tagline;
_interests = interests;
_profileDone = true;
notifyListeners();
}
/// Reset ephemeral identity — fresh BLE token, clears profile.
Future<void> reset() async {
final prefs = await SharedPreferences.getInstance();
await Future.wait([
prefs.remove(_interestsKey),
prefs.remove(_nameKey),
prefs.remove(_taglineKey),
prefs.remove(_profileDoneKey),
]);
_interests = [];
_name = '';
_tagline = '';
_profileDone = false;
_bleToken = _newToken(prefs);
notifyListeners();
}
}

107
app/lib/theme.dart Normal file
View File

@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
// SigHej color palette — "Sunny Beach Day"
const _charcoalBlue = Color(0xFF264653); // dark surface / text
const _verdigris = Color(0xFF2A9D8F); // primary (teal-green)
const _jasmine = Color(0xFFE9C46A); // tertiary / accents
const _sandyBrown = Color(0xFFF4A261); // secondary
const _burntPeach = Color(0xFFE76F51); // error / CTA accent
ThemeData buildTheme({bool dark = false}) {
final scheme = dark ? _darkScheme : _lightScheme;
return ThemeData(
colorScheme: scheme,
useMaterial3: true,
// Slightly rounded cards
cardTheme: const CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
),
// Filled buttons use primary
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
),
// Chips use secondary container for selected state
chipTheme: ChipThemeData(
selectedColor: scheme.secondaryContainer,
labelStyle: TextStyle(color: scheme.onSurface),
),
);
}
const _lightScheme = ColorScheme(
brightness: Brightness.light,
// Primary — Verdigris
primary: _verdigris,
onPrimary: Colors.white,
primaryContainer: Color(0xFFB2DFDB),
onPrimaryContainer: _charcoalBlue,
// Secondary — Sandy Brown
secondary: _sandyBrown,
onSecondary: Colors.white,
secondaryContainer: Color(0xFFFFE0C8),
onSecondaryContainer: _charcoalBlue,
// Tertiary — Jasmine
tertiary: _jasmine,
onTertiary: _charcoalBlue,
tertiaryContainer: Color(0xFFFFF3C4),
onTertiaryContainer: _charcoalBlue,
// Error — Burnt Peach
error: _burntPeach,
onError: Colors.white,
errorContainer: Color(0xFFFFDAD6),
onErrorContainer: Color(0xFF6B0000),
// Surface — warm off-white
surface: Color(0xFFFAF8F5),
onSurface: _charcoalBlue,
surfaceContainerHighest: Color(0xFFEDE9E3),
onSurfaceVariant: Color(0xFF4A6572),
outline: Color(0xFF8EA7B0),
outlineVariant: Color(0xFFCDD8DC),
shadow: Colors.black,
scrim: Colors.black,
inverseSurface: _charcoalBlue,
onInverseSurface: Colors.white,
inversePrimary: Color(0xFF80CBC4),
);
const _darkScheme = ColorScheme(
brightness: Brightness.dark,
primary: Color(0xFF80CBC4),
onPrimary: Color(0xFF00363A),
primaryContainer: _verdigris,
onPrimaryContainer: Color(0xFFB2DFDB),
secondary: Color(0xFFFFCC80),
onSecondary: Color(0xFF3E2000),
secondaryContainer: Color(0xFF7A3800),
onSecondaryContainer: Color(0xFFFFDDB4),
tertiary: _jasmine,
onTertiary: Color(0xFF3C2800),
tertiaryContainer: Color(0xFF564000),
onTertiaryContainer: Color(0xFFFFF3C4),
error: Color(0xFFFFB4AB),
onError: Color(0xFF6B0000),
errorContainer: _burntPeach,
onErrorContainer: Colors.white,
surface: Color(0xFF1A2A2E), // deep Charcoal Blue derived
onSurface: Color(0xFFE0EAED),
surfaceContainerHighest: Color(0xFF263238),
onSurfaceVariant: Color(0xFFB0C4CB),
outline: Color(0xFF607D8B),
outlineVariant: Color(0xFF37474F),
shadow: Colors.black,
scrim: Colors.black,
inverseSurface: Color(0xFFE0EAED),
onInverseSurface: _charcoalBlue,
inversePrimary: _verdigris,
);

26
app/pubspec.yaml Normal file
View File

@@ -0,0 +1,26 @@
name: sighej
description: Social Proximity — nudging people into real, face-to-face conversation.
publish_to: "none"
version: 0.1.0+1
environment:
sdk: ">=3.3.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
flutter_blue_plus: ^1.31.0 # BLE scanning and advertising
http: ^1.2.0 # REST API client
flutter_local_notifications: ^18.0.0 # Rich system notifications (no Firebase needed)
provider: ^6.1.0 # State management
shared_preferences: ^2.2.0 # Persist interests locally
permission_handler: ^11.3.0 # BLE + location permissions
uuid: ^4.3.0 # Ephemeral token generation
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
flutter:
uses-material-design: true

30
app/setup.sh Normal file
View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# One-time setup: generate native Android + iOS platform files for the SigHej Flutter app.
# Run this once from the project root (SigHej/) or from app/:
# cd app && bash setup.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
echo "📱 Generating Flutter platform files..."
flutter create \
--platforms=android,ios \
--project-name sighej \
--org dk.sighej \
.
echo "📦 Installing dependencies..."
flutter pub get
echo "✅ Setup complete."
echo ""
echo "Next steps:"
echo " Android: open app/android/ in Android Studio and run on device/emulator"
echo " iOS: open app/ios/Runner.xcworkspace in Xcode and run on device/simulator"
echo ""
echo "⚠️ After running flutter create, copy the permission files:"
echo " The AndroidManifest.xml and Info.plist in this repo contain the required BLE"
echo " and notification permissions. If flutter create overwrote them, restore with:"
echo " git checkout app/android/app/src/main/AndroidManifest.xml"
echo " git checkout app/ios/Runner/Info.plist"

4
backend/.env.example Normal file
View File

@@ -0,0 +1,4 @@
REDIS_URL=redis://localhost:6379
DATABASE_URL=postgresql+asyncpg://sighej:sighej@localhost/sighej
SESSION_TTL_SECONDS=7200
ALLOWED_ORIGINS=http://localhost:3000

11
backend/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

15
backend/README.md Normal file
View File

@@ -0,0 +1,15 @@
# backend
FastAPI backend for Social Proximity.
See [Docs/BACKEND.md](../Docs/BACKEND.md) for full documentation.
## Quick start
```bash
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000
```
Or from root: `docker-compose up`

0
backend/app/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,9 @@
from fastapi import APIRouter
from fastapi.responses import JSONResponse
router = APIRouter(tags=["health"])
@router.get("/health")
async def health() -> JSONResponse:
return JSONResponse({"status": "ok"})

19
backend/app/api/match.py Normal file
View File

@@ -0,0 +1,19 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.models.match import MatchRequest, MatchResult
from app.services import match_service, stats_service
router = APIRouter(tags=["match"])
@router.post("", response_model=MatchResult)
async def attempt_match(
body: MatchRequest,
db: AsyncSession = Depends(get_db),
) -> MatchResult:
result = await match_service.attempt_match(body.own_token, body.detected_token)
if result["match"]:
await stats_service.increment_matches(db)
return MatchResult(**result)

View File

@@ -0,0 +1,18 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.models.session import SessionCreate, SessionResponse
from app.services import session_service, stats_service
router = APIRouter(tags=["session"])
@router.post("", response_model=SessionResponse)
async def create_session(
body: SessionCreate,
db: AsyncSession = Depends(get_db),
) -> SessionResponse:
result = await session_service.create_session(body.ble_token, body.interests)
await stats_service.increment_sessions(db)
return SessionResponse(**result)

14
backend/app/api/stats.py Normal file
View File

@@ -0,0 +1,14 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.models.stats import StatsResponse
from app.services import stats_service
router = APIRouter(tags=["stats"])
@router.get("", response_model=StatsResponse)
async def get_stats(db: AsyncSession = Depends(get_db)) -> StatsResponse:
data = await stats_service.get_stats(db)
return StatsResponse(**data)

16
backend/app/api/ws.py Normal file
View File

@@ -0,0 +1,16 @@
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.services.ws_manager import manager
router = APIRouter(tags=["websocket"])
@router.websocket("/ws/{ble_token}")
async def websocket_endpoint(websocket: WebSocket, ble_token: str) -> None:
await manager.connect(ble_token, websocket)
try:
while True:
# Keep the connection alive; nudges are pushed server-side.
await websocket.receive_text()
except WebSocketDisconnect:
manager.disconnect(ble_token)

View File

@@ -0,0 +1,3 @@
from app.core.config import settings # noqa: F401
from app.core.database import get_db # noqa: F401
from app.core.redis import get_client # noqa: F401

View File

@@ -0,0 +1,13 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
redis_url: str = "redis://localhost:6379"
database_url: str = "postgresql+asyncpg://sighej:sighej@localhost/sighej"
session_ttl_seconds: int = 7200
allowed_origins: list[str] = ["http://localhost:3000"]
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
settings = Settings()

View File

@@ -0,0 +1,21 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
engine = create_async_engine(settings.database_url, echo=False)
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def create_tables() -> None:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def get_db() -> AsyncSession:
async with AsyncSessionLocal() as session:
yield session

16
backend/app/core/redis.py Normal file
View File

@@ -0,0 +1,16 @@
import redis.asyncio as redis
from app.core.config import settings
_pool: redis.ConnectionPool | None = None
def get_pool() -> redis.ConnectionPool:
global _pool
if _pool is None:
_pool = redis.ConnectionPool.from_url(settings.redis_url, decode_responses=True)
return _pool
def get_client() -> redis.Redis:
return redis.Redis(connection_pool=get_pool())

34
backend/app/main.py Normal file
View File

@@ -0,0 +1,34 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api import health, session, match, ws, stats
from app.core.config import settings
from app.core.database import create_tables
@asynccontextmanager
async def lifespan(app: FastAPI):
await create_tables()
yield
app = FastAPI(
title="Social Proximity API",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.allowed_origins,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(health.router)
app.include_router(session.router, prefix="/session")
app.include_router(match.router, prefix="/match")
app.include_router(ws.router)
app.include_router(stats.router, prefix="/stats")

View File

View File

@@ -0,0 +1,20 @@
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class DailyStat(Base):
__tablename__ = "daily_stats"
date: Mapped[sa.Date] = mapped_column(sa.Date, primary_key=True)
total_sessions: Mapped[int] = mapped_column(sa.Integer, default=0)
total_matches: Mapped[int] = mapped_column(sa.Integer, default=0)
class InterestCategoryCount(Base):
__tablename__ = "interest_category_counts"
date: Mapped[sa.Date] = mapped_column(sa.Date, primary_key=True)
category: Mapped[str] = mapped_column(sa.String(64), primary_key=True)
count: Mapped[int] = mapped_column(sa.Integer, default=0)

View File

@@ -0,0 +1,12 @@
from pydantic import BaseModel
class MatchRequest(BaseModel):
own_token: str
detected_token: str
class MatchResult(BaseModel):
match: bool
shared_interests: list[str]
nudge_sent: bool

View File

@@ -0,0 +1,13 @@
from datetime import datetime
from pydantic import BaseModel, Field
class SessionCreate(BaseModel):
ble_token: str = Field(..., min_length=1, max_length=128)
interests: list[str] = Field(..., min_length=1, max_length=20)
class SessionResponse(BaseModel):
session_id: str
expires_at: datetime

View File

@@ -0,0 +1,8 @@
from pydantic import BaseModel
class StatsResponse(BaseModel):
total_sessions_today: int
total_matches_today: int
active_sessions_now: int
top_interest_categories: list[str]

View File

View File

@@ -0,0 +1,38 @@
from app.services.session_service import get_session
from app.services.ws_manager import manager
async def attempt_match(own_token: str, detected_token: str) -> dict:
"""
Check if two nearby users share interests and both consent.
Sends a WebSocket nudge to both if matched.
"""
own_session = await get_session(own_token)
other_session = await get_session(detected_token)
if own_session is None or other_session is None:
return {"match": False, "shared_interests": [], "nudge_sent": False}
own_interests = set(own_session.get("interests", []))
other_interests = set(other_session.get("interests", []))
shared = list(own_interests & other_interests)
if not shared:
return {"match": False, "shared_interests": [], "nudge_sent": False}
nudge = {
"type": "nudge",
"shared_interests": shared,
"message": f"Someone nearby shares your interest in {_format_interests(shared)}.",
}
await manager.send(own_token, nudge)
await manager.send(detected_token, nudge)
return {"match": True, "shared_interests": shared, "nudge_sent": True}
def _format_interests(interests: list[str]) -> str:
if len(interests) == 1:
return interests[0]
return ", ".join(interests[:-1]) + " and " + interests[-1]

View File

@@ -0,0 +1,39 @@
import json
import uuid
from datetime import UTC, datetime, timedelta
from app.core.config import settings
from app.core.redis import get_client
async def create_session(ble_token: str, interests: list[str]) -> dict:
"""Store an ephemeral BLE session in Redis with TTL."""
client = get_client()
session_id = str(uuid.uuid4())
expires_at = datetime.now(UTC) + timedelta(seconds=settings.session_ttl_seconds)
payload = json.dumps({
"session_id": session_id,
"interests": interests,
"created_at": datetime.now(UTC).isoformat(),
})
await client.setex(f"session:{ble_token}", settings.session_ttl_seconds, payload)
return {"session_id": session_id, "expires_at": expires_at}
async def get_session(ble_token: str) -> dict | None:
"""Retrieve a session by BLE token. Returns None if expired or not found."""
client = get_client()
raw = await client.get(f"session:{ble_token}")
if raw is None:
return None
return json.loads(raw)
async def count_active_sessions() -> int:
"""Return number of active (non-expired) sessions."""
client = get_client()
keys = await client.keys("session:*")
return len(keys)

View File

@@ -0,0 +1,60 @@
from datetime import UTC, date, datetime
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.db_models import DailyStat, InterestCategoryCount
from app.services.session_service import count_active_sessions
async def get_stats(db: AsyncSession) -> dict:
"""Return anonymised aggregate stats for the admin dashboard."""
today = date.today()
row = await db.scalar(
sa.select(DailyStat).where(DailyStat.date == today)
)
top_categories_rows = await db.scalars(
sa.select(InterestCategoryCount)
.where(InterestCategoryCount.date == today)
.order_by(InterestCategoryCount.count.desc())
.limit(5)
)
top_categories = [r.category for r in top_categories_rows]
active = await count_active_sessions()
return {
"total_sessions_today": row.total_sessions if row else 0,
"total_matches_today": row.total_matches if row else 0,
"active_sessions_now": active,
"top_interest_categories": top_categories,
}
async def increment_sessions(db: AsyncSession) -> None:
"""Increment daily session counter. Called when a new session is created."""
today = date.today()
await db.execute(
sa.dialects.postgresql.insert(DailyStat)
.values(date=today, total_sessions=1, total_matches=0)
.on_conflict_do_update(
index_elements=["date"],
set_={"total_sessions": DailyStat.total_sessions + 1},
)
)
await db.commit()
async def increment_matches(db: AsyncSession) -> None:
"""Increment daily match counter. Called on a successful match."""
today = date.today()
await db.execute(
sa.dialects.postgresql.insert(DailyStat)
.values(date=today, total_sessions=0, total_matches=1)
.on_conflict_do_update(
index_elements=["date"],
set_={"total_matches": DailyStat.total_matches + 1},
)
)
await db.commit()

View File

@@ -0,0 +1,25 @@
import json
from fastapi import WebSocket
class ConnectionManager:
"""Manages active WebSocket connections keyed by BLE token."""
def __init__(self) -> None:
self._connections: dict[str, WebSocket] = {}
async def connect(self, token: str, websocket: WebSocket) -> None:
await websocket.accept()
self._connections[token] = websocket
def disconnect(self, token: str) -> None:
self._connections.pop(token, None)
async def send(self, token: str, message: dict) -> None:
websocket = self._connections.get(token)
if websocket is not None:
await websocket.send_text(json.dumps(message))
manager = ConnectionManager()

11
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,11 @@
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
pythonpath = ["."]
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I"]
ignore = ["E501"]

View File

@@ -0,0 +1,4 @@
-r requirements.txt
pytest>=8.0.0
pytest-asyncio>=0.23.0
httpx>=0.27.0

8
backend/requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
fastapi>=0.111.0
uvicorn[standard]>=0.29.0
pydantic>=2.7.0
pydantic-settings>=2.2.0
redis[asyncio]>=5.0.0
asyncpg>=0.29.0
sqlalchemy[asyncio]>=2.0.0
websockets>=12.0

View File

@@ -0,0 +1,12 @@
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.mark.asyncio
async def test_health() -> None:
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
response = await client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}

View File

@@ -0,0 +1,61 @@
from unittest.mock import AsyncMock, patch
import pytest
from app.services.match_service import attempt_match, _format_interests
@pytest.mark.asyncio
async def test_no_match_when_sessions_missing() -> None:
with patch("app.services.match_service.get_session", return_value=None):
result = await attempt_match("token_a", "token_b")
assert result["match"] is False
assert result["nudge_sent"] is False
@pytest.mark.asyncio
async def test_no_match_when_no_shared_interests() -> None:
sessions = {
"token_a": {"interests": ["devops"]},
"token_b": {"interests": ["cooking"]},
}
async def fake_get(token: str) -> dict:
return sessions.get(token)
with patch("app.services.match_service.get_session", side_effect=fake_get):
with patch("app.services.match_service.manager") as mock_mgr:
result = await attempt_match("token_a", "token_b")
assert result["match"] is False
mock_mgr.send.assert_not_called()
@pytest.mark.asyncio
async def test_match_with_shared_interests() -> None:
sessions = {
"token_a": {"interests": ["devops", "hardstyle"]},
"token_b": {"interests": ["hardstyle", "philosophy"]},
}
async def fake_get(token: str) -> dict:
return sessions.get(token)
with patch("app.services.match_service.get_session", side_effect=fake_get):
with patch("app.services.match_service.manager") as mock_mgr:
mock_mgr.send = AsyncMock()
result = await attempt_match("token_a", "token_b")
assert result["match"] is True
assert "hardstyle" in result["shared_interests"]
assert result["nudge_sent"] is True
assert mock_mgr.send.call_count == 2
def test_format_interests_single() -> None:
assert _format_interests(["devops"]) == "devops"
def test_format_interests_multiple() -> None:
result = _format_interests(["devops", "hardstyle"])
assert result == "devops and hardstyle"

View File

@@ -0,0 +1,68 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.mark.asyncio
async def test_stats_zero_sessions() -> None:
"""Stats endpoint returns zeroed StatsResponse when no data exists."""
mock_db = AsyncMock()
with (
patch("app.services.stats_service.count_active_sessions", return_value=0),
patch("app.core.database.get_db", return_value=mock_db),
):
# DB returns None for DailyStat row and empty for interest categories
mock_db.scalar = AsyncMock(return_value=None)
mock_db.scalars = AsyncMock(return_value=iter([]))
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.get("/stats")
assert response.status_code == 200
data = response.json()
assert "active_sessions_now" in data
assert "total_sessions_today" in data
assert "total_matches_today" in data
assert "top_interest_categories" in data
assert data["active_sessions_now"] == 0
assert data["total_sessions_today"] == 0
assert data["total_matches_today"] == 0
assert data["top_interest_categories"] == []
@pytest.mark.asyncio
async def test_stats_with_data() -> None:
"""Stats endpoint returns correct values when DailyStat row exists."""
mock_stat = MagicMock()
mock_stat.total_sessions = 12
mock_stat.total_matches = 5
mock_category = MagicMock()
mock_category.category = "hiking"
mock_db = AsyncMock()
with (
patch("app.services.stats_service.count_active_sessions", return_value=3),
patch("app.core.database.get_db", return_value=mock_db),
):
mock_db.scalar = AsyncMock(return_value=mock_stat)
mock_db.scalars = AsyncMock(return_value=iter([mock_category]))
async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://test"
) as client:
response = await client.get("/stats")
assert response.status_code == 200
data = response.json()
assert data["active_sessions_now"] == 3
assert data["total_sessions_today"] == 12
assert data["total_matches_today"] == 5
assert data["top_interest_categories"] == ["hiking"]

46
docker-compose.yml Normal file
View File

@@ -0,0 +1,46 @@
version: "3.9"
services:
backend:
build: ./backend
ports:
- "8000:8000"
environment:
- REDIS_URL=redis://redis:6379
- DATABASE_URL=postgresql+asyncpg://sighej:sighej@postgres/sighej
- SESSION_TTL_SECONDS=7200
- ALLOWED_ORIGINS=http://localhost:3000
depends_on:
- redis
- postgres
volumes:
- ./backend:/app
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
redis:
image: redis:7-alpine
ports:
- "6379:6379"
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: sighej
POSTGRES_USER: sighej
POSTGRES_PASSWORD: sighej
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
admin:
build: ./admin
ports:
- "3000:3000"
environment:
- BACKEND_URL=http://backend:8000
depends_on:
- backend
volumes:
postgres_data: