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>
202 lines
6.0 KiB
Swift
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
|
|
}
|