mirror of
https://github.com/Lyxminxx/jast-frontend.git
synced 2026-10-11 20:57:44 +02:00
Initial commit: Flet frontend with CI/CD
This commit is contained in:
commit
d51545bd86
19 files changed
+4355
No files matched your search
@@ -0,0 +1,69 @@
|
|||||||
|
name: CI/CD - Build Web Docker & Android APK
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# --- JOB 1: BUILD & PUSH WEB DOCKER IMAGE FOR CASAOS ---
|
||||||
|
build-web-docker:
|
||||||
|
name: Build & Push Web Container
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Frontend Code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and Push Docker Web Image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ secrets.DOCKERHUB_USERNAME }}/jast-frontend:latest
|
||||||
|
|
||||||
|
# --- JOB 2: BUILD ANDROID APK ---
|
||||||
|
build-android-apk:
|
||||||
|
name: Build Android APK
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v3
|
||||||
|
with:
|
||||||
|
enable-cache: true
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install Dependencies with uv
|
||||||
|
run: uv sync
|
||||||
|
|
||||||
|
- name: Build APK via Flet Action
|
||||||
|
uses: flet-dev/flet-build-action@v1
|
||||||
|
with:
|
||||||
|
target: apk
|
||||||
|
project-path: .
|
||||||
|
build-number: ${{ github.run_number }}
|
||||||
|
|
||||||
|
- name: Upload Android APK
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: jast-android-app
|
||||||
|
path: build/apk/*.apk
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Environments / secrets
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Tool caches
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Flet
|
||||||
|
.flet/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
FROM python:3.12-slim AS builder
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
git \
|
||||||
|
curl \
|
||||||
|
unzip \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY pyproject.toml uv.lock* ./
|
||||||
|
|
||||||
|
RUN uv sync --frozen || uv sync
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN uv run flet build web
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
COPY --from=builder /app/build/web /usr/share/nginx/html
|
||||||
|
|
||||||
|
RUN echo 'server { \
|
||||||
|
listen 8000; \
|
||||||
|
location / { \
|
||||||
|
root /usr/share/nginx/html; \
|
||||||
|
try_files $uri $uri/ /index.html; \
|
||||||
|
} \
|
||||||
|
}' > /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# JastFrontend app
|
||||||
|
|
||||||
|
## Run the app
|
||||||
|
|
||||||
|
### uv
|
||||||
|
|
||||||
|
Run as a desktop app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run flet run
|
||||||
|
```
|
||||||
|
|
||||||
|
Run as a web app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run flet run --web
|
||||||
|
```
|
||||||
|
|
||||||
|
For more details on running the app, refer to the [Getting Started Guide](https://flet.dev/docs/).
|
||||||
|
|
||||||
|
## Build the app
|
||||||
|
|
||||||
|
### Android
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flet build apk -v
|
||||||
|
```
|
||||||
|
|
||||||
|
For more details on building and signing `.apk` or `.aab`, refer to the [Android Packaging Guide](https://flet.dev/docs/publish/android/).
|
||||||
|
|
||||||
|
### iOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flet build ipa -v
|
||||||
|
```
|
||||||
|
|
||||||
|
For more details on building and signing `.ipa`, refer to the [iOS Packaging Guide](https://flet.dev/docs/publish/ios/).
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flet build macos -v
|
||||||
|
```
|
||||||
|
|
||||||
|
For more details on building macOS package, refer to the [macOS Packaging Guide](https://flet.dev/docs/publish/macos/).
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flet build linux -v
|
||||||
|
```
|
||||||
|
|
||||||
|
For more details on building Linux package, refer to the [Linux Packaging Guide](https://flet.dev/docs/publish/linux/).
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flet build windows -v
|
||||||
|
```
|
||||||
|
|
||||||
|
For more details on building Windows package, refer to the [Windows Packaging Guide](https://flet.dev/docs/publish/windows/).
|
||||||
|
|
||||||
|
### Web
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flet build web -v
|
||||||
|
```
|
||||||
|
|
||||||
|
For more details on building Web app, refer to the [Web Packaging Guide](https://flet.dev/docs/publish/web/).
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
[project]
|
||||||
|
name = "jast-frontend"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = ""
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
authors = [
|
||||||
|
{ name = "Flet developer", email = "[email protected]" }
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
"flet>=1.0.0",
|
||||||
|
"flet-charts>=1.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"flet-cli>=1.0.0",
|
||||||
|
"flet-desktop>=1.0.0",
|
||||||
|
"flet-web>=1.0.0",
|
||||||
|
# Integration testing with `flet test` / pytest. The `test` extra brings in
|
||||||
|
# pytest, pytest-asyncio and the screenshot-comparison dependencies.
|
||||||
|
"flet[test]>=1.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["src"]
|
||||||
|
|
||||||
|
[tool.flet]
|
||||||
|
desktop_flavor = "light" # Change to "full" to use flet-audio and flet-video
|
||||||
|
org = "com.mycompany"
|
||||||
|
product = "jast-frontend"
|
||||||
|
company = "Flet"
|
||||||
|
copyright = "Copyright (C) 2023-2026 by Flet"
|
||||||
|
|
||||||
|
# src/assets/icon.png is the icon for every platform. Keep it transparent and
|
||||||
|
# filling the whole canvas: web, Windows and Linux show it as-is, and Flet
|
||||||
|
# shrinks it to the margin iOS, macOS and Android each mask away. This colour
|
||||||
|
# is what sits behind it where transparency cannot survive - the iOS icon, the
|
||||||
|
# macOS tile and the Android background layer. Defaults to white.
|
||||||
|
# icon_background = "#ffffff"
|
||||||
|
|
||||||
|
[tool.flet.app]
|
||||||
|
path = "src"
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
+54
@@ -0,0 +1,54 @@
|
|||||||
|
import flet as ft
|
||||||
|
from services.storage import get_setting
|
||||||
|
from themes import get_theme
|
||||||
|
from views.dashboard import DashboardView
|
||||||
|
from views.login import LoginView
|
||||||
|
from views.server_picker import ServerPickerView
|
||||||
|
from views.statistics import StatisticsView
|
||||||
|
from views.account import AccountView
|
||||||
|
from views.register import RegisterView
|
||||||
|
|
||||||
|
|
||||||
|
def main(page: ft.Page):
|
||||||
|
page.theme_mode = ft.ThemeMode.DARK
|
||||||
|
forest = get_theme()
|
||||||
|
page.theme = forest
|
||||||
|
page.bgcolor = forest.color_scheme.surface
|
||||||
|
|
||||||
|
page.title = "Jast Tracker"
|
||||||
|
|
||||||
|
def navigate(route: str):
|
||||||
|
page.route = route
|
||||||
|
route_change()
|
||||||
|
|
||||||
|
def route_change(e=None):
|
||||||
|
page.views.clear()
|
||||||
|
|
||||||
|
if page.route == "/server":
|
||||||
|
page.views.append(ServerPickerView(page, navigate))
|
||||||
|
elif page.route == "/login":
|
||||||
|
page.views.append(LoginView(page, navigate))
|
||||||
|
elif page.route == "/register":
|
||||||
|
page.views.append(RegisterView(page, navigate))
|
||||||
|
elif page.route == "/dashboard":
|
||||||
|
page.views.append(DashboardView(page, navigate))
|
||||||
|
elif page.route == "/statistics":
|
||||||
|
page.views.append(StatisticsView(page, navigate))
|
||||||
|
elif page.route == "/account":
|
||||||
|
page.views.append(AccountView(page, navigate))
|
||||||
|
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
page.on_route_change = route_change
|
||||||
|
|
||||||
|
# Startup route guard
|
||||||
|
if not get_setting("server_url"):
|
||||||
|
navigate("/server")
|
||||||
|
elif not get_setting("auth_token"):
|
||||||
|
navigate("/login")
|
||||||
|
else:
|
||||||
|
navigate("/dashboard")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
ft.run(main)
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import httpx
|
||||||
|
from services.storage import get_setting, remove_setting, save_setting
|
||||||
|
|
||||||
|
class ApiClient:
|
||||||
|
def __init__(self, timeout: float = 10.0):
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_url(self) -> str | None:
|
||||||
|
return get_setting("server_url")
|
||||||
|
|
||||||
|
def get_headers(self) -> dict[str, str]:
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
token = get_setting("auth_token")
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def get_me(self) -> tuple[bool, dict | str]:
|
||||||
|
"""Fetches profile info for current authenticated user."""
|
||||||
|
if not self.base_url:
|
||||||
|
return False, "Server URL is not configured."
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=self.timeout) as client:
|
||||||
|
res = client.get(f"{self.base_url}/auth/me", headers=self.get_headers())
|
||||||
|
if res.status_code == 200:
|
||||||
|
return True, res.json()
|
||||||
|
return False, f"Failed to fetch profile ({res.status_code})"
|
||||||
|
except Exception as err:
|
||||||
|
return False, str(err)
|
||||||
|
|
||||||
|
def get_categories(self) -> tuple[bool, list]:
|
||||||
|
"""Fetches available categories from Django Ninja."""
|
||||||
|
if not self.base_url:
|
||||||
|
return False, []
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=self.timeout) as client:
|
||||||
|
res = client.get(f"{self.base_url}/categories", headers=self.get_headers())
|
||||||
|
if res.status_code == 200:
|
||||||
|
return True, res.json()
|
||||||
|
return False, []
|
||||||
|
except Exception as err:
|
||||||
|
return False, []
|
||||||
|
|
||||||
|
def get_transactions(self) -> tuple[bool, list | str]:
|
||||||
|
"""Fetches transactions list from Django Ninja."""
|
||||||
|
if not self.base_url:
|
||||||
|
return False, "Server URL is not configured."
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=self.timeout) as client:
|
||||||
|
res = client.get(f"{self.base_url}/transactions", headers=self.get_headers())
|
||||||
|
if res.status_code == 200:
|
||||||
|
return True, res.json()
|
||||||
|
return False, f"Failed to fetch transactions ({res.status_code})"
|
||||||
|
except Exception as err:
|
||||||
|
return False, str(err)
|
||||||
|
|
||||||
|
def create_transaction(
|
||||||
|
self, title: str, amount: str | float, date_str: str, category_id: int | None = None
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Posts a new transaction."""
|
||||||
|
if not self.base_url:
|
||||||
|
return False, "Server URL is not configured."
|
||||||
|
payload = {
|
||||||
|
"title": title,
|
||||||
|
"amount": str(amount),
|
||||||
|
"date": date_str,
|
||||||
|
"category_id": category_id,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=self.timeout) as client:
|
||||||
|
res = client.post(
|
||||||
|
f"{self.base_url}/transactions",
|
||||||
|
json=payload,
|
||||||
|
headers=self.get_headers(),
|
||||||
|
)
|
||||||
|
if res.status_code == 200:
|
||||||
|
return True, "Transaction created"
|
||||||
|
return False, f"Error creating transaction ({res.status_code})"
|
||||||
|
except Exception as err:
|
||||||
|
return False, str(err)
|
||||||
|
|
||||||
|
def update_transaction(
|
||||||
|
self, transaction_id: int, title: str, amount: str | float, date_str: str, category_id: int | None = None
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Updates an existing transaction by ID."""
|
||||||
|
if not self.base_url:
|
||||||
|
return False, "Server URL is not configured."
|
||||||
|
payload = {
|
||||||
|
"title": title,
|
||||||
|
"amount": str(amount),
|
||||||
|
"date": date_str,
|
||||||
|
"category_id": category_id,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=self.timeout) as client:
|
||||||
|
res = client.put(
|
||||||
|
f"{self.base_url}/transactions/{transaction_id}",
|
||||||
|
json=payload,
|
||||||
|
headers=self.get_headers(),
|
||||||
|
)
|
||||||
|
if res.status_code == 200:
|
||||||
|
return True, "Transaction updated"
|
||||||
|
return False, f"Error updating transaction ({res.status_code})"
|
||||||
|
except Exception as err:
|
||||||
|
return False, str(err)
|
||||||
|
|
||||||
|
def delete_transaction(self, transaction_id: int) -> tuple[bool, str]:
|
||||||
|
"""Deletes a transaction by ID."""
|
||||||
|
if not self.base_url:
|
||||||
|
return False, "Server URL is not configured."
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=self.timeout) as client:
|
||||||
|
res = client.delete(
|
||||||
|
f"{self.base_url}/transactions/{transaction_id}",
|
||||||
|
headers=self.get_headers(),
|
||||||
|
)
|
||||||
|
if res.status_code == 200:
|
||||||
|
return True, "Transaction deleted"
|
||||||
|
return False, f"Error deleting transaction ({res.status_code})"
|
||||||
|
except Exception as err:
|
||||||
|
return False, str(err)
|
||||||
|
|
||||||
|
def login(self, username: str, password: str) -> tuple[bool, str]:
|
||||||
|
if not self.base_url:
|
||||||
|
return False, "Server URL is not configured."
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=self.timeout) as client:
|
||||||
|
response = client.post(
|
||||||
|
f"{self.base_url}/auth/login",
|
||||||
|
json={"username": username, "password": password},
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
save_setting("auth_token", data.get("access_token"))
|
||||||
|
if data.get("refresh_token"):
|
||||||
|
save_setting("refresh_token", data.get("refresh_token"))
|
||||||
|
return True, "Login successful"
|
||||||
|
return False, "Invalid username or password"
|
||||||
|
except Exception as err:
|
||||||
|
return False, str(err)
|
||||||
|
|
||||||
|
def logout(self) -> None:
|
||||||
|
remove_setting("auth_token")
|
||||||
|
remove_setting("refresh_token")
|
||||||
|
|
||||||
|
def update_me(
|
||||||
|
self,
|
||||||
|
username: str | None = None,
|
||||||
|
email: str | None = None,
|
||||||
|
first_name: str | None = None,
|
||||||
|
last_name: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
) -> tuple[bool, dict | str]:
|
||||||
|
"""Updates profile details for the authenticated user."""
|
||||||
|
if not self.base_url:
|
||||||
|
return False, "Server URL is not configured."
|
||||||
|
payload = {}
|
||||||
|
if username:
|
||||||
|
payload["username"] = username
|
||||||
|
if email is not None:
|
||||||
|
payload["email"] = email
|
||||||
|
if first_name is not None:
|
||||||
|
payload["first_name"] = first_name
|
||||||
|
if last_name is not None:
|
||||||
|
payload["last_name"] = last_name
|
||||||
|
if password:
|
||||||
|
payload["password"] = password
|
||||||
|
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=self.timeout) as client:
|
||||||
|
res = client.put(
|
||||||
|
f"{self.base_url}/auth/me",
|
||||||
|
json=payload,
|
||||||
|
headers=self.get_headers(),
|
||||||
|
)
|
||||||
|
if res.status_code == 200:
|
||||||
|
try:
|
||||||
|
return True, res.json()
|
||||||
|
except Exception:
|
||||||
|
return True, {}
|
||||||
|
try:
|
||||||
|
err_json = res.json()
|
||||||
|
return False, err_json.get("detail", f"Update failed ({res.status_code})")
|
||||||
|
except Exception:
|
||||||
|
return False, f"Update failed ({res.status_code}): {res.text}"
|
||||||
|
except Exception as err:
|
||||||
|
return False, str(err)
|
||||||
|
|
||||||
|
def delete_me(self) -> tuple[bool, str]:
|
||||||
|
"""Deletes the current user account."""
|
||||||
|
if not self.base_url:
|
||||||
|
return False, "Server URL is not configured."
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=self.timeout) as client:
|
||||||
|
res = client.delete(f"{self.base_url}/auth/me", headers=self.get_headers())
|
||||||
|
if res.status_code == 200:
|
||||||
|
self.logout()
|
||||||
|
return True, "Account deleted."
|
||||||
|
return False, "Failed to delete account."
|
||||||
|
except Exception as err:
|
||||||
|
return False, str(err)
|
||||||
|
|
||||||
|
def register(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
email: str | None = None,
|
||||||
|
first_name: str | None = None,
|
||||||
|
last_name: str | None = None,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Registers a new user account and saves the auth tokens."""
|
||||||
|
if not self.base_url:
|
||||||
|
return False, "Server URL is not configured."
|
||||||
|
payload = {
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
"email": email or "",
|
||||||
|
"first_name": first_name or "",
|
||||||
|
"last_name": last_name or "",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=self.timeout) as client:
|
||||||
|
res = client.post(
|
||||||
|
f"{self.base_url}/auth/register",
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
if res.status_code == 200:
|
||||||
|
data = res.json()
|
||||||
|
save_setting("auth_token", data.get("access_token"))
|
||||||
|
if data.get("refresh_token"):
|
||||||
|
save_setting("refresh_token", data.get("refresh_token"))
|
||||||
|
return True, "Registration successful"
|
||||||
|
|
||||||
|
try:
|
||||||
|
err_detail = res.json().get("detail", "Registration failed")
|
||||||
|
except Exception:
|
||||||
|
err_detail = f"Registration failed ({res.status_code})"
|
||||||
|
return False, err_detail
|
||||||
|
except Exception as err:
|
||||||
|
return False, str(err)
|
||||||
|
api_client = ApiClient()
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
STORAGE_FILE = Path("settings.json")
|
||||||
|
|
||||||
|
|
||||||
|
def save_setting(key: str, value: str) -> None:
|
||||||
|
data = {}
|
||||||
|
if STORAGE_FILE.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(STORAGE_FILE.read_text())
|
||||||
|
except Exception:
|
||||||
|
data = {}
|
||||||
|
data[key] = value
|
||||||
|
STORAGE_FILE.write_text(json.dumps(data, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def get_setting(key: str, default: str | None = None) -> str | None:
|
||||||
|
if not STORAGE_FILE.exists():
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
data = json.loads(STORAGE_FILE.read_text())
|
||||||
|
return data.get(key, default)
|
||||||
|
except Exception:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def remove_setting(key: str) -> None:
|
||||||
|
if STORAGE_FILE.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(STORAGE_FILE.read_text())
|
||||||
|
if key in data:
|
||||||
|
del data[key]
|
||||||
|
STORAGE_FILE.write_text(json.dumps(data, indent=2))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_currency_symbol() -> str:
|
||||||
|
return get_setting("currency_symbol") or "kr"
|
||||||
|
|
||||||
|
|
||||||
|
def set_currency_symbol(symbol: str) -> None:
|
||||||
|
save_setting("currency_symbol", symbol)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .forest import forest_theme
|
||||||
|
|
||||||
|
def get_theme() -> ft.Theme:
|
||||||
|
return forest_theme()
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import flet as ft
|
||||||
|
|
||||||
|
def forest_theme() -> ft.Theme:
|
||||||
|
return ft.Theme(
|
||||||
|
color_scheme=ft.ColorScheme(
|
||||||
|
primary="#a3b18a", # Soft Sage Green
|
||||||
|
on_primary="#111d13", # Dark text on primary
|
||||||
|
primary_container="#344e41", # Dark Fern Container
|
||||||
|
on_primary_container="#dad7cd",
|
||||||
|
secondary="#dda15e", # Warm Earth/Amber accent
|
||||||
|
on_secondary="#111d13",
|
||||||
|
surface="#111d13", # Deep Pine / Charcoal-Green Base
|
||||||
|
on_surface="#dad7cd", # Soft Cream Text
|
||||||
|
on_surface_variant="#a3b18a",# Muted Subtext
|
||||||
|
outline="#588157", # Fern Green Borders
|
||||||
|
error="#e76f51", # Terracotta Red
|
||||||
|
on_error="#111d13",
|
||||||
|
),
|
||||||
|
visual_density=ft.VisualDensity.COMFORTABLE,
|
||||||
|
)
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import flet as ft
|
||||||
|
from services.api import api_client
|
||||||
|
from services.storage import get_currency_symbol, set_currency_symbol
|
||||||
|
|
||||||
|
def AccountView(page: ft.Page, navigate) -> ft.View:
|
||||||
|
user_info = {}
|
||||||
|
|
||||||
|
# Form Fields: Dark containers with crisp white text
|
||||||
|
input_style = {
|
||||||
|
"color": "#FFFFFF",
|
||||||
|
"bgcolor": "#2a3d32",
|
||||||
|
"border_color": "#588157",
|
||||||
|
"focused_border_color": "#a3b18a",
|
||||||
|
"label_style": ft.TextStyle(color="#dad7cd", weight=ft.FontWeight.BOLD),
|
||||||
|
"width": float("inf"),
|
||||||
|
}
|
||||||
|
|
||||||
|
username_in = ft.TextField(label="Username", **input_style)
|
||||||
|
first_name_in = ft.TextField(label="First Name", **input_style)
|
||||||
|
last_name_in = ft.TextField(label="Last Name", **input_style)
|
||||||
|
email_in = ft.TextField(label="Email", **input_style)
|
||||||
|
|
||||||
|
currency_in = ft.TextField(
|
||||||
|
label="Currency Symbol (e.g. kr, $, €)",
|
||||||
|
value=get_currency_symbol(),
|
||||||
|
**input_style,
|
||||||
|
)
|
||||||
|
|
||||||
|
password_in = ft.TextField(
|
||||||
|
label="New Password (leave blank to keep current)",
|
||||||
|
password=True,
|
||||||
|
can_reveal_password=True,
|
||||||
|
**input_style,
|
||||||
|
)
|
||||||
|
|
||||||
|
status_msg = ft.Text("", size=13)
|
||||||
|
loading_spinner = ft.ProgressRing(width=20, height=20, visible=False)
|
||||||
|
|
||||||
|
# --- DELETE ACCOUNT MODAL ---
|
||||||
|
def close_delete_modal(e=None):
|
||||||
|
delete_modal.open = False
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
def confirm_delete_account(e):
|
||||||
|
ok, msg = api_client.delete_me()
|
||||||
|
delete_modal.open = False
|
||||||
|
page.update()
|
||||||
|
if ok:
|
||||||
|
navigate("/login")
|
||||||
|
else:
|
||||||
|
status_msg.value = f"Delete failed: {msg}"
|
||||||
|
status_msg.color = ft.Colors.RED_300
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
delete_modal = ft.AlertDialog(
|
||||||
|
title=ft.Text("Delete Account", color="#FFFFFF"),
|
||||||
|
content=ft.Text(
|
||||||
|
"Are you sure you want to permanently delete your account and all associated transactions? This action cannot be undone.",
|
||||||
|
color="#dad7cd",
|
||||||
|
),
|
||||||
|
actions=[
|
||||||
|
ft.TextButton("Cancel", on_click=close_delete_modal),
|
||||||
|
ft.FilledButton(
|
||||||
|
"Delete Permanently",
|
||||||
|
bgcolor=ft.Colors.RED_400,
|
||||||
|
color=ft.Colors.WHITE,
|
||||||
|
on_click=confirm_delete_account,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def prompt_delete_account(e):
|
||||||
|
if delete_modal not in page.overlay:
|
||||||
|
page.overlay.append(delete_modal)
|
||||||
|
delete_modal.open = True
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
def handle_logout(e):
|
||||||
|
api_client.logout()
|
||||||
|
navigate("/login")
|
||||||
|
|
||||||
|
def handle_save_profile(e):
|
||||||
|
status_msg.value = ""
|
||||||
|
loading_spinner.visible = True
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
u_val = username_in.value.strip()
|
||||||
|
e_val = email_in.value.strip()
|
||||||
|
fn_val = first_name_in.value.strip()
|
||||||
|
ln_val = last_name_in.value.strip()
|
||||||
|
p_val = password_in.value.strip()
|
||||||
|
c_val = currency_in.value.strip() or "kr"
|
||||||
|
|
||||||
|
# Save currency locally
|
||||||
|
set_currency_symbol(c_val)
|
||||||
|
|
||||||
|
ok, res = api_client.update_me(
|
||||||
|
username=u_val or None,
|
||||||
|
email=e_val,
|
||||||
|
first_name=fn_val,
|
||||||
|
last_name=ln_val,
|
||||||
|
password=p_val or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
loading_spinner.visible = False
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
status_msg.value = "Profile and settings updated successfully!"
|
||||||
|
status_msg.color = "#a3b18a"
|
||||||
|
password_in.value = ""
|
||||||
|
else:
|
||||||
|
status_msg.value = f"Error: {res}"
|
||||||
|
status_msg.color = ft.Colors.RED_300
|
||||||
|
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
def load_user_profile():
|
||||||
|
ok, res = api_client.get_me()
|
||||||
|
if ok and isinstance(res, dict):
|
||||||
|
nonlocal user_info
|
||||||
|
user_info = res
|
||||||
|
username_in.value = user_info.get("username", "")
|
||||||
|
email_in.value = user_info.get("email", "")
|
||||||
|
first_name_in.value = user_info.get("first_name", "")
|
||||||
|
last_name_in.value = user_info.get("last_name", "")
|
||||||
|
page.update()
|
||||||
|
elif not ok and "401" in str(res):
|
||||||
|
api_client.logout()
|
||||||
|
navigate("/login")
|
||||||
|
|
||||||
|
load_user_profile()
|
||||||
|
|
||||||
|
content_column = ft.Column(
|
||||||
|
controls=[
|
||||||
|
ft.Container(height=32),
|
||||||
|
# Header Row
|
||||||
|
ft.Row(
|
||||||
|
controls=[
|
||||||
|
ft.Text("Jast", size=24, weight=ft.FontWeight.BOLD, color="#a3b18a"),
|
||||||
|
ft.IconButton(
|
||||||
|
icon=ft.Icons.ARROW_BACK,
|
||||||
|
icon_color="#a3b18a",
|
||||||
|
icon_size=20,
|
||||||
|
tooltip="Back to Dashboard",
|
||||||
|
on_click=lambda _: navigate("/dashboard"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||||
|
),
|
||||||
|
ft.Text("Account Settings", size=20, weight=ft.FontWeight.BOLD, color="#FFFFFF"),
|
||||||
|
ft.Container(height=6),
|
||||||
|
|
||||||
|
# --- PROFILE DETAILS CARD ---
|
||||||
|
ft.Card(
|
||||||
|
bgcolor="#344e41",
|
||||||
|
margin=0,
|
||||||
|
content=ft.Container(
|
||||||
|
padding=20,
|
||||||
|
content=ft.Column(
|
||||||
|
controls=[
|
||||||
|
ft.Text("PROFILE DETAILS", size=12, color="#a3b18a", weight=ft.FontWeight.BOLD),
|
||||||
|
username_in,
|
||||||
|
first_name_in,
|
||||||
|
last_name_in,
|
||||||
|
email_in,
|
||||||
|
currency_in,
|
||||||
|
password_in,
|
||||||
|
status_msg,
|
||||||
|
ft.Column(
|
||||||
|
controls=[
|
||||||
|
loading_spinner,
|
||||||
|
ft.FilledButton(
|
||||||
|
"Save Changes",
|
||||||
|
bgcolor="#a3b18a",
|
||||||
|
color="#111d13",
|
||||||
|
width=float("inf"),
|
||||||
|
on_click=handle_save_profile,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
spacing=8,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
spacing=14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ft.Container(height=10),
|
||||||
|
|
||||||
|
# --- SESSION & DANGER ZONE CARD ---
|
||||||
|
ft.Card(
|
||||||
|
bgcolor="#344e41",
|
||||||
|
margin=0,
|
||||||
|
content=ft.Container(
|
||||||
|
padding=20,
|
||||||
|
content=ft.Column(
|
||||||
|
controls=[
|
||||||
|
ft.Text("SESSION & ACCOUNT", size=12, color="#a3b18a", weight=ft.FontWeight.BOLD),
|
||||||
|
ft.OutlinedButton(
|
||||||
|
"Log Out",
|
||||||
|
icon=ft.Icons.LOGOUT,
|
||||||
|
icon_color="#FFFFFF",
|
||||||
|
style=ft.ButtonStyle(color="#FFFFFF"),
|
||||||
|
width=float("inf"),
|
||||||
|
on_click=handle_logout,
|
||||||
|
),
|
||||||
|
ft.FilledButton(
|
||||||
|
"Delete Account",
|
||||||
|
icon=ft.Icons.DELETE_FOREVER,
|
||||||
|
bgcolor=ft.Colors.RED_400,
|
||||||
|
color=ft.Colors.WHITE,
|
||||||
|
width=float("inf"),
|
||||||
|
on_click=prompt_delete_account,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
spacing=12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ft.Container(height=20),
|
||||||
|
],
|
||||||
|
spacing=10,
|
||||||
|
scroll=ft.ScrollMode.AUTO,
|
||||||
|
expand=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ft.View(
|
||||||
|
route="/account",
|
||||||
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
controls=[
|
||||||
|
ft.Container(
|
||||||
|
width=600,
|
||||||
|
content=content_column,
|
||||||
|
padding=ft.padding.Padding(16, 0, 16, 0),
|
||||||
|
expand=True,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
import flet as ft
|
||||||
|
from services.api import api_client
|
||||||
|
from services.storage import get_currency_symbol
|
||||||
|
|
||||||
|
def DashboardView(page: ft.Page, navigate) -> ft.View:
|
||||||
|
transactions = []
|
||||||
|
categories = []
|
||||||
|
user_info = {}
|
||||||
|
is_loading = True
|
||||||
|
editing_tx_id = None
|
||||||
|
|
||||||
|
user_greeting = ft.Text("Loading...", size=20, weight=ft.FontWeight.BOLD)
|
||||||
|
current_balance_text = ft.Text("0.00", size=32, weight=ft.FontWeight.BOLD, color="#dad7cd")
|
||||||
|
|
||||||
|
transaction_list = ft.Column(spacing=10, scroll=ft.ScrollMode.AUTO, expand=True)
|
||||||
|
loading_spinner = ft.ProgressRing(width=22, height=22, visible=True)
|
||||||
|
|
||||||
|
category_dropdown = ft.Dropdown(
|
||||||
|
label="Category (Optional)",
|
||||||
|
options=[],
|
||||||
|
)
|
||||||
|
title_in = ft.TextField(label="Title", autofocus=True)
|
||||||
|
|
||||||
|
def toggle_sign(e=None):
|
||||||
|
sign_btn.data = not sign_btn.data
|
||||||
|
sign_btn.icon = ft.Icons.ADD if sign_btn.data else ft.Icons.REMOVE
|
||||||
|
sign_btn.bgcolor = "#a3b18a" if sign_btn.data else "#e76f51"
|
||||||
|
sign_btn.update()
|
||||||
|
|
||||||
|
sign_btn = ft.FilledButton(
|
||||||
|
"",
|
||||||
|
icon=ft.Icons.REMOVE,
|
||||||
|
data=False,
|
||||||
|
bgcolor="#e76f51",
|
||||||
|
icon_color="#111d13",
|
||||||
|
on_click=toggle_sign,
|
||||||
|
width=55,
|
||||||
|
height=50,
|
||||||
|
style=ft.ButtonStyle(
|
||||||
|
shape=ft.RoundedRectangleBorder(radius=4),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
amount_in = ft.TextField(
|
||||||
|
label=f"Amount ({get_currency_symbol()})",
|
||||||
|
keyboard_type=ft.KeyboardType.NUMBER,
|
||||||
|
expand=True
|
||||||
|
)
|
||||||
|
|
||||||
|
amount_row = ft.Row(
|
||||||
|
[sign_btn, amount_in],
|
||||||
|
spacing=10,
|
||||||
|
vertical_alignment=ft.CrossAxisAlignment.CENTER
|
||||||
|
)
|
||||||
|
|
||||||
|
date_in = ft.TextField(
|
||||||
|
label="Date (YYYY-MM-DD)",
|
||||||
|
value=datetime.now().strftime("%Y-%m-%d"),
|
||||||
|
)
|
||||||
|
dialog_status = ft.Text("", color=ft.Colors.RED, size=12)
|
||||||
|
modal_title = ft.Text("Add Transaction")
|
||||||
|
|
||||||
|
def close_dialog(e=None):
|
||||||
|
add_modal.open = False
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
def save_transaction(e):
|
||||||
|
t_val = title_in.value.strip()
|
||||||
|
a_val = amount_in.value.strip().replace(',', '.')
|
||||||
|
d_val = date_in.value.strip()
|
||||||
|
|
||||||
|
cat_val = None
|
||||||
|
if category_dropdown.value:
|
||||||
|
cat_val = int(category_dropdown.value)
|
||||||
|
|
||||||
|
if not t_val or not a_val or not d_val:
|
||||||
|
dialog_status.value = "Title, Amount, and Date are required."
|
||||||
|
page.update()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
amt_float = float(a_val)
|
||||||
|
if not sign_btn.data and amt_float > 0:
|
||||||
|
amt_float = -amt_float
|
||||||
|
elif sign_btn.data and amt_float < 0:
|
||||||
|
amt_float = abs(amt_float)
|
||||||
|
except ValueError:
|
||||||
|
dialog_status.value = "Enter a valid numeric amount."
|
||||||
|
page.update()
|
||||||
|
return
|
||||||
|
|
||||||
|
if editing_tx_id is None:
|
||||||
|
ok, msg = api_client.create_transaction(t_val, amt_float, d_val, cat_val)
|
||||||
|
else:
|
||||||
|
ok, msg = api_client.update_transaction(editing_tx_id, t_val, amt_float, d_val, cat_val)
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
add_modal.open = False
|
||||||
|
page.update()
|
||||||
|
load_data()
|
||||||
|
else:
|
||||||
|
dialog_status.value = f"Failed: {msg}"
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
add_modal = ft.AlertDialog(
|
||||||
|
title=modal_title,
|
||||||
|
content=ft.Column(
|
||||||
|
controls=[
|
||||||
|
category_dropdown,
|
||||||
|
title_in,
|
||||||
|
amount_row,
|
||||||
|
date_in,
|
||||||
|
dialog_status,
|
||||||
|
],
|
||||||
|
tight=True,
|
||||||
|
spacing=15,
|
||||||
|
),
|
||||||
|
actions=[
|
||||||
|
ft.TextButton("Cancel", on_click=close_dialog),
|
||||||
|
ft.FilledButton("Save", on_click=save_transaction),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def open_add_dialog(e=None):
|
||||||
|
nonlocal editing_tx_id
|
||||||
|
editing_tx_id = None
|
||||||
|
modal_title.value = "Add Transaction"
|
||||||
|
amount_in.label = f"Amount ({get_currency_symbol()})"
|
||||||
|
|
||||||
|
title_in.value = ""
|
||||||
|
amount_in.value = ""
|
||||||
|
date_in.value = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
dialog_status.value = ""
|
||||||
|
category_dropdown.value = None
|
||||||
|
|
||||||
|
sign_btn.data = False
|
||||||
|
sign_btn.icon = ft.Icons.REMOVE
|
||||||
|
sign_btn.bgcolor = "#e76f51"
|
||||||
|
|
||||||
|
if add_modal not in page.overlay:
|
||||||
|
page.overlay.append(add_modal)
|
||||||
|
|
||||||
|
add_modal.open = True
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
def edit_item(tx: dict):
|
||||||
|
nonlocal editing_tx_id
|
||||||
|
editing_tx_id = tx["id"]
|
||||||
|
modal_title.value = "Edit Transaction"
|
||||||
|
amount_in.label = f"Amount ({get_currency_symbol()})"
|
||||||
|
|
||||||
|
title_in.value = tx.get("title", "")
|
||||||
|
amt_val = float(tx.get("amount", 0))
|
||||||
|
amount_in.value = f"{abs(amt_val):.2f}"
|
||||||
|
date_in.value = tx.get("date", datetime.now().strftime("%Y-%m-%d"))
|
||||||
|
dialog_status.value = ""
|
||||||
|
|
||||||
|
cat_obj = tx.get("category")
|
||||||
|
if cat_obj and isinstance(cat_obj, dict):
|
||||||
|
category_dropdown.value = str(cat_obj.get("id"))
|
||||||
|
else:
|
||||||
|
category_dropdown.value = None
|
||||||
|
|
||||||
|
is_income = amt_val >= 0
|
||||||
|
sign_btn.data = is_income
|
||||||
|
sign_btn.icon = ft.Icons.ADD if is_income else ft.Icons.REMOVE
|
||||||
|
sign_btn.bgcolor = "#a3b18a" if is_income else "#e76f51"
|
||||||
|
|
||||||
|
if add_modal not in page.overlay:
|
||||||
|
page.overlay.append(add_modal)
|
||||||
|
|
||||||
|
add_modal.open = True
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
deleting_tx_id = None
|
||||||
|
delete_confirm_text = ft.Text("")
|
||||||
|
|
||||||
|
def close_delete_dialog(e=None):
|
||||||
|
delete_modal.open = False
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
def confirm_delete(e):
|
||||||
|
nonlocal deleting_tx_id
|
||||||
|
if deleting_tx_id is not None:
|
||||||
|
ok, msg = api_client.delete_transaction(deleting_tx_id)
|
||||||
|
delete_modal.open = False
|
||||||
|
page.update()
|
||||||
|
if ok:
|
||||||
|
load_data()
|
||||||
|
else:
|
||||||
|
page.snack_bar = ft.SnackBar(
|
||||||
|
content=ft.Text(f"Failed to delete: {msg}"),
|
||||||
|
bg_color=ft.Colors.RED,
|
||||||
|
)
|
||||||
|
page.snack_bar.open = True
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
delete_modal = ft.AlertDialog(
|
||||||
|
title=ft.Text("Confirm Delete"),
|
||||||
|
content=delete_confirm_text,
|
||||||
|
actions=[
|
||||||
|
ft.TextButton("Cancel", on_click=close_delete_dialog),
|
||||||
|
ft.FilledButton("Delete", bgcolor=ft.Colors.RED_400, color=ft.Colors.WHITE, on_click=confirm_delete),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def prompt_delete(tx: dict):
|
||||||
|
nonlocal deleting_tx_id
|
||||||
|
deleting_tx_id = tx["id"]
|
||||||
|
tx_title = tx.get("title", "this transaction")
|
||||||
|
delete_confirm_text.value = f"Are you sure you want to delete '{tx_title}'?"
|
||||||
|
|
||||||
|
if delete_modal not in page.overlay:
|
||||||
|
page.overlay.append(delete_modal)
|
||||||
|
|
||||||
|
delete_modal.open = True
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
def load_data(e=None):
|
||||||
|
nonlocal transactions, user_info, categories, is_loading
|
||||||
|
is_loading = True
|
||||||
|
loading_spinner.visible = True
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
ok_user, user_res = api_client.get_me()
|
||||||
|
if ok_user and isinstance(user_res, dict):
|
||||||
|
user_info = user_res
|
||||||
|
first_name = user_info.get("first_name")
|
||||||
|
username = user_info.get("username", "User")
|
||||||
|
user_greeting.value = f"Welcome, {first_name or username}!"
|
||||||
|
elif not ok_user and "401" in str(user_res):
|
||||||
|
api_client.logout()
|
||||||
|
navigate("/login")
|
||||||
|
return
|
||||||
|
|
||||||
|
ok_cat, cat_res = api_client.get_categories()
|
||||||
|
if ok_cat and isinstance(cat_res, list):
|
||||||
|
categories = cat_res
|
||||||
|
category_dropdown.options = [
|
||||||
|
ft.dropdown.Option(key=str(c["id"]), text=c["name"]) for c in categories
|
||||||
|
]
|
||||||
|
|
||||||
|
ok_tx, tx_res = api_client.get_transactions()
|
||||||
|
if ok_tx and isinstance(tx_res, list):
|
||||||
|
transactions = tx_res
|
||||||
|
else:
|
||||||
|
transactions = []
|
||||||
|
|
||||||
|
is_loading = False
|
||||||
|
loading_spinner.visible = False
|
||||||
|
render_dashboard()
|
||||||
|
|
||||||
|
def render_dashboard():
|
||||||
|
curr_symbol = get_currency_symbol()
|
||||||
|
total_spent = sum(float(t.get("amount", 0)) for t in transactions)
|
||||||
|
current_balance_text.value = f"{total_spent:,.2f} {curr_symbol}"
|
||||||
|
|
||||||
|
transaction_list.controls.clear()
|
||||||
|
|
||||||
|
if not transactions:
|
||||||
|
transaction_list.controls.append(
|
||||||
|
ft.Container(
|
||||||
|
padding=30,
|
||||||
|
alignment=ft.Alignment.CENTER,
|
||||||
|
content=ft.Column(
|
||||||
|
controls=[
|
||||||
|
ft.Icon(ft.Icons.RECEIPT_LONG, size=48, color="#a3b18a"),
|
||||||
|
ft.Text("No transactions yet.", color="#a3b18a", size=16),
|
||||||
|
ft.Text("Tap '+' below to add one.", color="#a3b18a", size=12),
|
||||||
|
],
|
||||||
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
spacing=8,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for tx in transactions:
|
||||||
|
cat_name = tx.get("category", {}).get("name") if tx.get("category") else "General"
|
||||||
|
amount_val = float(tx.get("amount", 0))
|
||||||
|
|
||||||
|
icon_type = ft.Icons.ADD if amount_val >= 0 else ft.Icons.REMOVE
|
||||||
|
|
||||||
|
transaction_list.controls.append(
|
||||||
|
ft.Card(
|
||||||
|
bgcolor="#344e41",
|
||||||
|
content=ft.Container(
|
||||||
|
padding=14,
|
||||||
|
content=ft.Column(
|
||||||
|
controls=[
|
||||||
|
ft.Row(
|
||||||
|
controls=[
|
||||||
|
ft.Text(
|
||||||
|
tx.get("title", "Untitled"),
|
||||||
|
weight=ft.FontWeight.BOLD,
|
||||||
|
size=16,
|
||||||
|
expand=True,
|
||||||
|
),
|
||||||
|
ft.Text(
|
||||||
|
f"{cat_name} • {tx.get('date', '')}",
|
||||||
|
size=12,
|
||||||
|
color="#a3b18a",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||||
|
),
|
||||||
|
ft.Row(
|
||||||
|
controls=[
|
||||||
|
ft.Row(
|
||||||
|
controls=[
|
||||||
|
ft.Container(
|
||||||
|
padding=6,
|
||||||
|
border_radius=8,
|
||||||
|
bgcolor="#2a3d32",
|
||||||
|
content=ft.Icon(
|
||||||
|
icon_type,
|
||||||
|
color="#a3b18a",
|
||||||
|
size=18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ft.Text(
|
||||||
|
f"{abs(amount_val):,.2f} {curr_symbol}",
|
||||||
|
weight=ft.FontWeight.BOLD,
|
||||||
|
size=16,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
spacing=10,
|
||||||
|
),
|
||||||
|
ft.Row(
|
||||||
|
controls=[
|
||||||
|
ft.IconButton(
|
||||||
|
icon=ft.Icons.EDIT_OUTLINED,
|
||||||
|
icon_color="#a3b18a",
|
||||||
|
icon_size=20,
|
||||||
|
tooltip="Edit",
|
||||||
|
on_click=lambda e, item=tx: edit_item(item),
|
||||||
|
),
|
||||||
|
ft.IconButton(
|
||||||
|
icon=ft.Icons.DELETE_OUTLINED,
|
||||||
|
icon_color=ft.Colors.RED_300,
|
||||||
|
icon_size=20,
|
||||||
|
tooltip="Delete",
|
||||||
|
on_click=lambda e, item=tx: prompt_delete(item),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
spacing=0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||||
|
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
spacing=8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
load_data()
|
||||||
|
|
||||||
|
content_column = ft.Column(
|
||||||
|
controls=[
|
||||||
|
ft.Container(height=32),
|
||||||
|
ft.Row(
|
||||||
|
controls=[
|
||||||
|
ft.Text("Jast", size=24, weight=ft.FontWeight.BOLD, color="#a3b18a"),
|
||||||
|
ft.Row(
|
||||||
|
controls=[
|
||||||
|
loading_spinner,
|
||||||
|
ft.IconButton(
|
||||||
|
icon=ft.Icons.REFRESH,
|
||||||
|
icon_color="#a3b18a",
|
||||||
|
icon_size=20,
|
||||||
|
tooltip="Refresh Data",
|
||||||
|
on_click=load_data,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
spacing=0,
|
||||||
|
vertical_alignment=ft.CrossAxisAlignment.CENTER
|
||||||
|
)
|
||||||
|
],
|
||||||
|
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||||
|
),
|
||||||
|
user_greeting,
|
||||||
|
ft.Container(height=6),
|
||||||
|
ft.Card(
|
||||||
|
bgcolor="#344e41",
|
||||||
|
margin=0,
|
||||||
|
content=ft.Container(
|
||||||
|
padding=20,
|
||||||
|
width=float("inf"),
|
||||||
|
content=ft.Column(
|
||||||
|
controls=[
|
||||||
|
ft.Text(
|
||||||
|
"CURRENT BALANCE",
|
||||||
|
size=12,
|
||||||
|
color="#a3b18a",
|
||||||
|
weight=ft.FontWeight.BOLD,
|
||||||
|
),
|
||||||
|
current_balance_text,
|
||||||
|
],
|
||||||
|
spacing=4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ft.Container(height=4),
|
||||||
|
ft.Row(
|
||||||
|
controls=[
|
||||||
|
ft.FilledButton(
|
||||||
|
"Statistics",
|
||||||
|
icon=ft.Icons.BAR_CHART,
|
||||||
|
color="#dad7cd",
|
||||||
|
bgcolor="#344e41",
|
||||||
|
expand=True,
|
||||||
|
on_click=lambda _: navigate("/statistics"),
|
||||||
|
),
|
||||||
|
ft.FilledButton(
|
||||||
|
"Account",
|
||||||
|
icon=ft.Icons.ACCOUNT_CIRCLE,
|
||||||
|
color="#dad7cd",
|
||||||
|
bgcolor="#344e41",
|
||||||
|
expand=True,
|
||||||
|
on_click=lambda _: navigate("/account"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
spacing=10,
|
||||||
|
),
|
||||||
|
ft.Container(height=6),
|
||||||
|
ft.Text("Recent Transactions", size=18, weight=ft.FontWeight.BOLD),
|
||||||
|
transaction_list,
|
||||||
|
],
|
||||||
|
spacing=10,
|
||||||
|
expand=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ft.View(
|
||||||
|
route="/dashboard",
|
||||||
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
floating_action_button=ft.FloatingActionButton(
|
||||||
|
icon=ft.Icons.ADD,
|
||||||
|
bgcolor="#a3b18a",
|
||||||
|
foreground_color="#344e41",
|
||||||
|
tooltip="Add Transaction",
|
||||||
|
on_click=open_add_dialog,
|
||||||
|
),
|
||||||
|
controls=[
|
||||||
|
ft.Container(
|
||||||
|
width=600,
|
||||||
|
content=content_column,
|
||||||
|
padding=ft.padding.Padding(16, 0, 16, 0),
|
||||||
|
expand=True,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import flet as ft
|
||||||
|
from services.api import api_client
|
||||||
|
from services.storage import get_setting, remove_setting
|
||||||
|
|
||||||
|
|
||||||
|
def LoginView(page: ft.Page, navigate) -> ft.View:
|
||||||
|
server_url = get_setting("server_url") or "Not Configured"
|
||||||
|
|
||||||
|
# Input controls
|
||||||
|
username_input = ft.TextField(
|
||||||
|
label="Username or Email",
|
||||||
|
prefix_icon=ft.Icons.PERSON,
|
||||||
|
autofocus=True,
|
||||||
|
width=360,
|
||||||
|
)
|
||||||
|
|
||||||
|
password_input = ft.TextField(
|
||||||
|
label="Password",
|
||||||
|
prefix_icon=ft.Icons.LOCK,
|
||||||
|
password=True,
|
||||||
|
can_reveal_password=True,
|
||||||
|
width=360,
|
||||||
|
)
|
||||||
|
|
||||||
|
status_text = ft.Text("", color=ft.Colors.RED, size=14)
|
||||||
|
loading_ring = ft.ProgressRing(width=20, height=20, visible=False, stroke_width=2)
|
||||||
|
|
||||||
|
def handle_login(e):
|
||||||
|
username = username_input.value.strip()
|
||||||
|
password = password_input.value
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
status_text.value = "Please enter both username and password."
|
||||||
|
status_text.color = ft.Colors.RED
|
||||||
|
page.update()
|
||||||
|
return
|
||||||
|
|
||||||
|
# UI feedback for in-flight request
|
||||||
|
login_btn.disabled = True
|
||||||
|
loading_ring.visible = True
|
||||||
|
status_text.value = ""
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
# Call Django authentication endpoint
|
||||||
|
success, message = api_client.login(username, password)
|
||||||
|
|
||||||
|
loading_ring.visible = False
|
||||||
|
login_btn.disabled = False
|
||||||
|
|
||||||
|
if success:
|
||||||
|
navigate("/dashboard")
|
||||||
|
else:
|
||||||
|
status_text.value = message
|
||||||
|
status_text.color = ft.Colors.RED
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
login_btn = ft.FilledButton(
|
||||||
|
"Sign In",
|
||||||
|
icon=ft.Icons.LOGIN,
|
||||||
|
on_click=handle_login,
|
||||||
|
width=360,
|
||||||
|
)
|
||||||
|
|
||||||
|
def change_server(e):
|
||||||
|
remove_setting("server_url")
|
||||||
|
navigate("/server")
|
||||||
|
|
||||||
|
return ft.View(
|
||||||
|
route="/login",
|
||||||
|
controls=[
|
||||||
|
ft.SafeArea(
|
||||||
|
content=ft.Container(
|
||||||
|
alignment=ft.Alignment.CENTER,
|
||||||
|
content=ft.Column(
|
||||||
|
controls=[
|
||||||
|
ft.Icon(ft.Icons.LOCK_PERSON, size=64, color="#a3b18a"),
|
||||||
|
ft.Text("Sign In", size=28, weight=ft.FontWeight.BOLD),
|
||||||
|
# Connected server badge with reset action
|
||||||
|
ft.Row(
|
||||||
|
controls=[
|
||||||
|
ft.Icon(ft.Icons.STORAGE, size=16, color="#a3b18a"),
|
||||||
|
ft.Text(
|
||||||
|
server_url,
|
||||||
|
size=12,
|
||||||
|
color="#a3b18a",
|
||||||
|
weight=ft.FontWeight.W_500,
|
||||||
|
),
|
||||||
|
ft.TextButton(
|
||||||
|
"Change",
|
||||||
|
on_click=change_server,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
|
spacing=4,
|
||||||
|
),
|
||||||
|
ft.Container(height=10),
|
||||||
|
username_input,
|
||||||
|
password_input,
|
||||||
|
ft.Row(
|
||||||
|
controls=[loading_ring, status_text],
|
||||||
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
|
spacing=10,
|
||||||
|
),
|
||||||
|
login_btn,
|
||||||
|
ft.Container(height=10),
|
||||||
|
# Link to register view
|
||||||
|
ft.Row(
|
||||||
|
controls=[
|
||||||
|
ft.Text("Don't have an account?", color="#a3b18a"),
|
||||||
|
ft.TextButton(
|
||||||
|
"Register",
|
||||||
|
on_click=lambda _: navigate("/register"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
|
spacing=2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
|
spacing=12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
expand=True,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import flet as ft
|
||||||
|
from services.api import api_client
|
||||||
|
from services.storage import get_setting, remove_setting
|
||||||
|
|
||||||
|
|
||||||
|
def RegisterView(page: ft.Page, navigate) -> ft.View:
|
||||||
|
server_url = get_setting("server_url") or "Not Configured"
|
||||||
|
|
||||||
|
# Input controls
|
||||||
|
username_input = ft.TextField(
|
||||||
|
label="Username",
|
||||||
|
prefix_icon=ft.Icons.PERSON,
|
||||||
|
autofocus=True,
|
||||||
|
width=360,
|
||||||
|
)
|
||||||
|
|
||||||
|
email_input = ft.TextField(
|
||||||
|
label="Email (Optional)",
|
||||||
|
prefix_icon=ft.Icons.EMAIL,
|
||||||
|
width=360,
|
||||||
|
)
|
||||||
|
|
||||||
|
first_name_input = ft.TextField(
|
||||||
|
label="First Name (Optional)",
|
||||||
|
prefix_icon=ft.Icons.BADGE,
|
||||||
|
width=360,
|
||||||
|
)
|
||||||
|
|
||||||
|
last_name_input = ft.TextField(
|
||||||
|
label="Last Name (Optional)",
|
||||||
|
prefix_icon=ft.Icons.BADGE_OUTLINED,
|
||||||
|
width=360,
|
||||||
|
)
|
||||||
|
|
||||||
|
password_input = ft.TextField(
|
||||||
|
label="Password",
|
||||||
|
prefix_icon=ft.Icons.LOCK,
|
||||||
|
password=True,
|
||||||
|
can_reveal_password=True,
|
||||||
|
width=360,
|
||||||
|
)
|
||||||
|
|
||||||
|
confirm_password_input = ft.TextField(
|
||||||
|
label="Confirm Password",
|
||||||
|
prefix_icon=ft.Icons.LOCK_OUTLINED,
|
||||||
|
password=True,
|
||||||
|
can_reveal_password=True,
|
||||||
|
width=360,
|
||||||
|
)
|
||||||
|
|
||||||
|
status_text = ft.Text("", color=ft.Colors.RED, size=14)
|
||||||
|
loading_ring = ft.ProgressRing(width=20, height=20, visible=False, stroke_width=2)
|
||||||
|
|
||||||
|
def handle_register(e):
|
||||||
|
username = username_input.value.strip()
|
||||||
|
email = email_input.value.strip()
|
||||||
|
first_name = first_name_input.value.strip()
|
||||||
|
last_name = last_name_input.value.strip()
|
||||||
|
password = password_input.value
|
||||||
|
confirm_password = confirm_password_input.value
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
status_text.value = "Username and password are required."
|
||||||
|
status_text.color = ft.Colors.RED
|
||||||
|
page.update()
|
||||||
|
return
|
||||||
|
|
||||||
|
if password != confirm_password:
|
||||||
|
status_text.value = "Passwords do not match."
|
||||||
|
status_text.color = ft.Colors.RED
|
||||||
|
page.update()
|
||||||
|
return
|
||||||
|
|
||||||
|
# UI feedback for in-flight request
|
||||||
|
register_btn.disabled = True
|
||||||
|
loading_ring.visible = True
|
||||||
|
status_text.value = ""
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
# Call Django registration endpoint
|
||||||
|
success, message = api_client.register(
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
email=email,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
loading_ring.visible = False
|
||||||
|
register_btn.disabled = False
|
||||||
|
|
||||||
|
if success:
|
||||||
|
navigate("/dashboard")
|
||||||
|
else:
|
||||||
|
status_text.value = message
|
||||||
|
status_text.color = ft.Colors.RED
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
register_btn = ft.FilledButton(
|
||||||
|
"Create Account",
|
||||||
|
icon=ft.Icons.PERSON_ADD,
|
||||||
|
on_click=handle_register,
|
||||||
|
width=360,
|
||||||
|
)
|
||||||
|
|
||||||
|
def change_server(e):
|
||||||
|
remove_setting("server_url")
|
||||||
|
navigate("/server")
|
||||||
|
|
||||||
|
return ft.View(
|
||||||
|
route="/register",
|
||||||
|
controls=[
|
||||||
|
ft.SafeArea(
|
||||||
|
content=ft.Container(
|
||||||
|
alignment=ft.Alignment.CENTER,
|
||||||
|
content=ft.Column(
|
||||||
|
controls=[
|
||||||
|
ft.Icon(ft.Icons.APP_REGISTRATION, size=56, color="#a3b18a"),
|
||||||
|
ft.Text("Register", size=28, weight=ft.FontWeight.BOLD),
|
||||||
|
# Connected server badge with reset action
|
||||||
|
ft.Row(
|
||||||
|
controls=[
|
||||||
|
ft.Icon(ft.Icons.STORAGE, size=16, color="#a3b18a"),
|
||||||
|
ft.Text(
|
||||||
|
server_url,
|
||||||
|
size=12,
|
||||||
|
color="#a3b18a",
|
||||||
|
weight=ft.FontWeight.W_500,
|
||||||
|
),
|
||||||
|
ft.TextButton(
|
||||||
|
"Change",
|
||||||
|
on_click=change_server,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
|
spacing=4,
|
||||||
|
),
|
||||||
|
ft.Container(height=6),
|
||||||
|
username_input,
|
||||||
|
email_input,
|
||||||
|
first_name_input,
|
||||||
|
last_name_input,
|
||||||
|
password_input,
|
||||||
|
confirm_password_input,
|
||||||
|
ft.Row(
|
||||||
|
controls=[loading_ring, status_text],
|
||||||
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
|
spacing=10,
|
||||||
|
),
|
||||||
|
register_btn,
|
||||||
|
ft.Container(height=6),
|
||||||
|
# Link to login view
|
||||||
|
ft.Row(
|
||||||
|
controls=[
|
||||||
|
ft.Text("Already have an account?", color="#a3b18a"),
|
||||||
|
ft.TextButton(
|
||||||
|
"Sign In",
|
||||||
|
on_click=lambda _: navigate("/login"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
|
spacing=2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
|
spacing=10,
|
||||||
|
scroll=ft.ScrollMode.AUTO,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
expand=True,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import flet as ft
|
||||||
|
from services.storage import save_setting
|
||||||
|
|
||||||
|
|
||||||
|
def ServerPickerView(page: ft.Page, navigate) -> ft.View:
|
||||||
|
# 1. Set width to 140px so "https://" fits comfortably alongside the dropdown arrow
|
||||||
|
protocol_dropdown = ft.Dropdown(
|
||||||
|
value="https://",
|
||||||
|
width=140,
|
||||||
|
options=[
|
||||||
|
ft.dropdown.Option("https://"),
|
||||||
|
ft.dropdown.Option("http://"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Host input field takes remaining horizontal space
|
||||||
|
host_input = ft.TextField(
|
||||||
|
label="Server Host / IP",
|
||||||
|
hint_text="jast.example.com",
|
||||||
|
autofocus=True,
|
||||||
|
expand=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
status_text = ft.Text("", color=ft.Colors.RED)
|
||||||
|
|
||||||
|
def save_server(e):
|
||||||
|
raw_host = host_input.value.strip()
|
||||||
|
|
||||||
|
# Clean off any protocol typed by accident
|
||||||
|
if raw_host.startswith("http://"):
|
||||||
|
raw_host = raw_host[7:]
|
||||||
|
elif raw_host.startswith("https://"):
|
||||||
|
raw_host = raw_host[8:]
|
||||||
|
|
||||||
|
# Strip trailing slashes or pre-existing /api
|
||||||
|
raw_host = raw_host.rstrip("/")
|
||||||
|
if raw_host.endswith("/api"):
|
||||||
|
raw_host = raw_host[:-4].rstrip("/")
|
||||||
|
|
||||||
|
if not raw_host:
|
||||||
|
status_text.value = "Please enter a valid host address"
|
||||||
|
page.update()
|
||||||
|
return
|
||||||
|
|
||||||
|
protocol = protocol_dropdown.value
|
||||||
|
final_url = f"{protocol}{raw_host}/api"
|
||||||
|
|
||||||
|
save_setting("server_url", final_url)
|
||||||
|
navigate("/login")
|
||||||
|
|
||||||
|
return ft.View(
|
||||||
|
route="/server",
|
||||||
|
controls=[
|
||||||
|
ft.SafeArea(
|
||||||
|
content=ft.Container(
|
||||||
|
alignment=ft.Alignment.CENTER,
|
||||||
|
content=ft.Column(
|
||||||
|
controls=[
|
||||||
|
ft.Icon(ft.Icons.PARK, size=64, color="#a3b18a"),
|
||||||
|
ft.Text(
|
||||||
|
"Welcome to Jast",
|
||||||
|
size=28,
|
||||||
|
weight=ft.FontWeight.BOLD,
|
||||||
|
),
|
||||||
|
ft.Text(
|
||||||
|
"Select protocol and enter server host",
|
||||||
|
color="#a3b18a",
|
||||||
|
),
|
||||||
|
ft.Container(height=10),
|
||||||
|
# Container set to 360px total width
|
||||||
|
ft.Container(
|
||||||
|
width=360,
|
||||||
|
content=ft.Row(
|
||||||
|
controls=[protocol_dropdown, host_input],
|
||||||
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
|
spacing=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
# Subtext helper note
|
||||||
|
ft.Text(
|
||||||
|
"e.g. jast.domain.com or 192.168.1.50:8000",
|
||||||
|
size=12,
|
||||||
|
color="#a3b18a",
|
||||||
|
),
|
||||||
|
status_text,
|
||||||
|
ft.FilledButton(
|
||||||
|
"Connect to Server",
|
||||||
|
icon=ft.Icons.ARROW_FORWARD,
|
||||||
|
on_click=save_server,
|
||||||
|
width=360,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
|
spacing=12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
expand=True,
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import flet as ft
|
||||||
|
import flet_charts as fch
|
||||||
|
from services.api import api_client
|
||||||
|
from services.storage import get_currency_symbol
|
||||||
|
|
||||||
|
def StatisticsView(page: ft.Page, navigate) -> ft.View:
|
||||||
|
transactions = []
|
||||||
|
is_loading = True
|
||||||
|
|
||||||
|
PIE_COLORS = [
|
||||||
|
"#56B4E9",
|
||||||
|
"#E69F00",
|
||||||
|
"#009E73",
|
||||||
|
"#CC79A7",
|
||||||
|
"#F0E442",
|
||||||
|
"#D55E00",
|
||||||
|
"#0072B2",
|
||||||
|
]
|
||||||
|
|
||||||
|
loading_spinner = ft.ProgressRing(width=22, height=22, visible=True)
|
||||||
|
chart_container = ft.Container(alignment=ft.Alignment.CENTER, height=300)
|
||||||
|
legend_column = ft.Column(spacing=10)
|
||||||
|
|
||||||
|
callout_text = ft.Text(
|
||||||
|
"Tap or hover over a section to view details",
|
||||||
|
size=13,
|
||||||
|
color="#a3b18a",
|
||||||
|
weight=ft.FontWeight.W_500,
|
||||||
|
text_align=ft.TextAlign.CENTER,
|
||||||
|
)
|
||||||
|
|
||||||
|
speech_bubble = ft.Container(
|
||||||
|
content=callout_text,
|
||||||
|
bgcolor="#344e41",
|
||||||
|
padding=ft.padding.Padding(16, 10, 16, 10),
|
||||||
|
border_radius=12,
|
||||||
|
alignment=ft.Alignment.CENTER,
|
||||||
|
border=ft.Border.all(1, "#588157"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def load_data():
|
||||||
|
nonlocal transactions, is_loading
|
||||||
|
is_loading = True
|
||||||
|
loading_spinner.visible = True
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
ok_tx, tx_res = api_client.get_transactions()
|
||||||
|
if ok_tx and isinstance(tx_res, list):
|
||||||
|
transactions = tx_res
|
||||||
|
else:
|
||||||
|
transactions = []
|
||||||
|
|
||||||
|
is_loading = False
|
||||||
|
loading_spinner.visible = False
|
||||||
|
render_statistics()
|
||||||
|
|
||||||
|
def render_statistics():
|
||||||
|
curr_symbol = get_currency_symbol()
|
||||||
|
category_totals = {}
|
||||||
|
total_expenses = 0.0
|
||||||
|
|
||||||
|
for tx in transactions:
|
||||||
|
amt = float(tx.get("amount", 0))
|
||||||
|
if amt < 0:
|
||||||
|
cat_name = tx.get("category", {}).get("name") if tx.get("category") else "General"
|
||||||
|
abs_amt = abs(amt)
|
||||||
|
category_totals[cat_name] = category_totals.get(cat_name, 0) + abs_amt
|
||||||
|
total_expenses += abs_amt
|
||||||
|
|
||||||
|
chart_container.content = None
|
||||||
|
legend_column.controls.clear()
|
||||||
|
|
||||||
|
if total_expenses == 0:
|
||||||
|
chart_container.content = ft.Column(
|
||||||
|
controls=[
|
||||||
|
ft.Icon(ft.Icons.PIE_CHART_OUTLINE, size=48, color="#a3b18a"),
|
||||||
|
ft.Text("No expense data to display.", color="#a3b18a"),
|
||||||
|
],
|
||||||
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
alignment=ft.MainAxisAlignment.CENTER,
|
||||||
|
)
|
||||||
|
page.update()
|
||||||
|
return
|
||||||
|
|
||||||
|
sorted_categories = sorted(category_totals.items(), key=lambda item: item[1], reverse=True)
|
||||||
|
|
||||||
|
display_categories = []
|
||||||
|
if len(sorted_categories) > 6:
|
||||||
|
display_categories = sorted_categories[:6]
|
||||||
|
other_total = sum(amount for _, amount in sorted_categories[6:])
|
||||||
|
if other_total > 0:
|
||||||
|
display_categories.append(("Other", other_total))
|
||||||
|
else:
|
||||||
|
display_categories = sorted_categories
|
||||||
|
|
||||||
|
sections = []
|
||||||
|
chart_data = []
|
||||||
|
|
||||||
|
for index, (cat_name, amount) in enumerate(display_categories):
|
||||||
|
color = PIE_COLORS[index]
|
||||||
|
percentage = (amount / total_expenses) * 100
|
||||||
|
|
||||||
|
chart_data.append({
|
||||||
|
"cat_name": cat_name,
|
||||||
|
"amount": amount,
|
||||||
|
"percentage": percentage,
|
||||||
|
"color": color
|
||||||
|
})
|
||||||
|
|
||||||
|
sections.append(
|
||||||
|
fch.PieChartSection(
|
||||||
|
value=amount,
|
||||||
|
title=f"{percentage:.0f}%",
|
||||||
|
title_style=ft.TextStyle(size=13, color="#111d13", weight=ft.FontWeight.BOLD),
|
||||||
|
color=color,
|
||||||
|
radius=80,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
legend_column.controls.append(
|
||||||
|
ft.Card(
|
||||||
|
bgcolor="#344e41",
|
||||||
|
margin=0,
|
||||||
|
content=ft.Container(
|
||||||
|
padding=12,
|
||||||
|
content=ft.Row(
|
||||||
|
controls=[
|
||||||
|
ft.Row(
|
||||||
|
controls=[
|
||||||
|
ft.Container(width=12, height=12, border_radius=6, bgcolor=color),
|
||||||
|
ft.Text(cat_name, weight=ft.FontWeight.BOLD, size=14),
|
||||||
|
],
|
||||||
|
spacing=10,
|
||||||
|
),
|
||||||
|
ft.Text(f"{amount:,.2f} {curr_symbol}", weight=ft.FontWeight.BOLD, size=14),
|
||||||
|
],
|
||||||
|
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_chart_event(e):
|
||||||
|
hovered_index = getattr(e, "section_index", -1)
|
||||||
|
if hovered_index is None:
|
||||||
|
hovered_index = -1
|
||||||
|
|
||||||
|
if 0 <= hovered_index < len(chart_data):
|
||||||
|
selected = chart_data[hovered_index]
|
||||||
|
callout_text.value = f"{selected['cat_name']}: {selected['amount']:,.2f} {curr_symbol} ({selected['percentage']:.1f}%)"
|
||||||
|
callout_text.color = "#dad7cd"
|
||||||
|
speech_bubble.border = ft.Border.all(1.5, selected["color"])
|
||||||
|
else:
|
||||||
|
callout_text.value = "Tap or hover over a section to view details"
|
||||||
|
callout_text.color = "#a3b18a"
|
||||||
|
speech_bubble.border = ft.Border.all(1, "#588157")
|
||||||
|
|
||||||
|
for idx, section in enumerate(pie_chart.sections):
|
||||||
|
if idx == hovered_index:
|
||||||
|
section.radius = 98
|
||||||
|
else:
|
||||||
|
section.radius = 80
|
||||||
|
|
||||||
|
speech_bubble.update()
|
||||||
|
pie_chart.update()
|
||||||
|
|
||||||
|
pie_chart = fch.PieChart(
|
||||||
|
sections=sections,
|
||||||
|
sections_space=3,
|
||||||
|
center_space_radius=45,
|
||||||
|
expand=True,
|
||||||
|
on_event=on_chart_event,
|
||||||
|
)
|
||||||
|
chart_container.content = pie_chart
|
||||||
|
|
||||||
|
page.update()
|
||||||
|
|
||||||
|
load_data()
|
||||||
|
|
||||||
|
content_column = ft.Column(
|
||||||
|
controls=[
|
||||||
|
ft.Container(height=32),
|
||||||
|
ft.Row(
|
||||||
|
controls=[
|
||||||
|
ft.Text("Jast", size=24, weight=ft.FontWeight.BOLD, color="#a3b18a"),
|
||||||
|
ft.Row(
|
||||||
|
controls=[
|
||||||
|
loading_spinner,
|
||||||
|
ft.IconButton(
|
||||||
|
icon=ft.Icons.ARROW_BACK,
|
||||||
|
icon_color="#a3b18a",
|
||||||
|
icon_size=20,
|
||||||
|
tooltip="Back to Dashboard",
|
||||||
|
on_click=lambda _: navigate("/dashboard"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
spacing=0,
|
||||||
|
vertical_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
alignment=ft.MainAxisAlignment.SPACE_BETWEEN,
|
||||||
|
),
|
||||||
|
ft.Text("Statistics", size=20, weight=ft.FontWeight.BOLD),
|
||||||
|
ft.Container(height=10),
|
||||||
|
speech_bubble,
|
||||||
|
ft.Container(height=10),
|
||||||
|
chart_container,
|
||||||
|
ft.Container(height=10),
|
||||||
|
ft.Text("Spending by Category", size=18, weight=ft.FontWeight.BOLD),
|
||||||
|
legend_column,
|
||||||
|
],
|
||||||
|
spacing=10,
|
||||||
|
scroll=ft.ScrollMode.AUTO,
|
||||||
|
expand=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ft.View(
|
||||||
|
route="/statistics",
|
||||||
|
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
|
||||||
|
controls=[
|
||||||
|
ft.Container(
|
||||||
|
width=600,
|
||||||
|
content=content_column,
|
||||||
|
padding=ft.padding.Padding(16, 0, 16, 0),
|
||||||
|
expand=True,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import flet.testing as ftt
|
||||||
|
|
||||||
|
|
||||||
|
async def test_increment(flet_app: ftt.FletTestApp):
|
||||||
|
"""Counter sample: tap the FAB and assert the counter goes 0 -> 1.
|
||||||
|
|
||||||
|
The `flet_app` fixture is provided automatically by the flet pytest plugin.
|
||||||
|
Run with `flet test` (or `uv run pytest`) from the project directory.
|
||||||
|
"""
|
||||||
|
tester = flet_app.tester
|
||||||
|
|
||||||
|
await tester.pump_and_settle()
|
||||||
|
|
||||||
|
# Initial state
|
||||||
|
assert (await tester.find_by_text("0")).count == 1
|
||||||
|
|
||||||
|
# Tap the increment button (found by its key) and let the UI settle
|
||||||
|
await tester.tap(await tester.find_by_key("increment"))
|
||||||
|
await tester.pump_and_settle()
|
||||||
|
|
||||||
|
# New state
|
||||||
|
assert (await tester.find_by_text("1")).count == 1
|
||||||
Reference in new issue
Block a user