mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:02:10 +00:00
Live Location Sharing Banner (#5370)
* Live Location Sharing Banner # Conflicts: # ElementX.xcodeproj/project.pbxproj * updated the top banner modifier and the top banner views # Conflicts: # ElementX/Resources/Localizations/en-US.lproj/Localizable.strings # ElementX/Resources/Localizations/en.lproj/Localizable.strings * improved the stopping function from the timeline item * stop live location sharing before starting a new one. * added some tests for LiveLocationManager * pr suggestions
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// A single banner item to be displayed in a `topBanners` overlay.
|
||||
/// An individual banner in the vertical stack of a `TopBannerLayer`.
|
||||
struct TopBannerItem {
|
||||
var banner: AnyView
|
||||
var isVisible: Bool
|
||||
@@ -18,18 +18,42 @@ struct TopBannerItem {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Z-axis banner slot displayed in a `topBanners` overlay. Each slot may
|
||||
/// contain one or more vertically stacked banners, each with its own
|
||||
/// visibility. The slot's overall visibility is derived from whether any of
|
||||
/// its vertical banners are visible. Later items in the `topBanners` array
|
||||
/// are overlayed on top of earlier ones (Z-axis).
|
||||
struct TopBannerLayer {
|
||||
var verticalBanners: [TopBannerItem]
|
||||
|
||||
var isVisible: Bool {
|
||||
verticalBanners.contains { $0.isVisible }
|
||||
}
|
||||
|
||||
/// Convenience initialiser for a single-banner slot.
|
||||
init(_ banner: some View, isVisible: Bool) {
|
||||
verticalBanners = [TopBannerItem(banner, isVisible: isVisible)]
|
||||
}
|
||||
|
||||
init(verticalBanners: [TopBannerItem]) {
|
||||
self.verticalBanners = verticalBanners
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Overlays the given banner view at the top edge of this view, using a
|
||||
/// slide from the top edge when `isVisible` is toggled.
|
||||
func topBanner(_ banner: some View, isVisible: Bool, footer: some View = EmptyView()) -> some View {
|
||||
topBanners([TopBannerItem(banner, isVisible: isVisible)], footer: footer)
|
||||
topBanners([TopBannerLayer(banner, isVisible: isVisible)], footer: footer)
|
||||
}
|
||||
|
||||
/// Overlays the given banner views at the top edge of this view. Each banner
|
||||
/// slides from the top edge based on its own `isVisible` flag. Later items in
|
||||
/// the array are overlayed on top of earlier ones. The footer is shared across
|
||||
/// all banners and displayed below the topmost visible banner.
|
||||
func topBanners(_ items: [TopBannerItem], footer: some View = EmptyView()) -> some View {
|
||||
/// Overlays the given Z-axis banner slots at the top edge of this view.
|
||||
/// Later items in the array are overlayed on top of earlier ones. Within
|
||||
/// each slot, visible vertical banners are stacked in a VStack and slide
|
||||
/// in/out from the top edge. The shadow and bottom padding are applied to
|
||||
/// the VStack of each slot. The footer is shared and displayed below the
|
||||
/// topmost visible slot.
|
||||
func topBanners(_ items: [TopBannerLayer], footer: some View = EmptyView()) -> some View {
|
||||
let anyBannerVisible = items.contains { $0.isVisible }
|
||||
return overlay(alignment: .top) {
|
||||
ZStack(alignment: .top) {
|
||||
@@ -38,8 +62,19 @@ extension View {
|
||||
ZStack(alignment: .top) {
|
||||
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
|
||||
if item.isVisible {
|
||||
item.banner
|
||||
.transition(.move(edge: .top))
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(item.verticalBanners.enumerated()), id: \.offset) { _, vBanner in
|
||||
if vBanner.isVisible {
|
||||
vBanner.banner
|
||||
.transition(.move(edge: .top))
|
||||
}
|
||||
}
|
||||
}
|
||||
.compositingGroup()
|
||||
.shadow(color: Color(red: 0.11, green: 0.11, blue: 0.13).opacity(0.1), radius: 12, x: 0, y: 4)
|
||||
// To include the shadow in the size
|
||||
.padding(.bottom, 28)
|
||||
.transition(.move(edge: .top))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,7 +88,7 @@ extension View {
|
||||
.hidden()
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.animation(.elementDefault, value: items.map(\.isVisible))
|
||||
.animation(.elementDefault, value: items.map { $0.verticalBanners.map(\.isVisible) })
|
||||
.clipped()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ enum TestablePreviewsDictionary {
|
||||
"LegalInformationScreen_Previews" : LegalInformationScreen_Previews.self,
|
||||
"LinkNewDeviceScreen_Previews" : LinkNewDeviceScreen_Previews.self,
|
||||
"LiveLocationRoomTimelineView_Previews" : LiveLocationRoomTimelineView_Previews.self,
|
||||
"LiveLocationSharingBannerView_Previews" : LiveLocationSharingBannerView_Previews.self,
|
||||
"LoadableImage_Previews" : LoadableImage_Previews.self,
|
||||
"LocationMarkerView_Previews" : LocationMarkerView_Previews.self,
|
||||
"LocationPickerSheet_Previews" : LocationPickerSheet_Previews.self,
|
||||
|
||||
@@ -192,6 +192,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
actionsSubject.send(.presentThreadList)
|
||||
case .displayThread(let threadRootEventID, let focussedEventID):
|
||||
actionsSubject.send(.presentThread(threadRootEventID: threadRootEventID, focussedEventID: focussedEventID))
|
||||
case .stopLiveLocationSharing:
|
||||
Task { [weak self] in await self?.timelineViewModel.stopLiveLocationSharing() }
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
@@ -21,6 +21,7 @@ enum RoomScreenViewModelAction: Equatable {
|
||||
case displayKnockRequests
|
||||
case displayRoom(roomID: String, via: [String])
|
||||
case displayMessageForwarding(MessageForwardingItem)
|
||||
case stopLiveLocationSharing
|
||||
}
|
||||
|
||||
enum RoomScreenViewAction {
|
||||
@@ -34,6 +35,7 @@ enum RoomScreenViewAction {
|
||||
case viewKnockRequests
|
||||
case displaySuccessorRoom
|
||||
case displayThreadList
|
||||
case tappedStopLiveLocation
|
||||
}
|
||||
|
||||
struct RoomScreenViewState: BindableState {
|
||||
@@ -48,6 +50,8 @@ struct RoomScreenViewState: BindableState {
|
||||
!pinnedEventsBannerState.isEmpty && lastScrollDirection != .top
|
||||
}
|
||||
|
||||
var isSharingLiveLocation = false
|
||||
|
||||
var canSendMessage = true
|
||||
|
||||
/// Whether or not starting a call is supported.
|
||||
|
||||
@@ -122,6 +122,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
actionsSubject.send(.displayRoom(roomID: successorID, via: Array(serverNames)))
|
||||
case .displayThreadList:
|
||||
actionsSubject.send(.displayThreadList)
|
||||
case .tappedStopLiveLocation:
|
||||
actionsSubject.send(.stopLiveLocationSharing)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,6 +174,15 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
appSettings.$knockingEnabled
|
||||
.weakAssign(to: \.state.isKnockingEnabled, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
appSettings.$liveLocationSharingEnabled
|
||||
.combineLatest(appSettings.$liveLocationSharingTimeoutDatesByRoomID)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] isEnabled, timeoutDatesByRoomID in
|
||||
guard let self else { return }
|
||||
state.isSharingLiveLocation = isEnabled && timeoutDatesByRoomID.keys.contains(roomProxy.id)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
roomProxy.infoPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
|
||||
@@ -28,9 +28,6 @@ struct KnockRequestsBannerView: View {
|
||||
mainContent
|
||||
.padding(16)
|
||||
.background(.compound.bgCanvasDefaultLevel1, in: RoundedRectangle(cornerRadius: 12))
|
||||
.compositingGroup()
|
||||
.shadow(color: Color(red: 0.11, green: 0.11, blue: 0.13).opacity(0.1), radius: 12, x: 0, y: 4)
|
||||
.padding(.bottom, 28)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
@@ -202,6 +199,14 @@ struct KnockRequestsBannerView_Previews: PreviewProvider, TestablePreview {
|
||||
]
|
||||
|
||||
static var previews: some View {
|
||||
allPreviews
|
||||
.padding()
|
||||
.background(.gray)
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
static var allPreviews: some View {
|
||||
KnockRequestsBannerView(requests: singleRequest) { } onAccept: { _ in } onViewAll: { }
|
||||
.previewDisplayName("Single Request")
|
||||
// swiftlint:disable:next trailing_closure
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//
|
||||
// Copyright 2025 Element Creations Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct LiveLocationSharingBannerView: View {
|
||||
var onStop: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 9) {
|
||||
CompoundIcon(\.locationPinSolid, size: .medium, relativeTo: .compound.bodyMDSemibold)
|
||||
.foregroundColor(Color.compound.iconSuccessPrimary)
|
||||
.accessibilityHidden(true)
|
||||
Text(L10n.screenRoomLiveLocationBanner)
|
||||
.font(.compound.bodyMDSemibold)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
Spacer()
|
||||
Button(L10n.actionStop, role: .destructive, action: onStop)
|
||||
.buttonStyle(.compound(.primary, size: .small))
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
.padding(.horizontal, 15)
|
||||
.background(Color.compound.bgCanvasDefault)
|
||||
.overlay(alignment: .top) { Color.compound.separatorPrimary.frame(height: 1) }
|
||||
.overlay(alignment: .bottom) { Color.compound.separatorPrimary.frame(height: 1) }
|
||||
}
|
||||
}
|
||||
|
||||
struct LiveLocationSharingBannerView_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
LiveLocationSharingBannerView { }
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -23,9 +23,6 @@ struct PinnedItemsBannerView: View {
|
||||
.padding(.vertical, 16)
|
||||
.padding(.leading, 16)
|
||||
.background(Color.compound.bgCanvasDefault)
|
||||
.shadow(color: Color(red: 0.11, green: 0.11, blue: 0.13).opacity(0.1), radius: 12, x: 0, y: 4)
|
||||
// To include the shadow in the size
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
private var mainButton: some View {
|
||||
@@ -111,5 +108,8 @@ struct PinnedItemsBannerView_Previews: PreviewProvider, TestablePreview {
|
||||
onMainButtonTap: { },
|
||||
onViewAllButtonTap: { })
|
||||
}
|
||||
.padding()
|
||||
.background(.gray)
|
||||
.previewLayout(.sizeThatFits)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,17 +35,27 @@ struct RoomScreen: View {
|
||||
}
|
||||
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
|
||||
.topBanners([
|
||||
TopBannerItem(pinnedItemsBanner, isVisible: context.viewState.shouldShowPinnedEventsBanner && !isVoiceOverEnabled),
|
||||
// This can overlay on top of the pinnedItemsBanner
|
||||
TopBannerItem(knockRequestsBanner, isVisible: context.viewState.shouldSeeKnockRequests)
|
||||
TopBannerLayer(verticalBanners: [
|
||||
TopBannerItem(pinnedItemsBanner, isVisible: context.viewState.shouldShowPinnedEventsBanner && !isVoiceOverEnabled),
|
||||
TopBannerItem(liveLocationBanner, isVisible: context.viewState.isSharingLiveLocation && !isVoiceOverEnabled)
|
||||
]),
|
||||
// This can overlay on top of the stacked banners
|
||||
TopBannerLayer(knockRequestsBanner, isVisible: context.viewState.shouldSeeKnockRequests)
|
||||
], footer: dateBadge)
|
||||
.safeAreaInset(edge: .top) {
|
||||
// When VoiceOver is enabled, the table view isn't reversed and the scroll gestures
|
||||
// don't trigger meaning the banner never hides itself and so the .overlay layout
|
||||
// above permanently obscures the top of the timeline. So whenever VoiceOver is
|
||||
// enabled we use a safe area inset to vertically stack it above the timeline.
|
||||
if context.viewState.shouldShowPinnedEventsBanner, isVoiceOverEnabled {
|
||||
pinnedItemsBanner
|
||||
if context.viewState.shouldShowPinnedEventsBanner || context.viewState.isSharingLiveLocation, isVoiceOverEnabled {
|
||||
VStack(spacing: 0) {
|
||||
if context.viewState.shouldShowPinnedEventsBanner {
|
||||
pinnedItemsBanner
|
||||
}
|
||||
if context.viewState.isSharingLiveLocation {
|
||||
liveLocationBanner
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
@@ -76,6 +86,12 @@ struct RoomScreen: View {
|
||||
.sentryTrace("\(Self.self)")
|
||||
}
|
||||
|
||||
private var liveLocationBanner: some View {
|
||||
LiveLocationSharingBannerView {
|
||||
context.send(viewAction: .tappedStopLiveLocation)
|
||||
}
|
||||
}
|
||||
|
||||
private var pinnedItemsBanner: some View {
|
||||
PinnedItemsBannerView(state: context.viewState.pinnedEventsBannerState,
|
||||
onMainButtonTap: { context.send(viewAction: .tappedPinnedEventsBanner) },
|
||||
|
||||
@@ -70,7 +70,7 @@ enum TimelineViewAction {
|
||||
case handlePollAction(TimelineViewPollAction)
|
||||
case handleAudioPlayerAction(TimelineAudioPlayerAction)
|
||||
|
||||
case stopLiveLocationSharing
|
||||
case stopLiveLocationSharing(TimelineItemIdentifier)
|
||||
|
||||
/// Focus the timeline onto the specified event ID (switching to a detached timeline if needed).
|
||||
case focusOnEventID(String)
|
||||
@@ -143,6 +143,8 @@ struct TimelineViewState: BindableState {
|
||||
var mapTilerConfiguration: MapTilerConfiguration
|
||||
|
||||
var enableKeyShareOnInvite: Bool
|
||||
|
||||
var stoppedLiveLocationIDs: Set<TimelineItemIdentifier> = []
|
||||
|
||||
var bindings: TimelineViewStateBindings
|
||||
}
|
||||
|
||||
@@ -198,8 +198,9 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
handlePollAction(pollAction)
|
||||
case .handleAudioPlayerAction(let audioPlayerAction):
|
||||
handleAudioPlayerAction(audioPlayerAction)
|
||||
case .stopLiveLocationSharing:
|
||||
Task { await userSession.liveLocationManager.stopLiveLocation(roomID: state.roomID) }
|
||||
case .stopLiveLocationSharing(let id):
|
||||
state.stoppedLiveLocationIDs.insert(id)
|
||||
Task { await stopLiveLocationSharing() }
|
||||
case .focusOnEventID(let eventID):
|
||||
Task { await focusOnEvent(eventID: eventID) }
|
||||
case .focusLive:
|
||||
@@ -276,6 +277,10 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
func stopLiveLocationSharing() async {
|
||||
await userSession.liveLocationManager.stopLiveLocation(roomID: roomProxy.id)
|
||||
}
|
||||
|
||||
func makeForwardingItem(for itemID: TimelineItemIdentifier) async -> MessageForwardingItem? {
|
||||
guard let content = await timelineController.messageEventContent(for: itemID) else { return nil }
|
||||
return .init(id: itemID, roomID: roomProxy.id, content: content)
|
||||
|
||||
@@ -18,7 +18,8 @@ protocol TimelineViewModelProtocol {
|
||||
func process(composerAction: ComposerToolbarViewModelAction)
|
||||
/// Updates the timeline to show and highlight the item with the corresponding event ID.
|
||||
func focusOnEvent(eventID: String) async
|
||||
|
||||
/// Stops the current live location sharing
|
||||
func stopLiveLocationSharing() async
|
||||
/// Handles getting the content to forward an item given its item ID.
|
||||
func makeForwardingItem(for itemID: TimelineItemIdentifier) async -> MessageForwardingItem?
|
||||
}
|
||||
|
||||
+3
-3
@@ -15,10 +15,10 @@ struct LiveLocationRoomTimelineView: View {
|
||||
let timelineItem: LiveLocationRoomTimelineItem
|
||||
private let currentDate: Date
|
||||
|
||||
init(currentDate: Date = .now, timelineItem: LiveLocationRoomTimelineItem) {
|
||||
init(currentDate: Date = .now, timelineItem: LiveLocationRoomTimelineItem, isStopped: Bool = false) {
|
||||
self.currentDate = currentDate
|
||||
self.timelineItem = timelineItem
|
||||
_hasExpired = State(initialValue: currentDate >= timelineItem.content.timeoutDate)
|
||||
_hasExpired = State(initialValue: isStopped || currentDate >= timelineItem.content.timeoutDate)
|
||||
}
|
||||
|
||||
/// A publisher that fires once when the timeoutDate is reached, setting `hasExpired` to true.
|
||||
@@ -191,7 +191,7 @@ struct LiveLocationRoomTimelineView: View {
|
||||
|
||||
private func stop() {
|
||||
hasExpired = true
|
||||
context?.send(viewAction: .stopLiveLocationSharing)
|
||||
context?.send(viewAction: .stopLiveLocationSharing(timelineItem.id))
|
||||
}
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
@@ -60,6 +60,12 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana
|
||||
}
|
||||
|
||||
func startLiveLocation(roomID: String, duration: Duration) async -> Result<Void, LiveLocationManagerError> {
|
||||
// Stop any existing session for this room first (e.g. one started from a different device)
|
||||
// before starting a new one.
|
||||
if appSettings.liveLocationSharingTimeoutDatesByRoomID[roomID] != nil {
|
||||
await stopLiveLocation(roomID: roomID)
|
||||
}
|
||||
|
||||
guard case .joined(let roomProxy) = await clientProxy.roomForIdentifier(roomID) else {
|
||||
MXLog.error("Failed to resolve joined room for identifier: \(roomID)")
|
||||
return .failure(.roomNotJoined)
|
||||
@@ -79,17 +85,23 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana
|
||||
}
|
||||
|
||||
func stopLiveLocation(roomID: String) async {
|
||||
// Best effort: send the stop event to the room regardless of tracking state.
|
||||
if let roomProxy = await resolveRoomProxy(for: roomID) {
|
||||
var roomProxy: JoinedRoomProxyProtocol?
|
||||
let cachedRoomProxy = activeRoomProxies[roomID]
|
||||
appSettings.liveLocationSharingTimeoutDatesByRoomID.removeValue(forKey: roomID)
|
||||
|
||||
if let cachedRoomProxy {
|
||||
roomProxy = cachedRoomProxy
|
||||
// Best effort: send the stop event to the room regardless of tracking state.
|
||||
} else if case let .joined(fetchedRoomProxy) = await clientProxy.roomForIdentifier(roomID) {
|
||||
roomProxy = fetchedRoomProxy
|
||||
}
|
||||
|
||||
if let roomProxy {
|
||||
let result = await roomProxy.stopLiveLocationShare()
|
||||
if case .failure(let error) = result {
|
||||
MXLog.error("Failed to stop live location share in room \(roomID): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// Always clean up locally.
|
||||
appSettings.liveLocationSharingTimeoutDatesByRoomID.removeValue(forKey: roomID)
|
||||
activeRoomProxies.removeValue(forKey: roomID)
|
||||
}
|
||||
|
||||
// MARK: - CLLocationManagerDelegate
|
||||
|
||||
@@ -76,7 +76,7 @@ struct RoomTimelineItemView: View {
|
||||
case .callNotification(let item):
|
||||
CallNotificationRoomTimelineView(timelineItem: item)
|
||||
case .liveLocation(let item):
|
||||
LiveLocationRoomTimelineView(timelineItem: item)
|
||||
LiveLocationRoomTimelineView(timelineItem: item, isStopped: context?.viewState.stoppedLiveLocationIDs.contains(item.id) ?? false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user