diff --git a/MyPass/ViewModels/VaultViewModel.swift b/MyPass/ViewModels/VaultViewModel.swift new file mode 100644 index 0000000..b04f20b --- /dev/null +++ b/MyPass/ViewModels/VaultViewModel.swift @@ -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 } + } + } +} diff --git a/MyPass/Views/GroupBrowserView.swift b/MyPass/Views/GroupBrowserView.swift new file mode 100644 index 0000000..6948386 --- /dev/null +++ b/MyPass/Views/GroupBrowserView.swift @@ -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) + } + } +}