430 lines
16 KiB
Plaintext
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|