wip: ingest
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user