From 0629c736dbae1346356410ad937c3998709b75a2 Mon Sep 17 00:00:00 2001 From: yoshikazu Date: Mon, 27 Jul 2026 15:43:30 +0000 Subject: [PATCH] Add main window and toolbar --- Sources/Photochk/Views/ContentView.swift | 145 +++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 Sources/Photochk/Views/ContentView.swift diff --git a/Sources/Photochk/Views/ContentView.swift b/Sources/Photochk/Views/ContentView.swift new file mode 100644 index 0000000..3bf9414 --- /dev/null +++ b/Sources/Photochk/Views/ContentView.swift @@ -0,0 +1,145 @@ +import SwiftUI + +struct ContentView: View { + @Bindable var model: ScanViewModel + + @State private var isConfirmingTrash = false + + var body: some View { + NavigationSplitView { + GroupListView(model: model) + .navigationSplitViewColumnWidth(min: 270, ideal: 310, max: 420) + } detail: { + if let group = model.selectedGroup { + GroupDetailView(model: model, group: group) + } else { + placeholder + } + } + .toolbar { ScanToolbar(model: model, isConfirmingTrash: $isConfirmingTrash) } + .overlay { + if let progress = model.progress { + Color.black.opacity(0.12).ignoresSafeArea() + ScanProgressView(progress: progress) { model.cancelScan() } + } + } + .confirmationDialog( + "\(model.markedCount) 枚をゴミ箱へ移動します", + isPresented: $isConfirmingTrash, + titleVisibility: .visible + ) { + Button("ゴミ箱へ移動(\(model.markedBytesText))", role: .destructive) { + Task { await model.moveMarkedToTrash() } + } + Button("キャンセル", role: .cancel) {} + } message: { + Text("削除ではなくゴミ箱への移動です。Finder のゴミ箱からいつでも元に戻せます。") + } + .alert( + "エラー", + isPresented: Binding( + get: { model.errorMessage != nil }, + set: { if !$0 { model.errorMessage = nil } } + ) + ) { + Button("OK") { model.errorMessage = nil } + } message: { + Text(model.errorMessage ?? "") + } + } + + @ViewBuilder + private var placeholder: some View { + if model.folder == nil { + ContentUnavailableView { + Label("フォルダを選んでください", systemImage: "photo.stack") + } description: { + Text("選んだフォルダの中から、内容が同じ写真とよく似た写真を探します。") + } actions: { + Button("フォルダを選ぶ…") { model.chooseFolder() } + .buttonStyle(.borderedProminent) + } + } else if model.hasScanned && model.groups.isEmpty { + ContentUnavailableView { + Label("似た写真は見つかりませんでした", systemImage: "checkmark.circle") + } description: { + Text("判定をゆるくすると、より広く似た写真を拾えます。") + } + } else { + ContentUnavailableView( + "グループを選択してください", + systemImage: "square.grid.2x2" + ) + } + } +} + +struct ScanToolbar: ToolbarContent { + @Bindable var model: ScanViewModel + @Binding var isConfirmingTrash: Bool + + var body: some ToolbarContent { + ToolbarItem(placement: .navigation) { + Button { + model.chooseFolder() + } label: { + Label("フォルダを選ぶ", systemImage: "folder") + } + .help(model.folder?.path ?? "調べるフォルダを選びます") + } + + ToolbarItem { + Picker("類似度", selection: $model.settings.sensitivity) { + ForEach(Sensitivity.allCases) { level in + Text(level.label).tag(level) + } + } + .pickerStyle(.segmented) + .frame(width: 220) + .help(model.settings.sensitivity.help) + .disabled(model.isScanning) + } + + ToolbarItem { + Toggle(isOn: $model.settings.includesSubfolders) { + Label("サブフォルダも含む", systemImage: "folder.badge.plus") + } + .help("サブフォルダの中まで再帰的に調べます") + .disabled(model.isScanning) + } + + ToolbarItem { + Menu { + ForEach(KeepRule.allCases) { rule in + Button(rule.label) { model.applyKeepRule(rule) } + } + Divider() + Button("選択をすべて解除") { model.clearMarks() } + } label: { + Label("一括選択", systemImage: "checklist") + } + .disabled(model.groups.isEmpty) + } + + ToolbarItem { + Button { + isConfirmingTrash = true + } label: { + Label( + model.markedCount > 0 ? "\(model.markedCount) 枚をゴミ箱へ" : "ゴミ箱へ", + systemImage: "trash" + ) + } + .disabled(model.markedCount == 0) + } + + ToolbarItem { + Button { + model.rescan() + } label: { + Label("再スキャン", systemImage: "arrow.clockwise") + } + .disabled(model.folder == nil || model.isScanning) + } + } +}