Skip to content
Snippets Groups Projects
Commit 00c19ae1 authored by Bruno Muniz's avatar Bruno Muniz :apple:
Browse files

Almost done w/ username searching

parent aeb90a8d
No related branches found
No related tags found
2 merge requests!54Releasing 1.1.4,!50Search UI 2.0
......@@ -2,17 +2,43 @@ import UIKit
import Theme
import Shared
import Combine
import XXModels
import DrawerFeature
import DependencyInjection
enum SearchSection {
case stranger
case connections
}
enum SearchItem: Equatable, Hashable {
case stranger(Contact)
case connection(Contact)
}
class SearchTableViewDiffableDataSource: UITableViewDiffableDataSource<SearchSection, SearchItem> {
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
switch snapshot().sectionIdentifiers[section] {
case .stranger:
return ""
case .connections:
return "CONNECTIONS"
}
}
}
public final class SearchContainerController: UIViewController {
@Dependency private var statusBarController: StatusBarStyleControlling
@Dependency var coordinator: SearchCoordinating
@Dependency var statusBarController: StatusBarStyleControlling
lazy private var screenView = SearchContainerView()
private var cancellables = Set<AnyCancellable>()
private let qrController = SearchQRController()
private var cancellables = Set<AnyCancellable>()
private let viewModel = SearchContainerViewModel()
private let emailController = SearchEmailController()
private let phoneController = SearchPhoneController()
private var drawerCancellables = Set<AnyCancellable>()
private let usernameController = SearchUsernameController()
public override func loadView() {
......@@ -30,6 +56,11 @@ public final class SearchContainerController: UIViewController {
)
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
viewModel.didAppear()
}
public override func viewDidLoad() {
super.viewDidLoad()
setupNavigationBar()
......@@ -61,6 +92,12 @@ public final class SearchContainerController: UIViewController {
let point: CGPoint = CGPoint(x: screenView.frame.width * page, y: 0.0)
screenView.scrollView.setContentOffset(point, animated: true)
}.store(in: &cancellables)
viewModel.coverTrafficPublisher
.receive(on: DispatchQueue.main)
.sink { [unowned self] in presentCoverTrafficDrawer() }
.store(in: &cancellables)
}
@objc private func didTapBack() {
......@@ -156,3 +193,66 @@ extension SearchContainerController: UIScrollViewDelegate {
}
}
}
extension SearchContainerController {
private func presentCoverTrafficDrawer() {
let enableButton = CapsuleButton()
enableButton.set(
style: .brandColored,
title: Localized.ChatList.Traffic.positive
)
let dismissButton = CapsuleButton()
dismissButton.set(
style: .seeThrough,
title: Localized.ChatList.Traffic.negative
)
let drawer = DrawerController(with: [
DrawerText(
font: Fonts.Mulish.bold.font(size: 26.0),
text: Localized.ChatList.Traffic.title,
color: Asset.neutralActive.color,
alignment: .left,
spacingAfter: 19
),
DrawerText(
font: Fonts.Mulish.regular.font(size: 16.0),
text: Localized.ChatList.Traffic.subtitle,
color: Asset.neutralBody.color,
alignment: .left,
lineHeightMultiple: 1.1,
spacingAfter: 39
),
DrawerStack(
axis: .horizontal,
spacing: 20,
distribution: .fillEqually,
views: [enableButton, dismissButton]
)
])
enableButton
.publisher(for: .touchUpInside)
.receive(on: DispatchQueue.main)
.sink {
drawer.dismiss(animated: true) { [weak self] in
guard let self = self else { return }
self.drawerCancellables.removeAll()
self.viewModel.didEnableCoverTraffic()
}
}.store(in: &drawerCancellables)
dismissButton
.publisher(for: .touchUpInside)
.receive(on: DispatchQueue.main)
.sink {
drawer.dismiss(animated: true) { [weak self] in
guard let self = self else { return }
self.drawerCancellables.removeAll()
}
}.store(in: &drawerCancellables)
coordinator.toDrawer(drawer, from: self)
}
}
......@@ -65,7 +65,7 @@ final class SearchQRController: UIViewController {
} else {
DispatchQueue.main.async {
self.status = .failed(.cameraPermission)
self.screenView.update(with: .failed(.cameraPermission))
// self.screenView.update(with: .failed(.cameraPermission))
}
}
}
......
import HUD
import UIKit
import Shared
import Combine
import XXModels
import Defaults
import Countries
import DrawerFeature
import DependencyInjection
final class SearchUsernameController: UIViewController {
@Dependency private var hud: HUDType
@Dependency private var coordinator: SearchCoordinating
@KeyObject(.email, defaultValue: nil) var email: String?
@KeyObject(.phone, defaultValue: nil) var phone: String?
@KeyObject(.sharingEmail, defaultValue: false) var isSharingEmail: Bool
@KeyObject(.sharingPhone, defaultValue: false) var isSharingPhone: Bool
lazy private var screenView = SearchUsernameView()
private var cancellables = Set<AnyCancellable>()
private let viewModel = SearchUsernameViewModel()
private var drawerCancellables = Set<AnyCancellable>()
private let adrpURLString = "https://links.xx.network/adrp"
private var dataSource: SearchTableViewDiffableDataSource!
override func loadView() {
view = screenView
......@@ -18,15 +31,82 @@ final class SearchUsernameController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
setupTableView()
setupBindings()
}
private func setupTableView() {
screenView.tableView.separatorStyle = .none
screenView.tableView.tableFooterView = UIView()
screenView.tableView.register(SmallAvatarAndTitleCell.self)
screenView.tableView.dataSource = dataSource
screenView.tableView.delegate = self
dataSource = SearchTableViewDiffableDataSource(
tableView: screenView.tableView
) { tableView, indexPath, item in
let contact: Contact
let cell = tableView.dequeueReusableCell(forIndexPath: indexPath, ofType: SmallAvatarAndTitleCell.self)
switch item {
case .stranger(let stranger):
contact = stranger
case .connection(let connection):
contact = connection
}
let title = (contact.nickname ?? contact.username) ?? ""
cell.titleLabel.text = title
cell.avatarView.setupProfile(title: title, image: contact.photo, size: .medium)
return cell
}
}
private func setupBindings() {
viewModel.hudPublisher
.receive(on: DispatchQueue.main)
.sink { [hud] in hud.update(with: $0) }
.store(in: &cancellables)
viewModel.statePublisher
.compactMap(\.snapshot)
.receive(on: DispatchQueue.main)
.sink { [unowned self] in
screenView.placeholderView.isHidden = true
dataSource.apply($0, animatingDifferences: false)
}.store(in: &cancellables)
screenView.placeholderView
.infoPublisher
.receive(on: DispatchQueue.main)
.sink { [unowned self] in presentSearchDisclaimer() }
.store(in: &cancellables)
screenView.inputField
.textPublisher
.receive(on: DispatchQueue.main)
.sink { [unowned self] in viewModel.didEnterInput($0) }
.store(in: &cancellables)
screenView.inputField
.returnPublisher
.receive(on: DispatchQueue.main)
.sink { [unowned self] _ in viewModel.didStartSearching() }
.store(in: &cancellables)
screenView.inputField
.isEditingPublisher
.receive(on: DispatchQueue.main)
.sink { [unowned self] isEditing in
UIView.animate(withDuration: 0.25) {
self.screenView.placeholderView.alpha = isEditing ? 0.1 : 1.0
}
}.store(in: &cancellables)
viewModel.successPublisher
.receive(on: DispatchQueue.main)
.sink { [unowned self] in presentSucessDrawerFor(contact: $0) }
.store(in: &cancellables)
}
private func presentSearchDisclaimer() {
......@@ -46,7 +126,7 @@ final class SearchUsernameController: UIViewController {
),
DrawerLinkText(
text: Localized.Ud.Placeholder.Drawer.subtitle,
urlString: "https://links.xx.network/adrp",
urlString: adrpURLString,
spacingAfter: 37
),
DrawerStack(views: [
......@@ -66,4 +146,208 @@ final class SearchUsernameController: UIViewController {
coordinator.toDrawer(drawer, from: self)
}
private func presentSucessDrawerFor(contact: Contact) {
var items: [DrawerItem] = []
let drawerTitle = DrawerText(
font: Fonts.Mulish.extraBold.font(size: 26.0),
text: Localized.Ud.NicknameDrawer.title,
color: Asset.neutralDark.color,
spacingAfter: 20
)
let drawerSubtitle = DrawerText(
font: Fonts.Mulish.regular.font(size: 16.0),
text: Localized.Ud.NicknameDrawer.subtitle,
color: Asset.neutralDark.color,
spacingAfter: 20
)
items.append(contentsOf: [
drawerTitle,
drawerSubtitle
])
let drawerNicknameInput = DrawerInput(
placeholder: contact.username!,
validator: .init(
wrongIcon: .image(Asset.sharedError.image),
correctIcon: .image(Asset.sharedSuccess.image),
shouldAcceptPlaceholder: true
),
spacingAfter: 29
)
items.append(drawerNicknameInput)
let drawerSaveButton = DrawerCapsuleButton(
model: .init(
title: Localized.Ud.NicknameDrawer.save,
style: .brandColored
), spacingAfter: 5
)
items.append(drawerSaveButton)
let drawer = DrawerController(with: items)
var nickname: String?
var allowsSave = true
drawerNicknameInput.validationPublisher
.receive(on: DispatchQueue.main)
.sink { allowsSave = $0 }
.store(in: &drawerCancellables)
drawerNicknameInput.inputPublisher
.receive(on: DispatchQueue.main)
.sink {
guard !$0.isEmpty else {
nickname = contact.username
return
}
nickname = $0
}
.store(in: &drawerCancellables)
drawerSaveButton.action
.receive(on: DispatchQueue.main)
.sink { [unowned self] in
guard allowsSave else { return }
drawer.dismiss(animated: true) {
self.viewModel.didSet(nickname: nickname ?? contact.username!, for: contact)
}
}
.store(in: &drawerCancellables)
coordinator.toNicknameDrawer(drawer, from: self)
}
private func presentRequestDrawer(forContact contact: Contact) {
var items: [DrawerItem] = []
let drawerTitle = DrawerText(
font: Fonts.Mulish.extraBold.font(size: 26.0),
text: Localized.Ud.RequestDrawer.title,
color: Asset.neutralDark.color,
spacingAfter: 20
)
var subtitleFragment = "Share your information with #\(contact.username ?? "")"
if let email = contact.email {
subtitleFragment.append(contentsOf: " (\(email))#")
} else if let phone = contact.phone {
subtitleFragment.append(contentsOf: " (\(Country.findFrom(phone).prefix) \(phone.dropLast(2)))#")
} else {
subtitleFragment.append(contentsOf: "#")
}
subtitleFragment.append(contentsOf: " so they know its you.")
let drawerSubtitle = DrawerText(
font: Fonts.Mulish.regular.font(size: 16.0),
text: subtitleFragment,
color: Asset.neutralDark.color,
spacingAfter: 31.5,
customAttributes: [
.font: Fonts.Mulish.regular.font(size: 16.0) as Any,
.foregroundColor: Asset.brandPrimary.color
]
)
items.append(contentsOf: [
drawerTitle,
drawerSubtitle
])
if let email = email {
let drawerEmail = DrawerSwitch(
title: Localized.Ud.RequestDrawer.email,
content: email,
spacingAfter: phone != nil ? 23 : 31,
isInitiallyOn: isSharingEmail
)
items.append(drawerEmail)
drawerEmail.isOnPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] in self?.isSharingEmail = $0 }
.store(in: &drawerCancellables)
}
if let phone = phone {
let drawerPhone = DrawerSwitch(
title: Localized.Ud.RequestDrawer.phone,
content: "\(Country.findFrom(phone).prefix) \(phone.dropLast(2))",
spacingAfter: 31,
isInitiallyOn: isSharingPhone
)
items.append(drawerPhone)
drawerPhone.isOnPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] in self?.isSharingPhone = $0 }
.store(in: &drawerCancellables)
}
let drawerSendButton = DrawerCapsuleButton(
model: .init(
title: Localized.Ud.RequestDrawer.send,
style: .brandColored
), spacingAfter: 5
)
let drawerCancelButton = DrawerCapsuleButton(
model: .init(
title: Localized.Ud.RequestDrawer.cancel,
style: .simplestColoredBrand
), spacingAfter: 5
)
items.append(contentsOf: [drawerSendButton, drawerCancelButton])
let drawer = DrawerController(with: items)
drawerSendButton.action
.receive(on: DispatchQueue.main)
.sink { [unowned self] in
drawer.dismiss(animated: true) {
self.viewModel.didTapRequest(contact: contact)
}
}.store(in: &drawerCancellables)
drawerCancelButton.action
.receive(on: DispatchQueue.main)
.sink { drawer.dismiss(animated: true) }
.store(in: &drawerCancellables)
coordinator.toDrawer(drawer, from: self)
}
}
extension SearchUsernameController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if let item = dataSource.itemIdentifier(for: indexPath) {
switch item {
case .stranger(let contact):
didTap(contact: contact)
case .connection(let contact):
didTap(contact: contact)
}
}
}
private func didTap(contact: Contact) {
guard contact.authStatus == .stranger else {
coordinator.toContact(contact, from: self)
return
}
presentRequestDrawer(forContact: contact)
}
}
import UIKit
import Combine
import Defaults
import Integration
import PushFeature
import DependencyInjection
final class SearchContainerViewModel {
@Dependency var session: SessionType
@Dependency var pushHandler: PushHandling
@KeyObject(.dummyTrafficOn, defaultValue: false) var isCoverTrafficEnabled
@KeyObject(.pushNotifications, defaultValue: false) var pushNotifications
@KeyObject(.askedDummyTrafficOnce, defaultValue: false) var offeredCoverTraffic
var coverTrafficPublisher: AnyPublisher<Void, Never> {
coverTrafficSubject.eraseToAnyPublisher()
}
private let coverTrafficSubject = PassthroughSubject<Void, Never>()
func didAppear() {
verifyCoverTraffic()
verifyNotifications()
}
func didEnableCoverTraffic() {
isCoverTrafficEnabled = true
session.setDummyTraffic(status: true)
}
private func verifyCoverTraffic() {
guard offeredCoverTraffic == false else { return }
offeredCoverTraffic = true
coverTrafficSubject.send()
}
private func verifyNotifications() {
guard pushNotifications == false else { return }
pushHandler.requestAuthorization { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let granted):
if granted {
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
self.pushNotifications = granted
case .failure:
self.pushNotifications = false
}
}
}
}
import HUD
import UIKit
import Combine
import XXModels
import Integration
import DependencyInjection
typealias SearchSnapshot = NSDiffableDataSourceSnapshot<SearchSection, SearchItem>
struct SearchUsernameViewState {
var input = ""
var snapshot: SearchSnapshot?
}
final class SearchUsernameViewModel {
@Dependency var session: SessionType
var hudPublisher: AnyPublisher<HUDStatus, Never> {
hudSubject.eraseToAnyPublisher()
}
var successPublisher: AnyPublisher<Contact, Never> {
successSubject.eraseToAnyPublisher()
}
var statePublisher: AnyPublisher<SearchUsernameViewState, Never> {
stateSubject.eraseToAnyPublisher()
}
private let successSubject = PassthroughSubject<Contact, Never>()
private let hudSubject = CurrentValueSubject<HUDStatus, Never>(.none)
private let stateSubject = CurrentValueSubject<SearchUsernameViewState, Never>(.init())
func didEnterInput(_ string: String) {
stateSubject.value.input = string
}
func didStartSearching() {
hudSubject.send(.on(nil))
do {
try session.search(fact: "U\(stateSubject.value.input)") { [weak self] in
guard let self = self else { return }
switch $0 {
case .success(let contact):
self.hudSubject.send(.none)
self.appendToLocalSearch(contact)
case .failure(let error):
self.appendToLocalSearch(nil)
self.hudSubject.send(.error(.init(with: error)))
}
}
} catch {
hudSubject.send(.error(.init(with: error)))
}
}
func didTapRequest(contact: Contact) {
hudSubject.send(.on(nil))
var contact = contact
contact.nickname = contact.username
do {
try self.session.add(contact)
hudSubject.send(.none)
successSubject.send(contact)
} catch {
hudSubject.send(.error(.init(with: error)))
}
}
func didSet(nickname: String, for contact: Contact) {
if var contact = try? session.dbManager.fetchContacts(.init(id: [contact.id])).first {
contact.nickname = nickname
_ = try? session.dbManager.saveContact(contact)
}
}
private func appendToLocalSearch(_ contact: Contact?) {
var snapshot = SearchSnapshot()
if let contact = contact {
snapshot.appendSections([.stranger])
snapshot.appendItems([.stranger(contact)], toSection: .stranger)
}
if let locals = try? session.dbManager.fetchContacts(Contact.Query(username: stateSubject.value.input)), locals.count > 0 {
snapshot.appendSections([.connections])
snapshot.appendItems(locals.map(SearchItem.connection), toSection: .connections)
}
stateSubject.value.snapshot = snapshot
}
}
......@@ -36,12 +36,9 @@ struct SearchViewState: Equatable {
}
final class SearchViewModel {
@KeyObject(.dummyTrafficOn, defaultValue: false) var isCoverTrafficEnabled: Bool
@KeyObject(.pushNotifications, defaultValue: false) private var pushNotifications
@KeyObject(.askedDummyTrafficOnce, defaultValue: false) var offeredCoverTraffic: Bool
@Dependency private var session: SessionType
@Dependency private var pushHandler: PushHandling
var hudPublisher: AnyPublisher<HUDStatus, Never> {
hudSubject.eraseToAnyPublisher()
......@@ -51,10 +48,6 @@ final class SearchViewModel {
placeholderSubject.eraseToAnyPublisher()
}
var coverTrafficPublisher: AnyPublisher<Void, Never> {
coverTrafficSubject.eraseToAnyPublisher()
}
var statePublisher: AnyPublisher<SearchViewState, Never> {
stateSubject.eraseToAnyPublisher()
}
......@@ -68,15 +61,12 @@ final class SearchViewModel {
let itemsRelay = CurrentValueSubject<[Contact], Never>([])
private let successSubject = PassthroughSubject<Contact, Never>()
private let coverTrafficSubject = PassthroughSubject<Void, Never>()
private let hudSubject = CurrentValueSubject<HUDStatus, Never>(.none)
private let placeholderSubject = CurrentValueSubject<Bool, Never>(true)
private let stateSubject = CurrentValueSubject<SearchViewState, Never>(.init())
func didAppear() {
verifyCoverTraffic()
verifyNotifications()
}
func didSelect(filter: SelectedFilter) {
stateSubject.value.selectedFilter = filter
......@@ -94,10 +84,6 @@ final class SearchViewModel {
stateSubject.value.country = country
}
func didEnableCoverTraffic() {
isCoverTrafficEnabled = true
session.setDummyTraffic(status: true)
}
func didTapSearch() {
hudSubject.send(.on(nil))
......@@ -132,35 +118,6 @@ final class SearchViewModel {
}
}
private func verifyCoverTraffic() {
guard offeredCoverTraffic == false else {
return
}
offeredCoverTraffic = true
coverTrafficSubject.send()
}
private func verifyNotifications() {
guard pushNotifications == false else { return }
pushHandler.requestAuthorization { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let granted):
if granted {
DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications()
}
}
self.pushNotifications = granted
case .failure:
self.pushNotifications = false
}
}
}
func didSet(nickname: String, for contact: Contact) {
if var contact = try? session.dbManager.fetchContacts(.init(id: [contact.id])).first {
......
......@@ -2,6 +2,7 @@ import UIKit
import Shared
final class SearchUsernameView: UIView {
let tableView = UITableView()
let inputField = SearchComponent()
let placeholderView = SearchUsernamePlaceholderView()
......@@ -13,6 +14,7 @@ final class SearchUsernameView: UIView {
imageAtRight: nil
)
addSubview(tableView)
addSubview(inputField)
addSubview(placeholderView)
......@@ -28,6 +30,13 @@ final class SearchUsernameView: UIView {
$0.right.equalToSuperview().offset(-20)
}
tableView.snp.makeConstraints {
$0.top.equalTo(inputField.snp.bottom)
$0.left.equalToSuperview()
$0.right.equalToSuperview()
$0.bottom.equalToSuperview()
}
placeholderView.snp.makeConstraints {
$0.top.equalTo(inputField.snp.bottom)
$0.left.equalToSuperview().offset(32.5)
......
......@@ -15,6 +15,10 @@ public final class SearchComponent: UIView {
textSubject.eraseToAnyPublisher()
}
public var returnPublisher: AnyPublisher<Void, Never> {
returnSubject.eraseToAnyPublisher()
}
private var rightImage = Asset.sharedScan.image {
didSet {
rightButton.setImage(rightImage, for: .normal)
......@@ -26,9 +30,10 @@ public final class SearchComponent: UIView {
}
private var cancellables = Set<AnyCancellable>()
private var rightSubject = PassthroughSubject<Void, Never>()
private var textSubject = PassthroughSubject<String, Never>()
private var isEditingSubject = CurrentValueSubject<Bool, Never>(false)
private let rightSubject = PassthroughSubject<Void, Never>()
private let textSubject = PassthroughSubject<String, Never>()
private let returnSubject = PassthroughSubject<Void, Never>()
private let isEditingSubject = CurrentValueSubject<Bool, Never>(false)
public init() {
super.init(frame: .zero)
......@@ -169,6 +174,12 @@ public final class SearchComponent: UIView {
isEditingSubject.send(true)
}
public func textFieldShouldReturn(_ textField: UITextField) -> Bool {
inputField.resignFirstResponder()
returnSubject.send(())
return true
}
public func textFieldDidEndEditing(_ textField: UITextField) {
rightButton.setImage(rightImage, for: .normal)
isEditingSubject.send(false)
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment