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 label; in property detail; in property 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 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 card-path; /// Whether the source holds a DCIM folder. Advisory: plenty of legitimate /// sources do not, so this informs and never blocks. in property card-looks-right: false; callback volume-picked(int); callback card-path-changed(string); callback refresh-volumes(); // --- what is on it --------------------------------------------------- in property surveying: false; /// "1,847 photographs · 61.2 GB". Empty before anything has been counted. in property survey-summary; // --- where they are going -------------------------------------------- /// The library folder on the server — the only destination there is. /// /// Empty means no account is signed in, and Import stays disabled: the /// library lives on the server, so an import without one has nowhere to /// go at all. in property upload-target; in-out property 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 template-preview; in property <[string]> mode-labels; in property mode-selected: 0; /// Whether the chosen mode will eventually erase the card. in property mode-is-move: false; in property <[string]> duplicate-labels; in property duplicate-selected: 0; in property verify: true; in property backup-on: false; in-out property 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 running: false; in property fraction: 0; /// What it is doing now, or what it did — one line, always present once /// something has happened. in property status; in property error; /// Whether Import can be pressed at all. in property 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.upload-target != "" ? root.upload-target : "not signed in"; overflow: elide; horizontal-stretch: 1; } } // The order is the promise, and a user watching a slow // upload needs to already know the photographs are safe. // Said here, beside the destination, rather than beside the // Import button where it would read as a warning. if root.upload-target != "": Caption { text: "Copied to this computer and verified first, then uploaded. Anything that does not go up stays queued for the next import."; wrap: word-wrap; } if root.upload-target == "": Caption { text: "Sign in first — the library this imports into is on the server."; wrap: word-wrap; } 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 of photographs the server has confirmed. Anything that does not upload keeps its card copy — which is then the only copy there is."; 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; } } } } } }