Skip to content

Commit

Permalink
Add UI for creating and editing a custom list
Browse files Browse the repository at this point in the history
  • Loading branch information
Jon Petersson committed Feb 21, 2024
1 parent 2adee19 commit cd07be0
Show file tree
Hide file tree
Showing 22 changed files with 852 additions and 80 deletions.
6 changes: 4 additions & 2 deletions ios/MullvadSettings/CustomList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import MullvadTypes
public struct CustomList: Codable, Equatable {
public let id: UUID
public var name: String
public var list: [RelayLocation] = []
public init(id: UUID, name: String) {
public var locations: [RelayLocation]

public init(id: UUID = UUID(), name: String, locations: [RelayLocation]) {
self.id = id
self.name = name
self.locations = locations
}
}
4 changes: 2 additions & 2 deletions ios/MullvadSettings/CustomListRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,12 @@ public struct CustomListRepository: CustomListRepositoryProtocol {

public init() {}

public func create(_ name: String) throws -> CustomList {
public func create(_ name: String, locations: [RelayLocation]) throws -> CustomList {
var lists = fetchAll()
if lists.contains(where: { $0.name == name }) {
throw CustomRelayListError.duplicateName
} else {
let item = CustomList(id: UUID(), name: name)
let item = CustomList(id: UUID(), name: name, locations: locations)
lists.append(item)
try write(lists)
return item
Expand Down
3 changes: 2 additions & 1 deletion ios/MullvadSettings/CustomListRepositoryProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ public protocol CustomListRepositoryProtocol {

/// Create a custom list by unique name.
/// - Parameter name: a custom list name.
/// - Parameter locations: locations in a custom list.
/// - Returns: a persistent custom list model upon success, otherwise throws `Error`.
func create(_ name: String) throws -> CustomList
func create(_ name: String, locations: [RelayLocation]) throws -> CustomList

/// Fetch all custom list.
/// - Returns: all custom list model .
Expand Down
82 changes: 65 additions & 17 deletions ios/MullvadVPN.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//
// AddCustomListCoordinator.swift
// MullvadVPN
//
// Created by Jon Petersson on 2024-02-14.
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
//

import Combine
import MullvadSettings
import Routing
import UIKit

class AddCustomListCoordinator: Coordinator, Presentable, Presenting {
let navigationController: UINavigationController
let customListInteractor: CustomListInteractorProtocol

var presentedViewController: UIViewController {
navigationController
}

var didFinish: (() -> Void)?

init(
navigationController: UINavigationController,
customListInteractor: CustomListInteractorProtocol
) {
self.navigationController = navigationController
self.customListInteractor = customListInteractor
}

func start() {
let subject = CurrentValueSubject<CustomListViewModel, Never>(
CustomListViewModel(id: UUID(), name: "", locations: [], tableSections: [.name, .addLocations])
)

let controller = CustomListViewController(
interactor: customListInteractor,
subject: subject,
alertPresenter: AlertPresenter(context: self)
)
controller.delegate = self

controller.navigationItem.title = NSLocalizedString(
"CUSTOM_LIST_NAVIGATION_EDIT_TITLE",
tableName: "CustomLists",
value: "New custom list",
comment: ""
)

controller.saveBarButton.title = NSLocalizedString(
"CUSTOM_LIST_NAVIGATION_CREATE_BUTTON",
tableName: "CustomLists",
value: "Create",
comment: ""
)

navigationController.pushViewController(controller, animated: false)
}
}

extension AddCustomListCoordinator: CustomListViewControllerDelegate {
func customListDidSave() {
didFinish?()
}

func customListDidDelete() {
// No op.
}

func showLocations() {
// TODO: Show view controller for locations.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
//
// CustomListCellConfiguration.swift
// MullvadVPN
//
// Created by Jon Petersson on 2024-02-14.
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
//

import Combine
import UIKit

struct CustomListCellConfiguration {
let tableView: UITableView
let subject: CurrentValueSubject<CustomListViewModel, Never>

var onDelete: (() -> Void)?

func dequeueCell(
at indexPath: IndexPath,
for itemIdentifier: CustomListItemIdentifier,
validationErrors: Set<CustomListFieldValidationError>
) -> UITableViewCell {
let cell = tableView.dequeueReusableView(withIdentifier: itemIdentifier.cellIdentifier, for: indexPath)

configureBackground(cell: cell, itemIdentifier: itemIdentifier, validationErrors: validationErrors)

switch itemIdentifier {
case .name:
configureName(cell, itemIdentifier: itemIdentifier)
case .addLocations, .editLocations:
configureLocations(cell, itemIdentifier: itemIdentifier)
case .deleteList:
configureDelete(cell, itemIdentifier: itemIdentifier)
}

return cell
}

private func configureBackground(
cell: UITableViewCell,
itemIdentifier: CustomListItemIdentifier,
validationErrors: Set<CustomListFieldValidationError>
) {
configureErrorState(
cell: cell,
itemIdentifier: itemIdentifier,
contentValidationErrors: validationErrors
)

guard let cell = cell as? DynamicBackgroundConfiguration else { return }

cell.setAutoAdaptingBackgroundConfiguration(.mullvadListGroupedCell(), selectionType: .dimmed)
}

private func configureErrorState(
cell: UITableViewCell,
itemIdentifier: CustomListItemIdentifier,
contentValidationErrors: Set<CustomListFieldValidationError>
) {
let itemsWithErrors = CustomListItemIdentifier.fromFieldValidationErrors(contentValidationErrors)

if itemsWithErrors.contains(itemIdentifier) {
cell.layer.cornerRadius = 10
cell.layer.borderWidth = 1
cell.layer.borderColor = UIColor.Cell.validationErrorBorderColor.cgColor
} else {
cell.layer.borderWidth = 0
}
}

private func configureName(_ cell: UITableViewCell, itemIdentifier: CustomListItemIdentifier) {
var contentConfiguration = TextCellContentConfiguration()

contentConfiguration.text = itemIdentifier.text
contentConfiguration.setPlaceholder(type: .required)
contentConfiguration.textFieldProperties = .withSmartFeaturesDisabled()
contentConfiguration.inputText = subject.value.name
contentConfiguration.editingEvents.onChange = subject.bindTextAction(to: \.name)

cell.contentConfiguration = contentConfiguration
}

private func configureLocations(_ cell: UITableViewCell, itemIdentifier: CustomListItemIdentifier) {
var contentConfiguration = UIListContentConfiguration.mullvadValueCell(tableStyle: tableView.style)

contentConfiguration.text = itemIdentifier.text
cell.contentConfiguration = contentConfiguration

if let cell = cell as? CustomCellDisclosureHandling {
cell.disclosureType = .chevron
}
}

private func configureDelete(_ cell: UITableViewCell, itemIdentifier: CustomListItemIdentifier) {
var contentConfiguration = ButtonCellContentConfiguration()

contentConfiguration.style = .tableInsetGroupedDanger
contentConfiguration.text = itemIdentifier.text
contentConfiguration.primaryAction = UIAction { _ in
onDelete?()
}

cell.contentConfiguration = contentConfiguration
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//
// CustomListDataSourceConfigurationv.swift
// MullvadVPN
//
// Created by Jon Petersson on 2024-02-14.
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
//

import UIKit

class CustomListDataSourceConfiguration: NSObject {
let dataSource: UITableViewDiffableDataSource<CustomListSectionIdentifier, CustomListItemIdentifier>
var validationErrors: Set<CustomListFieldValidationError> = []

var didSelectItem: ((CustomListItemIdentifier) -> Void)?

init(dataSource: UITableViewDiffableDataSource<CustomListSectionIdentifier, CustomListItemIdentifier>) {
self.dataSource = dataSource
}

func updateDataSource(
sections: [CustomListSectionIdentifier],
validationErrors: Set<CustomListFieldValidationError>,
animated: Bool,
completion: (() -> Void)? = nil
) {
var snapshot = NSDiffableDataSourceSnapshot<CustomListSectionIdentifier, CustomListItemIdentifier>()

sections.forEach { section in
switch section {
case .name:
snapshot.appendSections([.name])
snapshot.appendItems([.name], toSection: .name)
case .addLocations:
snapshot.appendSections([.addLocations])
snapshot.appendItems([.addLocations], toSection: .addLocations)
case .editLocations:
snapshot.appendSections([.editLocations])
snapshot.appendItems([.editLocations], toSection: .editLocations)
case .deleteList:
snapshot.appendSections([.deleteList])
snapshot.appendItems([.deleteList], toSection: .deleteList)
}
}

dataSource.apply(snapshot, animatingDifferences: animated)
}

func set(validationErrors: Set<CustomListFieldValidationError>) {
self.validationErrors = validationErrors

var snapshot = dataSource.snapshot()

validationErrors.forEach { error in
switch error {
case .name:
snapshot.reloadSections([.name])
}
}

dataSource.apply(snapshot, animatingDifferences: false)
}
}

extension CustomListDataSourceConfiguration: UITableViewDelegate {
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
UIMetrics.SettingsCell.customListsCellHeight
}

func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
let snapshot = dataSource.snapshot()

let sectionIdentifier = snapshot.sectionIdentifiers[section]
let itemsInSection = snapshot.itemIdentifiers(inSection: sectionIdentifier)

let itemsWithErrors = CustomListItemIdentifier.fromFieldValidationErrors(validationErrors)
let errorsInSection = itemsWithErrors.filter { itemsInSection.contains($0) }.compactMap { item in
switch item {
case .name:
CustomListFieldValidationError.name
case .addLocations, .editLocations, .deleteList:
nil
}
}

switch sectionIdentifier {
case .name:
let view = SettingsFieldValidationErrorContentView(
configuration: SettingsFieldValidationErrorConfiguration(
errors: errorsInSection.settingsFieldValidationErrors
)
)
return view
default:
return nil
}
}

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: false)

if let item = dataSource.itemIdentifier(for: indexPath) {
didSelectItem?(item)
}
}
}
31 changes: 31 additions & 0 deletions ios/MullvadVPN/Coordinators/CustomLists/CustomListInteractor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// CustomListInteractor.swift
// MullvadVPN
//
// Created by Jon Petersson on 2024-02-15.
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
//

import MullvadSettings

protocol CustomListInteractorProtocol {
func createCustomList(viewModel: CustomListViewModel) throws
func updateCustomList(viewModel: CustomListViewModel)
func deleteCustomList(id: UUID)
}

struct CustomListInteractor: CustomListInteractorProtocol {
let repository: CustomListRepositoryProtocol

func createCustomList(viewModel: CustomListViewModel) throws {
try _ = repository.create(viewModel.name, locations: viewModel.locations)
}

func updateCustomList(viewModel: CustomListViewModel) {
repository.update(viewModel.customList)
}

func deleteCustomList(id: UUID) {
repository.delete(id: id)
}
}
Loading

0 comments on commit cd07be0

Please sign in to comment.