Фикс: empty state + service messages + навигация GroupInfoView + профиль из группы + Saved Messages

This commit is contained in:
2026-04-16 09:16:57 +05:00
parent bc478ab484
commit 75008e4a20
15 changed files with 548 additions and 83 deletions

View File

@@ -0,0 +1,15 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "ic_forward.pdf"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
},
"properties" : {
"template-rendering-intent" : "template"
}
}

Binary file not shown.

View File

@@ -0,0 +1,15 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "ic_share.pdf"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
},
"properties" : {
"template-rendering-intent" : "template"
}
}

Binary file not shown.

View File

@@ -0,0 +1,15 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "ic_delete.pdf"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
},
"properties" : {
"template-rendering-intent" : "template"
}
}

Binary file not shown.

View File

@@ -34,10 +34,17 @@ final class ChatDetailViewController: UIViewController {
private var scrollToBottomTrigger: UInt = 0
private var pendingAttachments: [PendingAttachment] = []
private var forwardingMessage: ChatMessage?
private var forwardingMessages: [ChatMessage] = []
private var messageToDelete: ChatMessage?
private var isMultiSelectMode = false
private var selectedMessageIds: Set<String> = []
// MARK: - Selection Toolbar
private var selectionToolbar: SelectionToolbarView?
private var selectionHeaderOverlay: UIView?
private var selectionHeaderLabel: UILabel?
// MARK: - Cached
private let currentPublicKey = SessionManager.shared.currentPublicKey
@@ -579,9 +586,16 @@ final class ChatDetailViewController: UIViewController {
}
}
cellActions.onEnterSelection = { [weak self] msg in
self?.isMultiSelectMode = true
self?.selectedMessageIds = [msg.id]
self?.messageListController?.setSelectionMode(true, animated: true)
guard let self else { return }
self.isMultiSelectMode = true
self.selectedMessageIds = [msg.id]
self.messageListController?.setSelectionMode(true, animated: true)
// Sync selected IDs AFTER setSelectionMode so the first message gets checkmarked
self.messageListController?.updateSelectedIds(self.selectedMessageIds)
self.showSelectionToolbar(animated: true)
self.showSelectionHeader(animated: true)
self.selectionToolbar?.updateState(selectedCount: 1, canDelete: true)
self.updateSelectionHeaderLabel()
}
cellActions.onToggleSelection = { [weak self] msgId in
guard let self else { return }
@@ -591,6 +605,8 @@ final class ChatDetailViewController: UIViewController {
self.selectedMessageIds.insert(msgId)
}
self.messageListController?.updateSelectedIds(self.selectedMessageIds)
self.selectionToolbar?.updateState(selectedCount: self.selectedMessageIds.count, canDelete: true)
self.updateSelectionHeaderLabel()
}
cellActions.onMentionTap = { [weak self] username in
self?.handleMentionTap(username: username)
@@ -789,13 +805,25 @@ final class ChatDetailViewController: UIViewController {
}
private func showForwardPicker() {
// Support both single message (context menu) and multi-select forward
let messagesToForward: [ChatMessage]
if !forwardingMessages.isEmpty {
messagesToForward = forwardingMessages
forwardingMessages = []
} else if let single = forwardingMessage {
messagesToForward = [single]
forwardingMessage = nil
} else {
return
}
let picker = ForwardChatPickerView { [weak self] targetRoutes in
guard let self else { return }
self.dismiss(animated: true)
guard let message = self.forwardingMessage else { return }
self.forwardingMessage = nil
for route in targetRoutes {
self.forwardMessage(message, to: route)
for message in messagesToForward {
self.forwardMessage(message, to: route)
}
}
}
let hosting = UIHostingController(rootView: picker)
@@ -817,9 +845,7 @@ final class ChatDetailViewController: UIViewController {
self.removeMessage(message)
self.messageToDelete = nil
if self.isMultiSelectMode {
self.isMultiSelectMode = false
self.selectedMessageIds.removeAll()
self.messageListController?.setSelectionMode(false, animated: true)
self.exitSelectionMode(animated: true)
}
})
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in
@@ -857,6 +883,175 @@ final class ChatDetailViewController: UIViewController {
present(alert, animated: true)
}
// MARK: - Selection Toolbar & Header
private func showSelectionToolbar(animated: Bool) {
guard selectionToolbar == nil else { return }
let toolbar = SelectionToolbarView()
let safeBottom = view.safeAreaInsets.bottom
let toolbarHeight: CGFloat = 40 + safeBottom
let finalY = view.bounds.height - toolbarHeight
toolbar.frame = CGRect(
x: 0,
y: animated ? view.bounds.height : finalY,
width: view.bounds.width,
height: toolbarHeight
)
toolbar.layer.zPosition = 50
view.addSubview(toolbar)
selectionToolbar = toolbar
// Wire callbacks
toolbar.onDelete = { [weak self] in self?.deleteSelectedMessages() }
toolbar.onForward = { [weak self] in self?.forwardSelectedMessages() }
if animated {
UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.85, initialSpringVelocity: 0, options: []) {
toolbar.frame.origin.y = finalY
}
}
}
private func hideSelectionToolbar(animated: Bool) {
guard let toolbar = selectionToolbar else { return }
if animated {
UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseIn) {
toolbar.frame.origin.y = self.view.bounds.height
toolbar.alpha = 0
} completion: { _ in
toolbar.removeFromSuperview()
}
} else {
toolbar.removeFromSuperview()
}
selectionToolbar = nil
}
private func showSelectionHeader(animated: Bool) {
guard selectionHeaderOverlay == nil else { return }
let safeTop = view.safeAreaInsets.top
let overlay = UIView()
overlay.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: safeTop + headerBarHeight)
overlay.layer.zPosition = 60
// Simple dark background (Telegram style no glass, just solid dark)
overlay.backgroundColor = UIColor { $0.userInterfaceStyle == .dark
? UIColor(white: 0.11, alpha: 0.95) // Dark translucent
: UIColor(white: 0.97, alpha: 0.95) // Light translucent
}
// Cancel button (left, blue text)
let cancelBtn = UIButton(type: .system)
cancelBtn.setTitle("Cancel", for: .normal)
cancelBtn.titleLabel?.font = .systemFont(ofSize: 17)
cancelBtn.tintColor = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1) // #248AE6
cancelBtn.sizeToFit()
let centerY = safeTop + headerBarHeight * 0.5
cancelBtn.frame = CGRect(
x: 16,
y: centerY - cancelBtn.bounds.height * 0.5,
width: cancelBtn.bounds.width,
height: cancelBtn.bounds.height
)
cancelBtn.addTarget(self, action: #selector(selectionCancelTapped), for: .touchUpInside)
overlay.addSubview(cancelBtn)
// "N Selected" label (centered)
let label = UILabel()
label.font = .systemFont(ofSize: 17, weight: .semibold)
label.textColor = UIColor { $0.userInterfaceStyle == .dark ? .white : .black }
label.textAlignment = .center
overlay.addSubview(label)
selectionHeaderLabel = label
label.frame = CGRect(x: 0, y: safeTop, width: view.bounds.width, height: headerBarHeight)
overlay.alpha = animated ? 0 : 1
view.addSubview(overlay)
selectionHeaderOverlay = overlay
if animated {
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut) {
overlay.alpha = 1
}
}
}
private func hideSelectionHeader(animated: Bool) {
guard let overlay = selectionHeaderOverlay else { return }
if animated {
UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseIn) {
overlay.alpha = 0
} completion: { _ in
overlay.removeFromSuperview()
}
} else {
overlay.removeFromSuperview()
}
selectionHeaderOverlay = nil
selectionHeaderLabel = nil
}
private func updateSelectionHeaderLabel() {
let count = selectedMessageIds.count
selectionHeaderLabel?.text = "\(count) Selected"
}
private func exitSelectionMode(animated: Bool) {
isMultiSelectMode = false
selectedMessageIds.removeAll()
messageListController?.setSelectionMode(false, animated: animated)
hideSelectionToolbar(animated: animated)
hideSelectionHeader(animated: animated)
}
@objc private func selectionCancelTapped() {
exitSelectionMode(animated: true)
}
private func forwardSelectedMessages() {
let messages = viewModel.messages
.filter { selectedMessageIds.contains($0.id) }
.sorted { $0.timestamp < $1.timestamp }
guard !messages.isEmpty else { return }
forwardingMessages = messages
exitSelectionMode(animated: true)
showForwardPicker()
}
private func deleteSelectedMessages() {
let count = selectedMessageIds.count
let alert = UIAlertController(
title: "Delete \(count) Message\(count == 1 ? "" : "s")",
message: "This action cannot be undone.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in
guard let self else { return }
let messages = self.viewModel.messages.filter { self.selectedMessageIds.contains($0.id) }
for msg in messages {
self.removeMessage(msg)
}
self.exitSelectionMode(animated: true)
})
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
present(alert, animated: true)
}
private func shareSelectedMessages() {
let messages = viewModel.messages
.filter { selectedMessageIds.contains($0.id) }
.sorted { $0.timestamp < $1.timestamp }
let texts = messages.map { $0.text }.filter { !$0.isEmpty }
guard !texts.isEmpty else { return }
let activity = UIActivityViewController(
activityItems: [texts.joined(separator: "\n\n")],
applicationActivities: nil
)
present(activity, animated: true)
}
// MARK: - Scroll
private func scrollToMessage(id: String) {

View File

@@ -1391,6 +1391,12 @@ extension ComposerView: RecordingMicButtonDelegate {
}
private func finalizeVoiceSession(cleanup: VoiceSessionCleanupMode, dismissStyle: VoiceSessionDismissStyle) {
// Remove preview panel immediately BEFORE resetting state to avoid
// race condition where input is restored while panel is still animating.
recordingPreviewPanel?.stopPlayback()
recordingPreviewPanel?.removeFromSuperview()
recordingPreviewPanel = nil
resetVoiceSessionState(cleanup: cleanup)
switch dismissStyle {
@@ -1410,10 +1416,6 @@ extension ComposerView: RecordingMicButtonDelegate {
recordingLockView?.dismiss()
recordingLockView = nil
recordingPreviewPanel?.animateOut { [weak self] in
self?.recordingPreviewPanel = nil
}
restoreComposerChrome()
// For cancel: play bin animation inside attach button, then restore icon
@@ -1512,15 +1514,16 @@ extension ComposerView: RecordingMicButtonDelegate {
private func resumeRecordingFromPreview() {
let trimRange = recordingPreviewPanel?.selectedTrimRange
// Remove preview panel immediately before layout changes
recordingPreviewPanel?.stopPlayback()
recordingPreviewPanel?.removeFromSuperview()
recordingPreviewPanel = nil
setPreviewRowReplacement(false)
micButton.resetState()
guard audioRecorder.resumeRecording(trimRange: trimRange) else {
dismissOverlayAndRestore()
return
}
recordingPreviewPanel?.animateOut { [weak self] in
self?.recordingPreviewPanel = nil
}
isRecording = true
isRecordingLocked = true
setRecordingFlowState(.recordingLocked)

View File

@@ -12,6 +12,10 @@ struct OpponentProfileView: View {
@State private var topInset: CGFloat = 0
@State private var isMuted = false
@State private var showMoreSheet = false
/// When true, shows "Message" button in action bar (group member profile context).
var showMessageButton = false
/// Navigation controller for pushing chat from profile.
@State private var navController: UINavigationController?
@State private var selectedTab: PeerProfileTab = .media
@Namespace private var tabNamespace
@@ -95,8 +99,15 @@ struct OpponentProfileView: View {
viewModel.loadSharedContent()
viewModel.loadCommonGroups()
}
.background {
NavigationControllerAccessor { nav in
self.navController = nav
}
}
.confirmationDialog("", isPresented: $showMoreSheet, titleVisibility: .hidden) {
Button("Block User", role: .destructive) {}
if !route.isSavedMessages {
Button("Block User", role: .destructive) {}
}
Button("Clear Chat History", role: .destructive) {}
}
}
@@ -127,10 +138,14 @@ struct OpponentProfileView: View {
avatarInitials: RosettaColors.initials(name: displayName, publicKey: route.publicKey),
avatarColorIndex: RosettaColors.avatarColorIndex(for: displayName, publicKey: route.publicKey),
isMuted: isMuted,
showCallButton: !route.isSavedMessages,
showMuteButton: !route.isSavedMessages,
showMessageButton: route.isSavedMessages || showMessageButton,
onCall: handleCall,
onMuteToggle: handleMuteToggle,
onSearch: { dismiss() },
onMore: { showMoreSheet = true }
onMore: { showMoreSheet = true },
onMessage: handleMessage
)
}
}
@@ -407,6 +422,19 @@ struct OpponentProfileView: View {
DialogRepository.shared.toggleMute(opponentKey: route.publicKey)
isMuted.toggle()
}
private func handleMessage() {
if route.isSavedMessages {
// Pop back to Saved Messages chat
dismiss()
} else {
// Push chat with this user
let chatView = ChatDetailView(route: route)
let vc = UIHostingController(rootView: chatView)
vc.navigationItem.hidesBackButton = true
navController?.pushViewController(vc, animated: true)
}
}
}
// MARK: - Media Tile

View File

@@ -14,10 +14,13 @@ struct PeerProfileHeaderView: View {
let avatarColorIndex: Int
let isMuted: Bool
var showCallButton: Bool = true
var showMuteButton: Bool = true
var showMessageButton: Bool = false
let onCall: () -> Void
let onMuteToggle: () -> Void
let onSearch: () -> Void
let onMore: () -> Void
var onMessage: (() -> Void)?
@Environment(\.colorScheme) private var colorScheme
@@ -166,14 +169,19 @@ struct PeerProfileHeaderView: View {
private var actionButtons: some View {
HStack(spacing: 6) {
if showMessageButton, let onMessage {
profileActionButton(icon: "bubble.left.fill", title: "Message", action: onMessage)
}
if showCallButton {
profileActionButton(icon: "phone.fill", title: "Call", action: onCall)
}
profileActionButton(
icon: isMuted ? "bell.slash.fill" : "bell.fill",
title: isMuted ? "Unmute" : "Mute",
action: onMuteToggle
)
if showMuteButton {
profileActionButton(
icon: isMuted ? "bell.slash.fill" : "bell.fill",
title: isMuted ? "Unmute" : "Mute",
action: onMuteToggle
)
}
profileActionButton(icon: "magnifyingglass", title: "Search", action: onSearch)
profileActionButton(icon: "ellipsis", title: "More", action: onMore)
}

View File

@@ -381,7 +381,7 @@ final class RecordingPreviewPanel: UIView, UIGestureRecognizerDelegate {
stopDisplayLink()
}
private func stopPlayback(resetToTrimStart: Bool = true) {
func stopPlayback(resetToTrimStart: Bool = true) {
audioPlayer?.stop()
if resetToTrimStart {
audioPlayer?.currentTime = trimStart

View File

@@ -0,0 +1,177 @@
import UIKit
/// Telegram-parity selection toolbar: Delete (glass circle) + "N Selected" (glass capsule) + Forward (blue circle).
/// Matches Telegram's bottom toolbar during multi-select mode.
final class SelectionToolbarView: UIView {
// MARK: - Callbacks
var onDelete: (() -> Void)?
var onForward: (() -> Void)?
// MARK: - Constants
private let buttonSize: CGFloat = 42
private let pillHeight: CGFloat = 42
private let horizontalPadding: CGFloat = 16
// MARK: - UI
// Delete button (left glass circle + trash icon)
private let deleteGlass = TelegramGlassUIView(frame: .zero)
private let deleteButton = UIButton(type: .system)
// Center pill "N Selected" label inside glass capsule
private let pillGlass = TelegramGlassUIView(frame: .zero)
private let pillLabel: UILabel = {
let l = UILabel()
l.font = .systemFont(ofSize: 15, weight: .medium)
l.textColor = UIColor { $0.userInterfaceStyle == .dark ? .white : .black }
l.textAlignment = .center
return l
}()
// Forward/done button (right blue circle + white checkmark)
private let forwardCircle = UIView()
private let forwardButton = UIButton(type: .system)
private let checkmarkLayer = CAShapeLayer()
// MARK: - Init
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
// MARK: - Setup
private func setupUI() {
let iconColor = UIColor { $0.userInterfaceStyle == .dark ? .white : UIColor(white: 0.0, alpha: 0.85) }
// Delete glass circle
deleteGlass.isCircle = true
deleteGlass.isUserInteractionEnabled = false
addSubview(deleteGlass)
deleteButton.setImage(UIImage(named: "SelectionTrash")?.withRenderingMode(.alwaysTemplate), for: .normal)
deleteButton.tintColor = iconColor
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
deleteButton.addTarget(self, action: #selector(buttonDown(_:)), for: .touchDown)
deleteButton.addTarget(self, action: #selector(buttonUp(_:)), for: [.touchUpInside, .touchUpOutside, .touchCancel])
addSubview(deleteButton)
// Center pill glass capsule with label
pillGlass.isUserInteractionEnabled = false
addSubview(pillGlass)
addSubview(pillLabel)
// Forward blue circle with white checkmark
forwardCircle.backgroundColor = UIColor(red: 0.14, green: 0.54, blue: 0.90, alpha: 1) // #248AE6
forwardCircle.layer.cornerRadius = buttonSize / 2
forwardCircle.isUserInteractionEnabled = false
addSubview(forwardCircle)
// Draw checkmark (Telegram CheckNode path)
let checkSize: CGFloat = buttonSize
let scale = (checkSize - 4) / 18.0
let cx = checkSize / 2
let cy = checkSize / 2
let startX = cx - 4.333 * scale
let startY = cy + 0.5 * scale
let path = UIBezierPath()
path.move(to: CGPoint(x: startX, y: startY))
path.addLine(to: CGPoint(x: startX + 2.5 * scale, y: startY + 3.0 * scale))
path.addLine(to: CGPoint(x: startX + 2.5 * scale + 4.667 * scale, y: startY + 3.0 * scale - 6.0 * scale))
checkmarkLayer.path = path.cgPath
checkmarkLayer.strokeColor = UIColor.white.cgColor
checkmarkLayer.fillColor = UIColor.clear.cgColor
checkmarkLayer.lineWidth = 2.0
checkmarkLayer.lineCap = .round
checkmarkLayer.lineJoin = .round
checkmarkLayer.frame = CGRect(x: 0, y: 0, width: checkSize, height: checkSize)
forwardCircle.layer.addSublayer(checkmarkLayer)
forwardButton.addTarget(self, action: #selector(forwardTapped), for: .touchUpInside)
forwardButton.addTarget(self, action: #selector(buttonDown(_:)), for: .touchDown)
forwardButton.addTarget(self, action: #selector(buttonUp(_:)), for: [.touchUpInside, .touchUpOutside, .touchCancel])
addSubview(forwardButton)
}
// MARK: - State
func updateState(selectedCount: Int, canDelete: Bool) {
let hasSelection = selectedCount > 0
// Update pill label
pillLabel.text = "\(selectedCount) Selected"
// Delete enabled state
let deleteEnabled = hasSelection && canDelete
deleteButton.isEnabled = deleteEnabled
deleteGlass.alpha = deleteEnabled ? 1.0 : 0.5
deleteButton.alpha = deleteEnabled ? 1.0 : 0.5
// Forward enabled state
forwardButton.isEnabled = hasSelection
forwardCircle.alpha = hasSelection ? 1.0 : 0.5
}
// MARK: - Layout
override func layoutSubviews() {
super.layoutSubviews()
let w = bounds.width
// Same padding as composer (16pt each side)
let left = horizontalPadding
let right = horizontalPadding
// Delete button (left)
let deleteFrame = CGRect(x: left, y: 0, width: buttonSize, height: buttonSize)
deleteGlass.frame = deleteFrame
deleteButton.frame = deleteFrame
// Forward button (right)
let forwardFrame = CGRect(x: w - right - buttonSize, y: 0, width: buttonSize, height: buttonSize)
forwardCircle.frame = forwardFrame
forwardButton.frame = forwardFrame
// Center pill (fills space between delete and forward with some spacing)
let pillSpacing: CGFloat = 10
let pillX = deleteFrame.maxX + pillSpacing
let pillRight = forwardFrame.minX - pillSpacing
let pillWidth = pillRight - pillX
let pillFrame = CGRect(x: pillX, y: (buttonSize - pillHeight) / 2, width: pillWidth, height: pillHeight)
pillGlass.frame = pillFrame
pillLabel.frame = pillFrame
}
// MARK: - Actions
@objc private func deleteTapped() { onDelete?() }
@objc private func forwardTapped() { onForward?() }
// MARK: - Highlight
@objc private func buttonDown(_ sender: UIButton) {
guard sender.isEnabled else { return }
let target: UIView? = sender == deleteButton ? deleteGlass : forwardCircle
UIView.animate(withDuration: 0.05) {
sender.alpha = 0.6
target?.alpha = 0.6
}
}
@objc private func buttonUp(_ sender: UIButton) {
guard sender.isEnabled else { return }
let target: UIView? = sender == deleteButton ? deleteGlass : forwardCircle
UIView.animate(withDuration: 0.2) {
sender.alpha = 1.0
target?.alpha = 1.0
}
}
}

View File

@@ -91,7 +91,8 @@ struct GroupInfoView: View {
.onChange(of: showMemberChat) { show in
guard show, let route = selectedMemberRoute else { return }
showMemberChat = false
let profile = OpponentProfileView(route: route)
var profile = OpponentProfileView(route: route)
profile.showMessageButton = true // show "Message" button (group member context)
let vc = UIHostingController(rootView: profile)
vc.navigationItem.hidesBackButton = true
navController?.pushViewController(vc, animated: true)
@@ -808,7 +809,7 @@ private struct GroupIOS18ScrollTracker<Content: View>: View {
// MARK: - UIKit Navigation Bridge
/// Invisible UIView that captures the nearest UINavigationController via responder chain.
private struct NavigationControllerAccessor: UIViewRepresentable {
struct NavigationControllerAccessor: UIViewRepresentable {
let callback: (UINavigationController?) -> Void
func makeUIView(context: Context) -> UIView {
@@ -837,7 +838,7 @@ private struct NavigationControllerAccessor: UIViewRepresentable {
}
}
private extension UIView {
extension UIView {
var parentViewController: UIViewController? {
var responder: UIResponder? = self.next
while let r = responder {

View File

@@ -19,21 +19,30 @@ struct SafetyView: View {
}
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
keysSection
actionsSection
ZStack(alignment: .topLeading) {
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
keysSection
actionsSection
}
.padding(.horizontal, 16)
.padding(.top, 60)
.padding(.bottom, 100)
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 100)
// Inline back button UIKit ComposeGlassBackButton (SVG chevron, identical to Appearance)
SettingsGlassBackButton {
dismiss()
}
.frame(width: 44, height: 44)
.padding(.leading, 8)
.padding(.top, 4)
}
.background(RosettaColors.Adaptive.background)
.scrollContentBackground(.hidden)
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.enableSwipeBack()
.toolbar { toolbarContent }
.toolbarBackground(.hidden, for: .navigationBar)
.alert("Delete Account", isPresented: $showDeleteConfirmation) {
Button("Cancel", role: .cancel) {}
@@ -61,24 +70,6 @@ struct SafetyView: View {
}
}
// MARK: - Toolbar
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) {
Button {
dismiss()
} label: {
Image(systemName: "chevron.left")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
.frame(width: 44, height: 44)
}
.buttonStyle(.plain)
.glassCircle()
}
}
// MARK: - Keys Section
private var keysSection: some View {

View File

@@ -6,46 +6,35 @@ struct UpdatesView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
statusCard
versionCard
helpText
checkButton
ZStack(alignment: .topLeading) {
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
statusCard
versionCard
helpText
checkButton
}
.padding(.horizontal, 16)
.padding(.top, 60)
.padding(.bottom, 100)
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 100)
// Inline back button UIKit ComposeGlassBackButton (SVG chevron, identical to Appearance)
SettingsGlassBackButton {
dismiss()
}
.frame(width: 44, height: 44)
.padding(.leading, 8)
.padding(.top, 4)
}
.background(RosettaColors.Adaptive.background)
.scrollContentBackground(.hidden)
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.enableSwipeBack()
.toolbar { toolbarContent }
.toolbarBackground(.hidden, for: .navigationBar)
}
// MARK: - Toolbar
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .navigationBarLeading) {
Button {
dismiss()
} label: {
Image(systemName: "chevron.left")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(RosettaColors.Adaptive.text)
.frame(width: 44, height: 44)
}
.buttonStyle(.plain)
.glassCircle()
}
// No title per user request
}
// MARK: - Status Card
private var statusCard: some View {
@@ -181,3 +170,31 @@ struct UpdatesView: View {
}
}
}
// MARK: - Glass Back Button Bridge (UIKit SwiftUI)
/// Wraps `ComposeGlassBackButton` (UIKit) for use in SwiftUI settings screens.
/// Identical chevron icon to AppearanceViewController's back button.
struct SettingsGlassBackButton: UIViewRepresentable {
var action: () -> Void
func makeUIView(context: Context) -> ComposeGlassBackButton {
let button = ComposeGlassBackButton()
button.addTarget(context.coordinator, action: #selector(Coordinator.tapped), for: .touchUpInside)
return button
}
func updateUIView(_ uiView: ComposeGlassBackButton, context: Context) {
context.coordinator.action = action
}
func makeCoordinator() -> Coordinator {
Coordinator(action: action)
}
final class Coordinator: NSObject {
var action: () -> Void
init(action: @escaping () -> Void) { self.action = action }
@objc func tapped() { action() }
}
}