Initial commit: Flet frontend with CI/CD

This commit is contained in:
Lyxminxx committed 2026-09-21 20:43:41 +02:00
commit d51545bd86
19 files changed
+4355

No files matched your search

+69
View File
@@ -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
View File
@@ -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
View File
@@ -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;"]
+69
View File
@@ -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/).
+45
View File
@@ -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
View File
@@ -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)
+244
View File
@@ -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()
+44
View File
@@ -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)
+4
View File
@@ -0,0 +1,4 @@
from .forest import forest_theme
def get_theme() -> ft.Theme:
return forest_theme()
+20
View File
@@ -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,
)
+238
View File
@@ -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,
),
],
)
+456
View File
@@ -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,
),
],
)
+127
View File
@@ -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,
)
],
)
+174
View File
@@ -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,
)
],
)
+101
View File
@@ -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,
)
],
)
+228
View File
@@ -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,
),
],
)
+22
View File
@@ -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
Generated
+2399
View File
File diff suppressed because it is too large. Load diff