mirror of
https://github.com/ProtonMail/ios-mail.git
synced 2026-05-15 09:50:39 +00:00
ce6dce2d8e
# Conflicts: # Modules/App/Tests/Tests/Snapshots/Screens/Sidebar/SidebarScreenSnapshotTests.swift
478 lines
18 KiB
Swift
478 lines
18 KiB
Swift
// Copyright (c) 2024 Proton Technologies AG
|
|
//
|
|
// This file is part of Proton Mail.
|
|
//
|
|
// Proton Mail is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// Proton Mail is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with Proton Mail. If not, see https://www.gnu.org/licenses/.
|
|
|
|
import InboxCoreUI
|
|
import InboxDesignSystem
|
|
import InboxIAP
|
|
import SwiftUI
|
|
import proton_app_uniffi
|
|
|
|
struct SidebarScreen: View {
|
|
private enum AxisLock {
|
|
case none, horizontal, vertical
|
|
}
|
|
|
|
@EnvironmentObject private var appUIStateStore: AppUIStateStore
|
|
@ObservedObject private var screenModel: SidebarModel
|
|
@State private var headerHeight: CGFloat = .zero
|
|
@GestureState private var gestureState: GestureStateData = .init()
|
|
@State private var lastCommittedAxis: AxisLock = .none
|
|
|
|
private struct GestureStateData {
|
|
var isDragging: Bool = false
|
|
var startWidth: CGFloat?
|
|
var lockedAxis: AxisLock = .none
|
|
}
|
|
|
|
private let widthOfDragableSpaceOnTheMailbox: CGFloat = 25
|
|
/// This value is to make sure a twitch of a finger when releasing the sidebar won't cause the sidebar to move in the other direction against user's wishes.
|
|
private let lastSwipeSignificanceThreshold: CGFloat = 25
|
|
/// Minimum movement (pts) before locking axis.
|
|
/// Avoids accidental lock from tiny diagonal wiggles.
|
|
private let axisSlop: CGFloat = 6
|
|
private let animationDuration = 0.2
|
|
private let selectedItem: (SidebarItem) -> Void
|
|
private let appVersionProvider: AppVersionProvider
|
|
|
|
init(
|
|
screenModel: SidebarModel,
|
|
appVersionProvider: AppVersionProvider = .init(),
|
|
selectedItem: @escaping (SidebarItem) -> Void
|
|
) {
|
|
self.screenModel = screenModel
|
|
self.selectedItem = selectedItem
|
|
self.appVersionProvider = appVersionProvider
|
|
}
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
ZStack(alignment: .leading) {
|
|
opacityBackground
|
|
.gesture(closeSidebarTapGesture)
|
|
.frame(width: 2 * geometry.size.width)
|
|
|
|
Color.clear
|
|
.contentShape(Rectangle())
|
|
.frame(width: appUIStateStore.sidebarWidth + geometry.safeAreaInsets.leading + widthOfDragableSpaceOnTheMailbox)
|
|
.highPriorityGesture(sidebarDragGesture)
|
|
.gesture(appUIStateStore.sidebarState.isOpen ? closeSidebarTapGesture : nil)
|
|
|
|
HStack(spacing: .zero) {
|
|
sideBarBackground
|
|
.frame(width: geometry.safeAreaInsets.leading)
|
|
ZStack(alignment: .topLeading) {
|
|
sideBarBackground
|
|
.shadow(DS.Shadows.liftedRight, isVisible: appUIStateStore.sidebarState.isOpen)
|
|
sideBarItemsList
|
|
.safeAreaPadding(.top, headerHeight)
|
|
header
|
|
}
|
|
.accessibilityElement(children: .contain)
|
|
.accessibilityIdentifier(SidebarScreenIdentifiers.rootItem)
|
|
}
|
|
.frame(width: appUIStateStore.sidebarWidth)
|
|
.simultaneousGesture(sidebarDragGesture)
|
|
}
|
|
.animation(.easeOut(duration: animationDuration), value: appUIStateStore.sidebarState.visibleWidth)
|
|
.offset(x: appUIStateStore.sidebarState.visibleWidth - appUIStateStore.sidebarWidth - geometry.safeAreaInsets.leading)
|
|
}
|
|
.onChange(of: gestureState.isDragging) { _, isDragging in
|
|
if !isDragging && lastCommittedAxis != .none {
|
|
appUIStateStore.toggleSidebar(isOpen: isCloserToOpenThanClosed)
|
|
lastCommittedAxis = .none
|
|
}
|
|
}
|
|
.onAppear { screenModel.handle(action: .viewAppear) }
|
|
.onOpenURL(perform: handleDeepLink)
|
|
}
|
|
|
|
private var header: some View {
|
|
VStack(alignment: .leading) {
|
|
Image(DS.Images.mailProductLogo)
|
|
.padding(.leading, DS.Spacing.extraLarge)
|
|
.padding(.vertical, DS.Spacing.small)
|
|
.onTapGesture(count: 5) { screenModel.handle(action: .logoTappedFiveTimes) }
|
|
separator
|
|
}
|
|
.background(
|
|
GeometryReader { geometry in
|
|
BlurredBackground(fallbackBackgroundColor: DS.Color.Sidebar.background)
|
|
.edgesIgnoringSafeArea(.all)
|
|
.preference(key: HeightPreferenceKey.self, value: geometry.size.height)
|
|
.onPreferenceChange(HeightPreferenceKey.self) { value in
|
|
headerHeight = value
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private var sidebarDragGesture: some Gesture {
|
|
DragGesture(coordinateSpace: .global)
|
|
.updating($gestureState) { value, state, _ in
|
|
if !state.isDragging {
|
|
state.isDragging = true
|
|
state.startWidth = appUIStateStore.sidebarState.visibleWidth
|
|
state.lockedAxis = .none
|
|
}
|
|
|
|
if state.lockedAxis == .none {
|
|
let dx = abs(value.translation.width)
|
|
let dy = abs(value.translation.height)
|
|
|
|
if max(dx, dy) > axisSlop {
|
|
state.lockedAxis = dx > dy ? .horizontal : .vertical
|
|
}
|
|
}
|
|
}
|
|
.onChanged { value in
|
|
if gestureState.lockedAxis != .none && lastCommittedAxis == .none {
|
|
lastCommittedAxis = gestureState.lockedAxis
|
|
}
|
|
|
|
if gestureState.lockedAxis == .horizontal, let startWidth = gestureState.startWidth {
|
|
let dragTranslation = value.translation.width
|
|
let newWidth = startWidth + dragTranslation
|
|
|
|
let clampedWidth = min(appUIStateStore.sidebarWidth, max(0, newWidth))
|
|
appUIStateStore.sidebarState.visibleWidth = clampedWidth
|
|
}
|
|
}
|
|
.onEnded { value in
|
|
guard lastCommittedAxis == .horizontal else {
|
|
lastCommittedAxis = .none
|
|
return
|
|
}
|
|
|
|
defer {
|
|
lastCommittedAxis = .none
|
|
}
|
|
|
|
let predictedEndWidth = value.predictedEndTranslation.width
|
|
let predictedDx = predictedEndWidth - value.translation.width
|
|
let hasPredictedSignificantSlide = abs(predictedDx) > lastSwipeSignificanceThreshold
|
|
|
|
let shouldBeOpen: Bool
|
|
|
|
if hasPredictedSignificantSlide {
|
|
shouldBeOpen = predictedEndWidth > 0
|
|
} else {
|
|
shouldBeOpen = isCloserToOpenThanClosed
|
|
}
|
|
|
|
appUIStateStore.toggleSidebar(isOpen: shouldBeOpen)
|
|
}
|
|
}
|
|
|
|
private var closeSidebarTapGesture: some Gesture {
|
|
TapGesture()
|
|
.onEnded { appUIStateStore.toggleSidebar(isOpen: false) }
|
|
}
|
|
|
|
private var opacityBackground: some View {
|
|
DS.Color.Global.modal
|
|
.animation(.linear(duration: animationDuration), value: appUIStateStore.sidebarState.visibleWidth)
|
|
.opacity(0.5 * (appUIStateStore.sidebarState.visibleWidth / appUIStateStore.sidebarWidth))
|
|
.ignoresSafeArea(.all)
|
|
}
|
|
|
|
private var sideBarBackground: some View {
|
|
DS.Color.Sidebar.background
|
|
.edgesIgnoringSafeArea(.all)
|
|
}
|
|
|
|
private var sideBarItemsList: some View {
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: .zero) {
|
|
VStack(spacing: .zero) {
|
|
if let upsellItem = screenModel.state.upsell, case .upsell(let upsellType) = upsellItem {
|
|
upsellSidebarItem(item: upsellItem, upsellType: upsellType)
|
|
}
|
|
systemFoldersList()
|
|
}
|
|
.padding(.vertical, DS.Spacing.medium)
|
|
|
|
separator
|
|
customFoldersList()
|
|
.padding(.vertical, DS.Spacing.medium)
|
|
separator
|
|
labelsList()
|
|
.padding(.vertical, DS.Spacing.medium)
|
|
separator
|
|
otherItemsList()
|
|
.padding(.vertical, DS.Spacing.medium)
|
|
separator
|
|
appVersionNote
|
|
}
|
|
.onChange(of: appUIStateStore.sidebarState.isOpen) { _, isSidebarOpen in
|
|
if isSidebarOpen, let first = screenModel.state.items.first {
|
|
proxy.scrollTo(first.id, anchor: .zero)
|
|
}
|
|
}
|
|
.accessibilityElement(children: .contain)
|
|
}
|
|
.scrollDisabled(gestureState.lockedAxis == .horizontal || lastCommittedAxis == .horizontal)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func upsellSidebarItem(item: SidebarItem, upsellType: UpsellType) -> some View {
|
|
SidebarItemButton(
|
|
item: item,
|
|
isTappable: isButtonTappable,
|
|
action: { select(item: item) },
|
|
content: {
|
|
HStack(spacing: .zero) {
|
|
sidebarItemImage(icon: upsellType.icon.image, isSelected: false, renderingMode: .original)
|
|
itemNameLabel(name: upsellType.title, isSelected: false)
|
|
Spacer()
|
|
}
|
|
}
|
|
)
|
|
.id(item.id)
|
|
}
|
|
|
|
private func systemFoldersList() -> some View {
|
|
VStack(spacing: .zero) {
|
|
ForEach(screenModel.state.system) { item in
|
|
SidebarItemButton(
|
|
item: .system(item),
|
|
isTappable: isButtonTappable,
|
|
action: { select(item: .system(item)) },
|
|
content: { systemItemContent(model: item) }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func customFoldersList() -> some View {
|
|
VStack(spacing: .zero) {
|
|
ForEach(screenModel.state.folders) { folder in
|
|
SingleFolderNodeView(
|
|
folder: folder,
|
|
isTappable: isButtonTappable,
|
|
selected: { folder in select(item: .folder(folder)) },
|
|
toggle: { folder, expand in screenModel.handle(action: .toggle(folder: folder, expand: expand)) },
|
|
unreadTextView: { count, isSelected in unreadLabel(unreadCount: count, isSelected: isSelected) }
|
|
)
|
|
}
|
|
createButton(
|
|
for: screenModel.state.createFolder,
|
|
isListEmpty: screenModel.state.folders.isEmpty
|
|
)
|
|
}
|
|
}
|
|
|
|
private func labelsList() -> some View {
|
|
VStack(spacing: .zero) {
|
|
ForEach(screenModel.state.labels) { item in
|
|
SidebarItemButton(
|
|
item: .label(item),
|
|
isTappable: isButtonTappable,
|
|
action: { select(item: .label(item)) },
|
|
content: { labelItemContent(model: item) }
|
|
)
|
|
}
|
|
createButton(
|
|
for: screenModel.state.createLabel,
|
|
isListEmpty: screenModel.state.labels.isEmpty
|
|
)
|
|
}
|
|
}
|
|
|
|
private func otherItemsList() -> some View {
|
|
VStack(spacing: .zero) {
|
|
ForEach(screenModel.state.other) { item in
|
|
SidebarItemButton(
|
|
item: .other(item),
|
|
isTappable: isButtonTappable,
|
|
action: { select(item: .other(item)) },
|
|
content: { otherItemContent(model: item) }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func createButton(for item: SidebarOtherItem, isListEmpty: Bool) -> some View {
|
|
SidebarCreateButton(item: item, isTappable: isButtonTappable, isListEmpty: isListEmpty) {
|
|
select(item: .other(item))
|
|
}
|
|
}
|
|
|
|
private func systemItemContent(model: SystemFolder) -> some View {
|
|
HStack(spacing: .zero) {
|
|
sidebarItemImage(icon: model.type.icon, isSelected: model.isSelected)
|
|
itemNameLabel(name: model.type.humanReadable.string, isSelected: model.isSelected)
|
|
Spacer()
|
|
if let unreadCount = model.unreadCount {
|
|
unreadLabel(unreadCount: unreadCount, isSelected: model.isSelected)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func otherItemContent(model: SidebarOtherItem) -> some View {
|
|
HStack(spacing: .zero) {
|
|
sidebarItemImage(icon: model.icon.image, isSelected: model.isSelected)
|
|
itemNameLabel(name: model.name, isSelected: model.isSelected)
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
private func sidebarItemImage(icon: Image, isSelected: Bool, renderingMode: Image.TemplateRenderingMode = .template) -> some View {
|
|
icon
|
|
.renderingMode(renderingMode)
|
|
.resizable()
|
|
.square(size: 20)
|
|
.foregroundStyle(isSelected ? DS.Color.Sidebar.iconSelected : DS.Color.Sidebar.iconNorm)
|
|
.padding(.trailing, DS.Spacing.extraLarge)
|
|
.accessibilityIdentifier(SidebarScreenIdentifiers.icon)
|
|
}
|
|
|
|
private func labelItemContent(model: SidebarLabel) -> some View {
|
|
HStack(spacing: .zero) {
|
|
Color(hex: model.color)
|
|
.square(size: 12)
|
|
.clipShape(Circle())
|
|
.square(size: 20)
|
|
.padding(.trailing, DS.Spacing.extraLarge)
|
|
.accessibilityElement()
|
|
.accessibilityIdentifier(SidebarScreenIdentifiers.icon)
|
|
itemNameLabel(name: model.name, isSelected: model.isSelected)
|
|
Spacer()
|
|
if let unreadCount = model.unreadCount {
|
|
unreadLabel(unreadCount: unreadCount, isSelected: model.isSelected)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func unreadLabel(unreadCount: String, isSelected: Bool) -> some View {
|
|
Text(unreadCount)
|
|
.foregroundStyle(isSelected ? DS.Color.Sidebar.textSelected : DS.Color.Sidebar.textNorm)
|
|
.font(.footnote)
|
|
.fontWeight(.semibold)
|
|
.accessibilityIdentifier(SidebarScreenIdentifiers.badgeIcon)
|
|
}
|
|
|
|
private func itemNameLabel(name: String, isSelected: Bool) -> some View {
|
|
Text(name)
|
|
.font(.subheadline)
|
|
.fontWeight(isSelected ? .bold : .regular)
|
|
.foregroundStyle(isSelected ? DS.Color.Sidebar.textSelected : DS.Color.Sidebar.textNorm)
|
|
.lineLimit(1)
|
|
.accessibilityIdentifier(SidebarScreenIdentifiers.textItem)
|
|
}
|
|
|
|
private var separator: some View {
|
|
Divider()
|
|
.frame(height: 1)
|
|
.background(DS.Color.Sidebar.separator)
|
|
}
|
|
|
|
private var appVersionNote: some View {
|
|
Text("Proton Mail \(appVersionProvider.fullVersion)".notLocalized)
|
|
.font(.footnote)
|
|
.foregroundStyle(DS.Color.Sidebar.textWeak)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.top, DS.Spacing.jumbo)
|
|
.padding(.bottom, DS.Spacing.extraLarge)
|
|
}
|
|
|
|
private func select(item: SidebarItem) {
|
|
screenModel.handle(action: .select(item: item))
|
|
selectedItem(item)
|
|
appUIStateStore.toggleSidebar(isOpen: !item.hideSidebar)
|
|
}
|
|
|
|
private func handleDeepLink(_ deepLink: URL) {
|
|
switch DeepLinkRouteCoder.decode(deepLink: deepLink) {
|
|
case .mailbox(.systemFolder(_, let systemFolder)):
|
|
if let systemFolder = screenModel.state.system.first(where: { $0.type == systemFolder }) {
|
|
let item = SidebarItem.system(systemFolder)
|
|
screenModel.handle(action: .select(item: item))
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private var isButtonTappable: Bool {
|
|
gestureState.lockedAxis == .none && lastCommittedAxis == .none
|
|
}
|
|
|
|
private var isCloserToOpenThanClosed: Bool {
|
|
appUIStateStore.sidebarState.visibleWidth > appUIStateStore.sidebarWidth / 2
|
|
}
|
|
}
|
|
|
|
struct SidebarScreenIdentifiers {
|
|
static let rootItem = "sidebar.rootItem"
|
|
static let icon = "sidebar.button.icon"
|
|
static let textItem = "sidebar.button.text"
|
|
static let badgeIcon = "sidebar.button.badgeIcon"
|
|
|
|
static func otherButton(type: SidebarOtherItem.ItemType) -> String {
|
|
"sidebar.button.\(type.rawValue)"
|
|
}
|
|
}
|
|
|
|
private extension SidebarItem {
|
|
var hideSidebar: Bool {
|
|
switch self {
|
|
case .upsell, .system, .label, .folder:
|
|
true
|
|
case .other(let item):
|
|
item.hideSidebar
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension SidebarOtherItem {
|
|
var hideSidebar: Bool {
|
|
switch type {
|
|
case .createFolder, .createLabel, .settings:
|
|
true
|
|
case .shareLogs, .contacts, .bugReport, .subscriptions:
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension UpsellType {
|
|
var planName: String {
|
|
switch self {
|
|
case .mailPlus:
|
|
"Mail Plus"
|
|
case .unlimited:
|
|
"Unlimited"
|
|
}
|
|
}
|
|
|
|
var icon: ImageResource {
|
|
switch self {
|
|
case .mailPlus:
|
|
DS.Icon.icDiamond
|
|
case .unlimited:
|
|
DS.Icon.icInfinity
|
|
}
|
|
}
|
|
|
|
var title: String {
|
|
L10n.Sidebar.upgrade(to: planName).string
|
|
}
|
|
}
|