diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj
index 0a94c61..a94fbeb 100644
--- a/Rosetta.xcodeproj/project.pbxproj
+++ b/Rosetta.xcodeproj/project.pbxproj
@@ -231,7 +231,6 @@
LA000000C2F8D22220092AD05 /* RosettaLiveActivityWidget */ = {
isa = PBXGroup;
children = (
- LA000000D2F8D22220092AD05 /* RosettaLiveActivityWidgetBundle.swift */,
LA000000E2F8D22220092AD05 /* CallLiveActivity.swift */,
LA000000F2F8D22220092AD05 /* CallActivityAttributes.swift */,
);
@@ -442,8 +441,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
- LA00000102F8D22220092AD05 /* RosettaLiveActivityWidgetBundle.swift in Sources */,
- LA00000112F8D22220092AD05 /* CallLiveActivity.swift in Sources */,
+ LA00000112F8D22220092AD05 /* CallLiveActivity.swift in Sources */,
LA00000122F8D22220092AD05 /* CallActivityAttributes.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -771,7 +769,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = RosettaLiveActivityWidget/RosettaLiveActivityWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = QN8Z263QGX;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = RosettaLiveActivityWidget/Info.plist;
@@ -781,7 +779,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 1.2.6;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev.LiveActivityWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -798,7 +796,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = RosettaLiveActivityWidget/RosettaLiveActivityWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 27;
DEVELOPMENT_TEAM = QN8Z263QGX;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = RosettaLiveActivityWidget/Info.plist;
@@ -808,7 +806,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 1.2.6;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev.LiveActivityWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
diff --git a/Rosetta.xcodeproj/xcuserdata/gaidartimirbaev.xcuserdatad/xcschemes/xcschememanagement.plist b/Rosetta.xcodeproj/xcuserdata/gaidartimirbaev.xcuserdatad/xcschemes/xcschememanagement.plist
index 414c6b6..1fb243c 100644
--- a/Rosetta.xcodeproj/xcuserdata/gaidartimirbaev.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/Rosetta.xcodeproj/xcuserdata/gaidartimirbaev.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -12,12 +12,12 @@
RosettaLiveActivityWidget.xcscheme_^#shared#^_
orderHint
- 2
+ 1
RosettaNotificationService.xcscheme_^#shared#^_
orderHint
- 1
+ 2
SuppressBuildableAutocreation
diff --git a/Rosetta/Core/Services/CallActivityAttributes.swift b/Rosetta/Core/Services/CallActivityAttributes.swift
index d36b243..0db9fa9 100644
--- a/Rosetta/Core/Services/CallActivityAttributes.swift
+++ b/Rosetta/Core/Services/CallActivityAttributes.swift
@@ -2,11 +2,11 @@ import ActivityKit
import Foundation
struct CallActivityAttributes: ActivityAttributes {
- /// Fixed data set when the activity starts.
let peerName: String
let peerPublicKey: String
+ let avatarData: Data?
+ let colorIndex: Int
- /// Dynamic state updated during the call.
struct ContentState: Codable, Hashable {
let durationSec: Int
let isActive: Bool
diff --git a/Rosetta/Core/Services/CallManager.swift b/Rosetta/Core/Services/CallManager.swift
index f74c611..773d17f 100644
--- a/Rosetta/Core/Services/CallManager.swift
+++ b/Rosetta/Core/Services/CallManager.swift
@@ -325,15 +325,27 @@ final class CallManager: NSObject, ObservableObject {
// MARK: - Live Activity
func startLiveActivity() {
+ // End any stale activities from previous calls / schema changes
+ for activity in Activity.activities {
+ Task { await activity.end(nil, dismissalPolicy: .immediate) }
+ }
+
let authInfo = ActivityAuthorizationInfo()
print("[Call] LiveActivity: areActivitiesEnabled=\(authInfo.areActivitiesEnabled), frequentPushesEnabled=\(authInfo.frequentPushesEnabled)")
guard authInfo.areActivitiesEnabled else {
print("[Call] LiveActivity DISABLED by user settings")
return
}
+ // Load peer avatar thumbnail for Live Activity
+ var avatarJpeg: Data?
+ if let avatar = AvatarRepository.shared.loadAvatar(publicKey: uiState.peerPublicKey) {
+ avatarJpeg = avatar.jpegData(compressionQuality: 0.5)
+ }
let attributes = CallActivityAttributes(
peerName: uiState.displayName,
- peerPublicKey: uiState.peerPublicKey
+ peerPublicKey: uiState.peerPublicKey,
+ avatarData: avatarJpeg,
+ colorIndex: RosettaColors.avatarColorIndex(for: uiState.peerTitle, publicKey: uiState.peerPublicKey)
)
let state = CallActivityAttributes.ContentState(
durationSec: uiState.durationSec,
diff --git a/Rosetta/Features/Calls/ActiveCallOverlayView.swift b/Rosetta/Features/Calls/ActiveCallOverlayView.swift
index addf5ae..e67f2f2 100644
--- a/Rosetta/Features/Calls/ActiveCallOverlayView.swift
+++ b/Rosetta/Features/Calls/ActiveCallOverlayView.swift
@@ -15,18 +15,11 @@ struct ActiveCallOverlayView: View {
}
private var peerInitials: String {
- let name = state.peerTitle.isEmpty ? state.peerUsername : state.peerTitle
- guard !name.isEmpty else { return "?" }
- let parts = name.split(separator: " ")
- if parts.count >= 2 {
- return "\(parts[0].prefix(1))\(parts[1].prefix(1))".uppercased()
- }
- return String(name.prefix(2)).uppercased()
+ RosettaColors.initials(name: state.peerTitle.isEmpty ? state.peerUsername : state.peerTitle, publicKey: state.peerPublicKey)
}
private var peerColorIndex: Int {
- guard !state.peerPublicKey.isEmpty else { return 0 }
- return abs(state.peerPublicKey.hashValue) % 7
+ RosettaColors.avatarColorIndex(for: state.peerTitle, publicKey: state.peerPublicKey)
}
var body: some View {
diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift
index ee3fbfd..75f5094 100644
--- a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift
+++ b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift
@@ -58,7 +58,8 @@ struct MessageCellView: View, Equatable {
}
}
.modifier(ConditionalSwipeToReply(
- enabled: !isSavedMessages && !isSystemAccount,
+ enabled: !isSavedMessages && !isSystemAccount
+ && !message.attachments.contains(where: { $0.type == .avatar || $0.type == .call || $0.type == .messages }),
onReply: { actions.onReply(message) }
))
.overlay {
@@ -544,11 +545,16 @@ struct MessageCellView: View, Equatable {
private func bubbleActions(for message: ChatMessage) -> [BubbleContextAction] {
var result: [BubbleContextAction] = []
- result.append(BubbleContextAction(
- title: "Reply",
- image: UIImage(systemName: "arrowshape.turn.up.left"),
- role: []
- ) { actions.onReply(message) })
+ // Avatars, calls, and forwarded messages cannot be replied to or forwarded
+ let isAvatarOrForwarded = message.attachments.contains(where: { $0.type == .avatar || $0.type == .call || $0.type == .messages })
+
+ if !isAvatarOrForwarded {
+ result.append(BubbleContextAction(
+ title: "Reply",
+ image: UIImage(systemName: "arrowshape.turn.up.left"),
+ role: []
+ ) { actions.onReply(message) })
+ }
result.append(BubbleContextAction(
title: "Copy",
@@ -556,7 +562,7 @@ struct MessageCellView: View, Equatable {
role: []
) { actions.onCopy(message.text) })
- if !message.attachments.contains(where: { $0.type == .avatar }) {
+ if !isAvatarOrForwarded {
result.append(BubbleContextAction(
title: "Forward",
image: UIImage(systemName: "arrowshape.turn.up.right"),
diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift
index 5bdd3d1..65069aa 100644
--- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift
+++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift
@@ -41,6 +41,29 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private static let mediaClockMinImage = StatusIconRenderer.makeClockMinImage(color: mediaMetaColor)
private static let errorIcon = StatusIconRenderer.makeErrorIcon(color: .systemRed)
private static let maxVisiblePhotoTiles = 5
+ // Telegram-exact reply arrow rendered from SVG path (matches SwiftUI TelegramIconPath.replyArrow).
+ // Rendered at DISPLAY size (20×20) so UIImageView never upscales the raster.
+ private static let telegramReplyArrowImage: UIImage = {
+ let viewBox = CGSize(width: 16, height: 13)
+ let canvasSize = CGSize(width: 20, height: 20) // match replyIconView frame
+ let scale = UIScreen.main.scale
+ UIGraphicsBeginImageContextWithOptions(canvasSize, false, scale)
+ guard let ctx = UIGraphicsGetCurrentContext() else { return UIImage() }
+ var parser = SVGPathParser(pathData: TelegramIconPath.replyArrow)
+ let cgPath = parser.parse()
+ // Aspect-fit path into canvas, centered
+ let fitScale = min(canvasSize.width / viewBox.width, canvasSize.height / viewBox.height)
+ let scaledW = viewBox.width * fitScale
+ let scaledH = viewBox.height * fitScale
+ ctx.translateBy(x: (canvasSize.width - scaledW) / 2, y: (canvasSize.height - scaledH) / 2)
+ ctx.scaleBy(x: fitScale, y: fitScale)
+ ctx.addPath(cgPath)
+ ctx.setFillColor(UIColor.white.cgColor)
+ ctx.fillPath()
+ let image = UIGraphicsGetImageFromCurrentImageContext() ?? UIImage()
+ UIGraphicsEndImageContext()
+ return image.withRenderingMode(.alwaysOriginal)
+ }()
// Telegram-exact stretchable bubble images (raster, not vector — only way to get exact tail)
private static let bubbleImages = BubbleImageFactory.generate(
outgoingColor: outgoingColor,
@@ -111,7 +134,12 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private let forwardNameLabel = UILabel()
// Swipe-to-reply
+ private let replyCircleView = UIView()
private let replyIconView = UIImageView()
+ private var hasTriggeredSwipeHaptic = false
+ private let swipeHaptic = UIImpactFeedbackGenerator(style: .heavy)
+ /// Global X of the first touch — reject if near left screen edge (back gesture zone).
+ private var swipeStartX: CGFloat?
private let deliveryFailedButton = UIButton(type: .custom)
// MARK: - State
@@ -145,6 +173,9 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
private func setupViews() {
contentView.backgroundColor = .clear
backgroundColor = .clear
+ // Allow reply swipe icon to extend beyond cell bounds
+ contentView.clipsToBounds = false
+ clipsToBounds = false
contentView.transform = CGAffineTransform(scaleX: 1, y: -1) // inverted scroll flip
// Bubble — CAShapeLayer for shadow (index 0), then outline, then raster image on top
@@ -348,10 +379,14 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
forwardNameLabel.textColor = .white
bubbleView.addSubview(forwardNameLabel)
- // Swipe reply icon
- replyIconView.image = UIImage(systemName: "arrowshape.turn.up.left.fill")?
- .withRenderingMode(.alwaysTemplate)
- replyIconView.tintColor = UIColor.white.withAlphaComponent(0.5)
+ // Swipe reply icon — circle + Telegram-exact arrow (same vector as SwiftUI SwipeToReplyModifier)
+ replyCircleView.backgroundColor = UIColor.white.withAlphaComponent(0.12)
+ replyCircleView.layer.cornerRadius = 17 // 34pt / 2
+ replyCircleView.alpha = 0
+ contentView.addSubview(replyCircleView)
+
+ replyIconView.image = Self.telegramReplyArrowImage
+ replyIconView.contentMode = .scaleAspectFit
replyIconView.alpha = 0
contentView.addSubview(replyIconView)
@@ -633,7 +668,6 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
let cellW = contentView.bounds.width
let tailProtrusion = Self.bubbleMetrics.tailProtrusion
- let tailW: CGFloat = layout.hasTail ? tailProtrusion : 0
// Rule 2: Tail reserve (6pt) + margin (2pt) — strict vertical body alignment
let bubbleX: CGFloat
@@ -838,14 +872,14 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
deliveryFailedButton.alpha = 0
}
- // Reply icon (for swipe gesture) — use actual bubbleView frame
- replyIconView.frame = CGRect(
- x: layout.isOutgoing
- ? bubbleView.frame.minX - 30
- : bubbleView.frame.maxX + tailW + 8,
- y: bubbleView.frame.midY - 10,
- width: 20, height: 20
- )
+ // Reply icon (for swipe gesture) — positioned behind bubble's trailing edge.
+ // Starts hidden (alpha=0, scale=0). As bubble slides left via transform,
+ // the icon is revealed in the gap between shifted bubble and original position.
+ let replyIconDiameter: CGFloat = 34
+ let replyIconX = bubbleView.frame.maxX - replyIconDiameter
+ let replyIconY = bubbleView.frame.midY - replyIconDiameter / 2
+ replyCircleView.frame = CGRect(x: replyIconX, y: replyIconY, width: replyIconDiameter, height: replyIconDiameter)
+ replyIconView.frame = CGRect(x: replyIconX + 7, y: replyIconY + 7, width: 20, height: 20)
}
private static func formattedDuration(seconds: Int) -> String {
@@ -951,12 +985,16 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
actions.onCopy(message.text)
})
}
- items.append(UIAction(title: "Reply", image: UIImage(systemName: "arrowshape.turn.up.left")) { _ in
- actions.onReply(message)
- })
- items.append(UIAction(title: "Forward", image: UIImage(systemName: "arrowshape.turn.up.right")) { _ in
- actions.onForward(message)
- })
+ // Avatars, calls, and forwarded messages cannot be replied to or forwarded
+ let isAvatarOrForwarded = message.attachments.contains(where: { $0.type == .avatar || $0.type == .call || $0.type == .messages })
+ if !isAvatarOrForwarded {
+ items.append(UIAction(title: "Reply", image: UIImage(systemName: "arrowshape.turn.up.left")) { _ in
+ actions.onReply(message)
+ })
+ items.append(UIAction(title: "Forward", image: UIImage(systemName: "arrowshape.turn.up.right")) { _ in
+ actions.onForward(message)
+ })
+ }
items.append(UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { _ in
actions.onDelete(message)
})
@@ -967,28 +1005,81 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
// MARK: - Swipe to Reply
@objc private func handleSwipe(_ gesture: UIPanGestureRecognizer) {
+ // Block swipe on avatar, call, and forwarded-message attachments
+ let isReplyBlocked = message?.attachments.contains(where: { $0.type == .avatar || $0.type == .call || $0.type == .messages }) ?? false
+ if isReplyBlocked { return }
+
let translation = gesture.translation(in: contentView)
- let isOutgoing = currentLayout?.isOutgoing ?? false
+ let threshold: CGFloat = 55
+ let elasticCap: CGFloat = 85 // match SwiftUI SwipeToReplyModifier
+ let backGestureEdge: CGFloat = 40
switch gesture.state {
+ case .began:
+ // Record start position — reject if near left screen edge (iOS back gesture zone)
+ let startPoint = gesture.location(in: contentView.window)
+ swipeStartX = startPoint.x
+ // Pre-warm haptic engine for instant response at threshold
+ swipeHaptic.prepare()
+
case .changed:
- let dx = isOutgoing ? min(translation.x, 0) : max(translation.x, 0)
- let clamped = isOutgoing ? max(dx, -60) : min(dx, 60)
+ // Reject gestures from back gesture zone (left 40pt)
+ if let startX = swipeStartX, startX < backGestureEdge { return }
+
+ // Telegram: ALL messages swipe LEFT
+ let raw = min(translation.x, 0)
+ guard raw < 0 else { return }
+
+ // Elastic resistance past cap (Telegram rubber-band)
+ let absRaw = abs(raw)
+ let clamped: CGFloat
+ if absRaw > elasticCap {
+ clamped = -(elasticCap + (absRaw - elasticCap) * 0.15)
+ } else {
+ clamped = raw
+ }
+
bubbleView.transform = CGAffineTransform(translationX: clamped, y: 0)
- let progress = min(abs(clamped) / 50, 1)
+
+ // Icon progress: fade in from 4pt to threshold
+ let absClamped = abs(clamped)
+ let progress: CGFloat = absClamped > 4 ? min((absClamped - 4) / (threshold - 4), 1) : 0
+ replyCircleView.alpha = progress
+ replyCircleView.transform = CGAffineTransform(scaleX: progress, y: progress)
replyIconView.alpha = progress
replyIconView.transform = CGAffineTransform(scaleX: progress, y: progress)
+ // Haptic at threshold crossing (once per gesture, pre-prepared)
+ if absClamped >= threshold, !hasTriggeredSwipeHaptic {
+ hasTriggeredSwipeHaptic = true
+ swipeHaptic.impactOccurred()
+ }
+
case .ended, .cancelled:
- if abs(translation.x) > 50, let message, let actions {
- UIImpactFeedbackGenerator(style: .medium).impactOccurred()
+ let shouldReply = abs(translation.x) >= threshold
+ if shouldReply, let message, let actions {
actions.onReply(message)
}
- UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut) {
+ hasTriggeredSwipeHaptic = false
+ swipeStartX = nil
+
+ // Velocity-aware spring (Telegram passes swipe velocity for natural spring-back)
+ let velocity = gesture.velocity(in: contentView)
+ let currentOffset = bubbleView.transform.tx
+ let relativeVx: CGFloat = currentOffset != 0 ? velocity.x / abs(currentOffset) : 0
+ let initialVelocity = CGVector(dx: relativeVx, dy: 0)
+
+ let timing = UISpringTimingParameters(mass: 1, stiffness: 386, damping: 33.4, initialVelocity: initialVelocity)
+ let animator = UIViewPropertyAnimator(duration: 0, timingParameters: timing)
+ animator.addAnimations {
self.bubbleView.transform = .identity
+ self.replyCircleView.alpha = 0
+ self.replyCircleView.transform = .identity
self.replyIconView.alpha = 0
self.replyIconView.transform = .identity
}
+ animator.startAnimation()
+
default:
break
}
@@ -1788,7 +1879,12 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
forwardNameLabel.isHidden = true
photoContainer.isHidden = true
bubbleView.transform = .identity
+ replyCircleView.alpha = 0
+ replyCircleView.transform = .identity
replyIconView.alpha = 0
+ replyIconView.transform = .identity
+ hasTriggeredSwipeHaptic = false
+ swipeStartX = nil
deliveryFailedButton.isHidden = true
deliveryFailedButton.alpha = 0
isDeliveryFailedVisible = false
@@ -1801,7 +1897,8 @@ extension NativeMessageCell: UIGestureRecognizerDelegate {
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
let velocity = pan.velocity(in: contentView)
- return abs(velocity.x) > abs(velocity.y) * 1.5
+ // Telegram: only left swipe (negative velocity.x), clear horizontal dominance
+ return velocity.x < 0 && abs(velocity.x) > abs(velocity.y) * 2.0
}
}
diff --git a/RosettaLiveActivityWidget/CallActivityAttributes.swift b/RosettaLiveActivityWidget/CallActivityAttributes.swift
index d36b243..0db9fa9 100644
--- a/RosettaLiveActivityWidget/CallActivityAttributes.swift
+++ b/RosettaLiveActivityWidget/CallActivityAttributes.swift
@@ -2,11 +2,11 @@ import ActivityKit
import Foundation
struct CallActivityAttributes: ActivityAttributes {
- /// Fixed data set when the activity starts.
let peerName: String
let peerPublicKey: String
+ let avatarData: Data?
+ let colorIndex: Int
- /// Dynamic state updated during the call.
struct ContentState: Codable, Hashable {
let durationSec: Int
let isActive: Bool
diff --git a/RosettaLiveActivityWidget/CallLiveActivity.swift b/RosettaLiveActivityWidget/CallLiveActivity.swift
index 87448d4..fef9b23 100644
--- a/RosettaLiveActivityWidget/CallLiveActivity.swift
+++ b/RosettaLiveActivityWidget/CallLiveActivity.swift
@@ -2,36 +2,191 @@ import ActivityKit
import SwiftUI
import WidgetKit
+// Mantine v8 avatar palette (11 colors) — desktop parity
+private let mantineAvatarTints: [Color] = [
+ Color(red: 0.133, green: 0.545, blue: 0.902), // blue #228be6
+ Color(red: 0.082, green: 0.667, blue: 0.749), // cyan #15aabf
+ Color(red: 0.745, green: 0.294, blue: 0.859), // grape #be4bdb
+ Color(red: 0.251, green: 0.753, blue: 0.341), // green #40c057
+ Color(red: 0.298, green: 0.431, blue: 0.961), // indigo #4c6ef5
+ Color(red: 0.510, green: 0.788, blue: 0.118), // lime #82c91e
+ Color(red: 0.992, green: 0.494, blue: 0.078), // orange #fd7e14
+ Color(red: 0.902, green: 0.286, blue: 0.502), // pink #e64980
+ Color(red: 0.980, green: 0.322, blue: 0.322), // red #fa5252
+ Color(red: 0.071, green: 0.722, blue: 0.525), // teal #12b886
+ Color(red: 0.475, green: 0.314, blue: 0.949), // violet #7950f2
+]
+
+@main
struct CallLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: CallActivityAttributes.self) { context in
- // Lock Screen
- Text("Rosetta Call: \(context.attributes.peerName)")
- .foregroundColor(.white)
- .padding()
- .activityBackgroundTint(.black)
- } dynamicIsland: { context in
- DynamicIsland {
- DynamicIslandExpandedRegion(.center) {
+ // MARK: - Lock Screen Banner
+ 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(duration(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()
+
+ ZStack {
+ Circle().fill(Color.red)
+ Image(systemName: "phone.down.fill")
+ .font(.system(size: 15, weight: .semibold))
.foregroundColor(.white)
}
- DynamicIslandExpandedRegion(.bottom) {
- Text("Encrypted call")
- .font(.caption2)
- .foregroundColor(.gray)
+ .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(duration(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: {
- Image(systemName: "phone.fill")
- .foregroundColor(.green)
+ avatarView(context: context, size: 26, fontSize: 10)
+
} compactTrailing: {
- Text("Call")
- .font(.caption2)
- .foregroundColor(.green)
+ if context.state.isActive {
+ Text(duration(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
+
+ @ViewBuilder
+ private func avatarView(context: ActivityViewContext, size: CGFloat, fontSize: CGFloat) -> some View {
+ if let data = context.attributes.avatarData,
+ let uiImage = UIImage(data: data) {
+ Image(uiImage: uiImage)
+ .resizable()
+ .scaledToFill()
+ .frame(width: size, height: size)
+ .clipShape(Circle())
+ } else {
+ let idx = context.attributes.colorIndex
+ let color = mantineAvatarTints[idx < mantineAvatarTints.count ? idx : 0]
+ ZStack {
+ Circle().fill(color)
+ Text(initials(from: context.attributes.peerName, publicKey: context.attributes.peerPublicKey))
+ .font(.system(size: fontSize, weight: .bold))
+ .foregroundColor(.white)
+ }
+ .frame(width: size, height: size)
+ }
+ }
+
+ // MARK: - Helpers
+
+ private func duration(_ sec: Int) -> String {
+ String(format: "%d:%02d", sec / 60, sec % 60)
+ }
+
+ private func initials(from name: String, publicKey: String) -> String {
+ let words = name.trimmingCharacters(in: .whitespaces)
+ .split(whereSeparator: { $0.isWhitespace })
+ .filter { !$0.isEmpty }
+ switch words.count {
+ case 0:
+ return publicKey.isEmpty ? "?" : String(publicKey.prefix(2)).uppercased()
+ case 1:
+ return String(words[0].prefix(2)).uppercased()
+ default:
+ let first = words[0].first.map(String.init) ?? ""
+ let second = words[1].first.map(String.init) ?? ""
+ return (first + second).uppercased()
+ }
+ }
}
diff --git a/RosettaLiveActivityWidget/RosettaLiveActivityWidgetBundle.swift b/RosettaLiveActivityWidget/RosettaLiveActivityWidgetBundle.swift
deleted file mode 100644
index 9e4fa83..0000000
--- a/RosettaLiveActivityWidget/RosettaLiveActivityWidgetBundle.swift
+++ /dev/null
@@ -1,10 +0,0 @@
-import ActivityKit
-import SwiftUI
-import WidgetKit
-
-@main
-struct RosettaLiveActivityWidgetBundle: WidgetBundle {
- var body: some Widget {
- CallLiveActivity()
- }
-}