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;
INFOPLIST_FILE = BusinessCard/Info.plist;
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_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@ -568,7 +568,7 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = BusinessCard/Info.plist;
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_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;

View File

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

View File

@ -98,6 +98,7 @@ struct BusinessCardApp: App {
WindowGroup {
RootTabView()
.environment(appState)
.preferredColorScheme(.light)
}
.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">
<plist version="1.0">
<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>
<array>
<string>remote-notification</string>

View File

@ -21,7 +21,19 @@ enum CardTheme: String, CaseIterable, Identifiable, Hashable, Sendable {
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) {
switch self {
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 {
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 {
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" : {
"extractionState" : "stale",
@ -1553,9 +1550,6 @@
},
"This person will appear in your Contacts tab so you can track who has your card." : {
},
"Track this share" : {
},
"Track who receives your card" : {
"extractionState" : "stale",

View File

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

View File

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

View File

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

View File

@ -2,11 +2,11 @@ import SwiftUI
import Bedrock
/// 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 {
let systemImage: String
let text: String
var inverted: Bool = true
var textColor: Color = Color.Text.inverted
var body: some View {
HStack(spacing: Design.Spacing.xSmall) {
@ -19,17 +19,13 @@ struct IconRowView: View {
.lineLimit(1)
}
}
private var textColor: Color {
inverted ? Color.Text.inverted : Color.Text.primary
}
}
#Preview {
VStack(alignment: .leading, spacing: Design.Spacing.small) {
IconRowView(systemImage: "envelope", text: "hello@example.com")
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()
.background(Color.CardPalette.midnight)

View File

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

View File

@ -81,6 +81,8 @@ struct ShareCardView: View {
private struct QRCodeCardView: View {
let card: BusinessCard
private var textColor: Color { card.theme.textColor }
var body: some View {
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")
.font(.subheadline)
.foregroundStyle(Color.Text.secondary)
.foregroundStyle(textColor.opacity(Design.Opacity.strong))
.multilineTextAlignment(.center)
}
.padding(Design.Spacing.large)