diff --git a/ios-native/App/Localizable.xcstrings b/ios-native/App/Localizable.xcstrings index 7a76e5f..20a3f61 100644 --- a/ios-native/App/Localizable.xcstrings +++ b/ios-native/App/Localizable.xcstrings @@ -515,12 +515,12 @@ } } }, - "A place": { + "A place on PetNote is a spot on Apple Maps, like a park, a café or a vet. A home or street address can't be added here, but a meetup can be at one.": { "localizations": { "zh-Hans": { "stringUnit": { "state": "translated", - "value": "一个地点" + "value": "PetNote 上的地点,是苹果地图上的公园、咖啡店、宠物医院这类地方。住家和门牌地址不能加在这里,但聚会可以办在那里。" } } } @@ -688,52 +688,52 @@ } } }, - "All": { + "Addresses": { "localizations": { "zh-Hans": { "stringUnit": { "state": "translated", - "value": "全部" + "value": "地址" } } } }, - "Already on PetNote": { + "All": { "localizations": { "zh-Hans": { "stringUnit": { "state": "translated", - "value": "已在 PetNote 上" + "value": "全部" } } } }, - "Already uploaded": { + "Already on PetNote": { "localizations": { "zh-Hans": { "stringUnit": { "state": "translated", - "value": "已上传" + "value": "已在 PetNote 上" } } } }, - "Always choose a public, well-lit location — dog parks, community parks, or pet-friendly cafés": { + "Already uploaded": { "localizations": { "zh-Hans": { "stringUnit": { "state": "translated", - "value": "选一个公开、光线好的地方,比如狗公园、社区公园或宠物友好的咖啡馆" + "value": "已上传" } } } }, - "An address": { + "Always choose a public, well-lit location — dog parks, community parks, or pet-friendly cafés": { "localizations": { "zh-Hans": { "stringUnit": { "state": "translated", - "value": "一个地址" + "value": "选一个公开、光线好的地方,比如狗公园、社区公园或宠物友好的咖啡馆" } } } @@ -809,16 +809,6 @@ } } }, - "As it was": { - "localizations": { - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "保持原样" - } - } - } - }, "Ask one of the pet's owners for the %lld-character code on their Owners screen.": { "localizations": { "zh-Hans": { @@ -1491,6 +1481,16 @@ } } }, + "Choose somewhere else": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "换个地方" + } + } + } + }, "City hidden": { "localizations": { "zh-Hans": { @@ -3454,6 +3454,16 @@ } } }, + "Keep where it was": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "还用原来的地点" + } + } + } + }, "Language": { "localizations": { "zh-Hans": { @@ -4357,12 +4367,22 @@ } } }, - "Nothing found on Apple Maps.": { + "Nothing on Apple Maps by that name.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "苹果地图上没有叫这个名字的地方。" + } + } + } + }, + "Nothing on Apple Maps matches that.": { "localizations": { "zh-Hans": { "stringUnit": { "state": "translated", - "value": "苹果地图上没有找到。" + "value": "苹果地图上没有找到相符的结果。" } } } @@ -4624,6 +4644,16 @@ } } }, + "Parks, cafés, vets and other spots on Apple Maps. A home address can't be added.": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "苹果地图上的公园、咖啡店、宠物医院等地方。住家地址不能添加。" + } + } + } + }, "Password": { "localizations": { "zh-Hans": { @@ -5609,6 +5639,16 @@ } } }, + "Search for a place or an address": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "搜索地点或地址" + } + } + } + }, "Search people, pets, tags": { "localizations": { "zh-Hans": { @@ -7974,6 +8014,16 @@ } } }, + "Use “%@” as the address": { + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用「%@」作为地址" + } + } + } + }, "Verify your email before commenting.": { "localizations": { "zh-Hans": { diff --git a/ios-native/Core/Maps/PlaceDirectory.swift b/ios-native/Core/Maps/PlaceDirectory.swift index e8fac68..d8d84a5 100644 --- a/ios-native/Core/Maps/PlaceDirectory.swift +++ b/ios-native/Core/Maps/PlaceDirectory.swift @@ -36,11 +36,21 @@ protocol PlaceDirectory: Sendable { /// The places Apple Maps finds for `text`, most relevant first. Only /// those with an identifier: a place of ours can keep nothing else. func search(_ text: String) async throws -> [PlaceSearchHit] + /// The street addresses Apple Maps finds for `text`, most relevant first: + /// where a meetup can be, as a place of ours cannot. A meetup keeps only + /// the words of the one chosen, as if they had been typed. + func searchAddresses(_ text: String) async throws -> [PlaceDetails] /// Drops everything looked up so far, at sign-out: the terms' "temporary", /// and nothing of one person's browsing left for the next. func forget() async } +extension PlaceDirectory { + /// None, for a directory that has no addresses to find: the unit tests' + /// own. The app's two find them. + func searchAddresses(_ text: String) async throws -> [PlaceDetails] { [] } +} + /// Apple Maps, through MapKit, with the temporary cache the terms allow. /// /// A place a screen shows twice is asked for once, and two rows asking for @@ -60,6 +70,7 @@ actor MapKitPlaceDirectory: PlaceDirectory { var place: @Sendable (String) async throws -> PlaceDetails? var address: @Sendable (String) async throws -> PlaceDetails? var search: @Sendable (String) async throws -> [PlaceSearchHit] + var searchAddresses: @Sendable (String) async throws -> [PlaceDetails] = { _ in [] } } private let lookups: Lookups @@ -99,6 +110,20 @@ actor MapKitPlaceDirectory: PlaceDirectory { return hits } + /// Asked afresh each time, as places are. Each address is kept as the + /// answer for its own words, so a meetup saved at one shows its map + /// without asking again. + func searchAddresses(_ text: String) async throws -> [PlaceDetails] { + let text = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return [] } + let started = generation + var seen = Set() + let found = try await lookups.searchAddresses(text).filter { !$0.address.isEmpty && seen.insert($0.address).inserted } + guard generation == started else { return found } + for details in found { addresses.updateValue(details, forKey: details.address) } + return found + } + func forget() { places = [:] addresses = [:] @@ -187,6 +212,18 @@ extension MapKitPlaceDirectory.Lookups { } catch let error as MKError where error.code == .placemarkNotFound { return [] } + }, + searchAddresses: { text in + let request = MKLocalSearch.Request() + request.naturalLanguageQuery = text + // Street addresses, which the places search leaves out: a home, + // say, which can hold a meetup and never a place. + request.resultTypes = .address + do { + return try await MKLocalSearch(request: request).start().mapItems.map(PlaceDetails.init) + } catch let error as MKError where error.code == .placemarkNotFound { + return [] + } } ) } @@ -287,6 +324,17 @@ struct StandInPlaceDirectory: PlaceDirectory { .map { PlaceSearchHit(applePlaceID: $0.key, details: $0.value) } } + /// Words with a number in them are a street address, where `locate` + /// finds it; a town is added when they name none. Words without a number, + /// the way a place is searched for, find no address. + func searchAddresses(_ text: String) async throws -> [PlaceDetails] { + let text = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard text.contains(where: \.isNumber) else { return [] } + let street = String(text.split(separator: ",").first ?? Substring(text)) + let address = text.contains(",") ? text : "\(text), Medford, MA 02155" + return [PlaceDetails(name: street, address: address, latitude: 42.3876, longitude: -71.0995)] + } + func forget() async {} } #endif diff --git a/ios-native/Features/Meetups/MeetupForm.swift b/ios-native/Features/Meetups/MeetupForm.swift index 8e61383..dbd5986 100644 --- a/ios-native/Features/Meetups/MeetupForm.swift +++ b/ios-native/Features/Meetups/MeetupForm.swift @@ -7,12 +7,14 @@ import SwiftUI /// What is sent to create or edit a meetup (`createMeetupCallable`, /// `updateMeetupCallable`, functions/src/meetups.ts): its words, when and for /// how long, and where, in one of the two shapes the app can find. A place -/// from Apple Maps goes by its identifier only; an address the organiser types -/// goes with what to call it, and makes no place (the owner's choice on -/// 2026-09-30). When only those who join may see where, everyone else sees the -/// area the organiser names. A meetup made on the web, at a place with a name, -/// address and position of its own, is sent back where it was unless the -/// organiser chooses somewhere else. +/// from Apple Maps goes by its identifier only; an address goes as the +/// organiser's words, typed or taken from an address Apple Maps found, with +/// what to call it, and makes no place (the owner's choice on 2026-09-30). +/// Nothing else Apple said about the address goes with it. When only those +/// who join may see where, everyone else sees the area the organiser names. A +/// meetup made on the web, at a place with a name, address and position of +/// its own, is sent back where it was unless the organiser chooses somewhere +/// else. struct MeetupDraft: Equatable, Sendable { /// The server's limits, in its units: UTF-16, as JavaScript counts. static let maxTitle = 60 @@ -117,9 +119,13 @@ struct MeetupDraft: Equatable, Sendable { var whereKind: Where = .applePlace /// For `.applePlace`: the place chosen from what Apple Maps found. var place: PlaceSearchHit? - /// For `.address`: the organiser's own words. + /// For `.address`: the organiser's own words, typed or taken from an + /// address Apple Maps found. var address = "" var label = "" + /// For `.address` taken from what Apple Maps found: what Apple said about + /// it, for its map while the words are still Apple's. Never sent. + var foundAddress: PlaceDetails? /// The web's default: only those who join see where. var isAddressPrivate = true var area = "" @@ -289,8 +295,8 @@ final class MeetupFormModel { private(set) var cover: Cover? var draft: MeetupDraft - /// Apple Maps, searched for the place. Any place can hold a meetup, so - /// none is marked. + /// Apple Maps, searched for where: a place or a street address. Any place + /// can hold a meetup, so none is marked. let finder: ApplePlaceFinder private(set) var isSaving = false private(set) var outcome: Outcome? @@ -318,7 +324,7 @@ final class MeetupFormModel { self.uploader = uploader self.now = now self.draft = MeetupDraft(date: MeetupDraft.defaultDate(now: now())) - self.finder = ApplePlaceFinder(places: places, marksAdded: false, directory: directory) + self.finder = ApplePlaceFinder(places: places, marksAdded: false, findsAddresses: true, directory: directory) } /// A meetup that is, as its organiser sees it. @@ -337,7 +343,7 @@ final class MeetupFormModel { self.uploader = uploader self.now = now self.draft = MeetupDraft(editing: meetup, place: place, details: details) - self.finder = ApplePlaceFinder(places: places, marksAdded: false, directory: directory) + self.finder = ApplePlaceFinder(places: places, marksAdded: false, findsAddresses: true, directory: directory) } var canSave: Bool { @@ -378,11 +384,33 @@ final class MeetupFormModel { } func choose(_ found: ApplePlaceFinder.Found) { + draft.whereKind = .applePlace draft.place = found.hit } + /// An address: one Apple Maps found, with what it said for the map, or + /// the words searched for, as typed. + func choose(address: String, found: PlaceDetails?) { + draft.whereKind = .address + draft.address = address + draft.foundAddress = found + draft.place = nil + } + + /// Back to the search, with nothing chosen. func changePlace() { + draft.whereKind = .applePlace draft.place = nil + draft.address = "" + draft.label = "" + draft.foundAddress = nil + } + + /// For a meetup made on the web: where it was, after all. + func keepWhereItWas() { + guard draft.unchangedPlace != nil else { return } + changePlace() + draft.whereKind = .unchanged } /// One at a time, taken before any suspension. The server's words when it @@ -524,33 +552,27 @@ struct MeetupFormSheet: View { .accessibilityIdentifier("createMeetup.safety") } Section { - Picker("Where", selection: $model.draft.whereKind) { - if model.draft.unchangedPlace != nil { - Text("As it was").tag(MeetupDraft.Where.unchanged) - } - Text("A place").tag(MeetupDraft.Where.applePlace) - Text("An address").tag(MeetupDraft.Where.address) - } - .pickerStyle(.segmented) - .accessibilityIdentifier("createMeetup.where") switch model.draft.whereKind { case .unchanged: if let kept = model.draft.unchangedPlace { unchangedPlace(kept) } + somewhereElse case .applePlace: ApplePlacePicker( finder: model.finder, chosen: model.draft.place, - onSelect: { model.choose($0) }, onChange: { model.changePlace() } + onSelect: { model.choose($0) }, onChange: { model.changePlace() }, + onChooseAddress: { model.choose(address: $0, found: $1) } ) + if model.draft.place == nil, model.draft.unchangedPlace != nil { + Button { model.keepWhereItWas() } label: { + Text("Keep where it was") + .frame(minHeight: Layout.minTouchTarget) + .contentShape(.rect) + } + .accessibilityIdentifier("createMeetup.keepUnchanged") + } case .address: - // Street and place names, which a dictionary would - // "correct". - TextField("Address", text: $model.draft.address) - .textContentType(.fullStreetAddress) - .autocorrectionDisabled() - .accessibilityIdentifier("createMeetup.address") - TextField("What to call it (optional)", text: $model.draft.label) - .autocorrectionDisabled() - .accessibilityIdentifier("createMeetup.label") + chosenAddress + somewhereElse } } header: { Text("Where") @@ -667,6 +689,35 @@ struct MeetupFormSheet: View { } } + /// The address chosen, in words that can still be changed (a flat + /// number, say), and, while they are still the words Apple Maps found, + /// its map, as Apple's terms ask wherever an address of theirs is shown. + @ViewBuilder + private var chosenAddress: some View { + // Street and place names, which a dictionary would "correct". + TextField("Address", text: $model.draft.address) + .textContentType(.fullStreetAddress) + .autocorrectionDisabled() + .accessibilityIdentifier("createMeetup.address") + if let found = model.draft.foundAddress, found.address == MeetupDraft.trimmed(model.draft.address), + found.directionsURL != nil { + PlaceMap(details: found) + } + TextField("What to call it (optional)", text: $model.draft.label) + .autocorrectionDisabled() + .accessibilityIdentifier("createMeetup.label") + } + + /// Back to the search. + private var somewhereElse: some View { + Button { model.changePlace() } label: { + Text("Choose somewhere else") + .frame(minHeight: Layout.minTouchTarget) + .contentShape(.rect) + } + .accessibilityIdentifier("createMeetup.changeWhere") + } + /// Where a meetup made on the web is, as it keeps it: its name, address /// and, with a position, a map. private func unchangedPlace(_ place: MeetupPlace) -> some View { diff --git a/ios-native/Features/Places/AddPlace.swift b/ios-native/Features/Places/AddPlace.swift index f620f89..c9409aa 100644 --- a/ios-native/Features/Places/AddPlace.swift +++ b/ios-native/Features/Places/AddPlace.swift @@ -224,7 +224,7 @@ struct AddPlaceSheet: View { var body: some View { Form { - Section("Place") { + Section { ApplePlacePicker( finder: model.finder, chosen: model.chosen, onSelect: { found in @@ -232,6 +232,13 @@ struct AddPlaceSheet: View { }, onChange: { model.changePlace() } ) + } header: { + Text("Place") + } footer: { + // Not under "nothing found", which says it at more length. + if model.chosen == nil, model.finder.state != .found([]) { + Text("Parks, cafés, vets and other spots on Apple Maps. A home address can't be added.") + } } if model.chosen != nil { Section("Category") { diff --git a/ios-native/Features/Places/ApplePlaceFinder.swift b/ios-native/Features/Places/ApplePlaceFinder.swift index 60f6e32..4948d29 100644 --- a/ios-native/Features/Places/ApplePlaceFinder.swift +++ b/ios-native/Features/Places/ApplePlaceFinder.swift @@ -6,7 +6,8 @@ import SwiftUI /// Finding a place on Apple Maps to choose: how adding a place and creating a /// meetup both start. Apple is asked on Return, once per search. When asked /// to, the places someone has added here already are marked, by the -/// identifier each keeps. +/// identifier each keeps; and street addresses are found as well, for a +/// meetup, which can be at one where a place of ours cannot. @MainActor @Observable final class ApplePlaceFinder { @@ -27,6 +28,14 @@ final class ApplePlaceFinder { var query: String private(set) var state: State = .idle + /// The street addresses the last search found, when it looks for them; + /// none while a search is under way. + private(set) var addresses: [PlaceDetails] = [] + /// What the last search was for, as typed: what a meetup can take as its + /// address when Apple has nothing that fits. + private(set) var searched = "" + /// Whether a search finds street addresses too. + let findsAddresses: Bool private let directory: any PlaceDirectory private let places: any PlacesReading @@ -36,12 +45,13 @@ final class ApplePlaceFinder { private let log = Logger(subsystem: "dev.local.petnote.native", category: "places") init( - query: String = "", places: any PlacesReading, marksAdded: Bool, + query: String = "", places: any PlacesReading, marksAdded: Bool, findsAddresses: Bool = false, directory: any PlaceDirectory = PlaceDirectories.shared ) { self.query = query self.places = places self.marksAdded = marksAdded + self.findsAddresses = findsAddresses self.directory = directory } @@ -51,14 +61,20 @@ final class ApplePlaceFinder { searches += 1 let mine = searches state = .searching + addresses = [] + searched = text + // At the same time as the places, not after them. + async let streets = streetAddresses(for: text) do { let hits = try await directory.search(text) let ours = marksAdded && !hits.isEmpty ? try await places.places(applePlaceIDs: hits.map(\.applePlaceID)) : [] + let found = await streets guard mine == searches else { return } let added = Dictionary( ours.compactMap { place in place.applePlaceID.map { ($0, place.id) } }, uniquingKeysWith: { first, _ in first } ) + addresses = found state = .found(hits.map { Found(hit: $0, placeID: added[$0.applePlaceID]) }) } catch { guard mine == searches else { return } @@ -66,29 +82,56 @@ final class ApplePlaceFinder { state = .failed(String(localized: "Couldn't search Apple Maps. Try again.")) } } + + /// None when a search does not look for them, and none when Apple could + /// not be asked: the places it found still show, and what was typed can + /// still be used as it is. + private func streetAddresses(for text: String) async -> [PlaceDetails] { + guard findsAddresses else { return [] } + do { + return try await directory.searchAddresses(text) + } catch { + log.error("Apple Maps address search failed: \(String(describing: error), privacy: .public)") + return [] + } + } } /// In a form's section: the search field and what it found, or the place /// chosen from it. One screen at a time shows it, so its identifiers are the /// same on each. +/// +/// For a meetup, which can be at an address, what Apple found comes in two +/// groups, places and addresses, and the words searched for can be taken as +/// the address as they are: one search for wherever it is, as Apple Maps and +/// the event apps have it, rather than a choice of kind before it. struct ApplePlacePicker: View { @Bindable var finder: ApplePlaceFinder let chosen: PlaceSearchHit? let onSelect: (ApplePlaceFinder.Found) -> Void let onChange: () -> Void + /// An address Apple found, with what it said about it, or the words as + /// typed with nothing: for a finder that finds addresses. + var onChooseAddress: (String, PlaceDetails?) -> Void = { _, _ in } var body: some View { if let chosen { ApplePlaceCard(hit: chosen, onChange: onChange) } else { - TextField("Search Apple Maps", text: $finder.query) + TextField(prompt, text: $finder.query) .submitLabel(.search) + // Street names, which a dictionary would "correct". + .autocorrectionDisabled(finder.findsAddresses) .onSubmit { Task { await finder.search() } } .accessibilityIdentifier("applePlace.search") results } } + private var prompt: String { + finder.findsAddresses ? String(localized: "Search for a place or an address") : String(localized: "Search Apple Maps") + } + @ViewBuilder private var results: some View { switch finder.state { @@ -102,36 +145,105 @@ struct ApplePlacePicker: View { Text(message) .foregroundStyle(Palette.danger) .accessibilityIdentifier("applePlace.searchError") + if finder.findsAddresses { asTyped } + case .found(let found) where finder.findsAddresses: + if found.isEmpty, finder.addresses.isEmpty { + Text("Nothing on Apple Maps matches that.") + .foregroundStyle(Palette.secondaryText) + .accessibilityIdentifier("applePlace.nothingFound") + } + if !found.isEmpty { + heading(String(localized: "Places")) + ForEach(found) { placeRow($0) } + } + if !finder.addresses.isEmpty { + heading(String(localized: "Addresses")) + ForEach(Array(finder.addresses.enumerated()), id: \.offset) { index, address in + addressRow(address, index: index) + } + } + asTyped case .found(let found) where found.isEmpty: - Text("Nothing found on Apple Maps.") - .foregroundStyle(Palette.secondaryText) - .accessibilityIdentifier("applePlace.nothingFound") + VStack(alignment: .leading, spacing: Spacing.xs) { + Text("Nothing on Apple Maps by that name.") + .foregroundStyle(Palette.primaryText) + Text("A place on PetNote is a spot on Apple Maps, like a park, a café or a vet. A home or street address can't be added here, but a meetup can be at one.") + .font(Typography.caption) + .foregroundStyle(Palette.secondaryText) + } + .accessibilityElement(children: .combine) + .accessibilityIdentifier("applePlace.nothingFound") case .found(let found): - ForEach(found) { result in - Button { onSelect(result) } label: { - VStack(alignment: .leading, spacing: Spacing.xs) { - Text(Self.name(of: result.hit)) - .font(Typography.body.weight(.semibold)) - .foregroundStyle(Palette.primaryText) - if !result.hit.details.address.isEmpty { - Text(result.hit.details.address) - .font(Typography.caption) - .foregroundStyle(Palette.secondaryText) - } - if result.placeID != nil { - Text("Already on PetNote") - .font(Typography.caption.weight(.semibold)) - .foregroundStyle(Palette.brandPrimary) - } - } - .frame(maxWidth: .infinity, minHeight: Layout.minTouchTarget, alignment: .leading) - .contentShape(.rect) + ForEach(found) { placeRow($0) } + } + } + + /// Over a group of results, as a list's section header reads. + private func heading(_ title: String) -> some View { + Text(title) + .font(Typography.caption.weight(.semibold)) + .foregroundStyle(Palette.secondaryText) + .accessibilityAddTraits(.isHeader) + } + + private func placeRow(_ result: ApplePlaceFinder.Found) -> some View { + Button { onSelect(result) } label: { + ResultRow(symbol: "mappin.circle.fill", tint: Palette.brandPrimary) { + Text(Self.name(of: result.hit)) + .font(Typography.body.weight(.semibold)) + .foregroundStyle(Palette.primaryText) + if !result.hit.details.address.isEmpty { + Text(result.hit.details.address) + .font(Typography.caption) + .foregroundStyle(Palette.secondaryText) + } + if result.placeID != nil { + Text("Already on PetNote") + .font(Typography.caption.weight(.semibold)) + .foregroundStyle(Palette.brandPrimary) + } + } + } + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("applePlace.result.\(result.id)") + } + + /// Its street on the first line and all of it on the second, as Apple + /// Maps lists an address. + private func addressRow(_ address: PlaceDetails, index: Int) -> some View { + Button { onChooseAddress(address.address, address) } label: { + ResultRow(symbol: "house.circle.fill", tint: Palette.secondaryText) { + Text(address.name.isEmpty ? address.address : address.name) + .font(Typography.body.weight(.semibold)) + .foregroundStyle(Palette.primaryText) + if !address.name.isEmpty, address.name != address.address { + Text(address.address) + .font(Typography.caption) + .foregroundStyle(Palette.secondaryText) } - .buttonStyle(.plain) - .accessibilityElement(children: .combine) - .accessibilityIdentifier("applePlace.result.\(result.id)") } } + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("applePlace.address.\(index)") + } + + /// For an address Apple does not know, or knows by other words. + @ViewBuilder + private var asTyped: some View { + if !finder.searched.isEmpty { + Button { onChooseAddress(finder.searched, nil) } label: { + ResultRow(symbol: "pencil.circle.fill", tint: Palette.secondaryText) { + Text("Use “\(finder.searched)” as the address") + .font(Typography.body) + .foregroundStyle(Palette.primaryText) + } + } + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("applePlace.asTyped") + } } static func name(of hit: PlaceSearchHit) -> String { @@ -139,6 +251,26 @@ struct ApplePlacePicker: View { } } +/// One result: what kind it is, as a symbol, then its lines. +private struct ResultRow: View { + let symbol: String + let tint: Color + @ViewBuilder let lines: Lines + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: Spacing.s) { + // Decoration: the group's heading and the lines say what it is. + Image(systemName: symbol) + .font(Typography.sectionTitle) + .foregroundStyle(tint) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: Spacing.xs) { lines } + } + .frame(maxWidth: .infinity, minHeight: Layout.minTouchTarget, alignment: .leading) + .contentShape(.rect) + } +} + /// The chosen place as Apple Maps has it, on Apple's map, as its terms ask /// wherever its address is shown. struct ApplePlaceCard: View { diff --git a/ios-native/PetNoteAppTests/AddPlaceTests.swift b/ios-native/PetNoteAppTests/AddPlaceTests.swift index 0c96e6d..ed96961 100644 --- a/ios-native/PetNoteAppTests/AddPlaceTests.swift +++ b/ios-native/PetNoteAppTests/AddPlaceTests.swift @@ -51,6 +51,14 @@ import Testing } func forget() async {} + /// Apple has addresses too, if asked: adding a place never asks. + private var addressSearches = 0 + var addressSearchCount: Int { lock.withLock { addressSearches } } + func searchAddresses(_ text: String) async throws -> [PlaceDetails] { + lock.withLock { addressSearches += 1 } + return [PlaceDetails(name: "12 Elm St", address: "12 Elm St, Somerville, MA", latitude: 42.39, longitude: -71.12)] + } + func release() { let continuation = lock.withLock { () -> CheckedContinuation? in defer { held = nil } @@ -113,6 +121,19 @@ import Testing #expect(found(model).map(\.placeID) == ["apple_I1", nil]) } + /// A home or street address can't be a place of ours, so adding a place + /// does not look for addresses at all. + @Test func addingAPlaceDoesNotLookForStreetAddresses() async { + let directory = Directory() + let model = AddPlaceModel(query: "12 Elm St", places: Places(), adder: Adder(), reviewer: PlacesMeetupsTests.FakeReviews(), uploader: FakeUploader(), directory: directory) + + await model.finder.search() + + #expect(!model.finder.findsAddresses) + #expect(model.finder.addresses.isEmpty) + #expect(directory.addressSearchCount == 0) + } + @Test func aPlaceSomeoneAddedAlreadyIsNotChosenToBeAddedAgain() async { let model = AddPlaceModel(query: "pet", places: Places(), adder: Adder(), reviewer: PlacesMeetupsTests.FakeReviews(), uploader: FakeUploader(), directory: Directory()) await model.finder.search() diff --git a/ios-native/PetNoteAppTests/CreateMeetupTests.swift b/ios-native/PetNoteAppTests/CreateMeetupTests.swift index f18968a..5c9341b 100644 --- a/ios-native/PetNoteAppTests/CreateMeetupTests.swift +++ b/ios-native/PetNoteAppTests/CreateMeetupTests.swift @@ -17,10 +17,21 @@ import Testing details: PlaceDetails(name: "Fenway Dog Run", address: "1 Park Dr, Boston, MA", latitude: 42.3434, longitude: -71.095) ) + private nonisolated static let elm = PlaceDetails( + name: "12 Elm St", address: "12 Elm St, Somerville, MA 02144", latitude: 42.3967, longitude: -71.122 + ) + + /// Apple Maps finding the dog run for any search, and an address too, + /// unless it cannot be asked for addresses. private struct Directory: PlaceDirectory { + var addressesFail = false func details(forApplePlaceID id: String) async throws -> PlaceDetails? { nil } func locate(address: String) async throws -> PlaceDetails? { nil } func search(_ text: String) async throws -> [PlaceSearchHit] { [CreateMeetupTests.run] } + func searchAddresses(_ text: String) async throws -> [PlaceDetails] { + if addressesFail { throw URLError(.notConnectedToInternet) } + return [CreateMeetupTests.elm] + } func forget() async {} } @@ -53,11 +64,12 @@ import Testing } private func model( - creator: Creator = Creator(), places: Places = Places(), pets: [Pet] = [], uploader: FakeUploader = FakeUploader() + creator: Creator = Creator(), places: Places = Places(), pets: [Pet] = [], uploader: FakeUploader = FakeUploader(), + directory: Directory = Directory() ) -> MeetupFormModel { MeetupFormModel( uid: "me", creator: creator, places: places, pets: FixedPets(pets: pets), uploader: uploader, - directory: Directory(), now: { Self.now } + directory: directory, now: { Self.now } ) } @@ -303,6 +315,100 @@ import Testing #expect(places.readsByAppleID == 0) } + /// A meetup can be at a street address, a home say, where a place of + /// ours cannot: one search finds both, and keeps what was searched for. + @Test func aSearchForWhereFindsStreetAddressesToo() async { + let model = model() + model.finder.query = " 12 Elm St " + + await model.finder.search() + + #expect(model.finder.addresses == [Self.elm]) + #expect(model.finder.searched == "12 Elm St") + guard case .found(let found) = model.finder.state else { + Issue.record("no results: \(model.finder.state)") + return + } + #expect(found.map(\.id) == ["I1"]) + } + + /// Apple asked for places and not for addresses: the places still show, + /// and the words can still be taken as they are. + @Test func anAddressSearchThatFailsLeavesThePlaces() async { + let model = model(directory: Directory(addressesFail: true)) + model.finder.query = "12 Elm St" + + await model.finder.search() + + #expect(model.finder.addresses.isEmpty) + #expect(model.finder.searched == "12 Elm St") + guard case .found(let found) = model.finder.state else { + Issue.record("the place search was lost with the address one: \(model.finder.state)") + return + } + #expect(found.map(\.id) == ["I1"]) + } + + /// An address Apple found goes as the organiser's words, as if typed: + /// none of the rest of what Apple said, its position above all. + @Test func anAddressAppleFoundIsSentAsWordsAndNothingElseOfApples() async throws { + let creator = Creator() + let model = model(creator: creator) + model.draft.title = "Yard games" + model.draft.description = "Fenced yard." + model.finder.query = "12 Elm St" + await model.finder.search() + let address = try #require(model.finder.addresses.first) + + model.choose(address: address.address, found: address) + #expect(model.draft.whereKind == .address) + #expect(model.draft.foundAddress == Self.elm, "kept for the form's map") + await model.submit() + + let payload = try #require(creator.sent.first).payload + #expect(payload["location"] as? [String: String] == [ + "kind": "address", "address": "12 Elm St, Somerville, MA 02144", "label": "", "area": "", + ]) + } + + /// For an address Apple does not know: the words searched for, as typed. + @Test func theWordsSearchedForCanBeTheAddressAsTheyAre() async throws { + let creator = Creator() + let model = model(creator: creator) + model.draft.title = "Yard games" + model.draft.description = "Fenced yard." + model.finder.query = " 5 Oak Ave, Medford, MA " + await model.finder.search() + + model.choose(address: model.finder.searched, found: nil) + model.draft.label = "Oak yard" + await model.submit() + + #expect(model.draft.foundAddress == nil) + let payload = try #require(creator.sent.first).payload + #expect(payload["location"] as? [String: String] == [ + "kind": "address", "address": "5 Oak Ave, Medford, MA", "label": "Oak yard", "area": "", + ]) + } + + /// Somewhere else: back to the search, with nothing of the address left + /// to be sent with a place chosen next. + @Test func choosingSomewhereElseStartsAgainWithNothingChosen() async { + let model = model() + model.draft.title = "Yard games" + model.draft.description = "Fenced yard." + model.choose(address: Self.elm.address, found: Self.elm) + model.draft.label = "Elm yard" + #expect(model.canSave) + + model.changePlace() + + #expect(model.draft.whereKind == .applePlace) + #expect(model.draft.place == nil && model.draft.address.isEmpty && model.draft.label.isEmpty) + #expect(model.draft.foundAddress == nil) + #expect(!model.canSave, "nothing chosen") + } + @Test func whoMayJoinIsSentAsTheWebsFormSetsIt() throws { var draft = MeetupDraft(date: Self.now) draft.requirements.petType = "dog" diff --git a/ios-native/PetNoteAppTests/EditMeetupTests.swift b/ios-native/PetNoteAppTests/EditMeetupTests.swift index 3edf6fd..11cc8d1 100644 --- a/ios-native/PetNoteAppTests/EditMeetupTests.swift +++ b/ios-native/PetNoteAppTests/EditMeetupTests.swift @@ -92,6 +92,30 @@ import Testing #expect(location as NSDictionary == Self.webPark as NSDictionary, "the web's shape, no kind, no area") } + /// Off to search somewhere else, and back: where it was, in the web's + /// shape, as if never left. + @Test func aMeetupMadeOnTheWebCanStayWhereItWasAfterASearch() throws { + let model = model(meetup(Self.webPark)) + + model.changePlace() + #expect(model.draft.whereKind == .applePlace && model.draft.place == nil) + model.keepWhereItWas() + + #expect(model.draft.whereKind == .unchanged) + let location = try #require(model.draft.payload["location"] as? [String: Any]) + #expect(location as NSDictionary == Self.webPark as NSDictionary) + } + + /// A meetup made here has no "where it was" to go back to. + @Test func aMeetupMadeHereHasNoWhereItWasToKeep() { + let model = model(meetup(["address": "5 Oak Ave, Medford, MA", "label": "Oak yard"])) + + model.changePlace() + model.keepWhereItWas() + + #expect(model.draft.whereKind == .applePlace, "nothing to go back to") + } + @Test func choosingSomewhereElseLeavesTheWebsPlaceBehind() throws { let model = model(meetup(Self.webPark)) model.draft.whereKind = .address diff --git a/ios-native/PetNoteAppTests/PlaceDirectoryTests.swift b/ios-native/PetNoteAppTests/PlaceDirectoryTests.swift index 0020c8b..5bef281 100644 --- a/ios-native/PetNoteAppTests/PlaceDirectoryTests.swift +++ b/ios-native/PetNoteAppTests/PlaceDirectoryTests.swift @@ -41,6 +41,7 @@ import Testing gate: Gate? = nil, searchGate: Gate? = nil, hits: [PlaceSearchHit] = [], + streets: [PlaceDetails] = [], place: @escaping @Sendable (String) throws -> PlaceDetails? = { _ in park } ) -> MapKitPlaceDirectory { MapKitPlaceDirectory(lookups: .init( @@ -57,6 +58,10 @@ import Testing asked.record("search \(text)") await searchGate?.pass() return hits + }, + searchAddresses: { text in + asked.record("addresses \(text)") + return streets } )) } @@ -163,6 +168,24 @@ import Testing #expect(asked.all == ["search dog park", "place I1"], "a search answered after forget() must not be kept") } + /// Asked each time, as a search for places is. Each address found is + /// kept as the answer for its own words, so a meetup saved at one does + /// not ask Apple again to show it; one Apple gave twice, or with no + /// words, is shown once, or not at all. + @Test func anAddressSearchKeepsEachAddressForItsOwnWords() async throws { + let asked = Asked() + let elm = PlaceDetails(name: "12 Elm St", address: "12 Elm St, Somerville, MA 02144", latitude: 42.39, longitude: -71.12) + let blank = PlaceDetails(name: "Somewhere", address: "", latitude: 42.4, longitude: -71.1) + let directory = directory(asked: asked, streets: [elm, blank, elm]) + + #expect(try await directory.searchAddresses(" 12 elm st ") == [elm]) + #expect(try await directory.locate(address: "12 Elm St, Somerville, MA 02144") == elm) + _ = try await directory.searchAddresses("12 elm st") + #expect(try await directory.searchAddresses(" ").isEmpty) + + #expect(asked.all == ["addresses 12 elm st", "addresses 12 elm st"]) + } + #if PETNOTE_FAULT_INJECTION /// The emulator build's table, as UI tests search it: by the words of a /// place's name, whatever the case. @@ -175,6 +198,19 @@ import Testing #expect(try await standIn.search("TEST CONTENT Hi").isEmpty, "the web's search test would find it") #expect(try await standIn.search(" ").isEmpty) } + + /// Its addresses, as UI tests search for one: words with a street number + /// are one, in the town they name or else in Medford; a place's name is + /// not. + @Test func theStandInFindsAStreetAddressForWordsWithANumber() async throws { + let standIn = StandInPlaceDirectory() + + let elm = try await standIn.searchAddresses(" 12 Elm St ") + #expect(elm.map(\.name) == ["12 Elm St"] && elm.map(\.address) == ["12 Elm St, Medford, MA 02155"]) + let oak = try await standIn.searchAddresses("5 Oak Ave, Medford, MA") + #expect(oak.map(\.name) == ["5 Oak Ave"] && oak.map(\.address) == ["5 Oak Ave, Medford, MA"]) + #expect(try await standIn.searchAddresses("dog run").isEmpty) + } #endif @Test func anAddressIsTrimmedAndAnEmptyOneIsNotAsked() async throws { diff --git a/ios-native/PetNoteAppUITests/PlacesMeetupsUITests.swift b/ios-native/PetNoteAppUITests/PlacesMeetupsUITests.swift index 7fe56e9..652643a 100644 --- a/ios-native/PetNoteAppUITests/PlacesMeetupsUITests.swift +++ b/ios-native/PetNoteAppUITests/PlacesMeetupsUITests.swift @@ -538,11 +538,11 @@ final class PlacesMeetupsUITests: XCTestCase { try openCreateMeetup(in: app) try type(title + "\n", into: "createMeetup.title", in: app) try type("TEST CONTENT A fenced yard.", into: "createMeetup.description", in: app) - let whereChoice = app.segmentedControls["createMeetup.where"] - reveal(whereChoice, in: app) - XCTAssertTrue(waitUntilHittable(whereChoice, in: app, timeout: 10), "\(app.debugDescription)") - whereChoice.buttons["An address"].tap() - try type("5 Oak Ave, Medford, MA\n", into: "createMeetup.address", in: app) + // One search for where it is, and the words as typed taken as the + // address. + try type("5 Oak Ave, Medford, MA\n", into: "applePlace.search", in: app) + try tapButton("applePlace.asTyped", in: app) + XCTAssertEqual(app.textFields["createMeetup.address"].value as? String, "5 Oak Ave, Medford, MA") try type("TEST CONTENT Oak yard\n", into: "createMeetup.label", in: app) try type("Medford\n", into: "createMeetup.area", in: app) @@ -607,6 +607,50 @@ final class PlacesMeetupsUITests: XCTestCase { XCTAssertEqual(JourneyAdmin.string(rulesStored["additionalNotes"]), "TEST CONTENT Bring water.") } + /// Creating a meetup at a street address Apple Maps found, a home say: + /// one search finds it among the addresses, the form shows it on Apple's + /// map while the words are still Apple's and not once they are changed, + /// and the server keeps the organiser's words and makes no place. + func testCreatingAMeetupAtAnAddressAppleMapsFound() throws { + let (app, me) = try signInAsNewAccount("addressmeetup-\(run)@petnote.test") + uid = me + inboxes.append(me) + organisers.append(me) + let title = "TEST CONTENT Backyard fetch \(run)" + + app.tabBars.buttons["Meetups"].tap() + try openCreateMeetup(in: app) + try type(title + "\n", into: "createMeetup.title", in: app) + try type("TEST CONTENT A fenced yard.", into: "createMeetup.description", in: app) + try type("12 Elm St\n", into: "applePlace.search", in: app) + try tapButton("applePlace.address.0", in: app) + let address = app.textFields["createMeetup.address"] + XCTAssertTrue(address.waitForExistence(timeout: 10), "the address was not chosen\n\(app.debugDescription)") + XCTAssertEqual(address.value as? String, "12 Elm St, Medford, MA 02155") + let map = app.descendants(matching: .any)["place.map"] + XCTAssertTrue(map.waitForExistence(timeout: 10), "no map for the address Apple found\n\(app.debugDescription)") + + // A flat number: the organiser's words now, so Apple's map goes. + reveal(address, in: app) + address.coordinate(withNormalizedOffset: CGVector(dx: 0.98, dy: 0.5)).tap() + XCTAssertTrue(Self.hasKeyboardFocus(address), "the address did not take the keyboard") + address.typeText(", Apt 2\n") + XCTAssertTrue(waitForDisappearance(of: map, timeout: 10), "Apple's map stayed for words that are no longer Apple's") + try type("Medford\n", into: "createMeetup.area", in: app) + let create = app.buttons["createMeetup.save"] + XCTAssertTrue(waitUntilHittable(create, in: app, timeout: 10), "Create stayed off") + create.tap() + + let shownTitle = app.staticTexts["meetupDetail.title"] + XCTAssertTrue(waitForExistence(of: shownTitle, in: app, timeout: 30), "the meetup did not open\n\(app.debugDescription)") + let id = try createdMeetup(by: me) + let stored = try XCTUnwrap(try JourneyAdmin.fields(path: "meetups/\(id)")) + XCTAssertEqual(JourneyAdmin.string(stored["locationVisibility"]), "participants_only", "an address is private unless chosen otherwise") + XCTAssertNil(stored["locationId"], "a meetup at an address made a place") + let hidden = try XCTUnwrap(try JourneyAdmin.fields(path: "meetups/\(id)/private/address")) + XCTAssertEqual(Self.strings(of: hidden), ["address": "12 Elm St, Medford, MA 02155, Apt 2", "label": ""]) + } + /// Creating a public meetup at a place from Apple Maps, with a cover: the /// server keeps the identifier and links the meetup to the place it makes /// for it, which holds none of Apple's words either, and keeps the @@ -712,7 +756,7 @@ final class PlacesMeetupsUITests: XCTestCase { reveal(kept, in: app) XCTAssertTrue(kept.exists, "where it was is not shown as kept\n\(app.debugDescription)") XCTAssertTrue(kept.label.contains(lot), kept.label) - XCTAssertTrue(app.segmentedControls["createMeetup.where"].buttons["As it was"].isSelected) + XCTAssertTrue(app.buttons["createMeetup.changeWhere"].exists, "no way to choose somewhere else\n\(app.debugDescription)") try save(in: app) // The server first, and the place it made noted for cleanup before // anything that can fail: a run that stopped on the title below left @@ -733,10 +777,9 @@ final class PlacesMeetupsUITests: XCTestCase { // Moved to a typed address. try openEdit(in: app) - let whereChoice = app.segmentedControls["createMeetup.where"] - reveal(whereChoice, in: app) - whereChoice.buttons["An address"].tap() - try type("5 Oak Ave, Medford, MA\n", into: "createMeetup.address", in: app) + try tapButton("createMeetup.changeWhere", in: app) + try type("5 Oak Ave, Medford, MA\n", into: "applePlace.search", in: app) + try tapButton("applePlace.asTyped", in: app) try save(in: app) assertMeetupPlace(["5 Oak Ave, Medford, MA"], in: app) var moved: [String: Any] = [:] @@ -759,6 +802,14 @@ final class PlacesMeetupsUITests: XCTestCase { "no Edit Meetup\n\(app.debugDescription)") } + /// A button of the form, scrolled to and tapped once it can be. + private func tapButton(_ identifier: String, in app: XCUIApplication) throws { + let button = app.buttons[identifier] + reveal(button, in: app) + XCTAssertTrue(waitUntilHittable(button, in: app, timeout: 20), "no \(identifier)\n\(app.debugDescription)") + button.tap() + } + private func save(in app: XCUIApplication) throws { let save = app.buttons["createMeetup.save"] XCTAssertTrue(waitUntilHittable(save, in: app, timeout: 10), "Save stayed off\n\(app.debugDescription)") diff --git a/ios-native/docs/apple-maps-places-plan.md b/ios-native/docs/apple-maps-places-plan.md index 3d5f51e..da724b0 100644 --- a/ios-native/docs/apple-maps-places-plan.md +++ b/ios-native/docs/apple-maps-places-plan.md @@ -52,6 +52,7 @@ 2. **办在一个门牌地址**(组织者自己输入的) - 存组织者输入的地址文字。这是用户自己的内容,可以存。 - 显示时,再向苹果现查位置、画地图。 + - 10-01 深夜起,发起聚会时一个搜索框同时搜地点和门牌地址(`searchAddresses`,`MKLocalSearch` 的 `.address`)。组织者从苹果找到的地址里选一个,和地址表单的自动补全一样,只是帮人把地址打全:存的仍然只是这串文字,组织者还可以接着改;苹果给的坐标不存,聚会页照旧每次现查。文字没改动时,表单里配苹果地图显示(条款 2.4)。 3. **私密聚会公开显示的「大概在哪」** - 以前自动用城市名。苹果查到的城市也不能存,所以改成组织者自己填一个区域,比如「Somerville」。 @@ -113,6 +114,7 @@ | 8b | iOS:发起聚会时的参加要求 | #259 | — | | 8c | iOS:聚会封面图 | #264(发起、编辑时设封面)、#265(列表里显示) | — | | 9 | iOS:编辑聚会 | #260 | — | +| 9b | iOS:在哪儿只用一个搜索框,地点和门牌地址都搜得到;添加地点搜不到时说清楚为什么 | 这一个 PR | — | | — | 把这些函数部署到测试项目 | | 批准(原来清单 B 的一部分) | | — | 部署到生产 | | 批准 | | — | 真机验证编号和限流 | 见「已经验证过的」 | — | diff --git a/ios-native/docs/visual-parity.md b/ios-native/docs/visual-parity.md index 959ec40..7bd0bbe 100644 --- a/ios-native/docs/visual-parity.md +++ b/ios-native/docs/visual-parity.md @@ -201,7 +201,7 @@ - **从哪进**:聚会列表右上角的「+」,和网页一样。没有聚会时,空白处也有「发起聚会」按钮。以前 iOS 上没有这两个入口,因为地址要靠服务器查。 - **填什么**:标题(最多 60 字)、介绍(最多 500 字)、开始时间(默认明天上午 10 点,只能选以后的时间)、时长(照网页的 6 个选项)。 -- **在哪儿**:两种选一种。「一个地点」:在苹果地图上搜、选。「一个地址」:自己填门牌地址,可以再起个称呼,比如「Oak 家后院」;这种不会加进「地点」,是你 09-30 定的第 1 点。 +- **在哪儿**:两种选一种。「一个地点」:在苹果地图上搜、选。「一个地址」:自己填门牌地址,可以再起个称呼,比如「Oak 家后院」;这种不会加进「地点」,是你 09-30 定的第 1 点。(10-01 深夜起改成一个搜索框,见下面「在哪儿只用一个搜索框」。) - **谁能看到地址**:默认只有报名的人能看到,和网页一样。这时可以填一个所有人都能看到的区域,比如「Somerville」,没报名的人只看到这个,是你定的第 2 点。 - **安全提示**:网页那 5 条组织者安全提示照搬,默认展开。 - **组织者的宠物**:和网页一样,自动带上你的第一只宠物,出现在报名名单上。 @@ -224,7 +224,7 @@ - **从哪进**:组织者打开自己办的、还没开始的聚会,「取消聚会」上面多了「编辑聚会」,和网页一样。别人看不到这个按钮,服务器也只认组织者和管理员。 - **能改什么**:发起时能填的都能改,打开时已经按这个聚会现在的样子填好了:标题、介绍、时间、时长、在哪儿、谁能看到地址、参加要求。 -- **网页办的老聚会不会被悄悄改地点**:网页办的聚会存的是地点名、地址和坐标。iOS 编辑时,「在哪儿」默认选「保持原样」,原样发回去,所以只改个标题,地点和它关联的「地点」页都不会变。想换地方,就选「一个地点」或「一个地址」。 +- **网页办的老聚会不会被悄悄改地点**:网页办的聚会存的是地点名、地址和坐标。iOS 编辑时,「在哪儿」默认选「保持原样」,原样发回去,所以只改个标题,地点和它关联的「地点」页都不会变。想换地方,就选「一个地点」或「一个地址」。(10-01 深夜起改成点「换个地方」再搜,见下面「在哪儿只用一个搜索框」。) - **换了地方之后**:服务器会更新公开的那份和只给报名者看的那份。原来关联的地点如果是聚会自动建的、又没人用了,会被删掉,这是你 09-30 定的第 4 点(#246)。 - **时间已经过了的聚会**:要先改到以后的时间才能保存,服务器也是这个规矩。 - **网页没给的时长**:网页只有 6 个时长选项,服务器允许 5 分钟到一天。原来的时长不在选项里的,会加进选项,不会显示成空白。 @@ -321,6 +321,19 @@ - **现在**:每一行都是标题在上、五颗星在下,标题一行写完。写评价和添加地点时顺手打分,两处都是这样。 - **为什么不是放不下的才挪**:试过「能并排就并排」,结果同一张卡片里「Safety」并排、另两行在上面,看着乱,所以统一放上面。 +### 10-01 深夜 在哪儿只用一个搜索框,地点和门牌地址都搜得到 + +对照图:[发起聚会时搜门牌地址、选好的地址和地图、同一个框搜狗公园、添加地点时搜门牌地址](visual-parity/meetup-where-search.jpg)。图里是测试数据。 + +- **为什么改**:以前发起聚会,要先在「一个地点」和「一个地址」里选一种。搜苹果地图的框只认公园、店铺这类有名字的地方,搜门牌地址什么都没有,也不告诉人该怎么办。做活动的 App,比如苹果的 Invites、Partiful、Luma,一般只有一个框:输入什么都行,地点和地址都能搜出来。 +- **现在**:「在哪儿」只有一个搜索框,提示写着「搜索地点或地址」。按回车后,结果分「地点」和「地址」两组。最后一行总是「用「…」作为地址」,苹果不认识的地址也能直接用。 +- **选了地址以后**:地址填进一个能改的框里,可以补上苹果没有的细节,比如几单元。没改动时,下面配着苹果地图上的位置;一改动,地图就不显示了,因为那已经是你自己的写法(苹果条款 2.4:显示它查到的地址时要配它的地图)。 +- **存什么**:只存地址这串文字,和自己手填的一样;苹果给的坐标不存,聚会页上照旧每次现查。不违反「只许存地点编号」那条,见 `apple-maps-places-plan.md`「以后存什么」。 +- **没变的**:地址默认只给报名的人看;办在地址的聚会照旧不会加进「地点」。 +- **添加地点**:那里只收公园、咖啡店、宠物医院这类苹果地图上的地方,这一点没变。现在搜索框下面有一句提示;搜不到时会说清楚:住家和门牌地址不能加在这里,但聚会可以办在那里。 +- **编辑网页办的老聚会**:原来的地点照样显示,下面多一个「换个地方」。搜的时候还有「还用原来的地点」,可以退回去。 +- **还没做的**:边打字边出建议,像苹果地图那样不用按回车。下一个 PR 做。 + ## 7. 候选 `2a6a8f3` 的验证 App 代码在 `0fe5b27`(四个问题的修复);`2a6a8f3` 只多了两处界面测试的修正。下面每一项都是在 `2a6a8f3` 上跑的。 diff --git a/ios-native/docs/visual-parity/meetup-where-search.jpg b/ios-native/docs/visual-parity/meetup-where-search.jpg new file mode 100644 index 0000000..db381bd Binary files /dev/null and b/ios-native/docs/visual-parity/meetup-where-search.jpg differ