Skip to content

Commit 1da87ee

Browse files
author
Jon Petersson
committed
Add UI for creating and editing a custom list
1 parent 2adee19 commit 1da87ee

22 files changed

+863
-81
lines changed

ios/MullvadSettings/CustomList.swift

+4-2
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ import MullvadTypes
1212
public struct CustomList: Codable, Equatable {
1313
public let id: UUID
1414
public var name: String
15-
public var list: [RelayLocation] = []
16-
public init(id: UUID, name: String) {
15+
public var locations: [RelayLocation]
16+
17+
public init(id: UUID = UUID(), name: String, locations: [RelayLocation]) {
1718
self.id = id
1819
self.name = name
20+
self.locations = locations
1921
}
2022
}

ios/MullvadSettings/CustomListRepository.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,12 @@ public struct CustomListRepository: CustomListRepositoryProtocol {
4141

4242
public init() {}
4343

44-
public func create(_ name: String) throws -> CustomList {
44+
public func create(_ name: String, locations: [RelayLocation]) throws -> CustomList {
4545
var lists = fetchAll()
4646
if lists.contains(where: { $0.name == name }) {
4747
throw CustomRelayListError.duplicateName
4848
} else {
49-
let item = CustomList(id: UUID(), name: name)
49+
let item = CustomList(id: UUID(), name: name, locations: locations)
5050
lists.append(item)
5151
try write(lists)
5252
return item

ios/MullvadSettings/CustomListRepositoryProtocol.swift

+2-1
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@ public protocol CustomListRepositoryProtocol {
2828

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

3435
/// Fetch all custom list.
3536
/// - Returns: all custom list model .

ios/MullvadVPN.xcodeproj/project.pbxproj

+65-17
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//
2+
// AddCustomListCoordinator.swift
3+
// MullvadVPN
4+
//
5+
// Created by Jon Petersson on 2024-02-14.
6+
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
7+
//
8+
9+
import Combine
10+
import MullvadSettings
11+
import Routing
12+
import UIKit
13+
14+
class AddCustomListCoordinator: Coordinator, Presentable, Presenting {
15+
let navigationController: UINavigationController
16+
let customListInteractor: CustomListInteractorProtocol
17+
18+
var presentedViewController: UIViewController {
19+
navigationController
20+
}
21+
22+
var didFinish: (() -> Void)?
23+
24+
init(
25+
navigationController: UINavigationController,
26+
customListInteractor: CustomListInteractorProtocol
27+
) {
28+
self.navigationController = navigationController
29+
self.customListInteractor = customListInteractor
30+
}
31+
32+
func start() {
33+
let subject = CurrentValueSubject<CustomListViewModel, Never>(
34+
CustomListViewModel(id: UUID(), name: "", locations: [], tableSections: [.name, .addLocations])
35+
)
36+
37+
let controller = CustomListViewController(
38+
interactor: customListInteractor,
39+
subject: subject,
40+
alertPresenter: AlertPresenter(context: self)
41+
)
42+
controller.delegate = self
43+
44+
controller.navigationItem.title = NSLocalizedString(
45+
"CUSTOM_LIST_NAVIGATION_EDIT_TITLE",
46+
tableName: "CustomLists",
47+
value: "New custom list",
48+
comment: ""
49+
)
50+
51+
controller.saveBarButton.title = NSLocalizedString(
52+
"CUSTOM_LIST_NAVIGATION_CREATE_BUTTON",
53+
tableName: "CustomLists",
54+
value: "Create",
55+
comment: ""
56+
)
57+
58+
navigationController.pushViewController(controller, animated: false)
59+
}
60+
}
61+
62+
extension AddCustomListCoordinator: CustomListViewControllerDelegate {
63+
func customListDidSave() {
64+
didFinish?()
65+
}
66+
67+
func customListDidDelete() {
68+
// No op.
69+
}
70+
71+
func showLocations() {
72+
// TODO: Show view controller for locations.
73+
}
74+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
//
2+
// CustomListCellConfiguration.swift
3+
// MullvadVPN
4+
//
5+
// Created by Jon Petersson on 2024-02-14.
6+
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
7+
//
8+
9+
import Combine
10+
import UIKit
11+
12+
struct CustomListCellConfiguration {
13+
let tableView: UITableView
14+
let subject: CurrentValueSubject<CustomListViewModel, Never>
15+
16+
var onDelete: (() -> Void)?
17+
18+
func dequeueCell(
19+
at indexPath: IndexPath,
20+
for itemIdentifier: CustomListItemIdentifier,
21+
validationErrors: Set<CustomListFieldValidationError>
22+
) -> UITableViewCell {
23+
let cell = tableView.dequeueReusableView(withIdentifier: itemIdentifier.cellIdentifier, for: indexPath)
24+
25+
configureBackground(cell: cell, itemIdentifier: itemIdentifier, validationErrors: validationErrors)
26+
27+
switch itemIdentifier {
28+
case .name:
29+
configureName(cell, itemIdentifier: itemIdentifier)
30+
case .addLocations, .editLocations:
31+
configureLocations(cell, itemIdentifier: itemIdentifier)
32+
case .deleteList:
33+
configureDelete(cell, itemIdentifier: itemIdentifier)
34+
}
35+
36+
return cell
37+
}
38+
39+
private func configureBackground(
40+
cell: UITableViewCell,
41+
itemIdentifier: CustomListItemIdentifier,
42+
validationErrors: Set<CustomListFieldValidationError>
43+
) {
44+
configureErrorState(
45+
cell: cell,
46+
itemIdentifier: itemIdentifier,
47+
contentValidationErrors: validationErrors
48+
)
49+
50+
guard let cell = cell as? DynamicBackgroundConfiguration else { return }
51+
52+
cell.setAutoAdaptingBackgroundConfiguration(.mullvadListGroupedCell(), selectionType: .dimmed)
53+
}
54+
55+
private func configureErrorState(
56+
cell: UITableViewCell,
57+
itemIdentifier: CustomListItemIdentifier,
58+
contentValidationErrors: Set<CustomListFieldValidationError>
59+
) {
60+
let itemsWithErrors = CustomListItemIdentifier.fromFieldValidationErrors(contentValidationErrors)
61+
62+
if itemsWithErrors.contains(itemIdentifier) {
63+
cell.layer.cornerRadius = 10
64+
cell.layer.borderWidth = 1
65+
cell.layer.borderColor = UIColor.Cell.validationErrorBorderColor.cgColor
66+
} else {
67+
cell.layer.borderWidth = 0
68+
}
69+
}
70+
71+
private func configureName(_ cell: UITableViewCell, itemIdentifier: CustomListItemIdentifier) {
72+
var contentConfiguration = TextCellContentConfiguration()
73+
74+
contentConfiguration.text = itemIdentifier.text
75+
contentConfiguration.setPlaceholder(type: .required)
76+
contentConfiguration.textFieldProperties = .withSmartFeaturesDisabled()
77+
contentConfiguration.inputText = subject.value.name
78+
contentConfiguration.editingEvents.onChange = subject.bindTextAction(to: \.name)
79+
80+
cell.contentConfiguration = contentConfiguration
81+
}
82+
83+
private func configureLocations(_ cell: UITableViewCell, itemIdentifier: CustomListItemIdentifier) {
84+
var contentConfiguration = UIListContentConfiguration.mullvadValueCell(tableStyle: tableView.style)
85+
86+
contentConfiguration.text = itemIdentifier.text
87+
cell.contentConfiguration = contentConfiguration
88+
89+
if let cell = cell as? CustomCellDisclosureHandling {
90+
cell.disclosureType = .chevron
91+
}
92+
}
93+
94+
private func configureDelete(_ cell: UITableViewCell, itemIdentifier: CustomListItemIdentifier) {
95+
var contentConfiguration = ButtonCellContentConfiguration()
96+
97+
contentConfiguration.style = .tableInsetGroupedDanger
98+
contentConfiguration.text = itemIdentifier.text
99+
contentConfiguration.primaryAction = UIAction { _ in
100+
onDelete?()
101+
}
102+
103+
cell.contentConfiguration = contentConfiguration
104+
}
105+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
//
2+
// CustomListDataSourceConfigurationv.swift
3+
// MullvadVPN
4+
//
5+
// Created by Jon Petersson on 2024-02-14.
6+
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
7+
//
8+
9+
import UIKit
10+
11+
class CustomListDataSourceConfiguration: NSObject {
12+
let dataSource: UITableViewDiffableDataSource<CustomListSectionIdentifier, CustomListItemIdentifier>
13+
var validationErrors: Set<CustomListFieldValidationError> = []
14+
15+
var didSelectItem: ((CustomListItemIdentifier) -> Void)?
16+
17+
init(dataSource: UITableViewDiffableDataSource<CustomListSectionIdentifier, CustomListItemIdentifier>) {
18+
self.dataSource = dataSource
19+
}
20+
21+
func updateDataSource(
22+
sections: [CustomListSectionIdentifier],
23+
validationErrors: Set<CustomListFieldValidationError>,
24+
animated: Bool,
25+
completion: (() -> Void)? = nil
26+
) {
27+
var snapshot = NSDiffableDataSourceSnapshot<CustomListSectionIdentifier, CustomListItemIdentifier>()
28+
29+
sections.forEach { section in
30+
switch section {
31+
case .name:
32+
snapshot.appendSections([.name])
33+
snapshot.appendItems([.name], toSection: .name)
34+
case .addLocations:
35+
snapshot.appendSections([.addLocations])
36+
snapshot.appendItems([.addLocations], toSection: .addLocations)
37+
case .editLocations:
38+
snapshot.appendSections([.editLocations])
39+
snapshot.appendItems([.editLocations], toSection: .editLocations)
40+
case .deleteList:
41+
snapshot.appendSections([.deleteList])
42+
snapshot.appendItems([.deleteList], toSection: .deleteList)
43+
}
44+
}
45+
46+
dataSource.apply(snapshot, animatingDifferences: animated)
47+
}
48+
49+
func set(validationErrors: Set<CustomListFieldValidationError>) {
50+
self.validationErrors = validationErrors
51+
52+
var snapshot = dataSource.snapshot()
53+
54+
validationErrors.forEach { error in
55+
switch error {
56+
case .name:
57+
snapshot.reloadSections([.name])
58+
}
59+
}
60+
61+
dataSource.apply(snapshot, animatingDifferences: false)
62+
}
63+
}
64+
65+
extension CustomListDataSourceConfiguration: UITableViewDelegate {
66+
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
67+
UIMetrics.SettingsCell.customListsCellHeight
68+
}
69+
70+
func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
71+
let snapshot = dataSource.snapshot()
72+
73+
let sectionIdentifier = snapshot.sectionIdentifiers[section]
74+
let itemsInSection = snapshot.itemIdentifiers(inSection: sectionIdentifier)
75+
76+
let itemsWithErrors = CustomListItemIdentifier.fromFieldValidationErrors(validationErrors)
77+
let errorsInSection = itemsWithErrors.filter { itemsInSection.contains($0) }.compactMap { item in
78+
switch item {
79+
case .name:
80+
CustomListFieldValidationError.name
81+
case .addLocations, .editLocations, .deleteList:
82+
nil
83+
}
84+
}
85+
86+
switch sectionIdentifier {
87+
case .name:
88+
let view = SettingsFieldValidationErrorContentView(
89+
configuration: SettingsFieldValidationErrorConfiguration(
90+
errors: errorsInSection.settingsFieldValidationErrors
91+
)
92+
)
93+
return view
94+
default:
95+
return nil
96+
}
97+
}
98+
99+
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
100+
tableView.deselectRow(at: indexPath, animated: false)
101+
102+
if let item = dataSource.itemIdentifier(for: indexPath) {
103+
didSelectItem?(item)
104+
}
105+
}
106+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
//
2+
// CustomListInteractor.swift
3+
// MullvadVPN
4+
//
5+
// Created by Jon Petersson on 2024-02-15.
6+
// Copyright © 2024 Mullvad VPN AB. All rights reserved.
7+
//
8+
9+
import MullvadSettings
10+
11+
protocol CustomListInteractorProtocol {
12+
func createCustomList(viewModel: CustomListViewModel) throws
13+
func updateCustomList(viewModel: CustomListViewModel)
14+
func deleteCustomList(id: UUID)
15+
}
16+
17+
struct CustomListInteractor: CustomListInteractorProtocol {
18+
let repository: CustomListRepositoryProtocol
19+
20+
func createCustomList(viewModel: CustomListViewModel) throws {
21+
try _ = repository.create(viewModel.name, locations: viewModel.locations)
22+
}
23+
24+
func updateCustomList(viewModel: CustomListViewModel) {
25+
repository.update(viewModel.customList)
26+
}
27+
28+
func deleteCustomList(id: UUID) {
29+
repository.delete(id: id)
30+
}
31+
}

0 commit comments

Comments
 (0)