2021-04-08 13:47:31 +02:00
//
// S e t t i n g s V i e w C o n t r o l l e r . s w i f t
// M a s t o d o n
//
// C r e a t e d b y i h u g o o n 2 0 2 1 / 4 / 7 .
//
import os . log
import UIKit
import Combine
import ActiveLabel
import CoreData
import CoreDataStack
2021-04-13 07:52:46 +02:00
import AlamofireImage
import Kingfisher
2021-04-08 13:47:31 +02:00
2021-04-12 15:42:43 +02:00
// i TODO: w h e n t o a s k p e r m i s s i o n t o U s e N o t i f i c a t i o n s
2021-04-08 13:47:31 +02:00
class SettingsViewController : UIViewController , NeedsDependency {
weak var context : AppContext ! { willSet { precondition ( ! isViewLoaded ) } }
weak var coordinator : SceneCoordinator ! { willSet { precondition ( ! isViewLoaded ) } }
var viewModel : SettingsViewModel ! { willSet { precondition ( ! isViewLoaded ) } }
var disposeBag = Set < AnyCancellable > ( )
var triggerMenu : UIMenu {
let anyone = L10n . Scene . Settings . Section . Notifications . Trigger . anyone
let follower = L10n . Scene . Settings . Section . Notifications . Trigger . follower
let follow = L10n . Scene . Settings . Section . Notifications . Trigger . follow
2021-04-13 10:22:41 +02:00
let noOne = L10n . Scene . Settings . Section . Notifications . Trigger . noone
2021-04-08 13:47:31 +02:00
let menu = UIMenu (
2021-04-12 15:42:43 +02:00
image : nil ,
2021-04-08 13:47:31 +02:00
identifier : nil ,
options : . displayInline ,
children : [
UIAction ( title : anyone , image : UIImage ( systemName : " person.3 " ) , attributes : [ ] ) { [ weak self ] action in
self ? . updateTrigger ( by : anyone )
} ,
UIAction ( title : follower , image : UIImage ( systemName : " person.crop.circle.badge.plus " ) , attributes : [ ] ) { [ weak self ] action in
self ? . updateTrigger ( by : follower )
} ,
UIAction ( title : follow , image : UIImage ( systemName : " person.crop.circle.badge.checkmark " ) , attributes : [ ] ) { [ weak self ] action in
self ? . updateTrigger ( by : follow )
} ,
UIAction ( title : noOne , image : UIImage ( systemName : " nosign " ) , attributes : [ ] ) { [ weak self ] action in
self ? . updateTrigger ( by : noOne )
} ,
2021-04-17 20:02:08 +02:00
]
2021-04-08 13:47:31 +02:00
)
return menu
}
lazy var notifySectionHeader : UIView = {
let view = UIStackView ( )
view . translatesAutoresizingMaskIntoConstraints = false
view . isLayoutMarginsRelativeArrangement = true
2021-04-25 06:48:29 +02:00
// v i e w . l a y o u t M a r g i n s = U I E d g e I n s e t s ( t o p : 1 5 , l e f t : 4 , b o t t o m : 5 , r i g h t : 4 )
2021-04-08 13:47:31 +02:00
view . axis = . horizontal
view . alignment = . fill
view . distribution = . equalSpacing
view . spacing = 4
let notifyLabel = UILabel ( )
notifyLabel . translatesAutoresizingMaskIntoConstraints = false
notifyLabel . font = UIFontMetrics ( forTextStyle : . title3 ) . scaledFont ( for : UIFont . systemFont ( ofSize : 20 , weight : . semibold ) )
notifyLabel . textColor = Asset . Colors . Label . primary . color
notifyLabel . text = L10n . Scene . Settings . Section . Notifications . Trigger . title
view . addArrangedSubview ( notifyLabel )
view . addArrangedSubview ( whoButton )
return view
} ( )
lazy var whoButton : UIButton = {
let whoButton = UIButton ( type : . roundedRect )
whoButton . menu = triggerMenu
whoButton . showsMenuAsPrimaryAction = true
whoButton . setBackgroundColor ( Asset . Colors . battleshipGrey . color , for : . normal )
whoButton . setTitleColor ( Asset . Colors . Label . primary . color , for : . normal )
if let setting = self . viewModel . setting . value , let trigger = setting . triggerBy {
whoButton . setTitle ( trigger , for : . normal )
}
whoButton . titleLabel ? . font = UIFontMetrics ( forTextStyle : . title3 ) . scaledFont ( for : UIFont . systemFont ( ofSize : 20 , weight : . semibold ) )
whoButton . contentEdgeInsets = UIEdgeInsets ( top : 5 , left : 5 , bottom : 5 , right : 5 )
whoButton . layer . cornerRadius = 10
whoButton . clipsToBounds = true
return whoButton
} ( )
lazy var tableView : UITableView = {
// i n i t w i t h a f r a m e t o f i x a c o n f l i c t ( ' U I V i e w - E n c a p s u l a t e d - L a y o u t - W i d t h ' U I S t a c k V i e w : 0 x 7 f 8 c 2 b 6 c 0 5 9 0 . w i d t h = = 0 )
let tableView = UITableView ( frame : CGRect ( x : 0 , y : 0 , width : 320 , height : 320 ) , style : . grouped )
tableView . translatesAutoresizingMaskIntoConstraints = false
tableView . delegate = self
tableView . rowHeight = UITableView . automaticDimension
tableView . backgroundColor = Asset . Colors . Background . systemGroupedBackground . color
tableView . register ( SettingsAppearanceTableViewCell . self , forCellReuseIdentifier : " SettingsAppearanceTableViewCell " )
tableView . register ( SettingsToggleTableViewCell . self , forCellReuseIdentifier : " SettingsToggleTableViewCell " )
tableView . register ( SettingsLinkTableViewCell . self , forCellReuseIdentifier : " SettingsLinkTableViewCell " )
return tableView
} ( )
lazy var footerView : UIView = {
// i n i t w i t h a f r a m e t o f i x a c o n f l i c t ( ' U I V i e w - E n c a p s u l a t e d - L a y o u t - H e i g h t ' U I S t a c k V i e w : 0 x 7 f f e 4 1 e 4 7 d a 0 . h e i g h t = = 0 )
let view = UIStackView ( frame : CGRect ( x : 0 , y : 0 , width : 320 , height : 320 ) )
view . isLayoutMarginsRelativeArrangement = true
view . layoutMargins = UIEdgeInsets ( top : 20 , left : 20 , bottom : 20 , right : 20 )
view . axis = . vertical
view . alignment = . center
let label = ActiveLabel ( style : . default )
label . textAlignment = . center
label . configure ( content : " Mastodon is open source software. You can contribute or report issues on GitHub at <a href= \" https://github.com/tootsuite/mastodon \" >tootsuite/mastodon</a> (v3.3.0). " )
label . delegate = self
view . addArrangedSubview ( label )
return view
} ( )
override func viewDidLoad ( ) {
super . viewDidLoad ( )
setupView ( )
bindViewModel ( )
viewModel . viewDidLoad . send ( )
}
override func viewDidLayoutSubviews ( ) {
super . viewDidLayoutSubviews ( )
guard let footerView = self . tableView . tableFooterView else {
return
}
let width = self . tableView . bounds . size . width
let size = footerView . systemLayoutSizeFitting ( CGSize ( width : width , height : UIView . layoutFittingCompressedSize . height ) )
if footerView . frame . size . height != size . height {
footerView . frame . size . height = size . height
self . tableView . tableFooterView = footerView
}
}
// M A K R : - P r i v a t e m e t h o d s
private func bindViewModel ( ) {
let input = SettingsViewModel . Input ( )
_ = viewModel . transform ( input : input )
}
private func setupView ( ) {
view . backgroundColor = Asset . Colors . Background . systemGroupedBackground . color
setupNavigation ( )
setupTableView ( )
view . addSubview ( tableView )
NSLayoutConstraint . activate ( [
tableView . topAnchor . constraint ( equalTo : view . topAnchor ) ,
tableView . leadingAnchor . constraint ( equalTo : view . leadingAnchor ) ,
tableView . trailingAnchor . constraint ( equalTo : view . trailingAnchor ) ,
tableView . bottomAnchor . constraint ( equalTo : view . bottomAnchor ) ,
] )
}
private func setupNavigation ( ) {
navigationController ? . navigationBar . prefersLargeTitles = true
navigationItem . rightBarButtonItem
= UIBarButtonItem ( barButtonSystemItem : UIBarButtonItem . SystemItem . done ,
target : self ,
action : #selector ( doneButtonDidClick ) )
navigationItem . title = L10n . Scene . Settings . title
let barAppearance = UINavigationBarAppearance ( )
barAppearance . configureWithDefaultBackground ( )
navigationItem . standardAppearance = barAppearance
navigationItem . compactAppearance = barAppearance
navigationItem . scrollEdgeAppearance = barAppearance
}
private func setupTableView ( ) {
2021-04-12 15:42:43 +02:00
viewModel . dataSource = UITableViewDiffableDataSource ( tableView : tableView , cellProvider : { [ weak self ] ( tableView , indexPath , item ) -> UITableViewCell ? in
guard let self = self else { return nil }
2021-04-08 13:47:31 +02:00
switch item {
case . apperance ( let item ) :
guard let cell = tableView . dequeueReusableCell ( withIdentifier : " SettingsAppearanceTableViewCell " ) as ? SettingsAppearanceTableViewCell else {
assertionFailure ( )
return nil
}
cell . update ( with : item , delegate : self )
return cell
case . notification ( let item ) :
guard let cell = tableView . dequeueReusableCell ( withIdentifier : " SettingsToggleTableViewCell " ) as ? SettingsToggleTableViewCell else {
assertionFailure ( )
return nil
}
cell . update ( with : item , delegate : self )
return cell
case . boringZone ( let item ) , . spicyZone ( let item ) :
guard let cell = tableView . dequeueReusableCell ( withIdentifier : " SettingsLinkTableViewCell " ) as ? SettingsLinkTableViewCell else {
assertionFailure ( )
return nil
}
cell . update ( with : item )
return cell
}
} )
tableView . tableFooterView = footerView
}
2021-04-13 10:22:41 +02:00
func alertToSignout ( ) {
let alertController = UIAlertController (
title : L10n . Common . Alerts . SignOut . title ,
message : L10n . Common . Alerts . SignOut . message ,
preferredStyle : . alert
)
let cancelAction = UIAlertAction ( title : L10n . Common . Controls . Actions . cancel , style : . cancel , handler : nil )
let signOutAction = UIAlertAction ( title : L10n . Common . Alerts . SignOut . confirm , style : . destructive ) { [ weak self ] _ in
guard let self = self else { return }
self . signout ( )
}
alertController . addAction ( cancelAction )
alertController . addAction ( signOutAction )
self . coordinator . present (
scene : . alertController ( alertController : alertController ) ,
from : self ,
transition : . alertController ( animated : true , completion : nil )
)
}
2021-04-08 13:47:31 +02:00
func signout ( ) {
guard let activeMastodonAuthenticationBox = context . authenticationService . activeMastodonAuthenticationBox . value else {
return
}
2021-04-13 10:22:41 +02:00
2021-04-08 13:47:31 +02:00
context . authenticationService . signOutMastodonUser (
domain : activeMastodonAuthenticationBox . domain ,
userID : activeMastodonAuthenticationBox . userID
)
. receive ( on : DispatchQueue . main )
. sink { [ weak self ] result in
guard let self = self else { return }
switch result {
case . failure ( let error ) :
assertionFailure ( error . localizedDescription )
case . success ( let isSignOut ) :
os_log ( . info , log : . debug , " %{public}s[%{public}ld], %{public}s: sign out %s " , ( ( #file as NSString ) . lastPathComponent ) , #line , #function , isSignOut ? " success " : " fail " )
guard isSignOut else { return }
self . coordinator . setup ( )
self . coordinator . setupOnboardingIfNeeds ( animated : true )
}
}
. store ( in : & disposeBag )
}
2021-04-12 15:42:43 +02:00
deinit {
os_log ( . info , log : . debug , " %{public}s[%{public}ld], %{public}s: " , ( ( #file as NSString ) . lastPathComponent ) , #line , #function )
}
2021-04-08 13:47:31 +02:00
// M a r k : - A c t i o n s
@objc func doneButtonDidClick ( ) {
dismiss ( animated : true , completion : nil )
}
}
extension SettingsViewController : UITableViewDelegate {
func tableView ( _ tableView : UITableView , viewForHeaderInSection section : Int ) -> UIView ? {
let sections = viewModel . dataSource . snapshot ( ) . sectionIdentifiers
guard section < sections . count else { return nil }
let sectionData = sections [ section ]
2021-04-25 06:48:29 +02:00
let header : SettingsSectionHeader
2021-04-08 13:47:31 +02:00
if section = = 1 {
2021-04-25 06:48:29 +02:00
header = SettingsSectionHeader (
2021-04-08 13:47:31 +02:00
frame : CGRect ( x : 0 , y : 0 , width : 375 , height : 66 ) ,
customView : notifySectionHeader )
header . update ( title : sectionData . title )
if let setting = self . viewModel . setting . value , let trigger = setting . triggerBy {
whoButton . setTitle ( trigger , for : . normal )
} else {
let anyone = L10n . Scene . Settings . Section . Notifications . Trigger . anyone
whoButton . setTitle ( anyone , for : . normal )
}
} else {
2021-04-25 06:48:29 +02:00
header = SettingsSectionHeader ( frame : CGRect ( x : 0 , y : 0 , width : 375 , height : 66 ) )
2021-04-08 13:47:31 +02:00
header . update ( title : sectionData . title )
}
2021-04-25 06:48:29 +02:00
header . preservesSuperviewLayoutMargins = true
return header
2021-04-08 13:47:31 +02:00
}
// r e m o v e t h e g a p o f t a b l e ' s f o o t e r
func tableView ( _ tableView : UITableView , viewForFooterInSection section : Int ) -> UIView ? {
2021-04-25 06:48:29 +02:00
2021-04-08 13:47:31 +02:00
return UIView ( )
}
// r e m o v e t h e g a p o f t a b l e ' s f o o t e r
func tableView ( _ tableView : UITableView , heightForFooterInSection section : Int ) -> CGFloat {
return 0
}
func tableView ( _ tableView : UITableView , didSelectRowAt indexPath : IndexPath ) {
2021-04-14 10:41:30 +02:00
let snapshot = self . viewModel . dataSource . snapshot ( )
let sectionIds = snapshot . sectionIdentifiers
guard indexPath . section < sectionIds . count else { return }
let sectionIdentifier = sectionIds [ indexPath . section ]
let items = snapshot . itemIdentifiers ( inSection : sectionIdentifier )
guard indexPath . row < items . count else { return }
let item = items [ indexPath . item ]
2021-04-08 13:47:31 +02:00
2021-04-14 10:41:30 +02:00
switch item {
case . boringZone :
2021-04-17 08:01:57 +02:00
guard let url = viewModel . privacyURL else { break }
2021-04-08 13:47:31 +02:00
coordinator . present (
2021-04-17 08:01:57 +02:00
scene : . safari ( url : url ) ,
2021-04-08 13:47:31 +02:00
from : self ,
2021-04-13 10:22:41 +02:00
transition : . safariPresent ( animated : true , completion : nil )
)
2021-04-14 10:41:30 +02:00
case . spicyZone ( let link ) :
// c l e a r m e d i a c a c h e
if link . title = = L10n . Scene . Settings . Section . Spicyzone . clear {
// c l e a n i m a g e c a c h e f o r A l a m o f i r e I m a g e
let diskBytes = ImageDownloader . defaultURLCache ( ) . currentDiskUsage
os_log ( . info , log : . debug , " %{public}s[%{public}ld], %{public}s: diskBytes %d " , ( ( #file as NSString ) . lastPathComponent ) , #line , #function , diskBytes )
os_log ( . info , log : . debug , " %{public}s[%{public}ld], %{public}s: clean image cache " , ( ( #file as NSString ) . lastPathComponent ) , #line , #function )
ImageDownloader . defaultURLCache ( ) . removeAllCachedResponses ( )
let cleanedDiskBytes = ImageDownloader . defaultURLCache ( ) . currentDiskUsage
os_log ( . info , log : . debug , " %{public}s[%{public}ld], %{public}s: diskBytes %d " , ( ( #file as NSString ) . lastPathComponent ) , #line , #function , cleanedDiskBytes )
// c l e a n K i n g f i s h e r C a c h e
KingfisherManager . shared . cache . clearDiskCache ( )
}
// l o g o u t
if link . title = = L10n . Scene . Settings . Section . Spicyzone . signout {
alertToSignout ( )
}
default :
break
2021-04-08 13:47:31 +02:00
}
}
}
// U p d a t e s e t t i n g i n t o c o r e d a t a
extension SettingsViewController {
func updateTrigger ( by who : String ) {
2021-04-17 20:02:08 +02:00
guard self . viewModel . triggerBy != who else { return }
2021-04-08 13:47:31 +02:00
guard let setting = self . viewModel . setting . value else { return }
2021-04-17 20:02:08 +02:00
setting . update ( triggerBy : who )
// t r i g g e r t o c a l l ` s u b s c r i p t i o n ` A P I w i t h P O S T m e t h o d
// c o n f i r m t h e l o c a l d a t a i s c o r r e c t e v e n i f r e q u e s t f a i l e d
// T h e a s y n c h r o n o u s e x e c u t i o n i s t o s o l v e t h e p r o b l e m o f d r o p p e d f r a m e s f o r a n i m a t i o n s .
DispatchQueue . main . async { [ weak self ] in
self ? . viewModel . setting . value = setting
2021-04-08 13:47:31 +02:00
}
}
func updateAlert ( title : String ? , isOn : Bool ) {
guard let title = title else { return }
guard let settings = self . viewModel . setting . value else { return }
guard let triggerBy = settings . triggerBy else { return }
2021-04-17 20:02:08 +02:00
if let alerts = settings . subscription ? . first ( where : { ( s ) -> Bool in
2021-04-08 13:47:31 +02:00
return s . type = = settings . triggerBy
2021-04-17 20:02:08 +02:00
} ) ? . alert {
var alertValues = [ Bool ? ] ( )
alertValues . append ( alerts . favourite ? . boolValue )
alertValues . append ( alerts . follow ? . boolValue )
alertValues . append ( alerts . reblog ? . boolValue )
alertValues . append ( alerts . mention ? . boolValue )
// n e e d t o u p d a t e ` a l e r t s ` t o m a k e u p d a t e A P I w i t h c o r r e c t p a r a m e t e r
switch title {
case L10n . Scene . Settings . Section . Notifications . favorites :
alertValues [ 0 ] = isOn
alerts . favourite = NSNumber ( booleanLiteral : isOn )
case L10n . Scene . Settings . Section . Notifications . follows :
alertValues [ 1 ] = isOn
alerts . follow = NSNumber ( booleanLiteral : isOn )
case L10n . Scene . Settings . Section . Notifications . boosts :
alertValues [ 2 ] = isOn
alerts . reblog = NSNumber ( booleanLiteral : isOn )
case L10n . Scene . Settings . Section . Notifications . mentions :
alertValues [ 3 ] = isOn
alerts . mention = NSNumber ( booleanLiteral : isOn )
default : break
}
self . viewModel . updateSubscriptionSubject . send ( ( triggerBy : triggerBy , values : alertValues ) )
} else if let alertValues = self . viewModel . notificationDefaultValue [ triggerBy ] {
self . viewModel . updateSubscriptionSubject . send ( ( triggerBy : triggerBy , values : alertValues ) )
2021-04-08 13:47:31 +02:00
}
}
}
extension SettingsViewController : SettingsAppearanceTableViewCellDelegate {
func settingsAppearanceCell ( _ view : SettingsAppearanceTableViewCell , didSelect : SettingsItem . AppearanceMode ) {
guard let setting = self . viewModel . setting . value else { return }
context . managedObjectContext . performChanges {
setting . update ( appearance : didSelect . rawValue )
}
. sink { ( _ ) in
// c h a n g e l i g h t / d a r k m o d e
var overrideUserInterfaceStyle : UIUserInterfaceStyle !
switch didSelect {
case . automatic :
overrideUserInterfaceStyle = . unspecified
case . light :
overrideUserInterfaceStyle = . light
case . dark :
overrideUserInterfaceStyle = . dark
}
view . window ? . overrideUserInterfaceStyle = overrideUserInterfaceStyle
} . store ( in : & disposeBag )
}
}
extension SettingsViewController : SettingsToggleCellDelegate {
func settingsToggleCell ( _ cell : SettingsToggleTableViewCell , didChangeStatus : Bool ) {
updateAlert ( title : cell . data ? . title , isOn : didChangeStatus )
}
}
extension SettingsViewController : ActiveLabelDelegate {
func activeLabel ( _ activeLabel : ActiveLabel , didSelectActiveEntity entity : ActiveEntity ) {
coordinator . present (
2021-04-13 10:22:41 +02:00
scene : . safari ( url : URL ( string : " https://github.com/tootsuite/mastodon " ) ! ) ,
2021-04-08 13:47:31 +02:00
from : self ,
2021-04-13 10:22:41 +02:00
transition : . safariPresent ( animated : true , completion : nil )
)
2021-04-08 13:47:31 +02:00
}
}
extension SettingsViewController {
static func updateOverrideUserInterfaceStyle ( window : UIWindow ? ) {
guard let box = AppContext . shared . authenticationService . activeMastodonAuthenticationBox . value else {
return
}
guard let setting : Setting ? = {
let domain = box . domain
let request = Setting . sortedFetchRequest
2021-04-17 20:02:08 +02:00
request . predicate = Setting . predicate ( domain : domain , userID : box . userID )
2021-04-08 13:47:31 +02:00
request . fetchLimit = 1
request . returnsObjectsAsFaults = false
do {
return try AppContext . shared . managedObjectContext . fetch ( request ) . first
} catch {
assertionFailure ( error . localizedDescription )
return nil
}
} ( ) else { return }
guard let didSelect = SettingsItem . AppearanceMode ( rawValue : setting ? . appearance ? ? " " ) else {
return
}
var overrideUserInterfaceStyle : UIUserInterfaceStyle !
switch didSelect {
case . automatic :
overrideUserInterfaceStyle = . unspecified
case . light :
overrideUserInterfaceStyle = . light
case . dark :
overrideUserInterfaceStyle = . dark
}
window ? . overrideUserInterfaceStyle = overrideUserInterfaceStyle
}
}
#if canImport ( SwiftUI ) && DEBUG
import SwiftUI
struct SettingsViewController_Previews : PreviewProvider {
static var previews : some View {
Group {
UIViewControllerPreview { ( ) -> UIViewController in
return SettingsViewController ( )
}
. previewLayout ( . fixed ( width : 390 , height : 844 ) )
}
}
}
#endif