Move the account migration screen to within the room list

This commit is contained in:
Stefan Ceriu
2024-01-26 11:39:26 +02:00
committed by Stefan Ceriu
parent 33a81989b0
commit f0d971c1ab
22 changed files with 349 additions and 519 deletions
@@ -42,7 +42,6 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
private var bugReportFlowCoordinator: BugReportFlowCoordinator?
private var cancellables = Set<AnyCancellable>()
private var migrationCancellable: AnyCancellable?
private let sidebarNavigationStackCoordinator: NavigationStackCoordinator
private let detailNavigationStackCoordinator: NavigationStackCoordinator
@@ -137,10 +136,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
}
func start() {
if appSettings.migratedAccounts[userSession.userID] != true {
// Show the migration screen for a new account.
stateMachine.processEvent(.startWithMigration)
} else if !appSettings.hasShownWelcomeScreen {
if !appSettings.hasShownWelcomeScreen {
stateMachine.processEvent(.startWithWelcomeScreen)
} else {
// Otherwise go straight to the home screen.
@@ -220,12 +216,6 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
case (.initial, .start, .roomList):
presentHomeScreen()
case (.initial, .startWithMigration, .migration):
presentMigrationScreen() // Full screen cover
presentHomeScreen() // Have the home screen ready to show underneath
case (.migration, .completeMigration, .roomList):
dismissMigrationScreen()
case (.initial, .startWithWelcomeScreen, .welcomeScreen):
presentHomeScreen()
presentWelcomeScreen()
@@ -304,33 +294,6 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
}
}
private func presentMigrationScreen() {
// Listen for the first sync to finish.
migrationCancellable = userSession.clientProxy.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
guard let self, stateMachine.state == .migration, case .receivedSyncUpdate = callback else { return }
migrationCancellable = nil
appSettings.migratedAccounts[userSession.userID] = true
stateMachine.processEvent(.completeMigration)
}
let coordinator = MigrationScreenCoordinator()
navigationSplitCoordinator.setFullScreenCoverCoordinator(coordinator)
}
private func dismissMigrationScreen() {
navigationSplitCoordinator.setFullScreenCoverCoordinator(nil)
// Not sure why but the full screen closure dismissal closure doesn't seem to work properly
// And not using the DispatchQueue.main results in the the screen getting presented as full screen too.
if !appSettings.hasShownWelcomeScreen {
DispatchQueue.main.async {
self.stateMachine.processEvent(.presentWelcomeScreen)
}
}
}
private func presentHomeScreen() {
let parameters = HomeScreenCoordinatorParameters(userSession: userSession,
attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: ServiceLocator.shared.settings.permalinkBaseURL,
@@ -23,9 +23,6 @@ class UserSessionFlowCoordinatorStateMachine {
/// The initial state, used before the coordinator starts
case initial
/// Showing the migration screen whilst the proxy performs an initial sync.
case migration
/// Showing the welcome screen.
case welcomeScreen
@@ -63,11 +60,6 @@ class UserSessionFlowCoordinatorStateMachine {
/// **Note:** This is event is only for users who used the app before v1.1.8.
/// It can be removed once the older TestFlight builds have expired.
case startWithWelcomeScreen
/// Start the user session flows with a migration screen.
case startWithMigration
/// Request to transition from the migration state to the home screen.
case completeMigration
/// Request presentation of the welcome screen.
case presentWelcomeScreen
@@ -124,9 +116,7 @@ class UserSessionFlowCoordinatorStateMachine {
private func configure() {
stateMachine.addRoutes(event: .start, transitions: [.initial => .roomList(selectedRoomID: nil)])
stateMachine.addRoutes(event: .startWithMigration, transitions: [.initial => .migration])
stateMachine.addRoutes(event: .startWithWelcomeScreen, transitions: [.initial => .welcomeScreen])
stateMachine.addRoutes(event: .completeMigration, transitions: [.migration => .roomList(selectedRoomID: nil)])
stateMachine.addRoutes(event: .dismissedWelcomeScreen, transitions: [.welcomeScreen => .roomList(selectedRoomID: nil)])
stateMachine.addRouteMapping { event, fromState, _ in
@@ -52,12 +52,15 @@ enum HomeScreenViewAction {
}
enum HomeScreenRoomListMode: CustomStringConvertible {
case migration
case skeletons
case empty
case rooms
var description: String {
switch self {
case .migration:
return "Showing account migration"
case .skeletons:
return "Showing placeholders"
case .empty:
@@ -27,6 +27,8 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
private let roomSummaryProvider: RoomSummaryProviderProtocol?
private let inviteSummaryProvider: RoomSummaryProviderProtocol?
private var migrationCancellable: AnyCancellable?
private var visibleItemRangeObservationToken: AnyCancellable?
private let visibleItemRangePublisher = CurrentValueSubject<(range: Range<Int>, isScrolling: Bool), Never>((0..<0, false))
@@ -101,7 +103,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
}
.store(in: &cancellables)
setupRoomSummaryProviderSubscriptions()
setupRoomListSubscriptions()
updateRooms()
}
@@ -166,7 +168,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
}
}
private func setupRoomSummaryProviderSubscriptions() {
private func setupRoomListSubscriptions() {
guard let roomSummaryProvider, let inviteSummaryProvider else {
MXLog.error("Room summary provider unavailable")
return
@@ -174,42 +176,32 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
ServiceLocator.shared.analytics.signpost.beginFirstRooms()
let hasUserBeenMigrated = appSettings.migratedAccounts[userSession.userID] == true
if !hasUserBeenMigrated {
state.roomListMode = .migration
MXLog.info("Account not migrated, setting view room list mode to \"\(state.roomListMode)\"")
migrationCancellable = userSession.clientProxy.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
guard let self, case .receivedSyncUpdate = callback else { return }
migrationCancellable = nil
appSettings.migratedAccounts[userSession.userID] = true
MXLog.info("Received first sync response, updating room list mode")
updateRoomListMode(with: roomSummaryProvider.statePublisher.value)
}
}
roomSummaryProvider.statePublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self else { return }
let isLoadingData = !state.isLoaded
let hasNoRooms = state.isLoaded && state.totalNumberOfRooms == 0
var roomListMode = self.state.roomListMode
if isLoadingData {
roomListMode = .skeletons
} else if hasNoRooms {
roomListMode = .empty
} else {
roomListMode = .rooms
}
guard roomListMode != self.state.roomListMode else {
return
}
if roomListMode == .rooms, self.state.roomListMode == .skeletons {
ServiceLocator.shared.analytics.signpost.endFirstRooms()
}
self.state.roomListMode = roomListMode
MXLog.info("Received room summary provider update, setting view room list mode to \"\(self.state.roomListMode)\"")
// Delay user profile detail loading until after the initial room list loads
if roomListMode == .rooms {
Task {
await self.userSession.clientProxy.loadUserAvatarURL()
await self.userSession.clientProxy.loadUserDisplayName()
}
}
updateRoomListMode(with: state)
}
.store(in: &cancellables)
@@ -240,6 +232,44 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
.store(in: &cancellables)
}
private func updateRoomListMode(with roomSummaryProviderState: RoomSummaryProviderState) {
guard appSettings.migratedAccounts[userSession.userID] == true else {
// Ignore room summary provider updates while "migrating"
return
}
let isLoadingData = !roomSummaryProviderState.isLoaded
let hasNoRooms = roomSummaryProviderState.isLoaded && roomSummaryProviderState.totalNumberOfRooms == 0
var roomListMode = state.roomListMode
if isLoadingData {
roomListMode = .skeletons
} else if hasNoRooms {
roomListMode = .empty
} else {
roomListMode = .rooms
}
guard roomListMode != state.roomListMode else {
return
}
if roomListMode == .rooms, state.roomListMode == .skeletons {
ServiceLocator.shared.analytics.signpost.endFirstRooms()
}
state.roomListMode = roomListMode
MXLog.info("Received room summary provider update, setting view room list mode to \"\(state.roomListMode)\"")
// Delay user profile detail loading until after the initial room list loads
if roomListMode == .rooms {
Task {
await self.userSession.clientProxy.loadUserAvatarURL()
await self.userSession.clientProxy.loadUserDisplayName()
}
}
}
private func installListRangeModifiers() {
guard visibleItemRangeObservationToken == nil else {
return
@@ -32,102 +32,49 @@ struct HomeScreen: View {
@State private var hairlineView: UIView?
var body: some View {
GeometryReader { geometry in
ScrollView {
switch context.viewState.roomListMode {
case .skeletons:
LazyVStack(spacing: 0) {
ForEach(context.viewState.visibleRooms) { room in
HomeScreenRoomCell(room: room, context: context, isSelected: false)
.redacted(reason: .placeholder)
.shimmer() // Putting this directly on the LazyVStack creates an accordion animation on iOS 16.
}
HomeScreenContent(context: context, scrollViewAdapter: scrollViewAdapter)
.alert(item: $context.alertInfo)
.alert(item: $context.leaveRoomAlertItem,
actions: leaveRoomAlertActions,
message: leaveRoomAlertMessage)
.navigationTitle(L10n.screenRoomlistMainSpaceTitle)
.toolbar { toolbar }
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.track(screen: .home)
.introspect(.viewController, on: .supportedVersions) { controller in
Task {
if bloomView == nil {
makeBloomView(controller: controller)
}
.disabled(true)
case .empty:
HomeScreenEmptyStateLayout(minHeight: geometry.size.height) {
topSection
HomeScreenEmptyStateView(context: context)
.layoutPriority(1)
}
let isTopController = controller.navigationController?.topViewController != controller
let isHidden = isTopController || context.isSearchFieldFocused
if let bloomView {
bloomView.isHidden = isHidden
UIView.transition(with: bloomView, duration: 1.75, options: .curveEaseInOut) {
bloomView.alpha = isTopController ? 0 : 1
}
case .rooms:
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
Section {
HomeScreenRoomList(context: context)
} header: {
topSection
}
}
gradientView?.isHidden = isHidden
navigationBarContainer?.clipsToBounds = !isHidden
hairlineView?.isHidden = isHidden || !scrollViewAdapter.isAtTopEdge.value
if !isHidden {
updateBloomCenter()
}
}
.onReceive(scrollViewAdapter.isAtTopEdge.removeDuplicates()) { value in
hairlineView?.isHidden = !value
guard let gradientView else {
return
}
if value {
UIView.transition(with: gradientView, duration: 0.3, options: .curveEaseIn) {
gradientView.alpha = 0
}
.searchable(text: $context.searchQuery)
.compoundSearchField()
.disableAutocorrection(true)
} else {
gradientView.alpha = 1
}
}
.introspect(.scrollView, on: .supportedVersions) { scrollView in
guard scrollView != scrollViewAdapter.scrollView else { return }
scrollViewAdapter.scrollView = scrollView
}
.onReceive(scrollViewAdapter.didScroll) { _ in
updateVisibleRange()
}
.onReceive(scrollViewAdapter.isScrolling) { _ in
updateVisibleRange()
}
.onChange(of: context.searchQuery) { _ in
updateVisibleRange()
}
.onChange(of: context.viewState.visibleRooms) { _ in
updateVisibleRange()
}
.scrollDismissesKeyboard(.immediately)
.scrollDisabled(context.viewState.roomListMode == .skeletons)
.scrollBounceBehavior(context.viewState.roomListMode == .empty ? .basedOnSize : .automatic)
.animation(.elementDefault, value: context.viewState.roomListMode)
.animation(.none, value: context.viewState.visibleRooms)
}
.alert(item: $context.alertInfo)
.alert(item: $context.leaveRoomAlertItem,
actions: leaveRoomAlertActions,
message: leaveRoomAlertMessage)
.navigationTitle(L10n.screenRoomlistMainSpaceTitle)
.toolbar { toolbar }
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.track(screen: .home)
.introspect(.viewController, on: .supportedVersions) { controller in
Task {
if bloomView == nil {
makeBloomView(controller: controller)
}
}
let isTopController = controller.navigationController?.topViewController != controller
let isHidden = isTopController || context.isSearchFieldFocused
if let bloomView {
bloomView.isHidden = isHidden
UIView.transition(with: bloomView, duration: 1.75, options: .curveEaseInOut) {
bloomView.alpha = isTopController ? 0 : 1
}
}
gradientView?.isHidden = isHidden
navigationBarContainer?.clipsToBounds = !isHidden
hairlineView?.isHidden = isHidden || !scrollViewAdapter.isAtTopEdge.value
if !isHidden {
updateBloomCenter()
}
}
.onReceive(scrollViewAdapter.isAtTopEdge.removeDuplicates()) { value in
hairlineView?.isHidden = !value
guard let gradientView else {
return
}
if value {
UIView.transition(with: gradientView, duration: 0.3, options: .curveEaseIn) {
gradientView.alpha = 0
}
} else {
gradientView.alpha = 1
}
}
}
// MARK: - Private
@@ -189,35 +136,6 @@ struct HomeScreen: View {
let center = leftBarButtonView.convert(leftBarButtonView.center, to: navigationBarContainer.coordinateSpace)
bloomView.center = center
}
@ViewBuilder
/// The session verification banner and invites button if either are needed.
private var topSection: some View {
VStack(spacing: 0) {
if context.viewState.shouldShowFilters {
filters
}
if context.viewState.showSessionVerificationBanner {
HomeScreenSessionVerificationBanner(context: context)
} else if context.viewState.showRecoveryKeyConfirmationBanner {
HomeScreenRecoveryKeyConfirmationBanner(context: context)
}
if context.viewState.hasPendingInvitations, !context.isSearchFieldFocused {
HomeScreenInvitesButton(title: L10n.actionInvitesList, hasBadge: context.viewState.hasUnreadPendingInvitations) {
context.send(viewAction: .selectInvites)
}
.accessibilityIdentifier(A11yIdentifiers.homeScreen.invites)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
.background(Color.compound.bgCanvasDefault)
}
private var filters: some View {
RoomListFiltersView(state: context.viewState.filtersState)
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
@@ -225,7 +143,7 @@ struct HomeScreen: View {
HomeScreenUserMenuButton(context: context)
}
ToolbarItemGroup(placement: .primaryAction) {
ToolbarItem(placement: .primaryAction) {
newRoomButton
}
}
@@ -234,41 +152,20 @@ struct HomeScreen: View {
BloomView(context: context)
}
@ViewBuilder
private var newRoomButton: some View {
Button {
context.send(viewAction: .startChat)
} label: {
CompoundIcon(\.edit)
switch context.viewState.roomListMode {
case .empty, .rooms:
Button {
context.send(viewAction: .startChat)
} label: {
CompoundIcon(\.edit)
}
.accessibilityLabel(L10n.actionStartChat)
.accessibilityIdentifier(A11yIdentifiers.homeScreen.startChat)
default:
EmptyView()
}
.accessibilityLabel(L10n.actionStartChat)
.accessibilityIdentifier(A11yIdentifiers.homeScreen.startChat)
}
/// Often times the scroll view's content size isn't correct yet when this method is called e.g. when cancelling a search
/// Dispatch it with a delay to allow the UI to update and the computations to be correct
/// Once we move to iOS 17 we should remove all of this and use scroll anchors instead
private func updateVisibleRange() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { delayedUpdateVisibleRange() }
}
private func delayedUpdateVisibleRange() {
guard let scrollView = scrollViewAdapter.scrollView,
context.viewState.visibleRooms.count > 0 else {
return
}
guard scrollView.contentSize.height > scrollView.bounds.height else {
return
}
let adjustedContentSize = max(scrollView.contentSize.height - scrollView.contentInset.top - scrollView.contentInset.bottom, scrollView.bounds.height)
let cellHeight = adjustedContentSize / Double(context.viewState.visibleRooms.count)
let firstIndex = Int(max(0.0, scrollView.contentOffset.y + scrollView.contentInset.top) / cellHeight)
let lastIndex = Int(max(0.0, scrollView.contentOffset.y + scrollView.bounds.height) / cellHeight)
// This will be deduped and throttled on the view model layer
context.send(viewAction: .updateVisibleItemRange(range: firstIndex..<lastIndex, isScrolling: scrollViewAdapter.isScrolling.value))
}
@ViewBuilder
@@ -287,32 +184,54 @@ struct HomeScreen: View {
// MARK: - Previews
struct HomeScreen_Previews: PreviewProvider, TestablePreview {
static let loadingViewModel = viewModel(.loading)
static let loadedViewModel = viewModel(.loaded(.mockRooms))
static let emptyViewModel = viewModel(.loaded([]))
static let migratingViewModel = viewModel(.migration)
static let loadingViewModel = viewModel(.skeletons)
static let emptyViewModel = viewModel(.empty)
static let loadedViewModel = viewModel(.rooms)
static var previews: some View {
NavigationStack {
HomeScreen(context: migratingViewModel.context)
}
.previewDisplayName("Migrating")
NavigationStack {
HomeScreen(context: loadingViewModel.context)
}
.previewDisplayName("Loading")
NavigationStack {
HomeScreen(context: loadedViewModel.context)
}
.previewDisplayName("Loaded")
.snapshot(delay: 4.0)
NavigationStack {
HomeScreen(context: emptyViewModel.context)
}
.previewDisplayName("Empty")
.snapshot(delay: 4.0)
NavigationStack {
HomeScreen(context: loadedViewModel.context)
}
.previewDisplayName("Loaded")
.snapshot(delay: 4.0)
}
static func viewModel(_ state: MockRoomSummaryProviderState) -> HomeScreenViewModel {
let clientProxy = MockClientProxy(userID: "@alice:example.com",
roomSummaryProvider: MockRoomSummaryProvider(state: state))
static func viewModel(_ mode: HomeScreenRoomListMode) -> HomeScreenViewModel {
let userID = mode == .migration ? "@unmigrated_alice:example.com" : "@alice:example.com"
let appSettings = AppSettings() // This uses shared storage under the hood
appSettings.migratedAccounts[userID] = mode != .migration
let roomSummaryProviderState: MockRoomSummaryProviderState = switch mode {
case .migration:
.loading
case .skeletons:
.loading
case .empty:
.loaded([])
case .rooms:
.loaded(.mockRooms)
}
let clientProxy = MockClientProxy(userID: userID,
roomSummaryProvider: MockRoomSummaryProvider(state: roomSummaryProviderState))
let userSession = MockUserSession(clientProxy: clientProxy,
mediaProvider: MockMediaProvider(),
@@ -320,7 +239,7 @@ struct HomeScreen_Previews: PreviewProvider, TestablePreview {
return HomeScreenViewModel(userSession: userSession,
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(),
appSettings: ServiceLocator.shared.settings,
appSettings: appSettings,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
}
}
@@ -0,0 +1,189 @@
//
// Copyright 2024 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.
//
import SwiftUI
struct HomeScreenContent: View {
@Environment(\.verticalSizeClass) private var verticalSizeClass
@ObservedObject var context: HomeScreenViewModel.Context
let scrollViewAdapter: ScrollViewAdapter
var body: some View {
switch context.viewState.roomListMode {
case .migration:
migrationView
default:
roomList
}
}
private var roomList: some View {
GeometryReader { geometry in
ScrollView {
switch context.viewState.roomListMode {
case .skeletons:
LazyVStack(spacing: 0) {
ForEach(context.viewState.visibleRooms) { room in
HomeScreenRoomCell(room: room, context: context, isSelected: false)
.redacted(reason: .placeholder)
.shimmer() // Putting this directly on the LazyVStack creates an accordion animation on iOS 16.
}
}
.disabled(true)
case .empty:
HomeScreenEmptyStateLayout(minHeight: geometry.size.height) {
topSection
HomeScreenEmptyStateView(context: context)
.layoutPriority(1)
}
case .rooms:
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
Section {
HomeScreenRoomList(context: context)
} header: {
topSection
}
}
.searchable(text: $context.searchQuery)
.compoundSearchField()
.disableAutocorrection(true)
case .migration:
EmptyView()
}
}
.introspect(.scrollView, on: .supportedVersions) { scrollView in
guard scrollView != scrollViewAdapter.scrollView else { return }
scrollViewAdapter.scrollView = scrollView
}
.onReceive(scrollViewAdapter.didScroll) { _ in
updateVisibleRange()
}
.onReceive(scrollViewAdapter.isScrolling) { _ in
updateVisibleRange()
}
.onChange(of: context.searchQuery) { _ in
updateVisibleRange()
}
.onChange(of: context.viewState.visibleRooms) { _ in
updateVisibleRange()
}
.scrollDismissesKeyboard(.immediately)
.scrollDisabled(context.viewState.roomListMode == .skeletons)
.scrollBounceBehavior(context.viewState.roomListMode == .empty ? .basedOnSize : .automatic)
.animation(.elementDefault, value: context.viewState.roomListMode)
.animation(.none, value: context.viewState.visibleRooms)
}
}
@ViewBuilder
/// The session verification banner and invites button if either are needed.
private var topSection: some View {
VStack(spacing: 0) {
if context.viewState.shouldShowFilters {
filters
}
if context.viewState.showSessionVerificationBanner {
HomeScreenSessionVerificationBanner(context: context)
} else if context.viewState.showRecoveryKeyConfirmationBanner {
HomeScreenRecoveryKeyConfirmationBanner(context: context)
}
if context.viewState.hasPendingInvitations, !context.isSearchFieldFocused {
HomeScreenInvitesButton(title: L10n.actionInvitesList, hasBadge: context.viewState.hasUnreadPendingInvitations) {
context.send(viewAction: .selectInvites)
}
.accessibilityIdentifier(A11yIdentifiers.homeScreen.invites)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
.background(Color.compound.bgCanvasDefault)
}
private var filters: some View {
RoomListFiltersView(state: context.viewState.filtersState)
}
@ViewBuilder
private var migrationView: some View {
if UIDevice.current.isPhone {
if verticalSizeClass == .compact {
migrationViewContent
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
WaitingDialog {
migrationViewContent
} bottomContent: {
EmptyView()
}
}
} else {
migrationViewContent
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private var migrationViewContent: some View {
VStack(spacing: 16) {
ProgressView()
.tint(.compound.iconPrimary)
.padding(.bottom, 4)
Text(L10n.screenMigrationTitle.tinting(".", color: Asset.Colors.brandColor.swiftUIColor))
.minimumScaleFactor(0.01)
.font(.compound.headingXLBold)
.multilineTextAlignment(.center)
.foregroundColor(.compound.textPrimary)
Text(L10n.screenMigrationMessage)
.minimumScaleFactor(0.01)
.font(.compound.bodyLG)
.multilineTextAlignment(.center)
.foregroundColor(.compound.textPrimary)
.accessibilityIdentifier(A11yIdentifiers.migrationScreen.message)
}
.padding(.horizontal)
}
/// Often times the scroll view's content size isn't correct yet when this method is called e.g. when cancelling a search
/// Dispatch it with a delay to allow the UI to update and the computations to be correct
/// Once we move to iOS 17 we should remove all of this and use scroll anchors instead
private func updateVisibleRange() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { delayedUpdateVisibleRange() }
}
private func delayedUpdateVisibleRange() {
guard let scrollView = scrollViewAdapter.scrollView,
context.viewState.visibleRooms.count > 0 else {
return
}
guard scrollView.contentSize.height > scrollView.bounds.height else {
return
}
let adjustedContentSize = max(scrollView.contentSize.height - scrollView.contentInset.top - scrollView.contentInset.bottom, scrollView.bounds.height)
let cellHeight = adjustedContentSize / Double(context.viewState.visibleRooms.count)
let firstIndex = Int(max(0.0, scrollView.contentOffset.y + scrollView.contentInset.top) / cellHeight)
let lastIndex = Int(max(0.0, scrollView.contentOffset.y + scrollView.bounds.height) / cellHeight)
// This will be deduped and throttled on the view model layer
context.send(viewAction: .updateVisibleItemRange(range: firstIndex..<lastIndex, isScrolling: scrollViewAdapter.isScrolling.value))
}
}
@@ -1,32 +0,0 @@
//
// 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.
//
import Combine
import SwiftUI
final class MigrationScreenCoordinator: CoordinatorProtocol {
private var viewModel: MigrationScreenViewModelProtocol
init() {
viewModel = MigrationScreenViewModel()
}
func start() { }
func toPresentable() -> AnyView {
AnyView(MigrationScreen(context: viewModel.context))
}
}
@@ -1,21 +0,0 @@
//
// 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.
//
import Foundation
struct MigrationScreenViewState: BindableState { }
enum MigrationScreenViewAction { }
@@ -1,26 +0,0 @@
//
// 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.
//
import Combine
import SwiftUI
typealias MigrationScreenViewModelType = StateStoreViewModel<MigrationScreenViewState, MigrationScreenViewAction>
class MigrationScreenViewModel: MigrationScreenViewModelType, MigrationScreenViewModelProtocol {
init() {
super.init(initialViewState: MigrationScreenViewState())
}
}
@@ -1,22 +0,0 @@
//
// 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.
//
import Combine
@MainActor
protocol MigrationScreenViewModelProtocol {
var context: MigrationScreenViewModelType.Context { get }
}
@@ -1,58 +0,0 @@
//
// 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.
//
import SwiftUI
struct MigrationScreen: View {
@ObservedObject var context: MigrationScreenViewModel.Context
var body: some View {
WaitingDialog {
content
} bottomContent: {
EmptyView()
}
.navigationBarBackButtonHidden()
}
var content: some View {
VStack(spacing: 16) {
ProgressView()
.tint(.compound.iconPrimary)
.padding(.bottom, 4)
Text(L10n.screenMigrationTitle.tinting(".", color: Asset.Colors.brandColor.swiftUIColor))
.font(.compound.headingXLBold)
.multilineTextAlignment(.center)
.foregroundColor(.compound.textPrimary)
Text(L10n.screenMigrationMessage)
.font(.compound.bodyLG)
.multilineTextAlignment(.center)
.foregroundColor(.compound.textPrimary)
.accessibilityIdentifier(A11yIdentifiers.migrationScreen.message)
}
}
}
// MARK: - Previews
struct MigrationScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = MigrationScreenViewModel()
static var previews: some View {
MigrationScreen(context: viewModel.context)
}
}
@@ -131,9 +131,6 @@ class MockScreen: Identifiable {
analytics: ServiceLocator.shared.analytics))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .migration:
let coordinator = MigrationScreenCoordinator()
return coordinator
case .authenticationFlow:
let navigationStackCoordinator = NavigationStackCoordinator()
let coordinator = AuthenticationCoordinator(authenticationService: MockAuthenticationServiceProxy(),
@@ -27,7 +27,6 @@ enum UITestsScreenIdentifier: String {
case waitlist
case analyticsPrompt
case analyticsSettingsScreen
case migration
case templateScreen
case appLockFlow
case appLockFlowAlternateWindow