Skip to content
Open
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
4 changes: 4 additions & 0 deletions DevAcademy.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
84FA93B52A68316300DFC974 /* Place.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FA93B42A68316300DFC974 /* Place.swift */; };
84FA93B72A68317F00DFC974 /* Point.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FA93B62A68317F00DFC974 /* Point.swift */; };
84FA93B92A68319800DFC974 /* Properties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FA93B82A68319800DFC974 /* Properties.swift */; };
BE1E12592AA5C6CB000BA3D8 /* StoreadAsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE1E12582AA5C6CB000BA3D8 /* StoreadAsyncImage.swift */; };
BEEF9EC12A67C74E002126F4 /* PlacesService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF9EC02A67C74E002126F4 /* PlacesService.swift */; };
C05610492A5C78CA007FB970 /* DevAcademyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C05610482A5C78CA007FB970 /* DevAcademyApp.swift */; };
C056104B2A5C78CA007FB970 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C056104A2A5C78CA007FB970 /* RootView.swift */; };
Expand All @@ -42,6 +43,7 @@
84FA93B42A68316300DFC974 /* Place.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Place.swift; sourceTree = "<group>"; };
84FA93B62A68317F00DFC974 /* Point.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Point.swift; sourceTree = "<group>"; };
84FA93B82A68319800DFC974 /* Properties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Properties.swift; sourceTree = "<group>"; };
BE1E12582AA5C6CB000BA3D8 /* StoreadAsyncImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreadAsyncImage.swift; sourceTree = "<group>"; };
BEEF9EC02A67C74E002126F4 /* PlacesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlacesService.swift; sourceTree = "<group>"; };
C05610452A5C78CA007FB970 /* DevAcademy.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DevAcademy.app; sourceTree = BUILT_PRODUCTS_DIR; };
C05610482A5C78CA007FB970 /* DevAcademyApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevAcademyApp.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -164,6 +166,7 @@
isa = PBXGroup;
children = (
C0CAEAC02A8017E7008D87C2 /* MapView.swift */,
BE1E12582AA5C6CB000BA3D8 /* StoreadAsyncImage.swift */,
);
path = Views;
sourceTree = "<group>";
Expand Down Expand Up @@ -277,6 +280,7 @@
84FA93B12A68313E00DFC974 /* PossibleKind.swift in Sources */,
C0CAEACB2A80220A008D87C2 /* Coordinator.swift in Sources */,
C0B74A2D2A8280C60018D10F /* ObservableObjects.swift in Sources */,
BE1E12592AA5C6CB000BA3D8 /* StoreadAsyncImage.swift in Sources */,
84FA93B72A68317F00DFC974 /* Point.swift in Sources */,
C056104B2A5C78CA007FB970 /* RootView.swift in Sources */,
C0B74A2C2A8280C60018D10F /* Environment.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,47 @@ import Foundation

final class PlacesObservableObject: ObservableObject {
@Published var places: [Place] = []


private(set) var favouritePlaces: [Int]? {
get { UserDefaults.standard.array(forKey: "favourites") as? [Int] }
set {
UserDefaults.standard.set(newValue, forKey: "favourites")
updatePlaces()
}
}

private var rawPlaces: [Place] = [] {
didSet { updatePlaces() }
}
private let placesService: PlacesService

init(placesService: PlacesService) {
self.placesService = placesService
}


func set(place: Place, favourite setFavourite: Bool) {
var favouritePlaces = self.favouritePlaces ?? []
let currentIndex = favouritePlaces.firstIndex(of: place.attributes.ogcFid)

switch (setFavourite, currentIndex) {
case (true, nil):
favouritePlaces.append(place.attributes.ogcFid)
case (false, let index?):
favouritePlaces.remove(at: index)
default:
return
}

self.favouritePlaces = favouritePlaces
}

// A. Closure variant
func fetchPlaces() {
placesService.places { result in
switch result {
case .success(let places):
DispatchQueue.main.async {
self.places = places.places
self.rawPlaces = places.places
}
case .failure(let error):
print(error)
Expand All @@ -29,7 +56,7 @@ final class PlacesObservableObject: ObservableObject {
switch result {
case .success(let places):
DispatchQueue.main.async {
self.places = places.places
self.rawPlaces = places.places
}
case .failure(let error):
print(error)
Expand All @@ -41,9 +68,26 @@ final class PlacesObservableObject: ObservableObject {
func fetchPlacesWithAsync() async {
do {
let placesResult = try await placesService.placesWithAsync()
self.places = placesResult.places
self.rawPlaces = placesResult.places
} catch {
print(error)
}
}

private func updatePlaces() {
var regularPlaces = rawPlaces
var presentOnTop: [Place] = []
let favouritePlaces = self.favouritePlaces ?? []

regularPlaces.removeAll { place in
if favouritePlaces.contains(place.attributes.ogcFid) {
presentOnTop.append(place)
return true
} else {
return false
}
}

self.places = presentOnTop + regularPlaces
}
}
153 changes: 153 additions & 0 deletions DevAcademy/Library/Views/StoreadAsyncImage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
//
// StoreadAsyncImage.swift
// DevAcademy
//
// Created by Mikoláš Stuchlík on 04.09.2023.
//

import SwiftUI
import CryptoKit

private class ImageStorage {
static let shared: ImageStorage = ImageStorage()

/// Select an folder in this application bundle. We want ideally an "Application Support" directory. In this directory, we want to store our images in subdirectory called "imageCache",
private let defaultPath: URL = FileManager.default.urls(for: .applicationDirectory, in: .userDomainMask).first!.appendingPathComponent("imageCache")

/// Initializer first checks, whether folder for `defaultPath` exists. If not, it creates a new one.
init() {
if !FileManager.default.fileExists(atPath: defaultPath.path(percentEncoded: false)) {
try? FileManager.default.createDirectory(atPath: defaultPath.path(percentEncoded: false), withIntermediateDirectories: true)
}
}


/// Takes an URL as a input and produces SHA256 String of the URL as the output.
///
/// We don't use protocol `Hashable` from two reasons. Firstly, the Apple Documentation explicitly forbids to use `Hashable` and `hashValue` for any purpose related to persistence. The hashes are different at each execution for security reasons. Secondly, the 64-bit `Int` is just too short.
///
/// - Parameter url: URL to be hashed
/// - Returns: Hashed string
private func hash(of url: URL) -> String {
let path = url.description.data(using: .utf8)!
return SHA256.hash(data: path).compactMap { String(format: "%02x", $0) }.joined()
}


/// Check, whether file is already cached and if so, returns an Image.
///
/// The function should have following behavior:
/// - Hash the URL
/// - Check, whether file names by this hash already exists
/// - If so, load it and return it as an Image
///
/// - Parameter url: The URL of the request that would be executed upon the server.
/// - Returns: Image if exists.
func loadImage(for url: URL) -> Image? {
let hashOfRemoteURL = hash(of: url)

guard
let data = try? Data(contentsOf: defaultPath.appendingPathComponent(hashOfRemoteURL)),
let image = UIImage(data: data)
else {
return nil
}

return Image(uiImage: image)
}


/// Updates image in the cache.
///
/// The function should have following behavior:
/// - Hash the URL
/// - Remove existring file (if exists)
/// - Create a binary data from the image
/// - Save the binary data to the file
///
/// - Parameters:
/// - image: Image to be stored
/// - url: The URL that was executed upon the server to get this image.
func update(image: UIImage, at url: URL) {
let hashOfRemoteURL = hash(of: url)

if FileManager.default.fileExists(atPath: defaultPath.appendingPathComponent(hashOfRemoteURL).path(percentEncoded: false)) {
try? FileManager.default.removeItem(at: defaultPath.appendingPathComponent(hashOfRemoteURL))
}

guard let bytes = image.jpegData(compressionQuality: 1.0) else { return }
try? bytes.write(to: defaultPath.appendingPathComponent(hashOfRemoteURL))
}
}

enum StoredAsyncImageError: Error {
case decodingFailed
}

struct StoredAsyncImage<I: View, P: View>: View {

@State private var image: Image?

private let url: URL?
private let imageBuilder: (Image) -> I
private let placeholderBuilder: () -> P


init(url: URL?, image: @escaping (Image) -> I, placeholder: @escaping () -> P) {
self.url = url
self.imageBuilder = image
self.placeholderBuilder = placeholder
}

private func performURLFetch(url: URL) async throws -> (UIImage, Image) {
let (data, _) = try await URLSession.shared.data(from: url)
guard let uiimage = UIImage(data: data) else {
throw StoredAsyncImageError.decodingFailed
}
let image = Image(uiImage: uiimage)

return (uiimage, image)
}

/// Look into the image cache, whether image is already downloaded.
///
/// If so, store it in `image` state variable.
/// If not, download the image via `performURLFetch()` function, store it in the cache and in the `image` state vriable.
private func loadImage() async {
guard let url else { return }

if let image = ImageStorage.shared.loadImage(for: url) {
self.image = image
return
}

do {
let (uiimage, image) = try await performURLFetch(url: url)
ImageStorage.shared.update(image: uiimage, at: url)
self.image = image
} catch {
switch error {
case _ where (error as NSError).code == NSURLErrorCancelled:
break
default:
print("Unrecognized error: \(error)")
}
}

}

/// The body should only show one of either states:
///
/// If `image` state variable is filled, present image using `imageBuilder`
/// If `image` state variable is empty, present `placeholder` and execute `loadImage()` function in the `.task` modifier.
var body: some View {
if let image {
imageBuilder(image)
} else {
placeholderBuilder()
.task {
await loadImage()
}
}
}
}
20 changes: 14 additions & 6 deletions DevAcademy/Scenes/PlaceDetail/PlaceDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,20 @@ struct PlaceDetailView: View {
var body: some View {
ScrollView {
VStack {
Text(state.placeType)
.font(.title2)
.fontWeight(.medium)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding([.leading, .bottom])
HStack(alignment: .center) {
Text(state.placeType)
.font(.title2)
.fontWeight(.medium)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
Button {
state.isFavourite.wrappedValue.toggle()
} label: {
Image(systemName: state.isFavourite.wrappedValue ? "heart.fill" : "heart")
.foregroundColor(Color.red)
}
}
.padding([.horizontal, .bottom])
if let placeImageUrl = state.placeImageUrl {
AsyncImage(url: placeImageUrl) { image in
image
Expand Down
14 changes: 12 additions & 2 deletions DevAcademy/Scenes/PlaceDetail/PlaceDetailViewState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,22 @@ import SwiftUI
import MapKit

struct PlaceDetailViewState: DynamicProperty {
@EnvironmentObject private var placesObject: PlacesObservableObject

private let place: Place

init(place: Place) {
self.place = place
}


var isFavourite: Binding<Bool> {
.init {
placesObject.favouritePlaces?.contains(place.attributes.ogcFid) ?? false
} set: { newValue in
placesObject.set(place: place, favourite: newValue)
}
}

var placeTitle: String {
place.attributes.name
}
Expand Down
2 changes: 1 addition & 1 deletion DevAcademy/Scenes/Places/PlaceRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ struct PlaceRow: View {

var body: some View {
HStack {
AsyncImage(url: place.attributes.imageUrl) { image in
StoredAsyncImage(url: place.attributes.imageUrl) { image in
image
.resizable()
.aspectRatio(contentMode: .fill)
Expand Down