mirror of
https://gitlab.opencode.de/bwi/bundesmessenger/clients/bundesmessenger-ios.git
synced 2026-04-26 19:34:25 +02:00
MESSENGER-3671 Merge FOSS 1.9.8
Conflicts: - CommonConfiguration.swift - BuildSettings.swift - Generated/images.Swift - RoomMemberDetailsViewController.m - LiveLocationSharingViewModell - PinCodeEnterViewController.m
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
//
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -17,7 +17,6 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BorderModifier<Shape: InsettableShape>: ViewModifier {
|
||||
|
||||
var color: Color
|
||||
var borderWidth: CGFloat
|
||||
var shape: Shape
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -15,20 +15,19 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Introspect
|
||||
import SwiftUI
|
||||
|
||||
/// A bordered style of text input
|
||||
///
|
||||
/// As defined in:
|
||||
/// https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=2039%3A26415
|
||||
struct BorderedInputFieldStyle: TextFieldStyle {
|
||||
|
||||
@Environment(\.theme) private var theme: ThemeSwiftUI
|
||||
@Environment(\.isEnabled) private var isEnabled: Bool
|
||||
|
||||
var isEditing: Bool = false
|
||||
var isError: Bool = false
|
||||
var isEditing = false
|
||||
var isError = false
|
||||
|
||||
private var borderColor: Color {
|
||||
if isError {
|
||||
@@ -47,7 +46,7 @@ struct BorderedInputFieldStyle: TextFieldStyle {
|
||||
}
|
||||
|
||||
private var textColor: Color {
|
||||
if (theme.identifier == ThemeIdentifier.dark) {
|
||||
if theme.identifier == ThemeIdentifier.dark {
|
||||
return (isEnabled ? theme.colors.primaryContent : theme.colors.tertiaryContent)
|
||||
} else {
|
||||
return (isEnabled ? theme.colors.primaryContent : theme.colors.quarterlyContent)
|
||||
@@ -55,18 +54,18 @@ struct BorderedInputFieldStyle: TextFieldStyle {
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
if !isEnabled && (theme.identifier == ThemeIdentifier.dark) {
|
||||
if !isEnabled, theme.identifier == ThemeIdentifier.dark {
|
||||
return theme.colors.quinaryContent
|
||||
}
|
||||
return theme.colors.background
|
||||
}
|
||||
|
||||
private var placeholderColor: Color {
|
||||
return theme.colors.tertiaryContent
|
||||
theme.colors.tertiaryContent
|
||||
}
|
||||
|
||||
private var borderWidth: CGFloat {
|
||||
return isEditing || isError ? 2.0 : 1.5
|
||||
isEditing || isError ? 2.0 : 1.5
|
||||
}
|
||||
|
||||
func _body(configuration: TextField<_Label>) -> some View {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -18,7 +18,7 @@ import SwiftUI
|
||||
|
||||
extension ThemableTextEditor {
|
||||
func showClearButton(text: Binding<String>, alignment: VerticalAlignment = .top) -> some View {
|
||||
return modifier(ClearViewModifier(alignment: alignment, text: text))
|
||||
modifier(ClearViewModifier(alignment: alignment, text: text))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -19,7 +19,6 @@ import SwiftUI
|
||||
@available(iOS, introduced: 14.0, deprecated: 15.0, message: "Use Text with an AttributedString instead that includes a link and handle the tap by adding an OpenURLAction to the environment.")
|
||||
/// A `Button`, that fakes having a tappable string inside of a regular string.
|
||||
struct InlineTextButton: View {
|
||||
|
||||
private struct StringComponent {
|
||||
let string: Substring
|
||||
let isTinted: Bool
|
||||
@@ -33,7 +32,6 @@ struct InlineTextButton: View {
|
||||
private let components: [StringComponent]
|
||||
private let action: () -> Void
|
||||
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
/// Creates a new `InlineTextButton`.
|
||||
@@ -43,7 +41,7 @@ struct InlineTextButton: View {
|
||||
/// - action: The action to perform when tapping the button.
|
||||
internal init(_ mainText: String, tappableText: String, action: @escaping () -> Void) {
|
||||
guard let range = mainText.range(of: "%@") else {
|
||||
self.components = [StringComponent(string: Substring(mainText), isTinted: false)]
|
||||
components = [StringComponent(string: Substring(mainText), isTinted: false)]
|
||||
self.action = action
|
||||
return
|
||||
}
|
||||
@@ -52,7 +50,7 @@ struct InlineTextButton: View {
|
||||
let middleComponent = StringComponent(string: Substring(tappableText), isTinted: true)
|
||||
let lastComponent = StringComponent(string: mainText[range.upperBound...], isTinted: false)
|
||||
|
||||
self.components = [firstComponent, middleComponent, lastComponent]
|
||||
components = [firstComponent, middleComponent, lastComponent]
|
||||
self.action = action
|
||||
}
|
||||
|
||||
@@ -63,7 +61,7 @@ struct InlineTextButton: View {
|
||||
EmptyView()
|
||||
}
|
||||
.buttonStyle(Style(components: components))
|
||||
.accessibilityLabel(components.map { $0.string }.joined())
|
||||
.accessibilityLabel(components.map(\.string).joined())
|
||||
}
|
||||
|
||||
private struct Style: ButtonStyle {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -17,14 +17,13 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MultilineTextField: View {
|
||||
|
||||
@Environment(\.theme) private var theme: ThemeSwiftUI
|
||||
|
||||
@Binding private var text: String
|
||||
@State private var dynamicHeight: CGFloat = 100
|
||||
@State private var isEditing = false
|
||||
|
||||
private var placeholder: String = ""
|
||||
private var placeholder = ""
|
||||
|
||||
private var showingPlaceholder: Bool {
|
||||
text.isEmpty
|
||||
@@ -32,11 +31,11 @@ struct MultilineTextField: View {
|
||||
|
||||
init(_ placeholder: String, text: Binding<String>) {
|
||||
self.placeholder = placeholder
|
||||
self._text = text
|
||||
_text = text
|
||||
}
|
||||
|
||||
private var textColor: Color {
|
||||
if (theme.identifier == ThemeIdentifier.dark) {
|
||||
if theme.identifier == ThemeIdentifier.dark {
|
||||
return theme.colors.primaryContent
|
||||
} else {
|
||||
return theme.colors.primaryContent
|
||||
@@ -44,11 +43,11 @@ struct MultilineTextField: View {
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
return theme.colors.background
|
||||
theme.colors.background
|
||||
}
|
||||
|
||||
private var placeholderColor: Color {
|
||||
return theme.colors.tertiaryContent
|
||||
theme.colors.tertiaryContent
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
@@ -60,7 +59,7 @@ struct MultilineTextField: View {
|
||||
}
|
||||
|
||||
private var borderWidth: CGFloat {
|
||||
return isEditing ? 2.0 : 1.5
|
||||
isEditing ? 2.0 : 1.5
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -91,7 +90,7 @@ struct MultilineTextField: View {
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct UITextViewWrapper: UIViewRepresentable {
|
||||
private struct UITextViewWrapper: UIViewRepresentable {
|
||||
typealias UIViewType = UITextView
|
||||
|
||||
@Binding var text: String
|
||||
@@ -115,8 +114,8 @@ fileprivate struct UITextViewWrapper: UIViewRepresentable {
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UITextView, context: UIViewRepresentableContext<UITextViewWrapper>) {
|
||||
if uiView.text != self.text {
|
||||
uiView.text = self.text
|
||||
if uiView.text != text {
|
||||
uiView.text = text
|
||||
}
|
||||
|
||||
UITextViewWrapper.recalculateHeight(view: uiView, result: $calculatedHeight)
|
||||
@@ -132,7 +131,7 @@ fileprivate struct UITextViewWrapper: UIViewRepresentable {
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
return Coordinator(text: $text, height: $calculatedHeight, isEditing: $isEditing)
|
||||
Coordinator(text: $text, height: $calculatedHeight, isEditing: $isEditing)
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, UITextViewDelegate {
|
||||
@@ -142,7 +141,7 @@ fileprivate struct UITextViewWrapper: UIViewRepresentable {
|
||||
|
||||
init(text: Binding<String>, height: Binding<CGFloat>, isEditing: Binding<Bool>) {
|
||||
self.text = text
|
||||
self.calculatedHeight = height
|
||||
calculatedHeight = height
|
||||
self.isEditing = isEditing
|
||||
}
|
||||
|
||||
@@ -171,9 +170,8 @@ fileprivate struct UITextViewWrapper: UIViewRepresentable {
|
||||
}
|
||||
|
||||
struct MultilineTextField_Previews: PreviewProvider {
|
||||
|
||||
static var previews: some View {
|
||||
return Group {
|
||||
Group {
|
||||
VStack {
|
||||
PreviewWrapper()
|
||||
PlaceholderPreviewWrapper()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -17,11 +17,9 @@
|
||||
import SwiftUI
|
||||
|
||||
struct OptionButton: View {
|
||||
|
||||
// MARK: - Style
|
||||
|
||||
private struct Style: ButtonStyle {
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.97 : 1)
|
||||
@@ -61,8 +59,7 @@ struct OptionButton: View {
|
||||
.background(theme.colors.quinaryContent)
|
||||
.foregroundColor(theme.colors.secondaryContent)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
)
|
||||
})
|
||||
.buttonStyle(Style())
|
||||
}
|
||||
}
|
||||
@@ -73,14 +70,14 @@ struct OptionButton_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
VStack {
|
||||
OptionButton(icon: Asset.Images.spaceTypeIcon.image, title: "A title", detailMessage: "Some details for this option", action: {}).theme(.light)
|
||||
OptionButton(icon: nil, title: "A title", detailMessage: "Some details for this option", action: {}).theme(.light)
|
||||
OptionButton(icon: nil, title: "A title", detailMessage: nil, action: {}).theme(.light)
|
||||
OptionButton(icon: Asset.Images.spaceTypeIcon.image, title: "A title", detailMessage: "Some details for this option", action: { }).theme(.light)
|
||||
OptionButton(icon: nil, title: "A title", detailMessage: "Some details for this option", action: { }).theme(.light)
|
||||
OptionButton(icon: nil, title: "A title", detailMessage: nil, action: { }).theme(.light)
|
||||
}
|
||||
VStack {
|
||||
OptionButton(icon: Asset.Images.spaceTypeIcon.image, title: "A title", detailMessage: "Some details for this option", action: {}).theme(.dark)
|
||||
OptionButton(icon: nil, title: "A title", detailMessage: "Some details for this option", action: {}).theme(.dark)
|
||||
OptionButton(icon: nil, title: "A title", detailMessage: nil, action: {}).theme(.dark)
|
||||
OptionButton(icon: Asset.Images.spaceTypeIcon.image, title: "A title", detailMessage: "Some details for this option", action: { }).theme(.dark)
|
||||
OptionButton(icon: nil, title: "A title", detailMessage: "Some details for this option", action: { }).theme(.dark)
|
||||
OptionButton(icon: nil, title: "A title", detailMessage: nil, action: { }).theme(.dark)
|
||||
}.preferredColorScheme(.dark)
|
||||
}
|
||||
.padding()
|
||||
|
||||
@@ -19,7 +19,6 @@ import SwiftUI
|
||||
/// Adds a reveal password button (e.g. an eye button) on the
|
||||
/// right side of the view. For use with `ThemableTextField`.
|
||||
struct PasswordButtonModifier: ViewModifier {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
let text: String
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -20,7 +20,7 @@ struct PrimaryActionButtonStyle: ButtonStyle {
|
||||
@Environment(\.theme) private var theme
|
||||
@Environment(\.isEnabled) private var isEnabled
|
||||
|
||||
var customColor: Color? = nil
|
||||
var customColor: Color?
|
||||
|
||||
private var fontColor: Color {
|
||||
// Always white unless disabled with a dark theme.
|
||||
@@ -69,7 +69,7 @@ struct PrimaryActionButtonStyle_Previews: PreviewProvider {
|
||||
.buttonStyle(PrimaryActionButtonStyle(customColor: .clear))
|
||||
|
||||
Button("Red BG") { }
|
||||
.buttonStyle(PrimaryActionButtonStyle(customColor: .red))
|
||||
.buttonStyle(PrimaryActionButtonStyle(customColor: .red))
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -17,7 +17,6 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RadioButton: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
var title: String
|
||||
@@ -61,8 +60,8 @@ struct RadioButton_Previews: PreviewProvider {
|
||||
|
||||
static var buttonGroup: some View {
|
||||
VStack {
|
||||
RadioButton(title: "A title", selected: false, action: {})
|
||||
RadioButton(title: "A title", selected: true, action: {})
|
||||
RadioButton(title: "A title", selected: false, action: { })
|
||||
RadioButton(title: "A title", selected: true, action: { })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -18,7 +18,7 @@ import SwiftUI
|
||||
|
||||
/// Positions this view within an invisible frame that fills the width of its parent view,
|
||||
/// whilst limiting the width of the content to a readable size (which is customizable).
|
||||
fileprivate struct ReadableFrameModifier: ViewModifier {
|
||||
private struct ReadableFrameModifier: ViewModifier {
|
||||
var maxWidth: CGFloat
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
|
||||
@@ -18,8 +18,7 @@ import UIKit
|
||||
|
||||
/// `ResponderManager` is used to chain `SwiftUI` text editing views that embed `UIKit` text editing views using `UIViewRepresentable`
|
||||
class ResponderManager {
|
||||
|
||||
private static var tagIndex: Int = 1000
|
||||
private static var tagIndex = 1000
|
||||
private static var registeredResponders = NSMapTable<NSNumber, UIView>(keyOptions: .strongMemory, valueOptions: .weakMemory)
|
||||
|
||||
private static var nextIndex: Int {
|
||||
@@ -63,7 +62,7 @@ class ResponderManager {
|
||||
/// Tries to get the focused registered responder and give the focus to it's next responder
|
||||
/// - Returns: `True` if the next responder has been found and is successfully focused. `False` otherwise.
|
||||
static func makeActiveNextResponder() -> Bool {
|
||||
guard let firstResponder = self.firstResponder else {
|
||||
guard let firstResponder = firstResponder else {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -17,17 +17,16 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RoundedBorderTextEditor: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
var title: String? = nil
|
||||
var title: String?
|
||||
let placeHolder: String
|
||||
@Binding var text: String
|
||||
var textMaxHeight: CGFloat? = nil
|
||||
var error: String? = nil
|
||||
var textMaxHeight: CGFloat?
|
||||
var error: String?
|
||||
|
||||
var onTextChanged: ((String) -> Void)? = nil
|
||||
var onEditingChanged: ((Bool) -> Void)? = nil
|
||||
var onTextChanged: ((String) -> Void)?
|
||||
var onEditingChanged: ((Bool) -> Void)?
|
||||
|
||||
@State private var editing = false
|
||||
|
||||
@@ -62,7 +61,7 @@ struct RoundedBorderTextEditor: View {
|
||||
})
|
||||
.showClearButton(text: $text)
|
||||
// Found no good solution here. Hidding next button for the moment
|
||||
// .modifier(NextViewModifier(alignment: .bottomTrailing, isEditing: $editing))
|
||||
// .modifier(NextViewModifier(alignment: .bottomTrailing, isEditing: $editing))
|
||||
.padding(EdgeInsets(top: 2, leading: 6, bottom: 0, trailing: 0))
|
||||
.onChange(of: text, perform: { newText in
|
||||
onTextChanged?(newText)
|
||||
@@ -82,7 +81,7 @@ struct RoundedBorderTextEditor: View {
|
||||
}
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(theme.colors.background))
|
||||
.overlay(RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(editing ? theme.colors.accent : (error == nil ? theme.colors.quinaryContent : theme.colors.alert), lineWidth: editing || error != nil ? 2 : 1))
|
||||
.stroke(editing ? theme.colors.accent : (error == nil ? theme.colors.quinaryContent : theme.colors.alert), lineWidth: editing || error != nil ? 2 : 1))
|
||||
.frame(height: textMaxHeight)
|
||||
if let error = self.error {
|
||||
Text(error)
|
||||
@@ -101,7 +100,6 @@ struct RoundedBorderTextEditor: View {
|
||||
|
||||
struct ThemableTextEditor_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
|
||||
Group {
|
||||
sampleView.theme(.light).preferredColorScheme(.light)
|
||||
sampleView.theme(.dark).preferredColorScheme(.dark)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -17,22 +17,21 @@
|
||||
import SwiftUI
|
||||
|
||||
struct RoundedBorderTextField: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
var title: String? = nil
|
||||
var title: String?
|
||||
let placeHolder: String
|
||||
@Binding var text: String
|
||||
var footerText: String? = nil
|
||||
var isError: Bool = false
|
||||
var footerText: String?
|
||||
var isError = false
|
||||
var isFirstResponder = false
|
||||
|
||||
var configuration: UIKitTextInputConfiguration = UIKitTextInputConfiguration()
|
||||
var configuration = UIKitTextInputConfiguration()
|
||||
@State var isSecureTextVisible = false
|
||||
|
||||
var onTextChanged: ((String) -> Void)? = nil
|
||||
var onEditingChanged: ((Bool) -> Void)? = nil
|
||||
var onCommit: (() -> Void)? = nil
|
||||
var onTextChanged: ((String) -> Void)?
|
||||
var onEditingChanged: ((Bool) -> Void)?
|
||||
var onCommit: (() -> Void)?
|
||||
|
||||
// MARK: Private
|
||||
|
||||
@@ -101,7 +100,7 @@ struct RoundedBorderTextField: View {
|
||||
private var borderColor: Color {
|
||||
if isEditing {
|
||||
return theme.colors.accent
|
||||
} else if footerText != nil && isError {
|
||||
} else if footerText != nil, isError {
|
||||
return theme.colors.alert
|
||||
} else {
|
||||
return theme.colors.quinaryContent
|
||||
@@ -118,7 +117,6 @@ struct RoundedBorderTextField: View {
|
||||
|
||||
struct TextFieldWithError_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
|
||||
Group {
|
||||
sampleView.theme(.light).preferredColorScheme(.light)
|
||||
sampleView.theme(.dark).preferredColorScheme(.dark)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -18,7 +18,6 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct RoundedCornerShape: Shape {
|
||||
|
||||
let radius: CGFloat
|
||||
let corners: UIRectCorner
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -22,7 +22,6 @@ import SwiftUI
|
||||
Replace with Swift 5.5 bindings enumerator later.
|
||||
*/
|
||||
struct SafeBindingCollectionEnumerator<T: RandomAccessCollection & MutableCollection, C: View>: View {
|
||||
|
||||
typealias BoundElement = Binding<T.Element>
|
||||
private let binding: BoundElement
|
||||
private let content: (BoundElement) -> C
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -33,6 +33,6 @@ struct ScreenTrackerViewModifier: ViewModifier {
|
||||
|
||||
extension View {
|
||||
func track(screen: AnalyticsScreen) -> some View {
|
||||
return self.modifier(ScreenTrackerViewModifier(screen: screen))
|
||||
modifier(ScreenTrackerViewModifier(screen: screen))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -17,7 +17,6 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SearchBar: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
var placeholder: String
|
||||
@@ -49,7 +48,7 @@ struct SearchBar: View {
|
||||
.foregroundColor(theme.colors.quarterlyContent)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if isEditing && !text.isEmpty {
|
||||
if isEditing, !text.isEmpty {
|
||||
Button(action: {
|
||||
self.text = ""
|
||||
}) {
|
||||
|
||||
@@ -20,7 +20,7 @@ struct SecondaryActionButtonStyle: ButtonStyle {
|
||||
@Environment(\.theme) private var theme
|
||||
@Environment(\.isEnabled) private var isEnabled
|
||||
|
||||
var customColor: Color? = nil
|
||||
var customColor: Color?
|
||||
|
||||
func makeBody(configuration: Self.Configuration) -> some View {
|
||||
configuration.label
|
||||
@@ -29,8 +29,8 @@ struct SecondaryActionButtonStyle: ButtonStyle {
|
||||
.foregroundColor(customColor ?? theme.colors.accent)
|
||||
.font(theme.fonts.body)
|
||||
.background(RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder()
|
||||
.foregroundColor(customColor ?? theme.colors.accent))
|
||||
.strokeBorder()
|
||||
.foregroundColor(customColor ?? theme.colors.accent))
|
||||
.opacity(opacity(when: configuration.isPressed))
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ struct SecondaryActionButtonStyle_Previews: PreviewProvider {
|
||||
.disabled(true)
|
||||
|
||||
Button("Red BG") { }
|
||||
.buttonStyle(SecondaryActionButtonStyle(customColor: .red))
|
||||
.buttonStyle(SecondaryActionButtonStyle(customColor: .red))
|
||||
|
||||
Button { } label: {
|
||||
Text("Custom")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -14,14 +14,13 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import DesignKit
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS, introduced: 14.0, deprecated: 15.0, message: "Use Text with an AttributedString instead.")
|
||||
/// A `Text` view that renders attributed strings with their `.font` and `.foregroundColor` attributes.
|
||||
/// This view is a workaround for iOS 13/14 not supporting `AttributedString`.
|
||||
struct StyledText: View {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
@@ -31,8 +30,8 @@ struct StyledText: View {
|
||||
/// A string with a bold property.
|
||||
private struct StringComponent {
|
||||
let string: String
|
||||
var font: Font? = nil
|
||||
var color: Color? = nil
|
||||
var font: Font?
|
||||
var color: Color?
|
||||
}
|
||||
|
||||
/// Internal representation of the string as composable parts.
|
||||
@@ -47,7 +46,7 @@ struct StyledText: View {
|
||||
let range = NSRange(location: 0, length: attributedString.length)
|
||||
let string = attributedString.string as NSString
|
||||
|
||||
attributedString.enumerateAttributes(in: range, options: []) { attributes, range, stop in
|
||||
attributedString.enumerateAttributes(in: range, options: []) { attributes, range, _ in
|
||||
let font = attributes[.font] as? UIFont
|
||||
let color = attributes[.foregroundColor] as? UIColor
|
||||
|
||||
@@ -66,7 +65,7 @@ struct StyledText: View {
|
||||
/// Creates a `StyledText` using a plain string.
|
||||
/// - Parameter string: The plain string to display
|
||||
init(_ string: String) {
|
||||
self.components = [StringComponent(string: string, font: nil)]
|
||||
components = [StringComponent(string: string, font: nil)]
|
||||
}
|
||||
|
||||
// MARK: - Views
|
||||
@@ -80,7 +79,6 @@ struct StyledText: View {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct StyledText_Previews: PreviewProvider {
|
||||
static func prettyText() -> NSAttributedString {
|
||||
let string = NSMutableAttributedString(string: "T", attributes: [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -17,11 +17,9 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ThemableButton: View {
|
||||
|
||||
// MARK: - Style
|
||||
|
||||
private struct Style: ButtonStyle {
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.97 : 1)
|
||||
@@ -67,12 +65,12 @@ struct ThemableButton_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
VStack(alignment: .center, spacing: 20) {
|
||||
ThemableButton(icon: Asset.Images.spaceTypeIcon.image, title: "A title", action: {}).theme(.light).preferredColorScheme(.light)
|
||||
ThemableButton(icon: nil, title: "A title", action: {}).theme(.light).preferredColorScheme(.light)
|
||||
ThemableButton(icon: Asset.Images.spaceTypeIcon.image, title: "A title", action: { }).theme(.light).preferredColorScheme(.light)
|
||||
ThemableButton(icon: nil, title: "A title", action: { }).theme(.light).preferredColorScheme(.light)
|
||||
}
|
||||
VStack(alignment: .center, spacing: 20) {
|
||||
ThemableButton(icon: Asset.Images.spaceTypeIcon.image, title: "A title", action: {}).theme(.dark).preferredColorScheme(.dark)
|
||||
ThemableButton(icon: nil, title: "A title", action: {}).theme(.dark).preferredColorScheme(.dark)
|
||||
ThemableButton(icon: Asset.Images.spaceTypeIcon.image, title: "A title", action: { }).theme(.dark).preferredColorScheme(.dark)
|
||||
ThemableButton(icon: nil, title: "A title", action: { }).theme(.dark).preferredColorScheme(.dark)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -17,7 +17,6 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ThemableNavigationBar: View {
|
||||
|
||||
// MARK: - Style
|
||||
|
||||
// MARK: - Properties
|
||||
@@ -36,8 +35,7 @@ struct ThemableNavigationBar: View {
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
HStack {
|
||||
Button(action: {backAction()})
|
||||
{
|
||||
Button(action: { backAction() }) {
|
||||
Image(uiImage: Asset.Images.spacesModalBack.image)
|
||||
.renderingMode(.template)
|
||||
.foregroundColor(theme.colors.secondaryContent)
|
||||
@@ -49,8 +47,7 @@ struct ThemableNavigationBar: View {
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
}
|
||||
Spacer()
|
||||
Button(action: {closeAction()})
|
||||
{
|
||||
Button(action: { closeAction() }) {
|
||||
Image(uiImage: Asset.Images.spacesModalClose.image)
|
||||
.renderingMode(.template)
|
||||
.foregroundColor(theme.colors.secondaryContent)
|
||||
@@ -68,16 +65,16 @@ struct NavigationBar_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
VStack {
|
||||
ThemableNavigationBar(title: nil, showBackButton: true, backAction: {}, closeAction: {})
|
||||
ThemableNavigationBar(title: "Some Title", showBackButton: true, backAction: {}, closeAction: {})
|
||||
ThemableNavigationBar(title: nil, showBackButton: false, backAction: {}, closeAction: {})
|
||||
ThemableNavigationBar(title: "Some Title", showBackButton: false, backAction: {}, closeAction: {})
|
||||
ThemableNavigationBar(title: nil, showBackButton: true, backAction: { }, closeAction: { })
|
||||
ThemableNavigationBar(title: "Some Title", showBackButton: true, backAction: { }, closeAction: { })
|
||||
ThemableNavigationBar(title: nil, showBackButton: false, backAction: { }, closeAction: { })
|
||||
ThemableNavigationBar(title: "Some Title", showBackButton: false, backAction: { }, closeAction: { })
|
||||
}
|
||||
VStack {
|
||||
ThemableNavigationBar(title: nil, showBackButton: true, backAction: {}, closeAction: {}).theme(.dark)
|
||||
ThemableNavigationBar(title: "Some Title", showBackButton: true, backAction: {}, closeAction: {}).theme(.dark)
|
||||
ThemableNavigationBar(title: nil, showBackButton: false, backAction: {}, closeAction: {}).theme(.dark)
|
||||
ThemableNavigationBar(title: "Some Title", showBackButton: false, backAction: {}, closeAction: {}).theme(.dark)
|
||||
ThemableNavigationBar(title: nil, showBackButton: true, backAction: { }, closeAction: { }).theme(.dark)
|
||||
ThemableNavigationBar(title: "Some Title", showBackButton: true, backAction: { }, closeAction: { }).theme(.dark)
|
||||
ThemableNavigationBar(title: nil, showBackButton: false, backAction: { }, closeAction: { }).theme(.dark)
|
||||
ThemableNavigationBar(title: "Some Title", showBackButton: false, backAction: { }, closeAction: { }).theme(.dark)
|
||||
}.preferredColorScheme(.dark)
|
||||
}
|
||||
.padding()
|
||||
|
||||
@@ -16,20 +16,18 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
|
||||
struct ThemableTextEditor: UIViewRepresentable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
@Binding var text: String
|
||||
@State var configuration: UIKitTextInputConfiguration = UIKitTextInputConfiguration()
|
||||
@State var configuration = UIKitTextInputConfiguration()
|
||||
var onEditingChanged: ((_ edit: Bool) -> Void)?
|
||||
|
||||
// MARK: Private
|
||||
|
||||
@Environment(\.theme) private var theme: ThemeSwiftUI
|
||||
|
||||
private let textView: UITextView = UITextView()
|
||||
private let textView = UITextView()
|
||||
private let internalParams = InternalParams()
|
||||
|
||||
// MARK: Setup
|
||||
@@ -37,8 +35,8 @@ struct ThemableTextEditor: UIViewRepresentable {
|
||||
init(text: Binding<String>,
|
||||
configuration: UIKitTextInputConfiguration = UIKitTextInputConfiguration(),
|
||||
onEditingChanged: ((_ edit: Bool) -> Void)? = nil) {
|
||||
self._text = text
|
||||
self._configuration = State(initialValue: configuration)
|
||||
_text = text
|
||||
_configuration = State(initialValue: configuration)
|
||||
self.onEditingChanged = onEditingChanged
|
||||
|
||||
ResponderManager.register(view: textView)
|
||||
@@ -63,8 +61,8 @@ struct ThemableTextEditor: UIViewRepresentable {
|
||||
uiView.textColor = UIColor(theme.colors.primaryContent)
|
||||
uiView.tintColor = UIColor(theme.colors.accent)
|
||||
|
||||
if uiView.text != self.text {
|
||||
uiView.text = self.text
|
||||
if uiView.text != text {
|
||||
uiView.text = text
|
||||
}
|
||||
|
||||
uiView.keyboardType = configuration.keyboardType
|
||||
@@ -81,7 +79,7 @@ struct ThemableTextEditor: UIViewRepresentable {
|
||||
// MARK: - Private
|
||||
|
||||
private func replaceText(with newText: String) {
|
||||
self.text = newText
|
||||
text = newText
|
||||
}
|
||||
|
||||
private class InternalParams {
|
||||
@@ -91,7 +89,7 @@ struct ThemableTextEditor: UIViewRepresentable {
|
||||
// MARK: - Coordinator
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
return Coordinator(self)
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UITextViewDelegate {
|
||||
|
||||
@@ -19,18 +19,17 @@ import SwiftUI
|
||||
struct UIKitTextInputConfiguration {
|
||||
var keyboardType: UIKeyboardType = .default
|
||||
var returnKeyType: UIReturnKeyType = .default
|
||||
var isSecureTextEntry: Bool = false
|
||||
var isSecureTextEntry = false
|
||||
var autocapitalizationType: UITextAutocapitalizationType = .sentences
|
||||
var autocorrectionType: UITextAutocorrectionType = .default
|
||||
}
|
||||
|
||||
struct ThemableTextField: UIViewRepresentable {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
@State var placeholder: String?
|
||||
@Binding var text: String
|
||||
@State var configuration: UIKitTextInputConfiguration = UIKitTextInputConfiguration()
|
||||
@State var configuration = UIKitTextInputConfiguration()
|
||||
@Binding var isSecureTextVisible: Bool
|
||||
var onEditingChanged: ((_ edit: Bool) -> Void)?
|
||||
var onCommit: (() -> Void)?
|
||||
@@ -39,7 +38,7 @@ struct ThemableTextField: UIViewRepresentable {
|
||||
|
||||
@Environment(\.theme) private var theme: ThemeSwiftUI
|
||||
|
||||
private let textField: UITextField = UITextField()
|
||||
private let textField = UITextField()
|
||||
private let internalParams = InternalParams()
|
||||
|
||||
// MARK: Setup
|
||||
@@ -50,10 +49,10 @@ struct ThemableTextField: UIViewRepresentable {
|
||||
isSecureTextVisible: Binding<Bool> = .constant(false),
|
||||
onEditingChanged: ((_ edit: Bool) -> Void)? = nil,
|
||||
onCommit: (() -> Void)? = nil) {
|
||||
self._text = text
|
||||
self._placeholder = State(initialValue: placeholder)
|
||||
self._configuration = State(initialValue: configuration)
|
||||
self._isSecureTextVisible = isSecureTextVisible
|
||||
_text = text
|
||||
_placeholder = State(initialValue: placeholder)
|
||||
_configuration = State(initialValue: configuration)
|
||||
_isSecureTextVisible = isSecureTextVisible
|
||||
self.onEditingChanged = onEditingChanged
|
||||
self.onCommit = onCommit
|
||||
|
||||
@@ -84,8 +83,8 @@ struct ThemableTextField: UIViewRepresentable {
|
||||
uiView.textColor = UIColor(theme.colors.primaryContent)
|
||||
uiView.tintColor = UIColor(theme.colors.accent)
|
||||
|
||||
if uiView.text != self.text {
|
||||
uiView.text = self.text
|
||||
if uiView.text != text {
|
||||
uiView.text = text
|
||||
}
|
||||
uiView.placeholder = placeholder
|
||||
|
||||
@@ -103,17 +102,16 @@ struct ThemableTextField: UIViewRepresentable {
|
||||
// MARK: - Private
|
||||
|
||||
private func replaceText(with newText: String) {
|
||||
self.text = newText
|
||||
text = newText
|
||||
}
|
||||
|
||||
// MARK: - Coordinator
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
return Coordinator(self)
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UITextFieldDelegate {
|
||||
|
||||
var parent: ThemableTextField
|
||||
|
||||
init(_ parent: ThemableTextField) {
|
||||
@@ -146,14 +144,13 @@ struct ThemableTextField: UIViewRepresentable {
|
||||
private class InternalParams {
|
||||
var isFirstResponder = false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - modifiers
|
||||
|
||||
extension ThemableTextField {
|
||||
func makeFirstResponder() -> ThemableTextField {
|
||||
return makeFirstResponder(true)
|
||||
makeFirstResponder(true)
|
||||
}
|
||||
|
||||
func makeFirstResponder(_ isFirstResponder: Bool) -> ThemableTextField {
|
||||
@@ -167,7 +164,7 @@ extension ThemableTextField {
|
||||
/// - alignment: The vertical alignment of the button in the text field. Default to `center`
|
||||
@ViewBuilder
|
||||
func addButton(_ show: Bool, alignment: VerticalAlignment = .center) -> some View {
|
||||
if show && configuration.isSecureTextEntry {
|
||||
if show, configuration.isSecureTextEntry {
|
||||
modifier(PasswordButtonModifier(text: text,
|
||||
isSecureTextVisible: $isSecureTextVisible,
|
||||
alignment: alignment))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -20,7 +20,7 @@ import SwiftUI
|
||||
extension View {
|
||||
@ViewBuilder func isHidden(_ isHidden: Bool) -> some View {
|
||||
if isHidden {
|
||||
self.hidden()
|
||||
hidden()
|
||||
} else {
|
||||
self
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@@ -18,7 +18,6 @@ import SwiftUI
|
||||
|
||||
/// A modifier for showing the wait overlay view over a view.
|
||||
struct WaitOverlayModifier: ViewModifier {
|
||||
|
||||
var allowUserInteraction: Bool
|
||||
var show: Bool
|
||||
var message: String?
|
||||
@@ -27,15 +26,16 @@ struct WaitOverlayModifier: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.modifier(WaitOverlay(
|
||||
allowUserInteraction: allowUserInteraction,
|
||||
message: message,
|
||||
isLoading: show))
|
||||
allowUserInteraction: allowUserInteraction,
|
||||
message: message,
|
||||
isLoading: show
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func waitOverlay(show: Bool, message: String? = nil, allowUserInteraction: Bool = true) -> some View {
|
||||
self.modifier(WaitOverlayModifier(allowUserInteraction: allowUserInteraction, show: show, message: message))
|
||||
modifier(WaitOverlayModifier(allowUserInteraction: allowUserInteraction, show: show, message: message))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ struct WaitOverlay: ViewModifier {
|
||||
// MARK: - Properties
|
||||
|
||||
var alignment: Alignment = .center
|
||||
var allowUserInteraction: Bool = true
|
||||
var allowUserInteraction = true
|
||||
var message: String?
|
||||
var isLoading: Bool
|
||||
|
||||
@@ -66,8 +66,7 @@ struct WaitOverlay: ViewModifier {
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
public func body(content: Content) -> some View
|
||||
{
|
||||
public func body(content: Content) -> some View {
|
||||
ZStack {
|
||||
content
|
||||
if isLoading {
|
||||
@@ -89,7 +88,7 @@ struct WaitOverlay: ViewModifier {
|
||||
}
|
||||
.padding(12)
|
||||
.background(RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
.fill(theme.colors.navigation.opacity(0.9)))
|
||||
.fill(theme.colors.navigation.opacity(0.9)))
|
||||
}
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
.transition(.opacity)
|
||||
@@ -103,24 +102,24 @@ struct WaitOverlay_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Group {
|
||||
VStack {
|
||||
ThemableNavigationBar(title: nil, showBackButton: true, backAction: {}, closeAction: {})
|
||||
ThemableNavigationBar(title: "Some Title", showBackButton: true, backAction: {}, closeAction: {})
|
||||
ThemableNavigationBar(title: nil, showBackButton: false, backAction: {}, closeAction: {})
|
||||
ThemableNavigationBar(title: "Some Title", showBackButton: false, backAction: {}, closeAction: {})
|
||||
ThemableNavigationBar(title: nil, showBackButton: true, backAction: { }, closeAction: { })
|
||||
ThemableNavigationBar(title: "Some Title", showBackButton: true, backAction: { }, closeAction: { })
|
||||
ThemableNavigationBar(title: nil, showBackButton: false, backAction: { }, closeAction: { })
|
||||
ThemableNavigationBar(title: "Some Title", showBackButton: false, backAction: { }, closeAction: { })
|
||||
}
|
||||
.modifier(WaitOverlay(isLoading: true))
|
||||
VStack {
|
||||
ThemableNavigationBar(title: nil, showBackButton: true, backAction: {}, closeAction: {})
|
||||
ThemableNavigationBar(title: "Some Title", showBackButton: true, backAction: {}, closeAction: {})
|
||||
ThemableNavigationBar(title: nil, showBackButton: false, backAction: {}, closeAction: {})
|
||||
ThemableNavigationBar(title: "Some Title", showBackButton: false, backAction: {}, closeAction: {})
|
||||
ThemableNavigationBar(title: nil, showBackButton: true, backAction: { }, closeAction: { })
|
||||
ThemableNavigationBar(title: "Some Title", showBackButton: true, backAction: { }, closeAction: { })
|
||||
ThemableNavigationBar(title: nil, showBackButton: false, backAction: { }, closeAction: { })
|
||||
ThemableNavigationBar(title: "Some Title", showBackButton: false, backAction: { }, closeAction: { })
|
||||
}
|
||||
.modifier(WaitOverlay(alignment:.topLeading, isLoading: true))
|
||||
.modifier(WaitOverlay(alignment: .topLeading, isLoading: true))
|
||||
VStack {
|
||||
ThemableNavigationBar(title: nil, showBackButton: true, backAction: {}, closeAction: {}).theme(.dark)
|
||||
ThemableNavigationBar(title: "Some Title", showBackButton: true, backAction: {}, closeAction: {}).theme(.dark)
|
||||
ThemableNavigationBar(title: nil, showBackButton: false, backAction: {}, closeAction: {}).theme(.dark)
|
||||
ThemableNavigationBar(title: "Some Title", showBackButton: false, backAction: {}, closeAction: {}).theme(.dark)
|
||||
ThemableNavigationBar(title: nil, showBackButton: true, backAction: { }, closeAction: { }).theme(.dark)
|
||||
ThemableNavigationBar(title: "Some Title", showBackButton: true, backAction: { }, closeAction: { }).theme(.dark)
|
||||
ThemableNavigationBar(title: nil, showBackButton: false, backAction: { }, closeAction: { }).theme(.dark)
|
||||
ThemableNavigationBar(title: "Some Title", showBackButton: false, backAction: { }, closeAction: { }).theme(.dark)
|
||||
}
|
||||
|
||||
.modifier(WaitOverlay(isLoading: true)).theme(.dark)
|
||||
|
||||
Reference in New Issue
Block a user