Fixes #367 - Refactor AppSettings usage patterns

This commit is contained in:
Stefan Ceriu
2022-12-22 10:59:12 +02:00
committed by Stefan Ceriu
parent e5522e7753
commit 98cfc2c9fb
9 changed files with 177 additions and 19 deletions
+10 -10
View File
@@ -52,7 +52,7 @@ final class AppSettings: ObservableObject {
/// The last known version of the app that was launched on this device, which is
/// used to detect when migrations should be run. When `nil` the app may have been
/// deleted between runs so should clear data in the shared container and keychain.
@AppStorage(UserDefaultsKeys.lastVersionLaunched.rawValue, store: store)
@UserSetting(key: UserDefaultsKeys.lastVersionLaunched.rawValue, defaultValue: nil, storage: store)
var lastVersionLaunched: String?
/// The default homeserver address used. This is intentionally a string without a scheme
@@ -114,27 +114,27 @@ final class AppSettings: ObservableObject {
}
/// `true` when the user has opted in to send analytics.
@AppStorage(UserDefaultsKeys.enableAnalytics.rawValue, store: store)
var enableAnalytics = false
@UserSetting(key: UserDefaultsKeys.enableAnalytics.rawValue, defaultValue: false, storage: store)
var enableAnalytics
/// Indicates if the device has already called identify for this session to PostHog.
/// This is separate to `enableAnalytics` as logging out leaves analytics
/// enabled, but requires the next account to be identified separately.
@AppStorage(UserDefaultsKeys.isIdentifiedForAnalytics.rawValue, store: store)
var isIdentifiedForAnalytics = false
@UserSetting(key: UserDefaultsKeys.isIdentifiedForAnalytics.rawValue, defaultValue: false, storage: store)
var isIdentifiedForAnalytics
// MARK: - Room Screen
@AppStorage(UserDefaultsKeys.timelineStyle.rawValue, store: store)
var timelineStyle = TimelineStyle.bubbles
@UserSettingRawRepresentable(key: UserDefaultsKeys.timelineStyle.rawValue, defaultValue: TimelineStyle.bubbles, storage: store)
var timelineStyle
// MARK: - Notifications
@AppStorage(UserDefaultsKeys.enableInAppNotifications.rawValue, store: store)
var enableInAppNotifications = true
@UserSetting(key: UserDefaultsKeys.timelineStyle.rawValue, defaultValue: true, storage: store)
var enableInAppNotifications
/// Tag describing which set of device specific rules a pusher executes.
@AppStorage(UserDefaultsKeys.pusherProfileTag.rawValue, store: store)
@UserSetting(key: UserDefaultsKeys.pusherProfileTag.rawValue, defaultValue: nil, storage: store)
var pusherProfileTag: String?
// MARK: - Other
@@ -0,0 +1,127 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Taken from https://www.swiftbysundell.com/articles/property-wrappers-in-swift/
import Combine
import Foundation
/// Property wrapper that allows transparent access to user defaults and exposes
/// a combine publisher for listening to value changes
///
/// Please use `UserSettingRawRepresentable` for storing RawRepresentable values
@propertyWrapper
struct UserSetting<Value: Equatable> {
private let key: String
private let defaultValue: Value
private let storage: UserDefaults
private let publisher: CurrentValueSubject<Value, Never>
init(key: String, defaultValue: Value, storage: UserDefaults = .standard) {
self.key = key
self.defaultValue = defaultValue
self.storage = storage
let value = storage.value(forKey: key) as? Value ?? defaultValue
publisher = CurrentValueSubject<Value, Never>(value)
}
var wrappedValue: Value {
get {
let value = storage.value(forKey: key) as? Value
return value ?? defaultValue
}
set {
if let optional = newValue as? AnyOptional, optional.isNil {
storage.removeObject(forKey: key)
publisher.send(defaultValue)
} else {
storage.setValue(newValue, forKey: key)
publisher.send(newValue)
}
}
}
var projectedValue: AnyPublisher<Value, Never> {
publisher.removeDuplicates(by: { $0 == $1 }).eraseToAnyPublisher()
}
}
extension UserSetting where Value: ExpressibleByNilLiteral {
init(key: String, storage: UserDefaults = .standard) {
self.init(key: key, defaultValue: nil, storage: storage)
}
}
/// Property wrapper that allows transparent access to user defaults for RawRepresentable types
/// and exposes a combine publisher for listening to value changes
///
/// Tried extending UserSetting with RawRepresentable conformance but in that case the non-restricted
/// method takes precedence. Decided to go with with the simple solution instead of fighting the system
@propertyWrapper
struct UserSettingRawRepresentable<Value: RawRepresentable & Equatable> {
private let key: String
private let defaultValue: Value
private let storage: UserDefaults
private let publisher: CurrentValueSubject<Value, Never>
init(key: String, defaultValue: Value, storage: UserDefaults = .standard) {
self.key = key
self.defaultValue = defaultValue
self.storage = storage
let value = (storage.value(forKey: key) as? Value.RawValue).flatMap { Value(rawValue: $0) } ?? defaultValue
publisher = CurrentValueSubject<Value, Never>(value)
}
var wrappedValue: Value {
get {
guard let value = storage.value(forKey: key) as? Value.RawValue else {
return defaultValue
}
return Value(rawValue: value) ?? defaultValue
}
set {
if let optional = newValue as? AnyOptional, optional.isNil {
storage.removeObject(forKey: key)
publisher.send(newValue)
} else {
storage.setValue(newValue.rawValue, forKey: key)
publisher.send(newValue)
}
}
}
var projectedValue: AnyPublisher<Value, Never> {
publisher.removeDuplicates(by: { $0 == $1 }).eraseToAnyPublisher()
}
}
extension UserSettingRawRepresentable where Value: ExpressibleByNilLiteral {
init(key: String, storage: UserDefaults = .standard) {
self.init(key: key, defaultValue: nil, storage: storage)
}
}
// Casting to AnyOptional will fail for any types that are not Optional (below)
private protocol AnyOptional {
var isNil: Bool { get }
}
extension Optional: AnyOptional {
var isNil: Bool { self == nil }
}
@@ -64,6 +64,8 @@ struct RoomScreenViewState: BindableState {
var canBackPaginate = true
var isBackPaginating = false
var showLoading = false
var timelineStyle: TimelineStyle
var bindings: RoomScreenViewStateBindings
var contextMenuActionProvider: (@MainActor (_ itemId: String) -> TimelineItemContextMenuActions?)?
@@ -40,6 +40,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
super.init(initialViewState: RoomScreenViewState(roomId: timelineController.roomID,
roomTitle: roomName ?? "Unknown room 💥",
roomAvatarURL: roomAvatarUrl,
timelineStyle: ServiceLocator.shared.settings.timelineStyle,
bindings: .init(composerText: "", composerFocused: false)),
imageProvider: mediaProvider)
@@ -78,6 +79,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
return self.contextMenuActionsForItemId(itemId)
}
ServiceLocator.shared.settings.$timelineStyle.sink { [weak self] timelineStyle in
self?.state.timelineStyle = timelineStyle
}
.store(in: &cancellables)
buildTimelineViews()
}
@@ -17,7 +17,6 @@
import SwiftUI
struct RoomScreen: View {
@ObservedObject private var settings = ServiceLocator.shared.settings
@ObservedObject var context: RoomScreenViewModel.Context
@State private var showReactionsMenuForItemId = ""
@@ -45,7 +44,7 @@ struct RoomScreen: View {
TimelineView()
.id(context.viewState.roomId)
.environmentObject(context)
.timelineStyle(settings.timelineStyle)
.timelineStyle(context.viewState.timelineStyle)
.overlay(alignment: .bottomTrailing) { scrollToBottomButton }
}
@@ -35,7 +35,7 @@ struct SettingsScreenViewState: BindableState {
}
struct SettingsScreenViewStateBindings {
var enableAnalytics = ServiceLocator.shared.settings.enableAnalytics
var timelineStyle: TimelineStyle
}
enum SettingsScreenViewAction {
@@ -44,4 +44,5 @@ enum SettingsScreenViewAction {
case reportBug
case sessionVerification
case logout
case changedTimelineStyle
}
@@ -14,6 +14,7 @@
// limitations under the License.
//
import Combine
import SwiftUI
typealias SettingsScreenViewModelType = StateStoreViewModel<SettingsScreenViewState, SettingsScreenViewAction>
@@ -25,13 +26,15 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
init(withUserSession userSession: UserSessionProtocol) {
self.userSession = userSession
let bindings = SettingsScreenViewStateBindings()
let bindings = SettingsScreenViewStateBindings(timelineStyle: ServiceLocator.shared.settings.timelineStyle)
super.init(initialViewState: .init(bindings: bindings,
deviceID: userSession.deviceId,
userID: userSession.userID,
showSessionVerificationSection: !(userSession.sessionVerificationController?.isVerified ?? false)),
imageProvider: userSession.mediaProvider)
listenToSettingsChange(publisher: ServiceLocator.shared.settings.$timelineStyle, keyPath: \.timelineStyle)
Task {
if case let .success(userAvatarURL) = await userSession.clientProxy.loadUserAvatarURL() {
state.userAvatarURL = userAvatarURL
@@ -71,6 +74,20 @@ class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewMo
callback?(.logout)
case .sessionVerification:
callback?(.sessionVerification)
case .changedTimelineStyle:
ServiceLocator.shared.settings.timelineStyle = state.bindings.timelineStyle
}
}
private func listenToSettingsChange<Value>(publisher: AnyPublisher<Value, Never>,
keyPath: WritableKeyPath<SettingsScreenViewStateBindings, Value>) where Value: Equatable {
publisher.sink { [weak self] newValue in
guard newValue != self?.state.bindings[keyPath: keyPath] else {
return
}
self?.state.bindings[keyPath: keyPath] = newValue
}
.store(in: &cancellables)
}
}
@@ -19,7 +19,6 @@ import SwiftUI
struct SettingsScreen: View {
@State private var showingLogoutConfirmation = false
@Environment(\.colorScheme) private var colorScheme
@ObservedObject private var settings = ServiceLocator.shared.settings
@ScaledMetric private var avatarSize = AvatarSize.user(on: .settings).value
@ScaledMetric private var menuIconSize = 30.0
@@ -104,13 +103,16 @@ struct SettingsScreen: View {
Section {
SettingsPickerRow(title: ElementL10n.settingsTimelineStyle,
image: Image(systemName: "rectangle.grid.1x2"),
selection: $settings.timelineStyle) {
selection: $context.timelineStyle) {
ForEach(TimelineStyle.allCases, id: \.self) { style in
Text(style.description)
Text(style.name)
.tag(style)
}
}
.accessibilityIdentifier("timelineStylePicker")
.onChange(of: context.timelineStyle) { _ in
context.send(viewAction: .changedTimelineStyle)
}
SettingsDefaultRow(title: ElementL10n.sendBugReport,
image: Image(systemName: "questionmark.circle")) {
@@ -166,8 +168,8 @@ struct SettingsScreen: View {
}
}
extension TimelineStyle: CustomStringConvertible {
var description: String {
private extension TimelineStyle {
var name: String {
switch self {
case .plain:
return ElementL10n.roomTimelineStylePlainLongDescription