WIP: local handoff implementation
Local work on the social.io handoff before merging the claude worktree branch. Includes the full per-spec Sources/Core/Design module (8 files), watchOS target under WatchApp/, Live Activity + widget extension, entitlements, scheme, and asset catalog. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
#if os(iOS) && canImport(ActivityKit) && canImport(AppIntents) && canImport(WidgetKit)
|
||||
import ActivityKit
|
||||
import AppIntents
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct MailNotificationActivityAttributes: ActivityAttributes {
|
||||
struct ContentState: Codable, Hashable {
|
||||
var sender: String
|
||||
var initials: String
|
||||
var subject: String
|
||||
var preview: String
|
||||
var route: String
|
||||
}
|
||||
|
||||
var threadRouteID: String
|
||||
}
|
||||
|
||||
struct OpenMailLiveActivityIntent: LiveActivityIntent {
|
||||
static var title: LocalizedStringResource = "Open"
|
||||
static var openAppWhenRun = true
|
||||
|
||||
@Parameter(title: "Route")
|
||||
var route: String
|
||||
|
||||
init() {
|
||||
route = "socialio://mailbox/inbox"
|
||||
}
|
||||
|
||||
init(route: String) {
|
||||
self.route = route
|
||||
}
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
.result()
|
||||
}
|
||||
}
|
||||
|
||||
struct SnoozeMailLiveActivityIntent: LiveActivityIntent {
|
||||
static var title: LocalizedStringResource = "Snooze"
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
.result()
|
||||
}
|
||||
}
|
||||
|
||||
struct MailNotificationLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: MailNotificationActivityAttributes.self) { context in
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
header(context: context)
|
||||
|
||||
Text(context.state.preview)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button(intent: OpenMailLiveActivityIntent(route: context.state.route)) {
|
||||
Text("Open")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button(intent: SnoozeMailLiveActivityIntent()) {
|
||||
Text("Snooze")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
Image(systemName: "envelope.fill")
|
||||
.foregroundStyle(SIO.tint)
|
||||
}
|
||||
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
Text(context.state.initials)
|
||||
.font(.headline.weight(.semibold))
|
||||
}
|
||||
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
header(context: context)
|
||||
Text(context.state.preview)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
HStack(spacing: 10) {
|
||||
Button(intent: OpenMailLiveActivityIntent(route: context.state.route)) {
|
||||
Text("Open")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button(intent: SnoozeMailLiveActivityIntent()) {
|
||||
Text("Snooze")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
} compactLeading: {
|
||||
Image(systemName: "envelope.fill")
|
||||
} compactTrailing: {
|
||||
Text(context.state.initials)
|
||||
.font(.caption2.weight(.bold))
|
||||
} minimal: {
|
||||
Image(systemName: "envelope.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func header(context: ActivityViewContext<MailNotificationActivityAttributes>) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Text(context.state.initials)
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(SIO.tint)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(SIO.tint.opacity(0.12), in: Circle())
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(context.state.sender)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(context.state.subject)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if !WIDGET_EXTENSION
|
||||
enum MailNotificationActivityController {
|
||||
static func startIfNeeded(with thread: MailThread) async {
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
|
||||
|
||||
let attributes = MailNotificationActivityAttributes(threadRouteID: thread.routeID)
|
||||
let state = MailNotificationActivityAttributes.ContentState(
|
||||
sender: thread.latestMessage?.sender.name ?? thread.participants.first?.name ?? "social.io",
|
||||
initials: initials(from: thread.latestMessage?.sender.name ?? thread.participants.first?.name ?? "SI"),
|
||||
subject: thread.subject,
|
||||
preview: thread.previewText,
|
||||
route: "socialio://open?thread=\(thread.routeID)"
|
||||
)
|
||||
|
||||
if let existing = Activity<MailNotificationActivityAttributes>.activities.first {
|
||||
await existing.update(ActivityContent(state: state, staleDate: nil))
|
||||
return
|
||||
}
|
||||
|
||||
_ = try? Activity<MailNotificationActivityAttributes>.request(
|
||||
attributes: attributes,
|
||||
content: ActivityContent(state: state, staleDate: nil)
|
||||
)
|
||||
}
|
||||
|
||||
private static func initials(from name: String) -> String {
|
||||
String(name.split(separator: " ").prefix(2).compactMap { $0.first }).uppercased()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
Reference in New Issue
Block a user