Files
SaraView/Client/App.py
T
2026-10-08 23:25:41 +02:00

168 lines
7.2 KiB
Python

"""What the window's parts share: the config, the current Source (local or
server), toasts, dialogs and the login flow. Changing the source (Settings,
logging in or out) tells every listener to reload."""
from typing import Callable, Optional
import flet as ft
from Core import AppConfig, ChipButton, LoadSessionToken, NotificationStyle, Palette, SaveSessionToken
from Sources import MakeSource, ServerSource, Source, SourceError
class App:
def __init__(self, Page: ft.Page, Config: AppConfig):
self.Page = Page
self.Config = Config
self.Picker = ft.FilePicker() # registers itself with the page
self.Source: Source = MakeSource(Config)
self.ConnectError = "" # why the server couldn't be reached, shown instead of the file list
self.DialogOpen = False # keyboard shortcuts pause while a dialog is up
self.Listeners: list[Callable[[], None]] = [] # run after the source changes
# --- Source -----------------------------------------------------------
def Connect(self) -> None:
"""(Re)builds the source from Config and, for a server, checks it's
there and whether the saved login still works. Listeners reload."""
if isinstance(self.Source, ServerSource):
self.Source.Close()
Token = LoadSessionToken(self.Config.ServerUrl) if self.Config.Mode == "Server" and self.Config.RememberLogin else ""
self.Source = MakeSource(self.Config, Token)
self.ConnectError = ""
if isinstance(self.Source, ServerSource):
try:
self.Source.Connect()
except SourceError as Error:
self.ConnectError = str(Error)
if Token and self.Source.User is None and not self.ConnectError:
SaveSessionToken(self.Config.ServerUrl, "")
self.Notify("Your saved login ended (the server restarted?). Browsing as Guest.", "warning")
self.Changed()
def Changed(self) -> None:
for Listener in self.Listeners:
Listener()
@property
def Server(self) -> Optional[ServerSource]:
return self.Source if isinstance(self.Source, ServerSource) else None
def HandleError(self, Error: SourceError, Doing: str = "") -> None:
"""A toast, plus the login dialog when the server wants a login."""
Text = f"{Doing}: {Error}" if Doing else str(Error)
if Error.Status == 401 and self.Server is not None:
if self.Server.User is not None: # the session ended under us
self.Server.Logout()
SaveSessionToken(self.Config.ServerUrl, "")
self.Changed()
self.ShowLoginDialog(Reason=Text)
return
self.Notify(Text, "error")
# --- Login ------------------------------------------------------------
def ShowLoginDialog(self, Reason: str = "") -> None:
Server = self.Server
if Server is None:
return
UsernameField = ft.TextField(label="Username", value=self.Config.Username, autofocus=not self.Config.Username)
PasswordField = ft.TextField(label="Password", password=True, can_reveal_password=True,
autofocus=bool(self.Config.Username))
RememberSwitch = ft.Switch(label="Stay logged in", value=self.Config.RememberLogin)
ErrorText = ft.Text("", color=Palette.Red, size=12, visible=False)
def DoLogin(e=None) -> None:
Username = (UsernameField.value or "").strip()
if not Username or not PasswordField.value:
return
try:
Server.Login(Username, PasswordField.value)
except SourceError as Error:
ErrorText.value = str(Error)
ErrorText.visible = True
ErrorText.update()
return
self.Config.Username = Username
self.Config.RememberLogin = RememberSwitch.value
self.Config.Save()
SaveSessionToken(self.Config.ServerUrl, Server.Token if RememberSwitch.value else "")
self.Close()
self.Notify(f"Logged in as {Username}.", "success")
self.Changed()
UsernameField.on_submit = lambda e: PasswordField.focus()
PasswordField.on_submit = DoLogin
Content = [ft.Text(Server.Url, color=Palette.Overlay0, size=12)]
if Reason:
Content.append(ft.Text(Reason, color=Palette.Yellow, size=13))
Content += [UsernameField, PasswordField, RememberSwitch, ErrorText]
self.ShowDialog("Log in", ft.Column(Content, tight=True, spacing=12), [
ChipButton("Cancel", lambda e: self.Close()),
ChipButton("Log in", DoLogin),
], Width=380)
def Logout(self) -> None:
if self.Server is None:
return
self.Server.Logout()
SaveSessionToken(self.Config.ServerUrl, "")
self.Notify("Logged out. Browsing as Guest.", "info")
self.Changed()
# --- UI helpers -------------------------------------------------------
def Notify(self, Text: str, Kind: str = "info") -> None:
Background, Accent = NotificationStyle(Kind)
self.Page.show_dialog(ft.SnackBar(
content=ft.Text(Text, color=Accent),
bgcolor=Background,
shape=ft.RoundedRectangleBorder(radius=4, side=ft.BorderSide(1, Accent)),
open=True,
))
def ShowDialog(self, Title: str, Content: ft.Control, Actions: list[ft.Control], Width: int = 440) -> None:
self.DialogOpen = True
self.Page.show_dialog(ft.AlertDialog(
title=Title,
content=ft.Container(Content, width=Width),
actions=Actions,
on_dismiss=lambda e: setattr(self, "DialogOpen", False),
))
def Close(self) -> None:
self.DialogOpen = False
self.Page.pop_dialog()
def Confirm(self, Title: str, Text: str, OnYes: Callable[[], None], YesText: str = "Delete") -> None:
def Yes(e) -> None:
self.Close()
OnYes()
self.ShowDialog(Title, ft.Text(Text), [
ChipButton("Cancel", lambda e: self.Close()),
ChipButton(YesText, Yes, Accent=Palette.Red),
])
def AskName(self, Title: str, Value: str, SelectEnd: int, OnDone: Callable[[str], None],
ActionText: str = "Create", Note: str = "", RequireChange: bool = True) -> None:
"""A one-field dialog with the name's stem preselected, like SaraFem's.
RequireChange: an unchanged name does nothing (Rename)."""
NameField = ft.TextField(value=Value, autofocus=True,
selection=ft.TextSelection(base_offset=0, extent_offset=SelectEnd))
def Done(e=None) -> None:
NewName = (NameField.value or "").strip()
self.Close()
if NewName and (NewName != Value or not RequireChange):
OnDone(NewName)
NameField.on_submit = Done
Content: ft.Control = NameField
if Note:
Content = ft.Column([NameField, ft.Text(Note, color=Palette.Overlay0, size=12)], tight=True, spacing=8)
self.ShowDialog(Title, Content, [
ChipButton("Cancel", lambda e: self.Close()),
ChipButton(ActionText, Done),
], Width=380)