362 lines
14 KiB
Plaintext
362 lines
14 KiB
Plaintext
import { Theme } from "theme.slint";
|
||
import { Button, PanelHeading, Label, Value, Caption, Panel, ProgressBar } from "widgets.slint";
|
||
import { Segmented, TextRow, Check, ChoiceChip } from "controls.slint";
|
||
|
||
// TRACES: FR-CAT-10 | FR-CAT-11 | FR-NC-7a
|
||
// Import: bringing a card into the library.
|
||
//
|
||
// A full-window page, the same shape as Settings and for the first of the same
|
||
// reasons — it refers to the library constantly, and a modal that has to be
|
||
// dismissed to check where something went is a dialogue arguing with itself.
|
||
//
|
||
// The second reason does not apply and produces the one structural difference:
|
||
// settings save on change and have nothing to confirm, where an import is a
|
||
// decision. So this page has a commit button, and everything above it is the
|
||
// sentence that button completes: take *these* files, from *there*, and put
|
||
// them *here*.
|
||
//
|
||
// # Why the destination is shown and not editable
|
||
//
|
||
// A photograph goes into the library, and the library is chosen at launch.
|
||
// Offering a second place to set it here would let the two disagree, and the
|
||
// disagreement would be discovered as a folder of photographs the grid does
|
||
// not show. What *is* editable is the layout within it (FR-NC-7a), which is a
|
||
// different question with a visible answer right beside it.
|
||
|
||
// One volume the app found, as a row that can be picked.
|
||
//
|
||
// The subtitle carries why it is being offered — a camera card, a removable
|
||
// drive — because "EOS DIGITAL" and "archive" look equally plausible in a list
|
||
// and only one of them is what the user just plugged in.
|
||
component VolumeRow inherits Rectangle {
|
||
in property <string> label;
|
||
in property <string> detail;
|
||
in property <bool> selected: false;
|
||
callback clicked();
|
||
|
||
height: 44px;
|
||
border-radius: Theme.radius-sm;
|
||
background: root.selected ? Theme.selected
|
||
: (touch.pressed ? Theme.pressed
|
||
: (touch.has-hover ? Theme.hover : transparent));
|
||
border-width: root.selected ? 1px : 0px;
|
||
border-color: Theme.selected-ring;
|
||
|
||
touch := TouchArea {
|
||
clicked => { root.clicked(); }
|
||
}
|
||
|
||
HorizontalLayout {
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
spacing: Theme.gap;
|
||
|
||
VerticalLayout {
|
||
alignment: center;
|
||
spacing: 1px;
|
||
Label { text: root.label; body: true; }
|
||
Caption { text: root.detail; }
|
||
}
|
||
|
||
Rectangle { horizontal-stretch: 1; }
|
||
}
|
||
}
|
||
|
||
export component ImportPage inherits Rectangle {
|
||
// --- where the photographs are coming from ---------------------------
|
||
in property <[string]> volume-labels;
|
||
in property <[string]> volume-details;
|
||
/// -1 while nothing is chosen, which is what the page opens on.
|
||
in property <int> volume-selected: -1;
|
||
/// The chosen source, also typable — a card mounted somewhere this app
|
||
/// does not think to look is still a card.
|
||
in-out property <string> card-path;
|
||
/// Whether the source holds a DCIM folder. Advisory: plenty of legitimate
|
||
/// sources do not, so this informs and never blocks.
|
||
in property <bool> card-looks-right: false;
|
||
|
||
callback volume-picked(int);
|
||
callback card-path-changed(string);
|
||
callback refresh-volumes();
|
||
|
||
// --- what is on it ---------------------------------------------------
|
||
in property <bool> surveying: false;
|
||
/// "1,847 photographs · 61.2 GB". Empty before anything has been counted.
|
||
in property <string> survey-summary;
|
||
|
||
// --- where they are going --------------------------------------------
|
||
/// The library root. Shown, not editable — see the header comment.
|
||
in property <string> destination;
|
||
in-out property <string> folder-template;
|
||
/// The template expanded against a real date, so the answer to "what will
|
||
/// this do" is on screen rather than in the manual.
|
||
in property <string> template-preview;
|
||
|
||
in property <[string]> mode-labels;
|
||
in property <int> mode-selected: 0;
|
||
/// Whether the chosen mode will eventually erase the card.
|
||
in property <bool> mode-is-move: false;
|
||
|
||
in property <[string]> duplicate-labels;
|
||
in property <int> duplicate-selected: 0;
|
||
in property <bool> verify: true;
|
||
in property <bool> backup-on: false;
|
||
in-out property <string> backup-path;
|
||
|
||
callback template-changed(string);
|
||
callback mode-picked(int);
|
||
callback duplicate-picked(int);
|
||
callback verify-toggled(bool);
|
||
callback backup-toggled(bool);
|
||
callback backup-path-changed(string);
|
||
|
||
// --- doing it --------------------------------------------------------
|
||
in property <bool> running: false;
|
||
in property <float> fraction: 0;
|
||
/// What it is doing now, or what it did — one line, always present once
|
||
/// something has happened.
|
||
in property <string> status;
|
||
in property <string> error;
|
||
/// Whether Import can be pressed at all.
|
||
in property <bool> can-start: false;
|
||
|
||
callback begin();
|
||
callback stop();
|
||
callback close();
|
||
|
||
background: Theme.ground;
|
||
|
||
// Somewhere for a key to start from, so Android's back gesture closes the
|
||
// page rather than the application. Same reasoning as the settings page.
|
||
FocusScope {
|
||
width: 0px;
|
||
height: 0px;
|
||
init => { self.focus(); }
|
||
}
|
||
|
||
VerticalLayout {
|
||
// --- header ------------------------------------------------------
|
||
Rectangle {
|
||
height: 44px;
|
||
background: Theme.surface;
|
||
|
||
HorizontalLayout {
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
spacing: Theme.gap;
|
||
|
||
Button {
|
||
text: "‹ Back";
|
||
y: (parent.height - self.height) / 2;
|
||
// Leaving mid-import would abandon a worker with no way
|
||
// back to it: the page is the only thing that reports what
|
||
// it is doing.
|
||
enabled: !root.running;
|
||
clicked => { root.close(); }
|
||
}
|
||
|
||
Value { text: "Import"; }
|
||
|
||
Rectangle { horizontal-stretch: 1; }
|
||
|
||
if root.running: Button {
|
||
text: "Stop";
|
||
y: (parent.height - self.height) / 2;
|
||
clicked => { root.stop(); }
|
||
}
|
||
|
||
if !root.running: Button {
|
||
text: "Import";
|
||
y: (parent.height - self.height) / 2;
|
||
enabled: root.can-start;
|
||
clicked => { root.begin(); }
|
||
}
|
||
}
|
||
|
||
Rectangle {
|
||
y: parent.height - 1px;
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
}
|
||
|
||
// The bar sits directly under the header while a run is going, which
|
||
// is where every other progress bar in this application is.
|
||
if root.running: ProgressBar {
|
||
fraction: root.fraction;
|
||
indeterminate: root.surveying;
|
||
}
|
||
|
||
if root.error != "": Rectangle {
|
||
height: 32px;
|
||
background: Theme.surface;
|
||
HorizontalLayout {
|
||
padding-left: Theme.gap;
|
||
padding-right: Theme.gap;
|
||
Caption { text: root.error; warn: true; overflow: elide; }
|
||
}
|
||
}
|
||
|
||
// Named child plus an explicit viewport height, as the settings page
|
||
// does it: a Flickable cannot measure an anonymous layout, and scrolls
|
||
// nowhere without being told how tall its contents are.
|
||
Flickable {
|
||
vertical-stretch: 1;
|
||
viewport-height: content.preferred-height;
|
||
|
||
content := VerticalLayout {
|
||
width: 100%;
|
||
padding: Theme.gap;
|
||
spacing: Theme.gap;
|
||
alignment: start;
|
||
|
||
// --- source ------------------------------------------------
|
||
Panel {
|
||
PanelHeading { text: "From"; }
|
||
|
||
if root.volume-labels.length == 0: Caption {
|
||
text: "No removable volume found. Type where the card is mounted, or plug it in and refresh.";
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
for label[i] in root.volume-labels: VolumeRow {
|
||
label: label;
|
||
detail: root.volume-details[i];
|
||
selected: i == root.volume-selected;
|
||
clicked => { root.volume-picked(i); }
|
||
}
|
||
|
||
TextRow {
|
||
label: "Location";
|
||
text <=> root.card-path;
|
||
placeholder: "/run/media/…";
|
||
field-width: 320px;
|
||
enabled: !root.running;
|
||
accepted(t) => { root.card-path-changed(t); }
|
||
}
|
||
|
||
HorizontalLayout {
|
||
spacing: Theme.gap;
|
||
Button {
|
||
text: "Refresh";
|
||
enabled: !root.running;
|
||
clicked => { root.refresh-volumes(); }
|
||
}
|
||
// Says what was found the moment it is known, so the
|
||
// user is not asked to press Import on faith.
|
||
Caption {
|
||
text: root.surveying ? "Looking…"
|
||
: (root.survey-summary != "" ? root.survey-summary : "");
|
||
vertical-alignment: center;
|
||
}
|
||
Rectangle { horizontal-stretch: 1; }
|
||
}
|
||
|
||
if root.card-path != "" && !root.card-looks-right && !root.surveying: Caption {
|
||
// Advisory, not a block: an archive folder is a
|
||
// legitimate source and has no DCIM either.
|
||
text: "No DCIM folder here — check this is the card and not somewhere else.";
|
||
wrap: word-wrap;
|
||
}
|
||
}
|
||
|
||
// --- destination -------------------------------------------
|
||
Panel {
|
||
PanelHeading { text: "Into"; }
|
||
|
||
HorizontalLayout {
|
||
spacing: Theme.gap;
|
||
Label { text: "Library"; }
|
||
Value {
|
||
text: root.destination;
|
||
overflow: elide;
|
||
horizontal-stretch: 1;
|
||
}
|
||
}
|
||
|
||
TextRow {
|
||
label: "Folders";
|
||
text <=> root.folder-template;
|
||
placeholder: "{yyyy}/{yyyy}-{mm}-{dd}";
|
||
field-width: 320px;
|
||
hint: "Tokens: {yyyy} {yy} {mm} {dd} {date} {make} {model}";
|
||
enabled: !root.running;
|
||
accepted(t) => { root.template-changed(t); }
|
||
}
|
||
|
||
// The template's answer, against a real photograph's date.
|
||
// A template language nobody can predict the output of is
|
||
// a template language nobody will change.
|
||
HorizontalLayout {
|
||
spacing: Theme.gap;
|
||
Label { text: "Example"; }
|
||
Value { text: root.template-preview; }
|
||
Rectangle { horizontal-stretch: 1; }
|
||
}
|
||
}
|
||
|
||
// --- how ----------------------------------------------------
|
||
Panel {
|
||
PanelHeading { text: "How"; }
|
||
|
||
Segmented {
|
||
label: "Transfer";
|
||
options: root.mode-labels;
|
||
selected: root.mode-selected;
|
||
enabled: !root.running;
|
||
picked(i) => { root.mode-picked(i); }
|
||
}
|
||
|
||
if root.mode-is-move: Caption {
|
||
// The one operation in this application with no undo,
|
||
// and the only honest place to say so is beside the
|
||
// control that arms it (FR-NC-7b).
|
||
text: "The card is emptied only after every file is verified in the library — and, on a library that syncs, after it has reached the server.";
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
Segmented {
|
||
label: "Already in the library";
|
||
options: root.duplicate-labels;
|
||
selected: root.duplicate-selected;
|
||
enabled: !root.running;
|
||
picked(i) => { root.duplicate-picked(i); }
|
||
}
|
||
|
||
Check {
|
||
label: "Verify every file";
|
||
hint: "Reads each copy back and compares it. Catches a dying card, which is otherwise invisible until the photograph is opened.";
|
||
checked: root.verify;
|
||
// `Check` has no enabled property, so the guard is on
|
||
// the callback: a toggle mid-run would change what the
|
||
// running worker was told to do, which it cannot hear.
|
||
toggled(v) => { if (!root.running) { root.verify-toggled(v); } }
|
||
}
|
||
|
||
Check {
|
||
label: "Second copy";
|
||
hint: "Written from the same read as the first, so it is a check on the copy rather than a copy of it.";
|
||
checked: root.backup-on;
|
||
toggled(v) => { if (!root.running) { root.backup-toggled(v); } }
|
||
}
|
||
|
||
if root.backup-on: TextRow {
|
||
label: "Second copy in";
|
||
text <=> root.backup-path;
|
||
placeholder: "/mnt/backup/Photos";
|
||
field-width: 320px;
|
||
enabled: !root.running;
|
||
accepted(t) => { root.backup-path-changed(t); }
|
||
}
|
||
}
|
||
|
||
// --- what happened -----------------------------------------
|
||
if root.status != "": Panel {
|
||
PanelHeading { text: root.running ? "Working" : "Result"; }
|
||
Caption { text: root.status; wrap: word-wrap; }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|