janky notifications
@@ -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",
|
||||
|
||||
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 13 KiB |
@@ -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 |
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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."
|
||||
|
||||