fixed login finally

This commit is contained in:
Lyxminxx committed 2026-09-23 22:13:32 +02:00
1 parent f0094826e7
commit e07036acf4
10 files changed
+116 -218

No files matched your search

+18 -14
View File
@@ -1,5 +1,6 @@
import flet as ft 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 themes import get_theme
from views.dashboard import DashboardView from views.dashboard import DashboardView
from views.login import LoginView from views.login import LoginView
@@ -8,15 +9,20 @@ from views.statistics import StatisticsView
from views.account import AccountView from views.account import AccountView
from views.register import RegisterView from views.register import RegisterView
# 1. Change to async def
def main(page: ft.Page): async def main(page: ft.Page):
page.theme_mode = ft.ThemeMode.DARK page.theme_mode = ft.ThemeMode.DARK
forest = get_theme() forest = get_theme()
page.theme = forest page.theme = forest
page.bgcolor = forest.color_scheme.surface page.bgcolor = forest.color_scheme.surface
page.title = "Jast Tracker" 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): def navigate(route: str):
page.route = route page.route = route
route_change() route_change()
@@ -25,30 +31,28 @@ def main(page: ft.Page):
page.views.clear() page.views.clear()
if page.route == "/server": if page.route == "/server":
page.views.append(ServerPickerView(page, navigate)) page.views.append(ServerPickerView(page, navigate, app_storage, api_client))
elif page.route == "/login": elif page.route == "/login":
page.views.append(LoginView(page, navigate)) page.views.append(LoginView(page, navigate, app_storage, api_client))
elif page.route == "/register": elif page.route == "/register":
page.views.append(RegisterView(page, navigate)) page.views.append(RegisterView(page, navigate, app_storage, api_client))
elif page.route == "/dashboard": elif page.route == "/dashboard":
page.views.append(DashboardView(page, navigate)) page.views.append(DashboardView(page, navigate, app_storage, api_client))
elif page.route == "/statistics": elif page.route == "/statistics":
page.views.append(StatisticsView(page, navigate)) page.views.append(StatisticsView(page, navigate, app_storage, api_client))
elif page.route == "/account": elif page.route == "/account":
page.views.append(AccountView(page, navigate)) page.views.append(AccountView(page, navigate, app_storage, api_client))
page.update() page.update()
page.on_route_change = route_change page.on_route_change = route_change
# Startup route guard if not app_storage.get_setting("server_url"):
if not get_setting("server_url"):
navigate("/server") navigate("/server")
elif not get_setting("auth_token"): elif not app_storage.get_setting("auth_token"):
navigate("/login") navigate("/login")
else: else:
navigate("/dashboard") navigate("/dashboard")
if __name__ == "__main__": if __name__ == "__main__":
ft.run(main) ft.run(main)
+47 -137
View File
@@ -1,25 +1,23 @@
import httpx import httpx
from services.storage import get_setting, remove_setting, save_setting
class ApiClient: class ApiClient:
def __init__(self, timeout: float = 10.0): def __init__(self, storage, timeout: float = 10.0):
self.storage = storage
self.timeout = timeout self.timeout = timeout
@property @property
def base_url(self) -> str | None: 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]: def get_headers(self) -> dict[str, str]:
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
token = get_setting("auth_token") token = self.storage.get_setting("auth_token")
if token: if token:
headers["Authorization"] = f"Bearer {token}" headers["Authorization"] = f"Bearer {token}"
return headers return headers
def get_me(self) -> tuple[bool, dict | str]: 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: try:
with httpx.Client(timeout=self.timeout) as client: with httpx.Client(timeout=self.timeout) as client:
res = client.get(f"{self.base_url}/auth/me", headers=self.get_headers()) res = client.get(f"{self.base_url}/auth/me", headers=self.get_headers())
@@ -30,22 +28,18 @@ class ApiClient:
return False, str(err) return False, str(err)
def get_categories(self) -> tuple[bool, list]: 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: try:
with httpx.Client(timeout=self.timeout) as client: with httpx.Client(timeout=self.timeout) as client:
res = client.get(f"{self.base_url}/categories", headers=self.get_headers()) res = client.get(f"{self.base_url}/categories", headers=self.get_headers())
if res.status_code == 200: if res.status_code == 200:
return True, res.json() return True, res.json()
return False, [] return False, []
except Exception as err: except Exception:
return False, [] return False, []
def get_transactions(self) -> tuple[bool, list | str]: 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: try:
with httpx.Client(timeout=self.timeout) as client: with httpx.Client(timeout=self.timeout) as client:
res = client.get(f"{self.base_url}/transactions", headers=self.get_headers()) res = client.get(f"{self.base_url}/transactions", headers=self.get_headers())
@@ -55,142 +49,81 @@ class ApiClient:
except Exception as err: except Exception as err:
return False, str(err) return False, str(err)
def create_transaction( def create_transaction(self, title: str, amount: str | float, date_str: str, category_id: int | None = None) -> tuple[bool, str]:
self, title: str, amount: str | float, date_str: str, category_id: int | None = None if not self.base_url: return False, "Server URL is not configured."
) -> tuple[bool, str]: payload = {"title": title, "amount": str(amount), "date": date_str, "category_id": category_id}
"""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: try:
with httpx.Client(timeout=self.timeout) as client: with httpx.Client(timeout=self.timeout) as client:
res = client.post( res = client.post(f"{self.base_url}/transactions", json=payload, headers=self.get_headers())
f"{self.base_url}/transactions", if res.status_code == 200: return True, "Transaction created"
json=payload,
headers=self.get_headers(),
)
if res.status_code == 200:
return True, "Transaction created"
return False, f"Error creating transaction ({res.status_code})" return False, f"Error creating transaction ({res.status_code})"
except Exception as err: except Exception as err:
return False, str(err) return False, str(err)
def update_transaction( def update_transaction(self, transaction_id: int, title: str, amount: str | float, date_str: str, category_id: int | None = None) -> tuple[bool, str]:
self, transaction_id: int, title: str, amount: str | float, date_str: str, category_id: int | None = None if not self.base_url: return False, "Server URL is not configured."
) -> tuple[bool, str]: payload = {"title": title, "amount": str(amount), "date": date_str, "category_id": category_id}
"""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: try:
with httpx.Client(timeout=self.timeout) as client: with httpx.Client(timeout=self.timeout) as client:
res = client.put( res = client.put(f"{self.base_url}/transactions/{transaction_id}", json=payload, headers=self.get_headers())
f"{self.base_url}/transactions/{transaction_id}", if res.status_code == 200: return True, "Transaction updated"
json=payload,
headers=self.get_headers(),
)
if res.status_code == 200:
return True, "Transaction updated"
return False, f"Error updating transaction ({res.status_code})" return False, f"Error updating transaction ({res.status_code})"
except Exception as err: except Exception as err:
return False, str(err) return False, str(err)
def delete_transaction(self, transaction_id: int) -> tuple[bool, str]: 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: try:
with httpx.Client(timeout=self.timeout) as client: with httpx.Client(timeout=self.timeout) as client:
res = client.delete( res = client.delete(f"{self.base_url}/transactions/{transaction_id}", headers=self.get_headers())
f"{self.base_url}/transactions/{transaction_id}", if res.status_code == 200: return True, "Transaction deleted"
headers=self.get_headers(),
)
if res.status_code == 200:
return True, "Transaction deleted"
return False, f"Error deleting transaction ({res.status_code})" return False, f"Error deleting transaction ({res.status_code})"
except Exception as err: except Exception as err:
return False, str(err) return False, str(err)
def login(self, username: str, password: str) -> tuple[bool, str]: def login(self, username: str, password: str) -> tuple[bool, str]:
if not self.base_url: if not self.base_url: return False, "Server URL is not configured."
return False, "Server URL is not configured."
try: try:
with httpx.Client(timeout=self.timeout) as client: with httpx.Client(timeout=self.timeout) as client:
response = client.post( response = client.post(f"{self.base_url}/auth/login", json={"username": username, "password": password})
f"{self.base_url}/auth/login",
json={"username": username, "password": password},
)
if response.status_code == 200: if response.status_code == 200:
data = response.json() 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"): 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 True, "Login successful"
return False, "Invalid username or password" return False, "Invalid username or password"
except Exception as err: except Exception as err:
return False, str(err) return False, str(err)
def logout(self) -> None: def logout(self) -> None:
remove_setting("auth_token") self.storage.remove_setting("auth_token")
remove_setting("refresh_token") self.storage.remove_setting("refresh_token")
def update_me( 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]:
self, if not self.base_url: return False, "Server URL is not configured."
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 = {} payload = {}
if username: if username: payload["username"] = username
payload["username"] = username if email is not None: payload["email"] = email
if email is not None: if first_name is not None: payload["first_name"] = first_name
payload["email"] = email if last_name is not None: payload["last_name"] = last_name
if first_name is not None: if password: payload["password"] = password
payload["first_name"] = first_name
if last_name is not None:
payload["last_name"] = last_name
if password:
payload["password"] = password
try: try:
with httpx.Client(timeout=self.timeout) as client: with httpx.Client(timeout=self.timeout) as client:
res = client.put( res = client.put(f"{self.base_url}/auth/me", json=payload, headers=self.get_headers())
f"{self.base_url}/auth/me",
json=payload,
headers=self.get_headers(),
)
if res.status_code == 200: if res.status_code == 200:
try: return True, res.json()
except Exception: return True, {}
try: try:
return True, res.json() return False, res.json().get("detail", f"Update failed ({res.status_code})")
except Exception:
return True, {}
try:
err_json = res.json()
return False, err_json.get("detail", f"Update failed ({res.status_code})")
except Exception: except Exception:
return False, f"Update failed ({res.status_code}): {res.text}" return False, f"Update failed ({res.status_code}): {res.text}"
except Exception as err: except Exception as err:
return False, str(err) return False, str(err)
def delete_me(self) -> tuple[bool, str]: 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: try:
with httpx.Client(timeout=self.timeout) as client: with httpx.Client(timeout=self.timeout) as client:
res = client.delete(f"{self.base_url}/auth/me", headers=self.get_headers()) res = client.delete(f"{self.base_url}/auth/me", headers=self.get_headers())
@@ -201,44 +134,21 @@ class ApiClient:
except Exception as err: except Exception as err:
return False, str(err) return False, str(err)
def register( def register(self, username: str, password: str, email: str | None = None, first_name: str | None = None, last_name: str | None = None) -> tuple[bool, str]:
self, if not self.base_url: return False, "Server URL is not configured."
username: str, payload = {"username": username, "password": password, "email": email or "", "first_name": first_name or "", "last_name": last_name or ""}
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: try:
with httpx.Client(timeout=self.timeout) as client: with httpx.Client(timeout=self.timeout) as client:
res = client.post( res = client.post(f"{self.base_url}/auth/register", json=payload)
f"{self.base_url}/auth/register",
json=payload,
)
if res.status_code == 200: if res.status_code == 200:
data = res.json() 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"): 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" return True, "Registration successful"
try: try: err_detail = res.json().get("detail", "Registration failed")
err_detail = res.json().get("detail", "Registration failed") except: err_detail = f"Registration failed ({res.status_code})"
except Exception:
err_detail = f"Registration failed ({res.status_code})"
return False, err_detail return False, err_detail
except Exception as err: except Exception as err:
return False, str(err) return False, str(err)
api_client = ApiClient()
+29 -34
View File
@@ -1,44 +1,39 @@
import json import flet as ft
from pathlib import Path
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"]
def save_setting(key: str, value: str) -> None: for k in app_keys:
data = {} if await self.prefs.contains_key(k):
if STORAGE_FILE.exists(): self._cache[k] = str(await self.prefs.get(k))
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)
def get_setting(key: str, default: str | None = None) -> str | None: async def _async_save(self, key, value):
if not STORAGE_FILE.exists(): await self.prefs.set(key, str(value))
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(key: str) -> None: def remove_setting(self, key: str) -> None:
if STORAGE_FILE.exists(): if key in self._cache:
try: del self._cache[key]
data = json.loads(STORAGE_FILE.read_text()) self.page.run_task(self._async_remove, key)
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() -> str: def get_currency_symbol(self) -> str:
return get_setting("currency_symbol") or "kr" return self.get_setting("currency_symbol") or "kr"
def set_currency_symbol(self, symbol: str) -> None:
def set_currency_symbol(symbol: str) -> None: self.save_setting("currency_symbol", symbol)
save_setting("currency_symbol", symbol)
+3 -5
View File
@@ -1,8 +1,6 @@
import flet as ft 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 = {} user_info = {}
# Form Fields: Dark containers with crisp white text # Form Fields: Dark containers with crisp white text
@@ -22,7 +20,7 @@ def AccountView(page: ft.Page, navigate) -> ft.View:
currency_in = ft.TextField( currency_in = ft.TextField(
label="Currency Symbol (e.g. kr, $, €)", label="Currency Symbol (e.g. kr, $, €)",
value=get_currency_symbol(), value=app_storage.get_currency_symbol(),
**input_style, **input_style,
) )
@@ -92,7 +90,7 @@ def AccountView(page: ft.Page, navigate) -> ft.View:
c_val = currency_in.value.strip() or "kr" c_val = currency_in.value.strip() or "kr"
# Save currency locally # Save currency locally
set_currency_symbol(c_val) app_storage.set_currency_symbol(c_val)
ok, res = api_client.update_me( ok, res = api_client.update_me(
username=u_val or None, username=u_val or None,
+5 -7
View File
@@ -1,9 +1,7 @@
from datetime import datetime from datetime import datetime
import flet as ft 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 = [] transactions = []
categories = [] categories = []
user_info = {} user_info = {}
@@ -43,7 +41,7 @@ def DashboardView(page: ft.Page, navigate) -> ft.View:
) )
amount_in = ft.TextField( amount_in = ft.TextField(
label=f"Amount ({get_currency_symbol()})", label=f"Amount ({app_storage.get_currency_symbol()})",
keyboard_type=ft.KeyboardType.NUMBER, keyboard_type=ft.KeyboardType.NUMBER,
expand=True expand=True
) )
@@ -126,7 +124,7 @@ def DashboardView(page: ft.Page, navigate) -> ft.View:
nonlocal editing_tx_id nonlocal editing_tx_id
editing_tx_id = None editing_tx_id = None
modal_title.value = "Add Transaction" 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 = "" title_in.value = ""
amount_in.value = "" amount_in.value = ""
@@ -148,7 +146,7 @@ def DashboardView(page: ft.Page, navigate) -> ft.View:
nonlocal editing_tx_id nonlocal editing_tx_id
editing_tx_id = tx["id"] editing_tx_id = tx["id"]
modal_title.value = "Edit Transaction" 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", "") title_in.value = tx.get("title", "")
amt_val = float(tx.get("amount", 0)) amt_val = float(tx.get("amount", 0))
@@ -252,7 +250,7 @@ def DashboardView(page: ft.Page, navigate) -> ft.View:
render_dashboard() render_dashboard()
def 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) total_spent = sum(float(t.get("amount", 0)) for t in transactions)
current_balance_text.value = f"{total_spent:,.2f} {curr_symbol}" current_balance_text.value = f"{total_spent:,.2f} {curr_symbol}"
+4 -7
View File
@@ -1,10 +1,7 @@
import flet as ft import flet as ft
from services.api import api_client
from services.storage import get_setting, remove_setting
def LoginView(page: ft.Page, navigate, app_storage, api_client) -> ft.View:
def LoginView(page: ft.Page, navigate) -> ft.View: server_url = app_storage.get_setting("server_url") or "Not Configured"
server_url = get_setting("server_url") or "Not Configured"
# Input controls # Input controls
username_input = ft.TextField( username_input = ft.TextField(
@@ -41,7 +38,7 @@ def LoginView(page: ft.Page, navigate) -> ft.View:
status_text.value = "" status_text.value = ""
page.update() page.update()
# Call Django authentication endpoint # Call Django authentication endpoint using the passed client
success, message = api_client.login(username, password) success, message = api_client.login(username, password)
loading_ring.visible = False loading_ring.visible = False
@@ -62,7 +59,7 @@ def LoginView(page: ft.Page, navigate) -> ft.View:
) )
def change_server(e): def change_server(e):
remove_setting("server_url") app_storage.remove_setting("server_url")
navigate("/server") navigate("/server")
return ft.View( return ft.View(
+3 -6
View File
@@ -1,10 +1,7 @@
import flet as ft import flet as ft
from services.api import api_client
from services.storage import get_setting, remove_setting
def RegisterView(page: ft.Page, navigate, app_storage, api_client) -> ft.View:
def RegisterView(page: ft.Page, navigate) -> ft.View: server_url = app_storage.get_setting("server_url") or "Not Configured"
server_url = get_setting("server_url") or "Not Configured"
# Input controls # Input controls
username_input = ft.TextField( username_input = ft.TextField(
@@ -104,7 +101,7 @@ def RegisterView(page: ft.Page, navigate) -> ft.View:
) )
def change_server(e): def change_server(e):
remove_setting("server_url") app_storage.remove_setting("server_url")
navigate("/server") navigate("/server")
return ft.View( return ft.View(
+3 -4
View File
@@ -1,8 +1,7 @@
import flet as ft import flet as ft
from services.storage import save_setting
# Including api_client parameter here to match the routing mapping in main.py seamlessly
def ServerPickerView(page: ft.Page, navigate) -> ft.View: 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 # 1. Set width to 140px so "https://" fits comfortably alongside the dropdown arrow
protocol_dropdown = ft.Dropdown( protocol_dropdown = ft.Dropdown(
value="https://", value="https://",
@@ -45,7 +44,7 @@ def ServerPickerView(page: ft.Page, navigate) -> ft.View:
protocol = protocol_dropdown.value protocol = protocol_dropdown.value
final_url = f"{protocol}{raw_host}/api" final_url = f"{protocol}{raw_host}/api"
save_setting("server_url", final_url) app_storage.save_setting("server_url", final_url)
navigate("/login") navigate("/login")
return ft.View( return ft.View(
+2 -4
View File
@@ -1,9 +1,7 @@
import flet as ft import flet as ft
import flet_charts as fch 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 = [] transactions = []
is_loading = True is_loading = True
@@ -55,7 +53,7 @@ def StatisticsView(page: ft.Page, navigate) -> ft.View:
render_statistics() render_statistics()
def render_statistics(): def render_statistics():
curr_symbol = get_currency_symbol() curr_symbol = app_storage.get_currency_symbol()
category_totals = {} category_totals = {}
total_expenses = 0.0 total_expenses = 0.0
Generated
+2
View File
@@ -546,6 +546,7 @@ source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "flet" }, { name = "flet" },
{ name = "flet-charts" }, { name = "flet-charts" },
{ name = "httpx" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@@ -560,6 +561,7 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "flet", specifier = ">=1.0.0" }, { name = "flet", specifier = ">=1.0.0" },
{ name = "flet-charts", specifier = ">=1.0.0" }, { name = "flet-charts", specifier = ">=1.0.0" },
{ name = "httpx" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]