- 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.
177 lines
5.6 KiB
Swift
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)
|
|
}
|
|
}
|