You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
PadelClub/PadelClub/Views/Club/ClubSearchView.swift

484 lines
18 KiB

//
// ClubSearchView.swift
// PadelClub
//
// Created by Razmig Sarkissian on 21/03/2024.
//
import SwiftUI
import CoreLocation
import CoreLocationUI
import TipKit
import LeStorage
struct ClubSearchView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject var dataStore: DataStore
@State private var searchedCity: String = ""
@State private var radius: Double = 50
@State private var clubMarkers : [ClubMarker] = []
@State private var searching: Bool = false
@State private var selectedClubs: [ClubMarker] = []
@State private var searchAttempted: Bool = false
@StateObject var locationManager = LocationManager()
@StateObject private var debouncableViewModel: DebouncableViewModel = DebouncableViewModel()
@State private var getForwardCityList: [CLPlacemark] = []
@State private var searchPresented: Bool = false
@State private var showingSettingsAlert = false
@State private var newClub: Club?
@State private var error: Error?
var presentClubCreationView: Binding<Bool> { Binding(
get: { newClub != nil },
set: { isPresented in
if isPresented == false {
newClub = nil
}
}
)}
var displayContext: DisplayContext = .edition
var club: Club?
var selection: ((Club) -> ())? = nil
private var distanceLimit: Measurement<UnitLength> {
Measurement(value: radius, unit: .kilometers)
}
private func _prompt() -> String {
if clubMarkers.isEmpty {
return "Chercher une ville ou un code postal"
} else {
return "Chercher un club parmi ceux listés"
}
}
private func getClubs() async {
do {
defer {
searching = false
searchAttempted = true
}
error = nil
clubMarkers = []
guard let city = locationManager.city else { return }
let response = try await NetworkFederalService.shared.federalClubs(city: city, radius: radius, location: locationManager.location)
await MainActor.run {
clubMarkers = response.clubMarkers.sorted(by: { a, b in
locationManager.location?.distance(from: a.location) ?? 0 < locationManager.location?.distance(from: b.location) ?? 0
})
}
} catch {
print("getclubs", error)
self.error = error
Logger.error(error)
}
}
/*
Form {
}
.toolbarRole(.editor)
.navigationTitle("Chercher un club")
*/
var body: some View {
List {
if _filteredClubs().isEmpty == false {
Section {
ForEach(_filteredClubs()) { clubMark in
Button {
let clubToEdit = club ?? Club.findOrCreate(name: clubMark.nom, code: clubMark.clubID)
_importClub(clubToEdit: clubToEdit, clubMarker: clubMark)
} label: {
clubView(clubMark)
.contentShape(Rectangle())
}
.frame(maxWidth: .infinity)
.buttonStyle(.plain)
}
} header: {
HStack {
if let city = locationManager.city {
Text(_filteredClubs().count.formatted() + " clubs autour de \(city)")
} else {
Text(_filteredClubs().count.formatted() + " clubs trouvés")
}
Spacer()
Button {
_resetSearch()
} label: {
Text("effacer")
}
.buttonStyle(.borderless)
.textCase(nil)
}
}
}
}
.task {
do {
try await dataStore.clubs.loadDataFromServerIfAllowed()
} catch {
Logger.error(error)
}
}
.listStyle(.grouped)
.onChange(of: searchPresented) {
locationManager.lastError = nil
}
.overlay {
if locationManager.requestStarted == false {
if locationManager.lastError != nil {
ContentUnavailableView {
Label("Erreur", systemImage: "exclamationmark.circle")
} description: {
Text("Une erreur est survenue lors de la récupération de votre localisation.")
} actions: {
RowButtonView("D'accord") {
locationManager.lastError = nil
}
}
} else if clubMarkers.isEmpty == false && searching == false && _filteredClubs().isEmpty {
ContentUnavailableView.search(text: searchedCity)
} else if clubMarkers.isEmpty && searching == false && searchPresented == false {
ContentUnavailableView {
if searchAttempted {
if error != nil {
Label("Une erreur est survenue", systemImage: "exclamationmark.circle.fill")
} else {
Label("Aucun club trouvé", systemImage: "mappin.slash")
}
} else {
Label("Recherche de club", systemImage: "location.circle")
}
} description: {
if searchAttempted && error != nil {
Text("Tenup est peut-être en maintenance, veuillez ré-essayer plus tard.")
} else {
Text("Padel Club recherche via Tenup un club autour de vous, d'une ville ou d'un code postal, facilitant ainsi la saisie d'information.")
}
} actions: {
if locationManager.manager.authorizationStatus != .restricted {
RowButtonView("Chercher autour de moi") {
if locationManager.manager.authorizationStatus == .notDetermined {
locationManager.manager.requestWhenInUseAuthorization()
} else if locationManager.manager.authorizationStatus == .denied {
showingSettingsAlert = true
} else {
locationManager.requestLocation()
}
}
}
if error != nil {
Link(destination: URLs.tenup.url) {
Text("Voir si tenup est en maintenance")
}
}
RowButtonView("Chercher une ville ou un code postal") {
searchPresented = true
}
if searchAttempted {
RowButtonView("Créer un club manuellement") {
newClub = club ?? Club.newEmptyInstance()
}
}
}
}
} else {
ContentUnavailableView("recherche en cours", systemImage: "mappin.and.ellipse", description: Text("recherche des clubs autour de vous"))
}
}
.sheet(isPresented: presentClubCreationView) {
if let newClub {
CreateClubView(club: newClub) { club in
selection?(club)
dismiss()
}
.tint(.master)
}
}
.alert(isPresented: $showingSettingsAlert) {
Alert(
title: Text("Réglages"),
message: Text("Pour trouver les clubs autour de vous, vous devez l'autorisation à Padel Club de récupérer votre position."),
primaryButton: .default(Text("Ouvrir les réglages"), action: {
_openSettings()
}),
secondaryButton: .cancel()
)
}
.onReceive(
debouncableViewModel.$debouncableText
.debounce(for: .seconds(debouncableViewModel.debounceTrigger), scheduler: DispatchQueue.main)
) {
guard !$0.isEmpty else {
if searchedCity.isEmpty == false {
searchedCity = ""
}
return
}
print(">> searching for: \($0)")
if debouncableViewModel.debouncableText.trimmed.count > 1 {
searchedCity = $0
}
}
.onChange(of: searchedCity, {
if searchedCity.isEmpty == false {
locationManager.geocodeCity(cityOrZipcode: searchedCity) { cities, error in
getForwardCityList = cities ?? []
}
}
})
.onChange(of: locationManager.requestStarted, { oldValue, newValue in
if oldValue == true && newValue == false {
if locationManager.lastError == nil {
debouncableViewModel.debouncableText = ""
searchedCity = ""
searching = true
getForwardCityList = []
searchPresented = false
Task {
await getClubs()
}
}
}
})
.navigationTitle("Recherche de club")
.searchable(text: $debouncableViewModel.debouncableText, isPresented: $searchPresented, prompt: _prompt())
.autocorrectionDisabled(true)
.keyboardType(.alphabet)
.searchSuggestions {
if clubMarkers.isEmpty {
ForEach(getForwardCityList, id: \.self) { placemark in
Button {
locationManager.location = placemark.location
locationManager.city = placemark._userReadableCityAndZipCode()
} label: {
Text(placemark._userReadableCityAndZipCode())
}
}
}
}
.onSubmit(of: .search, {
if clubMarkers.isEmpty {
debouncableViewModel.debouncableText = ""
searchedCity = ""
searching = true
if getForwardCityList.isEmpty {
locationManager.city = nil
locationManager.location = nil
locationManager.postalCode = nil
} else {
locationManager.location = getForwardCityList.first?.location
locationManager.city = getForwardCityList.first?._userReadableCityAndZipCode()
}
searchPresented = false
Task {
await getClubs()
}
}
})
.toolbarBackground(.visible, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
if locationManager.requestStarted == false && searching == false {
LocationButton(.currentLocation) {
clubMarkers = []
locationManager.requestLocation()
}
.labelStyle(.iconOnly)
.symbolVariant(.fill)
.foregroundColor (Color.white)
.cornerRadius (20)
.font(.system(size: 12))
} else {
ProgressView()
}
}
// if selectedClubs.isEmpty == false {
// Button {
//
// selectedClubs.forEach { club in
//// let federalClub = FederalClubData(context: viewContext)
//// federalClub.updateWith(club)
//// user.addToClubs(federalClub)
// }
//
//// save()
// dismiss()
// } label: {
// Text("Valider")
// }
// }
}
}
private func _importClub(clubToEdit: Club, clubMarker: ClubMarker) {
if clubToEdit.hasBeenCreated(by: StoreCenter.main.userId) {
if clubToEdit.name.isEmpty {
clubToEdit.name = clubMarker.nom.capitalized
clubToEdit.acronym = clubToEdit.automaticShortName().uppercased()
}
clubToEdit.code = clubMarker.clubID
clubToEdit.latitude = clubMarker.lat
clubToEdit.longitude = clubMarker.lng
clubToEdit.city = clubMarker.ville
clubToEdit.courtCount = clubMarker.numberOfCourts(for: "Padel") ?? 2
}
if displayContext == .addition && clubToEdit.hasBeenCreated(by: StoreCenter.main.userId) {
do {
try dataStore.clubs.addOrUpdate(instance: clubToEdit)
} catch {
Logger.error(error)
}
}
if dataStore.user.clubs.contains(where: { $0 == clubToEdit.id }) == false {
dataStore.user.clubs.append(clubToEdit.id)
self.dataStore.saveUser()
}
dismiss()
selection?(clubToEdit)
}
private func _filteredClubs() -> [ClubMarker] {
clubMarkers.filter({ _isClubValidForSearchedTerms(club: $0) })
}
private func _isClubValidForSearchedTerms(club: ClubMarker) -> Bool {
searchedCity.isEmpty ||
club.nom.localizedCaseInsensitiveContains(searchedCity) ||
club.ville.localizedCaseInsensitiveContains(searchedCity)
}
private func _resetSearch() {
searchAttempted = false
error = nil
debouncableViewModel.debouncableText = ""
searchedCity = ""
locationManager.city = nil
locationManager.location = nil
locationManager.postalCode = nil
locationManager.lastError = nil
clubMarkers = []
getForwardCityList = []
}
private func _openSettings() {
guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else {
return
}
UIApplication.shared.open(settingsURL)
}
@ViewBuilder
private func clubView(_ club: ClubMarker) -> some View {
LabeledContent {
Text(club.distance(from: locationManager.location))
} label: {
Text(club.nom).lineLimit(1)
Text(club.ville).font(.caption)
}
}
}
// MARK: - FederalClubResponse
struct FederalClubResponse: Codable {
let typeRecherche: String
let nombreResultat: Int
let clubMarkers: [ClubMarker]
enum CodingKeys: String, CodingKey {
case typeRecherche, nombreResultat
case clubMarkers = "club_markers"
}
}
enum Pratique: String, Codable {
case beach = "BEACH"
case padel = "PADEL"
case tennis = "TENNIS"
case pickle = "PICKLE"
}
// MARK: - ClubMarker
struct ClubMarker: Codable, Hashable, Identifiable {
let nom, clubID, ville, distance: String
let terrainPratiqueLibelle: String
let pratiques: [Pratique]
let lat, lng: Double
// Method to get the number of courts for a specific sport
func numberOfCourts(for sport: String) -> Int? {
// Split the `terrainPratiqueLibelle` string into components
let components = terrainPratiqueLibelle.split(separator: ",")
// Iterate through the components to find the relevant sport or general number of courts
for component in components {
let trimmedComponent = component.trimmingCharacters(in: .whitespacesAndNewlines)
// Check if the component explicitly mentions the sport
if let colonIndex = trimmedComponent.firstIndex(of: ":") {
let sportName = trimmedComponent[..<colonIndex].trimmingCharacters(in: .whitespacesAndNewlines)
let courtsString = trimmedComponent[trimmedComponent.index(after: colonIndex)...].trimmingCharacters(in: .whitespacesAndNewlines)
// If the sport matches the requested sport, return the number of courts
if sportName.lowercased() == sport.lowercased() {
if let courtsNumber = courtsString.split(separator: " ").first,
let courts = Int(courtsNumber) {
return courts
}
}
} else if pratiques.count == 1 && pratiques.first?.rawValue.lowercased() == sport.lowercased() {
// Handle cases where only the number of courts is provided (e.g., "2 terrains")
if let courtsNumber = trimmedComponent.split(separator: " ").first,
let courts = Int(courtsNumber) {
return courts
}
}
}
// Return nil if the sport or number of courts is not found
return nil
}
var location: CLLocation {
CLLocation(latitude: lat, longitude: lng)
}
func distance(from location: CLLocation?) -> String {
guard let location else { return "" }
let measurement = Measurement(value: location.distance(from: self.location) / 1000, unit: UnitLength.kilometers)
return measurement.formatted()
}
var id: String {
clubID
}
enum CodingKeys: String, CodingKey {
case nom
case clubID = "clubId"
case ville, distance, terrainPratiqueLibelle, pratiques, lat, lng
}
}
fileprivate extension CLPlacemark {
func _userReadableCityAndZipCode() -> String {
[locality, postalCode].compactMap { $0 }.joined(separator: ", ")
}
}
//#Preview {
// ClubSearchView()
//}