Address PR comments

Add more docs.
Rename PhotoPickerPresenter to MediaPickerPresenter.
Use a Character for the placeholder avatar rather than a string.
This commit is contained in:
Doug 2022-03-21 11:42:58 +00:00
parent a41d25f846
commit bf08b86a36
18 changed files with 84 additions and 48 deletions

View file

@ -33,7 +33,6 @@
"onboarding_avatar_title" = "Add a profile picture"; "onboarding_avatar_title" = "Add a profile picture";
"onboarding_avatar_message" = "You can change this anytime."; "onboarding_avatar_message" = "You can change this anytime.";
"onboarding_avatar_placeholder_accessibility_label" = "Profile picture, %@"; "onboarding_avatar_accessibility_label" = "Profile picture";
"onboarding_avatar_image_accessibility_label" = "Profile picture, image";
"image_picker_action_files" = "Choose from files"; "image_picker_action_files" = "Choose from files";

View file

@ -74,6 +74,7 @@
"ok" = "OK"; "ok" = "OK";
"error" = "Error"; "error" = "Error";
"suggest" = "Suggest"; "suggest" = "Suggest";
"edit" = "Edit";
// Activities // Activities
"loading" = "Loading"; "loading" = "Loading";

View file

@ -1651,6 +1651,10 @@ public class VectorL10n: NSObject {
public static var e2eRoomKeyRequestTitle: String { public static var e2eRoomKeyRequestTitle: String {
return VectorL10n.tr("Vector", "e2e_room_key_request_title") return VectorL10n.tr("Vector", "e2e_room_key_request_title")
} }
/// Edit
public static var edit: String {
return VectorL10n.tr("Vector", "edit")
}
/// Activities /// Activities
public static var emojiPickerActivityCategory: String { public static var emojiPickerActivityCategory: String {
return VectorL10n.tr("Vector", "emoji_picker_activity_category") return VectorL10n.tr("Vector", "emoji_picker_activity_category")

View file

@ -14,18 +14,14 @@ public extension VectorL10n {
static var imagePickerActionFiles: String { static var imagePickerActionFiles: String {
return VectorL10n.tr("Untranslated", "image_picker_action_files") return VectorL10n.tr("Untranslated", "image_picker_action_files")
} }
/// Profile picture, image /// Profile picture
static var onboardingAvatarImageAccessibilityLabel: String { static var onboardingAvatarAccessibilityLabel: String {
return VectorL10n.tr("Untranslated", "onboarding_avatar_image_accessibility_label") return VectorL10n.tr("Untranslated", "onboarding_avatar_accessibility_label")
} }
/// You can change this anytime. /// You can change this anytime.
static var onboardingAvatarMessage: String { static var onboardingAvatarMessage: String {
return VectorL10n.tr("Untranslated", "onboarding_avatar_message") return VectorL10n.tr("Untranslated", "onboarding_avatar_message")
} }
/// Profile picture, %@
public static func onboardingAvatarPlaceholderAccessibilityLabel(_ p1: String) -> String {
return VectorL10n.tr("Untranslated", "onboarding_avatar_placeholder_accessibility_label", p1)
}
/// Add a profile picture /// Add a profile picture
static var onboardingAvatarTitle: String { static var onboardingAvatarTitle: String {
return VectorL10n.tr("Untranslated", "onboarding_avatar_title") return VectorL10n.tr("Untranslated", "onboarding_avatar_title")

View file

@ -19,15 +19,18 @@ import PhotosUI
import CommonKit import CommonKit
@available(iOS 14.0, *) @available(iOS 14.0, *)
protocol PhotoPickerPresenterDelegate: AnyObject { protocol MediaPickerPresenterDelegate: AnyObject {
func photoPickerPresenter(_ presenter: PhotoPickerPresenter, didPickImage image: UIImage) func mediaPickerPresenter(_ presenter: MediaPickerPresenter, didPickImage image: UIImage)
func photoPickerPresenterDidCancel(_ presenter: PhotoPickerPresenter) func mediaPickerPresenterDidCancel(_ presenter: MediaPickerPresenter)
} }
/// A picker for photos and videos from the user's photo library on iOS 14+ using the /// A picker for photos and videos from the user's photo library on iOS 14+ using the
/// new `PHPickerViewController` that doesn't require permission to be granted. /// new `PHPickerViewController` that doesn't require permission to be granted.
///
/// **Note:** If you need to support iOS 12 & 13, then you will need to use the older
/// `MediaPickerCoordinator`/`MediaPickerViewController` instead.
@available(iOS 14.0, *) @available(iOS 14.0, *)
final class PhotoPickerPresenter: NSObject { final class MediaPickerPresenter: NSObject {
// MARK: - Properties // MARK: - Properties
@ -40,7 +43,7 @@ final class PhotoPickerPresenter: NSObject {
// MARK: Public // MARK: Public
weak var delegate: PhotoPickerPresenterDelegate? weak var delegate: MediaPickerPresenterDelegate?
// MARK: - Public // MARK: - Public
@ -77,11 +80,11 @@ final class PhotoPickerPresenter: NSObject {
// MARK: - PHPickerViewControllerDelegate // MARK: - PHPickerViewControllerDelegate
@available(iOS 14, *) @available(iOS 14, *)
extension PhotoPickerPresenter: PHPickerViewControllerDelegate { extension MediaPickerPresenter: PHPickerViewControllerDelegate {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
// TODO: Handle videos and multi-selection // TODO: Handle videos and multi-selection
guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else { guard let provider = results.first?.itemProvider, provider.canLoadObject(ofClass: UIImage.self) else {
self.delegate?.photoPickerPresenterDidCancel(self) self.delegate?.mediaPickerPresenterDidCancel(self)
return return
} }
@ -93,14 +96,14 @@ extension PhotoPickerPresenter: PHPickerViewControllerDelegate {
guard let image = image as? UIImage else { guard let image = image as? UIImage else {
DispatchQueue.main.async { DispatchQueue.main.async {
self.hideLoadingIndicator() self.hideLoadingIndicator()
self.delegate?.photoPickerPresenterDidCancel(self) self.delegate?.mediaPickerPresenterDidCancel(self)
} }
return return
} }
DispatchQueue.main.async { DispatchQueue.main.async {
self.hideLoadingIndicator() self.hideLoadingIndicator()
self.delegate?.photoPickerPresenter(self, didPickImage: image) self.delegate?.mediaPickerPresenter(self, didPickImage: image)
} }
} }
} }

View file

@ -343,7 +343,7 @@ final class OnboardingCoordinator: NSObject, OnboardingCoordinatorProtocol {
@available(iOS 14.0, *) @available(iOS 14.0, *)
private func congratulationsCoordinator(_ coordinator: OnboardingCongratulationsCoordinator, didCompleteWith result: OnboardingCongratulationsCoordinatorResult) { private func congratulationsCoordinator(_ coordinator: OnboardingCongratulationsCoordinator, didCompleteWith result: OnboardingCongratulationsCoordinatorResult) {
switch result { switch result {
case .personaliseProfile(let userSession): case .personalizeProfile(let userSession):
if shouldShowDisplayNameScreen { if shouldShowDisplayNameScreen {
showDisplayNameScreen(for: userSession) showDisplayNameScreen(for: userSession)
return return

View file

@ -17,6 +17,11 @@
import SwiftUI import SwiftUI
@available(iOS 14.0, *) @available(iOS 14.0, *)
/// A reusable view that will show a standard placeholder avatar with the
/// supplied character and colour index for the `namesAndAvatars` color array.
///
/// This view has a forced 1:1 aspect ratio but will appear very large until a `.frame`
/// modifier is applied.
struct PlaceholderAvatarImage: View { struct PlaceholderAvatarImage: View {
// MARK: - Private // MARK: - Private
@ -25,7 +30,7 @@ struct PlaceholderAvatarImage: View {
// MARK: - Public // MARK: - Public
let firstCharacter: String let firstCharacter: Character
let colorIndex: Int let colorIndex: Int
// MARK: - Views // MARK: - Views
@ -34,7 +39,7 @@ struct PlaceholderAvatarImage: View {
ZStack { ZStack {
theme.colors.namesAndAvatars[colorIndex] theme.colors.namesAndAvatars[colorIndex]
Text(firstCharacter) Text(String(firstCharacter))
.padding(4) .padding(4)
.foregroundColor(.white) .foregroundColor(.white)
// Make the text resizable (i.e. Make it large and then allow it to scale down) // Make the text resizable (i.e. Make it large and then allow it to scale down)

View file

@ -35,7 +35,7 @@ struct SpaceAvatarImage: View {
case .empty: case .empty:
ProgressView() ProgressView()
case .placeholder(let firstCharacter, let colorIndex): case .placeholder(let firstCharacter, let colorIndex):
Text(firstCharacter) Text(String(firstCharacter))
.padding(10) .padding(10)
.frame(width: CGFloat(size.rawValue), height: CGFloat(size.rawValue)) .frame(width: CGFloat(size.rawValue), height: CGFloat(size.rawValue))
.foregroundColor(.white) .foregroundColor(.white)

View file

@ -19,6 +19,6 @@ import UIKit
enum AvatarViewState { enum AvatarViewState {
case empty case empty
case placeholder(String, Int) case placeholder(Character, Int)
case avatar(UIImage) case avatar(UIImage)
} }

View file

@ -25,12 +25,9 @@ struct PlaceholderAvatarViewModel {
/// The number of total colors available for the `stableColorIndex`. /// The number of total colors available for the `stableColorIndex`.
let colorCount: Int let colorCount: Int
/// Get the first character of the display name capitalized or else an empty string. /// Get the first character of the display name capitalized or else a space character.
var firstCharacterCapitalized: String { var firstCharacterCapitalized: Character {
guard let character = displayName?.first else { return displayName?.capitalized.first ?? " "
return ""
}
return String(character).capitalized
} }
/// Provides the same color each time for a specified matrixId /// Provides the same color each time for a specified matrixId

View file

@ -41,8 +41,8 @@ final class OnboardingAvatarCoordinator: Coordinator, Presentable {
return presenter return presenter
}() }()
private lazy var photoPickerPresenter: PhotoPickerPresenter = { private lazy var mediaPickerPresenter: MediaPickerPresenter = {
let presenter = PhotoPickerPresenter() let presenter = MediaPickerPresenter()
presenter.delegate = self presenter.delegate = self
return presenter return presenter
}() }()
@ -100,24 +100,29 @@ final class OnboardingAvatarCoordinator: Coordinator, Presentable {
// MARK: - Private // MARK: - Private
/// Show a blocking activity indicator whilst saving.
private func startWaiting() { private func startWaiting() {
waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.saving, isInteractionBlocking: true)) waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.saving, isInteractionBlocking: true))
} }
/// Hide the currently displayed activity indicator.
private func stopWaiting() { private func stopWaiting() {
waitingIndicator = nil waitingIndicator = nil
} }
/// Present an image picker for the device photo library.
private func pickImage() { private func pickImage() {
let controller = toPresentable() let controller = toPresentable()
photoPickerPresenter.presentPicker(from: controller, with: .images, animated: true) mediaPickerPresenter.presentPicker(from: controller, with: .images, animated: true)
} }
/// Present a camera view to take a photo to use for the avatar.
private func takePhoto() { private func takePhoto() {
let controller = toPresentable() let controller = toPresentable()
cameraPresenter.presentCamera(from: controller, with: [.image], animated: true) cameraPresenter.presentCamera(from: controller, with: [.image], animated: true)
} }
/// Set the supplied image as user's avatar, completing the screen's display if successful.
func setAvatar(_ image: UIImage?) { func setAvatar(_ image: UIImage?) {
guard let image = image else { guard let image = image else {
MXLog.error("[OnboardingAvatarCoordinator] setAvatar called with a nil image.") MXLog.error("[OnboardingAvatarCoordinator] setAvatar called with a nil image.")
@ -160,16 +165,16 @@ final class OnboardingAvatarCoordinator: Coordinator, Presentable {
} }
} }
// MARK: - PhotoPickerPresenterDelegate // MARK: - MediaPickerPresenterDelegate
@available(iOS 14.0, *) @available(iOS 14.0, *)
extension OnboardingAvatarCoordinator: PhotoPickerPresenterDelegate { extension OnboardingAvatarCoordinator: MediaPickerPresenterDelegate {
func photoPickerPresenter(_ presenter: PhotoPickerPresenter, didPickImage image: UIImage) { func mediaPickerPresenter(_ presenter: MediaPickerPresenter, didPickImage image: UIImage) {
onboardingAvatarViewModel.updateAvatarImage(with: image) onboardingAvatarViewModel.updateAvatarImage(with: image)
presenter.dismiss(animated: true, completion: nil) presenter.dismiss(animated: true, completion: nil)
} }
func photoPickerPresenterDidCancel(_ presenter: PhotoPickerPresenter) { func mediaPickerPresenterDidCancel(_ presenter: MediaPickerPresenter) {
presenter.dismiss(animated: true, completion: nil) presenter.dismiss(animated: true, completion: nil)
} }
} }

View file

@ -19,36 +19,45 @@ import UIKit
// MARK: View model // MARK: View model
enum OnboardingAvatarViewModelResult { enum OnboardingAvatarViewModelResult {
/// The user would like to choose an image from their photo library.
case pickImage case pickImage
/// The user would like to take a photo to use as their avatar.
case takePhoto case takePhoto
/// The user would like to set specified image as their avatar.
case save(UIImage?) case save(UIImage?)
/// Move on to the next screen in the flow without setting an avatar.
case skip case skip
} }
// MARK: View // MARK: View
struct OnboardingAvatarViewState: BindableState { struct OnboardingAvatarViewState: BindableState {
let placeholderAvatarLetter: String /// The letter shown in the placeholder avatar.
let placeholderAvatarLetter: Character
/// The color index to use for the placeholder avatar's background.
let placeholderAvatarColorIndex: Int let placeholderAvatarColorIndex: Int
/// The image selected by the user to use as their avatar.
var avatar: UIImage? var avatar: UIImage?
var bindings: OnboardingAvatarBindings var bindings: OnboardingAvatarBindings
/// The image shown in the avatar's button.
var buttonImage: ImageAsset { var buttonImage: ImageAsset {
avatar == nil ? Asset.Images.onboardingAvatarCamera : Asset.Images.onboardingAvatarEdit avatar == nil ? Asset.Images.onboardingAvatarCamera : Asset.Images.onboardingAvatarEdit
} }
var avatarAccessibilityLabel: String {
avatar == nil ? VectorL10n.onboardingAvatarPlaceholderAccessibilityLabel(placeholderAvatarLetter) : VectorL10n.onboardingAvatarImageAccessibilityLabel
}
} }
struct OnboardingAvatarBindings { struct OnboardingAvatarBindings {
/// The currently displayed alert's info value otherwise `nil`.
var alertInfo: AlertInfo<Int>? var alertInfo: AlertInfo<Int>?
} }
enum OnboardingAvatarViewAction { enum OnboardingAvatarViewAction {
/// The user would like to choose an image from their photo library.
case pickImage case pickImage
/// The user would like to take a photo to use as their avatar.
case takePhoto case takePhoto
/// The user would like to save their chosen avatar image.
case save case save
/// Move on to the next screen in the flow without setting an avatar.
case skip case skip
} }

View file

@ -75,8 +75,8 @@ struct OnboardingAvatarScreen: View {
.onTapGesture { isPresentingPickerSelection = true } .onTapGesture { isPresentingPickerSelection = true }
.actionSheet(isPresented: $isPresentingPickerSelection) { pickerSelectionActionSheet } .actionSheet(isPresented: $isPresentingPickerSelection) { pickerSelectionActionSheet }
.accessibilityElement(children: .ignore) .accessibilityElement(children: .ignore)
.accessibilityLabel(viewModel.viewState.avatarAccessibilityLabel) .accessibilityLabel(VectorL10n.onboardingAvatarAccessibilityLabel)
.accessibilityValue(VectorL10n.accessibilityButtonLabel) .accessibilityValue(VectorL10n.edit)
} }
/// The button to indicate the user can tap to select an avatar /// The button to indicate the user can tap to select an avatar

View file

@ -17,12 +17,17 @@
import SwiftUI import SwiftUI
struct OnboardingCongratulationsCoordinatorParameters { struct OnboardingCongratulationsCoordinatorParameters {
/// The user session used to determine the user ID to display.
let userSession: UserSession let userSession: UserSession
/// When `true` the "Personalise Profile" button will be hidden, preventing the
/// user from setting a displayname or avatar.
let personalizationDisabled: Bool let personalizationDisabled: Bool
} }
enum OnboardingCongratulationsCoordinatorResult { enum OnboardingCongratulationsCoordinatorResult {
case personaliseProfile(UserSession) /// Show the display name and/or avatar screens for the user to personalize their profile.
case personalizeProfile(UserSession)
/// Continue the flow by skipping the display name and avatar screens.
case takeMeHome(UserSession) case takeMeHome(UserSession)
} }
@ -64,8 +69,8 @@ final class OnboardingCongratulationsCoordinator: Coordinator, Presentable {
MXLog.debug("[OnboardingCongratulationsCoordinator] OnboardingCongratulationsViewModel did complete with result: \(result).") MXLog.debug("[OnboardingCongratulationsCoordinator] OnboardingCongratulationsViewModel did complete with result: \(result).")
switch result { switch result {
case .personaliseProfile: case .personalizeProfile:
self.completion?(.personaliseProfile(self.parameters.userSession)) self.completion?(.personalizeProfile(self.parameters.userSession))
case .takeMeHome: case .takeMeHome:
self.completion?(.takeMeHome(self.parameters.userSession)) self.completion?(.takeMeHome(self.parameters.userSession))
} }

View file

@ -21,7 +21,7 @@ import Foundation
// MARK: View model // MARK: View model
enum OnboardingCongratulationsViewModelResult { enum OnboardingCongratulationsViewModelResult {
case personaliseProfile case personalizeProfile
case takeMeHome case takeMeHome
} }

View file

@ -43,7 +43,7 @@ class OnboardingCongratulationsViewModel: OnboardingCongratulationsViewModelType
override func process(viewAction: OnboardingCongratulationsViewAction) { override func process(viewAction: OnboardingCongratulationsViewAction) {
switch viewAction { switch viewAction {
case .personaliseProfile: case .personaliseProfile:
completion?(.personaliseProfile) completion?(.personalizeProfile)
case .takeMeHome: case .takeMeHome:
completion?(.takeMeHome) completion?(.takeMeHome)
} }

View file

@ -80,14 +80,17 @@ final class OnboardingDisplayNameCoordinator: Coordinator, Presentable {
// MARK: - Private // MARK: - Private
/// Show a blocking activity indicator whilst saving.
private func startWaiting() { private func startWaiting() {
waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.saving, isInteractionBlocking: true)) waitingIndicator = indicatorPresenter.present(.loading(label: VectorL10n.saving, isInteractionBlocking: true))
} }
/// Hide the currently displayed activity indicator.
private func stopWaiting() { private func stopWaiting() {
waitingIndicator = nil waitingIndicator = nil
} }
/// Set the supplied string as user's display name, completing the screen's display if successful.
private func setDisplayName(_ displayName: String) { private func setDisplayName(_ displayName: String) {
startWaiting() startWaiting()

View file

@ -19,7 +19,9 @@ import Foundation
// MARK: View model // MARK: View model
enum OnboardingDisplayNameViewModelResult { enum OnboardingDisplayNameViewModelResult {
/// The user would like to save the entered display name.
case save(String) case save(String)
/// Move on to the next screen in the flow without setting a display name.
case skip case skip
} }
@ -27,20 +29,27 @@ enum OnboardingDisplayNameViewModelResult {
struct OnboardingDisplayNameViewState: BindableState { struct OnboardingDisplayNameViewState: BindableState {
var bindings: OnboardingDisplayNameBindings var bindings: OnboardingDisplayNameBindings
/// Any error that occurred during display name validation otherwise `nil`.
var validationErrorMessage: String? var validationErrorMessage: String?
/// The string to be displayed in the text field's footer.
var textFieldFooterMessage: String { var textFieldFooterMessage: String {
validationErrorMessage ?? VectorL10n.onboardingDisplayNameHint validationErrorMessage ?? VectorL10n.onboardingDisplayNameHint
} }
} }
struct OnboardingDisplayNameBindings { struct OnboardingDisplayNameBindings {
/// The display name string entered by the user.
var displayName: String var displayName: String
/// The currently displayed alert's info value otherwise `nil`.
var alertInfo: AlertInfo<Int>? var alertInfo: AlertInfo<Int>?
} }
enum OnboardingDisplayNameViewAction { enum OnboardingDisplayNameViewAction {
/// The display name needs validation.
case validateDisplayName case validateDisplayName
/// The user would like to save the entered display name.
case save case save
/// Move on to the next screen in the flow without setting a display name.
case skip case skip
} }