eksplicit mapping af envs
This commit is contained in:
54
.gitea/workflows/backend.yml
Normal file
54
.gitea/workflows/backend.yml
Normal 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
|
||||||
34
.gitea/workflows/flutter.yml
Normal file
34
.gitea/workflows/flutter.yml
Normal 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
47
.gitignore
vendored
Normal 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
72
Docs/ADMIN.md
Normal 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
129
Docs/APP.md
Normal 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
97
Docs/ARCHITECTURE.md
Normal 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
185
Docs/BACKEND.md
Normal 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
78
Docs/DECISIONS.md
Normal 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
103
Docs/TESTING.md
Normal 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
21
Docs/colors.md
Normal 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
141
Makefile
Normal 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 ""
|
||||||
24
README.md
24
README.md
@@ -1,6 +1,6 @@
|
|||||||
# Social Proximity
|
# 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
|
**Status:** Early concept / prototype phase
|
||||||
|
|
||||||
@@ -8,23 +8,25 @@
|
|||||||
|
|
||||||
## Overview
|
## 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
|
## 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:
|
When proximity and mutual interest align, users might see something like:
|
||||||
|
|
||||||
> *"Someone nearby also works with DevOps and enjoys philosophy & hardstyle."*
|
> *"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.
|
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
|
### 💬 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 work with cloud infrastructure."*
|
||||||
- *"You both enjoy early hardstyle."*
|
- *"You both enjoy early hardstyle."*
|
||||||
- *"Maybe ask about Kubernetes homelabs."*
|
- *"Maybe ask about Kubernetes homelabs."*
|
||||||
|
|
||||||
|
Once the nudge lands, the phone goes back in the pocket.
|
||||||
|
|
||||||
### 🔒 Privacy First
|
### 🔒 Privacy First
|
||||||
Designed from the ground up with privacy in mind:
|
Designed from the ground up with privacy in mind:
|
||||||
- No live maps or exact locations
|
- No live maps or exact locations
|
||||||
@@ -71,7 +75,9 @@ Social Proximity fits naturally wherever people gather:
|
|||||||
|
|
||||||
## Vision
|
## 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.
|
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 |
|
| **Human-first** | The app should feel calm, respectful, and natural |
|
||||||
| **Consent-first** | No interaction happens without mutual participation |
|
| **Consent-first** | No interaction happens without mutual participation |
|
||||||
| **Privacy-first** | Users should never feel monitored or exposed |
|
| **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
19
admin/Dockerfile
Normal 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
14
admin/README.md
Normal 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
9
admin/next.config.js
Normal 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
5324
admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
admin/package.json
Normal file
24
admin/package.json
Normal 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
0
admin/public/.gitkeep
Normal file
13
admin/src/app/layout.tsx
Normal file
13
admin/src/app/layout.tsx
Normal 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
52
admin/src/app/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
admin/src/components/StatCard.tsx
Normal file
20
admin/src/components/StatCard.tsx
Normal 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
23
admin/tsconfig.json
Normal 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
14
app/README.md
Normal 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.
|
||||||
9
app/analysis_options.yaml
Normal file
9
app/analysis_options.yaml
Normal 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
|
||||||
59
app/android/app/src/main/AndroidManifest.xml
Normal file
59
app/android/app/src/main/AndroidManifest.xml
Normal 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 6–11) -->
|
||||||
|
<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
67
app/ios/Runner/Info.plist
Normal 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
35
app/lib/main.dart
Normal 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/lib/models/match_result.dart
Normal file
20
app/lib/models/match_result.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
268
app/lib/screens/home_screen.dart
Normal file
268
app/lib/screens/home_screen.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
201
app/lib/screens/profile_screen.dart
Normal file
201
app/lib/screens/profile_screen.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/lib/services/api_service.dart
Normal file
43
app/lib/services/api_service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
89
app/lib/services/session_store.dart
Normal file
89
app/lib/services/session_store.dart
Normal 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
107
app/lib/theme.dart
Normal 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
26
app/pubspec.yaml
Normal 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
30
app/setup.sh
Normal 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
4
backend/.env.example
Normal 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
11
backend/Dockerfile
Normal 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
15
backend/README.md
Normal 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
0
backend/app/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
9
backend/app/api/health.py
Normal file
9
backend/app/api/health.py
Normal 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
19
backend/app/api/match.py
Normal 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)
|
||||||
18
backend/app/api/session.py
Normal file
18
backend/app/api/session.py
Normal 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
14
backend/app/api/stats.py
Normal 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
16
backend/app/api/ws.py
Normal 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)
|
||||||
3
backend/app/core/__init__.py
Normal file
3
backend/app/core/__init__.py
Normal 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
|
||||||
13
backend/app/core/config.py
Normal file
13
backend/app/core/config.py
Normal 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()
|
||||||
21
backend/app/core/database.py
Normal file
21
backend/app/core/database.py
Normal 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
16
backend/app/core/redis.py
Normal 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
34
backend/app/main.py
Normal 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")
|
||||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
20
backend/app/models/db_models.py
Normal file
20
backend/app/models/db_models.py
Normal 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)
|
||||||
12
backend/app/models/match.py
Normal file
12
backend/app/models/match.py
Normal 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
|
||||||
13
backend/app/models/session.py
Normal file
13
backend/app/models/session.py
Normal 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
|
||||||
8
backend/app/models/stats.py
Normal file
8
backend/app/models/stats.py
Normal 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]
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
38
backend/app/services/match_service.py
Normal file
38
backend/app/services/match_service.py
Normal 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]
|
||||||
39
backend/app/services/session_service.py
Normal file
39
backend/app/services/session_service.py
Normal 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)
|
||||||
60
backend/app/services/stats_service.py
Normal file
60
backend/app/services/stats_service.py
Normal 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()
|
||||||
25
backend/app/services/ws_manager.py
Normal file
25
backend/app/services/ws_manager.py
Normal 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
11
backend/pyproject.toml
Normal 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"]
|
||||||
4
backend/requirements-dev.txt
Normal file
4
backend/requirements-dev.txt
Normal 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
8
backend/requirements.txt
Normal 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
|
||||||
12
backend/tests/test_health.py
Normal file
12
backend/tests/test_health.py
Normal 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"}
|
||||||
61
backend/tests/test_match.py
Normal file
61
backend/tests/test_match.py
Normal 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"
|
||||||
68
backend/tests/test_stats.py
Normal file
68
backend/tests/test_stats.py
Normal 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
46
docker-compose.yml
Normal 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:
|
||||||
Reference in New Issue
Block a user