PR Suggestions

This commit is contained in:
Mauro Romito
2026-03-31 12:09:50 +02:00
committed by Mauro
parent f31c256037
commit a1029132c5
5 changed files with 40 additions and 43 deletions
@@ -48,7 +48,7 @@ final class AppSettings {
case analyticsConsentState case analyticsConsentState
case hasRunNotificationPermissionsOnboarding case hasRunNotificationPermissionsOnboarding
case hasRunIdentityConfirmationOnboarding case hasRunIdentityConfirmationOnboarding
case hasRequestedAlwaysLocationAuthorization case hasRequestedLocationAlwaysLocationAuthorization
case frequentlyUsedSystemEmojis case frequentlyUsedSystemEmojis
@@ -344,8 +344,8 @@ final class AppSettings {
@UserPreference(key: UserDefaultsKeys.hasRunIdentityConfirmationOnboarding, defaultValue: false, storageType: .userDefaults(store)) @UserPreference(key: UserDefaultsKeys.hasRunIdentityConfirmationOnboarding, defaultValue: false, storageType: .userDefaults(store))
var hasRunIdentityConfirmationOnboarding var hasRunIdentityConfirmationOnboarding
@UserPreference(key: UserDefaultsKeys.hasRequestedAlwaysLocationAuthorization, defaultValue: false, storageType: .userDefaults(store)) @UserPreference(key: UserDefaultsKeys.hasRequestedLocationAlwaysLocationAuthorization, defaultValue: false, storageType: .userDefaults(store))
var hasRequestedAlwaysLocationAuthorization var hasRequestedLocationAlwaysLocationAuthorization
@UserPreference(key: UserDefaultsKeys.frequentlyUsedSystemEmojis, defaultValue: [FrequentlyUsedEmoji](), storageType: .userDefaults(store)) @UserPreference(key: UserDefaultsKeys.frequentlyUsedSystemEmojis, defaultValue: [FrequentlyUsedEmoji](), storageType: .userDefaults(store))
var frequentlyUsedSystemEmojis var frequentlyUsedSystemEmojis
@@ -8,7 +8,7 @@
import Combine import Combine
import Foundation import Foundation
import UIKit import SwiftUI
typealias LocationSharingScreenViewModelType = StateStoreViewModelV2<LocationSharingScreenViewState, LocationSharingScreenViewAction> typealias LocationSharingScreenViewModelType = StateStoreViewModelV2<LocationSharingScreenViewState, LocationSharingScreenViewAction>
@@ -114,8 +114,8 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
private func startLiveLocationSharing() { private func startLiveLocationSharing() {
authorizationStatusSubscription = nil authorizationStatusSubscription = nil
let authStatus = liveLocationManager.authorizationStatus.value let authorizationStatus = liveLocationManager.authorizationStatus.value
switch authStatus { switch authorizationStatus {
case .authorizedAlways: case .authorizedAlways:
// TODO: Start sending live location updates to the room // TODO: Start sending live location updates to the room
break break
@@ -123,7 +123,7 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
// This is to solve a race condition with map libre which always tries first // This is to solve a race condition with map libre which always tries first
// to request the when in use permission, we wait for it and then try again // to request the when in use permission, we wait for it and then try again
authorizationStatusSubscription = liveLocationManager.authorizationStatus authorizationStatusSubscription = liveLocationManager.authorizationStatus
.filter { $0 != authStatus } // skip current status .filter { $0 != authorizationStatus } // skip current status
.first() // this publisher only fires when there is an actual change, and if the user is done with permissions .first() // this publisher only fires when there is an actual change, and if the user is done with permissions
.sink { [weak self] newValue in .sink { [weak self] newValue in
guard newValue == .authorizedWhenInUse else { return } guard newValue == .authorizedWhenInUse else { return }
@@ -137,7 +137,7 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
} }
authorizationStatusSubscription = liveLocationManager.authorizationStatus authorizationStatusSubscription = liveLocationManager.authorizationStatus
.filter { $0 != authStatus } // skip current status .filter { $0 != authorizationStatus } // skip current status
.first() // this publisher only fires when there is an actual change, and if the user is done with permissions .first() // this publisher only fires when there is an actual change, and if the user is done with permissions
.sink { newValue in .sink { newValue in
guard newValue == .authorizedAlways else { return } guard newValue == .authorizedAlways else { return }
@@ -149,10 +149,9 @@ class LocationSharingScreenViewModel: LocationSharingScreenViewModelType, Locati
} }
private func showMissingAlwaysAuthorizedAlert() { private func showMissingAlwaysAuthorizedAlert() {
let action: () -> Void = { [weak self] in self?.actionsSubject.send(.openSystemSettings) }
state.bindings.alertInfo = .init(locationSharingViewError: .missingAlwaysAuthorization, state.bindings.alertInfo = .init(locationSharingViewError: .missingAlwaysAuthorization,
primaryButton: .init(title: L10n.actionNotNow, role: .cancel, action: nil), primaryButton: .init(title: L10n.actionNotNow, role: .cancel, action: nil),
secondaryButton: .init(title: L10n.commonSettings, action: action)) secondaryButton: .init(title: L10n.commonSettings) { [weak self] in self?.actionsSubject.send(.openSystemSettings) })
} }
private func sendLocation(_ geoURI: GeoURI, isUserLocation: Bool) async { private func sendLocation(_ geoURI: GeoURI, isUserLocation: Bool) async {
@@ -39,8 +39,8 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana
@discardableResult @discardableResult
func requestAlwaysAuthorizationIfPossible() -> Bool { func requestAlwaysAuthorizationIfPossible() -> Bool {
guard !appSettings.hasRequestedAlwaysLocationAuthorization else { return false } guard !appSettings.hasRequestedLocationAlwaysLocationAuthorization else { return false }
appSettings.hasRequestedAlwaysLocationAuthorization = true appSettings.hasRequestedLocationAlwaysLocationAuthorization = true
locationManager.requestAlwaysAuthorization() locationManager.requestAlwaysAuthorization()
return true return true
} }
@@ -51,14 +51,8 @@ class LiveLocationManager: NSObject, LiveLocationManagerProtocol, CLLocationMana
// If the system resets authorization to notDetermined (e.g. after app reinstall or // If the system resets authorization to notDetermined (e.g. after app reinstall or
// settings reset), clear the flag so we can request again. // settings reset), clear the flag so we can request again.
if manager.authorizationStatus == .notDetermined { if manager.authorizationStatus == .notDetermined {
appSettings.hasRequestedAlwaysLocationAuthorization = false appSettings.hasRequestedLocationAlwaysLocationAuthorization = false
} }
authorizationStatusSubject.send(manager.authorizationStatus) authorizationStatusSubject.send(manager.authorizationStatus)
} }
// MARK: - Private
// TODO: Add CLLocationManager location update handling to forward updates to rooms
// TODO: Track which rooms are currently sharing live location
// TODO: Send location updates to all active rooms via clientProxy
} }
@@ -11,7 +11,7 @@ import Foundation
// sourcery: AutoMockable // sourcery: AutoMockable
protocol LiveLocationManagerProtocol: AnyObject { protocol LiveLocationManagerProtocol: AnyObject {
/// Publishes the current "Always" location authorization status. /// Publishes the current location authorization status.
var authorizationStatus: CurrentValuePublisher<CLAuthorizationStatus, Never> { get } var authorizationStatus: CurrentValuePublisher<CLAuthorizationStatus, Never> { get }
/// Requests "Always" location authorization from the user if the system allows it. /// Requests "Always" location authorization from the user if the system allows it.
@@ -12,11 +12,11 @@ import CoreLocation
import Testing import Testing
@MainActor @MainActor
struct LocationSharingScreenViewModelTests { final class LocationSharingScreenViewModelTests {
var timelineProxy: TimelineProxyMock! private var timelineProxy: TimelineProxyMock!
var viewModel: LocationSharingScreenViewModel! private var viewModel: LocationSharingScreenViewModel!
var context: LocationSharingScreenViewModel.Context { private var context: LocationSharingScreenViewModel.Context {
viewModel.context viewModel.context
} }
@@ -24,8 +24,12 @@ struct LocationSharingScreenViewModelTests {
AppSettings.resetAllSettings() AppSettings.resetAllSettings()
} }
deinit {
AppSettings.resetAllSettings()
}
@Test @Test
mutating func userDidPan() { func userDidPan() {
setupViewModel() setupViewModel()
#expect(context.viewState.isSharingUserLocation) #expect(context.viewState.isSharingUserLocation)
#expect(context.showsUserLocationMode == .showAndFollow) #expect(context.showsUserLocationMode == .showAndFollow)
@@ -35,7 +39,7 @@ struct LocationSharingScreenViewModelTests {
} }
@Test @Test
mutating func centerOnUser() { func centerOnUser() {
setupViewModel() setupViewModel()
#expect(context.viewState.isSharingUserLocation) #expect(context.viewState.isSharingUserLocation)
context.showsUserLocationMode = .show context.showsUserLocationMode = .show
@@ -46,7 +50,7 @@ struct LocationSharingScreenViewModelTests {
} }
@Test @Test
mutating func centerOnUserWithoutAuth() { func centerOnUserWithoutAuthorization() {
setupViewModel() setupViewModel()
context.showsUserLocationMode = .hide context.showsUserLocationMode = .hide
context.isLocationAuthorized = nil context.isLocationAuthorized = nil
@@ -55,7 +59,7 @@ struct LocationSharingScreenViewModelTests {
} }
@Test @Test
mutating func centerOnUserWithDeniedAuth() { func centerOnUserWithDeniedAuthorization() {
setupViewModel() setupViewModel()
context.isLocationAuthorized = false context.isLocationAuthorized = false
context.showsUserLocationMode = .hide context.showsUserLocationMode = .hide
@@ -65,18 +69,18 @@ struct LocationSharingScreenViewModelTests {
} }
@Test @Test
mutating func errorMapping() { func errorMapping() {
setupViewModel() setupViewModel()
let mapError = AlertInfo(locationSharingViewError: .mapError(.failedLoadingMap)) let mapError = AlertInfo(locationSharingViewError: .mapError(.failedLoadingMap))
#expect(mapError.title == L10n.errorFailedLoadingMap(InfoPlistReader.main.bundleDisplayName)) #expect(mapError.title == L10n.errorFailedLoadingMap(InfoPlistReader.main.bundleDisplayName))
let locationError = AlertInfo(locationSharingViewError: .mapError(.failedLocatingUser)) let locationError = AlertInfo(locationSharingViewError: .mapError(.failedLocatingUser))
#expect(locationError.title == L10n.errorFailedLocatingUser(InfoPlistReader.main.bundleDisplayName)) #expect(locationError.title == L10n.errorFailedLocatingUser(InfoPlistReader.main.bundleDisplayName))
let authorizationError = AlertInfo(locationSharingViewError: .missingAuthorization) let AuthorizationError = AlertInfo(locationSharingViewError: .missingAuthorization)
#expect(authorizationError.message == L10n.dialogPermissionLocationDescriptionIos(InfoPlistReader.main.bundleDisplayName)) #expect(AuthorizationError.message == L10n.dialogPermissionLocationDescriptionIos(InfoPlistReader.main.bundleDisplayName))
} }
@Test @Test
mutating func sendUserLocation() async throws { func sendUserLocation() async throws {
setupViewModel() setupViewModel()
context.mapCenterLocation = .init(latitude: 0, longitude: 0) context.mapCenterLocation = .init(latitude: 0, longitude: 0)
context.geolocationUncertainty = 10 context.geolocationUncertainty = 10
@@ -98,7 +102,7 @@ struct LocationSharingScreenViewModelTests {
} }
@Test @Test
mutating func sendPickedLocation() async throws { func sendPickedLocation() async throws {
setupViewModel() setupViewModel()
context.mapCenterLocation = .init(latitude: 0, longitude: 0) context.mapCenterLocation = .init(latitude: 0, longitude: 0)
context.isLocationAuthorized = nil context.isLocationAuthorized = nil
@@ -123,28 +127,28 @@ struct LocationSharingScreenViewModelTests {
// MARK: - Live Location Authorization Tests // MARK: - Live Location Authorization Tests
@Test @Test
mutating func startLiveLocationWithDeniedAuth() { func startLiveLocationWithDeniedAuthorization() {
setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .denied)) setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .denied))
context.send(viewAction: .startLiveLocation) context.send(viewAction: .startLiveLocation)
#expect(context.alertInfo?.id == .missingAlwaysAuthorization) #expect(context.alertInfo?.id == .missingAlwaysAuthorization)
} }
@Test @Test
mutating func startLiveLocationWithRestrictedAuth() { func startLiveLocationWithRestrictedAuthorization() {
setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .restricted)) setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .restricted))
context.send(viewAction: .startLiveLocation) context.send(viewAction: .startLiveLocation)
#expect(context.alertInfo?.id == .missingAlwaysAuthorization) #expect(context.alertInfo?.id == .missingAlwaysAuthorization)
} }
@Test @Test
mutating func startLiveLocationWithAlwaysAuth() { func startLiveLocationWithAlwaysAuthorization() {
setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .authorizedAlways)) setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .authorizedAlways))
context.send(viewAction: .startLiveLocation) context.send(viewAction: .startLiveLocation)
#expect(context.alertInfo == nil) #expect(context.alertInfo == nil)
} }
@Test @Test
mutating func startLiveLocationWithWhenInUseAuthAlreadyRequested() { func startLiveLocationWithWhenInUseAuthorizationAlreadyRequested() {
setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .authorizedWhenInUse, setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .authorizedWhenInUse,
requestAlwaysAuthorizationIfPossibleReturnValue: false)) requestAlwaysAuthorizationIfPossibleReturnValue: false))
context.send(viewAction: .startLiveLocation) context.send(viewAction: .startLiveLocation)
@@ -152,7 +156,7 @@ struct LocationSharingScreenViewModelTests {
} }
@Test @Test
mutating func startLiveLocationWithWhenInUseAuthNotYetRequested() { func startLiveLocationWithWhenInUseAuthorizationNotYetRequested() {
setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .authorizedWhenInUse, setupViewModel(liveLocationManagerConfiguration: .init(authorizationStatus: .authorizedWhenInUse,
requestAlwaysAuthorizationIfPossibleReturnValue: true)) requestAlwaysAuthorizationIfPossibleReturnValue: true))
context.send(viewAction: .startLiveLocation) context.send(viewAction: .startLiveLocation)
@@ -161,7 +165,7 @@ struct LocationSharingScreenViewModelTests {
} }
@Test @Test
mutating func startLiveLocationWithNotDeterminedAuthTransitionsToWhenInUse() async { func startLiveLocationWithNotDeterminedAuthorizationTransitionsToWhenInUse() async {
let authorizationStatusSubject = CurrentValueSubject<CLAuthorizationStatus, Never>(.notDetermined) let authorizationStatusSubject = CurrentValueSubject<CLAuthorizationStatus, Never>(.notDetermined)
let liveLocationManagerMock = LiveLocationManagerMock() let liveLocationManagerMock = LiveLocationManagerMock()
liveLocationManagerMock.underlyingAuthorizationStatus = .init(authorizationStatusSubject) liveLocationManagerMock.underlyingAuthorizationStatus = .init(authorizationStatusSubject)
@@ -173,7 +177,7 @@ struct LocationSharingScreenViewModelTests {
// No alert yet — waiting for MapLibre to resolve the status to whenInUse // No alert yet — waiting for MapLibre to resolve the status to whenInUse
#expect(context.alertInfo == nil) #expect(context.alertInfo == nil)
// Simulate MapLibre resolving the authorization to whenInUse, and confirm that the ViewModel // Simulate MapLibre resolving the Authorization to whenInUse, and confirm that the ViewModel
// recurses and calls requestAlwaysAuthorizationIfPossible as a result // recurses and calls requestAlwaysAuthorizationIfPossible as a result
await waitForConfirmation { confirmation in await waitForConfirmation { confirmation in
liveLocationManagerMock.requestAlwaysAuthorizationIfPossibleClosure = { liveLocationManagerMock.requestAlwaysAuthorizationIfPossibleClosure = {
@@ -183,13 +187,13 @@ struct LocationSharingScreenViewModelTests {
authorizationStatusSubject.send(.authorizedWhenInUse) authorizationStatusSubject.send(.authorizedWhenInUse)
} }
// The request was made, so no alert — waiting for the always authorization prompt response // The request was made, so no alert — waiting for the always Authorization prompt response
#expect(context.alertInfo == nil) #expect(context.alertInfo == nil)
} }
// MARK: - Private // MARK: - Private
private mutating func setupViewModel(liveLocationManagerConfiguration: LiveLocationManagerMock.Configuration = .init()) { private func setupViewModel(liveLocationManagerConfiguration: LiveLocationManagerMock.Configuration = .init()) {
timelineProxy = TimelineProxyMock(.init()) timelineProxy = TimelineProxyMock(.init())
viewModel = LocationSharingScreenViewModel(interactionMode: .picker, viewModel = LocationSharingScreenViewModel(interactionMode: .picker,
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration, mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,
@@ -203,7 +207,7 @@ struct LocationSharingScreenViewModelTests {
viewModel.state.bindings.isLocationAuthorized = true viewModel.state.bindings.isLocationAuthorized = true
} }
private mutating func setupViewModel(liveLocationManagerMock: LiveLocationManagerMock) { private func setupViewModel(liveLocationManagerMock: LiveLocationManagerMock) {
timelineProxy = TimelineProxyMock(.init()) timelineProxy = TimelineProxyMock(.init())
viewModel = LocationSharingScreenViewModel(interactionMode: .picker, viewModel = LocationSharingScreenViewModel(interactionMode: .picker,
mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration, mapURLBuilder: ServiceLocator.shared.settings.mapTilerConfiguration,