From 501d80a9b848a6eeace18b2d8cb4ebce7203b8c4 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 15 Oct 2026 20:11:25 +0200 Subject: [PATCH] Lagerort- und Kategorieauswahl zeigen den Baum statt einer Mischung Die Auswahl war nicht flach, weil die Beschriftung gefehlt haette - der volle Pfad stand laengst drin. Sie kam vom Server nach dem BLATTnamen sortiert, und so standen "Garage -> Fach A" und "Keller -> Fach A" irgendwo zwischen allem anderen. Geschwister und Kinder fanden nie zusammen. Neu ist baum() in Baum.swift: bringt eine flache Eltern-Kind-Liste in Baumreihenfolge und rueckt je Ebene ein. Waisen gelten als oberste Ebene, damit nichts lautlos aus der Auswahl faellt. Derselbe Lauf lag bisher dreimal im Projekt (CategoryPicker, TreeMasterView, LocationEditor). Alle drei zeigen jetzt auf den einen - unterm Strich verschwinden mehr Zeilen als dazukommen. Zwei der Kopien waren auch nicht gleichwertig: LocationEditor.pfad hatte den Zyklusschutz von path(in:) nicht und waere bei einer ringfoermigen Elternkette endlos gelaufen. Umgestellt sind neun Auswahlfelder. Die Lagerortzeile beim Kassenzettel zeigte nur den nackten Namen und bekommt den vollen Pfad - dort ist kein Platz zum Einruecken. MARKETING_VERSION stand auf 1.2 in der project.pbxproj, aber auf 1.1 in der project.yml, aus der sie erzeugt wird. Die Quelle nachgezogen, sonst haette das naechste "xcodegen generate" die Anhebung zurueckgedreht. Co-Authored-By: Claude Opus 5 --- ios/Sources/Baum.swift | 38 ++++++++++++++++ ios/Sources/CategoryPicker.swift | 22 +-------- ios/Sources/CheckInFormView.swift | 4 +- ios/Sources/CheckInView.swift | 6 ++- ios/Sources/GroupViews.swift | 4 +- ios/Sources/ItemViews.swift | 4 +- ios/Sources/MasterDataViews.swift | 62 ++++---------------------- ios/Sources/ObjectStockView.swift | 2 +- ios/Sources/ProductDetailView.swift | 4 +- ios/Vorrania.xcodeproj/project.pbxproj | 8 +++- ios/project.yml | 2 +- 11 files changed, 69 insertions(+), 87 deletions(-) create mode 100644 ios/Sources/Baum.swift diff --git a/ios/Sources/Baum.swift b/ios/Sources/Baum.swift new file mode 100644 index 0000000..c0335d9 --- /dev/null +++ b/ios/Sources/Baum.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Eine Zeile einer flach ausgelegten Baumliste, mit ihrer Tiefe. +/// +/// Lagerorte und Kategorien sind Baeume, kommen vom Server aber als flache +/// Liste nach Namen sortiert. Daraus wird eine Auswahl, in der "Garage → Fach A" +/// und "Keller → Fach A" irgendwo zwischen allem anderen stehen - Geschwister +/// und Kinder finden nie zusammen. Diese Reihenfolge stellt das her. +struct Baumzeile: Identifiable { + let item: T + let tiefe: Int + + var id: T.ID { item.id } + + /// Name mit Einrueckung je Ebene. Macht die Hierarchie in einer flachen + /// Auswahl sichtbar, ohne jede Zeile mit dem vollen Pfad zu ueberladen. + var label: String { String(repeating: " ", count: tiefe) + item.name } +} + +/// Bringt eine flache Eltern-Kind-Liste in Baumreihenfolge, mit Tiefe. +/// +/// Waisen - deren Elternteil weggefiltert oder geloescht ist - gelten als +/// oberste Ebene. Ohne diese Regel verschwaenden sie lautlos aus der Auswahl. +func baum(_ liste: [T]) -> [Baumzeile] { + let ids = Set(liste.map(\.id)) + var ergebnis: [Baumzeile] = [] + + func gehe(_ knoten: T, _ tiefe: Int) { + ergebnis.append(Baumzeile(item: knoten, tiefe: tiefe)) + for kind in liste.filter({ $0.parentId == knoten.id }) { + gehe(kind, tiefe + 1) + } + } + for wurzel in liste.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) { + gehe(wurzel, 0) + } + return ergebnis +} diff --git a/ios/Sources/CategoryPicker.swift b/ios/Sources/CategoryPicker.swift index 219f31b..5472077 100644 --- a/ios/Sources/CategoryPicker.swift +++ b/ios/Sources/CategoryPicker.swift @@ -13,9 +13,8 @@ struct CategoryPicker: View { var body: some View { Picker("Kategorie", selection: $selection) { Text("– keine –").tag(Int?.none) - ForEach(flattened(), id: \.item.id) { eintrag in - Text(String(repeating: " ", count: eintrag.depth) + eintrag.item.name) - .tag(Int?.some(eintrag.item.id)) + ForEach(baum(gefiltert)) { eintrag in + Text(eintrag.label).tag(Int?.some(eintrag.item.id)) } } } @@ -24,23 +23,6 @@ struct CategoryPicker: View { guard let tracking else { return categories } return categories.filter { $0.tracking == tracking } } - - private func flattened() -> [(item: CategoryItem, depth: Int)] { - let quelle = gefiltert - let ids = Set(quelle.map(\.id)) - var result: [(CategoryItem, Int)] = [] - - func walk(_ node: CategoryItem, _ depth: Int) { - result.append((node, depth)) - for child in quelle.filter({ $0.parentId == node.id }) { - walk(child, depth + 1) - } - } - for root in quelle.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) { - walk(root, 0) - } - return result - } } /// Legt eine Gruppe an Ort und Stelle an, damit man das Formular nicht diff --git a/ios/Sources/CheckInFormView.swift b/ios/Sources/CheckInFormView.swift index d84420c..e71150c 100644 --- a/ios/Sources/CheckInFormView.swift +++ b/ios/Sources/CheckInFormView.swift @@ -211,8 +211,8 @@ struct CheckInFormView: View { Section { Picker("Lagerort", selection: $locationId) { Text("– keiner –").tag(String?.none) - ForEach(locations) { location in - Text(location.path(in: locations)).tag(String?.some(location.id)) + ForEach(baum(locations)) { eintrag in + Text(eintrag.label).tag(String?.some(eintrag.item.id)) } } Button { diff --git a/ios/Sources/CheckInView.swift b/ios/Sources/CheckInView.swift index 55b0277..d065c62 100644 --- a/ios/Sources/CheckInView.swift +++ b/ios/Sources/CheckInView.swift @@ -675,7 +675,11 @@ struct KassenzettelPruefenView: View { HStack { Menu { Button("– ohne –") { z.wrappedValue.ortId = nil } - ForEach(locations) { loc in Button(loc.name) { z.wrappedValue.ortId = loc.id } } + // Voller Pfad statt nur des Namens: In dieser engen Zeile gibt es + // keine Einrueckung, und "Fach A" allein ist nicht eindeutig. + ForEach(locations) { loc in + Button(loc.path(in: locations)) { z.wrappedValue.ortId = loc.id } + } } label: { Label(ortName(zeile.ortId), systemImage: "mappin.and.ellipse") } diff --git a/ios/Sources/GroupViews.swift b/ios/Sources/GroupViews.swift index fda3674..89fd495 100644 --- a/ios/Sources/GroupViews.swift +++ b/ios/Sources/GroupViews.swift @@ -217,8 +217,8 @@ struct GroupEditor: View { HStack { Picker("Ort", selection: row.locationId) { Text(UEBERALL_NAME).tag(String?.some(UEBERALL_ID)) - ForEach(locations) { l in - Text(l.path(in: locations)).tag(String?.some(l.id)) + ForEach(baum(locations)) { eintrag in + Text(eintrag.label).tag(String?.some(eintrag.item.id)) } } TextField("Menge", text: row.amount) diff --git a/ios/Sources/ItemViews.swift b/ios/Sources/ItemViews.swift index b18a835..6bd585f 100644 --- a/ios/Sources/ItemViews.swift +++ b/ios/Sources/ItemViews.swift @@ -340,7 +340,7 @@ struct ItemEditView: View { Section { Picker("Lagerort", selection: $locationId) { Text("– ohne –").tag(String?.none) - ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) } + ForEach(baum(locations)) { e in Text(e.label).tag(String?.some(e.item.id)) } } // Lagerort per QR setzen – ohne den (evtl. nicht vorhandenen) QR // des Einzelstücks. Praktisch z.B. bei Kleidung. @@ -831,7 +831,7 @@ struct ItemAddSheet: View { Stepper("Anzahl: \(count)", value: $count, in: 1...200) Picker("Lagerort", selection: $locationId) { Text("– ohne –").tag(String?.none) - ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) } + ForEach(baum(locations)) { e in Text(e.label).tag(String?.some(e.item.id)) } } Button { locScanShown = true } label: { Label("Lagerort scannen", systemImage: "qrcode.viewfinder") diff --git a/ios/Sources/MasterDataViews.swift b/ios/Sources/MasterDataViews.swift index 4a737c1..0d40890 100644 --- a/ios/Sources/MasterDataViews.swift +++ b/ios/Sources/MasterDataViews.swift @@ -197,33 +197,11 @@ struct TreeMasterView: View { @State private var addShown = false @State private var pendingDeletion: Item? - private struct Node: Identifiable { - let item: Item - let depth: Int - var id: Item.ID { item.id } - } - private var shown: [Item] { items.filter(include) } - /// Baumreihenfolge mit Tiefe. Waisen (Elternteil gefiltert/gelöscht) gelten - /// als oberste Ebene, damit nichts verschwindet. - private var nodes: [Node] { - let list = shown - let ids = Set(list.map(\.id)) - var result: [Node] = [] - func walk(_ n: Item, _ depth: Int) { - result.append(Node(item: n, depth: depth)) - for child in list.filter({ $0.parentId == n.id }) { walk(child, depth + 1) } - } - for root in list.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) { - walk(root, 0) - } - return result - } - private func childCount(_ id: Item.ID) -> Int { shown.filter { $0.parentId == id }.count } - private var visible: [Node] { + private var visible: [Baumzeile] { let parent = Dictionary(uniqueKeysWithValues: shown.map { ($0.id, $0.parentId) }) func hidden(_ id: Item.ID) -> Bool { var p = parent[id] ?? nil @@ -233,7 +211,7 @@ struct TreeMasterView: View { } return false } - return nodes.filter { !hidden($0.item.id) } + return baum(shown).filter { !hidden($0.item.id) } } var body: some View { @@ -286,7 +264,7 @@ struct TreeMasterView: View { } @ViewBuilder - private func row(_ node: Node) -> some View { + private func row(_ node: Baumzeile) -> some View { let kinder = childCount(node.item.id) let zu = collapsed.contains(node.item.id) HStack(spacing: 8) { @@ -321,7 +299,7 @@ struct TreeMasterView: View { } .buttonStyle(.plain) } - .padding(.leading, CGFloat(node.depth) * 16) + .padding(.leading, CGFloat(node.tiefe) * 16) } private func toggle(_ id: Item.ID) { @@ -468,8 +446,8 @@ struct LocationEditor: View { Section { Picker("Übergeordnet", selection: $parentId) { Text("– oberste Ebene –").tag(String?.none) - ForEach(parentOptions) { loc in - Text(LocationEditor.pfad(loc, in: all)).tag(String?.some(loc.id)) + ForEach(baum(parentOptions)) { eintrag in + Text(eintrag.label).tag(String?.some(eintrag.item.id)) } } } header: { @@ -523,19 +501,6 @@ struct LocationEditor: View { } return result } - - /// "Keller → Regal" – der Pfad macht gleiche Namen im flachen Picker - /// unterscheidbar. - private static func pfad(_ loc: StorageLocation, in all: [StorageLocation]) -> String { - let byId = Dictionary(uniqueKeysWithValues: all.map { ($0.id, $0) }) - var teile = [loc.name] - var pid = loc.parentId - while let cur = pid, let parent = byId[cur] { - teile.insert(parent.name, at: 0) - pid = parent.parentId - } - return teile.joined(separator: " → ") - } } struct UnitsView: View { @@ -820,8 +785,8 @@ struct CategoryEditor: View { Section { Picker("Übergeordnet", selection: $parentId) { Text("– oberste Ebene –").tag(Int?.none) - ForEach(all) { c in - Text(CategoryEditor.pfad(c, in: all)).tag(Int?.some(c.id)) + ForEach(baum(all)) { eintrag in + Text(eintrag.label).tag(Int?.some(eintrag.item.id)) } } } header: { @@ -896,17 +861,6 @@ struct CategoryEditor: View { self.error = error.localizedDescription } } - - private static func pfad(_ c: CategoryItem, in all: [CategoryItem]) -> String { - let byId = Dictionary(uniqueKeysWithValues: all.map { ($0.id, $0) }) - var teile = [c.name] - var pid = c.parentId - while let cur = pid, let parent = byId[cur] { - teile.insert(parent.name, at: 0) - pid = parent.parentId - } - return teile.joined(separator: " → ") - } } // MARK: - Eigene Felder je Kategorie diff --git a/ios/Sources/ObjectStockView.swift b/ios/Sources/ObjectStockView.swift index 0270f1e..5510eae 100644 --- a/ios/Sources/ObjectStockView.swift +++ b/ios/Sources/ObjectStockView.swift @@ -152,7 +152,7 @@ private struct LocationPicker: View { var body: some View { Picker(title, selection: $selection) { Text("– ohne Lagerort –").tag(String?.none) - ForEach(locations) { loc in Text(loc.path(in: locations)).tag(String?.some(loc.id)) } + ForEach(baum(locations)) { e in Text(e.label).tag(String?.some(e.item.id)) } } } } diff --git a/ios/Sources/ProductDetailView.swift b/ios/Sources/ProductDetailView.swift index 2763a72..08847ff 100644 --- a/ios/Sources/ProductDetailView.swift +++ b/ios/Sources/ProductDetailView.swift @@ -688,7 +688,7 @@ struct LotEditView: View { Section("Lagerort") { Picker("Lagerort", selection: $locationId) { Text("– ohne –").tag(String?.none) - ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) } + ForEach(baum(locations)) { e in Text(e.label).tag(String?.some(e.item.id)) } } // Ort per QR am Regal/Fach setzen, statt in der Liste zu suchen. Button { locScanShown = true } label: { @@ -800,7 +800,7 @@ struct ProductLocationMinView: View { HStack { Picker("Ort", selection: $row.locationId) { Text(UEBERALL_NAME).tag(String?.some(UEBERALL_ID)) - ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) } + ForEach(baum(locations)) { e in Text(e.label).tag(String?.some(e.item.id)) } } TextField("Menge", text: $row.amount) .keyboardType(.decimalPad) diff --git a/ios/Vorrania.xcodeproj/project.pbxproj b/ios/Vorrania.xcodeproj/project.pbxproj index b50cb99..e3faa17 100644 --- a/ios/Vorrania.xcodeproj/project.pbxproj +++ b/ios/Vorrania.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ 83F9AD06177F3BC95421F128 /* DashboardCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = 090485CC54558EB4433376A9 /* DashboardCards.swift */; }; 87A34A269AAD85A19C3F4359 /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA706060734632DE78FA1073 /* ServerListView.swift */; }; 926451E72FD13708C5DD657B /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */; }; + 93BE44F5C09AA9A9B67ED211 /* Baum.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85F4CDB145DD84793DFC90F1 /* Baum.swift */; }; 96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */; }; 9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4741D0E95875919C921945CF /* RootView.swift */; }; A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */; }; @@ -77,6 +78,7 @@ 76393ADA26304E5BC5AD7E7F /* AssignScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssignScanView.swift; sourceTree = ""; }; 7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjectStockView.swift; sourceTree = ""; }; 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; + 85F4CDB145DD84793DFC90F1 /* Baum.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Baum.swift; sourceTree = ""; }; 8AF19A735AC17451B57BF5BB /* ScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerView.swift; sourceTree = ""; }; 939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = ""; }; 9948219CDDC4188EA4298F22 /* VorraniaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorraniaApp.swift; sourceTree = ""; }; @@ -115,6 +117,7 @@ 378F1B2DE567B62169E84426 /* APIClient.swift */, 660ACEFA22BC77D52FBE736A /* AppDashboards.swift */, 76393ADA26304E5BC5AD7E7F /* AssignScanView.swift */, + 85F4CDB145DD84793DFC90F1 /* Baum.swift */, AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */, CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */, E580BA2815BB8F6341D64B47 /* ChartCards.swift */, @@ -253,6 +256,7 @@ 39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */, F3F718BFC60BF945CFA32BD2 /* AppDashboards.swift in Sources */, 5A0C3254B45CA13B1C3D9265 /* AssignScanView.swift in Sources */, + 93BE44F5C09AA9A9B67ED211 /* Baum.swift in Sources */, A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */, 25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */, FFD4DB783F89083C9098F1C4 /* ChartCards.swift in Sources */, @@ -367,7 +371,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.vorrania; SDKROOT = iphoneos; SWIFT_VERSION = 5.9; @@ -390,7 +394,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.vorrania; SDKROOT = iphoneos; SWIFT_VERSION = 5.9; diff --git a/ios/project.yml b/ios/project.yml index 3c2cc9d..fd6e9eb 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -52,7 +52,7 @@ targets: # MARKETING_VERSION ist die sichtbare Version (App Store), # CURRENT_PROJECT_VERSION die Build-Nummer: muss bei jedem Upload # innerhalb derselben Version steigen. - MARKETING_VERSION: "1.1" + MARKETING_VERSION: "1.2" CURRENT_PROJECT_VERSION: "2" TARGETED_DEVICE_FAMILY: "1,2" SWIFT_VERSION: "5.9"