// TRACES: FR-CAT-11a | FR-CAT-15 | FR-UI-4 // The duplicate originals review: every group of copies of one file, which // one stays, what the others bring to it, and the one button that moves the // rest to the trash. // // Same shape as the merge page — a header with the one verb, a progress bar // under it while a job runs, a scrolling body — because it is the same kind // of page: a job proposes, the photographer confirms, and nothing moves // before the button is pressed. Every row is a tap target the height of a // finger, because the tablet uses this page too. import { Theme } from "theme.slint"; import { Button, Caption, Value, ProgressBar } from "widgets.slint"; import { Check } from "controls.slint"; /// One copy's line in a group. export struct DuplicateCopy { path: string, /// The copy that stays in the library. survivor: bool, } /// One group of copies of one file. export struct DuplicateRow { /// "Canon EOS 6D · 24 Jun 2023 12:21 · 26.5 MB". title: string, thumb: image, has-thumb: bool, copies: [DuplicateCopy], /// What the check found: "Same file", "Not checked yet", or why it was /// left out. status: string, /// What the survivor gains, and any conflict. detail: string, /// Left out by the check, or failed. warn: bool, /// In the plan. include: bool, /// Whether the include box means anything: a group the check dropped /// cannot be put back by hand. can-include: bool, /// Consolidated in this session. done: bool, } export global Duplicates { /// The current page of groups. in property <[DuplicateRow]> rows; /// The dry run: "1,836 groups · 3,379 copies to move to the trash · 2 /// skipped". in property summary; /// "Groups 1–30 of 1,836". in property page-label; in property has-prev: false; in property has-next: false; /// A check or a consolidation is running. in property running: false; in property fraction: 0; in property progress-label; /// The check's button and what it costs. in property check-label; in property check-cost; in property can-check: false; /// "Move 3,379 copies to trash". in property confirm-label; in property can-confirm: false; in property error; /// How many groups the library holds, for the entries into this page. in property group-count: 0; callback open(); callback close(); callback check(); callback stop(); callback confirm(); callback prev-page(); callback next-page(); /// A copy was chosen to stay: row on this page, copy in the group. callback pick-survivor(int, int); /// A group was put in or taken out of the plan, by row on this page. callback toggle(int, bool); } component CopyLine inherits Rectangle { in property copy; in property enabled: true; callback picked(); height: Theme.touch-target; border-radius: Theme.radius-sm; background: root.copy.survivor ? Theme.surface-raised : transparent; accessible-role: button; accessible-label: (root.copy.survivor ? "Stays: " : "To the trash: ") + root.copy.path; accessible-action-default => { root.picked(); } HorizontalLayout { padding-left: Theme.gap-sm; padding-right: Theme.gap-sm; spacing: Theme.gap-sm; Text { width: 88px; text: root.copy.survivor ? "Stays" : "To trash"; color: root.copy.survivor ? Theme.ink : Theme.ink-faint; font-size: Theme.text-sm; font-weight: root.copy.survivor ? 600 : 400; vertical-alignment: center; } Text { text: root.copy.path; color: root.copy.survivor ? Theme.ink : Theme.ink-dim; font-size: Theme.text-sm; overflow: elide; vertical-alignment: center; horizontal-stretch: 1; } } TouchArea { enabled: root.enabled; clicked => { root.picked(); } } } component GroupCard inherits Rectangle { in property group; in property busy; callback pick(int); callback toggled(bool); background: Theme.surface; border-radius: Theme.radius; height: layout.preferred-height; layout := HorizontalLayout { padding: Theme.gap; spacing: Theme.gap; // One picture for the group: the copies are the same file. Rectangle { width: 96px; height: 96px; background: Theme.ground; border-radius: Theme.radius-sm; clip: true; if root.group.has-thumb: Image { width: parent.width; height: parent.height; source: root.group.thumb; image-fit: contain; } } VerticalLayout { spacing: 2px; horizontal-stretch: 1; HorizontalLayout { spacing: Theme.gap-sm; Value { text: root.group.title; overflow: elide; horizontal-stretch: 1; } if root.group.can-include && !root.group.done: Check { label: "Include"; checked: root.group.include; controlled: true; toggled(on) => { root.toggled(on); } } } for copy[i] in root.group.copies: CopyLine { copy: copy; enabled: !root.busy && !root.group.done && root.group.can-include; picked => { root.pick(i); } } Caption { text: root.group.status; warn: root.group.warn; wrap: word-wrap; } if root.group.detail != "": Caption { text: root.group.detail; wrap: word-wrap; } } } } export component DuplicatesPage inherits Rectangle { background: Theme.ground; FocusScope { width: 0px; height: 0px; init => { self.focus(); } } VerticalLayout { 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; clicked => { Duplicates.close(); } } Value { text: "Duplicate originals"; } Rectangle { horizontal-stretch: 1; } if Duplicates.running: Button { text: "Stop"; y: (parent.height - self.height) / 2; clicked => { Duplicates.stop(); } } // GESTURE: Check duplicate originals are the same file // where: Duplicate originals // touch: Tap "Check" // pointer: Click "Check" // why: Nothing is moved on the catalog's say-so. The // check reads the first and last megabyte of each // copy and its sidecar, keeps what it read, and // drops any group whose bytes or edits differ. // manual: duplicate-originals if !Duplicates.running && Duplicates.can-check: Button { text: Duplicates.check-label; y: (parent.height - self.height) / 2; clicked => { Duplicates.check(); } } // GESTURE: Move the spare copies to the trash // where: Duplicate originals // touch: Tap "Move N copies to trash" // pointer: Click "Move N copies to trash" // why: The one verb, named with its count, and off until // a check has proved something. Each group is merged // onto the copy that stays and the others go to the // trash together or not at all; the trash view gives // them back. // manual: duplicate-originals if !Duplicates.running: Button { text: Duplicates.confirm-label; primary: true; enabled: Duplicates.can-confirm; y: (parent.height - self.height) / 2; clicked => { Duplicates.confirm(); } } } Rectangle { y: parent.height - 1px; height: 1px; background: Theme.rule; } } if Duplicates.running: ProgressBar { fraction: Duplicates.fraction; label: Duplicates.progress-label; } if Duplicates.error != "": Rectangle { height: 32px; background: Theme.surface; HorizontalLayout { padding-left: Theme.gap; padding-right: Theme.gap; Caption { text: Duplicates.error; warn: true; overflow: elide; } } } Flickable { vertical-stretch: 1; viewport-height: body.preferred-height; body := VerticalLayout { width: 100%; padding: Theme.gap-lg; spacing: Theme.gap; alignment: start; Caption { text: Duplicates.summary; wrap: word-wrap; } if Duplicates.running: Caption { text: Duplicates.progress-label; } if Duplicates.check-cost != "": Caption { text: Duplicates.check-cost; wrap: word-wrap; } // GESTURE: Choose which copy of a duplicate stays // where: Duplicate originals // touch: Tap the copy's path // pointer: Click the copy's path // why: The rule picks the copy outside a backup folder // that still has the camera's name. The path is the // evidence, so the path is what is tapped to // overrule it. // manual: duplicate-originals // // GESTURE: Leave a duplicate group as it is // where: Duplicate originals // touch: Untick "Include" on the group // pointer: Untick "Include" on the group // why: The plan is every group the check proved; a group // you want to keep in two places is taken out of it // rather than argued with. // manual: duplicate-originals for row[i] in Duplicates.rows: GroupCard { group: row; busy: Duplicates.running; pick(copy) => { Duplicates.pick-survivor(i, copy); } toggled(on) => { Duplicates.toggle(i, on); } } if Duplicates.has-prev || Duplicates.has-next: HorizontalLayout { spacing: Theme.gap; alignment: center; Button { text: "‹ Previous"; enabled: Duplicates.has-prev; clicked => { Duplicates.prev-page(); } } Caption { text: Duplicates.page-label; vertical-alignment: center; } Button { text: "Next ›"; enabled: Duplicates.has-next; clicked => { Duplicates.next-page(); } } } } } } }