Release 2.0.0

This commit is contained in:
Frank Rotermund
2022-11-27 13:18:53 +00:00
parent bf57719009
commit 0dc8ec0982
570 changed files with 20366 additions and 4410 deletions
@@ -25,54 +25,76 @@ struct UserSessionListItem: View {
}
@Environment(\.theme) private var theme: ThemeSwiftUI
let viewData: UserSessionListItemViewData
var isEditModeEnabled = false
var onBackgroundTap: ((String) -> Void)?
var onBackgroundLongPress: ((String) -> Void)?
var body: some View {
Button {
onBackgroundTap?(viewData.sessionId)
} label: {
VStack(alignment: .leading, spacing: LayoutConstants.verticalPadding) {
HStack(spacing: LayoutConstants.avatarRightMargin) {
DeviceAvatarView(viewData: viewData.deviceAvatarViewData)
VStack(alignment: .leading, spacing: 2) {
Text(viewData.sessionName)
.font(theme.fonts.bodySB)
.foregroundColor(theme.colors.primaryContent)
.multilineTextAlignment(.leading)
Text(viewData.sessionDetails)
.font(theme.fonts.caption1)
.foregroundColor(theme.colors.secondaryContent)
.multilineTextAlignment(.leading)
}
Button { } label: {
ZStack {
if viewData.isSelected {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(theme.colors.system)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(4)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, LayoutConstants.horizontalPadding)
// Separator
// Note: Separator leading is matching the text leading, we could use alignment guide in the future
SeparatorLine()
.padding(.leading, LayoutConstants.horizontalPadding + LayoutConstants.avatarRightMargin + LayoutConstants.avatarWidth)
VStack(alignment: .leading, spacing: LayoutConstants.verticalPadding) {
HStack(spacing: LayoutConstants.avatarRightMargin) {
if isEditModeEnabled {
Image(viewData.isSelected ? Asset.Images.userSessionListItemSelected.name : Asset.Images.userSessionListItemNotSelected.name)
}
DeviceAvatarView(viewData: viewData.deviceAvatarViewData, isSelected: viewData.isSelected)
VStack(alignment: .leading, spacing: 2) {
Text(viewData.sessionName)
.font(theme.fonts.bodySB)
.foregroundColor(theme.colors.primaryContent)
.multilineTextAlignment(.leading)
HStack {
if let sessionDetailsIcon = viewData.sessionDetailsIcon {
Image(sessionDetailsIcon)
.padding(.leading, 2)
}
Text(viewData.sessionDetails)
.font(theme.fonts.caption1)
.foregroundColor(viewData.highlightSessionDetails ? theme.colors.alert : theme.colors.secondaryContent)
.multilineTextAlignment(.leading)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, LayoutConstants.horizontalPadding)
// Separator
// Note: Separator leading is matching the text leading, we could use alignment guide in the future
SeparatorLine()
.padding(.leading, LayoutConstants.horizontalPadding + LayoutConstants.avatarRightMargin + LayoutConstants.avatarWidth)
}
.padding(.top, LayoutConstants.verticalPadding)
}.onTapGesture {
onBackgroundTap?(viewData.sessionId)
}
.onLongPressGesture {
onBackgroundLongPress?(viewData.sessionId)
}
.padding(.top, LayoutConstants.verticalPadding)
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityIdentifier("UserSessionListItem_\(viewData.sessionId)")
}
}
struct UserSessionListPreview: View {
let userSessionsOverviewService: UserSessionsOverviewServiceProtocol = MockUserSessionsOverviewService()
var isEditModeEnabled = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(userSessionsOverviewService.overviewData.otherSessions) { userSessionInfo in
let viewData = UserSessionListItemViewData(session: userSessionInfo)
UserSessionListItem(viewData: viewData, onBackgroundTap: { _ in
ForEach(userSessionsOverviewService.otherSessions) { userSessionInfo in
let viewData = UserSessionListItemViewDataFactory().create(from: userSessionInfo)
UserSessionListItem(viewData: viewData, isEditModeEnabled: isEditModeEnabled, onBackgroundTap: { _ in
})
}
}
@@ -84,6 +106,8 @@ struct UserSessionListItem_Previews: PreviewProvider {
Group {
UserSessionListPreview().theme(.light).preferredColorScheme(.light)
UserSessionListPreview().theme(.dark).preferredColorScheme(.dark)
UserSessionListPreview(isEditModeEnabled: true).theme(.light).preferredColorScheme(.light)
UserSessionListPreview(isEditModeEnabled: true).theme(.dark).preferredColorScheme(.dark)
}
}
}
@@ -16,60 +16,25 @@
import Foundation
typealias SessionId = String
/// View data for UserSessionListItem
struct UserSessionListItemViewData: Identifiable {
struct UserSessionListItemViewData: Identifiable, Hashable {
var id: String {
sessionId
}
let sessionId: String
let sessionId: SessionId
let sessionName: String
let sessionDetails: String
let highlightSessionDetails: Bool
let deviceAvatarViewData: DeviceAvatarViewData
init(sessionId: String,
sessionDisplayName: String?,
deviceType: DeviceType,
isVerified: Bool,
lastActivityDate: TimeInterval?) {
self.sessionId = sessionId
sessionName = UserSessionNameFormatter.sessionName(deviceType: deviceType, sessionDisplayName: sessionDisplayName)
sessionDetails = Self.buildSessionDetails(isVerified: isVerified, lastActivityDate: lastActivityDate)
deviceAvatarViewData = DeviceAvatarViewData(deviceType: deviceType, isVerified: isVerified)
}
// MARK: - Private
private static func buildSessionDetails(isVerified: Bool, lastActivityDate: TimeInterval?) -> String {
let sessionDetailsString: String
let sessionStatusText = isVerified ? VectorL10n.userSessionVerifiedShort : VectorL10n.userSessionUnverifiedShort
var lastActivityDateString: String?
if let lastActivityDate = lastActivityDate {
lastActivityDateString = UserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityDate)
}
if let lastActivityDateString = lastActivityDateString, lastActivityDateString.isEmpty == false {
sessionDetailsString = VectorL10n.userSessionItemDetails(sessionStatusText, lastActivityDateString)
} else {
sessionDetailsString = sessionStatusText
}
return sessionDetailsString
}
}
extension UserSessionListItemViewData {
init(session: UserSessionInfo) {
self.init(sessionId: session.id,
sessionDisplayName: session.name,
deviceType: session.deviceType,
isVerified: session.isVerified,
lastActivityDate: session.lastSeenTimestamp)
}
let sessionDetailsIcon: String?
let isSelected: Bool
}
@@ -0,0 +1,95 @@
//
// 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 UserSessionListItemViewDataFactory {
func create(from sessionInfo: UserSessionInfo,
highlightSessionDetails: Bool = false,
isSelected: Bool = false) -> UserSessionListItemViewData {
let sessionName = UserSessionNameFormatter.sessionName(deviceType: sessionInfo.deviceType,
sessionDisplayName: sessionInfo.name)
let sessionDetails = buildSessionDetails(sessionInfo: sessionInfo)
let deviceAvatarViewData = DeviceAvatarViewData(deviceType: sessionInfo.deviceType,
verificationState: sessionInfo.verificationState)
return UserSessionListItemViewData(sessionId: sessionInfo.id,
sessionName: sessionName,
sessionDetails: sessionDetails,
highlightSessionDetails: highlightSessionDetails,
deviceAvatarViewData: deviceAvatarViewData,
sessionDetailsIcon: getSessionDetailsIcon(isActive: sessionInfo.isActive),
isSelected: isSelected)
}
private func buildSessionDetails(sessionInfo: UserSessionInfo) -> String {
if sessionInfo.isActive {
return activeSessionDetails(sessionInfo: sessionInfo)
} else {
return inactiveSessionDetails(sessionInfo: sessionInfo)
}
}
private func inactiveSessionDetails(sessionInfo: UserSessionInfo) -> String {
if let lastActivityDate = sessionInfo.lastSeenTimestamp {
let lastActivityDateString = InactiveUserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityDate)
return VectorL10n.userInactiveSessionItemWithDate(lastActivityDateString)
}
return VectorL10n.userInactiveSessionItem
}
private func activeSessionDetails(sessionInfo: UserSessionInfo) -> String {
// Start by creating the main part of the details string.
var sessionDetailsString = ""
var lastActivityDateString: String?
if let lastActivityDate = sessionInfo.lastSeenTimestamp {
lastActivityDateString = UserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityDate)
}
if sessionInfo.isCurrent {
sessionDetailsString = VectorL10n.userOtherSessionCurrentSessionDetails
} else if let lastActivityDateString = lastActivityDateString, lastActivityDateString.isEmpty == false {
sessionDetailsString = VectorL10n.userSessionItemDetailsLastActivity(lastActivityDateString)
}
// Prepend the verification state if one is known.
let sessionStatusText: String?
switch sessionInfo.verificationState {
case .verified:
sessionStatusText = VectorL10n.userSessionVerifiedShort
case .unverified:
sessionStatusText = VectorL10n.userSessionUnverifiedShort
case .unknown:
sessionStatusText = nil
}
if let sessionStatusText = sessionStatusText {
if sessionDetailsString.isEmpty {
sessionDetailsString = sessionStatusText
} else {
sessionDetailsString = VectorL10n.userSessionItemDetails(sessionStatusText, sessionDetailsString)
}
} else if sessionDetailsString.isEmpty {
sessionDetailsString = VectorL10n.userSessionVerificationUnknownShort
}
return sessionDetailsString
}
private func getSessionDetailsIcon(isActive: Bool) -> String? {
isActive ? nil : Asset.Images.userSessionListItemInactiveSession.name
}
}
@@ -0,0 +1,65 @@
//
// 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 UserSessionsListViewAllView: View {
@Environment(\.theme) private var theme: ThemeSwiftUI
let count: Int
var onBackgroundTap: (() -> Void)?
var body: some View {
Button {
onBackgroundTap?()
} label: {
Button(action: { onBackgroundTap?() }) {
VStack(spacing: 0) {
HStack {
Text("View all (\(count))")
.font(theme.fonts.body)
.foregroundColor(theme.colors.accent)
.frame(maxWidth: .infinity, alignment: .leading)
Image(Asset.Images.chevron.name)
}
.padding(.vertical, 15)
.padding(.trailing, 20)
SeparatorLine()
}
.background(theme.colors.background)
.padding(.leading, 72)
}
}
.accessibilityIdentifier("ViewAllButton")
}
}
struct UserSessionsListViewAllView_Previews: PreviewProvider {
static var previews: some View {
Group {
UserSessionsListViewAllView(count: 8)
.previewLayout(PreviewLayout.sizeThatFits)
.theme(.light)
.preferredColorScheme(.light)
UserSessionsListViewAllView(count: 8)
.previewLayout(PreviewLayout.sizeThatFits)
.theme(.dark)
.preferredColorScheme(.dark)
}
}
}
@@ -21,22 +21,36 @@ struct UserSessionsOverview: View {
@ObservedObject var viewModel: UserSessionsOverviewViewModel.Context
private let maxOtherSessionsToDisplay = 5
var body: some View {
ScrollView {
if hasSecurityRecommendations {
securityRecommendationsSection
}
currentSessionsSection
if !viewModel.viewState.otherSessionsViewData.isEmpty {
otherSessionsSection
GeometryReader { _ in
VStack(alignment: .leading, spacing: 0) {
ScrollView {
if hasSecurityRecommendations {
securityRecommendationsSection
}
currentSessionsSection
if !viewModel.viewState.otherSessionsViewData.isEmpty {
otherSessionsSection
}
}
.readableFrame()
// if viewModel.viewState.linkDeviceButtonVisible {
// linkDeviceView
// .padding(.bottom, geometry.safeAreaInsets.bottom > 0 ? 20 : 36)
// }
}
}
.background(theme.colors.system.ignoresSafeArea())
.frame(maxHeight: .infinity)
.navigationTitle(VectorL10n.userSessionsOverviewTitle)
.navigationBarTitleDisplayMode(.inline)
.activityIndicator(show: viewModel.viewState.showLoadingIndicator)
.accentColor(theme.colors.accent)
.onAppear {
viewModel.send(viewAction: .viewAppeared)
}
@@ -91,26 +105,61 @@ struct UserSessionsOverview: View {
viewModel.send(viewAction: .viewCurrentSessionDetails)
})
} header: {
Text(VectorL10n.userSessionsOverviewCurrentSessionSectionTitle)
.textCase(.uppercase)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.secondaryContent)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 12.0)
.padding(.top, 24.0)
HStack(alignment: .firstTextBaseline) {
Text(VectorL10n.userSessionsOverviewCurrentSessionSectionTitle)
.textCase(.uppercase)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.secondaryContent)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom, 12.0)
.padding(.top, 24.0)
currentSessionMenu
}
}
.padding(.horizontal, 16)
}
}
private var currentSessionMenu: some View {
Menu {
SwiftUI.Section {
Button { viewModel.send(viewAction: .renameCurrentSession) } label: {
Label(VectorL10n.manageSessionRename, systemImage: "pencil")
}
}
if #available(iOS 15, *) {
Button(role: .destructive) { viewModel.send(viewAction: .logoutOfCurrentSession) } label: {
Label(VectorL10n.signOut, systemImage: "rectangle.portrait.and.arrow.right.fill")
}
} else {
Button { viewModel.send(viewAction: .logoutOfCurrentSession) } label: {
Label(VectorL10n.signOut, systemImage: "rectangle.righthalf.inset.fill.arrow.right")
}
}
} label: {
Image(systemName: "ellipsis")
.foregroundColor(theme.colors.secondaryContent)
.padding(.horizontal, 8)
.padding(.vertical, 12)
}
.offset(x: 8) // Re-align the symbol after applying padding.
}
private var otherSessionsSection: some View {
SwiftUI.Section {
LazyVStack(spacing: 0) {
ForEach(viewModel.viewState.otherSessionsViewData) { viewData in
ForEach(viewModel.viewState.otherSessionsViewData.prefix(maxOtherSessionsToDisplay)) { viewData in
UserSessionListItem(viewData: viewData, onBackgroundTap: { sessionId in
viewModel.send(viewAction: .tapUserSession(sessionId))
})
}
if viewModel.viewState.otherSessionsViewData.count > maxOtherSessionsToDisplay {
UserSessionsListViewAllView(count: viewModel.viewState.otherSessionsViewData.count) {
viewModel.send(viewAction: .viewAllOtherSessions)
}
}
}
.background(theme.colors.background)
} header: {
@@ -132,6 +181,23 @@ struct UserSessionsOverview: View {
}
.accessibilityIdentifier("userSessionsOverviewOtherSection")
}
/// The footer view containing link device button.
var linkDeviceView: some View {
VStack {
Button {
viewModel.send(viewAction: .linkDevice)
} label: {
Text(VectorL10n.userSessionsOverviewLinkDevice)
}
.buttonStyle(PrimaryActionButtonStyle(font: theme.fonts.bodySB))
.padding(.top, 28)
.padding(.bottom, 12)
.padding(.horizontal, 16)
.accessibilityIdentifier("linkDeviceButton")
}
.background(theme.colors.system.ignoresSafeArea())
}
}
// MARK: - Previews