diff --git a/MC1/Extensions/View+ScrollRevealTitle.swift b/MC1/Extensions/View+ScrollRevealTitle.swift new file mode 100644 index 00000000..62d49a9f --- /dev/null +++ b/MC1/Extensions/View+ScrollRevealTitle.swift @@ -0,0 +1,44 @@ +import SwiftUI + +extension View { + /// Leaves the navigation bar title empty while the header is on screen, then fades + /// in `title` (the entity name) once the user scrolls past `revealAfter` points. + /// Pass the header's measured height (see `scrollRevealHeaderHeight`) so the reveal + /// tracks the real header size and adapts to Dynamic Type. + /// Pairs with `.navigationBarTitleDisplayMode(.inline)`. + func scrollRevealNavigationTitle(_ title: String, revealAfter: CGFloat = 150) -> some View { + modifier(ScrollRevealNavigationTitle(title: title, revealAfter: revealAfter)) + } + + /// Reports the measured height of the scroll-reveal header into `height`, so the + /// title reveals based on the header's real (Dynamic Type-aware) size rather than a + /// fixed threshold. Attach to the header content, then feed `height` to + /// `scrollRevealNavigationTitle(_:revealAfter:)`. + func scrollRevealHeaderHeight(into height: Binding) -> some View { + onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height.wrappedValue = $0 } + } +} + +private struct ScrollRevealNavigationTitle: ViewModifier { + let title: String + let revealAfter: CGFloat + + @State private var isRevealed = false + + func body(content: Content) -> some View { + content + .onScrollGeometryChange(for: Bool.self) { geometry in + geometry.contentOffset.y > revealAfter + } action: { _, revealed in + guard revealed != isRevealed else { return } + withAnimation(.easeInOut(duration: 0.2)) { isRevealed = revealed } + } + .toolbar { + ToolbarItem(placement: .principal) { + Text(title) + .font(.headline) + .opacity(isRevealed ? 1 : 0) + } + } + } +} diff --git a/MC1/Views/Chats/Room/RoomInfoSheet.swift b/MC1/Views/Chats/Room/RoomInfoSheet.swift index 661b6c64..3d9768da 100644 --- a/MC1/Views/Chats/Room/RoomInfoSheet.swift +++ b/MC1/Views/Chats/Room/RoomInfoSheet.swift @@ -16,6 +16,7 @@ struct RoomInfoSheet: View { @State private var favoriteTask: Task? @State private var showTelemetry = false @State private var showSettings = false + @State private var headerHeight: CGFloat = 150 init(session: RemoteNodeSessionDTO) { self.session = session @@ -27,17 +28,27 @@ struct RoomInfoSheet: View { NavigationStack { List { Section { - HStack { - Spacer() - NodeAvatar(publicKey: session.publicKey, role: .roomServer, size: 80) - Spacer() + VStack(spacing: 12) { + NodeAvatar(publicKey: session.publicKey, role: .roomServer, size: 150) + + VStack(spacing: 4) { + Text(session.name) + .font(.title2) + .bold() + + Text(L10n.RemoteNodes.RemoteNodes.Auth.typeRoom) + .font(.subheadline) + .foregroundStyle(.secondary) + } } + .frame(maxWidth: .infinity) + .scrollRevealHeaderHeight(into: $headerHeight) .listRowBackground(Color.clear) } ConversationQuickActionsSection( - notificationLevel: $notificationLevel, isFavorite: $isFavorite, + notificationLevel: $notificationLevel, availableLevels: NotificationLevel.roomLevels ) .onChange(of: notificationLevel) { _, newValue in @@ -102,10 +113,11 @@ struct RoomInfoSheet: View { .themedRowBackground(theme) } .themedCanvas(theme) - .navigationTitle(Strings.infoTitle) .navigationBarTitleDisplayMode(.inline) + .scrollRevealNavigationTitle(session.name, revealAfter: headerHeight) + .contentMargins(.top, 0, for: .scrollContent) .toolbar { - ToolbarItem(placement: .cancellationAction) { + ToolbarItem(placement: .confirmationAction) { Button(L10n.Localizable.Common.done) { dismiss() } } } diff --git a/MC1/Views/Chats/Sheets/ChannelInfoSheet.swift b/MC1/Views/Chats/Sheets/ChannelInfoSheet.swift index 91eefd22..35ecdd31 100644 --- a/MC1/Views/Chats/Sheets/ChannelInfoSheet.swift +++ b/MC1/Views/Chats/Sheets/ChannelInfoSheet.swift @@ -34,6 +34,7 @@ struct ChannelInfoSheet: View { @State private var discoveredNewRegions: [String] = [] @State private var showingDiscoveryResults = false @State private var selectedFloodScope: ChannelFloodScope + @State private var headerHeight: CGFloat = 150 init(channel: ChannelDTO, onClearMessages: @escaping () -> Void, onDelete: @escaping () -> Void) { self.channel = channel @@ -48,12 +49,12 @@ struct ChannelInfoSheet: View { NavigationStack { Form { // Channel Header Section - ChannelInfoHeaderSection(channel: channel) + ChannelInfoHeaderSection(channel: channel, measuredHeight: $headerHeight) // Quick Actions Section ConversationQuickActionsSection( - notificationLevel: $notificationLevel, isFavorite: $isFavorite, + notificationLevel: $notificationLevel, availableLevels: NotificationLevel.channelLevels ) .onChange(of: notificationLevel) { _, newValue in @@ -123,10 +124,11 @@ struct ChannelInfoSheet: View { ) } .themedCanvas(theme) - .navigationTitle(L10n.Chats.Chats.ChannelInfo.title) .navigationBarTitleDisplayMode(.inline) + .scrollRevealNavigationTitle(channel.displayName, revealAfter: headerHeight) + .contentMargins(.top, 0, for: .scrollContent) .toolbar { - ToolbarItem(placement: .cancellationAction) { + ToolbarItem(placement: .confirmationAction) { Button(L10n.Chats.Chats.Common.done) { dismiss() } @@ -354,6 +356,7 @@ struct ChannelInfoSheet: View { private struct ChannelInfoHeaderSection: View { let channel: ChannelDTO + @Binding var measuredHeight: CGFloat private var channelTypeLabel: String { if channel.isPublicChannel { @@ -367,11 +370,10 @@ private struct ChannelInfoHeaderSection: View { var body: some View { Section { - HStack { - Spacer() - VStack(spacing: 12) { - ChannelAvatar(channel: channel, size: 80) + VStack(spacing: 12) { + ChannelAvatar(channel: channel, size: 150) + VStack(spacing: 4) { Text(channel.displayName) .font(.title2) .bold() @@ -380,8 +382,9 @@ private struct ChannelInfoHeaderSection: View { .font(.subheadline) .foregroundStyle(.secondary) } - Spacer() } + .frame(maxWidth: .infinity) + .scrollRevealHeaderHeight(into: $measuredHeight) .listRowBackground(Color.clear) } } @@ -487,33 +490,28 @@ private struct ChannelInfoActionsSection: View { var body: some View { Section { - Button { + Button(role: .destructive) { showingClearMessagesConfirmation = true } label: { HStack { - Spacer() + Label(L10n.Chats.Chats.ChannelInfo.clearMessagesButton, systemImage: "xmark.circle") if isClearingMessages { + Spacer() ProgressView() - } else { - Label(L10n.Chats.Chats.ChannelInfo.clearMessagesButton, systemImage: "xmark.circle") } - Spacer() } } .disabled(isActionInProgress) - .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } Button(role: .destructive) { showingDeleteConfirmation = true } label: { HStack { - Spacer() + Label(L10n.Chats.Chats.ChannelInfo.deleteButton, systemImage: "trash") if isDeleting { + Spacer() ProgressView() - } else { - Label(L10n.Chats.Chats.ChannelInfo.deleteButton, systemImage: "trash") } - Spacer() } } .disabled(isActionInProgress) diff --git a/MC1/Views/Components/ConversationQuickActionsSection.swift b/MC1/Views/Components/ConversationQuickActionsSection.swift index aa6375dc..50af1379 100644 --- a/MC1/Views/Components/ConversationQuickActionsSection.swift +++ b/MC1/Views/Components/ConversationQuickActionsSection.swift @@ -3,28 +3,28 @@ import SwiftUI struct ConversationQuickActionsSection: View { @Environment(\.appTheme) private var theme - @Binding var notificationLevel: NotificationLevel @Binding var isFavorite: Bool + @Binding var notificationLevel: NotificationLevel let availableLevels: [NotificationLevel] init( - notificationLevel: Binding, isFavorite: Binding, + notificationLevel: Binding, availableLevels: [NotificationLevel] = NotificationLevel.allCases ) { - _notificationLevel = notificationLevel _isFavorite = isFavorite + _notificationLevel = notificationLevel self.availableLevels = availableLevels } var body: some View { Section { - VStack(spacing: 16) { - NotificationLevelPicker(selection: $notificationLevel, availableLevels: availableLevels) + NotificationLevelPicker(selection: $notificationLevel, availableLevels: availableLevels) + .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) - FavoriteToggleRow(isFavorite: $isFavorite) + Toggle(isOn: $isFavorite) { + Label(L10n.Chats.Chats.Action.favorite, systemImage: "star") } - .listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)) } .themedRowBackground(theme) } diff --git a/MC1/Views/Components/FavoriteToggleRow.swift b/MC1/Views/Components/FavoriteToggleRow.swift deleted file mode 100644 index c0949c9f..00000000 --- a/MC1/Views/Components/FavoriteToggleRow.swift +++ /dev/null @@ -1,22 +0,0 @@ -import SwiftUI - -struct FavoriteToggleRow: View { - @Binding var isFavorite: Bool - - var body: some View { - HStack { - Image(systemName: isFavorite ? "star.fill" : "star") - .foregroundStyle(isFavorite ? .yellow : .secondary) - - Text(L10n.Chats.Chats.Row.favorite) - - Spacer() - - Toggle("", isOn: $isFavorite) - .labelsHidden() - } - .accessibilityElement(children: .combine) - .accessibilityLabel(L10n.Chats.Chats.Row.favorite) - .accessibilityValue(isFavorite ? L10n.Localizable.Accessibility.on : L10n.Localizable.Accessibility.off) - } -} diff --git a/MC1/Views/Contacts/ContactDetailView.swift b/MC1/Views/Contacts/ContactDetailView.swift index e670d946..853f6301 100644 --- a/MC1/Views/Contacts/ContactDetailView.swift +++ b/MC1/Views/Contacts/ContactDetailView.swift @@ -46,6 +46,7 @@ struct ContactDetailView: View { @Environment(\.appState) private var appState @Environment(\.appTheme) private var theme @Environment(\.dismiss) private var dismiss + @Environment(\.isPresented) private var isPresented let contact: ContactDTO let showFromDirectChat: Bool @@ -78,7 +79,8 @@ struct ContactDetailView: View { @State private var showingClearMessagesAlert = false @State private var isClearingMessages = false @State private var isSaving = false - @State private var isTogglingFavorite = false + @State private var isFavorite: Bool + @State private var favoriteTask: Task? @State private var errorMessage: String? @State private var pathViewModel = PathManagementViewModel() @State private var showRoomJoinSheet = false @@ -94,12 +96,14 @@ struct ContactDetailView: View { @State private var pingResult: PingResult? @State private var isSharing = false @State private var showShareSuccess = false + @State private var headerHeight: CGFloat = 150 init(contact: ContactDTO, showFromDirectChat: Bool = false, onClearMessages: @escaping () -> Void = {}) { self.contact = contact self.showFromDirectChat = showFromDirectChat self.onClearMessages = onClearMessages _currentContact = State(initialValue: contact) + _isFavorite = State(initialValue: contact.isFavorite) } var body: some View { @@ -107,15 +111,16 @@ struct ContactDetailView: View { // Profile header ContactProfileSection( currentContact: currentContact, - contactTypeLabel: contactTypeLabel + contactTypeLabel: contactTypeLabel, + measuredHeight: $headerHeight ) // Quick actions ContactActionsSection( currentContact: currentContact, + isFavorite: $isFavorite, showFromDirectChat: showFromDirectChat, isPinging: isPinging, - isTogglingFavorite: isTogglingFavorite, pingResult: pingResult, onJoinRoom: { showRoomJoinSheet = true }, onShowTelemetry: { @@ -130,13 +135,16 @@ struct ContactDetailView: View { showRepeaterAdminAuth = true }, onPingRepeater: { Task { await pingRepeater() } }, - onToggleFavorite: { Task { await toggleFavorite() } }, onShareQR: { showQRShareSheet = true }, onShareViaAdvert: { Task { await shareContact() } }, isSharing: isSharing, showShareSuccess: showShareSuccess ) .themedRowBackground(theme) + .onChange(of: isFavorite) { _, newValue in + favoriteTask?.cancel() + favoriteTask = Task { await setFavorite(newValue) } + } // Info section ContactInfoSection( @@ -187,8 +195,16 @@ struct ContactDetailView: View { } .themedCanvas(theme) .errorAlert($errorMessage) - .navigationTitle(contactTypeLabel) .navigationBarTitleDisplayMode(.inline) + .scrollRevealNavigationTitle(currentContact.displayName, revealAfter: headerHeight) + .contentMargins(.top, 0, for: .scrollContent) + .toolbar { + if isPresented { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.Localizable.Common.done) { dismiss() } + } + } + } .alert(L10n.Contacts.Contacts.Detail.Alert.Block.title, isPresented: $showingBlockAlert) { Button(L10n.Contacts.Contacts.Common.cancel, role: .cancel) {} Button(L10n.Contacts.Contacts.Action.block, role: .destructive) { @@ -383,16 +399,12 @@ struct ContactDetailView: View { // MARK: - Actions - private func toggleFavorite() async { - isTogglingFavorite = true - defer { isTogglingFavorite = false } - + private func setFavorite(_ isFavorite: Bool) async { do { try await appState.services?.contactService.setContactFavorite( currentContact.id, - isFavorite: !currentContact.isFavorite + isFavorite: isFavorite ) - await refreshContact() } catch { errorMessage = error.userFacingMessage } @@ -504,11 +516,11 @@ private struct ContactDetailAvatarView: View { var body: some View { switch contact.type { case .chat: - ContactAvatar(contact: contact, size: 100) + ContactAvatar(contact: contact, size: 150) case .repeater: - NodeAvatar(publicKey: contact.publicKey, role: .repeater, size: 100) + NodeAvatar(publicKey: contact.publicKey, role: .repeater, size: 150) case .room: - NodeAvatar(publicKey: contact.publicKey, role: .roomServer, size: 100) + NodeAvatar(publicKey: contact.publicKey, role: .roomServer, size: 150) } } } @@ -516,10 +528,11 @@ private struct ContactDetailAvatarView: View { private struct ContactProfileSection: View { let currentContact: ContactDTO let contactTypeLabel: String + @Binding var measuredHeight: CGFloat var body: some View { Section { - VStack(spacing: 16) { + VStack(spacing: 12) { ContactDetailAvatarView(contact: currentContact) VStack(spacing: 4) { @@ -532,28 +545,30 @@ private struct ContactProfileSection: View { .foregroundStyle(.secondary) // Status indicators - HStack(spacing: 12) { - if currentContact.isFavorite { - Label(L10n.Contacts.Contacts.Detail.favorite, systemImage: "star.fill") - .font(.caption) - .foregroundStyle(.yellow) - } - + VStack(spacing: 8) { if currentContact.isBlocked { - Label(L10n.Contacts.Contacts.Detail.blocked, systemImage: "hand.raised.fill") - .font(.caption) - .foregroundStyle(.orange) + HStack(spacing: 4) { + Image(systemName: "hand.raised.fill") + Text(L10n.Contacts.Contacts.Detail.blocked) + } + .font(.caption) + .foregroundStyle(.red) } if currentContact.hasLocation { - Label(L10n.Contacts.Contacts.Detail.hasLocation, systemImage: "location.fill") - .font(.caption) - .foregroundStyle(.green) + HStack(spacing: 4) { + Image(systemName: "location.fill") + Text(L10n.Contacts.Contacts.Detail.hasLocation) + } + .font(.caption) + .foregroundStyle(.green) } } + .padding(.top, 4) } } .frame(maxWidth: .infinity) + .scrollRevealHeaderHeight(into: $measuredHeight) .listRowBackground(Color.clear) } } @@ -563,15 +578,14 @@ private struct ContactActionsSection: View { @Environment(\.appState) private var appState let currentContact: ContactDTO + @Binding var isFavorite: Bool let showFromDirectChat: Bool let isPinging: Bool - let isTogglingFavorite: Bool let pingResult: PingResult? let onJoinRoom: () -> Void let onShowTelemetry: () -> Void let onShowAdminAccess: () -> Void let onPingRepeater: () -> Void - let onToggleFavorite: () -> Void let onShareQR: () -> Void let onShareViaAdvert: () -> Void let isSharing: Bool @@ -636,22 +650,6 @@ private struct ContactActionsSection: View { } } - // Toggle favorite (for all contact types) - Button(action: onToggleFavorite) { - HStack { - Label( - currentContact.isFavorite ? L10n.Contacts.Contacts.Detail.removeFromFavorites : L10n.Contacts.Contacts.Detail.addToFavorites, - systemImage: currentContact.isFavorite ? "star.slash" : "star" - ) - if isTogglingFavorite { - Spacer() - ProgressView() - } - } - } - .disabled(isTogglingFavorite) - .radioDisabled(for: appState.connectionState) - // Share Contact via QR Button(action: onShareQR) { Label(L10n.Contacts.Contacts.Detail.shareContact, systemImage: "square.and.arrow.up") @@ -668,6 +666,10 @@ private struct ContactActionsSection: View { } } .radioDisabled(for: appState.connectionState, or: isSharing || showShareSuccess) + + Toggle(isOn: $isFavorite) { + Label(L10n.Contacts.Contacts.Detail.favorite, systemImage: "star") + } } } } @@ -750,10 +752,13 @@ private struct ContactInfoSection: View { Text(currentContact.nickname ?? L10n.Contacts.Contacts.Detail.nicknameNone) .foregroundStyle(.secondary) - Button(L10n.Contacts.Contacts.Common.edit) { + Button(action: { isEditingNickname = true + }) { + Image(systemName: "pencil") } .buttonStyle(.borderless) + .accessibilityLabel(L10n.Contacts.Contacts.Common.edit) } } @@ -1139,6 +1144,14 @@ private struct ContactDangerSection: View { var body: some View { Section { if currentContact.type == .chat { + Button(action: onToggleBlock) { + Label( + currentContact.isBlocked ? L10n.Contacts.Contacts.Detail.unblockContact : L10n.Contacts.Contacts.Detail.blockContact, + systemImage: currentContact.isBlocked ? "hand.raised.slash" : "hand.raised" + ) + } + .radioDisabled(for: appState.connectionState) + Button(role: .destructive, action: onClearMessages) { HStack { Label(L10n.Contacts.Contacts.Detail.clearMessages, systemImage: "xmark.circle") @@ -1149,14 +1162,6 @@ private struct ContactDangerSection: View { } } .disabled(isClearingMessages) - - Button(action: onToggleBlock) { - Label( - currentContact.isBlocked ? L10n.Contacts.Contacts.Detail.unblockContact : L10n.Contacts.Contacts.Detail.blockContact, - systemImage: currentContact.isBlocked ? "hand.raised.slash" : "hand.raised" - ) - } - .radioDisabled(for: appState.connectionState) } Button(role: .destructive, action: onDelete) { diff --git a/MC1/Views/Contacts/ContactQRShareSheet.swift b/MC1/Views/Contacts/ContactQRShareSheet.swift index 1770032d..6552cb75 100644 --- a/MC1/Views/Contacts/ContactQRShareSheet.swift +++ b/MC1/Views/Contacts/ContactQRShareSheet.swift @@ -55,6 +55,7 @@ struct ContactQRShareSheet: View { } .sensoryFeedback(.success, trigger: copyHapticTrigger) } + .presentationBackground(theme.surfaces?.canvas ?? Color(.systemGroupedBackground)) } // MARK: - Private Methods