diff --git a/Passepartout-iOS/Global/Macros.swift b/Passepartout-iOS/Global/Macros.swift index d173fe66..5ebc08fe 100644 --- a/Passepartout-iOS/Global/Macros.swift +++ b/Passepartout-iOS/Global/Macros.swift @@ -36,15 +36,16 @@ class Macros { } extension UIAlertController { - func addDefaultAction(_ title: String, handler: @escaping () -> Void) { + @discardableResult func addDefaultAction(_ title: String, handler: @escaping () -> Void) -> UIAlertAction { let action = UIAlertAction(title: title, style: .default) { (action) in handler() } addAction(action) preferredAction = action + return action } - func addCancelAction(_ title: String, handler: (() -> Void)? = nil) { + @discardableResult func addCancelAction(_ title: String, handler: (() -> Void)? = nil) -> UIAlertAction { let action = UIAlertAction(title: title, style: .cancel) { (action) in handler?() } @@ -52,20 +53,23 @@ extension UIAlertController { if actions.count == 1 { preferredAction = action } + return action } - func addAction(_ title: String, handler: @escaping () -> Void) { + @discardableResult func addAction(_ title: String, handler: @escaping () -> Void) -> UIAlertAction { let action = UIAlertAction(title: title, style: .default) { (action) in handler() } addAction(action) + return action } - func addDestructiveAction(_ title: String, handler: @escaping () -> Void) { + @discardableResult func addDestructiveAction(_ title: String, handler: @escaping () -> Void) -> UIAlertAction { let action = UIAlertAction(title: title, style: .destructive) { (action) in handler() } addAction(action) preferredAction = action + return action } } diff --git a/Passepartout-iOS/Global/Theme.swift b/Passepartout-iOS/Global/Theme.swift index 13fe5af8..f6dfb81b 100644 --- a/Passepartout-iOS/Global/Theme.swift +++ b/Passepartout-iOS/Global/Theme.swift @@ -125,6 +125,17 @@ extension UIButton { } } +extension UITextField { + func applyProfileId(_ theme: Theme) { + placeholder = L10n.Global.Host.TitleInput.placeholder + clearButtonMode = .always + keyboardType = .asciiCapable + returnKeyType = .done + autocapitalizationType = .none + autocorrectionType = .no + } +} + // XXX: status bar is broken extension MFMailComposeViewController { func apply(_ theme: Theme) { diff --git a/Passepartout-iOS/Scenes/Organizer/OrganizerViewController.swift b/Passepartout-iOS/Scenes/Organizer/OrganizerViewController.swift index 1288fd39..bebb2427 100644 --- a/Passepartout-iOS/Scenes/Organizer/OrganizerViewController.swift +++ b/Passepartout-iOS/Scenes/Organizer/OrganizerViewController.swift @@ -445,7 +445,7 @@ extension OrganizerViewController: ConnectionServiceDelegate { perform(segue: StoryboardSegue.Organizer.selectProfileSegueIdentifier, sender: profile) } - func connectionService(didRename profile: ConnectionProfile) { + func connectionService(didRename oldProfile: ConnectionProfile, to newProfile: ConnectionProfile) { TransientStore.shared.serialize() // rename reloadModel() diff --git a/Passepartout-iOS/Scenes/Organizer/WizardHostViewController.swift b/Passepartout-iOS/Scenes/Organizer/WizardHostViewController.swift index 1a030964..804df87c 100644 --- a/Passepartout-iOS/Scenes/Organizer/WizardHostViewController.swift +++ b/Passepartout-iOS/Scenes/Organizer/WizardHostViewController.swift @@ -51,6 +51,7 @@ class WizardHostViewController: UITableViewController, TableModelHost { lazy var model: TableModel = { let model: TableModel = TableModel() model.add(.meta) + model.setFooter(L10n.Global.Host.TitleInput.message, for: .meta) if !existingHosts.isEmpty { model.add(.existing) model.setHeader(L10n.Wizards.Host.Sections.Existing.header, for: .existing) @@ -185,6 +186,10 @@ extension WizardHostViewController { return model.header(for: section) } + override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? { + return model.footer(for: section) + } + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return model.count(for: section) } @@ -196,9 +201,7 @@ extension WizardHostViewController { cell.caption = L10n.Wizards.Host.Cells.TitleInput.caption cell.captionWidth = 100.0 cell.allowedCharset = .filename - cell.field.placeholder = L10n.Wizards.Host.Cells.TitleInput.placeholder - cell.field.clearButtonMode = .always - cell.field.returnKeyType = .done + cell.field.applyProfileId(Theme.current) cell.delegate = self return cell diff --git a/Passepartout-iOS/Scenes/ServiceViewController.swift b/Passepartout-iOS/Scenes/ServiceViewController.swift index e3b27951..0f66ad57 100644 --- a/Passepartout-iOS/Scenes/ServiceViewController.swift +++ b/Passepartout-iOS/Scenes/ServiceViewController.swift @@ -38,6 +38,7 @@ class ServiceViewController: UIViewController, TableModelHost { var profile: ConnectionProfile? { didSet { title = profile?.id + navigationItem.rightBarButtonItem?.isEnabled = (profile?.context == .host) reloadModel() updateViewsIfNeeded() } @@ -47,6 +48,8 @@ class ServiceViewController: UIViewController, TableModelHost { private lazy var vpn = GracefulVPN(service: service) + private weak var pendingRenameAction: UIAlertAction? + private var lastInfrastructureUpdate: Date? // MARK: Table @@ -179,7 +182,7 @@ class ServiceViewController: UIViewController, TableModelHost { viewWelcome?.isHidden = (profile != nil) } - @IBAction private func activate() { + private func activateProfile() { service.activateProfile(uncheckedProfile) TransientStore.shared.serialize() // activate @@ -189,6 +192,28 @@ class ServiceViewController: UIViewController, TableModelHost { vpn.disconnect(completionHandler: nil) } + @IBAction private func renameProfile() { + let alert = Macros.alert(L10n.Service.Alerts.Rename.title, L10n.Global.Host.TitleInput.message) + alert.addTextField { (field) in + field.text = self.profile?.id + field.applyProfileId(Theme.current) + field.delegate = self + } + pendingRenameAction = alert.addDefaultAction(L10n.Global.ok) { + guard let newId = alert.textFields?.first?.text else { + return + } + self.doRenameCurrentProfile(to: newId) + } + alert.addCancelAction(L10n.Global.cancel) + pendingRenameAction?.isEnabled = false + present(alert, animated: true, completion: nil) + } + + private func doRenameCurrentProfile(to newId: String) { + profile = service.renameProfile(uncheckedHostProfile, to: newId) + } + private func toggleVpnService(cell: ToggleTableViewCell) { if cell.isOn { guard !service.needsCredentials(for: uncheckedProfile) else { @@ -713,7 +738,7 @@ extension ServiceViewController: UITableViewDataSource, UITableViewDelegate, Tog private func handle(row: RowType, cell: UITableViewCell) -> Bool { switch row { case .useProfile: - activate() + activateProfile() case .reconnect: confirmVpnReconnection() @@ -931,6 +956,21 @@ extension ServiceViewController: UITableViewDataSource, UITableViewDelegate, Tog // MARK: - +extension ServiceViewController: UITextFieldDelegate { + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + guard string.rangeOfCharacter(from: CharacterSet.filename.inverted) == nil else { + return false + } + if let text = textField.text { + let replacement = (text as NSString).replacingCharacters(in: range, with: string) + pendingRenameAction?.isEnabled = (replacement != uncheckedProfile.id) + } + return true + } +} + +// MARK: - + extension ServiceViewController: TrustedNetworksModelDelegate { func trustedNetworksCouldDisconnect(_: TrustedNetworksModel) -> Bool { return (service.preferences.trustPolicy == .disconnect) && (vpn.status != .disconnected) diff --git a/Passepartout-iOS/en.lproj/Main.storyboard b/Passepartout-iOS/en.lproj/Main.storyboard index 393a3dfd..c36330e4 100644 --- a/Passepartout-iOS/en.lproj/Main.storyboard +++ b/Passepartout-iOS/en.lproj/Main.storyboard @@ -1,11 +1,11 @@ - + - + @@ -215,7 +215,13 @@ - + + + + + + + diff --git a/Passepartout/Resources/en.lproj/Localizable.strings b/Passepartout/Resources/en.lproj/Localizable.strings index 43cd719f..e53761bb 100644 --- a/Passepartout/Resources/en.lproj/Localizable.strings +++ b/Passepartout/Resources/en.lproj/Localizable.strings @@ -26,6 +26,8 @@ "global.ok" = "OK"; "global.cancel" = "Cancel"; "global.next" = "Next"; +"global.host.title_input.message" = "Legal characters are alphanumerics plus dash (-), underscore (_) and dot (.)."; +"global.host.title_input.placeholder" = "My Profile"; "reddit.title" = "Reddit"; "reddit.message" = "Did you know that Passepartout has a subreddit? Subscribe for updates or to discuss issues, features, new platforms or whatever you like.\n\nIt's also a great way to show you care about this project."; @@ -49,7 +51,6 @@ "account.suggestion_footer.infrastructure.pia" = "Use your website credentials. Your username is usually numeric with a \"p\" prefix."; "wizards.host.cells.title_input.caption" = "Title"; -"wizards.host.cells.title_input.placeholder" = "My Profile"; "wizards.host.sections.existing.header" = "Existing profiles"; "wizards.host.alerts.existing.message" = "A host profile with the same title already exists. Replace it?"; @@ -105,6 +106,7 @@ "service.cells.debug_log.caption" = "Debug log"; "service.cells.report_issue.caption" = "Report connectivity issue"; +"service.alerts.rename.title" = "Rename profile"; "service.alerts.credentials_needed.message" = "You need to enter account credentials first."; "service.alerts.reconnect_vpn.message" = "Do you want to reconnect to the VPN?"; "service.alerts.trusted.no_network.message" = "You are not connected to any Wi-Fi network."; diff --git a/Passepartout/Sources/SwiftGen+Strings.swift b/Passepartout/Sources/SwiftGen+Strings.swift index 2ef727f9..e7f290da 100644 --- a/Passepartout/Sources/SwiftGen+Strings.swift +++ b/Passepartout/Sources/SwiftGen+Strings.swift @@ -243,6 +243,14 @@ internal enum L10n { internal static let next = L10n.tr("Localizable", "global.next") /// OK internal static let ok = L10n.tr("Localizable", "global.ok") + internal enum Host { + internal enum TitleInput { + /// Legal characters are alphanumerics plus dash (-), underscore (_) and dot (.). + internal static let message = L10n.tr("Localizable", "global.host.title_input.message") + /// My Profile + internal static let placeholder = L10n.tr("Localizable", "global.host.title_input.placeholder") + } + } } internal enum ImportedHosts { @@ -415,6 +423,10 @@ internal enum L10n { /// Do you want to reconnect to the VPN? internal static let message = L10n.tr("Localizable", "service.alerts.reconnect_vpn.message") } + internal enum Rename { + /// Rename profile + internal static let title = L10n.tr("Localizable", "service.alerts.rename.title") + } internal enum TestConnectivity { /// Connectivity internal static let title = L10n.tr("Localizable", "service.alerts.test_connectivity.title") @@ -655,8 +667,6 @@ internal enum L10n { internal enum TitleInput { /// Title internal static let caption = L10n.tr("Localizable", "wizards.host.cells.title_input.caption") - /// My Profile - internal static let placeholder = L10n.tr("Localizable", "wizards.host.cells.title_input.placeholder") } } internal enum Sections {