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() - } -}