SimpleX-Chat/apps/ios/Shared/Views/ChatList/UserPicker.swift
Evgeny 9478172a82
ios: fix toolbar in member support chat (#5902)
* ios: fix toolbar in member support chat

* refactor

* refactor nav buttons

* ios: unread reports, members requiring attention

* top bar destop (special case for 1 support chat)

* put Navigation view higher in hierarchy

* use nav title for knocking

* refactor loading secondary chat, overlay fake toolbar for knocking chats

* fix member reports toolbar

* unread counter for member's support chat
2025-05-14 10:36:54 +01:00

297 lines
12 KiB
Swift

//
// Created by Avently on 16.01.2023.
// Copyright (c) 2023 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct UserPicker: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@Environment(\.scenePhase) private var scenePhase: ScenePhase
@Environment(\.colorScheme) private var colorScheme: ColorScheme
@Binding var userPickerShown: Bool
@Binding var activeSheet: UserPickerSheet?
@State private var currentUser: Int64?
@State private var switchingProfile = false
@State private var frameWidth: CGFloat = 0
@State private var resetScroll = ResetScrollAction()
// Inset grouped list dimensions
private let imageSize: CGFloat = 44
private let rowPadding: CGFloat = 16
private let rowVerticalPadding: CGFloat = 11
private let sectionSpacing: CGFloat = 35
private var sectionHorizontalPadding: CGFloat { frameWidth > 375 ? 20 : 16 }
private let sectionShape = RoundedRectangle(cornerRadius: 10, style: .continuous)
var body: some View {
let otherUsers: [UserInfo] = m.users
.filter { u in !u.user.hidden && u.user.userId != m.currentUser?.userId }
.sorted(using: KeyPathComparator<UserInfo>(\.user.activeOrder, order: .reverse))
let sectionWidth = max(frameWidth - sectionHorizontalPadding * 2, 0)
let currentUserWidth = max(frameWidth - sectionHorizontalPadding - rowPadding * 2 - 14 - imageSize, 0)
let stopped = m.chatRunning != true
VStack(spacing: sectionSpacing) {
if let user = m.currentUser {
StickyScrollView(resetScroll: $resetScroll) {
HStack(spacing: rowPadding) {
HStack {
ProfileImage(imageStr: user.image, size: imageSize, color: Color(uiColor: .tertiarySystemGroupedBackground))
.padding(.trailing, 6)
profileName(user).lineLimit(1)
}
.padding(rowPadding)
.frame(width: otherUsers.isEmpty ? sectionWidth : currentUserWidth, alignment: .leading)
.modifier(ListRow { activeSheet = .currentProfile })
.clipShape(sectionShape)
.disabled(stopped)
.opacity(stopped ? 0.4 : 1)
ForEach(otherUsers) { u in
userView(u, size: imageSize)
.frame(maxWidth: sectionWidth * 0.618)
.fixedSize()
.disabled(stopped)
.opacity(stopped ? 0.4 : 1)
}
}
.padding(.horizontal, sectionHorizontalPadding)
}
.frame(height: 2 * rowPadding + imageSize)
.padding(.top, sectionSpacing)
.overlay(DetermineWidth())
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
}
VStack(spacing: 0) {
openSheetOnTap("qrcode", title: m.userAddress == nil ? "Create SimpleX address" : "Your SimpleX address", sheet: .address, disabled: stopped)
openSheetOnTap("switch.2", title: "Chat preferences", sheet: .chatPreferences, disabled: stopped)
openSheetOnTap("person.crop.rectangle.stack", title: "Your chat profiles", sheet: .chatProfiles, disabled: stopped)
openSheetOnTap("desktopcomputer", title: "Use from desktop", sheet: .useFromDesktop, disabled: stopped)
ZStack(alignment: .trailing) {
openSheetOnTap("gearshape", title: "Settings", sheet: .settings, showDivider: false)
Image(systemName: colorScheme == .light ? "sun.max" : "moon.fill")
.resizable()
.scaledToFit()
.symbolRenderingMode(.monochrome)
.foregroundColor(theme.colors.secondary)
.frame(maxWidth: 20, maxHeight: .infinity)
.padding(.horizontal, rowPadding)
.background(Color(.systemBackground).opacity(0.01))
.onTapGesture {
if (colorScheme == .light) {
ThemeManager.applyTheme(systemDarkThemeDefault.get())
} else {
ThemeManager.applyTheme(DefaultTheme.LIGHT.themeName)
}
}
.onLongPressGesture {
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
}
}
}
.clipShape(sectionShape)
.padding(.horizontal, sectionHorizontalPadding)
.padding(.bottom, sectionSpacing)
}
.onAppear {
// This check prevents the call of listUsers after the app is suspended, and the database is closed.
if case .active = scenePhase {
currentUser = m.currentUser?.userId
Task {
do {
let users = try await listUsersAsync()
await MainActor.run {
m.users = users
currentUser = m.currentUser?.userId
}
} catch {
logger.error("Error loading users \(responseError(error))")
}
}
}
}
.onChange(of: userPickerShown) {
if !$0 { resetScroll() }
}
.modifier(ThemedBackground(grouped: true))
.disabled(switchingProfile)
}
private func userView(_ u: UserInfo, size: CGFloat) -> some View {
HStack {
ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: u.user.image, size: size, color: Color(uiColor: .tertiarySystemGroupedBackground))
if (u.unreadCount > 0) {
userUnreadBadge(u, theme: theme).offset(x: 4, y: -4)
}
}
.padding(.trailing, 6)
Text(u.user.displayName).font(.title2).lineLimit(1)
}
.padding(rowPadding)
.modifier(ListRow {
switchingProfile = true
Task {
do {
try await changeActiveUserAsync_(u.user.userId, viewPwd: nil)
await MainActor.run {
switchingProfile = false
userPickerShown = false
}
} catch {
await MainActor.run {
switchingProfile = false
showAlert(
NSLocalizedString("Error switching profile!", comment: "alertTitle"),
message: String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "alert message"), responseError(error))
)
}
}
}
})
.clipShape(sectionShape)
}
private func openSheetOnTap(_ icon: String, title: LocalizedStringKey, sheet: UserPickerSheet, showDivider: Bool = true, disabled: Bool = false) -> some View {
ZStack(alignment: .bottom) {
settingsRow(icon, color: theme.colors.secondary) {
Text(title).foregroundColor(.primary).opacity(disabled ? 0.4 : 1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, rowPadding)
.padding(.vertical, rowVerticalPadding)
.modifier(ListRow { activeSheet = sheet })
.disabled(disabled)
if showDivider {
Divider().padding(.leading, 52)
}
}
}
}
@inline(__always)
func userUnreadBadge(_ userInfo: UserInfo, theme: AppTheme) -> some View {
UnreadBadge(
count: userInfo.unreadCount,
color: userInfo.user.showNtfs ? theme.colors.primary : theme.colors.secondary
)
}
struct UnreadBadge: View {
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
var count: Int
var color: Color
var body: some View {
let size = dynamicSize(userFont).chatInfoSize
unreadCountText(count)
.font(userFont <= .xxxLarge ? .caption : .caption2)
.foregroundColor(.white)
.padding(.horizontal, dynamicSize(userFont).unreadPadding)
.frame(minWidth: size, minHeight: size)
.background(color)
.cornerRadius(dynamicSize(userFont).unreadCorner)
}
}
struct ListRow: ViewModifier {
@Environment(\.colorScheme) private var colorScheme: ColorScheme
@State private var touchDown = false
let action: () -> Void
func body(content: Content) -> some View {
ZStack {
elevatedSecondarySystemGroupedBackground
Color(.systemGray4).opacity(touchDown ? 1 : 0)
content
TouchOverlay(touchDown: $touchDown, action: action)
}
}
var elevatedSecondarySystemGroupedBackground: Color {
switch colorScheme {
case .dark: Color(0xFF2C2C2E)
default: Color(0xFFFFFFFF)
}
}
struct TouchOverlay: UIViewRepresentable {
@Binding var touchDown: Bool
let action: () -> Void
func makeUIView(context: Context) -> TouchView {
let touchView = TouchView()
let gesture = UILongPressGestureRecognizer(
target: touchView,
action: #selector(touchView.longPress(gesture:))
)
gesture.delegate = touchView
gesture.minimumPressDuration = 0
touchView.addGestureRecognizer(gesture)
return touchView
}
func updateUIView(_ touchView: TouchView, context: Context) {
touchView.representer = self
}
class TouchView: UIView, UIGestureRecognizerDelegate {
var representer: TouchOverlay?
private var startLocation: CGPoint?
private var task: Task<Void, Never>?
@objc
func longPress(gesture: UILongPressGestureRecognizer) {
switch gesture.state {
case .began:
startLocation = gesture.location(in: nil)
task = Task {
do {
try await Task.sleep(nanoseconds: 200_000000)
await MainActor.run { representer?.touchDown = true }
} catch { }
}
case .ended:
if hitTest(gesture.location(in: self), with: nil) == self {
representer?.action()
}
task?.cancel()
representer?.touchDown = false
case .changed:
if let startLocation {
let location = gesture.location(in: nil)
let dx = location.x - startLocation.x
let dy = location.y - startLocation.y
if sqrt(pow(dx, 2) + pow(dy, 2)) > 10 { gesture.state = .failed }
}
case .cancelled, .failed:
task?.cancel()
representer?.touchDown = false
default: break
}
}
func gestureRecognizer(
_: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith: UIGestureRecognizer
) -> Bool { true }
}
}
}
struct UserPicker_Previews: PreviewProvider {
static var previews: some View {
@State var activeSheet: UserPickerSheet?
let m = ChatModel()
m.users = [UserInfo.sampleData, UserInfo.sampleData]
return UserPicker(
userPickerShown: .constant(true),
activeSheet: $activeSheet
)
.environmentObject(m)
}
}