Свайп-реплай: Telegram-parity эффекты и иконка
This commit is contained in:
@@ -231,7 +231,6 @@
|
||||
LA000000C2F8D22220092AD05 /* RosettaLiveActivityWidget */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
LA000000D2F8D22220092AD05 /* RosettaLiveActivityWidgetBundle.swift */,
|
||||
LA000000E2F8D22220092AD05 /* CallLiveActivity.swift */,
|
||||
LA000000F2F8D22220092AD05 /* CallActivityAttributes.swift */,
|
||||
);
|
||||
@@ -442,7 +441,6 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
LA00000102F8D22220092AD05 /* RosettaLiveActivityWidgetBundle.swift in Sources */,
|
||||
LA00000112F8D22220092AD05 /* CallLiveActivity.swift in Sources */,
|
||||
LA00000122F8D22220092AD05 /* CallActivityAttributes.swift in Sources */,
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
<key>RosettaLiveActivityWidget.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>2</integer>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<key>RosettaNotificationService.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
<integer>2</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<CallActivityAttributes>.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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
// 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"),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)")
|
||||
// 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)
|
||||
.padding()
|
||||
.activityBackgroundTint(.black)
|
||||
.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)
|
||||
}
|
||||
.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) {
|
||||
Text("Encrypted call")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.gray)
|
||||
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)
|
||||
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<CallActivityAttributes>, 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import ActivityKit
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
@main
|
||||
struct RosettaLiveActivityWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
CallLiveActivity()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user