"""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)