Skip to content

Commit d1f4091

Browse files
authored
Merge pull request #374 from loopandlearn/contact-improvements
Contact improvements
2 parents 6815cf7 + d98af8f commit d1f4091

File tree

10 files changed

+256
-97
lines changed

10 files changed

+256
-97
lines changed

LoopFollow.xcodeproj/project.pbxproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@
8888
DD9ED0CA2D355257000D2A63 /* LogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ED0C92D355256000D2A63 /* LogView.swift */; };
8989
DD9ED0CC2D35526E000D2A63 /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ED0CB2D35526E000D2A63 /* SearchBar.swift */; };
9090
DD9ED0CE2D35587A000D2A63 /* LogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ED0CD2D355879000D2A63 /* LogEntry.swift */; };
91+
DDA9ACA82D6A66E200E6F1A9 /* ContactColorOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9ACA72D6A66DD00E6F1A9 /* ContactColorOption.swift */; };
92+
DDA9ACAA2D6A6B8300E6F1A9 /* ContactIncludeOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9ACA92D6A6B8200E6F1A9 /* ContactIncludeOption.swift */; };
93+
DDA9ACAC2D6B317100E6F1A9 /* ContactType.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9ACAB2D6B316F00E6F1A9 /* ContactType.swift */; };
9194
DDAD162F2D2EF9830084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAD162E2D2EF97C0084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift */; };
9295
DDB0AF522BB1A8BE00AFA48B /* BuildDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB0AF512BB1A8BE00AFA48B /* BuildDetails.swift */; };
9396
DDB0AF552BB1B24A00AFA48B /* BuildDetails.plist in Resources */ = {isa = PBXBuildFile; fileRef = DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */; };
@@ -362,6 +365,9 @@
362365
DD9ED0C92D355256000D2A63 /* LogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogView.swift; sourceTree = "<group>"; };
363366
DD9ED0CB2D35526E000D2A63 /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = "<group>"; };
364367
DD9ED0CD2D355879000D2A63 /* LogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogEntry.swift; sourceTree = "<group>"; };
368+
DDA9ACA72D6A66DD00E6F1A9 /* ContactColorOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactColorOption.swift; sourceTree = "<group>"; };
369+
DDA9ACA92D6A6B8200E6F1A9 /* ContactIncludeOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactIncludeOption.swift; sourceTree = "<group>"; };
370+
DDA9ACAB2D6B316F00E6F1A9 /* ContactType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactType.swift; sourceTree = "<group>"; };
365371
DDAD162E2D2EF97C0084BE10 /* RileyLinkHeartbeatBluetoothDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RileyLinkHeartbeatBluetoothDevice.swift; sourceTree = "<group>"; };
366372
DDB0AF502BB1A84500AFA48B /* capture-build-details.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = "capture-build-details.sh"; sourceTree = "<group>"; };
367373
DDB0AF512BB1A8BE00AFA48B /* BuildDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildDetails.swift; sourceTree = "<group>"; };
@@ -706,6 +712,9 @@
706712
DD50C74D2D0828250057AE6F /* Contact */ = {
707713
isa = PBXGroup;
708714
children = (
715+
DDA9ACAB2D6B316F00E6F1A9 /* ContactType.swift */,
716+
DDA9ACA92D6A6B8200E6F1A9 /* ContactIncludeOption.swift */,
717+
DDA9ACA72D6A66DD00E6F1A9 /* ContactColorOption.swift */,
709718
DD50C7512D0828B40057AE6F /* Settings */,
710719
DD50C7542D0862770057AE6F /* ContactImageUpdater.swift */,
711720
);
@@ -1437,6 +1446,7 @@
14371446
FC16A97B249966A3003D6245 /* AlarmSound.swift in Sources */,
14381447
DDBE3ABD2CB5A961006B37DC /* OverrideView.swift in Sources */,
14391448
DDB0AF522BB1A8BE00AFA48B /* BuildDetails.swift in Sources */,
1449+
DDA9ACAA2D6A6B8300E6F1A9 /* ContactIncludeOption.swift in Sources */,
14401450
DD0C0C622C4175FD00DBADDF /* NSProfile.swift in Sources */,
14411451
DD58171E2D299FCA0041FB98 /* BluetoothDeviceDelegate.swift in Sources */,
14421452
DDE69ED22C7256260013EAEC /* RemoteType.swift in Sources */,
@@ -1456,6 +1466,7 @@
14561466
FCFEECA02488157B00402A7F /* Chart.swift in Sources */,
14571467
DDCF979424C0D380002C9752 /* UIViewExtension.swift in Sources */,
14581468
DD0C0C6D2C48606200DBADDF /* CarbMetric.swift in Sources */,
1469+
DDA9ACA82D6A66E200E6F1A9 /* ContactColorOption.swift in Sources */,
14591470
DD7E19882ACDA5DA00DBD158 /* Notes.swift in Sources */,
14601471
FCEF87AC24A141A700AE6FA0 /* Localizer.swift in Sources */,
14611472
FC1BDD3224A2585C001B652C /* DataStructs.swift in Sources */,
@@ -1518,6 +1529,7 @@
15181529
DD4878032C7B297E0048F05C /* StorageValue.swift in Sources */,
15191530
DD4878192C7C56D60048F05C /* TrioNightscoutRemoteController.swift in Sources */,
15201531
FC1BDD2B24A22650001B652C /* Stats.swift in Sources */,
1532+
DDA9ACAC2D6B317100E6F1A9 /* ContactType.swift in Sources */,
15211533
DDDF6F432D479A9900884336 /* LoopNightscoutRemoteView.swift in Sources */,
15221534
DDD10F052C529DA200D76A8E /* ObservableValue.swift in Sources */,
15231535
FC1BDD2D24A23204001B652C /* StatsView.swift in Sources */,
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//
2+
// ContactColorOption.swift
3+
// LoopFollow
4+
//
5+
// Created by Jonas Björkert on 2025-02-22.
6+
// Copyright © 2025 Jon Fawcett. All rights reserved.
7+
//
8+
9+
import UIKit
10+
11+
enum ContactColorOption: String, CaseIterable {
12+
case red, blue, cyan, green, yellow, orange, purple, white, black
13+
14+
var uiColor: UIColor {
15+
switch self {
16+
case .red: return .red
17+
case .blue: return .blue
18+
case .cyan: return .cyan
19+
case .green: return .green
20+
case .yellow: return .yellow
21+
case .orange: return .orange
22+
case .purple: return .purple
23+
case .white: return .white
24+
case .black: return .black
25+
}
26+
}
27+
}

LoopFollow/Contact/ContactImageUpdater.swift

Lines changed: 109 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -14,86 +14,140 @@ class ContactImageUpdater {
1414
private let contactStore = CNContactStore()
1515
private let queue = DispatchQueue(label: "ContactImageUpdaterQueue")
1616

17-
func updateContactImage(bgValue: String, extra: String, stale: Bool) {
17+
private var savedBackgroundUIColor: UIColor {
18+
let rawValue = Storage.shared.contactBackgroundColor.value
19+
return ContactColorOption(rawValue: rawValue)?.uiColor ?? .black
20+
}
21+
22+
private var savedTextUIColor: UIColor {
23+
let rawValue = Storage.shared.contactTextColor.value
24+
return ContactColorOption(rawValue: rawValue)?.uiColor ?? .white
25+
}
26+
27+
func updateContactImage(bgValue: String, trend: String, delta: String, stale: Bool) {
1828
queue.async {
1929
guard CNContactStore.authorizationStatus(for: .contacts) == .authorized else {
2030
LogManager.shared.log(category: .contact, message: "Access to contacts is not authorized.")
2131
return
2232
}
2333

24-
guard let imageData = self.generateContactImage(bgValue: bgValue, extra: extra, stale: stale)?.pngData() else {
25-
LogManager.shared.log(category: .contact, message: "Failed to generate contact image.")
26-
return
27-
}
28-
2934
let bundleDisplayName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow"
30-
let contactName = "\(bundleDisplayName) - BG"
31-
let predicate = CNContact.predicateForContacts(matchingName: contactName)
32-
let keysToFetch = [CNContactGivenNameKey, CNContactFamilyNameKey, CNContactImageDataKey] as [CNKeyDescriptor]
33-
34-
do {
35-
let contacts = try self.contactStore.unifiedContacts(matching: predicate, keysToFetch: keysToFetch)
36-
37-
if let contact = contacts.first, let mutableContact = contact.mutableCopy() as? CNMutableContact {
38-
mutableContact.imageData = imageData
39-
let saveRequest = CNSaveRequest()
40-
saveRequest.update(mutableContact)
41-
try self.contactStore.execute(saveRequest)
42-
LogManager.shared.log(category: .contact, message: "Contact image updated", isDebug: true)
43-
} else {
44-
let newContact = CNMutableContact()
45-
newContact.givenName = contactName
46-
newContact.imageData = imageData
47-
let saveRequest = CNSaveRequest()
48-
saveRequest.add(newContact, toContainerWithIdentifier: nil)
49-
try self.contactStore.execute(saveRequest)
50-
LogManager.shared.log(category: .contact, message: "New contact created")
35+
36+
for contactType in ContactType.allCases {
37+
if contactType == .Delta && Storage.shared.contactDelta.value != .separate {
38+
continue
39+
}
40+
41+
if contactType == .Trend && Storage.shared.contactTrend.value != .separate {
42+
continue
43+
}
44+
45+
let contactName = "\(bundleDisplayName) - \(contactType.rawValue)"
46+
47+
guard let imageData = self.generateContactImage(bgValue: bgValue, trend: trend, delta: delta, stale: stale, contactType: contactType)?.pngData() else {
48+
LogManager.shared.log(category: .contact, message: "Failed to generate contact image for \(contactName).")
49+
continue
50+
}
51+
52+
let predicate = CNContact.predicateForContacts(matchingName: contactName)
53+
let keysToFetch = [CNContactGivenNameKey, CNContactFamilyNameKey, CNContactImageDataKey] as [CNKeyDescriptor]
54+
55+
do {
56+
let contacts = try self.contactStore.unifiedContacts(matching: predicate, keysToFetch: keysToFetch)
57+
58+
if let contact = contacts.first, let mutableContact = contact.mutableCopy() as? CNMutableContact {
59+
mutableContact.imageData = imageData
60+
let saveRequest = CNSaveRequest()
61+
saveRequest.update(mutableContact)
62+
try self.contactStore.execute(saveRequest)
63+
print("Contact image updated successfully for \(contactName).")
64+
} else {
65+
let newContact = CNMutableContact()
66+
newContact.givenName = contactName
67+
newContact.imageData = imageData
68+
let saveRequest = CNSaveRequest()
69+
saveRequest.add(newContact, toContainerWithIdentifier: nil)
70+
try self.contactStore.execute(saveRequest)
71+
print("New contact created with updated image for \(contactName).")
72+
}
73+
} catch {
74+
LogManager.shared.log(category: .contact, message: "Failed to update or create contact for \(contactName): \(error)")
5175
}
52-
} catch {
53-
LogManager.shared.log(category: .contact, message: "Failed to update or create contact: \(error)")
5476
}
5577
}
5678
}
5779

58-
private func generateContactImage(bgValue: String, extra: String, stale: Bool) -> UIImage? {
80+
private func generateContactImage(bgValue: String, trend: String, delta: String, stale: Bool, contactType: ContactType) -> UIImage? {
5981
let size = CGSize(width: 300, height: 300)
6082
UIGraphicsBeginImageContextWithOptions(size, false, 0)
6183
guard let context = UIGraphicsGetCurrentContext() else { return nil }
6284

63-
UIColor.black.setFill()
85+
savedBackgroundUIColor.setFill()
6486
context.fill(CGRect(origin: .zero, size: size))
6587

6688
let paragraphStyle = NSMutableParagraphStyle()
6789
paragraphStyle.alignment = .center
6890

69-
let maxFontSize: CGFloat = extra.isEmpty ? 200 : 160
70-
let fontSize = maxFontSize - CGFloat(bgValue.count * 15)
71-
72-
var bgAttributes: [NSAttributedString.Key: Any] = [
73-
.font: UIFont.boldSystemFont(ofSize: fontSize),
74-
.foregroundColor: stale ? UIColor.gray : UIColor.white,
75-
.paragraphStyle: paragraphStyle
76-
]
77-
78-
if stale {
79-
bgAttributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
80-
}
91+
// Format extraDelta based on the user's unit preference
92+
let unitPreference = UserDefaultsRepository.units.value
93+
let yOffset: CGFloat = 48
94+
if contactType == .Trend && Storage.shared.contactTrend.value == .separate {
95+
let trendRect = CGRect(x: 0, y: 46, width: size.width, height: size.height - 80)
96+
let trendFontSize = max(40, 200 - CGFloat(trend.count * 15))
97+
98+
let trendAttributes: [NSAttributedString.Key: Any] = [
99+
.font: UIFont.boldSystemFont(ofSize: trendFontSize),
100+
.foregroundColor: stale ? UIColor.gray : savedTextUIColor,
101+
.paragraphStyle: paragraphStyle
102+
]
103+
104+
trend.draw(in: trendRect, withAttributes: trendAttributes)
105+
} else if contactType == .Delta && Storage.shared.contactDelta.value == .separate {
106+
let deltaRect = CGRect(x: 0, y: yOffset, width: size.width, height: size.height - 80)
107+
let deltaFontSize = max(40, 200 - CGFloat(delta.count * 15))
108+
109+
let deltaAttributes: [NSAttributedString.Key: Any] = [
110+
.font: UIFont.boldSystemFont(ofSize: deltaFontSize),
111+
.foregroundColor: stale ? UIColor.gray : savedTextUIColor,
112+
.paragraphStyle: paragraphStyle
113+
]
114+
115+
delta.draw(in: deltaRect, withAttributes: deltaAttributes)
116+
} else if contactType == .BG {
117+
let includesExtra = Storage.shared.contactDelta.value == .include || Storage.shared.contactTrend.value == .include
118+
119+
let maxFontSize: CGFloat = includesExtra ? 160 : 200
120+
let fontSize = maxFontSize - CGFloat(bgValue.count * 15)
121+
var bgAttributes: [NSAttributedString.Key: Any] = [
122+
.font: UIFont.boldSystemFont(ofSize: fontSize),
123+
.foregroundColor: stale ? UIColor.gray : savedTextUIColor,
124+
.paragraphStyle: paragraphStyle
125+
]
126+
127+
if stale {
128+
// Force background color back to black if stale
129+
UIColor.black.setFill()
130+
context.fill(CGRect(origin: .zero, size: size))
131+
bgAttributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
132+
}
81133

82-
let extraAttributes: [NSAttributedString.Key: Any] = [
83-
.font: UIFont.systemFont(ofSize: 90),
84-
.foregroundColor: UIColor.white,
85-
.paragraphStyle: paragraphStyle
86-
]
134+
let bgRect: CGRect = includesExtra
135+
? CGRect(x: 0, y: yOffset - 20, width: size.width, height: size.height / 2)
136+
: CGRect(x: 0, y: yOffset, width: size.width, height: size.height - 80)
87137

88-
let bgRect = extra.isEmpty
89-
? CGRect(x: 0, y: 46, width: size.width, height: size.height - 80)
90-
: CGRect(x: 0, y: 26, width: size.width, height: size.height / 2)
138+
bgValue.draw(in: bgRect, withAttributes: bgAttributes)
91139

92-
bgValue.draw(in: bgRect, withAttributes: bgAttributes)
140+
if includesExtra {
141+
let extraRect = CGRect(x: 0, y: size.height / 2 + 6, width: size.width, height: size.height / 2 - 20)
142+
let extraAttributes: [NSAttributedString.Key: Any] = [
143+
.font: UIFont.systemFont(ofSize: 90),
144+
.foregroundColor: stale ? UIColor.gray : savedTextUIColor,
145+
.paragraphStyle: paragraphStyle
146+
]
93147

94-
if !extra.isEmpty {
95-
let extraRect = CGRect(x: 0, y: size.height / 2 + 6, width: size.width, height: size.height / 2 - 20)
96-
extra.draw(in: extraRect, withAttributes: extraAttributes)
148+
let extra = Storage.shared.contactDelta.value == .include ? delta : trend
149+
extra.draw(in: extraRect, withAttributes: extraAttributes)
150+
}
97151
}
98152

99153
let image = UIGraphicsGetImageFromCurrentImageContext()
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//
2+
// ContactIncludeOption.swift
3+
// LoopFollow
4+
//
5+
// Created by Jonas Björkert on 2025-02-22.
6+
// Copyright © 2025 Jon Fawcett. All rights reserved.
7+
//
8+
9+
enum ContactIncludeOption: String, Codable, Equatable, CaseIterable {
10+
case off = "Off"
11+
case include = "Include"
12+
case separate = "Separate"
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//
2+
// ContactSuffix.swift
3+
// LoopFollow
4+
//
5+
// Created by Jonas Björkert on 2025-02-23.
6+
// Copyright © 2025 Jon Fawcett. All rights reserved.
7+
//
8+
9+
enum ContactType: String, CaseIterable {
10+
case BG = "BG"
11+
case Trend = "Trend"
12+
case Delta = "Delta"
13+
}

LoopFollow/Contact/Settings/ContactSettingsView.swift

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,22 +36,48 @@ struct ContactSettingsView: View {
3636
}
3737

3838
if viewModel.contactEnabled {
39+
Section(header: Text("Color Options")) {
40+
Text("Select the colors for your BG values. Note: not all watch faces allow control over colors. Recommend options like Activity or Modular Duo if you want to customize colors.")
41+
.font(.footnote)
42+
.foregroundColor(.secondary)
43+
.padding(.vertical, 4)
44+
45+
Picker("Select Background Color", selection: $viewModel.contactBackgroundColor) {
46+
ForEach(ContactColorOption.allCases, id: \.rawValue) { option in
47+
Text(option.rawValue.capitalized).tag(option.rawValue)
48+
}
49+
}
50+
51+
Picker("Select Text Color", selection: $viewModel.contactTextColor) {
52+
ForEach(ContactColorOption.allCases, id: \.rawValue) { option in
53+
Text(option.rawValue.capitalized).tag(option.rawValue)
54+
}
55+
}
56+
}
57+
3958
Section(header: Text("Additional Information")) {
40-
Toggle("Show Trend", isOn: $viewModel.contactTrend)
41-
.toggleStyle(SwitchToggleStyle())
42-
.onChange(of: viewModel.contactTrend) { isTrendEnabled in
43-
if isTrendEnabled {
44-
viewModel.contactDelta = false
45-
}
59+
Text("To see your trend or delta, include one in the original '\(viewModel.contactName)' contact, or create separate contacts ending in '- Trend' and '- Delta' for up to three contacts on your watch face.")
60+
.font(.footnote)
61+
.foregroundColor(.secondary)
62+
.padding(.vertical, 4)
63+
64+
Text("Show Trend")
65+
.font(.subheadline)
66+
Picker("Show Trend", selection: $viewModel.contactTrend) {
67+
ForEach(ContactIncludeOption.allCases, id: \.self) { option in
68+
Text(option.rawValue).tag(option)
4669
}
70+
}
71+
.pickerStyle(SegmentedPickerStyle())
4772

48-
Toggle("Show Delta", isOn: $viewModel.contactDelta)
49-
.toggleStyle(SwitchToggleStyle())
50-
.onChange(of: viewModel.contactDelta) { isDeltaEnabled in
51-
if isDeltaEnabled {
52-
viewModel.contactTrend = false
53-
}
73+
Text("Show Delta")
74+
.font(.subheadline)
75+
Picker("Show Delta", selection: $viewModel.contactDelta) {
76+
ForEach(ContactIncludeOption.allCases, id: \.self) { option in
77+
Text(option.rawValue).tag(option)
5478
}
79+
}
80+
.pickerStyle(SegmentedPickerStyle())
5581
}
5682
}
5783
}

0 commit comments

Comments
 (0)