Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add UI for adding and editing a custom list #5818

Merged
merged 1 commit into from
Feb 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading