janky notifications

This commit is contained in:
2025-10-21 00:42:35 +02:00
parent fc633d7c90
commit 12e8a0e098
19 changed files with 1012 additions and 2835 deletions

View File

@@ -6,30 +6,6 @@
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "cardboy-icon-dark.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"filename" : "cardboy-icon-tinted.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 41 KiB

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -13,6 +13,40 @@
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs6">
<linearGradient
id="swatch19"
inkscape:swatch="solid">
<stop
style="stop-color:#cccccc;stop-opacity:1;"
offset="0"
id="stop20" />
</linearGradient>
<linearGradient
id="swatch15">
<stop
style="stop-color:#cccccc;stop-opacity:1;"
offset="0.52188009"
id="stop15" />
</linearGradient>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath5">
<rect
style="fill:#000000;stroke:#000000;stroke-width:0;stroke-linejoin:bevel;paint-order:stroke markers fill;stop-color:#000000"
id="rect5-4"
width="20"
height="200"
x="530"
y="595" />
</clipPath>
<linearGradient
id="swatch4"
inkscape:swatch="solid">
<stop
style="stop-color:#cccccc;stop-opacity:1;"
offset="0"
id="stop4" />
</linearGradient>
<clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath61">
@@ -1247,109 +1281,109 @@
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.92030791"
inkscape:cx="606.86211"
inkscape:cy="530.80061"
inkscape:window-width="1728"
inkscape:window-height="1186"
inkscape:window-x="832"
inkscape:window-y="99"
inkscape:window-maximized="0"
inkscape:zoom="0.76316607"
inkscape:cx="429.13333"
inkscape:cy="386.54758"
inkscape:window-width="2560"
inkscape:window-height="1381"
inkscape:window-x="0"
inkscape:window-y="31"
inkscape:window-maximized="1"
inkscape:current-layer="svg6" />
<path
id="path222"
clip-path="url(#clipPath223)"
style="fill:#e6e6e6;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
style="fill:#cccccc;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(0,0.18386638,0.18386638,0,-502.11504,-1071.3194)"
d="m 9421.5,3980 1327.75,0.5 c 45.813,0.5 82.75,37.4365 82.75,82.75 v 2905 c 0,45.8135 -36.937,82.75 -82.75,82.75 H 9140.5 l -2667.25,2e-4 c -45.3134,3e-4 -82.2499,-36.9362 -82.75,-82.75 v -2905 c 0.5001,-45.3132 37.4366,-82.2497 82.75,-82.75 z"
sodipodi:nodetypes="ccccccccccc" />
<path
id="path104"
d="m 9098,4379 h 264 v 233 l -132,132 -132,-132 v -233"
style="fill:#f9f9f9;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
transform="matrix(0,0.22984236,0.22984236,0,-721.48942,-1495.6777)"
clip-path="url(#clipPath105)" />
<path
id="path106"
d="m 9622,4904 h -232 l -133,-133 133,-132 h 232 v 265"
style="fill:#f9f9f9;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
transform="matrix(0,0.22984236,0.22984236,0,-721.48942,-1495.6777)"
clip-path="url(#clipPath107)" />
<path
id="path108"
d="m 10006,5513 v -335 h 264 v 335 h -264"
style="fill:#f9f9f9;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
transform="matrix(0,0.22983461,0.22983461,0,-755.97458,-1537.3453)"
clip-path="url(#clipPath109)" />
<path
id="path110"
d="m 9457,6189 h -312 v -265 h 312 v 265"
style="fill:#f9f9f9;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
transform="matrix(0,0.22979634,0.22979634,0,-788.97058,-1495.2529)"
clip-path="url(#clipPath111)" />
<path
id="path114"
d="m 9003,6567 v -265 h 312 v 265 h -312"
style="fill:#f9f9f9;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
transform="matrix(0,0.22979634,0.22979634,0,-788.97058,-1495.2529)"
clip-path="url(#clipPath115)" />
<path
id="path116"
d="m 10006,5867 v -335 h 264 v 335 h -264"
style="fill:#f9f9f9;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
transform="matrix(0,0.22983461,0.22983461,0,-755.97458,-1537.3453)"
clip-path="url(#clipPath117)" />
<path
id="path120"
d="m 9362,4931 v 233 h -264 v -233 l 132,-132 132,132"
style="fill:#f9f9f9;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
transform="matrix(0,0.22984236,0.22984236,0,-721.48942,-1495.6777)"
clip-path="url(#clipPath121)" />
<path
id="path122"
d="m 9071,4639 132,132 -132,133 h -233 v -265 h 233"
style="fill:#f9f9f9;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
transform="matrix(0,0.22984236,0.22984236,0,-721.48942,-1495.6777)"
clip-path="url(#clipPath123)" />
<path
id="path126"
d="M 6650,4098 H 8379 V 6933 H 6650 V 4098"
style="fill:#ffffff;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
style="fill:#ffffff;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1;fill-opacity:1"
transform="matrix(0,0.18386638,0.18386638,0,-502.11504,-1071.3194)"
clip-path="url(#clipPath127)" />
<path
id="path130"
d="M 8379,4098 H 6650"
style="fill:none;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
transform="matrix(0,0.18386638,0.18386638,0,-502.11504,-1071.3194)"
clip-path="url(#clipPath131)"
sodipodi:nodetypes="cc" />
<path
id="path132"
d="M 6650,6933 H 8379"
style="fill:none;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
transform="matrix(0,0.18386638,0.18386638,0,-502.11504,-1071.3194)"
clip-path="url(#clipPath133)"
sodipodi:nodetypes="cc" />
<path
id="path158"
d="m 9098,4612 132,132"
style="fill:none;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
transform="matrix(0,0.22984236,0.22984236,0,-721.48942,-1495.6777)"
clip-path="url(#clipPath159)"
sodipodi:nodetypes="cc" />
<path
id="path164"
d="m 9230,4799 -132,132"
style="fill:none;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
transform="matrix(0,0.22984236,0.22984236,0,-721.48942,-1495.6777)"
clip-path="url(#clipPath165)"
sodipodi:nodetypes="cc" />
<path
id="path174"
d="M 9622,4639 H 9390"
style="fill:none;stroke:#000000;stroke-width:24;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1"
transform="matrix(0,0.15345005,0.15345005,0,-334.35379,-809.35838)"
transform="matrix(0,0.22984236,0.22984236,0,-721.48942,-1495.6777)"
clip-path="url(#clipPath175)"
sodipodi:nodetypes="cc"
inkscape:label="path174" />

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

@@ -77,6 +77,11 @@ private struct TimeSyncTabView: View {
}
.buttonStyle(.bordered)
}
Button(action: manager.sendTestNotification) {
Label("Send Test Notification", systemImage: "bell.badge.waveform")
}
.buttonStyle(.bordered)
}
VStack(spacing: 8) {

View File

@@ -6,5 +6,7 @@
<array>
<string>bluetooth-central</string>
</array>
<key>NSUserNotificationUsageDescription</key>
<string>Allow Cardboy Companion to send local notifications for testing.</string>
</dict>
</plist>

View File

@@ -2,8 +2,9 @@ import Combine
import CoreBluetooth
import Foundation
import UniformTypeIdentifiers
import UserNotifications
final class TimeSyncManager: NSObject, ObservableObject {
final class TimeSyncManager: NSObject, ObservableObject, UNUserNotificationCenterDelegate {
enum ConnectionState: String {
case idle = "Idle"
case scanning = "Scanning"
@@ -116,6 +117,7 @@ final class TimeSyncManager: NSObject, ObservableObject {
private var pendingListOperationID: UUID?
private var simpleOperationID: UUID?
private var pendingDirectoryRequest: (path: String, operationID: UUID)?
private let notificationCenter = UNUserNotificationCenter.current()
private struct UploadState {
let id: UUID
@@ -141,6 +143,9 @@ final class TimeSyncManager: NSObject, ObservableObject {
// Force central manager to initialise immediately so state updates arrive right away.
_ = central
// Ensure we can present notifications while app is foreground
notificationCenter.delegate = self
if central.state == .poweredOn {
startScanning()
}
@@ -201,6 +206,70 @@ final class TimeSyncManager: NSObject, ObservableObject {
statusMessage = "Time synced at \(timeString)."
}
func sendTestNotification() {
func scheduleNotification() {
DispatchQueue.main.async {
self.statusMessage = "Scheduling test notification…"
}
let content = UNMutableNotificationContent()
content.title = "Cardboy Test Notification"
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .medium
content.body = "Triggered at \(formatter.string(from: Date()))"
content.sound = UNNotificationSound.default
// Schedule slightly later so the notification is visible when the app is foreground
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 5, repeats: false)
let request = UNNotificationRequest(identifier: "cardboy-test-\(UUID().uuidString)",
content: content,
trigger: trigger)
notificationCenter.add(request) { error in
DispatchQueue.main.async {
if let error {
self.statusMessage = "Failed to schedule test notification: \(error.localizedDescription)"
} else {
self.statusMessage = "Test notification scheduled."
}
}
}
}
func requestPermissionAndSchedule() {
notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
if let error {
DispatchQueue.main.async {
self.statusMessage = "Notification permission error: \(error.localizedDescription)"
}
return
}
if granted {
scheduleNotification()
} else {
DispatchQueue.main.async {
self.statusMessage = "Enable notifications in Settings to test."
}
}
}
}
notificationCenter.getNotificationSettings { settings in
switch settings.authorizationStatus {
case .authorized, .provisional, .ephemeral:
scheduleNotification()
case .notDetermined:
requestPermissionAndSchedule()
case .denied:
DispatchQueue.main.async {
self.statusMessage = "Enable notifications in Settings to test."
}
@unknown default:
requestPermissionAndSchedule()
}
}
}
// MARK: - File operations exposed to UI
func refreshDirectory() {
@@ -763,7 +832,17 @@ extension TimeSyncManager: CBCentralManagerDelegate {
statusMessage = "Turn on Bluetooth to continue."
stopScanning()
case .poweredOn:
startScanning()
// If there are peripherals already connected that match our services, restore connection.
let connected = central.retrieveConnectedPeripherals(withServices: [timeServiceUUID, fileServiceUUID])
if let restored = connected.first {
statusMessage = "Restoring connection…"
connectionState = .connecting
targetPeripheral = restored
restored.delegate = self
central.connect(restored, options: nil)
} else {
startScanning()
}
@unknown default:
connectionState = .failed
statusMessage = "Unknown Bluetooth state."