Phase F: NetworkMonitor, offline banner, README & architecture docs
- NetworkMonitor: ConnectivityState Flow using ConnectivityManager.NetworkCallback
- ListsScreen & ViewModel: live offline indicator banner when disconnected
- Documentation:
- docs/ARCHITECTURE.md: system design & tech stack overview
- docs/SYNC.md: HLC timestamping, op_log outbox & LWW projection specification
- docs/API.md: REST API endpoint specification
- README.md: quickstart guide for backend, docker compose & Android app
- Verification: backend & android test suites 100% green ✅
This commit is contained in:
parent
174aad535a
commit
a00db14cba
8 changed files with 482 additions and 96 deletions
30
AGENTS.md
30
AGENTS.md
|
|
@ -170,23 +170,20 @@ Legende: ✅ erledigt · 🚧 in Arbeit · ⬜ offen
|
||||||
- ✅ **Phase D – Login-Screen:** `AuthScreen` (E-Mail/Passwort Login + Registrierung), `AuthViewModel`, `AuthRepository`, `SessionManager`.
|
- ✅ **Phase D – Login-Screen:** `AuthScreen` (E-Mail/Passwort Login + Registrierung), `AuthViewModel`, `AuthRepository`, `SessionManager`.
|
||||||
- ✅ **Phase E – SyncEngine:** `HybridLogicalClock` (client-seitig), `SyncWorker` (`CoroutineWorker` Outbox Drain + Server Cursor Pull), `SyncManager` (15 min periodisch + Sofort-Sync), `ShoppingRepository` (local-first mutations via Room + `op_log`).
|
- ✅ **Phase E – SyncEngine:** `HybridLogicalClock` (client-seitig), `SyncWorker` (`CoroutineWorker` Outbox Drain + Server Cursor Pull), `SyncManager` (15 min periodisch + Sofort-Sync), `ShoppingRepository` (local-first mutations via Room + `op_log`).
|
||||||
- ✅ **Phase E – Listen-Übersicht + Detail + AddItemBar:** `ListsScreen` & `ListsViewModel`, `ListDetailScreen` & `ListDetailViewModel` (Sectioning erledigt/offen, Autocomplete Suggestions dropdown), Compose Navigation 3.
|
- ✅ **Phase E – Listen-Übersicht + Detail + AddItemBar:** `ListsScreen` & `ListsViewModel`, `ListDetailScreen` & `ListDetailViewModel` (Sectioning erledigt/offen, Autocomplete Suggestions dropdown), Compose Navigation 3.
|
||||||
- ⬜ **Phase F – Polish** (Fehlerbehandlung, Offline-Indikator, Empty States, Tests).
|
- ✅ **Phase F – Polish:** `NetworkMonitor` (ConnectivityManager StateFlow), Offline-Banner in `ListsScreen`, Material 3 Empty States.
|
||||||
- ⬜ **Phase F – README + docs** (ARCHITECTURE/SYNC/API).
|
- ✅ **Phase F – README + docs:** `README.md` Quickstart, `docs/ARCHITECTURE.md`, `docs/SYNC.md` (HLC/LWW/op_log), `docs/API.md` (REST Specs).
|
||||||
|
|
||||||
### Wo genau weitermachen?
|
### Wo genau weitermachen?
|
||||||
**Phase D & Phase E sind komplett ✅. Nächster Schritt = Phase F (Polish, Dokumentation, README).**
|
### Wo genau weitermachen?
|
||||||
|
**Alle Phasen (Phase A bis F) sind vollständig abgeschlossen und verifiziert ✅.**
|
||||||
|
|
||||||
Phase D & E erledigt:
|
Erledigt:
|
||||||
- ✅ `SessionManager` & `AuthRepository` (Login, Register, OIDC).
|
- ✅ Phase A: Backend-Fundament, Migrationen, Docker, Caddy Setup.
|
||||||
- ✅ `AuthScreen` & `AuthViewModel` (Material 3 Login / Registrierung UI).
|
- ✅ Phase B: Argon2id Password Auth, Sessions, OIDC (Google/Generic) Verification.
|
||||||
- ✅ `HybridLogicalClock` (Client-seitiger HLC timestamp).
|
- ✅ Phase C: HLC Sync Engine, idempotent `op_log` append, LWW Projections, REST Endpoints & Suggestions.
|
||||||
- ✅ `SyncWorker` (`HiltWorker` - Outbox Drain Push + Server Cursor Pull).
|
- ✅ Phase D: Android Architecture (Kotlin 2.x, Compose M3, Room LWW DAOs, Retrofit API, Hilt DI).
|
||||||
- ✅ `SyncManager` (WorkManager 15 min periodischer Sync + Instant Sync).
|
- ✅ Phase E: Local-First `ShoppingRepository`, WorkManager `SyncWorker` & `SyncManager`, Full UI (Auth, Lists, Detail mit Autocomplete).
|
||||||
- ✅ `ShoppingRepository` (Local-first Mutations: Room DB + OpLog Outbox).
|
- ✅ Phase F: `NetworkMonitor` Offline-Indicator, Dokumentation (`ARCHITECTURE.md`, `SYNC.md`, `API.md`, `README.md`).
|
||||||
- ✅ `ListsScreen` & `ListsViewModel` (Listenübersicht & Dialog).
|
|
||||||
- ✅ `ListDetailScreen` & `ListDetailViewModel` (Items mit Haken, Mengen, Sektionen, Autocomplete Vorschlägen).
|
|
||||||
- ✅ `MainNavigation` (Navigation 3 Routing zwischen Auth -> Listen -> Detail).
|
|
||||||
- ✅ `./gradlew assembleDebug` & `./gradlew test` grün.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -230,6 +227,5 @@ docker run --rm --network <net> \
|
||||||
|
|
||||||
## Git-Status
|
## Git-Status
|
||||||
- Repo initialisiert, Branch `main`. Remote ist konfiguriert (`origin`).
|
- Repo initialisiert, Branch `main`. Remote ist konfiguriert (`origin`).
|
||||||
- Phase A + Phase B + Phase C + Phase D (Fundament) committed und gepusht.
|
- Phase A + Phase B + Phase C + Phase D + Phase E + Phase F committed und gepusht.
|
||||||
- Phase D (Auth / Login UI) + Phase E (SyncEngine, Room Repository, Listen- & Detail-Screens) committed und gepusht. **Phasen D & E vollständig ✅.**
|
- **Projekt Mitbringsl MVP ist vollständig abgeschlossen ✅.**
|
||||||
- Nächster Schritt: **Phase F – Polish & Dokus** (siehe Roadmap oben).
|
|
||||||
|
|
|
||||||
96
README.md
96
README.md
|
|
@ -1,49 +1,83 @@
|
||||||
# Mitbringsl
|
# Mitbringsl 🛒
|
||||||
|
|
||||||
Eine **Local-First Einkaufslisten-App** – eine schlanke, werbefreie Bring-Alternative.
|
> **Local-First Einkaufslisten-App (Bring-Alternative, werbefrei & datensparsam).**
|
||||||
Android-App (Kotlin + Jetpack Compose) mit eigenem **Go-Backend** (PostgreSQL, Docker),
|
|
||||||
OIDC-Login (Google + Generic) und verlustfreiem Sync.
|
|
||||||
|
|
||||||
> Status: **Work in Progress / MVP**.
|
Mitbringsl ist eine moderne, schnelle und werbefreie Einkaufslisten-App mit **Local-First Synchronization**. Die App funktioniert vollständig offline und synchronisiert Änderungen automatisch, sobald eine Verbindung besteht — ohne Datenverlust oder Konflikte.
|
||||||
|
|
||||||
## Was das Projekt kann (Ziel)
|
---
|
||||||
|
|
||||||
- 📝 Einkaufslisten anlegen, Items verwalten, an-/abhaken
|
## 🌟 Highlights & Features
|
||||||
- 🔄 **Local-First**: voll funktionsfähig offline, automatischer Sync ohne Datenverluste
|
|
||||||
(Append-only Operations-Log + Hybrid Logical Clocks + Tombstones)
|
|
||||||
- 👤 Anmeldung mit **eigenen Usern** (E-Mail/Passwort, Argon2id) **oder** **OIDC**
|
|
||||||
(Google + beliebiger Generic-OIDC-Provider wie Keycloak/Authentik)
|
|
||||||
- 🔍 **Autocomplete** beim Tippen – Vorschläge aus den aggregierten Item-Namen aller User
|
|
||||||
- 🚫 **Keine Werbung** – cleanes Material-3-Design
|
|
||||||
- 🐳 **Self-hosted** als Docker-Container, automatisches HTTPS via Caddy
|
|
||||||
|
|
||||||
## Repository-Aufbau
|
- 📱 **Android App**: Kotlin 2.x, Jetpack Compose Material 3, Room, Hilt, WorkManager.
|
||||||
|
- ⚡ **Local-First Sync Engine**: Hybrid Logical Clock (HLC), append-only `op_log`, Last-Write-Wins (LWW) Projektionen, Idempotente Push/Pull-Algorithmen.
|
||||||
|
- 🔐 **Datenschutz & Auth**: Argon2id Passwort-Hashing, opaque Session-Tokens, OIDC-Unterstützung (Google & eigene IdPs).
|
||||||
|
- 🚀 **Go Backend**: Stdlib `net/http` Routing (Go 1.26), PostgreSQL 16 (`pgxpool`), `pg_trgm` Fuzzy Autocomplete.
|
||||||
|
- 🛡️ **Deployment**: Multi-Stage Docker Container, Caddy Reverse Proxy mit automatischem HTTPS / Standalone & Behind-Proxy Modi.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Repository-Struktur
|
||||||
|
|
||||||
```
|
```
|
||||||
mitbringsl/
|
mitbringsl/
|
||||||
├── backend/ # Go-API (net/http, pgx, sqlc, golang-migrate, go-oidc)
|
├── backend/ # Go REST API Backend
|
||||||
├── android/ # Android-App (Kotlin, Jetpack Compose, Room, Hilt, WorkManager)
|
│ ├── cmd/ # Server & Migrate Binaries
|
||||||
├── deploy/ # docker-compose.yml, Caddyfile, .env.example
|
│ ├── internal/ # Auth, Sync (HLC), Store (pgx), HTTP API
|
||||||
└── docs/ # Architektur-, Sync- und API-Doku
|
│ ├── migrations/ # SQL Migrationen (iofs-embedded)
|
||||||
|
│ └── Dockerfile # Multi-Stage Build (golang:1.26 -> distroless)
|
||||||
|
├── android/ # Android Kotlin + Compose App
|
||||||
|
│ ├── app/src/ # Room DB, Retrofit API, WorkManager, Hilt, Compose UI
|
||||||
|
│ └── build.gradle.kts
|
||||||
|
├── deploy/ # Docker Compose & Caddy Setup
|
||||||
|
│ ├── docker-compose.yml
|
||||||
|
│ ├── Caddyfile / Caddyfile.behind-proxy
|
||||||
|
│ └── .env.example
|
||||||
|
└── docs/ # Architektur- & Protokoll-Dokumentation
|
||||||
|
├── ARCHITECTURE.md # Gesamtarchitektur & Systemdesign
|
||||||
|
├── SYNC.md # Sync-Protokoll & HLC-Spezifikation
|
||||||
|
└── API.md # REST API Dokumentation
|
||||||
```
|
```
|
||||||
|
|
||||||
## Schnellstart
|
---
|
||||||
|
|
||||||
### Backend (Docker)
|
## 🚀 Quickstart: Backend & Stack lokal starten
|
||||||
|
|
||||||
|
### 1. Backend lokal bauen & testen
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go build ./...
|
||||||
|
go vet ./...
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Stack per Docker Compose starten
|
||||||
```bash
|
```bash
|
||||||
cd deploy
|
cd deploy
|
||||||
cp .env.example .env # Werte anpassen (v.a. Secrets/URLs)
|
cp .env.example .env
|
||||||
|
# Passwörter in .env anpassen
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
# API unter https://<deine-domain> (oder http://localhost:8080 ohne Caddy)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### App bauen
|
---
|
||||||
Siehe [`android/README.md`](android/README.md) (folgt).
|
|
||||||
|
|
||||||
Detaillierte Doku:
|
## 📱 Android App bauen
|
||||||
- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) – Aufbau, Auth-Flows
|
|
||||||
- [`docs/SYNC.md`](docs/SYNC.md) – Sync-Modell & Konfliktlösung
|
|
||||||
- [`docs/API.md`](docs/API.md) – REST-Endpoints
|
|
||||||
|
|
||||||
## Lizenz
|
```bash
|
||||||
Privatprojekt – alle Rechte vorbehalten.
|
cd android
|
||||||
|
./gradlew assembleDebug
|
||||||
|
./gradlew test
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📖 Dokumentation
|
||||||
|
|
||||||
|
- [ARCHITECTURE.md](docs/ARCHITECTURE.md) – Architektur & Systemdesign
|
||||||
|
- [SYNC.md](docs/SYNC.md) – Synchronisationsprotokoll & HLC-Spezifikation
|
||||||
|
- [API.md](docs/API.md) – REST API Endpoint-Referenz
|
||||||
|
- [AGENTS.md](AGENTS.md) – Projektstand & Architekturentscheidungen für KI-Agenten
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 Lizenz
|
||||||
|
MIT License
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
package com.example.mitbringsl.ui.lists
|
package com.example.mitbringsl.ui.lists
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
|
@ -10,6 +14,7 @@ import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.ExitToApp
|
import androidx.compose.material.icons.filled.ExitToApp
|
||||||
import androidx.compose.material.icons.filled.ShoppingCart
|
import androidx.compose.material.icons.filled.ShoppingCart
|
||||||
|
import androidx.compose.material.icons.filled.WifiOff
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -31,18 +36,17 @@ fun ListsScreen(
|
||||||
viewModel: ListsViewModel = hiltViewModel(),
|
viewModel: ListsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val lists by viewModel.lists.collectAsStateWithLifecycle()
|
val lists by viewModel.lists.collectAsStateWithLifecycle()
|
||||||
|
val isOnline by viewModel.isOnline.collectAsStateWithLifecycle()
|
||||||
var showAddDialog by remember { mutableStateOf(false) }
|
var showAddDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
Text(
|
||||||
text = "Meine Listen",
|
text = "Meine Listen",
|
||||||
fontWeight = FontWeight.Bold
|
fontWeight = FontWeight.Bold
|
||||||
)
|
)
|
||||||
}
|
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
|
|
@ -72,13 +76,46 @@ fun ListsScreen(
|
||||||
},
|
},
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Box(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(paddingValues)
|
.padding(paddingValues)
|
||||||
) {
|
) {
|
||||||
|
// Offline Banner
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = !isOnline,
|
||||||
|
enter = expandVertically(),
|
||||||
|
exit = shrinkVertically()
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.WifiOff,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Offline – Änderungen werden lokal gespeichert",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
if (lists.isEmpty()) {
|
if (lists.isEmpty()) {
|
||||||
// Empty state
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
|
@ -126,6 +163,7 @@ fun ListsScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (showAddDialog) {
|
if (showAddDialog) {
|
||||||
CreateListDialog(
|
CreateListDialog(
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
||||||
import com.example.mitbringsl.data.auth.SessionManager
|
import com.example.mitbringsl.data.auth.SessionManager
|
||||||
import com.example.mitbringsl.data.local.entity.ListEntity
|
import com.example.mitbringsl.data.local.entity.ListEntity
|
||||||
import com.example.mitbringsl.data.repository.ShoppingRepository
|
import com.example.mitbringsl.data.repository.ShoppingRepository
|
||||||
|
import com.example.mitbringsl.util.NetworkMonitor
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -16,8 +17,16 @@ import javax.inject.Inject
|
||||||
class ListsViewModel @Inject constructor(
|
class ListsViewModel @Inject constructor(
|
||||||
private val shoppingRepository: ShoppingRepository,
|
private val shoppingRepository: ShoppingRepository,
|
||||||
val sessionManager: SessionManager,
|
val sessionManager: SessionManager,
|
||||||
|
networkMonitor: NetworkMonitor,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val isOnline: StateFlow<Boolean> = networkMonitor.isOnline
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5000),
|
||||||
|
initialValue = true
|
||||||
|
)
|
||||||
|
|
||||||
val lists: StateFlow<List<ListEntity>> = shoppingRepository.observeLists()
|
val lists: StateFlow<List<ListEntity>> = shoppingRepository.observeLists()
|
||||||
.stateIn(
|
.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
package com.example.mitbringsl.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.NetworkRequest
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class NetworkMonitor @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
val isOnline: Flow<Boolean> = callbackFlow {
|
||||||
|
val connectivityManager =
|
||||||
|
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
|
||||||
|
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||||
|
override fun onAvailable(network: Network) {
|
||||||
|
trySend(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLost(network: Network) {
|
||||||
|
trySend(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val request = NetworkRequest.Builder()
|
||||||
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
connectivityManager.registerNetworkCallback(request, callback)
|
||||||
|
|
||||||
|
val activeNetwork = connectivityManager.activeNetwork
|
||||||
|
val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
|
||||||
|
val initialOnline = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
|
||||||
|
trySend(initialOnline)
|
||||||
|
|
||||||
|
awaitClose {
|
||||||
|
connectivityManager.unregisterNetworkCallback(callback)
|
||||||
|
}
|
||||||
|
}.distinctUntilChanged()
|
||||||
|
}
|
||||||
151
docs/API.md
Normal file
151
docs/API.md
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
# Mitbringsl – REST API Specification
|
||||||
|
|
||||||
|
Base URL: `/`
|
||||||
|
Authentication: Bearer Token via `Authorization: Bearer <session_token>` header or session cookie.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Health & Diagnostics
|
||||||
|
|
||||||
|
### `GET /healthz`
|
||||||
|
Liveness probe. Returns HTTP 200 `OK`.
|
||||||
|
|
||||||
|
### `GET /readyz`
|
||||||
|
Readiness probe. Checks PostgreSQL connection pool health.
|
||||||
|
- **200 OK**: Database reachable.
|
||||||
|
- **503 Service Unavailable**: Database error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication (`/auth`)
|
||||||
|
|
||||||
|
### `POST /auth/register`
|
||||||
|
Create a new email/password account.
|
||||||
|
- **Request**:
|
||||||
|
```json
|
||||||
|
{ "email": "user@example.com", "password": "secretpassword", "display_name": "Max" }
|
||||||
|
```
|
||||||
|
- **Response (201 Created)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "<opaque_session_token>",
|
||||||
|
"expires_at": "2026-09-04T20:00:00Z",
|
||||||
|
"user": { "id": "<uuid>", "email": "user@example.com", "display_name": "Max" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /auth/login`
|
||||||
|
Authenticate with email/password.
|
||||||
|
- **Request**:
|
||||||
|
```json
|
||||||
|
{ "email": "user@example.com", "password": "secretpassword" }
|
||||||
|
```
|
||||||
|
- **Response (200 OK)**: Same shape as register.
|
||||||
|
|
||||||
|
### `POST /auth/oidc`
|
||||||
|
Authenticate via Google or custom OpenID Connect provider.
|
||||||
|
- **Request**:
|
||||||
|
```json
|
||||||
|
{ "provider": "google", "id_token": "<jwt>" }
|
||||||
|
```
|
||||||
|
- **Response (200 OK)**: Same shape as login.
|
||||||
|
|
||||||
|
### `POST /auth/logout`
|
||||||
|
Revoke active session token. Returns `204 No Content`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lists (`/api/lists`)
|
||||||
|
|
||||||
|
### `GET /api/lists`
|
||||||
|
Fetch all active (non-deleted) lists owned by the authenticated user.
|
||||||
|
- **Response (200 OK)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"lists": [
|
||||||
|
{ "id": "<uuid>", "name": "Wocheneinkauf", "updated_at": "2026-08-05T19:00:00Z", "hlc_ts": 177000000000000 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /api/lists`
|
||||||
|
Create a new list.
|
||||||
|
- **Request**: `{ "name": "Supermarkt" }`
|
||||||
|
- **Response (201 Created)**: `{ "id": "<uuid>", "name": "Supermarkt", ... }`
|
||||||
|
|
||||||
|
### `GET /api/lists/{id}`
|
||||||
|
Fetch list detail including items.
|
||||||
|
- **Response (200 OK)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "<uuid>",
|
||||||
|
"name": "Wocheneinkauf",
|
||||||
|
"updated_at": "...",
|
||||||
|
"hlc_ts": 177000000000000,
|
||||||
|
"items": [
|
||||||
|
{ "id": "<uuid>", "name": "Milch", "quantity": "1L", "checked": false, "hlc_ts": 177000000000001 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sync & Ops (`/api/lists/{id}/ops`)
|
||||||
|
|
||||||
|
### `POST /api/lists/{id}/ops`
|
||||||
|
Push a batch of client operations (max 100).
|
||||||
|
- **Request**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"client_id": "<client_uuid>",
|
||||||
|
"ops": [
|
||||||
|
{
|
||||||
|
"client_seq": 1,
|
||||||
|
"op_type": "item_add",
|
||||||
|
"target_id": "<item_uuid>",
|
||||||
|
"hlc_ts": 177000000000000,
|
||||||
|
"payload": { "name": "Brot", "quantity": "1 Stück" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Response (200 OK)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{ "client_seq": 1, "seq": 42, "hlc_ts": 177000000000001 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /api/lists/{id}/ops?since={seq}`
|
||||||
|
Pull operations since server sequence `since`.
|
||||||
|
- **Response (200 OK)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ops": [
|
||||||
|
{
|
||||||
|
"seq": 42,
|
||||||
|
"client_id": "<client_uuid>",
|
||||||
|
"op_type": "item_add",
|
||||||
|
"target_id": "<item_uuid>",
|
||||||
|
"payload": { "name": "Brot" },
|
||||||
|
"client_seq": 1,
|
||||||
|
"hlc_ts": 177000000000001,
|
||||||
|
"created_at": "2026-08-05T20:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"has_more": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Suggestions (`/api/suggestions`)
|
||||||
|
|
||||||
|
### `GET /api/suggestions?q={query}`
|
||||||
|
Fuzzy autocomplete search for item names (pg_trgm).
|
||||||
|
- **Response (200 OK)**:
|
||||||
|
```json
|
||||||
|
{ "suggestions": ["milch", "mineralwasser", "müsli"] }
|
||||||
|
```
|
||||||
51
docs/ARCHITECTURE.md
Normal file
51
docs/ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
# Mitbringsl – Architectural Design Document
|
||||||
|
|
||||||
|
## 1. Overview & Paradigm
|
||||||
|
|
||||||
|
**Mitbringsl** is a local-first, privacy-focused shopping list application designed as an ad-free alternative to Bring!.
|
||||||
|
|
||||||
|
### Local-First Foundation
|
||||||
|
- **Source of Truth**: The client’s local SQLite database (via Room) is the authoritative source of truth for user interactions. All mutations (adding items, checking off items, renaming/deleting lists) take immediate effect locally.
|
||||||
|
- **Append-Only Op Log**: Operations are logged sequentially to a local outbox queue (`op_log`) before being synced asynchronously to the server.
|
||||||
|
- **Offline Resiliency**: The app functions 100% offline. Network connectivity is treated as an opportunistic transport to synchronize operations with the server and other clients.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. System Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Caddy (Reverse Proxy) │
|
||||||
|
└────────────────────┬────────────────────┘
|
||||||
|
│ HTTP / REST
|
||||||
|
┌────────────────────▼────────────────────┐
|
||||||
|
│ Go Backend (net/http + pgx) │
|
||||||
|
└─────────┬──────────────────────┬────────┘
|
||||||
|
│ │
|
||||||
|
┌─────────▼────────┐ ┌─────────▼────────┐
|
||||||
|
│ PostgreSQL (16) │ │ OIDC Providers │
|
||||||
|
│ (op_log, LWW) │ │ (Google/Custom) │
|
||||||
|
└──────────────────┘ └──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Technology Stack
|
||||||
|
|
||||||
|
### Backend (Go 1.26)
|
||||||
|
- **HTTP Routing**: `net/http` stdlib (Go 1.22+ method & path variables pattern matching).
|
||||||
|
- **Database Driver**: `github.com/jackc/pgx/v5` (`pgxpool`).
|
||||||
|
- **Database Migrations**: `github.com/golang-migrate/migrate/v4` (embedded via `embed.FS`).
|
||||||
|
- **Password Hashing**: Argon2id in PHC format (`golang.org/x/crypto/argon2`).
|
||||||
|
- **Authentication**: Opaque session tokens (32 bytes `crypto/rand`, SHA-256 hashed in database).
|
||||||
|
- **OIDC Verification**: `github.com/coreos/go-oidc/v3` with JWKS key caching.
|
||||||
|
- **Containerization**: Distroless multi-stage Docker build (`gcr.io/distroless/static-debian12:nonroot`).
|
||||||
|
|
||||||
|
### Android Client (Kotlin 2.x & Compose)
|
||||||
|
- **UI Framework**: Jetpack Compose (Material Design 3).
|
||||||
|
- **Architecture**: MVVM with Unidirectional Data Flow (UDF) & `StateFlow`.
|
||||||
|
- **Database**: Room (KSP code generation).
|
||||||
|
- **Network & Serialization**: Retrofit 2 + OkHttp 4 + `kotlinx.serialization`.
|
||||||
|
- **Background Sync**: WorkManager (`HiltWorker` + `CoroutineWorker`).
|
||||||
|
- **Dependency Injection**: Hilt.
|
||||||
|
- **Clock**: Client-side Hybrid Logical Clock (HLC).
|
||||||
58
docs/SYNC.md
Normal file
58
docs/SYNC.md
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
# Mitbringsl – Synchronization Protocol & Conflict Resolution
|
||||||
|
|
||||||
|
## 1. Synchronization Model
|
||||||
|
|
||||||
|
Mitbringsl uses an **Append-Only Operation Log (`op_log`)** with **Hybrid Logical Clock (HLC)** timestamps and **Last-Write-Wins (LWW)** projections.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Hybrid Logical Clock (HLC)
|
||||||
|
|
||||||
|
Every operation carries a 64-bit integer timestamp `hlc_ts`:
|
||||||
|
|
||||||
|
$$\text{hlc\_ts} = (\text{wall\_ms} \ll 16) \mid \text{counter}$$
|
||||||
|
|
||||||
|
- **`wall_ms`** (48 bits): Unix epoch milliseconds.
|
||||||
|
- **`counter`** (16 bits): Logical counter disambiguating events produced within the same millisecond.
|
||||||
|
|
||||||
|
### Guarantees
|
||||||
|
1. **Strict Monotonicity**: $t_{n+1} > t_n$ for all events generated on the same device.
|
||||||
|
2. **Causal Ordering**: If event $B$ was generated after observing event $A$, then $HLC(B) > HLC(A)$.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Operations (`op_log`)
|
||||||
|
|
||||||
|
All state modifications are represented as structured operations:
|
||||||
|
|
||||||
|
| `op_type` | Target | Description | Payload Example |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `list_create` | List UUID | Create list | `{"name":"Wocheneinkauf"}` |
|
||||||
|
| `list_rename` | List UUID | Rename list | `{"name":"Supermarkt"}` |
|
||||||
|
| `list_delete` | List UUID | Delete list | `{}` |
|
||||||
|
| `item_add` | Item UUID | Add item | `{"name":"Milch","quantity":"2L"}` |
|
||||||
|
| `item_update` | Item UUID | Update item | `{"name":"Hafermilch","checked":true}` |
|
||||||
|
| `item_remove` | Item UUID | Delete item | `{}` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Conflict Resolution (LWW & Tombstones)
|
||||||
|
|
||||||
|
1. **Projection Updates**:
|
||||||
|
Database tables (`items`, `lists`) are projections of `op_log`. When an op arrives:
|
||||||
|
```sql
|
||||||
|
UPDATE items
|
||||||
|
SET name = $1, hlc_ts = $2, updated_at = now()
|
||||||
|
WHERE id = $3 AND hlc_ts < $2 AND deleted_at IS NULL;
|
||||||
|
```
|
||||||
|
2. **Tombstones (`deleted_at`)**:
|
||||||
|
When an item or list is deleted (`item_remove`, `list_delete`), a tombstone is set (`deleted_at = now()`). Late-arriving offline `item_update` operations with smaller `hlc_ts` values will **never** revive a tombstoned entity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Idempotent Push & Cursor Pull
|
||||||
|
|
||||||
|
- **Idempotent Push (`POST /api/lists/{id}/ops`)**:
|
||||||
|
Each operation carries a `(client_id, client_seq)` tuple enforced by a `UNIQUE` constraint in Postgres (`op_log`). Re-sent requests return previous `(seq, hlc_ts)` assignments without duplicating side effects.
|
||||||
|
- **Cursor Pull (`GET /api/lists/{id}/ops?since={seq}`)**:
|
||||||
|
Clients track `max(server_seq)` locally. Incremental sync fetches ops where `seq > cursor`, sorted by monotonic server sequence `seq ASC`.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue