Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(iOS): search page #52

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
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
16 changes: 8 additions & 8 deletions apps/mobile/metro-now/metro-now.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_ASSET_PATHS = "\"metro-now Watch App/Preview Content\"";
DEVELOPMENT_TEAM = R6WU5ABNG2;
ENABLE_PREVIEWS = YES;
Expand All @@ -481,7 +481,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.3.3;
MARKETING_VERSION = 0.3.4;
PRODUCT_BUNDLE_IDENTIFIER = "com.krystof.metro-now.watchkitapp";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
Expand All @@ -499,7 +499,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_ASSET_PATHS = "\"metro-now Watch App/Preview Content\"";
DEVELOPMENT_TEAM = R6WU5ABNG2;
ENABLE_PREVIEWS = YES;
Expand All @@ -513,7 +513,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.3.3;
MARKETING_VERSION = 0.3.4;
PRODUCT_BUNDLE_IDENTIFIER = "com.krystof.metro-now.watchkitapp";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
Expand All @@ -532,7 +532,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_ASSET_PATHS = "\"metro-now/Preview Content\"";
DEVELOPMENT_TEAM = R6WU5ABNG2;
ENABLE_PREVIEWS = YES;
Expand All @@ -550,7 +550,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.3.3;
MARKETING_VERSION = 0.3.4;
PRODUCT_BUNDLE_IDENTIFIER = "com.krystof.metro-now";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
Expand All @@ -566,7 +566,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 3;
CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_ASSET_PATHS = "\"metro-now/Preview Content\"";
DEVELOPMENT_TEAM = R6WU5ABNG2;
ENABLE_PREVIEWS = YES;
Expand All @@ -584,7 +584,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.3.3;
MARKETING_VERSION = 0.3.4;
PRODUCT_BUNDLE_IDENTIFIER = "com.krystof.metro-now";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
Expand Down
25 changes: 18 additions & 7 deletions apps/mobile/metro-now/metro-now/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ struct ContentView: View {
AppStorageKeys.hasSeenWelcomeScreen.rawValue
) var hasSeenWelcomeScreen = false
@State private var showWelcomeScreen: Bool = false
@State private var showSearchScreen: Bool = false
@State private var searchText = ""

var body: some View {
ZStack {
Expand All @@ -24,15 +26,24 @@ struct ContentView: View {
Label("Settings", systemImage: "gearshape")
}
}
// ToolbarItem(placement: .topBarTrailing) {
// NavigationLink {
// SettingsPageView()
// } label: {
// Label("Search", systemImage: "magnifyingglass")
// }
// }
ToolbarItem(placement: .topBarTrailing) {
Button(action: {
showSearchScreen = true
}) {
Label("Search", systemImage: "magnifyingglass")
}
}
}
}
.sheet(
isPresented: $showSearchScreen,
onDismiss: {
showSearchScreen = false
}
) {
SearchPageView()
.presentationDetents([.large])
}
.sheet(
isPresented: $showWelcomeScreen,
onDismiss: dismissWelcomeScreen
Expand Down
216 changes: 216 additions & 0 deletions apps/mobile/metro-now/metro-now/pages/search/search-page.view.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
// metro-now
// https://github.com/krystxf/metro-now

import CoreLocation
import Foundation
import SwiftUI

private enum Sorting: String, CaseIterable, Identifiable {
case alphabetical, distance
var id: Self { self }
}

private enum Filter: String, CaseIterable, Identifiable {
case all, metro
var id: Self { self }
}

struct MetroLineStopsView: View {
private let route: ApiRoute
private let stops: [ApiStop]

init(stops: [ApiStop], route: ApiRoute) {
self.route = route
self.stops = stops.filter { stop in
let stopRoutes = stop.platforms.flatMap { platform in
platform.routes
}

return stopRoutes.contains(where: { $0.id == route.id })
}.sorted(by: { $0.name < $1.name })
}

var body: some View {
ForEach(stops, id: \.id) { stop in
let routes = uniqueBy(
array: stop.platforms.flatMap(\.routes),
by: { $0.id }
)

HStack {
ForEach(routes, id: \.id) { route in
RouteNameIconView(
label: route.name,
background: getRouteColor(route.name)
)
}
Text(stop.name)
}
}
}
}

func normalizeForSearch(_ input: String) -> String {
// Remove diacritics
let noDiacritics = input.folding(options: .diacriticInsensitive, locale: .current)
// Replace dots with spaces
let noDots = noDiacritics.replacingOccurrences(of: ".", with: " ")
// Normalize spaces (trim and replace multiple spaces with single)
let normalizedSpaces = noDots
.components(separatedBy: .whitespacesAndNewlines)
.filter { !$0.isEmpty }
.joined(separator: "")
return normalizedSpaces
}

struct SearchPageView: View {
@State private var searchText = ""
@StateObject private var viewModel = ClosestStopPageViewModel()

@Environment(\.dismiss) private var dismiss
@State private var sorting: Sorting = .alphabetical
@State private var filter: Filter = .metro

var body: some View {
NavigationStack {
List {
if filter == .metro, let stops = viewModel.metroStops {
let filteredStops = stops.filter { stop in
normalizeForSearch(searchText).isEmpty ||
normalizeForSearch(stop.name).contains(normalizeForSearch(searchText))
// stop.name.lowercased().folding(options: .diacriticInsensitive, locale: .current)

// stop.name.lowercased().replace(" ", with: )
}

if sorting == .alphabetical {
let platforms: [ApiPlatform] = stops.flatMap(
\.platforms
)
let routes: [ApiRoute] = uniqueBy(
array: platforms.flatMap(\.routes),
by: \.id
).sorted(by: {
$0.name < $1.name
})

ForEach(routes, id: \.id) { route in
let routeName: String = route.name.uppercased()

Section(header: Text("Line \(routeName)")) {
MetroLineStopsView(
stops: filteredStops,
route: route
)
}
}
}

else {
let sorted = filteredStops.sorted(by: {
let distance0 = viewModel.location!.distance(
from: CLLocation(
latitude: $0.avgLatitude,
longitude: $0.avgLongitude
)
)
let distance1 = viewModel.location!.distance(
from: CLLocation(
latitude: $1.avgLatitude,
longitude: $1.avgLongitude
)
)

return distance0 < distance1

})

ForEach(sorted, id: \.id) { stop in
let routes = uniqueBy(
array: stop.platforms.flatMap(\.routes),
by: { $0.id }
)

HStack {
ForEach(routes, id: \.id) { route in
RouteNameIconView(
label: route.name,
background: getRouteColor(route.name)
)
}
Text(stop.name)
}
}
}

} else if let stops = viewModel.allStops {
let filteredStops = stops.filter { stop in
normalizeForSearch(searchText).isEmpty ||
normalizeForSearch(stop.name).contains(normalizeForSearch(searchText))
}

ForEach(filteredStops, id: \.id) { stop in
// let routes = uniqueBy(
// array: stop.platforms.flatMap({ $0.routes }),
// by: {$0.id}
// )

HStack {
Text(stop.name)
}
}
}
}
.searchable(
text: $searchText,
placement: .navigationBarDrawer(displayMode: .always)
)
.navigationTitle("Search")
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Menu {
Button(action: {
sorting = .distance
}) {
Text("Distance")
if sorting == .distance {
Image(systemName: "checkmark")
}
}
Button(action: {
sorting = .alphabetical
}) {
Text("A-Z")
if sorting == .alphabetical {
Image(systemName: "checkmark")
}
}
} label: {
Label("Sorting", systemImage: "arrow.up.arrow.down")
}
}
ToolbarItem(placement: .principal) {
Picker("Filter", selection: $filter) {
Text("Metro").tag(Filter.metro)
Text("All").tag(Filter.all)
}
.pickerStyle(.inline)
.frame(maxWidth: 300)
}
ToolbarItem(placement: .topBarTrailing) {
Button(
action: {
dismiss()
}
) {
Label("Close", systemImage: "xmark")
}
}
}
}
}
}

#Preview {
SearchPageView()
}