Files
swiftapp/swift/WatchApp/WatchInboxView.swift
Jürgen Kunz 2fe6b8a6df 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>
2026-04-19 16:26:38 +02:00

202 lines
6.0 KiB
Swift

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
}