mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-14 18:38:11 +02:00
Add Flet desktop client
This commit is contained in:
1 parent
16787aba14
commit
0eecfba224
18 files changed
+2588
No files matched your search
+167
@@ -0,0 +1,167 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user