feat: add GroupBrowserView with search and group navigation

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYqycDFsynHH9VnnK7LNSf
This commit is contained in:
2026-09-19 15:58:41 +02:00
co-authored by Claude Sonnet 5
parent 543a063528
commit 66a01b3c80
2 changed files with 132 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
//
// VaultViewModel.swift
// MyPass
//
import Foundation
import Combine
import MyPassCore
@MainActor
final class VaultViewModel: ObservableObject {
@Published var searchQuery: String = ""
@Published var errorMessage: String?
let session: VaultSession
init(session: VaultSession) {
self.session = session
}
var filteredEntries: [Entry] {
guard !searchQuery.isEmpty else { return [] }
let q = searchQuery.lowercased()
return session.allEntries().filter {
$0.title.lowercased().contains(q)
|| $0.username.lowercased().contains(q)
|| $0.url.lowercased().contains(q)
}
}
var isSearching: Bool { !searchQuery.isEmpty }
func deleteEntry(_ entry: Entry, fromGroup group: Group) {
Task {
do { try session.deleteEntry(id: entry.id, fromGroupId: group.id) }
catch { errorMessage = error.localizedDescription }
}
}
}
+93
View File
@@ -0,0 +1,93 @@
//
// GroupBrowserView.swift
// MyPass
//
import SwiftUI
import MyPassCore
struct GroupBrowserView: View {
@ObservedObject var vm: VaultViewModel
let group: Group
@State private var selectedEntry: Entry?
@State private var showAddEntry = false
var body: some View {
List {
if vm.isSearching {
searchResultsSection
} else {
groupTreeSection
}
}
.navigationTitle(group.name)
.searchable(text: $vm.searchQuery, prompt: "Search all entries…")
.toolbar {
#if os(iOS)
ToolbarItem(placement: .navigationBarTrailing) { EditButton() }
#endif
ToolbarItem(placement: .primaryAction) {
Button { showAddEntry = true } label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showAddEntry) {
let editVM = EntryEditViewModel(session: vm.session, groupId: group.id)
EntryEditView(vm: editVM)
}
.alert("Error", isPresented: Binding(
get: { vm.errorMessage != nil },
set: { if !$0 { vm.errorMessage = nil } }
)) {
Button("OK", role: .cancel) { vm.errorMessage = nil }
} message: {
Text(vm.errorMessage ?? "")
}
}
@ViewBuilder
private var searchResultsSection: some View {
ForEach(vm.filteredEntries) { entry in
NavigationLink(destination: EntryDetailView(entry: entry, session: vm.session)) {
EntryRow(entry: entry)
}
}
}
@ViewBuilder
private var groupTreeSection: some View {
if !group.subgroups.isEmpty {
Section("Groups") {
ForEach(group.subgroups) { sub in
NavigationLink(destination: GroupBrowserView(vm: vm, group: sub)) {
Label(sub.name, systemImage: "folder")
}
}
}
}
if !group.entries.isEmpty {
Section("Entries") {
ForEach(group.entries) { entry in
NavigationLink(destination: EntryDetailView(entry: entry, session: vm.session)) {
EntryRow(entry: entry)
}
}
.onDelete { offsets in
offsets.map { group.entries[$0] }.forEach { vm.deleteEntry($0, fromGroup: group) }
}
}
}
}
}
private struct EntryRow: View {
let entry: Entry
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(entry.title).font(.body)
Text(entry.username).font(.caption).foregroundStyle(.secondary)
}
}
}