feat: Web-App-Integration für geteilte Einkaufslisten implementieren

- Ersetzt die lokale `web-app` durch eine Client-Server-Architektur.
- Implementiert den `WebAppClient` für die Kommunikation mit dem neuen Web-API, einschließlich Authentifizierung, Abrufen und Ändern von Artikeln.
- Aktualisiert die ViewModels und das Repository, um den neuen Web-Client zu integrieren.
- Fügt die erforderlichen Netzwerkberechtigungen und String-Ressourcen hinzu.
- Das alte `web-app`-Verzeichnis wird entfernt.
This commit is contained in:
2025-10-30 08:33:00 +01:00
parent e0b81837e1
commit 16ba4f62ca
11 changed files with 150 additions and 270 deletions

1
.idea/vcs.xml generated
View File

@@ -2,5 +2,6 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/sharelist" vcs="Git" />
</component>
</project>

View File

@@ -1,7 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:usesCleartextTraffic="true"
android:name=".NoteshopApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"

View File

@@ -1,11 +1,53 @@
package de.lxtools.noteshop.api
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.forms.submitForm
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.Parameters
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class TokenResponse(
val access_token: String,
val token_type: String
)
@Serializable
data class Item(
val id: Int,
val name: String,
val is_standard: Boolean,
val created_by_user_id: Int?,
val marked: Boolean
)
@Serializable
data class ItemsResponse(
val items: List<Item>
)
@Serializable
data class DeletionRequest(
val password: String
)
@Serializable
data class ErrorDetail(
val detail: String
)
class WebAppClient {
private val client = HttpClient(CIO) {
@@ -15,23 +57,103 @@ class WebAppClient {
prettyPrint = true
})
}
expectSuccess = false // Don't throw exceptions for non-2xx responses
}
suspend fun testConnection(url: String, user: String, pass: String): Boolean {
// TODO: Implement actual login logic
return true // Placeholder
private var token: String? = null
suspend fun testConnection(url: String, user: String, pass: String): Pair<Boolean, String> {
try {
val response = client.submitForm(
url = "$url/token",
formParameters = Parameters.build {
append("username", user)
append("password", pass)
}
)
if (response.status.value in 200..299) {
val tokenResponse: TokenResponse = response.body()
token = tokenResponse.access_token
return Pair(true, "")
} else {
val errorBody = response.bodyAsText()
return try {
val errorDetail = Json.decodeFromString<ErrorDetail>(errorBody)
Pair(false, "Login failed: ${errorDetail.detail}")
} catch (e: Exception) {
Pair(false, "Login failed with status: ${response.status.value}. Response: $errorBody")
}
}
} catch (e: Exception) {
e.printStackTrace()
return Pair(false, "An unexpected error occurred: ${e.message}")
}
}
suspend fun fetchItems(url: String, user: String, pass: String): List<String> {
// TODO: Implement actual item fetching
return listOf("Item 1 from Web", "Item 2 from Web") // Placeholder
if (token == null) {
val (success, _) = testConnection(url, user, pass)
if (!success) {
return emptyList()
}
}
return try {
val response: ItemsResponse = client.get("$url/api/items") {
header("Authorization", "Bearer $token")
}.body()
response.items.map { it.name }
} catch (e: Exception) {
e.printStackTrace()
emptyList()
}
}
suspend fun markItems(url: String, user: String, pass: String, items: List<String>) {
// TODO: Implement actual item marking
suspend fun markItems(url: String, user: String, pass: String, itemsToMark: List<String>) {
if (token == null) {
val (success, _) = testConnection(url, user, pass)
if (!success) {
return
}
}
try {
// First, get all items to find their IDs
val allItemsResponse: ItemsResponse = client.get("$url/api/items") {
header("Authorization", "Bearer $token")
}.body()
val itemsToMarkIds = allItemsResponse.items
.filter { it.name in itemsToMark }
.map { it.id }
for (itemId in itemsToMarkIds) {
client.put("$url/api/items/$itemId/mark") {
header("Authorization", "Bearer $token")
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
suspend fun deleteMarkedItems(url: String, user: String, pass: String, deletePass: String) {
// TODO: Implement actual item deletion
if (token == null) {
val (success, _) = testConnection(url, user, pass)
if (!success) {
return
}
}
try {
client.post("$url/api/items/delete-marked") {
header("Authorization", "Bearer $token")
contentType(ContentType.Application.Json)
setBody(DeletionRequest(password = deletePass))
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}

View File

@@ -165,7 +165,7 @@ interface NoteshopRepository {
/**
* Test the connection to the web app.
*/
suspend fun testWebAppConnection(url: String, user: String, pass: String): Boolean
suspend fun testWebAppConnection(url: String, user: String, pass: String): Pair<Boolean, String>
/**
* Import items from the web app for a specific list.
@@ -250,7 +250,7 @@ class OfflineNoteshopRepository(
return context.resources.getStringArray(de.lxtools.noteshop.R.array.standard_list_items).toList()
}
override suspend fun testWebAppConnection(url: String, user: String, pass: String): Boolean {
override suspend fun testWebAppConnection(url: String, user: String, pass: String): Pair<Boolean, String> {
return webAppClient.testConnection(url, user, pass)
}

View File

@@ -458,11 +458,14 @@ class ShoppingListsViewModel(private val noteshopRepository: NoteshopRepository,
return@launch
}
val username = String(java.util.Base64.getDecoder().decode(usernameEncrypted), Charsets.UTF_8)
val password = String(java.util.Base64.getDecoder().decode(passwordEncrypted), Charsets.UTF_8)
val decryptedUsernameBytes = fileEncryptor.decrypt(java.util.Base64.getDecoder().decode(usernameEncrypted), secretKey)
val username = String(decryptedUsernameBytes, Charsets.UTF_8)
val decryptedPasswordBytes = fileEncryptor.decrypt(java.util.Base64.getDecoder().decode(passwordEncrypted), secretKey)
val password = String(decryptedPasswordBytes, Charsets.UTF_8)
noteshopRepository.importItemsFromWebApp(listId, url, username, password)
Toast.makeText(getApplication(), "Items imported successfully", Toast.LENGTH_SHORT).show()
Toast.makeText(getApplication(), de.lxtools.noteshop.R.string.import_successful, Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(getApplication(), "Failed to import items: ${e.message}", Toast.LENGTH_LONG).show()

View File

@@ -81,7 +81,7 @@ class WebAppIntegrationViewModel(private val repository: NoteshopRepository, app
return@launch
}
val success = repository.testWebAppConnection(webAppUrl, username, password)
val (success, message) = repository.testWebAppConnection(webAppUrl, username, password)
if (success) {
val secretKey = keyManager.derivePbeKey(deletePassword.toCharArray())
@@ -95,9 +95,9 @@ class WebAppIntegrationViewModel(private val repository: NoteshopRepository, app
putString("key_pass", deletePassword) // Storing the key password directly, assuming it's the delete password
apply()
}
Toast.makeText(getApplication(), "Connection successful and credentials saved", Toast.LENGTH_SHORT).show()
Toast.makeText(getApplication(), de.lxtools.noteshop.R.string.connection_successful, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(getApplication(), "Connection test failed", Toast.LENGTH_SHORT).show()
Toast.makeText(getApplication(), message, Toast.LENGTH_LONG).show() // Use LONG length for error
}
}
}

View File

@@ -287,4 +287,6 @@
<string name="delete_password_label">Löschpasswort</string>
<string name="save_and_test_connection_button">Speichern und Verbindung testen</string>
<string name="import_from_web_app">Von Web-App importieren</string>
<string name="connection_successful">Verbindung erfolgreich und Zugangsdaten gespeichert</string>
<string name="import_successful">Artikel erfolgreich importiert</string>
</resources>

View File

@@ -287,4 +287,6 @@
<string name="delete_password_label">Deletion Password</string>
<string name="save_and_test_connection_button">Save and Test Connection</string>
<string name="import_from_web_app">Import from Web App</string>
<string name="connection_successful">Connection successful and credentials saved</string>
<string name="import_successful">Items imported successfully</string>
</resources>

View File

@@ -1,92 +0,0 @@
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
import sqlite3
import uvicorn
import requests
import os
app = FastAPI()
# --- Gotify Konfiguration ---
# Die Gotify URL wird aus der Umgebungsvariable GOTIFY_URL gelesen.
# Beispiel: https://gotify.example.com/message?token=MY_TOKEN
GOTIFY_URL = os.getenv("GOTIFY_URL")
def send_gotify_notification(title: str, message: str):
if not GOTIFY_URL:
print("GOTIFY_URL ist nicht gesetzt. Überspringe Benachrichtigung.")
return
try:
payload = {"title": title, "message": message}
requests.post(GOTIFY_URL, json=payload)
print("Gotify-Benachrichtigung gesendet.")
except Exception as e:
print(f"Fehler beim Senden der Gotify-Benachrichtigung: {e}")
from translations import get_standard_items
# --- Datenbank-Setup ---
def init_db():
conn = sqlite3.connect("shoppinglist.db")
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
)
""")
conn.commit()
conn.close()
print("Datenbank initialisiert.")
@app.get("/api/standard-items/{lang}")
async def get_standard_items_route(lang: str):
return get_standard_items(lang)
# --- Datenmodelle ---
class Item(BaseModel):
name: str
# --- API Endpunkte ---
@app.get("/api/items")
async def get_items():
conn = sqlite3.connect("shoppinglist.db")
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("SELECT * FROM items ORDER BY name")
items = cursor.fetchall()
conn.close()
return {"items": [dict(row) for row in items]}
@app.post("/api/items")
async def add_item(item: Item):
conn = sqlite3.connect("shoppinglist.db")
cursor = conn.cursor()
try:
cursor.execute("INSERT INTO items (name) VALUES (?)", (item.name,))
conn.commit()
except sqlite3.IntegrityError:
conn.close()
raise HTTPException(status_code=400, detail="Item already exists")
finally:
conn.close()
return {"status": "ok"}
@app.delete("/api/items/{item_id}")
async def delete_item(item_id: int):
conn = sqlite3.connect("shoppinglist.db")
cursor = conn.cursor()
cursor.execute("DELETE FROM items WHERE id = ?", (item_id,))
conn.commit()
conn.close()
return {"status": "ok"}
@app.post("/api/notify")
async def trigger_notification():
send_gotify_notification("Einkaufsliste aktualisiert", "Die Liste wurde von einem Benutzer geändert.")
return {"status": "notification sent"}
# --- Statische Dateien (Frontend) ---
app.mount("/", StaticFiles(directory="static", html=True), name="static")

View File

@@ -1,139 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Geteilte Einkaufsliste</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: auto; padding: 20px; }
h1 { text-align: center; }
ul { list-style-type: none; padding: 0; }
li { display: flex; justify-content: space-between; align-items: center; padding: 10px; border-bottom: 1px solid #ddd; }
.delete-btn { cursor: pointer; color: red; }
form { display: flex; margin-top: 20px; }
input[type="text"] { flex-grow: 1; padding: 10px; }
button { padding: 10px; }
#notify-btn { width: 100%; margin-top: 15px; background-color: #28a745; color: white; border: none; }
</style>
</head>
<body>
<h1>Geteilte Einkaufsliste</h1>
<ul id="item-list"></ul>
<form id="add-item-form">
<input type="text" id="item-name" placeholder="Neuer Artikel..." list="standard-items" required>
<datalist id="standard-items"></datalist>
<button type="submit">Hinzufügen</button>
<button type="button" id="add-combined-btn">+1</button>
</form>
<button id="notify-btn">Änderungen abschließen & Benachrichtigen</button>
<script>
const itemList = document.getElementById('item-list');
const addItemForm = document.getElementById('add-item-form');
const itemNameInput = document.getElementById('item-name');
const notifyBtn = document.getElementById('notify-btn');
const standardItemsDatalist = document.getElementById('standard-items');
const addCombinedBtn = document.getElementById('add-combined-btn');
let standardItems = [];
// --- Funktionen ---
async function fetchStandardItems() {
const lang = navigator.language.startsWith('de') ? 'de' : 'en';
const response = await fetch(`/api/standard-items/${lang}`);
standardItems = await response.json();
standardItemsDatalist.innerHTML = standardItems.map(item => `<option value="${item}">`).join('');
}
async function fetchItems() {
const response = await fetch('/api/items');
const data = await response.json();
itemList.innerHTML = ''; // Liste leeren
data.items.forEach(item => {
const li = document.createElement('li');
li.textContent = item.name;
const deleteBtn = document.createElement('span');
deleteBtn.textContent = 'Löschen';
deleteBtn.className = 'delete-btn';
deleteBtn.onclick = () => deleteItem(item.id);
li.appendChild(deleteBtn);
itemList.appendChild(li);
});
}
async function addItem(name) {
if (!name) return;
await fetch('/api/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name })
});
itemNameInput.value = '';
fetchItems();
}
async function addItemsFromInput(inputString, splitByComma) {
if (!inputString.trim()) return;
let itemsToAdd = [];
if (splitByComma && inputString.includes(',')) {
itemsToAdd = inputString.split(',').map(item => item.trim()).filter(item => item);
} else {
itemsToAdd.push(inputString.trim());
}
for (const item of itemsToAdd) {
await addItem(item);
}
}
async function deleteItem(id) {
await fetch(`/api/items/${id}`, { method: 'DELETE' });
fetchItems();
}
async function sendNotification() {
notifyBtn.textContent = 'Sende...';
await fetch('/api/notify', { method: 'POST' });
notifyBtn.textContent = 'Benachrichtigung gesendet!';
setTimeout(() => {
notifyBtn.textContent = 'Änderungen abschließen & Benachrichtigen';
}, 2000);
}
// --- Event Listeners ---
addItemForm.addEventListener('submit', async (event) => {
event.preventDefault();
await addItemsFromInput(itemNameInput.value, true);
});
itemNameInput.addEventListener('keydown', async (event) => {
if (event.key === 'Enter') {
event.preventDefault();
await addItemsFromInput(itemNameInput.value, true);
}
});
addCombinedBtn.addEventListener('click', async () => {
await addItemsFromInput(itemNameInput.value, false);
});
notifyBtn.addEventListener('click', sendNotification);
// --- Initialisierung ---
fetchStandardItems();
fetchItems();
</script>
</body>
</html>

View File

@@ -1,23 +0,0 @@
import xml.etree.ElementTree as ET
from typing import List
def get_standard_items(lang: str) -> List[str]:
"""
Parses the strings.xml file for the given language and returns the standard shopping list items.
"""
if lang == "de":
filepath = "../app/src/main/res/values-de/strings.xml"
else:
filepath = "../app/src/main/res/values/strings.xml"
try:
tree = ET.parse(filepath)
root = tree.getroot()
for string_array in root.findall('string-array'):
if string_array.get('name') == 'standard_list_items':
return [item.text for item in string_array.findall('item')]
except (ET.ParseError, FileNotFoundError):
return []
return []