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,32 @@
|
||||
import SwiftUI
|
||||
|
||||
enum SIO {
|
||||
static let tint = Color(red: 0.184, green: 0.420, blue: 1.0)
|
||||
static let laneFeed = Color(red: 0.184, green: 0.420, blue: 1.0)
|
||||
static let lanePaper = Color(red: 1.0, green: 0.624, blue: 0.039)
|
||||
static let lanePeople = Color(red: 0.188, green: 0.819, blue: 0.345)
|
||||
}
|
||||
|
||||
enum Lane: String, CaseIterable, Codable, Identifiable {
|
||||
case feed
|
||||
case paper
|
||||
case people
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .feed: "Feed"
|
||||
case .paper: "Paper"
|
||||
case .people: "People"
|
||||
}
|
||||
}
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .feed: SIO.laneFeed
|
||||
case .paper: SIO.lanePaper
|
||||
case .people: SIO.lanePeople
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct SocialIOWatchApp: App {
|
||||
@State private var store = WatchInboxStore()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
NavigationStack {
|
||||
WatchInboxView(store: store)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class WatchInboxStore {
|
||||
var threads: [MailThread] = []
|
||||
var selectedThreadID: MailThread.ID?
|
||||
|
||||
private let service = MockMailService()
|
||||
|
||||
var visibleThreads: [MailThread] {
|
||||
Array(
|
||||
threads
|
||||
.filter { $0.mailbox == .inbox }
|
||||
.sorted { $0.lastUpdated > $1.lastUpdated }
|
||||
.prefix(4)
|
||||
)
|
||||
}
|
||||
|
||||
var selectedThread: MailThread? {
|
||||
visibleThreads.first(where: { $0.id == selectedThreadID }) ?? visibleThreads.first
|
||||
}
|
||||
|
||||
func load() async {
|
||||
guard threads.isEmpty else { return }
|
||||
threads = (try? await service.loadThreads()) ?? service.previewThreads()
|
||||
selectedThreadID = visibleThreads.first?.id
|
||||
}
|
||||
|
||||
func select(_ thread: MailThread) {
|
||||
selectedThreadID = thread.id
|
||||
}
|
||||
|
||||
func archive(_ thread: MailThread) {
|
||||
guard let index = threads.firstIndex(where: { $0.id == thread.id }) else { return }
|
||||
threads[index].mailbox = .archive
|
||||
if selectedThreadID == thread.id {
|
||||
selectedThreadID = visibleThreads.first?.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WatchInboxView: View {
|
||||
@Bindable var store: WatchInboxStore
|
||||
|
||||
var body: some View {
|
||||
List(store.visibleThreads) { thread in
|
||||
NavigationLink {
|
||||
WatchThreadView(thread: thread, store: store)
|
||||
} label: {
|
||||
WatchInboxRow(
|
||||
thread: thread,
|
||||
isHighlighted: thread.id == (store.selectedThread?.id ?? store.visibleThreads.first?.id)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
store.select(thread)
|
||||
})
|
||||
}
|
||||
.listStyle(.carousel)
|
||||
.navigationTitle("Inbox")
|
||||
.task {
|
||||
await store.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchInboxRow: View {
|
||||
let thread: MailThread
|
||||
let isHighlighted: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
AvatarCircle(name: senderName, color: thread.lane.color)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
Text(senderName)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
if thread.isUnread {
|
||||
Circle()
|
||||
.fill(SIO.tint)
|
||||
.frame(width: 6, height: 6)
|
||||
}
|
||||
}
|
||||
|
||||
Text(thread.subject)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 6)
|
||||
.background(isHighlighted ? SIO.tint.opacity(0.12) : Color.clear, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
|
||||
private var senderName: String {
|
||||
thread.latestMessage?.sender.name ?? thread.participants.first?.name ?? "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
struct WatchThreadView: View {
|
||||
let thread: MailThread
|
||||
@Bindable var store: WatchInboxStore
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
AvatarCircle(name: senderName, color: thread.lane.color)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(senderName)
|
||||
.font(.headline)
|
||||
Text(thread.lane.label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(thread.lane.color)
|
||||
}
|
||||
}
|
||||
|
||||
Text(thread.subject)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
|
||||
Text(thread.previewText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(4)
|
||||
|
||||
Button("Reply") {
|
||||
openURL(URL(string: "socialio://compose?to=\(replyTarget)&subject=Re:%20\(thread.subject.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? thread.subject)")!)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(SIO.tint)
|
||||
|
||||
Button {
|
||||
store.archive(thread)
|
||||
} label: {
|
||||
Image(systemName: "archivebox")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
}
|
||||
}
|
||||
|
||||
private var senderName: String {
|
||||
thread.latestMessage?.sender.name ?? thread.participants.first?.name ?? "Unknown"
|
||||
}
|
||||
|
||||
private var replyTarget: String {
|
||||
(thread.latestMessage?.sender.email ?? thread.participants.first?.email ?? "hello@social.io")
|
||||
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "hello@social.io"
|
||||
}
|
||||
}
|
||||
|
||||
private struct AvatarCircle: View {
|
||||
let name: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
Text(initials)
|
||||
.font(.system(size: 9, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(color)
|
||||
.frame(width: 22, height: 22)
|
||||
.background(color.opacity(0.14), in: Circle())
|
||||
}
|
||||
|
||||
private var initials: String {
|
||||
String(name.split(separator: " ").prefix(2).compactMap { $0.first })
|
||||
.uppercased()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Watch Inbox") {
|
||||
NavigationStack {
|
||||
WatchInboxView(store: previewStore())
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Watch Thread") {
|
||||
NavigationStack {
|
||||
if let thread = previewStore().visibleThreads.first {
|
||||
WatchThreadView(thread: thread, store: previewStore())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func previewStore() -> WatchInboxStore {
|
||||
let store = WatchInboxStore()
|
||||
store.threads = MockMailService().previewThreads()
|
||||
store.selectedThreadID = store.visibleThreads.first?.id
|
||||
return store
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>$(WATCHOS_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,82 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct WatchUnreadEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let unreadCount: Int
|
||||
}
|
||||
|
||||
struct WatchUnreadProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> WatchUnreadEntry {
|
||||
WatchUnreadEntry(date: .now, unreadCount: 3)
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (WatchUnreadEntry) -> Void) {
|
||||
completion(makeEntry())
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<WatchUnreadEntry>) -> Void) {
|
||||
completion(Timeline(entries: [makeEntry()], policy: .after(.now.addingTimeInterval(900))))
|
||||
}
|
||||
|
||||
private func makeEntry() -> WatchUnreadEntry {
|
||||
let unreadCount = MockMailService().previewThreads().filter { $0.mailbox == .inbox && $0.isUnread }.count
|
||||
return WatchUnreadEntry(date: .now, unreadCount: unreadCount)
|
||||
}
|
||||
}
|
||||
|
||||
struct WatchUnreadComplication: Widget {
|
||||
let kind = "SocialIOWatchUnreadComplication"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: WatchUnreadProvider()) { entry in
|
||||
WatchUnreadComplicationView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("social.io Inbox")
|
||||
.description("Unread social.io mail at a glance.")
|
||||
.supportedFamilies([.accessoryRectangular, .accessoryCircular, .accessoryCorner])
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchUnreadComplicationView: View {
|
||||
let entry: WatchUnreadEntry
|
||||
|
||||
var body: some View {
|
||||
switch widgetFamily {
|
||||
case .accessoryCircular:
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
VStack(spacing: 2) {
|
||||
Image(systemName: "envelope.fill")
|
||||
Text(entry.unreadCount, format: .number)
|
||||
.font(.caption2.weight(.bold))
|
||||
}
|
||||
}
|
||||
.widgetURL(URL(string: "socialio://mailbox/inbox"))
|
||||
|
||||
case .accessoryCorner:
|
||||
Text("\(entry.unreadCount)")
|
||||
.font(.caption.weight(.bold))
|
||||
.widgetURL(URL(string: "socialio://mailbox/inbox"))
|
||||
|
||||
default:
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("social.io")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(entry.unreadCount) unread")
|
||||
.font(.caption.weight(.semibold))
|
||||
}
|
||||
.widgetURL(URL(string: "socialio://mailbox/inbox"))
|
||||
}
|
||||
}
|
||||
|
||||
@Environment(\.widgetFamily) private var widgetFamily
|
||||
}
|
||||
|
||||
@main
|
||||
struct SocialIOWatchWidgets: WidgetBundle {
|
||||
var body: some Widget {
|
||||
WatchUnreadComplication()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user