Files
DarkRoom/ui/dr-ui/ui/launch.slint
T
dtourolle 4d78041d1d
Build and test / Desktop (Linux) (push) Failing after 1m8s
Build and test / Android (aarch64) (push) Failing after 2s
Build and test / Layer separation (push) Canceled after 23s
Traceability / Requirement traces (push) Failing after 59s
Many imorovments
2026-08-12 22:16:15 +02:00

430 lines
16 KiB
Plaintext

import { Theme } from "theme.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, Field, Disclosure } from "widgets.slint";
// Launch screen: connect an account, or resume a saved one.
//
// Deliberately separate from AppWindow. It is the first thing a user sees
// with no library configured, and the place they return to in order to sign
// out or switch account (FR-NC-1, FR-NC-4).
// One tick-box in the format selection.
component FormatCheck inherits Rectangle {
in property <string> label;
in-out property <bool> checked;
callback toggled(bool);
// FR-UI-3: 44pt minimum hit target under touch. The drawn row is
// shorter, so the touch area extends beyond the visible bounds.
height: 32px;
touch := TouchArea {
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
clicked => {
root.checked = !root.checked;
root.toggled(root.checked);
}
}
HorizontalLayout {
spacing: Theme.gap;
alignment: start;
Rectangle {
width: 18px;
height: 18px;
y: (parent.height - self.height) / 2;
border-radius: 3px;
border-width: 1px;
// A ticked box is an engaged control, so it fills with `active`
// — the same token the slider fill and the focus border take.
border-color: root.checked ? Theme.active : Theme.rule;
background: root.checked ? Theme.active : transparent;
Text {
text: "✓";
// Dark on the fill: `active` is near-white, and the white
// tick this carried against a saturated accent is invisible
// against it.
color: Theme.ground;
font-size: 12px;
visible: root.checked;
horizontal-alignment: center;
vertical-alignment: center;
width: 100%;
height: 100%;
}
}
Label {
text: root.label;
emphasised: touch.has-hover;
body: true;
}
}
}
// This screen's buttons are form actions in a single stacked column, not
// chrome beside a photograph — they are given the full `touch-target` height
// rather than the denser `control-height` the toolbars use. `Button` draws
// its own hit area at least this tall either way; here the ink matches it.
component FormButton inherits Button {
height: Theme.touch-target;
}
// One folder in the picker. The whole row is the target, not just the text.
component FolderRow inherits Rectangle {
in property <string> label;
in property <bool> is-parent: false;
callback clicked();
height: Theme.touch-target;
background: touch.has-hover ? Theme.surface-raised : transparent;
border-radius: 3px;
touch := TouchArea { clicked => { root.clicked(); } }
HorizontalLayout {
padding-left: Theme.gap;
padding-right: Theme.gap;
spacing: Theme.gap;
Disclosure { text: root.is-parent ? "↑" : "▸"; }
Value { text: root.label; overflow: elide; }
}
}
export component LaunchScreen inherits Rectangle {
// --- state in ---
in property <bool> signed-in: false;
in property <string> account: "";
in property <string> library-root: "";
in property <string> server-url: "";
in property <bool> busy: false;
in property <string> status: "";
in property <string> error: "";
/// The URL to approve in a browser, shown while a login is pending.
in property <string> login-url: "";
/// False where no secrets daemon exists — the user must be told sign-in
/// will not persist rather than discovering it next launch.
in property <bool> can-remember: true;
in-out property <[string]> format-labels;
in-out property <[bool]> format-checked;
// --- events out ---
callback sign-in(string);
/// server, username, app password
callback sign-in-direct(string, string, string);
callback sign-out();
callback choose-folder();
callback open-library();
callback format-toggled(int, bool);
callback copy-login-url();
// --- folder picker ---
in property <bool> browsing: false;
in property <string> browse-path: "";
in property <bool> browse-loading: false;
in-out property <[string]> browse-entries;
callback browse-into(string);
callback browse-up();
callback browse-confirm();
callback browse-cancel();
background: Theme.ground;
VerticalLayout {
alignment: center;
padding: Theme.gap-lg;
Rectangle {
max-width: 460px;
horizontal-stretch: 0;
VerticalLayout {
spacing: Theme.gap-lg;
// --- masthead ---
VerticalLayout {
spacing: Theme.gap-sm;
// The one piece of type in the app that is a title rather
// than a label, so it is written here rather than given a
// component with a single call site.
Text {
text: "DarkRoom";
color: Theme.ink;
font-size: Theme.text-xl;
font-weight: 800;
letter-spacing: -0.5px;
}
Label {
text: root.signed-in
? "Connected"
: "Connect a Nextcloud account to begin";
body: true;
}
}
Rectangle { height: 1px; background: Theme.rule; }
// --- signed out: server entry ---
if !root.signed-in && root.login-url == "": VerticalLayout {
spacing: Theme.gap;
PanelHeading { text: "SERVER"; }
server-input := Field {
text: root.server-url;
placeholder: "https://cloud.example.com";
accepted(url) => { root.sign-in(url); }
}
if !root.can-remember: Caption {
text: "No system keyring found — you will need to sign in each time.";
warn: true;
wrap: word-wrap;
}
FormButton {
text: root.busy ? "Connecting…" : "Sign in";
primary: true;
enabled: !root.busy && server-input.text != "";
clicked => { root.sign-in(server-input.text); }
}
Caption {
text: "Sign-in happens in your browser. DarkRoom never sees your password.";
wrap: word-wrap;
}
// --- or: an app password entered directly ---
//
// The browser flow above is preferred because no credential
// ever reaches us. This exists for the cases it cannot
// serve: no browser able to complete the handshake, or a
// user who would rather paste a token they can revoke.
HorizontalLayout {
spacing: Theme.gap;
alignment: center;
Rectangle {
height: 1px;
background: Theme.rule;
horizontal-stretch: 1;
}
Caption { text: "or"; }
Rectangle {
height: 1px;
background: Theme.rule;
horizontal-stretch: 1;
}
}
PanelHeading { text: "USERNAME"; }
user-input := Field {
text: "";
placeholder: "your Nextcloud username";
}
PanelHeading { text: "APP PASSWORD"; }
pass-input := Field {
text: "";
placeholder: "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx";
secret: true;
accepted(pw) => {
root.sign-in-direct(server-input.text, user-input.text, pw);
}
}
FormButton {
text: root.busy ? "Connecting…" : "Connect directly";
enabled: !root.busy
&& server-input.text != ""
&& user-input.text != ""
&& pass-input.text != "";
clicked => {
root.sign-in-direct(
server-input.text,
user-input.text,
pass-input.text,
);
}
}
Caption {
text: "Create one in Nextcloud under Settings → Security → Devices & sessions. It is device-scoped and can be revoked on its own.";
wrap: word-wrap;
}
}
// --- login pending: the browser step ---
if root.login-url != "": VerticalLayout {
spacing: Theme.gap;
PanelHeading { text: "APPROVE IN YOUR BROWSER"; }
Panel {
Label {
text: root.login-url;
// char-wrap, not word-wrap: a URL has no spaces to
// break at, and word-wrap would run it off the box.
wrap: char-wrap;
}
}
FormButton {
text: "Copy link";
clicked => { root.copy-login-url(); }
}
Caption { text: "Waiting for approval…"; }
}
// --- folder picker ---
if root.signed-in && root.browsing: VerticalLayout {
spacing: Theme.gap;
PanelHeading { text: "CHOOSE LIBRARY FOLDER"; }
// Current location, so it is always clear what
// "Use this folder" would select.
Value {
text: root.browse-path == "" ? "/" : "/" + root.browse-path;
overflow: elide;
}
// Not a `Panel`: this box overlays three mutually
// exclusive states — loading, the list, "nothing here" —
// each filling it, and Panel stacks its children in a
// column. Same surface and rule, drawn directly.
Rectangle {
height: 220px;
background: Theme.surface;
border-radius: Theme.radius;
border-width: 1px;
border-color: Theme.rule;
if root.browse-loading: Caption {
text: "Loading…";
horizontal-alignment: center;
width: 100%;
height: 100%;
}
if !root.browse-loading: Flickable {
width: 100%;
height: 100%;
viewport-height: content.preferred-height;
content := VerticalLayout {
padding: 4px;
spacing: 2px;
alignment: start;
if root.browse-path != "": FolderRow {
label: "..";
is-parent: true;
clicked => { root.browse-up(); }
}
for name in root.browse-entries: FolderRow {
label: name;
clicked => { root.browse-into(name); }
}
}
}
if !root.browse-loading && root.browse-entries.length == 0
&& root.browse-path != "": Caption {
text: "No subfolders here";
horizontal-alignment: center;
width: 100%;
height: 100%;
}
}
HorizontalLayout {
spacing: Theme.gap;
FormButton {
text: "Cancel";
horizontal-stretch: 1;
clicked => { root.browse-cancel(); }
}
FormButton {
text: "Use this folder";
primary: true;
horizontal-stretch: 1;
clicked => { root.browse-confirm(); }
}
}
}
// --- signed in ---
if root.signed-in && !root.browsing: VerticalLayout {
spacing: Theme.gap;
PanelHeading { text: "ACCOUNT"; }
Value { text: root.account; }
Rectangle { height: Theme.gap-sm; }
PanelHeading { text: "LIBRARY FOLDER"; }
HorizontalLayout {
spacing: Theme.gap;
Value {
text: root.library-root == "" ? "(not chosen)" : root.library-root;
placeholder: root.library-root == "";
horizontal-stretch: 1;
overflow: elide;
}
FormButton {
text: "Choose…";
width: 110px;
clicked => { root.choose-folder(); }
}
}
Rectangle { height: Theme.gap-sm; }
PanelHeading { text: "SCAN FOR"; }
for label[i] in root.format-labels: FormatCheck {
label: label;
checked: root.format-checked[i];
toggled(on) => { root.format-toggled(i, on); }
}
Rectangle { height: Theme.gap; }
FormButton {
text: root.busy ? "Scanning…" : "Open library";
primary: true;
enabled: !root.busy && root.library-root != "";
clicked => { root.open-library(); }
}
FormButton {
text: "Sign out";
clicked => { root.sign-out(); }
}
}
// --- feedback ---
if root.status != "": Label {
text: root.status;
wrap: word-wrap;
}
// A failed sign-in or scan is a caution, not an active state:
// something the user has to act on rather than something the
// app is doing. It was the same accent as the headings above
// it, which said nothing had gone wrong.
if root.error != "": Caption {
text: root.error;
warn: true;
wrap: word-wrap;
}
}
}
}
}