Skip to content
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
10 changes: 10 additions & 0 deletions Sources/Core/Place.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import Foundation

/// A named entry in the place hierarchy (country / city / district), identified by a
/// `String` id — lets the picker sheet sort, filter, and select any of them generically.
protocol NamedPlace: Identifiable, Hashable, Sendable where ID == String {
var name: String { get }
}

/// A country in the EzanVakti hierarchy (`/ulkeler`). Türkiye is id `"2"`.
struct Country: Codable, Identifiable, Hashable, Sendable {
let id: String
Expand Down Expand Up @@ -33,3 +39,7 @@ struct District: Codable, Identifiable, Hashable, Sendable {
case name = "IlceAdi"
}
}

extension Country: NamedPlace {}
extension City: NamedPlace {}
extension District: NamedPlace {}
16 changes: 8 additions & 8 deletions Sources/Shared/LocationPickerModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,14 @@ final class LocationPickerModel: ObservableObject {
countries.first { $0.id == selectedCountryId }?.name
}

/// Countries sorted by name (locale-aware), optionally filtered by a search term.
func countries(matching search: String) -> [Country] {
let sorted = countries.sorted {
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
let term = search.trimmingCharacters(in: .whitespacesAndNewlines)
guard !term.isEmpty else { return sorted }
return sorted.filter { $0.name.localizedCaseInsensitiveContains(term) }
/// Display name of the currently selected city (for the picker row).
var selectedCityName: String? {
cities.first { $0.id == selectedCityId }?.name
}

/// Display name of the currently selected district (for the picker row).
var selectedDistrictName: String? {
districts.first { $0.id == selectedDistrictId }?.name
}

func loadCountriesIfNeeded() {
Expand Down
100 changes: 57 additions & 43 deletions Sources/Shared/LocationPickerView.swift
Original file line number Diff line number Diff line change
@@ -1,45 +1,27 @@
import SwiftUI

/// Cascading country → city → district picker, embeddable in a settings `Form` section.
/// Each level opens a searchable, name-sorted sheet (`PlacePickerSheet`).
struct LocationPickerView: View {
@ObservedObject var store: PrayerStore
@StateObject private var model = LocationPickerModel()
@Environment(\.theme) private var theme
@State private var showCountryPicker = false
@State private var showCityPicker = false
@State private var showDistrictPicker = false

var body: some View {
Group {
LabeledContent("Current", value: store.locationName)

Button {
showCountryPicker = true
} label: {
LabeledContent("Country") {
HStack(spacing: 6) {
Text(model.selectedCountryName ?? "Select…")
Image(systemName: "chevron.up.chevron.down")
.font(.caption2)
.foregroundStyle(theme.muted)
}
}
}
.buttonStyle(.plain)
.disabled(model.countries.isEmpty)
pickerRow("Country", value: model.selectedCountryName,
enabled: !model.countries.isEmpty) { showCountryPicker = true }

Picker("City", selection: Binding(
get: { model.selectedCityId },
set: { model.selectCity($0) }
)) {
Text("Select…").tag("")
ForEach(model.cities) { Text($0.name).tag($0.id) }
}
.disabled(model.cities.isEmpty)
pickerRow("City", value: model.selectedCityName,
enabled: !model.cities.isEmpty) { showCityPicker = true }

Picker("District", selection: $model.selectedDistrictId) {
Text("Select…").tag("")
ForEach(model.districts) { Text($0.name).tag($0.id) }
}
.disabled(model.districts.isEmpty)
pickerRow("District", value: model.selectedDistrictName,
enabled: !model.districts.isEmpty) { showDistrictPicker = true }

if let error = model.error {
Text(error).font(.caption).foregroundStyle(theme.error)
Expand All @@ -54,29 +36,61 @@ struct LocationPickerView: View {
}
.task { model.loadCountriesIfNeeded() }
.sheet(isPresented: $showCountryPicker) {
CountryPickerSheet(
model: model,
selectedId: model.selectedCountryId,
onSelect: { model.selectCountry($0.id) }
)
PlacePickerSheet(title: "Country", items: model.countries,
selectedId: model.selectedCountryId) { model.selectCountry($0.id) }
}
.sheet(isPresented: $showCityPicker) {
PlacePickerSheet(title: "City", items: model.cities,
selectedId: model.selectedCityId) { model.selectCity($0.id) }
}
.sheet(isPresented: $showDistrictPicker) {
PlacePickerSheet(title: "District", items: model.districts,
selectedId: model.selectedDistrictId) { model.selectedDistrictId = $0.id }
}
}

/// A tappable settings row that shows the current selection and opens a picker sheet.
private func pickerRow(_ title: LocalizedStringKey, value: String?,
enabled: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
LabeledContent(title) {
HStack(spacing: 6) {
Text(value ?? String(localized: "Select…"))
Image(systemName: "chevron.up.chevron.down")
.font(.caption2)
.foregroundStyle(theme.muted)
}
}
}
.buttonStyle(.plain)
.disabled(!enabled)
}
}

/// Searchable, name-sorted country list shown as a sheet.
private struct CountryPickerSheet: View {
@ObservedObject var model: LocationPickerModel
/// Searchable, name-sorted list of places shown as a sheet (country / city / district).
private struct PlacePickerSheet<Item: NamedPlace>: View {
let title: LocalizedStringKey
let items: [Item]
let selectedId: String
let onSelect: (Country) -> Void
let onSelect: (Item) -> Void

@Environment(\.dismiss) private var dismiss
@Environment(\.theme) private var theme
@State private var search = ""

private var filtered: [Item] {
let sorted = items.sorted {
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
let term = search.trimmingCharacters(in: .whitespacesAndNewlines)
guard !term.isEmpty else { return sorted }
return sorted.filter { $0.name.localizedCaseInsensitiveContains(term) }
}

var body: some View {
NavigationStack {
countryList
.navigationTitle("Country")
placeList
.navigationTitle(title)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $search, prompt: "Search")
Expand All @@ -93,17 +107,17 @@ private struct CountryPickerSheet: View {
#endif
}

private var countryList: some View {
List(model.countries(matching: search)) { country in
private var placeList: some View {
List(filtered) { item in
Button {
onSelect(country)
onSelect(item)
dismiss()
} label: {
HStack {
Text(country.name)
Text(item.name)
.foregroundStyle(theme.text)
Spacer()
if country.id == selectedId {
if item.id == selectedId {
Image(systemName: "checkmark")
.foregroundStyle(theme.accent)
}
Expand Down
Loading