mirror of
https://github.com/hanzoai/mobile.git
synced 2026-10-30 14:53:17 +00:00
182 lines
5.0 KiB
Swift
182 lines
5.0 KiB
Swift
import WidgetKit
|
|
import SwiftUI
|
|
|
|
// The Hanzo home-screen widget. The app writes {snippet, updatedAt} JSON into
|
|
// the shared App Group (lib/widget.ts -> modules/widget); this extension only
|
|
// reads and renders it. No network and no invented content: before the first
|
|
// sync the widget says so instead of faking a conversation.
|
|
|
|
private let suite = "group.ai.hanzo.mobile"
|
|
private let key = "widget.state"
|
|
|
|
private struct Shared: Decodable {
|
|
let snippet: String
|
|
// Milliseconds since epoch, because the writer is JavaScript.
|
|
let updatedAt: Double
|
|
}
|
|
|
|
private func shared() -> Shared? {
|
|
guard
|
|
let defaults = UserDefaults(suiteName: suite),
|
|
let raw = defaults.string(forKey: key),
|
|
let data = raw.data(using: .utf8)
|
|
else { return nil }
|
|
return try? JSONDecoder().decode(Shared.self, from: data)
|
|
}
|
|
|
|
struct Entry: TimelineEntry {
|
|
let date: Date
|
|
// nil means the app has never synced; the views render the honest hint.
|
|
let snippet: String?
|
|
let updated: Date?
|
|
}
|
|
|
|
struct Provider: TimelineProvider {
|
|
func placeholder(in context: Context) -> Entry {
|
|
Entry(date: .now, snippet: nil, updated: nil)
|
|
}
|
|
|
|
func getSnapshot(in context: Context, completion: @escaping (Entry) -> Void) {
|
|
completion(entry())
|
|
}
|
|
|
|
func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> Void) {
|
|
// The app reloads all timelines on every state write, so a single entry
|
|
// is enough; the relative timestamp below renders live without reloads.
|
|
completion(Timeline(entries: [entry()], policy: .never))
|
|
}
|
|
|
|
private func entry() -> Entry {
|
|
guard let state = shared() else { return Entry(date: .now, snippet: nil, updated: nil) }
|
|
return Entry(
|
|
date: .now,
|
|
snippet: state.snippet,
|
|
updated: Date(timeIntervalSince1970: state.updatedAt / 1000)
|
|
)
|
|
}
|
|
}
|
|
|
|
// lib/theme.ts, mirrored: ground #0a0a0a, fg #fafafa, dim 55%, hairline 8%.
|
|
private let ground = Color(red: 10 / 255, green: 10 / 255, blue: 10 / 255)
|
|
private let fg = Color(red: 250 / 255, green: 250 / 255, blue: 250 / 255)
|
|
private let dim = fg.opacity(0.55)
|
|
private let hairline = fg.opacity(0.08)
|
|
|
|
private struct Mark: View {
|
|
var body: some View {
|
|
Text("Hanzo")
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.tracking(0.5)
|
|
.foregroundStyle(dim)
|
|
}
|
|
}
|
|
|
|
private struct Snippet: View {
|
|
let entry: Entry
|
|
let lines: Int
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
if let snippet = entry.snippet, let updated = entry.updated {
|
|
Text(snippet)
|
|
.font(.system(size: 13, weight: .regular))
|
|
.foregroundStyle(fg)
|
|
.lineLimit(lines)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
Text(updated, style: .relative)
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(dim)
|
|
} else {
|
|
Text("Open Hanzo to sync")
|
|
.font(.system(size: 13))
|
|
.foregroundStyle(dim)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct Action: View {
|
|
let symbol: String
|
|
let url: String
|
|
|
|
var body: some View {
|
|
Link(destination: URL(string: url)!) {
|
|
Image(systemName: symbol)
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundStyle(fg)
|
|
.frame(maxWidth: .infinity, minHeight: 34)
|
|
.background(hairline, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct Small: View {
|
|
let entry: Entry
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Mark()
|
|
Spacer(minLength: 0)
|
|
Snippet(entry: entry, lines: 3)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
|
// The small family is one tap target; deep links per region need medium.
|
|
.widgetURL(URL(string: "hanzo://chat"))
|
|
}
|
|
}
|
|
|
|
private struct Medium: View {
|
|
let entry: Entry
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack {
|
|
Mark()
|
|
Spacer()
|
|
}
|
|
Snippet(entry: entry, lines: 2)
|
|
Spacer(minLength: 0)
|
|
HStack(spacing: 8) {
|
|
Action(symbol: "mic", url: "hanzo://voice")
|
|
Action(symbol: "doc", url: "hanzo://send?type=file")
|
|
Action(symbol: "photo", url: "hanzo://send?type=image")
|
|
Action(symbol: "lock", url: "hanzo://chat/private")
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
private struct Root: View {
|
|
@Environment(\.widgetFamily) private var family
|
|
let entry: Entry
|
|
|
|
var body: some View {
|
|
switch family {
|
|
case .systemMedium:
|
|
Medium(entry: entry)
|
|
default:
|
|
Small(entry: entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct HanzoWidget: Widget {
|
|
var body: some WidgetConfiguration {
|
|
StaticConfiguration(kind: "HanzoWidget", provider: Provider()) { entry in
|
|
Root(entry: entry)
|
|
.containerBackground(ground, for: .widget)
|
|
}
|
|
.configurationDisplayName("Hanzo")
|
|
.description("Last conversation and quick actions.")
|
|
.supportedFamilies([.systemSmall, .systemMedium])
|
|
}
|
|
}
|
|
|
|
@main
|
|
struct Widgets: WidgetBundle {
|
|
var body: some Widget {
|
|
HanzoWidget()
|
|
}
|
|
}
|