Share sheet: Hidden and Storage toggles, plus diagnostics for the missing accounts
Two menu chips start at the account's app settings (hidden filter, storage scope - now published per account), hidden-on asks for the app's unlock, and external storage is detected via nc:mount-type incl. everything under a mount. Adds logging for which accounts get published and why one is skipped. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
22a6602f08
commit
02d612bfec
@@ -15,7 +15,6 @@ final class ShareModel: ObservableObject {
|
||||
@Published var stage: Stage = .preparing
|
||||
@Published var items: [SharedItem] = []
|
||||
@Published var path = "/"
|
||||
@Published var folders: [DavFolder] = []
|
||||
@Published var isLoadingFolders = false
|
||||
@Published var folderError: String?
|
||||
/// A short explanation shown under the list - e.g. hidden folders stayed
|
||||
@@ -23,11 +22,21 @@ final class ShareModel: ObservableObject {
|
||||
@Published var notice: String?
|
||||
@Published private(set) var selected: SharedAccount?
|
||||
|
||||
/// The sheet's two toggles. They start at the app's own setting for the
|
||||
/// chosen account and can be changed for this share only.
|
||||
@Published private(set) var hiddenFilter: HiddenFilter = .hide
|
||||
@Published private(set) var storageFilter: StorageFilter = .cloud
|
||||
|
||||
/// Every child folder of [path], unfiltered; the toggles filter it live.
|
||||
@Published private var allFolders: [DavFolder] = []
|
||||
/// Mount points of external storage seen so far. Only the mount's root is
|
||||
/// flagged by the server, so anything under one of these is external too.
|
||||
private var externalRoots: Set<String> = []
|
||||
|
||||
let shared: SharedAccounts?
|
||||
|
||||
/// One successful unlock covers every gate for the rest of this sheet.
|
||||
private var unlocked = false
|
||||
private var hidden: HiddenFilter = .hide
|
||||
|
||||
/// Set by the view controller: what each button actually does.
|
||||
var onUpload: () -> Void = {}
|
||||
@@ -50,6 +59,23 @@ final class ShareModel: ObservableObject {
|
||||
items.count == 1 ? "1 file" : "\(items.count) files"
|
||||
}
|
||||
|
||||
// MARK: - Folders, filtered by the toggles
|
||||
|
||||
private func isExternal(_ folder: DavFolder) -> Bool {
|
||||
folder.isExternal
|
||||
|| externalRoots.contains { folder.path == $0 || folder.path.hasPrefix($0 + "/") }
|
||||
}
|
||||
|
||||
private var visibleFolders: [DavFolder] {
|
||||
allFolders.filter { hiddenFilter.shows(path: $0.path) && storageFilter.shows(isExternal: isExternal($0)) }
|
||||
}
|
||||
|
||||
/// Internal folders - and, for "All", the external ones are listed apart.
|
||||
var internalFolders: [DavFolder] { visibleFolders.filter { !isExternal($0) } }
|
||||
var externalFolders: [DavFolder] { visibleFolders.filter { isExternal($0) } }
|
||||
|
||||
// MARK: - Accounts
|
||||
|
||||
/// With several accounts the list comes first; with one it goes straight
|
||||
/// to its folders.
|
||||
func start() async {
|
||||
@@ -57,6 +83,7 @@ final class ShareModel: ObservableObject {
|
||||
stage = .noAccount
|
||||
return
|
||||
}
|
||||
NSLog("[ShareExtension] %d account(s), active=%@", shared.accounts.count, shared.active?.id ?? "none")
|
||||
if shared.accounts.count > 1 {
|
||||
stage = .chooseAccount
|
||||
} else {
|
||||
@@ -71,8 +98,8 @@ final class ShareModel: ObservableObject {
|
||||
|
||||
/// Uploading to an account other than the one the app is on is "switching"
|
||||
/// in the app's terms, so it asks for the same unlock when the app does.
|
||||
/// Hidden folders follow the account's own app setting, behind the app's
|
||||
/// "lock hidden files" unlock.
|
||||
/// The toggles start at that account's own app settings; hidden folders
|
||||
/// are behind the app's "lock hidden files" unlock.
|
||||
func select(_ account: SharedAccount) async {
|
||||
guard let shared else { return }
|
||||
notice = nil
|
||||
@@ -83,11 +110,14 @@ final class ShareModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
}
|
||||
NSLog("[ShareExtension] selected %@ (hidden=%@ storage=%@)", account.id, account.hiddenFilter, account.storageScope)
|
||||
selected = account
|
||||
hidden = HiddenFilter(raw: account.hiddenFilter)
|
||||
if hidden != .hide, shared.needsUnlockForHidden {
|
||||
externalRoots = []
|
||||
storageFilter = StorageFilter(raw: account.storageScope)
|
||||
hiddenFilter = HiddenFilter(raw: account.hiddenFilter)
|
||||
if hiddenFilter != .hide, shared.needsUnlockForHidden {
|
||||
if await !unlock("Unlock to show hidden folders") {
|
||||
hidden = .hide
|
||||
hiddenFilter = .hide
|
||||
notice = "Hidden folders are locked, so they're not shown."
|
||||
}
|
||||
}
|
||||
@@ -95,6 +125,31 @@ final class ShareModel: ObservableObject {
|
||||
await open("/")
|
||||
}
|
||||
|
||||
// MARK: - Toggles
|
||||
|
||||
/// Turning hidden folders *on* is what the app locks; going back to
|
||||
/// hiding them never exposes anything, and neither does switching
|
||||
/// between the two revealing modes.
|
||||
func setHiddenFilter(_ filter: HiddenFilter) async {
|
||||
guard filter != hiddenFilter else { return }
|
||||
if hiddenFilter == .hide, shared?.needsUnlockForHidden == true {
|
||||
guard await unlock("Unlock to show hidden folders") else {
|
||||
notice = "Hidden folders are locked, so they're not shown."
|
||||
return
|
||||
}
|
||||
}
|
||||
notice = nil
|
||||
hiddenFilter = filter
|
||||
await open("/")
|
||||
}
|
||||
|
||||
func setStorageFilter(_ filter: StorageFilter) async {
|
||||
guard filter != storageFilter else { return }
|
||||
notice = nil
|
||||
storageFilter = filter
|
||||
await open("/")
|
||||
}
|
||||
|
||||
private func unlock(_ reason: String) async -> Bool {
|
||||
if unlocked { return true }
|
||||
let ok = await DeviceAuth.authenticate(reason: reason)
|
||||
@@ -102,6 +157,10 @@ final class ShareModel: ObservableObject {
|
||||
return ok
|
||||
}
|
||||
|
||||
// MARK: - Browsing
|
||||
|
||||
/// A toggle can leave you inside a folder it now hides, so changing one
|
||||
/// always returns to the top - same as [open] with "/".
|
||||
func open(_ path: String) async {
|
||||
guard let account = selected else { return }
|
||||
self.path = path
|
||||
@@ -109,9 +168,10 @@ final class ShareModel: ObservableObject {
|
||||
folderError = nil
|
||||
defer { isLoadingFolders = false }
|
||||
do {
|
||||
folders = try await DavClient.listFolders(account: account, path: path, hidden: hidden)
|
||||
allFolders = try await DavClient.listFolders(account: account, path: path)
|
||||
externalRoots.formUnion(allFolders.filter(\.isExternal).map(\.path))
|
||||
} catch {
|
||||
folders = []
|
||||
allFolders = []
|
||||
folderError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
@@ -124,6 +184,7 @@ final class ShareModel: ObservableObject {
|
||||
|
||||
struct SharePickerView: View {
|
||||
@ObservedObject var model: ShareModel
|
||||
@State private var externalExpanded = true
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -203,12 +264,12 @@ struct SharePickerView: View {
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
|
||||
// MARK: Folders
|
||||
|
||||
private var folderList: some View {
|
||||
List {
|
||||
Section(
|
||||
header: accountHeader,
|
||||
footer: model.notice.map { Text($0) }
|
||||
) {
|
||||
Section(header: accountHeader, footer: model.notice.map { Text($0) }) {
|
||||
filterRow
|
||||
if model.path != "/" {
|
||||
Button {
|
||||
Task { await model.openParent() }
|
||||
@@ -220,15 +281,19 @@ struct SharePickerView: View {
|
||||
HStack { Spacer(); ProgressView(); Spacer() }
|
||||
} else if let error = model.folderError {
|
||||
Text(error).foregroundColor(.secondary)
|
||||
} else if model.folders.isEmpty {
|
||||
} else if model.internalFolders.isEmpty && model.externalFolders.isEmpty {
|
||||
Text("No folders here").foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(model.folders, id: \.path) { folder in
|
||||
Button {
|
||||
Task { await model.open(folder.path) }
|
||||
} label: {
|
||||
Label(folder.name, systemImage: "folder")
|
||||
}
|
||||
ForEach(model.internalFolders, id: \.path) { folderRow($0) }
|
||||
}
|
||||
}
|
||||
// "All" keeps external storage in its own group, like the app's list.
|
||||
if !model.isLoadingFolders, model.folderError == nil, !model.externalFolders.isEmpty {
|
||||
Section {
|
||||
DisclosureGroup(isExpanded: $externalExpanded) {
|
||||
ForEach(model.externalFolders, id: \.path) { folderRow($0) }
|
||||
} label: {
|
||||
Label("External storage (\(model.externalFolders.count))", systemImage: "externaldrive")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,6 +301,73 @@ struct SharePickerView: View {
|
||||
.listStyle(.insetGrouped)
|
||||
}
|
||||
|
||||
private func folderRow(_ folder: DavFolder) -> some View {
|
||||
Button {
|
||||
Task { await model.open(folder.path) }
|
||||
} label: {
|
||||
Label(folder.name, systemImage: folder.isExternal ? "externaldrive" : "folder")
|
||||
}
|
||||
}
|
||||
|
||||
/// The two toggles, as menus - Hidden folders and External storage each
|
||||
/// start at the app's setting and can be changed for this share.
|
||||
private var filterRow: some View {
|
||||
HStack(spacing: 8) {
|
||||
Menu {
|
||||
Picker("Hidden folders", selection: hiddenBinding) {
|
||||
Text("Hide hidden folders").tag(HiddenFilter.hide)
|
||||
Text("Only hidden folders").tag(HiddenFilter.only)
|
||||
Text("Show all").tag(HiddenFilter.include)
|
||||
}
|
||||
} label: {
|
||||
chip("eye", "Hidden", hiddenTitle)
|
||||
}
|
||||
Menu {
|
||||
Picker("External storage", selection: storageBinding) {
|
||||
Text("Cloud only").tag(StorageFilter.cloud)
|
||||
Text("Only external storage").tag(StorageFilter.external)
|
||||
Text("All + external storage").tag(StorageFilter.all)
|
||||
}
|
||||
} label: {
|
||||
chip("externaldrive", "Storage", storageTitle)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
|
||||
private func chip(_ icon: String, _ name: String, _ value: String) -> some View {
|
||||
Label("\(name): \(value)", systemImage: icon)
|
||||
.font(.footnote.weight(.medium))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Capsule().fill(Color(.secondarySystemFill)))
|
||||
}
|
||||
|
||||
private var hiddenBinding: Binding<HiddenFilter> {
|
||||
Binding(get: { model.hiddenFilter }, set: { value in Task { await model.setHiddenFilter(value) } })
|
||||
}
|
||||
|
||||
private var storageBinding: Binding<StorageFilter> {
|
||||
Binding(get: { model.storageFilter }, set: { value in Task { await model.setStorageFilter(value) } })
|
||||
}
|
||||
|
||||
private var hiddenTitle: String {
|
||||
switch model.hiddenFilter {
|
||||
case .hide: return "Hide"
|
||||
case .only: return "Only"
|
||||
case .include: return "All"
|
||||
}
|
||||
}
|
||||
|
||||
private var storageTitle: String {
|
||||
switch model.storageFilter {
|
||||
case .cloud: return "Cloud"
|
||||
case .external: return "External"
|
||||
case .all: return "All"
|
||||
}
|
||||
}
|
||||
|
||||
private var accountHeader: some View {
|
||||
HStack {
|
||||
Text(model.selected?.displayName ?? "")
|
||||
|
||||
Reference in New Issue
Block a user