@@ -20,7 +20,6 @@ struct SolidButton_Previews: PreviewProvider {
static var previews: some View {
Button("Button title") {}
.buttonStyle(SolidButton())
.previewLayout(PreviewLayout.sizeThatFits)
.padding()
.autosizedPreview()
}
}

This file was deleted.

This file was deleted.

@@ -0,0 +1,27 @@
import MapKit
import SwiftUI

class TreeAnnotation: NSObject {
let coordinate: CLLocationCoordinate2D
let tree: Tree
let isVisited: Bool

init?(tree: Tree, isVisited: Bool) {
guard let coordinate = tree.coordinate else { return nil }
self.coordinate = coordinate
self.tree = tree
self.isVisited = isVisited
}
}

extension TreeAnnotation: MKAnnotation {
var title: String? { "Tree #\(tree.id)" }
var subtitle: String? { tree.commonName }
}

extension TreeAnnotation: Annotation {
var identifier: Int { tree.id }
var tintColor: UIColor { isVisited ? color.withAlphaComponent(0.5) : color }

private var color: UIColor { UIColor(Color.accentColor) }
}

This file was deleted.

@@ -1,46 +1,35 @@
import Foundation

class TreeViewModel: ObservableObject, Identifiable {
@Published var isVisited: Bool

let tree: Tree
var address: String? { tree.properties.siteAddress }
var commonName: String { tree.properties.common }
var height: String? { "\(tree.properties.height) ft" }
var id: String { String(tree.properties.treeID) }
var neighborhood: String? { tree.properties.neighborhood?.capitalized }
var notes: String? { tree.properties.notes }
var scientificName: String { tree.properties.scientific }
var treeFact: String? { tree.properties.treeFactLong }
var address: String? { tree.address }
var commonName: String { tree.commonName }
var height: String? { "\(tree.height) ft" }
var id: Int { tree.id }
var neighborhood: String? { tree.neighborhood?.capitalized }
var notes: String? { tree.notes }
var scientificName: String { tree.scientificName }
var treeFact: String? { tree.fact }
var uniqueName: String { "Tree #\(id)" }
var visitedButtonImageName: String { isVisited ? "checkmark.circle.fill" : "checkmark.circle" }
var visitedButtonText: String { isVisited ? "Visited" : "Mark as visited" }
var wikipediaURL: URL? { WikipediaURL.search(querying: commonName) }

var circumference: String? {
guard let circumference = tree.properties.circumf else { return nil }
guard let circumference = tree.circumference else { return nil }
return "\(circumference) in"
}

var diameter: String? {
guard let diameter = tree.properties.diameter else { return nil }
guard let diameter = tree.diameter else { return nil }
return "\(diameter) in"
}

var spread: String? {
guard let spread = tree.properties.spread else { return nil }
guard let spread = tree.spread else { return nil }
return "\(spread) ft"
}

init(tree: Tree) {
self.tree = tree
self.isVisited = Persistence.isTreeVisited(tree)
}

func toggleVisited() {
let isVisited = !Persistence.isTreeVisited(tree)
Persistence.setTree(tree, visited: isVisited)
self.isVisited = isVisited
}
}

@@ -0,0 +1,10 @@
class VisitableTreeViewModel: TreeViewModel {
let isVisited: Bool
var visitedButtonImageName: String { isVisited ? "checkmark.circle.fill" : "checkmark.circle" }
var visitedButtonText: String { isVisited ? "Visited" : "Mark as visited" }

init(tree: Tree, isVisited: Bool) {
self.isVisited = isVisited
super.init(tree: tree)
}
}
@@ -2,9 +2,12 @@ import SwiftUI

@main
struct Portland_Heritage_TreesApp: App {
private let environment = Environment.remote

var body: some Scene {
WindowGroup {
HomeView(environment: .remote)
HomeView()
.environmentObject(environment.store)
}
}
}
@@ -0,0 +1,24 @@
import SwiftUI

struct AttributeRow: View {
let name: String
let value: String?

var body: some View {
if let value = value {
VStack(alignment: .leading) {
Text(name)
.foregroundColor(.mutedText)
.font(.caption)
Text(value)
}
}
}
}

struct AttributeRow_Previews: PreviewProvider {
static var previews: some View {
AttributeRow(name: "Height", value: "20 meters")
.autosizedPreview()
}
}
@@ -0,0 +1,26 @@
import SwiftUI

struct DimensionGroup: View {
let dimensions: [Dimension]

var body: some View {
VStack(alignment: .leading, spacing: 16) {
ForEach(dimensions, id: \.name) { dimension in
if let value = dimension.value {
DimensionView(name: dimension.name, value: value)
}
}
}
}
}

struct DimensionGroup_Previews: PreviewProvider {
static var previews: some View {
DimensionGroup(dimensions: [
Dimension(name: "Height", value: "205 ft"),
Dimension(name: "Missing value", value: nil),
Dimension(name: "Spread", value: "80 ft"),
])
.autosizedPreview()
}
}
@@ -0,0 +1,35 @@
import SwiftUI

struct DimensionView: View {
let name: String
let value: String

var body: some View {
HStack(spacing: 16) {
Image(name)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 28)
.padding(10)
.foregroundColor(.white)
.background(Color.accentColor)
.cornerRadius(10)
VStack(alignment: .leading) {
Text(name)
.font(.caption)
.bold()
.foregroundColor(.mutedText)
Text(value)
.font(.title2)
.bold()
}
}
}
}

struct DimensionView_Previews: PreviewProvider {
static var previews: some View {
DimensionView(name: "Height", value: "85 ft")
.autosizedPreview()
}
}
@@ -1,32 +1,24 @@
import SwiftUI

struct HomeView: View {
@ObservedObject var viewModel: TreeListViewModel

@EnvironmentObject private var store: TreeStore
@State private var showingMap = false

init(environment: Environment) {
self.viewModel = TreeListViewModel(apiSession: environment.apiSession)
}

var body: some View {
NavigationView {
Group {
if showingMap {
TreeMapListView(
viewModel: viewModel,
isShowing: $showingMap
)
.navigationBarItems(trailing: listButton)
TreeMapView()
.navigationBarItems(trailing: listButton)
} else {
TreeListView(viewModel: viewModel)
TreeListView()
.navigationBarItems(trailing: mapButton)
}
}
.navigationBarTitle("Heritage Trees", displayMode: .inline)
}
.onAppear {
self.viewModel.getTrees()
store.getTrees()
}
}

@@ -45,6 +37,7 @@ struct HomeView: View {

struct HomeView_Previews: PreviewProvider {
static var previews: some View {
HomeView(environment: .local)
HomeView()
.environmentObject(TreeStore(apiService: Environment.local.apiService))
}
}
@@ -1,13 +1,18 @@
import MapKit
import SwiftUI

private let AnnotationViewIdentifier = "Heritage Tree Annotation View"
let AnnotationViewIdentifier = "Annotation View"

struct MapView: UIViewRepresentable {
let annotations: [Annotation?]
var didSelectAnnotation: ((MKAnnotation) -> Void)?
public struct MapView: UIViewRepresentable {
var annotations: [Annotation]
var didSelectAnnotation: ((Annotation) -> Void)?

func makeUIView(context: Context) -> MKMapView {
public init(annotations: [Annotation], didSelectAnnotation: ((Annotation) -> Void)?) {
self.annotations = annotations
self.didSelectAnnotation = didSelectAnnotation
}

public func makeUIView(context: Context) -> MKMapView {
let mapView = MKMapView()
mapView.delegate = context.coordinator
mapView.showsUserLocation = true
@@ -16,36 +21,23 @@ struct MapView: UIViewRepresentable {
return mapView
}

func updateUIView(_ uiView: MKMapView, context: Context) {}

func makeCoordinator() -> Coordinator {
Coordinator(self)
}

class Coordinator: NSObject, MKMapViewDelegate {
var parent: MapView

init(_ parent: MapView) {
self.parent = parent
}

func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl) {
if let annotation = view.annotation {
parent.didSelectAnnotation?(annotation)
// TODO: Move to a different object.
public func updateUIView(_ mapView: MKMapView, context: Context) {
let existing = mapView.annotations.compactMap { $0 as? Annotation }.sorted(by: { $0.identifier < $1.identifier })
let diff = annotations.sorted(by: { $0.identifier < $1.identifier }).difference(from: existing) { $0 === $1 }
for change in diff {
switch change {
case .insert(_, let element, _): mapView.addAnnotation(element)
case .remove(_, let element, _): mapView.removeAnnotation(element)
}
}
}

func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
guard !(annotation is MKUserLocation) else { return nil }

let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: AnnotationViewIdentifier, for: annotation)
(annotationView as? PinAnnotationView)?.pinTintColor = (annotation as? Annotation)?.tintColor
return annotationView
}
public func makeCoordinator() -> Coordinator {
Coordinator(self)
}

private func addAnnotations(to mapView: MKMapView) {
let annotations = self.annotations.compactMap { $0 }
mapView.addAnnotations(annotations)
mapView.showAnnotations(annotations, animated: false)
}
@@ -0,0 +1,36 @@
import SwiftUI

struct NameView: View {
let url: URL?
let title: String
let subtitle: String

@State private var showingSafari = false

var body: some View {
if let url = url {
SafariButton(url: url) {
content
}
} else {
content
}
}

private var content: some View {
VStack(alignment: .leading) {
Text(title)
.font(.title)
Text(subtitle)
.font(.title3)
.foregroundColor(.mutedText)
}
}
}

struct TreeNameView_Previews: PreviewProvider {
static var previews: some View {
NameView(url: URL.example, title: "American Elm", subtitle: "Ulmus americana")
.autosizedPreview()
}
}
@@ -0,0 +1,36 @@
import SwiftUI

struct SafariButton<Content: View>: View {
let content: Content
let url: URL

@State private var showingSafari = false

init(url: URL, @ViewBuilder content: () -> Content) {
self.url = url
self.content = content()
}

var body: some View {
Button(action: { showingSafari = true }) {
HStack {
content
Spacer()
Image(systemName: "arrow.up.forward.app")
.foregroundColor(.gray)
}
}
.sheet(isPresented: $showingSafari) {
SafariView(url: url)
}
}
}

struct SafariButton_Previews: PreviewProvider {
static var previews: some View {
SafariButton(url: URL.example) {
Text("Show me the Safari")
}
.autosizedPreview()
}
}
@@ -0,0 +1,30 @@
import SwiftUI

struct ToggleVisitedTreeButton: View {
let tree: Tree

@EnvironmentObject private var store: TreeStore

private var viewModel: VisitableTreeViewModel {
VisitableTreeViewModel(tree: tree, isVisited: store.isVisited(tree: tree))
}

var body: some View {
Button(action: { store.toggleTreeIsVisited(tree) }) {
Image(systemName: viewModel.visitedButtonImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 24)
Text(viewModel.visitedButtonText)
.bold()
}
.buttonStyle(SolidButton())
}
}

struct ToggleVisitedTreeButton_Previews: PreviewProvider {
static var previews: some View {
ToggleVisitedTreeButton(tree: Tree.preview)
.autosizedPreview()
}
}
@@ -1,100 +1,57 @@
import MapKit
import SwiftUI

struct TreeDetailView: View {
@ObservedObject var viewModel: TreeViewModel
let tree: Tree

@EnvironmentObject private var store: TreeStore
private var viewModel: VisitableTreeViewModel {
VisitableTreeViewModel(tree: tree, isVisited: store.isVisited(tree: tree))
}

var body: some View {
ZStack {
VStack {
if let coordinate = viewModel.coordinate {
TreeMapView(coordinate: coordinate, viewModel: viewModel)
.frame(height: 200)
}
MapView(tree: tree)

List {
TreeNameView(viewModel: viewModel)
NameView(url: viewModel.wikipediaURL, title: tree.commonName, subtitle: tree.scientificName)
.padding(.vertical, 8)
LocationRow(viewModel: viewModel)
TreeLocationRow(tree: tree)
.padding(.vertical, 8)
TreeDimensionsView(viewModel: viewModel)
TreeDimensionsView(height: viewModel.height, spread: viewModel.spread, diameter: viewModel.diameter, circumference: viewModel.circumference)
.padding(.vertical, 8)

AttributeRow(name: "Notes", value: viewModel.notes)
.padding(.vertical, 8)
AttributeRow(name: "Tree fact", value: viewModel.treeFact)
.padding(.vertical, 8)
.padding(.bottom, 64)
}
}

VStack {
Spacer()
toggleVisitedButton
ToggleVisitedTreeButton(tree: tree)
}
}
.navigationBarTitle(viewModel.uniqueName, displayMode: .inline)
}

private var toggleVisitedButton: some View {
Button(action: { viewModel.toggleVisited() }) {
Image(systemName: viewModel.visitedButtonImageName)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 24)
Text(viewModel.visitedButtonText)
.bold()
}
.buttonStyle(SolidButton())
}
}

private struct LocationRow: View {
let viewModel: TreeViewModel
extension TreeDetailView {
struct MapView: View {
let tree: Tree

var body: some View {
if let coordinate = viewModel.coordinate {
NavigationLink(destination: mapView(coordinate: coordinate)) {
content
}
} else {
content
}
}

private var content: some View {
VStack(alignment: .leading) {
if let address = viewModel.address {
Text(address)
}
if let neighborhood = viewModel.neighborhood {
Text(neighborhood)
}
}
}

private func mapView(coordinate: CLLocationCoordinate2D) -> some View {
TreeMapView(coordinate: coordinate, viewModel: viewModel)
.navigationTitle(viewModel.address ?? "")
}
}

private struct AttributeRow: View {
let name: String
let value: String?

var body: some View {
if let value = value {
VStack(alignment: .leading) {
Text(name)
.foregroundColor(.mutedText)
.font(.caption)
Text(value)
var body: some View {
if let coordinate = tree.coordinate {
TreeMapDetailView(coordinate: coordinate, tree: tree)
.frame(height: 200)
}
}
}
}

struct TreeDetailView_Previews: PreviewProvider {
static var previews: some View {
TreeDetailView(viewModel: TreeViewModel.preview)
TreeDetailView(tree: Tree.preview)
}
}
@@ -1,65 +1,32 @@
import SwiftUI

struct TreeDimensionsView: View {
let viewModel: TreeViewModel
let height: String?
let spread: String?
let diameter: String?
let circumference: String?

var body: some View {
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 16) {
if let height = viewModel.height {
DimensionView(name: "Height", value: height)
}
if let spread = viewModel.spread {
DimensionView(name: "Spread", value: spread)
}
}
DimensionGroup(dimensions: [
Dimension(name: "Height", value: height),
Dimension(name: "Spread", value: spread)
])

Spacer()

VStack(alignment: .leading, spacing: 16) {
if let diameter = viewModel.diameter {
DimensionView(name: "Diameter", value: diameter)
}
if let circumference = viewModel.circumference {
DimensionView(name: "Circumference", value: circumference)
}
}
DimensionGroup(dimensions: [
Dimension(name: "Diameter", value: diameter),
Dimension(name: "Circumference", value: circumference)
])
}
.padding(.trailing)
}
}

private struct DimensionView: View {
let name: String
let value: String

var body: some View {
HStack(spacing: 16) {
Image(name)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(height: 28)
.padding(10)
.foregroundColor(.white)
.background(Color.accentColor)
.cornerRadius(10)
VStack(alignment: .leading) {
Text(name)
.font(.caption)
.bold()
.foregroundColor(.mutedText)
Text(value)
.font(.title2)
.bold()
}
}
}
}

struct TreeDimensionsView_Previews: PreviewProvider {
static var previews: some View {
TreeDimensionsView(viewModel: TreeViewModel.preview)
.previewLayout(PreviewLayout.sizeThatFits)
.padding()
TreeDimensionsView(height: "85 ft", spread: "107 ft", diameter: "48 in", circumference: "12.8 in")
.autosizedPreview()
}
}
@@ -1,11 +1,16 @@
import SwiftUI

struct TreeListItem: View {
@ObservedObject var viewModel: TreeViewModel
let tree: Tree

@EnvironmentObject private var store: TreeStore
private var viewModel: VisitableTreeViewModel {
VisitableTreeViewModel(tree: tree, isVisited: store.isVisited(tree: tree))
}

var body: some View {
HStack {
Text(viewModel.id)
Text(String(viewModel.id))
Text(viewModel.commonName)
Spacer()

@@ -18,21 +23,8 @@ struct TreeListItem: View {
}

struct TreeListItem_Previews: PreviewProvider {
static var visitedTreeViewModel: TreeViewModel {
let viewModel = TreeViewModel.preview
viewModel.isVisited = true
return viewModel
}

static var previews: some View {
Group {
TreeListItem(viewModel: TreeViewModel.preview)
.previewLayout(PreviewLayout.sizeThatFits)
.padding()

TreeListItem(viewModel: visitedTreeViewModel)
.previewLayout(PreviewLayout.sizeThatFits)
.padding()
}
TreeListItem(tree: Tree.preview)
.autosizedPreview()
}
}
@@ -1,20 +1,21 @@
import SwiftUI

struct TreeListView: View {
@ObservedObject var viewModel: TreeListViewModel
@EnvironmentObject private var store: TreeStore

var body: some View {
List(viewModel.treeViewModels.indices, id: \.self) { index in
NavigationLink(destination: TreeDetailView(viewModel: viewModel.treeViewModels[index])) {
TreeListItem(viewModel: viewModel.treeViewModels[index])
List(store.trees) { tree in
NavigationLink(destination: TreeDetailView(tree: tree)) {
TreeListItem(tree: tree)
}
}
.listStyle(PlainListStyle())
}
}

struct TreeListView_Previews: PreviewProvider {
struct TreeListView_Previews: PreviewProvider {
static var previews: some View {
TreeListView(viewModel: TreeListViewModel.preview)
TreeListView()
.environmentObject(Environment.local.store)
}
}
}
@@ -0,0 +1,41 @@
import MapKit
import SwiftUI

struct TreeLocationRow: View {
let tree: Tree

private var viewModel: TreeViewModel { TreeViewModel(tree: tree) }

var body: some View {
if let coordinate = tree.coordinate {
NavigationLink(destination: mapView(coordinate: coordinate)) {
content
}
} else {
content
}
}

private var content: some View {
VStack(alignment: .leading) {
if let address = viewModel.address {
Text(address)
}
if let neighborhood = viewModel.neighborhood {
Text(neighborhood)
}
}
}

private func mapView(coordinate: CLLocationCoordinate2D) -> some View {
TreeMapDetailView(coordinate: coordinate, tree: tree)
.navigationTitle(viewModel.address ?? "")
}
}

struct TreeLocationRow_Previews: PreviewProvider {
static var previews: some View {
TreeLocationRow(tree: Tree.preview)
.autosizedPreview()
}
}

This file was deleted.

@@ -0,0 +1,25 @@
import MapKit
import SwiftUI

struct TreeMapDetailView: View {
let coordinate: CLLocationCoordinate2D
let tree: Tree

private let locationManager = LocationManager()

@State private var region = MKCoordinateRegion(
center: CLLocationCoordinate2D.northWestNeighborhood,
span: MKCoordinateSpan(latitudeDelta: 0.005, longitudeDelta: 0.005)
)

var body: some View {
Map(coordinateRegion: $region, interactionModes: .all, showsUserLocation: true, userTrackingMode: nil, annotationItems: [tree]) { _ in
MapPin(coordinate: coordinate, tint: .accentColor)
}
.ignoresSafeArea(.all)
.onAppear {
region.center = coordinate
locationManager.rerequestAuthorization()
}
}
}

This file was deleted.

@@ -2,32 +2,32 @@ import MapKit
import SwiftUI

struct TreeMapView: View {
let coordinate: CLLocationCoordinate2D
let viewModel: TreeViewModel

@EnvironmentObject private var store: TreeStore
@State private var isActive = false
@State private var selectedAnnotation: TreeAnnotation?
private let locationManager = LocationManager()

@State private var region = MKCoordinateRegion(
center: CLLocationCoordinate2D.northWestNeighborhood,
span: MKCoordinateSpan(latitudeDelta: 0.005, longitudeDelta: 0.005)
span: MKCoordinateSpan(latitudeDelta: 0.15, longitudeDelta: 0.15)
)

var body: some View {
Map(coordinateRegion: $region, interactionModes: .all, showsUserLocation: true, userTrackingMode: nil, annotationItems: [viewModel]) { _ in
MapPin(coordinate: coordinate, tint: .accentColor)
Group {
MapView(annotations: store.treeAnnotations) { annotation in
selectedAnnotation = annotation as? TreeAnnotation
isActive = true
}
.ignoresSafeArea(.all)

if let tree = selectedAnnotation?.tree {
NavigationLink(destination: TreeDetailView(tree: tree), isActive: $isActive) {
EmptyView()
}
}
}
.ignoresSafeArea(.all)
.onAppear {
region.center = coordinate
locationManager.rerequestAuthorization()
locationManager.requestAuthorization()
}
}
}

struct TreeMapView_Previews: PreviewProvider {
static var previews: some View {
TreeMapView(coordinate: CLLocationCoordinate2D.northWestNeighborhood, viewModel: TreeViewModel.preview)
.previewLayout(PreviewLayout.sizeThatFits)
.padding()
}
}

This file was deleted.

Large diffs are not rendered by default.