Files
keevault/KeeVault/Views/EntryDetailView.swift
T
kriss 8bba64076f feat: copper accent theme, always-expanded group filter, app icon; rename MyPass to KeeVault
- Custom copper AccentColor and warm VaultBackground color assets (light/dark),
  applied to both the main app and the AutoFill extension (which needed its own
  copy of the asset catalog plus ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME
  and an explicit .tint() modifier, since extensions don't pick up the app-wide
  accent color the same way a full SwiftUI App/Scene does).
- GroupFilterView no longer uses DisclosureGroup for subgroups -- the whole tree
  renders always-expanded (indented per depth) so active filters are visible at
  a glance instead of requiring tap-to-expand.
- Added a placeholder app icon (padlock glyph, light/dark/tinted variants plus
  macOS sizes) -- previously the icon slot existed but had no actual images,
  which blocks App Store/TestFlight archive validation.
- Added ITSAppUsesNonExemptEncryption = NO (standard encryption only, via
  KeePassKit) to skip the export-compliance prompt on each upload.
- Renamed the app from MyPass to KeeVault throughout: Xcode project/targets/
  schemes, the MyPassCore package (now KeeVaultCore) and every import site,
  folder and file names, bundle identifiers (org.antiloop222.keevault) and
  their App Group/Keychain-group entitlements, and remaining UI/string
  references -- MyPass was already taken as an App Store app name.
2026-09-20 15:05:19 +02:00

177 lines
5.6 KiB
Swift

//
// EntryDetailView.swift
// KeeVault
//
import SwiftUI
import Combine
import KeeVaultCore
struct EntryDetailView: View {
let entry: Entry
let groupId: UUID
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, groupId: groupId, 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)
}
}