mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
269 lines
8.0 KiB
Swift
269 lines
8.0 KiB
Swift
import SwiftUI
|
|
import WidgetKit
|
|
|
|
/// The iOS inbox widget: the active account's inbox unread count and newest
|
|
/// messages. The app writes what it shows (src/widgets/ios.ts) into the
|
|
/// keychain group it shares with its extensions and reloads the widget after
|
|
/// each write; a tap opens the app on the message through its deep link.
|
|
|
|
/// Kind the app reloads (BulwarkWidgetKit.swift).
|
|
let inboxWidgetKind = "BulwarkInbox"
|
|
|
|
/// src/widgets/ios.ts IosWidgetSnapshot.
|
|
struct InboxSnapshot: Decodable {
|
|
struct Message: Decodable, Identifiable {
|
|
let id: String
|
|
let from: String
|
|
let initials: String
|
|
let color: String
|
|
let subject: String
|
|
let preview: String
|
|
let receivedAt: Double
|
|
let unread: Bool
|
|
let url: String
|
|
}
|
|
|
|
struct Strings: Decodable {
|
|
let inbox: String
|
|
let unread: String
|
|
let empty: String
|
|
let signedOut: String
|
|
let noSubject: String
|
|
}
|
|
|
|
let version: Int
|
|
let generatedAt: Double
|
|
let signedIn: Bool
|
|
let theme: String
|
|
let locale: String
|
|
let hour12: Bool
|
|
let account: String?
|
|
let unread: Int
|
|
let inboxUrl: String
|
|
let composeUrl: String
|
|
let messages: [Message]
|
|
let strings: Strings
|
|
|
|
/// IOS_WIDGET_KEY in src/widgets/ios.ts.
|
|
static func load() -> InboxSnapshot? {
|
|
guard let data = SharedKeychain.read("bulwark_widget_inbox") else { return nil }
|
|
return try? JSONDecoder().decode(InboxSnapshot.self, from: data)
|
|
}
|
|
}
|
|
|
|
struct InboxEntry: TimelineEntry {
|
|
let date: Date
|
|
let snapshot: InboxSnapshot?
|
|
}
|
|
|
|
struct InboxProvider: TimelineProvider {
|
|
func placeholder(in context: Context) -> InboxEntry {
|
|
InboxEntry(date: Date(), snapshot: nil)
|
|
}
|
|
|
|
func getSnapshot(in context: Context, completion: @escaping (InboxEntry) -> Void) {
|
|
completion(InboxEntry(date: Date(), snapshot: InboxSnapshot.load()))
|
|
}
|
|
|
|
func getTimeline(in context: Context, completion: @escaping (Timeline<InboxEntry>) -> Void) {
|
|
let entry = InboxEntry(date: Date(), snapshot: InboxSnapshot.load())
|
|
// The app reloads the widget when it writes new data; this only keeps
|
|
// "today" and the times current.
|
|
completion(Timeline(entries: [entry], policy: .after(Date().addingTimeInterval(30 * 60))))
|
|
}
|
|
}
|
|
|
|
/// The widget palette of src/widgets/theme.ts, roughly.
|
|
struct Palette {
|
|
let background: Color
|
|
let foreground: Color
|
|
let muted: Color
|
|
let accent: Color
|
|
|
|
init(dark: Bool) {
|
|
background = dark ? Color(hex: "#18181b") : Color(hex: "#ffffff")
|
|
foreground = dark ? Color(hex: "#fafafa") : Color(hex: "#09090b")
|
|
muted = dark ? Color(hex: "#a1a1aa") : Color(hex: "#71717a")
|
|
accent = Color(hex: "#db2d54")
|
|
}
|
|
}
|
|
|
|
extension Color {
|
|
init(hex: String) {
|
|
var value: UInt64 = 0
|
|
Scanner(string: hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))).scanHexInt64(&value)
|
|
self.init(
|
|
red: Double((value >> 16) & 0xff) / 255,
|
|
green: Double((value >> 8) & 0xff) / 255,
|
|
blue: Double(value & 0xff) / 255
|
|
)
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
/// iOS 17 wants the background declared and adds the margins itself.
|
|
@ViewBuilder func widgetBackground(_ color: Color) -> some View {
|
|
if #available(iOS 17.0, *) {
|
|
containerBackground(for: .widget) { color }
|
|
} else {
|
|
padding(14).background(color)
|
|
}
|
|
}
|
|
}
|
|
|
|
func listTime(_ ms: Double, locale: String, hour12: Bool) -> String {
|
|
let date = Date(timeIntervalSince1970: ms / 1000)
|
|
let formatter = DateFormatter()
|
|
formatter.locale = Locale(identifier: locale)
|
|
if Calendar.current.isDateInToday(date) {
|
|
formatter.dateFormat = hour12 ? "h:mm a" : "HH:mm"
|
|
} else {
|
|
formatter.setLocalizedDateFormatFromTemplate("MMMd")
|
|
}
|
|
return formatter.string(from: date)
|
|
}
|
|
|
|
struct MessageRow: View {
|
|
let message: InboxSnapshot.Message
|
|
let time: String
|
|
let palette: Palette
|
|
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
ZStack {
|
|
Circle().fill(Color(hex: message.color))
|
|
Text(message.initials)
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundColor(.white)
|
|
}
|
|
.frame(width: 26, height: 26)
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
HStack(spacing: 4) {
|
|
Text(message.from)
|
|
.font(.system(size: 13, weight: message.unread ? .semibold : .regular))
|
|
.foregroundColor(palette.foreground)
|
|
.lineLimit(1)
|
|
Spacer(minLength: 4)
|
|
Text(time)
|
|
.font(.system(size: 11))
|
|
.foregroundColor(message.unread ? palette.accent : palette.muted)
|
|
}
|
|
Text(message.subject)
|
|
.font(.system(size: 12))
|
|
.foregroundColor(message.unread ? palette.foreground : palette.muted)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct InboxWidgetView: View {
|
|
@Environment(\.widgetFamily) private var family
|
|
@Environment(\.colorScheme) private var systemScheme
|
|
let entry: InboxEntry
|
|
|
|
private var dark: Bool {
|
|
switch entry.snapshot?.theme {
|
|
case "dark": return true
|
|
case "light": return false
|
|
default: return systemScheme == .dark
|
|
}
|
|
}
|
|
|
|
private var rows: Int {
|
|
switch family {
|
|
case .systemSmall: return 1
|
|
case .systemMedium: return 2
|
|
default: return 6
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
let palette = Palette(dark: dark)
|
|
content(palette)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
.widgetBackground(palette.background)
|
|
.widgetURL(URL(string: entry.snapshot?.inboxUrl ?? "bulwarkmobile://mail/folder/inbox"))
|
|
}
|
|
|
|
@ViewBuilder private func content(_ palette: Palette) -> some View {
|
|
if let snapshot = entry.snapshot, snapshot.signedIn {
|
|
VStack(alignment: .leading, spacing: family == .systemSmall ? 6 : 8) {
|
|
header(snapshot, palette)
|
|
if snapshot.messages.isEmpty {
|
|
Spacer(minLength: 0)
|
|
Text(snapshot.strings.empty)
|
|
.font(.system(size: 13))
|
|
.foregroundColor(palette.muted)
|
|
Spacer(minLength: 0)
|
|
} else {
|
|
ForEach(snapshot.messages.prefix(rows)) { message in
|
|
let row = MessageRow(
|
|
message: message,
|
|
time: listTime(message.receivedAt, locale: snapshot.locale, hour12: snapshot.hour12),
|
|
palette: palette
|
|
)
|
|
// A small widget opens what it shows through widgetURL alone.
|
|
if family != .systemSmall, let url = URL(string: message.url) {
|
|
Link(destination: url) { row }
|
|
} else {
|
|
row
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Bulwark Mail")
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundColor(palette.foreground)
|
|
Text(entry.snapshot?.strings.signedOut ?? "Open Bulwark to load your inbox.")
|
|
.font(.system(size: 13))
|
|
.foregroundColor(palette.muted)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func header(_ snapshot: InboxSnapshot, _ palette: Palette) -> some View {
|
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text(snapshot.strings.inbox)
|
|
.font(.system(size: 15, weight: .semibold))
|
|
.foregroundColor(palette.foreground)
|
|
if let account = snapshot.account, family != .systemSmall {
|
|
Text(account)
|
|
.font(.system(size: 11))
|
|
.foregroundColor(palette.muted)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
Spacer(minLength: 4)
|
|
if snapshot.unread > 0 {
|
|
Text("\(snapshot.unread)")
|
|
.font(.system(size: family == .systemSmall ? 22 : 15, weight: .bold))
|
|
.foregroundColor(palette.accent)
|
|
.accessibilityLabel("\(snapshot.unread) \(snapshot.strings.unread)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct InboxWidget: Widget {
|
|
var body: some WidgetConfiguration {
|
|
StaticConfiguration(kind: inboxWidgetKind, provider: InboxProvider()) { entry in
|
|
InboxWidgetView(entry: entry)
|
|
}
|
|
.configurationDisplayName("Inbox")
|
|
.description("Unread mail and the newest messages.")
|
|
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
|
}
|
|
}
|
|
|
|
@main
|
|
struct BulwarkWidgets: WidgetBundle {
|
|
var body: some Widget {
|
|
InboxWidget()
|
|
}
|
|
}
|