Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-08 18:40:26 -06:00
parent a164669cf2
commit fadb46e9cd
12 changed files with 101 additions and 58 deletions

View File

@ -532,7 +532,7 @@
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BusinessCard/Info.plist; INFOPLIST_FILE = BusinessCard/Info.plist;
INFOPLIST_KEY_NSContactsUsageDescription = "for testing purposes"; INFOPLIST_KEY_NSContactsUsageDescription = "for testing purposes";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = ""; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "BusinessCard uses your photo library to add a profile photo to your business card.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@ -568,7 +568,7 @@
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BusinessCard/Info.plist; INFOPLIST_FILE = BusinessCard/Info.plist;
INFOPLIST_KEY_NSContactsUsageDescription = "for testing purposes"; INFOPLIST_KEY_NSContactsUsageDescription = "for testing purposes";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = ""; INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "BusinessCard uses your photo library to add a profile photo to your business card.";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES;

View File

@ -7,12 +7,12 @@
<key>BusinessCard.xcscheme_^#shared#^_</key> <key>BusinessCard.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>1</integer> <integer>2</integer>
</dict> </dict>
<key>BusinessCardWatch.xcscheme_^#shared#^_</key> <key>BusinessCardWatch.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>0</integer> <integer>1</integer>
</dict> </dict>
</dict> </dict>
</dict> </dict>

View File

@ -98,6 +98,7 @@ struct BusinessCardApp: App {
WindowGroup { WindowGroup {
RootTabView() RootTabView()
.environment(appState) .environment(appState)
.preferredColorScheme(.light)
} }
.modelContainer(modelContainer) .modelContainer(modelContainer)
} }

View File

@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>NSCameraUsageDescription</key>
<string>BusinessCard uses the camera to scan QR codes on other people's business cards.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>BusinessCard uses your photo library to add a profile photo to your business card.</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>remote-notification</string> <string>remote-notification</string>

View File

@ -21,7 +21,19 @@ enum CardTheme: String, CaseIterable, Identifiable, Hashable, Sendable {
static var all: [CardTheme] { allCases } static var all: [CardTheme] { allCases }
// RGB values - nonisolated // MARK: - Theme Brightness
/// Whether this theme requires dark text for proper contrast.
/// Light backgrounds need dark text; dark backgrounds need light text.
var requiresDarkText: Bool {
switch self {
case .lime: return true
case .coral, .midnight, .ocean, .violet: return false
}
}
// MARK: - RGB Values (nonisolated)
private var primaryRGB: (Double, Double, Double) { private var primaryRGB: (Double, Double, Double) {
switch self { switch self {
case .coral: return (0.95, 0.35, 0.33) case .coral: return (0.95, 0.35, 0.33)
@ -52,7 +64,14 @@ enum CardTheme: String, CaseIterable, Identifiable, Hashable, Sendable {
} }
} }
// Colors - computed from RGB private var textRGB: (Double, Double, Double) {
requiresDarkText
? (0.14, 0.14, 0.17) // Dark text for light backgrounds
: (0.98, 0.98, 0.98) // Light text for dark backgrounds
}
// MARK: - Colors (MainActor)
@MainActor var primaryColor: Color { @MainActor var primaryColor: Color {
Color(red: primaryRGB.0, green: primaryRGB.1, blue: primaryRGB.2) Color(red: primaryRGB.0, green: primaryRGB.1, blue: primaryRGB.2)
} }
@ -64,4 +83,9 @@ enum CardTheme: String, CaseIterable, Identifiable, Hashable, Sendable {
@MainActor var accentColor: Color { @MainActor var accentColor: Color {
Color(red: accentRGB.0, green: accentRGB.1, blue: accentRGB.2) Color(red: accentRGB.0, green: accentRGB.1, blue: accentRGB.2)
} }
/// The appropriate text color for content displayed on this theme's background.
@MainActor var textColor: Color {
Color(red: textRGB.0, green: textRGB.1, blue: textRGB.2)
}
} }

View File

@ -1013,9 +1013,6 @@
} }
} }
} }
},
"Record who received your card" : {
}, },
"Reviews" : { "Reviews" : {
"extractionState" : "stale", "extractionState" : "stale",
@ -1553,9 +1550,6 @@
}, },
"This person will appear in your Contacts tab so you can track who has your card." : { "This person will appear in your Contacts tab so you can track who has your card." : {
},
"Track this share" : {
}, },
"Track who receives your card" : { "Track who receives your card" : {
"extractionState" : "stale", "extractionState" : "stale",

View File

@ -47,7 +47,7 @@ private struct StackedCardLayout: View {
VStack(alignment: .leading, spacing: Design.Spacing.small) { VStack(alignment: .leading, spacing: Design.Spacing.small) {
CardHeaderView(card: card) CardHeaderView(card: card)
Divider() Divider()
.overlay(Color.Text.inverted.opacity(Design.Opacity.medium)) .overlay(card.theme.textColor.opacity(Design.Opacity.medium))
CardDetailsView(card: card) CardDetailsView(card: card)
if card.hasSocialLinks { if card.hasSocialLinks {
SocialLinksRow(card: card) SocialLinksRow(card: card)
@ -69,7 +69,7 @@ private struct SplitCardLayout: View {
} }
} }
Spacer(minLength: Design.Spacing.medium) Spacer(minLength: Design.Spacing.medium)
AccentBlockView(color: card.theme.accentColor) AccentBlockView(color: card.theme.accentColor, textColor: card.theme.textColor)
} }
} }
} }
@ -90,7 +90,8 @@ private struct PhotoCardLayout: View {
AvatarBadgeView( AvatarBadgeView(
systemName: card.avatarSystemName, systemName: card.avatarSystemName,
accentColor: card.theme.accentColor, accentColor: card.theme.accentColor,
photoData: card.photoData photoData: card.photoData,
borderColor: card.theme.textColor
) )
} }
} }
@ -100,58 +101,63 @@ private struct PhotoCardLayout: View {
private struct CardHeaderView: View { private struct CardHeaderView: View {
let card: BusinessCard let card: BusinessCard
private var textColor: Color { card.theme.textColor }
var body: some View { var body: some View {
HStack(spacing: Design.Spacing.medium) { HStack(spacing: Design.Spacing.medium) {
AvatarBadgeView( AvatarBadgeView(
systemName: card.avatarSystemName, systemName: card.avatarSystemName,
accentColor: card.theme.accentColor, accentColor: card.theme.accentColor,
photoData: card.photoData photoData: card.photoData,
borderColor: textColor
) )
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
HStack(spacing: Design.Spacing.xSmall) { HStack(spacing: Design.Spacing.xSmall) {
Text(card.displayName) Text(card.displayName)
.font(.headline) .font(.headline)
.bold() .bold()
.foregroundStyle(Color.Text.inverted) .foregroundStyle(textColor)
if !card.pronouns.isEmpty { if !card.pronouns.isEmpty {
Text("(\(card.pronouns))") Text("(\(card.pronouns))")
.font(.caption) .font(.caption)
.foregroundStyle(Color.Text.inverted.opacity(Design.Opacity.strong)) .foregroundStyle(textColor.opacity(Design.Opacity.strong))
} }
} }
Text(card.role) Text(card.role)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Color.Text.inverted.opacity(Design.Opacity.almostFull)) .foregroundStyle(textColor.opacity(Design.Opacity.almostFull))
Text(card.company) Text(card.company)
.font(.caption) .font(.caption)
.foregroundStyle(Color.Text.inverted.opacity(Design.Opacity.medium)) .foregroundStyle(textColor.opacity(Design.Opacity.medium))
} }
Spacer(minLength: Design.Spacing.small) Spacer(minLength: Design.Spacing.small)
LabelBadgeView(label: card.label, accentColor: card.theme.accentColor) LabelBadgeView(label: card.label, accentColor: card.theme.accentColor, textColor: textColor)
} }
} }
} }
private struct CardDetailsView: View { private struct CardDetailsView: View {
let card: BusinessCard let card: BusinessCard
private var textColor: Color { card.theme.textColor }
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
if !card.email.isEmpty { if !card.email.isEmpty {
IconRowView(systemImage: "envelope", text: card.email) IconRowView(systemImage: "envelope", text: card.email, textColor: textColor)
} }
if !card.phone.isEmpty { if !card.phone.isEmpty {
IconRowView(systemImage: "phone", text: card.phone) IconRowView(systemImage: "phone", text: card.phone, textColor: textColor)
} }
if !card.website.isEmpty { if !card.website.isEmpty {
IconRowView(systemImage: "link", text: card.website) IconRowView(systemImage: "link", text: card.website, textColor: textColor)
} }
if !card.bio.isEmpty { if !card.bio.isEmpty {
Text(card.bio) Text(card.bio)
.font(.caption) .font(.caption)
.foregroundStyle(Color.Text.inverted.opacity(Design.Opacity.strong)) .foregroundStyle(textColor.opacity(Design.Opacity.strong))
.lineLimit(2) .lineLimit(2)
.padding(.top, Design.Spacing.xxSmall) .padding(.top, Design.Spacing.xxSmall)
} }
@ -162,25 +168,27 @@ private struct CardDetailsView: View {
private struct SocialLinksRow: View { private struct SocialLinksRow: View {
let card: BusinessCard let card: BusinessCard
private var textColor: Color { card.theme.textColor }
var body: some View { var body: some View {
HStack(spacing: Design.Spacing.small) { HStack(spacing: Design.Spacing.small) {
if !card.linkedIn.isEmpty { if !card.linkedIn.isEmpty {
SocialIconView(systemImage: "link") SocialIconView(systemImage: "link", textColor: textColor)
} }
if !card.twitter.isEmpty { if !card.twitter.isEmpty {
SocialIconView(systemImage: "at") SocialIconView(systemImage: "at", textColor: textColor)
} }
if !card.instagram.isEmpty { if !card.instagram.isEmpty {
SocialIconView(systemImage: "camera") SocialIconView(systemImage: "camera", textColor: textColor)
} }
if !card.facebook.isEmpty { if !card.facebook.isEmpty {
SocialIconView(systemImage: "person.2") SocialIconView(systemImage: "person.2", textColor: textColor)
} }
if !card.tiktok.isEmpty { if !card.tiktok.isEmpty {
SocialIconView(systemImage: "play.rectangle") SocialIconView(systemImage: "play.rectangle", textColor: textColor)
} }
if !card.github.isEmpty { if !card.github.isEmpty {
SocialIconView(systemImage: "chevron.left.forwardslash.chevron.right") SocialIconView(systemImage: "chevron.left.forwardslash.chevron.right", textColor: textColor)
} }
} }
.padding(.top, Design.Spacing.xxSmall) .padding(.top, Design.Spacing.xxSmall)
@ -191,19 +199,21 @@ private struct SocialLinksRow: View {
private struct SocialIconView: View { private struct SocialIconView: View {
let systemImage: String let systemImage: String
var textColor: Color = Color.Text.inverted
var body: some View { var body: some View {
Image(systemName: systemImage) Image(systemName: systemImage)
.font(.caption2) .font(.caption2)
.foregroundStyle(Color.Text.inverted.opacity(Design.Opacity.strong)) .foregroundStyle(textColor.opacity(Design.Opacity.strong))
.frame(width: Design.Spacing.xLarge, height: Design.Spacing.xLarge) .frame(width: Design.Spacing.xLarge, height: Design.Spacing.xLarge)
.background(Color.Text.inverted.opacity(Design.Opacity.hint)) .background(textColor.opacity(Design.Opacity.hint))
.clipShape(.circle) .clipShape(.circle)
} }
} }
private struct AccentBlockView: View { private struct AccentBlockView: View {
let color: Color let color: Color
var textColor: Color = Color.Text.inverted
var body: some View { var body: some View {
RoundedRectangle(cornerRadius: Design.CornerRadius.medium) RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
@ -211,7 +221,7 @@ private struct AccentBlockView: View {
.frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize) .frame(width: Design.CardSize.avatarSize, height: Design.CardSize.avatarSize)
.overlay( .overlay(
Image(systemName: "bolt.fill") Image(systemName: "bolt.fill")
.foregroundStyle(Color.Text.inverted) .foregroundStyle(textColor)
) )
} }
} }

View File

@ -294,19 +294,23 @@ private struct PhotoPickerRow: View {
let avatarSystemName: String let avatarSystemName: String
var body: some View { var body: some View {
let hasPhoto = photoData != nil
let labelText = hasPhoto ? String.localized("Change Photo") : String.localized("Add Photo")
let accentColor = Color.Accent.red
HStack(spacing: Design.Spacing.medium) { HStack(spacing: Design.Spacing.medium) {
AvatarBadgeView( AvatarBadgeView(
systemName: avatarSystemName, systemName: avatarSystemName,
accentColor: Color.Accent.red, accentColor: accentColor,
photoData: photoData photoData: photoData
) )
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
PhotosPicker(selection: $selectedPhoto, matching: .images) { PhotosPicker(selection: $selectedPhoto, matching: .images) {
Text(photoData == nil ? String.localized("Add Photo") : String.localized("Change Photo")) Text(labelText)
.foregroundStyle(Color.Accent.red) .foregroundStyle(accentColor)
} }
if photoData != nil { if hasPhoto {
Button(String.localized("Remove Photo"), role: .destructive) { Button(String.localized("Remove Photo"), role: .destructive) {
photoData = nil photoData = nil
selectedPhoto = nil selectedPhoto = nil
@ -349,21 +353,24 @@ private struct EditorCardPreview: View {
let theme: CardTheme let theme: CardTheme
let photoData: Data? let photoData: Data?
private var textColor: Color { theme.textColor }
var body: some View { var body: some View {
VStack(spacing: Design.Spacing.medium) { VStack(spacing: Design.Spacing.medium) {
HStack(spacing: Design.Spacing.medium) { HStack(spacing: Design.Spacing.medium) {
AvatarBadgeView( AvatarBadgeView(
systemName: avatarSystemName, systemName: avatarSystemName,
accentColor: theme.accentColor, accentColor: theme.accentColor,
photoData: photoData photoData: photoData,
borderColor: textColor
) )
VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) {
Text(displayName).font(.headline).bold().foregroundStyle(Color.Text.inverted) Text(displayName).font(.headline).bold().foregroundStyle(textColor)
Text(role).font(.subheadline).foregroundStyle(Color.Text.inverted.opacity(Design.Opacity.almostFull)) Text(role).font(.subheadline).foregroundStyle(textColor.opacity(Design.Opacity.almostFull))
Text(company).font(.caption).foregroundStyle(Color.Text.inverted.opacity(Design.Opacity.medium)) Text(company).font(.caption).foregroundStyle(textColor.opacity(Design.Opacity.medium))
} }
Spacer(minLength: Design.Spacing.small) Spacer(minLength: Design.Spacing.small)
LabelBadgeView(label: label, accentColor: theme.accentColor) LabelBadgeView(label: label, accentColor: theme.accentColor, textColor: textColor)
} }
} }
.padding(Design.Spacing.large) .padding(Design.Spacing.large)

View File

@ -8,17 +8,20 @@ struct AvatarBadgeView: View {
let accentColor: Color let accentColor: Color
let photoData: Data? let photoData: Data?
let size: CGFloat let size: CGFloat
let borderColor: Color
init( init(
systemName: String = "person.crop.circle", systemName: String = "person.crop.circle",
accentColor: Color = Color.Accent.red, accentColor: Color = Color.Accent.red,
photoData: Data? = nil, photoData: Data? = nil,
size: CGFloat = Design.CardSize.avatarSize size: CGFloat = Design.CardSize.avatarSize,
borderColor: Color = Color.Text.inverted
) { ) {
self.systemName = systemName self.systemName = systemName
self.accentColor = accentColor self.accentColor = accentColor
self.photoData = photoData self.photoData = photoData
self.size = size self.size = size
self.borderColor = borderColor
} }
var body: some View { var body: some View {
@ -30,11 +33,11 @@ struct AvatarBadgeView: View {
.clipShape(.circle) .clipShape(.circle)
.overlay( .overlay(
Circle() Circle()
.stroke(Color.Text.inverted.opacity(Design.Opacity.medium), lineWidth: Design.LineWidth.thin) .stroke(borderColor.opacity(Design.Opacity.medium), lineWidth: Design.LineWidth.thin)
) )
} else { } else {
Circle() Circle()
.fill(Color.Text.inverted) .fill(borderColor)
.frame(width: size, height: size) .frame(width: size, height: size)
.overlay( .overlay(
Image(systemName: systemName) Image(systemName: systemName)
@ -42,7 +45,7 @@ struct AvatarBadgeView: View {
) )
.overlay( .overlay(
Circle() Circle()
.stroke(Color.Text.inverted.opacity(Design.Opacity.medium), lineWidth: Design.LineWidth.thin) .stroke(borderColor.opacity(Design.Opacity.medium), lineWidth: Design.LineWidth.thin)
) )
} }
} }
@ -58,7 +61,8 @@ struct AvatarBadgeView: View {
AvatarBadgeView( AvatarBadgeView(
systemName: "briefcase.fill", systemName: "briefcase.fill",
accentColor: Color.CardPalette.ocean, accentColor: Color.CardPalette.ocean,
size: 80 size: 80,
borderColor: Color.Text.primary
) )
} }
.padding() .padding()

View File

@ -2,11 +2,11 @@ import SwiftUI
import Bedrock import Bedrock
/// A row with an icon and text, used for contact info display. /// A row with an icon and text, used for contact info display.
/// Supports both inverted (on dark) and standard (on light) styles. /// Accepts a text color for flexibility with different background themes.
struct IconRowView: View { struct IconRowView: View {
let systemImage: String let systemImage: String
let text: String let text: String
var inverted: Bool = true var textColor: Color = Color.Text.inverted
var body: some View { var body: some View {
HStack(spacing: Design.Spacing.xSmall) { HStack(spacing: Design.Spacing.xSmall) {
@ -19,17 +19,13 @@ struct IconRowView: View {
.lineLimit(1) .lineLimit(1)
} }
} }
private var textColor: Color {
inverted ? Color.Text.inverted : Color.Text.primary
}
} }
#Preview { #Preview {
VStack(alignment: .leading, spacing: Design.Spacing.small) { VStack(alignment: .leading, spacing: Design.Spacing.small) {
IconRowView(systemImage: "envelope", text: "hello@example.com") IconRowView(systemImage: "envelope", text: "hello@example.com")
IconRowView(systemImage: "phone", text: "+1 555 123 4567") IconRowView(systemImage: "phone", text: "+1 555 123 4567")
IconRowView(systemImage: "link", text: "example.com", inverted: false) IconRowView(systemImage: "link", text: "example.com", textColor: Color.Text.primary)
} }
.padding() .padding()
.background(Color.CardPalette.midnight) .background(Color.CardPalette.midnight)

View File

@ -5,12 +5,13 @@ import Bedrock
struct LabelBadgeView: View { struct LabelBadgeView: View {
let label: String let label: String
let accentColor: Color let accentColor: Color
var textColor: Color = Color.Text.inverted
var body: some View { var body: some View {
Text(String.localized(label)) Text(String.localized(label))
.font(.caption) .font(.caption)
.bold() .bold()
.foregroundStyle(Color.Text.inverted) .foregroundStyle(textColor)
.padding(.horizontal, Design.Spacing.small) .padding(.horizontal, Design.Spacing.small)
.padding(.vertical, Design.Spacing.xxSmall) .padding(.vertical, Design.Spacing.xxSmall)
.background(accentColor.opacity(Design.Opacity.medium)) .background(accentColor.opacity(Design.Opacity.medium))
@ -21,7 +22,7 @@ struct LabelBadgeView: View {
#Preview { #Preview {
HStack { HStack {
LabelBadgeView(label: "Work", accentColor: Color.CardPalette.coral) LabelBadgeView(label: "Work", accentColor: Color.CardPalette.coral)
LabelBadgeView(label: "Personal", accentColor: Color.CardPalette.ocean) LabelBadgeView(label: "Personal", accentColor: Color.CardPalette.ocean, textColor: Color.Text.primary)
} }
.padding() .padding()
.background(Color.CardPalette.midnight) .background(Color.CardPalette.midnight)

View File

@ -81,6 +81,8 @@ struct ShareCardView: View {
private struct QRCodeCardView: View { private struct QRCodeCardView: View {
let card: BusinessCard let card: BusinessCard
private var textColor: Color { card.theme.textColor }
var body: some View { var body: some View {
VStack(spacing: Design.Spacing.medium) { VStack(spacing: Design.Spacing.medium) {
@ -92,7 +94,7 @@ private struct QRCodeCardView: View {
Text("Point your camera at the QR code to receive the card") Text("Point your camera at the QR code to receive the card")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(Color.Text.secondary) .foregroundStyle(textColor.opacity(Design.Opacity.strong))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
.padding(Design.Spacing.large) .padding(Design.Spacing.large)