diff --git a/src/main.py b/src/main.py index fc66f5f..78c3a0d 100644 --- a/src/main.py +++ b/src/main.py @@ -1,5 +1,6 @@ import flet as ft -from services.storage import get_setting +from services.storage import AppStorage +from services.api import ApiClient from themes import get_theme from views.dashboard import DashboardView from views.login import LoginView @@ -8,15 +9,20 @@ from views.statistics import StatisticsView from views.account import AccountView from views.register import RegisterView - -def main(page: ft.Page): +# 1. Change to async def +async 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" + app_storage = AppStorage(page) + # 2. Await the initial cache load BEFORE running the rest of the app + await app_storage.init() + + api_client = ApiClient(app_storage) + def navigate(route: str): page.route = route route_change() @@ -25,30 +31,28 @@ def main(page: ft.Page): page.views.clear() if page.route == "/server": - page.views.append(ServerPickerView(page, navigate)) + page.views.append(ServerPickerView(page, navigate, app_storage, api_client)) elif page.route == "/login": - page.views.append(LoginView(page, navigate)) + page.views.append(LoginView(page, navigate, app_storage, api_client)) elif page.route == "/register": - page.views.append(RegisterView(page, navigate)) + page.views.append(RegisterView(page, navigate, app_storage, api_client)) elif page.route == "/dashboard": - page.views.append(DashboardView(page, navigate)) + page.views.append(DashboardView(page, navigate, app_storage, api_client)) elif page.route == "/statistics": - page.views.append(StatisticsView(page, navigate)) + page.views.append(StatisticsView(page, navigate, app_storage, api_client)) elif page.route == "/account": - page.views.append(AccountView(page, navigate)) + page.views.append(AccountView(page, navigate, app_storage, api_client)) page.update() page.on_route_change = route_change - # Startup route guard - if not get_setting("server_url"): + if not app_storage.get_setting("server_url"): navigate("/server") - elif not get_setting("auth_token"): + elif not app_storage.get_setting("auth_token"): navigate("/login") else: navigate("/dashboard") - if __name__ == "__main__": ft.run(main) diff --git a/src/services/api.py b/src/services/api.py index 881fe6a..32a5fcd 100644 --- a/src/services/api.py +++ b/src/services/api.py @@ -1,25 +1,23 @@ import httpx -from services.storage import get_setting, remove_setting, save_setting class ApiClient: - def __init__(self, timeout: float = 10.0): + def __init__(self, storage, timeout: float = 10.0): + self.storage = storage self.timeout = timeout @property def base_url(self) -> str | None: - return get_setting("server_url") + return self.storage.get_setting("server_url") def get_headers(self) -> dict[str, str]: headers = {"Content-Type": "application/json"} - token = get_setting("auth_token") + token = self.storage.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." + 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()) @@ -30,22 +28,18 @@ class ApiClient: return False, str(err) def get_categories(self) -> tuple[bool, list]: - """Fetches available categories from Django Ninja.""" - if not self.base_url: - return False, [] + 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: + except Exception: 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." + 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()) @@ -55,142 +49,81 @@ class ApiClient: 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, - } + def create_transaction(self, title: str, amount: str | float, date_str: str, category_id: int | None = None) -> tuple[bool, str]: + 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" + 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, - } + def update_transaction(self, transaction_id: int, title: str, amount: str | float, date_str: str, category_id: int | None = None) -> tuple[bool, str]: + 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" + 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." + 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" + 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." + 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}, - ) + 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")) + self.storage.save_setting("auth_token", data.get("access_token")) if data.get("refresh_token"): - save_setting("refresh_token", data.get("refresh_token")) + self.storage.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") + self.storage.remove_setting("auth_token") + self.storage.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." + 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]: + 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 + 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(), - ) + 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: 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})") + return False, res.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." + 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()) @@ -200,45 +133,22 @@ class ApiClient: 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 "", - } + + def register(self, username: str, password: str, email: str | None = None, first_name: str | None = None, last_name: str | None = None) -> tuple[bool, str]: + 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, - ) + 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")) + self.storage.save_setting("auth_token", data.get("access_token")) if data.get("refresh_token"): - save_setting("refresh_token", data.get("refresh_token")) + self.storage.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})" + try: err_detail = res.json().get("detail", "Registration failed") + except: err_detail = f"Registration failed ({res.status_code})" return False, err_detail except Exception as err: return False, str(err) -api_client = ApiClient() - - diff --git a/src/services/storage.py b/src/services/storage.py index ae571f8..f226713 100644 --- a/src/services/storage.py +++ b/src/services/storage.py @@ -1,44 +1,39 @@ -import json -from pathlib import Path +import flet as ft -STORAGE_FILE = Path("settings.json") +class AppStorage: + def __init__(self, page: ft.Page): + self.page = page + self.prefs = ft.SharedPreferences() + self._cache = {} + async def init(self): + # Only look for our exact keys, ignoring hidden browser data + app_keys = ["server_url", "auth_token", "refresh_token", "currency_symbol"] + + for k in app_keys: + if await self.prefs.contains_key(k): + self._cache[k] = str(await self.prefs.get(k)) -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 save_setting(self, key: str, value: str) -> None: + self._cache[key] = value + self.page.run_task(self._async_save, key, value) + async def _async_save(self, key, value): + await self.prefs.set(key, str(value)) -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 get_setting(self, key: str, default: str | None = None) -> str | None: + return self._cache.get(key, default) + def remove_setting(self, key: str) -> None: + if key in self._cache: + del self._cache[key] + self.page.run_task(self._async_remove, key) -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 + async def _async_remove(self, key): + await self.prefs.remove(key) + def get_currency_symbol(self) -> str: + return self.get_setting("currency_symbol") or "kr" -def get_currency_symbol() -> str: - return get_setting("currency_symbol") or "kr" - - -def set_currency_symbol(symbol: str) -> None: - save_setting("currency_symbol", symbol) + def set_currency_symbol(self, symbol: str) -> None: + self.save_setting("currency_symbol", symbol) diff --git a/src/views/account.py b/src/views/account.py index 54891a0..10d0495 100644 --- a/src/views/account.py +++ b/src/views/account.py @@ -1,8 +1,6 @@ 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: +def AccountView(page: ft.Page, navigate, app_storage, api_client) -> ft.View: user_info = {} # Form Fields: Dark containers with crisp white text @@ -22,7 +20,7 @@ def AccountView(page: ft.Page, navigate) -> ft.View: currency_in = ft.TextField( label="Currency Symbol (e.g. kr, $, €)", - value=get_currency_symbol(), + value=app_storage.get_currency_symbol(), **input_style, ) @@ -92,7 +90,7 @@ def AccountView(page: ft.Page, navigate) -> ft.View: c_val = currency_in.value.strip() or "kr" # Save currency locally - set_currency_symbol(c_val) + app_storage.set_currency_symbol(c_val) ok, res = api_client.update_me( username=u_val or None, diff --git a/src/views/dashboard.py b/src/views/dashboard.py index b609bcf..d778bb2 100644 --- a/src/views/dashboard.py +++ b/src/views/dashboard.py @@ -1,9 +1,7 @@ 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: +def DashboardView(page: ft.Page, navigate, app_storage, api_client) -> ft.View: transactions = [] categories = [] user_info = {} @@ -43,7 +41,7 @@ def DashboardView(page: ft.Page, navigate) -> ft.View: ) amount_in = ft.TextField( - label=f"Amount ({get_currency_symbol()})", + label=f"Amount ({app_storage.get_currency_symbol()})", keyboard_type=ft.KeyboardType.NUMBER, expand=True ) @@ -126,7 +124,7 @@ def DashboardView(page: ft.Page, navigate) -> ft.View: nonlocal editing_tx_id editing_tx_id = None modal_title.value = "Add Transaction" - amount_in.label = f"Amount ({get_currency_symbol()})" + amount_in.label = f"Amount ({app_storage.get_currency_symbol()})" title_in.value = "" amount_in.value = "" @@ -148,7 +146,7 @@ def DashboardView(page: ft.Page, navigate) -> ft.View: nonlocal editing_tx_id editing_tx_id = tx["id"] modal_title.value = "Edit Transaction" - amount_in.label = f"Amount ({get_currency_symbol()})" + amount_in.label = f"Amount ({app_storage.get_currency_symbol()})" title_in.value = tx.get("title", "") amt_val = float(tx.get("amount", 0)) @@ -252,7 +250,7 @@ def DashboardView(page: ft.Page, navigate) -> ft.View: render_dashboard() def render_dashboard(): - curr_symbol = get_currency_symbol() + curr_symbol = app_storage.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}" diff --git a/src/views/login.py b/src/views/login.py index 9d14e8b..8af43af 100644 --- a/src/views/login.py +++ b/src/views/login.py @@ -1,10 +1,7 @@ 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" +def LoginView(page: ft.Page, navigate, app_storage, api_client) -> ft.View: + server_url = app_storage.get_setting("server_url") or "Not Configured" # Input controls username_input = ft.TextField( @@ -41,7 +38,7 @@ def LoginView(page: ft.Page, navigate) -> ft.View: status_text.value = "" page.update() - # Call Django authentication endpoint + # Call Django authentication endpoint using the passed client success, message = api_client.login(username, password) loading_ring.visible = False @@ -62,7 +59,7 @@ def LoginView(page: ft.Page, navigate) -> ft.View: ) def change_server(e): - remove_setting("server_url") + app_storage.remove_setting("server_url") navigate("/server") return ft.View( diff --git a/src/views/register.py b/src/views/register.py index 9c9bf72..b1b6e28 100644 --- a/src/views/register.py +++ b/src/views/register.py @@ -1,10 +1,7 @@ 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" +def RegisterView(page: ft.Page, navigate, app_storage, api_client) -> ft.View: + server_url = app_storage.get_setting("server_url") or "Not Configured" # Input controls username_input = ft.TextField( @@ -104,7 +101,7 @@ def RegisterView(page: ft.Page, navigate) -> ft.View: ) def change_server(e): - remove_setting("server_url") + app_storage.remove_setting("server_url") navigate("/server") return ft.View( diff --git a/src/views/server_picker.py b/src/views/server_picker.py index c916f82..24fd1e3 100644 --- a/src/views/server_picker.py +++ b/src/views/server_picker.py @@ -1,8 +1,7 @@ import flet as ft -from services.storage import save_setting - -def ServerPickerView(page: ft.Page, navigate) -> ft.View: +# Including api_client parameter here to match the routing mapping in main.py seamlessly +def ServerPickerView(page: ft.Page, navigate, app_storage, api_client=None) -> ft.View: # 1. Set width to 140px so "https://" fits comfortably alongside the dropdown arrow protocol_dropdown = ft.Dropdown( value="https://", @@ -45,7 +44,7 @@ def ServerPickerView(page: ft.Page, navigate) -> ft.View: protocol = protocol_dropdown.value final_url = f"{protocol}{raw_host}/api" - save_setting("server_url", final_url) + app_storage.save_setting("server_url", final_url) navigate("/login") return ft.View( diff --git a/src/views/statistics.py b/src/views/statistics.py index 898096b..fb4ba9f 100644 --- a/src/views/statistics.py +++ b/src/views/statistics.py @@ -1,9 +1,7 @@ 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: +def StatisticsView(page: ft.Page, navigate, app_storage, api_client) -> ft.View: transactions = [] is_loading = True @@ -55,7 +53,7 @@ def StatisticsView(page: ft.Page, navigate) -> ft.View: render_statistics() def render_statistics(): - curr_symbol = get_currency_symbol() + curr_symbol = app_storage.get_currency_symbol() category_totals = {} total_expenses = 0.0 diff --git a/uv.lock b/uv.lock index 1db41b5..a9e280f 100644 --- a/uv.lock +++ b/uv.lock @@ -546,6 +546,7 @@ source = { virtual = "." } dependencies = [ { name = "flet" }, { name = "flet-charts" }, + { name = "httpx" }, ] [package.dev-dependencies] @@ -560,6 +561,7 @@ dev = [ requires-dist = [ { name = "flet", specifier = ">=1.0.0" }, { name = "flet-charts", specifier = ">=1.0.0" }, + { name = "httpx" }, ] [package.metadata.requires-dev]