diff --git a/MyPass/Views/EntryDetailView.swift b/MyPass/Views/EntryDetailView.swift new file mode 100644 index 0000000..c6a7d0e --- /dev/null +++ b/MyPass/Views/EntryDetailView.swift @@ -0,0 +1,174 @@ +// +// EntryDetailView.swift +// MyPass +// + +import SwiftUI +import MyPassCore + +struct EntryDetailView: View { + let entry: Entry + let session: VaultSession + + @State private var showPassword = false + @State private var showEditSheet = false + + var body: some View { + List { + credentialSection + if !entry.url.isEmpty { urlSection } + if !entry.notes.isEmpty { notesSection } + if entry.totp != nil { totpSection } + if !entry.customFields.isEmpty { customFieldsSection } + if !entry.attachments.isEmpty { attachmentsSection } + } + .navigationTitle(entry.title) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Edit") { showEditSheet = true } + } + } + .sheet(isPresented: $showEditSheet) { + EntryEditView(vm: EntryEditViewModel(session: session, existing: entry)) + } + #if os(macOS) + .keyboardShortcut("e", modifiers: .command) + #endif + } + + private var credentialSection: some View { + Section("Credentials") { + FieldRow(label: "Username", value: entry.username, isCopyable: true) + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Password").font(.caption).foregroundStyle(.secondary) + Text(showPassword ? entry.password.reveal() : String(repeating: "•", count: 10)) + .font(.body.monospaced()) + } + Spacer() + Button { showPassword.toggle() } label: { + Image(systemName: showPassword ? "eye.slash" : "eye") + } + .buttonStyle(.plain) + Button { ClipboardService.copy(entry.password.reveal()) } label: { + Image(systemName: "doc.on.doc") + } + .buttonStyle(.plain) + } + } + } + + private var urlSection: some View { + Section("URL") { + FieldRow(label: "URL", value: entry.url, isCopyable: true) + } + } + + private var notesSection: some View { + Section("Notes") { + Text(entry.notes) + .font(.body) + } + } + + private var totpSection: some View { + Section("One-Time Password") { + if let config = entry.totp { + TOTPRow(config: config) + } + } + } + + private var customFieldsSection: some View { + Section("Custom Fields") { + ForEach(entry.customFields) { field in + FieldRow( + label: field.key, + value: field.value.reveal(), + isCopyable: true, + isProtected: field.value.isProtected + ) + } + } + } + + private var attachmentsSection: some View { + Section("Attachments") { + ForEach(entry.attachments) { att in + Label(att.name, systemImage: "paperclip") + } + } + } +} + +private struct FieldRow: View { + let label: String + let value: String + var isCopyable: Bool = false + var isProtected: Bool = false + @State private var revealed = false + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.caption).foregroundStyle(.secondary) + Text(isProtected && !revealed ? "••••••••" : value) + .font(.body) + } + Spacer() + if isProtected { + Button { revealed.toggle() } label: { + Image(systemName: revealed ? "eye.slash" : "eye") + }.buttonStyle(.plain) + } + if isCopyable { + Button { ClipboardService.copy(value) } label: { + Image(systemName: "doc.on.doc") + }.buttonStyle(.plain) + } + } + } +} + +private struct TOTPRow: View { + let config: TOTPConfig + @State private var code: String = "" + @State private var secondsLeft: Int = 30 + let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("TOTP").font(.caption).foregroundStyle(.secondary) + Text(formattedCode).font(.title3.monospaced()).bold() + } + Spacer() + ZStack { + Circle() + .stroke(Color.secondary.opacity(0.2), lineWidth: 3) + Circle() + .trim(from: 0, to: CGFloat(secondsLeft) / CGFloat(config.period)) + .stroke(secondsLeft > 10 ? Color.green : Color.orange, lineWidth: 3) + .rotationEffect(.degrees(-90)) + .animation(.linear(duration: 1), value: secondsLeft) + Text("\(secondsLeft)").font(.caption2) + } + .frame(width: 32, height: 32) + Button { ClipboardService.copy(code) } label: { + Image(systemName: "doc.on.doc") + }.buttonStyle(.plain) + } + .onReceive(timer) { _ in refresh() } + .onAppear { refresh() } + } + + private var formattedCode: String { + guard code.count == 6 else { return code } + return String(code.prefix(3)) + " " + String(code.suffix(3)) + } + + private func refresh() { + code = TOTPGenerator.generate(config: config) + secondsLeft = TOTPGenerator.secondsRemaining(config: config) + } +}