diff --git a/DevAcademy.xcodeproj/project.pbxproj b/DevAcademy.xcodeproj/project.pbxproj index a6622ef..b0fbf25 100644 --- a/DevAcademy.xcodeproj/project.pbxproj +++ b/DevAcademy.xcodeproj/project.pbxproj @@ -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 */; }; @@ -42,6 +43,7 @@ 84FA93B42A68316300DFC974 /* Place.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Place.swift; sourceTree = ""; }; 84FA93B62A68317F00DFC974 /* Point.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Point.swift; sourceTree = ""; }; 84FA93B82A68319800DFC974 /* Properties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Properties.swift; sourceTree = ""; }; + BE1E12582AA5C6CB000BA3D8 /* StoreadAsyncImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreadAsyncImage.swift; sourceTree = ""; }; BEEF9EC02A67C74E002126F4 /* PlacesService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlacesService.swift; sourceTree = ""; }; 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 = ""; }; @@ -164,6 +166,7 @@ isa = PBXGroup; children = ( C0CAEAC02A8017E7008D87C2 /* MapView.swift */, + BE1E12582AA5C6CB000BA3D8 /* StoreadAsyncImage.swift */, ); path = Views; sourceTree = ""; @@ -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 */, diff --git a/DevAcademy/Environment/ObservableObjects/PlacesObservableObject.swift b/DevAcademy/Environment/ObservableObjects/PlacesObservableObject.swift index d6ae300..234fc23 100644 --- a/DevAcademy/Environment/ObservableObjects/PlacesObservableObject.swift +++ b/DevAcademy/Environment/ObservableObjects/PlacesObservableObject.swift @@ -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) @@ -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) @@ -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 + } } diff --git a/DevAcademy/Library/Views/StoreadAsyncImage.swift b/DevAcademy/Library/Views/StoreadAsyncImage.swift new file mode 100644 index 0000000..c0442b4 --- /dev/null +++ b/DevAcademy/Library/Views/StoreadAsyncImage.swift @@ -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: 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() + } + } + } +} diff --git a/DevAcademy/Scenes/PlaceDetail/PlaceDetailView.swift b/DevAcademy/Scenes/PlaceDetail/PlaceDetailView.swift index c6b6cef..1cd1ef9 100644 --- a/DevAcademy/Scenes/PlaceDetail/PlaceDetailView.swift +++ b/DevAcademy/Scenes/PlaceDetail/PlaceDetailView.swift @@ -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 diff --git a/DevAcademy/Scenes/PlaceDetail/PlaceDetailViewState.swift b/DevAcademy/Scenes/PlaceDetail/PlaceDetailViewState.swift index 46be746..aa7a858 100644 --- a/DevAcademy/Scenes/PlaceDetail/PlaceDetailViewState.swift +++ b/DevAcademy/Scenes/PlaceDetail/PlaceDetailViewState.swift @@ -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 { + .init { + placesObject.favouritePlaces?.contains(place.attributes.ogcFid) ?? false + } set: { newValue in + placesObject.set(place: place, favourite: newValue) + } + } + var placeTitle: String { place.attributes.name } diff --git a/DevAcademy/Scenes/Places/PlaceRow.swift b/DevAcademy/Scenes/Places/PlaceRow.swift index 412def9..398eb9d 100644 --- a/DevAcademy/Scenes/Places/PlaceRow.swift +++ b/DevAcademy/Scenes/Places/PlaceRow.swift @@ -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)