-
Notifications
You must be signed in to change notification settings - Fork 20
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #731 from willowtreeapps/feature/voice-settings-pe…
…rsonal-voice [728] Add support for iOS 17 Personal Voice
- Loading branch information
Showing
8 changed files
with
338 additions
and
12 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
67 changes: 67 additions & 0 deletions
67
Vocable/Features/Settings/VoiceSettings/PersonalVoicePermissionPromptController.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
// | ||
// PersonalVoicePermissionPromptController.swift | ||
// Vocable | ||
// | ||
// Created by Chris Stroud on 4/26/24. | ||
// Copyright © 2024 WillowTree. All rights reserved. | ||
// | ||
|
||
import Foundation | ||
import AVFoundation | ||
import Combine | ||
import UIKit | ||
|
||
@available(iOS 17.0, *) | ||
final class PersonalVoicePermissionPromptController { | ||
|
||
private typealias AuthorizationStatus = AVSpeechSynthesizer.PersonalVoiceAuthorizationStatus | ||
|
||
struct PersonalVoicePermissionEmptyState { | ||
let state: PersonalVoiceEmptyState | ||
let action: EmptyStateView.ButtonConfiguration | ||
} | ||
|
||
@Published private(set) var state: PersonalVoicePermissionEmptyState? = .none | ||
|
||
private var cancellables = Set<AnyCancellable>() | ||
|
||
private var authorizationStatus: AuthorizationStatus { | ||
AVSpeechSynthesizer.personalVoiceAuthorizationStatus | ||
} | ||
|
||
init() { | ||
self.authorizationStatusDidChange(authorizationStatus) | ||
NotificationCenter.default | ||
.publisher( | ||
for: AVSpeechSynthesizer.availableVoicesDidChangeNotification, | ||
object: nil | ||
) | ||
.compactMap { [weak self] _ in | ||
self?.authorizationStatus | ||
} | ||
.receive(on: DispatchQueue.main) | ||
.sink { [weak self] (status) in | ||
self?.authorizationStatusDidChange(status) | ||
} | ||
.store(in: &cancellables) | ||
} | ||
|
||
private func authorizationStatusDidChange(_ status: AuthorizationStatus) { | ||
switch status { | ||
case .authorized: | ||
self.state = nil | ||
case .denied: // Need to go to settings | ||
self.state = .init(state: .denied) { | ||
UIApplication.openSettingsURL() | ||
} | ||
case .notDetermined: // Need to present alert | ||
self.state = .init(state: .notAuthorized) { [weak self] in | ||
AVSpeechSynthesizer.requestPersonalVoiceAuthorization { status in | ||
self?.authorizationStatusDidChange(status) | ||
} | ||
} | ||
default: | ||
self.state = nil | ||
} | ||
} | ||
} |
182 changes: 182 additions & 0 deletions
182
Vocable/Features/Settings/VoiceSettings/PersonalVoiceViewController.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,182 @@ | ||
// | ||
// PersonalVoiceViewController.swift | ||
// Vocable | ||
// | ||
// Created by Chris Stroud on 4/26/24. | ||
// Copyright © 2024 WillowTree. All rights reserved. | ||
// | ||
|
||
import Foundation | ||
import UIKit | ||
import AVFoundation | ||
import Combine | ||
|
||
@available(iOS 17.0, *) | ||
final class PersonalVoiceViewController: PagingCarouselViewController { | ||
|
||
private typealias DataSource = CarouselCollectionViewDataSourceProxy<Int, VoiceProfileItem> | ||
private typealias CellRegistration = UICollectionView.CellRegistration<VocableListCell, VoiceProfileItem> | ||
|
||
private var dataSource: DataSource! | ||
private var cellRegistration: CellRegistration! | ||
private let authorizationController = PersonalVoicePermissionPromptController() | ||
|
||
private let previewController = VoiceProfilePreviewController(context: .personalVoice) | ||
private var cancellables: Set<AnyCancellable> = [] | ||
|
||
override func viewDidLoad() { | ||
super.viewDidLoad() | ||
|
||
updateLayoutForCurrentTraitCollection() | ||
|
||
setupNavigationBar() | ||
setupCollectionView() | ||
updateDataSource() | ||
|
||
Publishers.CombineLatest(previewController.$items, authorizationController.$state) | ||
.receive(on: DispatchQueue.main) | ||
.sink { [weak self] (items, state) in | ||
self?.updateDataSource(items: items, authorizationState: state) | ||
} | ||
.store(in: &cancellables) | ||
} | ||
|
||
override func viewLayoutMarginsDidChange() { | ||
super.viewLayoutMarginsDidChange() | ||
updateBackgroundViewLayoutMargins() | ||
} | ||
|
||
private func setupNavigationBar() { | ||
navigationBar.title = String(localized: "personal_voices.title") | ||
} | ||
|
||
private func setupCollectionView() { | ||
collectionView.allowsMultipleSelection = true | ||
collectionView.allowsMultipleSelectionDuringEditing = true | ||
collectionView.backgroundColor = .collectionViewBackgroundColor | ||
|
||
let cellRegistration = CellRegistration { [weak self] cell, _, item in | ||
guard let self else { return } | ||
cell.contentConfiguration = VocableListContentConfiguration.voiceProfileItem( | ||
item, | ||
controller: self.previewController | ||
) { [weak self] in | ||
self?.handleVoiceSelection(item.voice) | ||
} | ||
} | ||
|
||
let dataSource = DataSource(collectionView: collectionView) { (collectionView, indexPath, voice) -> UICollectionViewCell? in | ||
collectionView.dequeueConfiguredReusableCell( | ||
using: cellRegistration, | ||
for: indexPath, | ||
item: voice | ||
) | ||
} | ||
self.dataSource = dataSource | ||
self.cellRegistration = cellRegistration | ||
} | ||
|
||
private func updateDataSource( | ||
items: [VoiceProfileItem]? = nil, | ||
previewing previewVoice: AVSpeechSynthesisVoice? = nil, | ||
authorizationState: PersonalVoicePermissionPromptController.PersonalVoicePermissionEmptyState? = nil | ||
) { | ||
let authorizationState = authorizationState ?? authorizationController.state | ||
let items = items ?? previewController.items | ||
var snapshot = NSDiffableDataSourceSnapshot<Int, VoiceProfileItem>() | ||
snapshot.appendSections([0]) | ||
snapshot.appendItems(items) | ||
dataSource.apply(snapshot, animatingDifferences: false) | ||
|
||
if let authorizationState { | ||
setBackgroundView(EmptyStateView(type: authorizationState.state, action: authorizationState.action)) | ||
} else if snapshot.itemIdentifiers.isEmpty { | ||
setBackgroundView(EmptyStateView(type: PersonalVoiceEmptyState.noContent)) | ||
} else { | ||
setBackgroundView(nil) | ||
} | ||
} | ||
|
||
private func setBackgroundView(_ view: UIView?) { | ||
collectionView.backgroundView = view | ||
updateBackgroundViewLayoutMargins() | ||
} | ||
|
||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { | ||
super.traitCollectionDidChange(previousTraitCollection) | ||
updateLayoutForCurrentTraitCollection() | ||
} | ||
|
||
private func updateLayoutForCurrentTraitCollection() { | ||
collectionView.layout.interItemSpacing = .init(interRowSpacing: 8, interColumnSpacing: 30) | ||
|
||
switch sizeClass { | ||
case .hRegular_vRegular: | ||
collectionView.layout.numberOfColumns = .fixedCount(2) | ||
collectionView.layout.numberOfRows = .flexible(minHeight: .absolute(100)) | ||
case .hCompact_vRegular: | ||
collectionView.layout.numberOfColumns = .fixedCount(1) | ||
collectionView.layout.numberOfRows = .flexible(minHeight: .absolute(64)) | ||
case .hCompact_vCompact, .hRegular_vCompact: | ||
collectionView.layout.numberOfColumns = .fixedCount(2) | ||
collectionView.layout.numberOfRows = .flexible(minHeight: .absolute(64)) | ||
default: | ||
break | ||
} | ||
} | ||
|
||
func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { | ||
return false | ||
} | ||
|
||
func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool { | ||
return false | ||
} | ||
|
||
private func updateBackgroundViewLayoutMargins() { | ||
guard let backgroundView = collectionView.backgroundView else { return } | ||
backgroundView.directionalLayoutMargins.leading = view.directionalLayoutMargins.leading | ||
backgroundView.directionalLayoutMargins.trailing = view.directionalLayoutMargins.trailing | ||
} | ||
|
||
fileprivate func handleVoiceSelection(_ voice: AVSpeechSynthesisVoice) { | ||
AppConfig.selectedVoiceIdentifier = voice.identifier | ||
updateDataSource() | ||
} | ||
} | ||
|
||
enum PersonalVoiceEmptyState: EmptyStateRepresentable { | ||
|
||
case denied | ||
case notAuthorized | ||
case noContent | ||
|
||
var title: String { | ||
return switch self { | ||
case .denied: String(localized: "personal_voices.empty_state.denied.title") | ||
case .notAuthorized: String(localized: "personal_voices.empty_state.not_authorized.title") | ||
case .noContent: String(localized: "personal_voices.empty_state.no_content.title") | ||
} | ||
} | ||
|
||
var description: String? { | ||
switch self { | ||
case .denied: | ||
let format = String(localized: "personal_voices.empty_state.denied.description") | ||
return String(format: format, UIDevice.current.model) | ||
case .noContent: | ||
let format = String(localized: "personal_voices.empty_state.no_content.description") | ||
return String(format: format, UIDevice.current.model) | ||
case .notAuthorized: | ||
return String(localized: "personal_voices.empty_state.not_authorized.description") | ||
} | ||
} | ||
|
||
var buttonTitle: String? { | ||
return switch self { | ||
case .denied: String(localized: "personal_voices.empty_state.denied.button.title") | ||
case .notAuthorized: String(localized: "personal_voices.empty_state.not_authorized.button.title") | ||
case .noContent: String(localized: "personal_voices.empty_state.no_content.button.title") | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.