import ActivityKit import SwiftUI import WidgetKit // Mantine v8 avatar palette — exact hex parity with RosettaColors.avatarColors // tint = shade-6, text = shade-3 private struct AvatarPalette { let tint: Color let text: Color } private let mantinePalette: [AvatarPalette] = [ AvatarPalette(tint: Color(red: 0x22/255, green: 0x8B/255, blue: 0xE6/255), text: Color(red: 0x74/255, green: 0xC0/255, blue: 0xFC/255)), // blue AvatarPalette(tint: Color(red: 0x15/255, green: 0xAA/255, blue: 0xBF/255), text: Color(red: 0x66/255, green: 0xD9/255, blue: 0xE8/255)), // cyan AvatarPalette(tint: Color(red: 0xBE/255, green: 0x4B/255, blue: 0xDB/255), text: Color(red: 0xE5/255, green: 0x99/255, blue: 0xF7/255)), // grape AvatarPalette(tint: Color(red: 0x40/255, green: 0xC0/255, blue: 0x57/255), text: Color(red: 0x8C/255, green: 0xE9/255, blue: 0x9A/255)), // green AvatarPalette(tint: Color(red: 0x4C/255, green: 0x6E/255, blue: 0xF5/255), text: Color(red: 0x91/255, green: 0xA7/255, blue: 0xFF/255)), // indigo AvatarPalette(tint: Color(red: 0x82/255, green: 0xC9/255, blue: 0x1E/255), text: Color(red: 0xC0/255, green: 0xEB/255, blue: 0x75/255)), // lime AvatarPalette(tint: Color(red: 0xFD/255, green: 0x7E/255, blue: 0x14/255), text: Color(red: 0xFF/255, green: 0xC0/255, blue: 0x78/255)), // orange AvatarPalette(tint: Color(red: 0xE6/255, green: 0x49/255, blue: 0x80/255), text: Color(red: 0xFA/255, green: 0xA2/255, blue: 0xC1/255)), // pink AvatarPalette(tint: Color(red: 0xFA/255, green: 0x52/255, blue: 0x52/255), text: Color(red: 0xFF/255, green: 0xA8/255, blue: 0xA8/255)), // red AvatarPalette(tint: Color(red: 0x12/255, green: 0xB8/255, blue: 0x86/255), text: Color(red: 0x63/255, green: 0xE6/255, blue: 0xBE/255)), // teal AvatarPalette(tint: Color(red: 0x79/255, green: 0x50/255, blue: 0xF2/255), text: Color(red: 0xB1/255, green: 0x97/255, blue: 0xFC/255)), // violet ] // Mantine dark body background private let mantineDarkBody = Color(red: 0x1A/255, green: 0x1B/255, blue: 0x1E/255) private let appGroupID = "group.com.rosetta.dev" @main struct CallLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: CallActivityAttributes.self) { context in // MARK: - Lock Screen HStack(spacing: 14) { avatarView(context: context, size: 48, fontSize: 18) VStack(alignment: .leading, spacing: 3) { Text(context.attributes.peerName) .font(.system(size: 17, weight: .semibold)) .foregroundColor(.white) .lineLimit(1) HStack(spacing: 6) { Image(systemName: "lock.fill") .font(.system(size: 10)) .foregroundColor(Color(white: 0.45)) if context.state.isActive { Text(fmt(context.state.durationSec)) .font(.system(size: 14, weight: .medium).monospacedDigit()) .foregroundColor(Color(white: 0.55)) } else { Text("Connecting...") .font(.system(size: 14, weight: .medium)) .foregroundColor(Color(white: 0.55)) } if context.state.isMuted { Image(systemName: "mic.slash.fill") .font(.system(size: 11)) .foregroundColor(.red) } } } Spacer() Link(destination: URL(string: "rosetta://call/end")!) { ZStack { Circle().fill(Color.red) Image(systemName: "phone.down.fill") .font(.system(size: 15, weight: .semibold)) .foregroundColor(.white) } .frame(width: 40, height: 40) } } .padding(.horizontal, 20) .padding(.vertical, 14) .activityBackgroundTint(Color(white: 0.08)) } dynamicIsland: { context in DynamicIsland { // MARK: - Expanded DynamicIslandExpandedRegion(.leading) { avatarView(context: context, size: 44, fontSize: 15) .padding(.leading, 6) .padding(.top, 4) } DynamicIslandExpandedRegion(.trailing) { VStack(spacing: 2) { if context.state.isActive { Text(fmt(context.state.durationSec)) .font(.system(size: 16, weight: .bold).monospacedDigit()) .foregroundColor(.green) } else { Text("...") .font(.system(size: 16, weight: .bold)) .foregroundColor(.orange) } if context.state.isMuted { Image(systemName: "mic.slash.fill") .font(.system(size: 11)) .foregroundColor(.red) } } .padding(.trailing, 6) .padding(.top, 4) } DynamicIslandExpandedRegion(.center) { Text(context.attributes.peerName) .font(.system(size: 16, weight: .semibold)) .foregroundColor(.white) .lineLimit(1) .padding(.top, 4) } DynamicIslandExpandedRegion(.bottom) { HStack(spacing: 5) { Circle() .fill(context.state.isActive ? Color.green : Color.orange) .frame(width: 6, height: 6) Image(systemName: "lock.fill") .font(.system(size: 9)) .foregroundColor(Color(white: 0.4)) Text("Rosetta · E2E encrypted") .font(.system(size: 11, weight: .medium)) .foregroundColor(Color(white: 0.4)) } .padding(.top, 4) } } compactLeading: { avatarView(context: context, size: 26, fontSize: 9) } compactTrailing: { if context.state.isActive { Text(fmt(context.state.durationSec)) .font(.system(size: 13, weight: .semibold).monospacedDigit()) .foregroundColor(.green) } else { Image(systemName: "phone.fill") .font(.system(size: 12)) .foregroundColor(.green) } } minimal: { Image(systemName: "phone.fill") .font(.system(size: 11)) .foregroundColor(.green) } } } // MARK: - Avatar (Mantine "light" dark variant — matches AvatarView in main app) @ViewBuilder private func avatarView(context: ActivityViewContext, size: CGFloat, fontSize: CGFloat) -> some View { if let data = context.attributes.avatarThumb, let img = UIImage(data: data) { Image(uiImage: img) .resizable() .scaledToFill() .frame(width: size, height: size) .clipShape(Circle()) } else { let idx = min(max(context.attributes.colorIndex, 0), mantinePalette.count - 1) let palette = mantinePalette[idx] ZStack { // Base: Mantine dark body Circle().fill(mantineDarkBody) // Overlay: tint at 15% opacity (dark mode) Circle().fill(palette.tint.opacity(0.15)) // Initials: shade-3 text color Text(makeInitials(context.attributes.peerName, context.attributes.peerPublicKey)) .font(.system(size: fontSize, weight: .bold, design: .rounded)) .foregroundColor(palette.text) } .frame(width: size, height: size) } } // MARK: - Helpers private func fmt(_ sec: Int) -> String { String(format: "%d:%02d", sec / 60, sec % 60) } private func makeInitials(_ name: String, _ pubKey: String) -> String { let words = name.trimmingCharacters(in: .whitespaces) .split(whereSeparator: { $0.isWhitespace }) .filter { !$0.isEmpty } switch words.count { case 0: return pubKey.isEmpty ? "?" : String(pubKey.prefix(2)).uppercased() case 1: return String(words[0].prefix(2)).uppercased() default: return "\(words[0].prefix(1))\(words[1].prefix(1))".uppercased() } } }