Added logout option, room list on home screen, made various methods asynchronous,

This commit is contained in:
Stefan Ceriu 2022-02-22 09:18:46 +02:00
parent d90738e1e4
commit b53efb4869
12 changed files with 522 additions and 71 deletions

View File

@ -23,7 +23,7 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
init() { init() {
splashViewController = SplashViewController() splashViewController = SplashViewController()
mainNavigationController = UINavigationController(rootViewController: splashViewController) mainNavigationController = UINavigationController(rootViewController: splashViewController)
mainNavigationController.setNavigationBarHidden(true, animated: false) mainNavigationController.navigationBar.isHidden = true
window = UIWindow(frame: UIScreen.main.bounds) window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = mainNavigationController window.rootViewController = mainNavigationController
@ -46,6 +46,10 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
// MARK: - AuthenticationCoordinatorDelegate // MARK: - AuthenticationCoordinatorDelegate
func authenticationCoordinatorDidStartLoading(_ authenticationCoordinator: AuthenticationCoordinator) {
}
func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator, didFailWithError error: AuthenticationCoordinatorError) { func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator, didFailWithError error: AuthenticationCoordinatorError) {
} }
@ -55,7 +59,8 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
} }
func authenticationCoordinatorDidTearDownUserSession(_ authenticationCoordinator: AuthenticationCoordinator) { func authenticationCoordinatorDidTearDownUserSession(_ authenticationCoordinator: AuthenticationCoordinator) {
mainNavigationController.setViewControllers([splashViewController], animated: false)
authenticationCoordinator.start()
} }
// MARK: - Private // MARK: - Private
@ -68,6 +73,13 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
let parameters = HomeScreenCoordinatorParameters(userSession: userSession) let parameters = HomeScreenCoordinatorParameters(userSession: userSession)
let coordinator = HomeScreenCoordinator(parameters: parameters) let coordinator = HomeScreenCoordinator(parameters: parameters)
coordinator.completion = { [weak self] result in
switch result {
case .logout:
self?.authenticationCoordinator.logout()
}
}
add(childCoordinator: coordinator) add(childCoordinator: coordinator)
navigationRouter.setRootModule(coordinator) navigationRouter.setRootModule(coordinator)
} }

View File

@ -15,6 +15,9 @@ enum AuthenticationCoordinatorError: Error {
} }
protocol AuthenticationCoordinatorDelegate: AnyObject { protocol AuthenticationCoordinatorDelegate: AnyObject {
func authenticationCoordinatorDidStartLoading(_ authenticationCoordinator: AuthenticationCoordinator)
func authenticationCoordinatorDidSetupUserSession(_ authenticationCoordinator: AuthenticationCoordinator) func authenticationCoordinatorDidSetupUserSession(_ authenticationCoordinator: AuthenticationCoordinator)
func authenticationCoordinatorDidTearDownUserSession(_ authenticationCoordinator: AuthenticationCoordinator) func authenticationCoordinatorDidTearDownUserSession(_ authenticationCoordinator: AuthenticationCoordinator)
@ -40,19 +43,46 @@ class AuthenticationCoordinator: Coordinator {
} }
func start() { func start() {
delegate?.authenticationCoordinatorDidStartLoading(self)
let availableRestoreTokens = keychainController.restoreTokens() let availableRestoreTokens = keychainController.restoreTokens()
guard let usernameTokenTuple = availableRestoreTokens.first else { guard let usernameTokenTuple = availableRestoreTokens.first else {
startNewLoginFlow() startNewLoginFlow { result in
switch result {
case .success:
self.delegate?.authenticationCoordinatorDidSetupUserSession(self)
case .failure(let error):
self.delegate?.authenticationCoordinator(self, didFailWithError: error)
MXLog.error("Failed logging in user with error: \(error)")
}
}
return return
} }
restorePreviousLogin(usernameTokenTuple) restorePreviousLogin(usernameTokenTuple) { [weak self] result in
guard let self = self else { return }
switch result {
case .success:
self.delegate?.authenticationCoordinatorDidSetupUserSession(self)
case .failure(let error):
self.delegate?.authenticationCoordinator(self, didFailWithError: error)
MXLog.error("Failed restoring login with error: \(error)")
}
}
}
func logout() {
keychainController.removeAllTokens()
userSession = nil
delegate?.authenticationCoordinatorDidTearDownUserSession(self)
} }
// MARK: - Private // MARK: - Private
private func startNewLoginFlow() { private func startNewLoginFlow(_ completion: @escaping (Result<(), AuthenticationCoordinatorError>) -> Void) {
let parameters = LoginScreenCoordinatorParameters() let parameters = LoginScreenCoordinatorParameters()
let coordinator = LoginScreenCoordinator(parameters: parameters) let coordinator = LoginScreenCoordinator(parameters: parameters)
@ -63,16 +93,18 @@ class AuthenticationCoordinator: Coordinator {
switch result { switch result {
case .login(let result): case .login(let result):
do { self.login(username: result.username, password: result.password) { [weak self] result in
self.setupUserSessionForClient(try loginNewClient(basePath: self.baseDirectoryPathForUsername(result.username), guard let self = self else { return }
username: result.username,
password: result.password)) switch result {
case .success:
completion(.success(()))
case .failure(let error):
completion(.failure(error))
}
self.remove(childCoordinator: coordinator) self.remove(childCoordinator: coordinator)
self.navigationRouter.dismissModule() self.navigationRouter.dismissModule()
} catch {
self.delegate?.authenticationCoordinator(self, didFailWithError: .failedLoggingIn)
MXLog.error("Failed logging in user with error: \(error)")
} }
} }
} }
@ -83,13 +115,42 @@ class AuthenticationCoordinator: Coordinator {
coordinator.start() coordinator.start()
} }
private func restorePreviousLogin(_ usernameTokenTuple: (username: String, token: String)) { private func login(username: String, password: String, completion: @escaping (Result<Void, AuthenticationCoordinatorError>) -> Void) {
do { DispatchQueue.global(qos: .background).async { [weak self] in
setupUserSessionForClient(try loginWithToken(basePath: baseDirectoryPathForUsername(usernameTokenTuple.username), guard let self = self else { return }
restoreToken: usernameTokenTuple.token))
} catch { do {
delegate?.authenticationCoordinator(self, didFailWithError: .failedRestoringLogin) self.setupUserSessionForClient(try loginNewClient(basePath: self.baseDirectoryPathForUsername(username),
MXLog.error("Failed restoring login with error: \(error)") username: username,
password: password))
DispatchQueue.main.async {
completion(.success(()))
}
} catch {
DispatchQueue.main.async {
completion(.failure(.failedLoggingIn))
}
}
}
}
private func restorePreviousLogin(_ usernameTokenTuple: (username: String, token: String), completion: @escaping (Result<Void, AuthenticationCoordinatorError>) -> Void) {
DispatchQueue.global(qos: .background).async { [weak self] in
guard let self = self else { return }
do {
self.setupUserSessionForClient(try loginWithToken(basePath: self.baseDirectoryPathForUsername(usernameTokenTuple.username),
restoreToken: usernameTokenTuple.token))
DispatchQueue.main.async {
completion(.success(()))
}
} catch {
DispatchQueue.main.async {
completion(.failure(.failedRestoringLogin))
}
}
} }
} }
@ -106,7 +167,6 @@ class AuthenticationCoordinator: Coordinator {
} }
userSession = UserSession(client: client) userSession = UserSession(client: client)
delegate?.authenticationCoordinatorDidSetupUserSession(self)
} }
private func baseDirectoryPathForUsername(_ username: String) -> String { private func baseDirectoryPathForUsername(_ username: String) -> String {

View File

@ -38,6 +38,7 @@ struct LoginScreen: View {
.padding(.horizontal, 8.0) .padding(.horizontal, 8.0)
.navigationTitle("Login") .navigationTitle("Login")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.navigationViewStyle(StackNavigationViewStyle())
} }
} }
} }

View File

@ -7,33 +7,41 @@
import Foundation import Foundation
import MatrixRustSDK import MatrixRustSDK
import Combine
import UIKit
class UserSession { enum UserSessionCallback {
case updatedData
}
enum UserSessionError: Error {
case failedRetrievingAvatar
}
class UserSession: ClientDelegate {
private let client: Client private let client: Client
let callbacks = PassthroughSubject<UserSessionCallback, Never>()
init(client: Client) { init(client: Client) {
self.client = client self.client = client
if !client.hasFirstSynced() { if !client.hasFirstSynced() {
MXLog.info("Started initial sync") client.startSync(delegate: self)
client.startSync()
MXLog.info("Finished intial sync")
} }
} }
func roomList() -> [RoomModel] { var userIdentifier: String {
client.conversations().compactMap { room in do {
do { return try client.userId()
return RoomModel(displayName: try room.displayName()) } catch {
} catch { MXLog.error("Failed retrieving room info with error: \(error)")
MXLog.error("Failed retrieving room info with error: \(error)") return "Unknown user identifier"
return nil
}
} }
} }
var displayName: String? { var userDisplayName: String? {
do { do {
return try client.displayName() return try client.displayName()
} catch { } catch {
@ -41,4 +49,42 @@ class UserSession {
return nil return nil
} }
} }
func getUserAvatar(_ completion: @escaping (Result<UIImage?, Error>) -> Void) {
DispatchQueue.global(qos: .background).async {
do {
let avatarData = try self.client.avatar()
DispatchQueue.main.async {
completion(.success(UIImage(data: Data(bytes: avatarData, count: avatarData.count))))
}
} catch {
MXLog.error("Failed retrieving room name with error: \(error)")
DispatchQueue.main.async {
completion(.failure(UserSessionError.failedRetrievingAvatar))
}
}
}
}
func getRoomList(_ completion: @escaping ([RoomModel]) -> Void) {
DispatchQueue.global(qos: .background).async {
let conversations = self.client.conversations()
let rooms = conversations.map {
return RoomModel(room: $0)
}
DispatchQueue.main.async {
completion(rooms)
}
}
}
// MARK: ClientDelegate
func didReceiveSyncUpdate() {
DispatchQueue.main.async {
self.callbacks.send(.updatedData)
}
}
} }

View File

@ -15,11 +15,16 @@
// //
import SwiftUI import SwiftUI
import Combine
struct HomeScreenCoordinatorParameters { struct HomeScreenCoordinatorParameters {
let userSession: UserSession let userSession: UserSession
} }
enum HomeScreenCoordinatorResult {
case logout
}
final class HomeScreenCoordinator: Coordinator, Presentable { final class HomeScreenCoordinator: Coordinator, Presentable {
// MARK: - Properties // MARK: - Properties
@ -27,30 +32,52 @@ final class HomeScreenCoordinator: Coordinator, Presentable {
// MARK: Private // MARK: Private
private let parameters: HomeScreenCoordinatorParameters private let parameters: HomeScreenCoordinatorParameters
private let homeScreenHostingController: UIViewController private let hostingController: UIViewController
private var homeScreenViewModel: HomeScreenViewModelProtocol private var viewModel: HomeScreenViewModelProtocol
private var cancellables = Set<AnyCancellable>()
// MARK: Public // MARK: Public
// Must be used only internally // Must be used only internally
var childCoordinators: [Coordinator] = [] var childCoordinators: [Coordinator] = []
var completion: ((HomeScreenViewModelResult) -> Void)? var completion: ((HomeScreenCoordinatorResult) -> Void)?
// MARK: - Setup // MARK: - Setup
@available(iOS 14.0, *)
init(parameters: HomeScreenCoordinatorParameters) { init(parameters: HomeScreenCoordinatorParameters) {
self.parameters = parameters self.parameters = parameters
let viewModel = HomeScreenViewModel(username: self.parameters.userSession.displayName ?? "💥") let userDisplayName = self.parameters.userSession.userDisplayName ?? self.parameters.userSession.userIdentifier
let view = HomeScreen(context: viewModel.context) viewModel = HomeScreenViewModel(userDisplayName: userDisplayName)
homeScreenViewModel = viewModel
homeScreenHostingController = UIHostingController(rootView: view)
homeScreenViewModel.completion = { [weak self] result in let view = HomeScreen(context: viewModel.context)
hostingController = UIHostingController(rootView: view)
viewModel.completion = { [weak self] result in
guard let self = self else { return } guard let self = self else { return }
self.completion?(result)
switch result {
case .logout:
self.completion?(.logout)
case .loadUserAvatar:
self.parameters.userSession.getUserAvatar({ result in
switch result {
case .success(let avatar):
self.viewModel.updateWithUserAvatar(avatar)
default:
break
}
})
}
} }
parameters.userSession.callbacks.sink { [weak self] result in
switch result {
case .updatedData:
self?.updateRoomsList()
}
}.store(in: &cancellables)
} }
// MARK: - Public // MARK: - Public
@ -59,6 +86,14 @@ final class HomeScreenCoordinator: Coordinator, Presentable {
} }
func toPresentable() -> UIViewController { func toPresentable() -> UIViewController {
return self.homeScreenHostingController return self.hostingController
}
// MARK: - Private
func updateRoomsList() {
parameters.userSession.getRoomList { [weak self] rooms in
self?.viewModel.updateWithRoomList(rooms)
}
} }
} }

View File

@ -15,17 +15,43 @@
// //
import Foundation import Foundation
import UIKit
enum HomeScreenViewModelResult { enum HomeScreenViewModelResult {
case logout case logout
} case loadUserAvatar
// MARK: View
struct HomeScreenViewState: BindableState {
let username: String
} }
enum HomeScreenViewAction { enum HomeScreenViewAction {
case logout case logout
case loadUserAvatar
case loadRoomAvatar(roomId: String)
}
struct HomeScreenViewState: BindableState {
let userDisplayName: String
var userAvatar: UIImage?
var rooms: [HomeScreenRoom] = []
var directRooms: [HomeScreenRoom] {
rooms.filter { $0.isDirect }
}
var nondirectRooms: [HomeScreenRoom] {
rooms.filter { !$0.isDirect }
}
}
struct HomeScreenRoom: Identifiable {
let id: String
let displayName: String
let topic: String?
let lastMessage: String?
var avatar: UIImage?
let isDirect: Bool
let isEncrypted: Bool
} }

View File

@ -18,28 +18,70 @@ import SwiftUI
@available(iOS 14, *) @available(iOS 14, *)
typealias HomeScreenViewModelType = StateStoreViewModel<HomeScreenViewState, typealias HomeScreenViewModelType = StateStoreViewModel<HomeScreenViewState,
Never, Never,
HomeScreenViewAction> HomeScreenViewAction>
@available(iOS 14, *) @available(iOS 14, *)
class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol { class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol {
// MARK: - Properties // MARK: - Properties
// MARK: Private // MARK: Private
private var roomList: [RoomModelProtocol]?
// MARK: Public // MARK: Public
var completion: ((HomeScreenViewModelResult) -> Void)? var completion: ((HomeScreenViewModelResult) -> Void)?
// MARK: - Setup // MARK: - Setup
init(username: String) { init(userDisplayName: String) {
super.init(initialViewState: HomeScreenViewState(username: username)) super.init(initialViewState: HomeScreenViewState(userDisplayName: userDisplayName))
} }
// MARK: - Public // MARK: - Public
override func process(viewAction: HomeScreenViewAction) { override func process(viewAction: HomeScreenViewAction) {
switch viewAction {
case .logout:
self.completion?(.logout)
case .loadRoomAvatar(let roomId):
guard let room = roomList?.filter({ $0.identifier == roomId }).first else {
break
}
room.getAvatar { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let image):
guard let index = self.state.rooms.firstIndex(where: { $0.id == roomId }) else {
return
}
self.state.rooms[index].avatar = image
default:
break
}
}
case .loadUserAvatar:
self.completion?(.loadUserAvatar)
}
}
func updateWithRoomList(_ roomList: [RoomModelProtocol]) {
self.roomList = roomList
state.rooms = roomList.map { roomModel in
HomeScreenRoom(id: roomModel.identifier,
displayName: roomModel.displayName,
topic: roomModel.topic,
lastMessage: roomModel.lastMessage,
isDirect: roomModel.isDirect,
isEncrypted: roomModel.isEncrypted)
}
}
func updateWithUserAvatar(_ avatar: UIImage?) {
self.state.userAvatar = avatar
} }
} }

View File

@ -15,10 +15,14 @@
// //
import Foundation import Foundation
import UIKit
protocol HomeScreenViewModelProtocol { protocol HomeScreenViewModelProtocol {
var completion: ((HomeScreenViewModelResult) -> Void)? { get set } var completion: ((HomeScreenViewModelResult) -> Void)? { get set }
@available(iOS 14, *)
var context: HomeScreenViewModelType.Context { get } var context: HomeScreenViewModelType.Context { get }
func updateWithRoomList(_ roomList: [RoomModelProtocol])
func updateWithUserAvatar(_ avatar: UIImage?)
} }

View File

@ -16,7 +16,6 @@
import SwiftUI import SwiftUI
@available(iOS 14.0, *)
struct HomeScreen: View { struct HomeScreen: View {
@ObservedObject var context: HomeScreenViewModel.Context @ObservedObject var context: HomeScreenViewModel.Context
@ -24,18 +23,110 @@ struct HomeScreen: View {
// MARK: Views // MARK: Views
var body: some View { var body: some View {
VStack { NavigationView {
Text("Hello, \(context.viewState.username)!") VStack(spacing: 16.0) {
HStack {
if let avatar = context.viewState.userAvatar {
Image(uiImage: avatar)
.resizable()
.scaledToFill()
.frame(width: 40, height: 40, alignment: .center)
} else {
let _ = context.send(viewAction: .loadUserAvatar)
}
Text("Hello, \(context.viewState.userDisplayName)!")
.font(.subheadline)
.fontWeight(.bold)
}
.padding(.vertical, 32.0)
List {
Section("People") {
ForEach(context.viewState.directRooms) { room in
RoomCell(room: room, context: context)
}
}
Section("Rooms") {
ForEach(context.viewState.nondirectRooms) { room in
RoomCell(room: room, context: context)
}
}
}
.headerProminence(.increased)
.listStyle(.plain)
}
.navigationBarTitleDisplayMode(.inline)
.navigationViewStyle(StackNavigationViewStyle())
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Logout") {
context.send(viewAction: .logout)
}
}
}
} }
} }
} }
struct RoomCell: View {
let room: HomeScreenRoom
let context: HomeScreenViewModel.Context
var body: some View {
HStack(spacing: 16.0) {
if let avatar = room.avatar {
Image(uiImage: avatar)
.resizable()
.scaledToFill()
.frame(width: 40, height: 40)
} else {
let _ = context.send(viewAction: .loadRoomAvatar(roomId: room.id))
Image(systemName: "person.3")
.frame(width: 40, height: 40)
}
VStack(alignment: .leading, spacing: 4.0) {
Text(roomName(room))
.font(.headline)
.fontWeight(.regular)
if let roomTopic = room.topic, roomTopic.count > 0 {
Text(roomTopic)
.font(.footnote)
.fontWeight(.bold)
.lineLimit(1)
}
if let lastMessage = room.lastMessage {
Text(lastMessage)
.font(.footnote)
.fontWeight(.medium)
.lineLimit(1)
}
}
}
.frame(minHeight: 60.0)
}
private func roomName(_ room: HomeScreenRoom) -> String {
room.displayName + (room.isEncrypted ? "🛡": "")
}
}
// MARK: - Previews // MARK: - Previews
@available(iOS 14.0, *)
struct HomeScreen_Previews: PreviewProvider { struct HomeScreen_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let viewModel = HomeScreenViewModel(username: "Johnny Appleseed") let viewModel = HomeScreenViewModel(userDisplayName: "Johnny Appleseed")
HomeScreen(context: viewModel.context)
let rooms = [MockRoomModel(displayName: "Alfa"),
MockRoomModel(displayName: "Beta"),
MockRoomModel(displayName: "Omega")]
viewModel.updateWithRoomList(rooms)
return HomeScreen(context: viewModel.context)
} }
} }

View File

@ -0,0 +1,29 @@
//
// MockRoomModel.swift
// ElementX
//
// Created by Stefan Ceriu on 17.02.2022.
//
import Foundation
import UIKit
struct MockRoomModel: RoomModelProtocol {
let identifier = UUID().uuidString
let name: String? = nil
let displayName: String
let topic: String? = nil
let lastMessage: String? = "Last message"
let avatarURL: URL? = nil
let isDirect = Bool.random()
let isSpace = Bool.random()
let isPublic = Bool.random()
let isEncrypted = Bool.random()
func getAvatar(_ completion: (Result<UIImage?, Error>) -> Void) {
completion(.success(UIImage(systemName: "wand.and.stars")))
}
}

View File

@ -7,7 +7,86 @@
import Foundation import Foundation
import UIKit import UIKit
import MatrixRustSDK
struct RoomModel { enum RoomModelError: Error {
let displayName: String case failedRetrievingAvatar
}
struct RoomModel: RoomModelProtocol {
private let room: Room
init(room: Room) {
self.room = room
}
var identifier: String {
return room.identifier()
}
var isDirect: Bool {
return room.isDirect()
}
var isPublic: Bool {
return room.isPublic()
}
var isSpace: Bool {
return room.isSpace()
}
var isEncrypted: Bool {
return room.isEncrypted()
}
var name: String? {
return room.name()
}
var displayName: String {
do {
return try room.displayName()
} catch {
MXLog.error("Failed retrieving room name with error: \(error)")
return "Error"
}
}
var topic: String? {
return room.topic()
}
var lastMessage: String? {
guard let lastMessage = try? room.messages().last else {
return "Last message unknown"
}
return "\(lastMessage.sender()): \(lastMessage.content())"
}
var avatarURL: URL? {
guard let urlString = room.avatarUrl() else {
return nil
}
return URL(string: urlString)
}
func getAvatar(_ completion: @escaping (Result<UIImage?, Error>) -> Void) {
DispatchQueue.global(qos: .background).async {
do {
let avatarData = try room.avatar()
DispatchQueue.main.async {
completion(.success(UIImage(data: Data(bytes: avatarData, count: avatarData.count))))
}
} catch {
DispatchQueue.main.async {
completion(.failure(RoomModelError.failedRetrievingAvatar))
}
}
}
}
} }

View File

@ -0,0 +1,26 @@
//
// RoomModelProtocol.swift
// ElementX
//
// Created by Stefan Ceriu on 17.02.2022.
//
import UIKit
protocol RoomModelProtocol {
var identifier: String { get }
var isDirect: Bool { get }
var isPublic: Bool { get }
var isSpace: Bool { get }
var isEncrypted: Bool { get }
var displayName: String { get }
var name: String? { get }
var topic: String? { get }
var lastMessage: String? { get }
var avatarURL: URL? { get }
func getAvatar(_ completion: @escaping (Result<UIImage?, Error>) -> Void)
}