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:
1
.idea/vcs.xml
generated
1
.idea/vcs.xml
generated
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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")
|
||||
@@ -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>
|
||||
@@ -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 []
|
||||
Reference in New Issue
Block a user