├── .gitignore
├── ImageSearch.xcodeproj
├── project.pbxproj
├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ ├── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
│ └── xcuserdata
│ │ └── ds.xcuserdatad
│ │ └── UserInterfaceState.xcuserstate
├── xcshareddata
│ └── xcschemes
│ │ └── ImageSearch.xcscheme
└── xcuserdata
│ └── ds.xcuserdatad
│ ├── xcdebugger
│ └── Breakpoints_v2.xcbkptlist
│ └── xcschemes
│ └── xcschememanagement.plist
├── ImageSearch
├── Common
│ ├── AppConfiguration.swift
│ ├── AppDelegate.swift
│ └── Utils
│ │ ├── Extensions.swift
│ │ ├── Supportive.swift
│ │ └── SwiftEvents.swift
├── Coordinator
│ ├── AppCoordinator.swift
│ ├── DIContainer
│ │ └── DIContainer.swift
│ └── FlowCoordinator.swift
├── Data
│ ├── APIs
│ │ └── FlickrAPI.swift
│ ├── Network
│ │ ├── APIInteractor
│ │ │ ├── APIInteractor.swift
│ │ │ └── URLSessionAPIInteractor.swift
│ │ ├── NetworkService
│ │ │ └── NetworkService.swift
│ │ └── Utils
│ │ │ ├── EncodeDecode.swift
│ │ │ ├── Endpoint.swift
│ │ │ ├── HTTPMethod.swift
│ │ │ ├── HTTPParams.swift
│ │ │ └── RequestFactory.swift
│ ├── Persistence
│ │ ├── DBInteractor
│ │ │ └── Image
│ │ │ │ ├── ImageDBInteractor.swift
│ │ │ │ └── SQLiteImageDBInteractor.swift
│ │ └── SQLite
│ │ │ ├── SQLTable.swift
│ │ │ └── SQLite.swift
│ └── Repositories
│ │ ├── DefaultImageRepository.swift
│ │ └── DefaultTagRepository.swift
├── Domain
│ ├── Entities
│ │ ├── Behaviors
│ │ │ └── ImageBehavior.swift
│ │ ├── Image.swift
│ │ ├── ImageQuery.swift
│ │ ├── ImageSearchResults.swift
│ │ ├── ImageWrapper.swift
│ │ ├── Tag.swift
│ │ └── Tags.swift
│ ├── Exception
│ │ └── CustomError.swift
│ ├── Interfaces
│ │ └── Repositories
│ │ │ ├── ImageRepository.swift
│ │ │ └── TagRepository.swift
│ ├── Services
│ │ └── ImageCachingService.swift
│ └── UseCases
│ │ ├── GetBigImageUseCase.swift
│ │ ├── GetHotTagsUseCase.swift
│ │ └── SearchImagesUseCase.swift
├── Info.plist
├── Presentation
│ ├── Common
│ │ └── Protocols
│ │ │ ├── Alertable.swift
│ │ │ └── Storyboarded.swift
│ └── ImagesFeature
│ │ ├── HotTags
│ │ ├── View
│ │ │ ├── SwiftUI
│ │ │ │ ├── HotTagsView.swift
│ │ │ │ └── HotTagsViewModelBridgeWrapper.swift
│ │ │ └── UIKit
│ │ │ │ ├── HotTags.storyboard
│ │ │ │ ├── HotTagsViewController.swift
│ │ │ │ └── TagsDataSource.swift
│ │ └── ViewModel
│ │ │ └── DefaultHotTagsViewModel.swift
│ │ ├── ImageDetails
│ │ ├── View
│ │ │ ├── ImageDetails.storyboard
│ │ │ └── ImageDetailsViewController.swift
│ │ └── ViewModel
│ │ │ └── DefaultImageDetailsViewModel.swift
│ │ ├── ImageSearch
│ │ ├── View
│ │ │ ├── Cells
│ │ │ │ ├── CollectionViewCell.swift
│ │ │ │ └── CollectionViewHeader.swift
│ │ │ ├── ImageSearch.storyboard
│ │ │ ├── ImageSearchViewController.swift
│ │ │ └── ImagesDataSource.swift
│ │ └── ViewModel
│ │ │ └── DefaultImageSearchViewModel.swift
│ │ └── MainCoordinator.swift
└── Resources
│ ├── Assets.xcassets
│ ├── AppIcon.appiconset
│ │ └── Contents.json
│ └── Contents.json
│ ├── Base.lproj
│ └── LaunchScreen.storyboard
│ ├── Toast.swift
│ ├── UAObfuscatedString.swift
│ ├── en.lproj
│ ├── LaunchScreen.strings
│ └── Localizable.strings
│ └── es.lproj
│ ├── LaunchScreen.strings
│ └── Localizable.strings
├── ImageSearchTests
├── Data
│ ├── FlickrAPITests.swift
│ ├── NetworkServiceTests.swift
│ └── SQLiteTests.swift
├── Domain
│ ├── Behaviors
│ │ └── ImageBehaviorTests.swift
│ └── UseCases
│ │ ├── ImageCachingServiceTests.swift
│ │ └── ImagesFeatureUseCasesTests.swift
├── Info.plist
└── Presentation
│ ├── HotTagsViewModelTests.swift
│ ├── ImageDetailsViewModelTests.swift
│ └── ImageSearchViewModelTests.swift
├── LICENSE
├── README.md
└── Screenshots
├── 1_iOS-MVVM-Clean-Architecture.png
├── 2_iOS-MVVM-Clean-Architecture.png
└── 3_iOS-MVVM-Clean-Architecture.png
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 |
--------------------------------------------------------------------------------
/ImageSearch.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/ImageSearch.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/ImageSearch.xcodeproj/project.xcworkspace/xcuserdata/ds.xcuserdatad/UserInterfaceState.xcuserstate:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/denissimon/iOS-MVVM-Clean-Architecture/5fb12c53eaf99c07b73e03b9a7623dd3feb6bc9e/ImageSearch.xcodeproj/project.xcworkspace/xcuserdata/ds.xcuserdatad/UserInterfaceState.xcuserstate
--------------------------------------------------------------------------------
/ImageSearch.xcodeproj/xcshareddata/xcschemes/ImageSearch.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
33 |
39 |
40 |
41 |
42 |
43 |
53 |
55 |
61 |
62 |
63 |
64 |
70 |
72 |
78 |
79 |
80 |
81 |
83 |
84 |
87 |
88 |
89 |
--------------------------------------------------------------------------------
/ImageSearch.xcodeproj/xcuserdata/ds.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
--------------------------------------------------------------------------------
/ImageSearch.xcodeproj/xcuserdata/ds.xcuserdatad/xcschemes/xcschememanagement.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | SchemeUserState
6 |
7 | ImageSearch.xcscheme_^#shared#^_
8 |
9 | orderHint
10 | 3
11 |
12 |
13 | SuppressBuildableAutocreation
14 |
15 | 4111D55D23FDAEFF00B74B83
16 |
17 | primary
18 |
19 |
20 | 4111D57323FDAF0200B74B83
21 |
22 | primary
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/ImageSearch/Common/AppConfiguration.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | struct AppConfiguration {
4 |
5 | struct ProductionServer {
6 | static let flickrBaseURL = "https://api.flickr.com/services/rest/"
7 | static let flickrApiKey = ""._8.c.a._5._5.b.c.a._1._3._8._4.f._4._5.a.b._9._5._7.b._7._6._1._8.a.f.c._6.e.c.c // "8ca55bca1384f45ab957b7618afc6ecc"
8 | static let photosPerRequest = 20 // up to 20 photos per search
9 | static let hotTagsCount = 50 // up to 50 trending tags for the week
10 | }
11 |
12 | struct ImageCollection {
13 | static let baseImageWidth: Float = 214
14 | static let itemsPerRowInVertOrient: CGFloat = 2
15 | static let itemsPerRowInHorizOrient: CGFloat = 3
16 | static let verticleSpace: CGFloat = 35
17 | static let horizontalSpace: CGFloat = 20
18 | }
19 |
20 | struct MemorySafety {
21 | static var enabled = true
22 | static var cacheAfterSearches = 3
23 | }
24 |
25 | struct SQLite {
26 | static let imageSearchDBPath = try! (FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false).appendingPathComponent("image_search.sqlite")).path
27 | }
28 |
29 | struct Other {
30 | static let toastDuration: TimeInterval = 4.0
31 | static let allTimesHotTags = ["sunset","beach","water","sky","flowers","nature","white","tree","green","sunrise","portrait","art","light","snow","dog","sun","clouds","cat","flower","park","winter","landscape","street","summer","sea","city","trees","night","yellow","lake","christmas","people","bridge","family","bird","river","pink","house","car","food","blue","old","macro","music","new","moon","home","orange","garden","blackandwhite"]
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/ImageSearch/Common/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | @main
4 | class AppDelegate: UIResponder, UIApplicationDelegate {
5 |
6 | private(set) var dependencyContainer = DIContainer()
7 | private(set) var coordinator: AppCoordinator?
8 | var window: UIWindow?
9 |
10 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
11 |
12 | coordinator = AppCoordinator(dependencyContainer: dependencyContainer)
13 |
14 | window = UIWindow(frame: UIScreen.main.bounds)
15 | window?.rootViewController = coordinator?.navigationController
16 | window?.makeKeyAndVisible()
17 |
18 | coordinator?.start(completionHandler: nil)
19 |
20 | return true
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/ImageSearch/Common/Utils/Extensions.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import SwiftUI
3 |
4 | extension String {
5 |
6 | func encodeURIComponent() -> String? {
7 | self.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed)
8 | }
9 |
10 | func decodeURIComponent() -> String? {
11 | self.removingPercentEncoding
12 | }
13 | }
14 |
15 | extension Array where Element == ImageWrapper {
16 | func toUIImageArray() -> [UIImage] {
17 | self.map { $0.uiImage ?? UIImage() }
18 | }
19 | }
20 |
21 | extension UIApplication {
22 | var keyWindow: UIWindow? {
23 | self.connectedScenes
24 | .filter { $0.activationState == .foregroundActive }
25 | .first(where: { $0 is UIWindowScene })
26 | .flatMap({ $0 as? UIWindowScene })?.windows
27 | .first(where: \.isKeyWindow)
28 | }
29 | }
30 |
31 | extension UIWindow {
32 | static var isLandscape: Bool {
33 | UIApplication.shared
34 | .keyWindow?
35 | .windowScene?
36 | .interfaceOrientation
37 | .isLandscape ?? false
38 | }
39 | }
40 |
41 | extension UIHostingController: Alertable {}
42 |
--------------------------------------------------------------------------------
/ImageSearch/Common/Utils/Supportive.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | class Supportive {
4 | static func toUIImage(from data: Data) -> UIImage? {
5 | if let image = UIImage(data: data) {
6 | return image
7 | }
8 | return nil
9 | }
10 | }
11 |
12 | class DeepCopier {
13 | static func copy(of object: T) -> T? {
14 | do {
15 | let json = try JSONEncoder().encode(object)
16 | return try JSONDecoder().decode(T.self, from: json)
17 | } catch {
18 | return nil
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/ImageSearch/Common/Utils/SwiftEvents.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | protocol Unsubscribable: AnyObject {
4 | func unsubscribe(_ target: AnyObject)
5 | }
6 |
7 | protocol Unbindable: AnyObject {
8 | func unbind(_ target: AnyObject)
9 | }
10 |
11 | final public class Event {
12 |
13 | struct Subscriber: Identifiable {
14 | weak var target: AnyObject?
15 | let queue: DispatchQueue?
16 | let handler: (T) -> ()
17 | let id: ObjectIdentifier
18 |
19 | init(target: AnyObject, queue: DispatchQueue?, handler: @escaping (T) -> ()) {
20 | self.target = target
21 | self.queue = queue
22 | self.handler = handler
23 | id = ObjectIdentifier(target)
24 | }
25 | }
26 |
27 | private var subscribers = [Subscriber]()
28 |
29 | /// The number of subscribers to the Event
30 | public var subscribersCount: Int { subscribers.count }
31 |
32 | /// The number of times the Event was triggered
33 | public private(set) var triggersCount = Int()
34 |
35 | public init() {}
36 |
37 | /// - Parameter target: The target object that subscribes to the Event
38 | /// - Parameter queue: The queue in which the handler should be executed when the Event triggers
39 | /// - Parameter handler: The closure you want executed when the Event triggers
40 | public func subscribe(_ target: O, queue: DispatchQueue? = nil, handler: @escaping (T) -> ()) {
41 | let subscriber = Subscriber(target: target, queue: queue, handler: handler)
42 | subscribers.append(subscriber)
43 | }
44 |
45 | /// Triggers the Event, calls all handlers, notifies all subscribers
46 | ///
47 | /// - Parameter data: The data to trigger the Event with
48 | public func notify(_ data: T) {
49 | triggersCount += 1
50 | for subscriber in subscribers {
51 | if subscriber.target != nil {
52 | callHandler(on: subscriber.queue, data: data, handler: subscriber.handler)
53 | } else {
54 | // Removes the subscriber if it is deallocated
55 | unsubscribe(id: subscriber.id)
56 | }
57 | }
58 | }
59 |
60 | /// Executes the handler with provided data
61 | private func callHandler(on queue: DispatchQueue?, data: T, handler: @escaping (T) -> ()) {
62 | guard let queue = queue else {
63 | handler(data)
64 | return
65 | }
66 | queue.async {
67 | handler(data)
68 | }
69 | }
70 |
71 | /// - Parameter id: The id of the subscriber
72 | private func unsubscribe(id: ObjectIdentifier) {
73 | subscribers = subscribers.filter { $0.id != id }
74 | }
75 |
76 | /// - Parameter target: The target object that subscribes to the Event
77 | public func unsubscribe(_ target: AnyObject) {
78 | unsubscribe(id: ObjectIdentifier(target))
79 | }
80 |
81 | public func unsubscribeAll() {
82 | subscribers.removeAll()
83 | }
84 | }
85 |
86 | final public class Observable {
87 |
88 | private let didChanged = Event()
89 |
90 | public var value: T {
91 | didSet {
92 | didChanged.notify(value)
93 | }
94 | }
95 |
96 | public init(_ v: T) {
97 | value = v
98 | }
99 | }
100 |
101 | extension Observable {
102 |
103 | /// The number of observers of the Observable
104 | public var observersCount: Int { didChanged.subscribersCount }
105 |
106 | /// The number of times the Observable's value was changed and the Observable was triggered
107 | public var triggersCount: Int { didChanged.triggersCount }
108 |
109 | /// - Parameter target: The target object that binds to the Observable
110 | /// - Parameter queue: The queue in which the handler should be executed when the Observable's value changes
111 | /// - Parameter handler: The closure you want executed when the Observable's value changes
112 | public func bind(_ target: O, queue: DispatchQueue? = nil, handler: @escaping (T) -> ()) {
113 | didChanged.subscribe(target, queue: queue, handler: handler)
114 | }
115 |
116 | /// - Parameter target: The target object that binds to the Observable
117 | public func unbind(_ target: AnyObject) {
118 | didChanged.unsubscribe(target)
119 | }
120 |
121 | public func unbindAll() {
122 | didChanged.unsubscribeAll()
123 | }
124 | }
125 |
126 | infix operator <<<
127 | public func <<< (left: Observable, right: @autoclosure () -> T) {
128 | left.value = right()
129 | }
130 |
131 | extension Event: Unsubscribable {}
132 | extension Observable: Unbindable {}
133 |
134 |
135 | /* ****************** Thread-safe Event & Observable ****************** */
136 |
137 | final public class EventTS {
138 |
139 | struct Subscriber: Identifiable {
140 | weak var target: AnyObject?
141 | let queue: DispatchQueue?
142 | let handler: (T) -> ()
143 | let id: ObjectIdentifier
144 |
145 | init(target: AnyObject, queue: DispatchQueue?, handler: @escaping (T) -> ()) {
146 | self.target = target
147 | self.queue = queue
148 | self.handler = handler
149 | id = ObjectIdentifier(target)
150 | }
151 | }
152 |
153 | private var subscribers = [Subscriber]()
154 |
155 | fileprivate let serialQueue = DispatchQueue(label: "com.swift.events.dispatch.queue")
156 |
157 | /// The number of subscribers to the Event
158 | public var subscribersCount: Int {
159 | getSubscribers().count
160 | }
161 |
162 | /// The number of times the Event was triggered
163 | public var triggersCount: Int {
164 | getTriggersCount()
165 | }
166 |
167 | private var _triggersCount = Int()
168 |
169 | public init() {}
170 |
171 | /// - Parameter target: The target object that subscribes to the Event
172 | /// - Parameter queue: The queue in which the handler should be executed when the Event triggers
173 | /// - Parameter handler: The closure you want executed when the Event triggers
174 | public func subscribe(_ target: O, queue: DispatchQueue? = nil, handler: @escaping (T) -> ()) {
175 | let subscriber = Subscriber(target: target, queue: queue, handler: handler)
176 | serialQueue.sync {
177 | self.subscribers.append(subscriber)
178 | }
179 | }
180 |
181 | /// Triggers the Event, calls all handlers, notifies all subscribers
182 | ///
183 | /// - Parameter data: The data to trigger the Event with
184 | public func notify(_ data: T) {
185 | serialQueue.sync {
186 | self._triggersCount += 1
187 | }
188 |
189 | let subscribers = getSubscribers()
190 |
191 | for subscriber in subscribers {
192 | if subscriber.target != nil {
193 | callHandler(on: subscriber.queue, data: data, handler: subscriber.handler)
194 | } else {
195 | // Removes the subscriber if it is deallocated
196 | unsubscribe(id: subscriber.id)
197 | }
198 | }
199 | }
200 |
201 | /// Executes the handler with provided data
202 | private func callHandler(on queue: DispatchQueue?, data: T, handler: @escaping (T) -> ()) {
203 | guard let queue = queue else {
204 | handler(data)
205 | return
206 | }
207 | queue.async {
208 | handler(data)
209 | }
210 | }
211 |
212 | /// - Parameter id: The id of the subscriber
213 | private func unsubscribe(id: ObjectIdentifier) {
214 | serialQueue.sync {
215 | self.subscribers = self.subscribers.filter { $0.id != id }
216 | }
217 | }
218 |
219 | /// - Parameter target: The target object that subscribes to the Event
220 | public func unsubscribe(_ target: AnyObject) {
221 | unsubscribe(id: ObjectIdentifier(target))
222 | }
223 |
224 | public func unsubscribeAll() {
225 | serialQueue.sync {
226 | self.subscribers.removeAll()
227 | }
228 | }
229 |
230 | private func getSubscribers() -> [Subscriber] {
231 | serialQueue.sync {
232 | self.subscribers
233 | }
234 | }
235 |
236 | private func getTriggersCount() -> Int {
237 | serialQueue.sync {
238 | self._triggersCount
239 | }
240 | }
241 | }
242 |
243 | final public class ObservableTS {
244 |
245 | private let didChanged = EventTS()
246 |
247 | public var value: T {
248 | get {
249 | didChanged.serialQueue.sync {
250 | self._value
251 | }
252 | }
253 | set {
254 | didChanged.serialQueue.sync {
255 | self._value = newValue
256 | }
257 | didChanged.notify(_value)
258 | }
259 | }
260 |
261 | private var _value: T
262 |
263 | public init(_ v: T) {
264 | _value = v
265 | }
266 | }
267 |
268 | extension ObservableTS {
269 |
270 | /// The number of observers of the Observable
271 | public var observersCount: Int { didChanged.subscribersCount }
272 |
273 | /// The number of times the Observable's value was changed and the Observable was triggered
274 | public var triggersCount: Int { didChanged.triggersCount }
275 |
276 | /// - Parameter target: The target object that binds to the Observable
277 | /// - Parameter queue: The queue in which the handler should be executed when the Observable's value changes
278 | /// - Parameter handler: The closure you want executed when the Observable's value changes
279 | public func bind(_ target: O, queue: DispatchQueue? = nil, handler: @escaping (T) -> ()) {
280 | didChanged.subscribe(target, queue: queue, handler: handler)
281 | }
282 |
283 | /// - Parameter target: The target object that binds to the Observable
284 | public func unbind(_ target: AnyObject) {
285 | didChanged.unsubscribe(target)
286 | }
287 |
288 | public func unbindAll() {
289 | didChanged.unsubscribeAll()
290 | }
291 | }
292 |
293 | public func <<< (left: ObservableTS, right: @autoclosure () -> T) {
294 | left.value = right()
295 | }
296 |
297 | extension EventTS: Unsubscribable {}
298 | extension ObservableTS: Unbindable {}
299 |
300 |
--------------------------------------------------------------------------------
/ImageSearch/Coordinator/AppCoordinator.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | class AppCoordinator: FlowCoordinator {
4 |
5 | lazy var navigationController = UINavigationController()
6 | let dependencyContainer: DIContainer
7 |
8 | init(dependencyContainer: DIContainer) {
9 | self.dependencyContainer = dependencyContainer
10 | }
11 |
12 | func start(completionHandler: CoordinatorStartCompletionHandler?) {
13 | let mainCoordinator = dependencyContainer.makeMainCoordinator(navigationController: navigationController)
14 | mainCoordinator.start(completionHandler: nil)
15 | }
16 | }
17 |
18 |
--------------------------------------------------------------------------------
/ImageSearch/Coordinator/DIContainer/DIContainer.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import SwiftUI
3 |
4 | class DIContainer {
5 |
6 | // MARK: - Network
7 |
8 | lazy var apiInteractor: APIInteractor = {
9 | let urlSession = URLSession.shared
10 | let networkService = NetworkService(urlSession: urlSession)
11 | return URLSessionAPIInteractor(with: networkService)
12 | }()
13 |
14 | // MARK: - Persistence
15 |
16 | lazy var imageDBInteractor: ImageDBInteractor = {
17 | let sqliteAdapter = try? SQLite(path: AppConfiguration.SQLite.imageSearchDBPath)
18 | return SQLiteImageDBInteractor(with: sqliteAdapter)
19 | }()
20 |
21 | // MARK: - Repositories
22 |
23 | func makeImageRepository() -> ImageRepository {
24 | DefaultImageRepository(apiInteractor: apiInteractor, imageDBInteractor: imageDBInteractor)
25 | }
26 |
27 | func makeTagRepository() -> TagRepository {
28 | DefaultTagRepository(apiInteractor: apiInteractor)
29 | }
30 |
31 | // MARK: - Use Cases
32 |
33 | func makeSearchImagesUseCase() -> SearchImagesUseCase {
34 | DefaultSearchImagesUseCase(imageRepository: makeImageRepository())
35 | }
36 |
37 | func makeGetBigImageUseCase() -> GetBigImageUseCase {
38 | DefaultGetBigImageUseCase(imageRepository: makeImageRepository())
39 | }
40 |
41 | func makeGetHotTagsUseCase() -> GetHotTagsUseCase {
42 | DefaultGetHotTagsUseCase(tagRepository: makeTagRepository())
43 | }
44 |
45 | // MARK: - Services
46 |
47 | lazy var imageCachingService: ImageCachingService = {
48 | DefaultImageCachingService(imageRepository: makeImageRepository())
49 | }()
50 |
51 | // MARK: - Flow Coordinators
52 |
53 | func makeMainCoordinator(navigationController: UINavigationController) -> MainCoordinator {
54 | MainCoordinator(navigationController: navigationController, dependencyContainer: self)
55 | }
56 | }
57 |
58 | // Optionally can be placed in a separate file DIContainer+MainCoordinatorDIContainer.swift
59 | extension DIContainer: MainCoordinatorDIContainer {
60 |
61 | // MARK: - View Controllers
62 |
63 | func makeImageSearchViewController(actions: ImageSearchCoordinatorActions) -> ImageSearchViewController {
64 | let viewModel = DefaultImageSearchViewModel(searchImagesUseCase: makeSearchImagesUseCase(), imageCachingService: imageCachingService)
65 | return ImageSearchViewController.instantiate(viewModel: viewModel, actions: actions)
66 | }
67 |
68 | func makeImageDetailsViewController(image: Image, imageQuery: ImageQuery, didFinish: Event) -> ImageDetailsViewController {
69 | let viewModel = DefaultImageDetailsViewModel(getBigImageUseCase: makeGetBigImageUseCase(), image: image, imageQuery: imageQuery, didFinish: didFinish)
70 | return ImageDetailsViewController.instantiate(viewModel: viewModel)
71 | }
72 |
73 | func makeHotTagsViewController(actions: HotTagsCoordinatorActions, didSelect: Event) -> UIViewController {
74 |
75 | // Configurable use of UIKit or SwiftUI
76 |
77 | // UIKit
78 | let viewModel = DefaultHotTagsViewModel(getHotTagsUseCase: makeGetHotTagsUseCase(), didSelect: didSelect)
79 | return HotTagsViewController.instantiate(viewModel: viewModel, actions: actions)
80 |
81 | // SwiftUI
82 | /*
83 | let viewModel = HotTagsViewModelBridgeWrapper(viewModel: DefaultHotTagsViewModel(getHotTagsUseCase: makeGetHotTagsUseCase(), didSelect: didSelect))
84 | let view = HotTagsView(viewModelBridgeWrapper: viewModel, coordinatorActions: actions)
85 | let hostingController = UIHostingController(rootView: view)
86 | viewModel.hostingController = hostingController
87 | return hostingController
88 | */
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/ImageSearch/Coordinator/FlowCoordinator.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | typealias CoordinatorStartCompletionHandler = () -> ()
4 |
5 | protocol FlowCoordinator {
6 | var navigationController: UINavigationController { get }
7 | func start(completionHandler: CoordinatorStartCompletionHandler?)
8 | }
9 |
10 |
--------------------------------------------------------------------------------
/ImageSearch/Data/APIs/FlickrAPI.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct FlickrAPI {
4 |
5 | static let baseURL = AppConfiguration.ProductionServer.flickrBaseURL
6 |
7 | static let defaultParams = HTTPParams(timeoutInterval: 10.0, headerValues:[
8 | (value: ContentType.applicationJson.rawValue, forHTTPHeaderField: HTTPHeader.accept.rawValue),
9 | (value: ContentType.applicationFormUrlencoded.rawValue, forHTTPHeaderField: HTTPHeader.contentType.rawValue)])
10 |
11 | static func search(_ imageQuery: ImageQuery) -> EndpointType {
12 | let path = "?method=flickr.photos.search&api_key=\(AppConfiguration.ProductionServer.flickrApiKey)&text=\(imageQuery.query.encodeURIComponent() ?? "")&per_page=\(AppConfiguration.ProductionServer.photosPerRequest)&format=json&nojsoncallback=1"
13 |
14 | let params = FlickrAPI.defaultParams
15 |
16 | return Endpoint(
17 | method: .GET,
18 | baseURL: FlickrAPI.baseURL,
19 | path: path,
20 | params: params)
21 | }
22 |
23 | static func getHotTags() -> EndpointType {
24 | let path = "?method=flickr.tags.getHotList&api_key=\(AppConfiguration.ProductionServer.flickrApiKey)&period=week&count=\(AppConfiguration.ProductionServer.hotTagsCount)&format=json&nojsoncallback=1"
25 |
26 | let params = FlickrAPI.defaultParams
27 |
28 | return Endpoint(
29 | method: .GET,
30 | baseURL: FlickrAPI.baseURL,
31 | path: path,
32 | params: params)
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/ImageSearch/Data/Network/APIInteractor/APIInteractor.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | // Result can be used as another way to return the result
4 |
5 | protocol APIInteractor {
6 | func request(_ endpoint: EndpointType) async throws -> Data
7 | func request(_ endpoint: EndpointType, type: T.Type) async throws -> T
8 | func fetchFile(url: URL) async throws -> Data?
9 | }
10 |
--------------------------------------------------------------------------------
/ImageSearch/Data/Network/APIInteractor/URLSessionAPIInteractor.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | class URLSessionAPIInteractor: APIInteractor {
4 |
5 | private let urlSessionAdapter: NetworkService
6 |
7 | init(with networkService: NetworkService) {
8 | self.urlSessionAdapter = networkService
9 | }
10 |
11 | private func customError(_ error: Error? = nil) -> CustomError {
12 | switch error {
13 | case nil:
14 | return CustomError.app(.apiClient)
15 | default:
16 | if error is NetworkError {
17 | let networkError = error as! NetworkError
18 | if let statusCode = networkError.statusCode {
19 | if statusCode >= 400 && statusCode <= 599 {
20 | return CustomError.server(networkError.error, statusCode: statusCode, data: networkError.data)
21 | } else {
22 | return CustomError.internetConnection(networkError.error, statusCode: statusCode, data: networkError.data)
23 | }
24 | }
25 | }
26 | return CustomError.internetConnection(error)
27 | }
28 | }
29 |
30 | func request(_ endpoint: EndpointType) async throws -> Data {
31 | guard let request = RequestFactory.request(endpoint) else { throw customError() }
32 | do {
33 | return try await urlSessionAdapter.request(request)
34 | } catch {
35 | throw customError(error)
36 | }
37 | }
38 |
39 | func request(_ endpoint: EndpointType, type: T.Type) async throws -> T {
40 | guard let request = RequestFactory.request(endpoint) else { throw customError() }
41 | do {
42 | return try await urlSessionAdapter.request(request, type: type)
43 | } catch {
44 | throw customError(error)
45 | }
46 | }
47 |
48 | func fetchFile(url: URL) async throws -> Data? {
49 | do {
50 | return try await urlSessionAdapter.fetchFile(url: url)
51 | } catch {
52 | return nil
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/ImageSearch/Data/Network/Utils/EncodeDecode.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct RequestEncodable {
4 | static func encode(_ value: T) -> Data? {
5 | let jsonEncoder = JSONEncoder()
6 | do {
7 | let data = try jsonEncoder.encode(value)
8 | return data
9 | } catch _ {
10 | return nil
11 | }
12 | }
13 | }
14 |
15 | extension Encodable {
16 | func encode() -> Data? { RequestEncodable.encode(self) }
17 | }
18 |
19 | struct ResponseDecodable {
20 | static func decode(_ type: T.Type, data: Data) -> T? {
21 | let jsonDecoder = JSONDecoder()
22 | do {
23 | let response = try jsonDecoder.decode(T.self, from: data)
24 | return response
25 | } catch _ {
26 | return nil
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/ImageSearch/Data/Network/Utils/Endpoint.swift:
--------------------------------------------------------------------------------
1 | protocol EndpointType {
2 | var method: HTTPMethod { get }
3 | var baseURL: String { get }
4 | var path: String { get set }
5 | var params: HTTPParams? { get set }
6 | }
7 |
8 | class Endpoint: EndpointType {
9 | let method: HTTPMethod
10 | let baseURL: String
11 | var path: String
12 | var params: HTTPParams?
13 |
14 | init(method: HTTPMethod, baseURL: String, path: String, params: HTTPParams?) {
15 | self.method = method
16 | self.baseURL = baseURL
17 | self.path = path
18 | self.params = params
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/ImageSearch/Data/Network/Utils/HTTPMethod.swift:
--------------------------------------------------------------------------------
1 | /// https://datatracker.ietf.org/doc/html/rfc7231#section-4.3
2 | enum HTTPMethod: String {
3 | case GET
4 | case POST
5 | case PUT
6 | case PATCH
7 | case DELETE
8 | case HEAD
9 | case OPTIONS
10 | case CONNECT
11 | case TRACE
12 | case QUERY /// https://www.ietf.org/archive/id/draft-ietf-httpbis-safe-method-w-body-02.html
13 | }
14 |
--------------------------------------------------------------------------------
/ImageSearch/Data/Network/Utils/HTTPParams.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct HTTPParams {
4 | var httpBody: Data?
5 | var cachePolicy: URLRequest.CachePolicy?
6 | var timeoutInterval: TimeInterval?
7 | var headerValues: [(value: String, forHTTPHeaderField: String)]?
8 | }
9 |
10 | enum HTTPHeader: String {
11 | case authentication = "Authorization"
12 | case contentType = "Content-Type"
13 | case accept = "Accept"
14 | case acceptEncoding = "Accept-Encoding"
15 | case acceptLanguage = "Accept-Language"
16 | case connection = "Connection"
17 | }
18 |
19 | enum ContentType: String {
20 | case applicationJson = "application/json"
21 | case applicationFormUrlencoded = "application/x-www-form-urlencoded"
22 | case multipartFormData = "multipart/form-data"
23 | case textPlain = "text/plain"
24 | case applicationXML = "application/xml"
25 | case applicationQuery = "application/query"
26 | }
27 |
--------------------------------------------------------------------------------
/ImageSearch/Data/Network/Utils/RequestFactory.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | class RequestFactory {
4 | static func request(_ endpoint: EndpointType) -> URLRequest? {
5 | guard let url = URL(string: endpoint.baseURL + endpoint.path) else { return nil }
6 |
7 | var request = URLRequest(url: url)
8 | request.httpMethod = endpoint.method.rawValue
9 |
10 | if let params = endpoint.params {
11 | request.httpBody = params.httpBody
12 | if params.cachePolicy != nil { request.cachePolicy = params.cachePolicy! }
13 | if params.timeoutInterval != nil { request.timeoutInterval = params.timeoutInterval! }
14 | if params.headerValues != nil {
15 | for header in params.headerValues! {
16 | request.addValue(header.value, forHTTPHeaderField: header.forHTTPHeaderField)
17 | }
18 | }
19 | }
20 |
21 | return request
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/ImageSearch/Data/Persistence/DBInteractor/Image/ImageDBInteractor.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | // Result can be used as another way to return the result
4 |
5 | protocol ImageDBInteractor {
6 | func saveImage(_ image: T, searchId: String, sortId: Int, type: T.Type) -> Bool?
7 | func getImages(searchId: String, type: T.Type) -> [T]?
8 | func checkImagesAreCached(searchId: String) -> Bool?
9 | func deleteAllImages()
10 | }
11 |
--------------------------------------------------------------------------------
/ImageSearch/Data/Persistence/DBInteractor/Image/SQLiteImageDBInteractor.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | class SQLiteImageDBInteractor: ImageDBInteractor {
4 |
5 | private let sqliteAdapter: SQLite?
6 |
7 | let imagesTable = SQLTable(
8 | name: "Images",
9 | columns: [
10 | ("id", .INT),
11 | ("searchId", .TEXT),
12 | ("sortId", .INT),
13 | ("json", .TEXT)
14 | ]
15 | )
16 |
17 | init(with sqliteAdapter: SQLite?) {
18 | self.sqliteAdapter = sqliteAdapter
19 | createImagesTable()
20 | }
21 |
22 | private func createImagesTable() {
23 | guard let sqliteAdapter = sqliteAdapter else { return }
24 | let sqlStatement = """
25 | CREATE TABLE IF NOT EXISTS "\(imagesTable.name)"(
26 | "\(imagesTable.primaryKey)" INTEGER NOT NULL,
27 | "searchId" CHAR(255) NOT NULL,
28 | "sortId" INT NOT NULL,
29 | "json" TEXT NOT NULL,
30 | PRIMARY KEY("\(imagesTable.primaryKey)" AUTOINCREMENT)
31 | );
32 | """
33 | do {
34 | try sqliteAdapter.createTable(sql: sqlStatement) // create table if not exists
35 | try sqliteAdapter.addIndex(to: imagesTable, forColumn: "searchId") // add index if not exists
36 | } catch {
37 | fatalError("\(imagesTable.name) table must be created by SQLite")
38 | }
39 | }
40 |
41 | /* This method can be used if it's necessary to propagate the error further to the calling repository
42 | private func customError(_ error: SQLiteError) -> CustomError {
43 | switch error {
44 | case .OpenDB(let msg), .Prepare(let msg), .Step(let msg), .Bind(let msg), .Column(let msg), .Statement(let msg), .Other(let msg):
45 | return CustomError.app(.database, description: msg)
46 | }
47 | }
48 | */
49 |
50 | private func log(_ error: SQLiteError) {
51 | // Optionally, reporting solutions like Firebase Crashlytics can be used here
52 | #if DEBUG
53 | print("SQLite:", error)
54 | #endif
55 | }
56 |
57 | func saveImage(_ image: T, searchId: String, sortId: Int, type: T.Type) -> Bool? {
58 | guard let sqliteAdapter = sqliteAdapter else { return nil }
59 |
60 | if let encodedData = try? JSONEncoder().encode(image), let jsonString = String(data: encodedData, encoding: .utf8) {
61 | do {
62 | let sql = "INSERT INTO \(imagesTable.name) (searchId, sortId, json) VALUES (?, ?, ?);"
63 | try sqliteAdapter.insertRow(sql: sql, params: [searchId, sortId, jsonString])
64 | return true
65 | } catch {
66 | log(error as! SQLiteError)
67 | return nil
68 | }
69 | } else {
70 | return nil
71 | }
72 | }
73 |
74 | func getImages(searchId: String, type: T.Type) -> [T]? {
75 | guard let sqliteAdapter = sqliteAdapter else { return nil }
76 |
77 | do {
78 | var images: [T] = []
79 |
80 | let sql = "SELECT * FROM \(imagesTable.name) WHERE searchId = ? ORDER BY sortId ASC;"
81 | guard let results = try sqliteAdapter.getRow(from: imagesTable, sql: sql, params: [searchId]) else {
82 | return nil
83 | }
84 |
85 | for row in results {
86 | let json = row[3] // 'json' column
87 | if let jsonData = (json.value as! String).data(using: .utf8), let decoded = try? JSONDecoder().decode(type, from: jsonData) {
88 | images.append(decoded)
89 | } else {
90 | return nil
91 | }
92 | }
93 | return images
94 | } catch {
95 | log(error as! SQLiteError)
96 | return nil
97 | }
98 | }
99 |
100 | /* Another way (albeit more computationally heavy) to perform this check is as follows:
101 | func checkImagesAreCached(searchId: String) -> Int? {
102 | ...
103 | let sql = "SELECT count(*) FROM \(imagesTable.name) WHERE searchId = ?;"
104 | let rowCount = try? sqliteAdapter.getRowCountWithCondition(sql: sql, params: [searchId])
105 | return rowCount // Returns 0 if images with the given searchId are not already cached
106 | ...
107 | }
108 | */
109 | func checkImagesAreCached(searchId: String) -> Bool? {
110 | guard let sqliteAdapter = sqliteAdapter else { return nil }
111 |
112 | let sql = "SELECT * FROM \(imagesTable.name) WHERE searchId = ? LIMIT 1"
113 | do {
114 | if let _ = try sqliteAdapter.getRow(from: imagesTable, sql: sql, params: [searchId]) {
115 | return true
116 | }
117 | return false
118 | } catch {
119 | log(error as! SQLiteError)
120 | return nil
121 | }
122 | }
123 |
124 | func deleteAllImages() {
125 | guard let sqliteAdapter = sqliteAdapter else { return }
126 | do {
127 | try sqliteAdapter.deleteAllRows(in: imagesTable)
128 | } catch {
129 | log(error as! SQLiteError)
130 | }
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/ImageSearch/Data/Persistence/SQLite/SQLTable.swift:
--------------------------------------------------------------------------------
1 | class SQLTable {
2 |
3 | let name: String
4 | let columns: SQLTableColums
5 | let primaryKey: String
6 |
7 | init(name: String, columns: SQLTableColums, primaryKey: String = "id") {
8 | self.name = name
9 | self.columns = columns
10 | self.primaryKey = primaryKey
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/ImageSearch/Data/Repositories/DefaultImageRepository.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | class DefaultImageRepository: ImageRepository {
4 |
5 | private let apiInteractor: APIInteractor
6 | private let imageDBInteractor: ImageDBInteractor
7 |
8 | init(apiInteractor: APIInteractor, imageDBInteractor: ImageDBInteractor) {
9 | self.apiInteractor = apiInteractor
10 | self.imageDBInteractor = imageDBInteractor
11 | }
12 |
13 | // MARK: - API methods
14 |
15 | private func prepareImages(_ imagesData: Data?) -> [Image]? {
16 | guard
17 | let imagesData = imagesData, !imagesData.isEmpty,
18 | let resultsDictionary = try? JSONSerialization.jsonObject(with: imagesData) as? [String: AnyObject],
19 | let stat = resultsDictionary["stat"] as? String
20 | else { return nil }
21 |
22 | if stat != "ok" { return nil }
23 |
24 | guard
25 | let container = resultsDictionary["photos"] as? [String: AnyObject],
26 | let photos = container["photo"] as? [[String: AnyObject]]
27 | else { return nil }
28 |
29 | let imagesArr: [Image] = photos.compactMap { imageDict in
30 | return Image(flickrParams: imageDict)
31 | }
32 |
33 | guard !imagesArr.isEmpty else { return nil }
34 |
35 | return imagesArr
36 | }
37 |
38 | func searchImages(_ imageQuery: ImageQuery) async -> Result<[ImageType], CustomError> {
39 | let endpoint = FlickrAPI.search(imageQuery)
40 | do {
41 | let data = try await apiInteractor.request(endpoint)
42 | if let images = prepareImages(data) {
43 | return .success(images)
44 | } else {
45 | return .failure(CustomError.app(.apiClient))
46 | }
47 | } catch {
48 | return .failure(error as! CustomError)
49 | }
50 | }
51 |
52 | func getImage(url: URL) async -> Data? {
53 | do {
54 | return try await apiInteractor.fetchFile(url: url)
55 | } catch {
56 | return nil
57 | }
58 | }
59 |
60 | // MARK: - DB methods
61 |
62 | func saveImage(_ image: Image, searchId: String, sortId: Int) async -> Bool? {
63 | await withCheckedContinuation { continuation in
64 | let result = imageDBInteractor.saveImage(image, searchId: searchId, sortId: sortId, type: Image.self)
65 | continuation.resume(returning: result)
66 | }
67 | }
68 |
69 | func getImages(searchId: String) async -> [ImageType]? {
70 | await withCheckedContinuation { continuation in
71 | let result = imageDBInteractor.getImages(searchId: searchId, type: Image.self)
72 | continuation.resume(returning: result)
73 | }
74 | }
75 |
76 | func checkImagesAreCached(searchId: String) async -> Bool? {
77 | await withCheckedContinuation { continuation in
78 | let result = imageDBInteractor.checkImagesAreCached(searchId: searchId)
79 | continuation.resume(returning: result)
80 | }
81 | }
82 |
83 | func deleteAllImages() async {
84 | imageDBInteractor.deleteAllImages()
85 | }
86 | }
87 |
88 | extension DefaultImageRepository {
89 | func toTestPrepareImages(_ imagesData: Data?) -> [Image]? {
90 | prepareImages(imagesData)
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/ImageSearch/Data/Repositories/DefaultTagRepository.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | class DefaultTagRepository: TagRepository {
4 |
5 | private let apiInteractor: APIInteractor
6 |
7 | init(apiInteractor: APIInteractor) {
8 | self.apiInteractor = apiInteractor
9 | }
10 |
11 | func getHotTags() async -> Result {
12 | let endpoint = FlickrAPI.getHotTags()
13 | do {
14 | let tags = try await apiInteractor.request(endpoint, type: Tags.self)
15 | if tags.stat != "ok" {
16 | return .failure(CustomError.app(.apiClient))
17 | }
18 | return .success(tags)
19 | } catch {
20 | return .failure(error as! CustomError)
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/ImageSearch/Domain/Entities/Behaviors/ImageBehavior.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | // Delegated behavior of Image entity
4 | class ImageBehavior {
5 |
6 | static func getFlickrImageURL(_ image: Image, size: ImageSize) -> URL? {
7 | guard let flickrParams = image.flickr else { return nil }
8 | if let url = URL(string: "https://farm\(flickrParams.farm).staticflickr.com/\(flickrParams.server)/\(flickrParams.imageID)_\(flickrParams.secret)_\(size.rawValue).jpg") {
9 | return url
10 | }
11 | return nil
12 | }
13 |
14 | static func updateImage(_ image: Image, newWrapper: ImageWrapper?, for size: ImageSize) -> Image {
15 | let resultImage = image.copy()
16 | switch size {
17 | case .thumbnail:
18 | resultImage.thumbnail = newWrapper
19 | case .big:
20 | resultImage.bigImage = newWrapper
21 | }
22 | return resultImage
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/ImageSearch/Domain/Entities/Image.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | protocol ImageType: AnyObject {
4 | var thumbnail: ImageWrapper? { get set }
5 | var bigImage: ImageWrapper? { get set }
6 | var title: String { get }
7 | }
8 |
9 | protocol ImageListItemVM: AnyObject {
10 | var thumbnail: ImageWrapper? { get }
11 | }
12 |
13 | class Image: Codable, ImageType, ImageListItemVM {
14 |
15 | struct FlickrImageParameters: Codable {
16 | let imageID: String
17 | let farm: Int
18 | let server: String
19 | let secret: String
20 | }
21 |
22 | var thumbnail: ImageWrapper?
23 | var bigImage: ImageWrapper?
24 | let title: String
25 | let flickr: FlickrImageParameters?
26 |
27 | init(title: String, flickr: FlickrImageParameters? = nil) {
28 | self.title = title
29 | self.flickr = flickr
30 | }
31 |
32 | convenience init?(flickrParams: [String: AnyObject]) {
33 | guard let imageID = flickrParams["id"] as? String,
34 | let farm = flickrParams["farm"] as? Int,
35 | let server = flickrParams["server"] as? String,
36 | let secret = flickrParams["secret"] as? String,
37 | let title = flickrParams["title"] as? String else {
38 | return nil
39 | }
40 | let flickr = FlickrImageParameters(imageID: imageID, farm: farm, server: server, secret: secret)
41 | self.init(title: title, flickr: flickr)
42 | }
43 |
44 | // Another way to make a deep copy is to use DeepCopier.copy(of:)
45 | func copy() -> Image {
46 | let newImage = Image(title: title, flickr: flickr)
47 | if thumbnail != nil {
48 | newImage.thumbnail = ImageWrapper(uiImage: thumbnail!.uiImage)
49 | }
50 | if bigImage != nil {
51 | newImage.bigImage = ImageWrapper(uiImage: bigImage!.uiImage)
52 | }
53 | return newImage
54 | }
55 | }
56 |
57 | extension Image: Equatable {
58 | static func == (lhs: Image, rhs: Image) -> Bool {
59 | if lhs.title == rhs.title &&
60 | lhs.flickr?.imageID == rhs.flickr?.imageID &&
61 | ((lhs.thumbnail != nil && rhs.thumbnail != nil) ||
62 | (lhs.thumbnail == nil && rhs.thumbnail == nil)) {
63 | return true
64 | }
65 | return false
66 | }
67 | }
68 |
69 | enum ImageSize: String {
70 | case thumbnail = "m"
71 | case big = "b"
72 | }
73 |
--------------------------------------------------------------------------------
/ImageSearch/Domain/Entities/ImageQuery.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct ImageQuery: Equatable {
4 | let query: String
5 |
6 | init?(query: String) {
7 | guard !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
8 | self.query = query
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/ImageSearch/Domain/Entities/ImageSearchResults.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | protocol ImageSearchResultsListItemVM: AnyObject {
4 | var id: String { get }
5 | var searchQuery: ImageQuery { get }
6 | var _searchResults: [ImageListItemVM] { get set }
7 | }
8 |
9 | class ImageSearchResults: Identifiable, ImageSearchResultsListItemVM {
10 | let id: String
11 | let searchQuery: ImageQuery
12 | var searchResults: [Image]
13 |
14 | init(id: String, searchQuery: ImageQuery, searchResults: [Image]) {
15 | self.id = id
16 | self.searchQuery = searchQuery
17 | self.searchResults = searchResults
18 | }
19 | }
20 |
21 | extension ImageSearchResults {
22 | var _searchResults: [ImageListItemVM] {
23 | get { searchResults }
24 | set { searchResults = newValue as! [Image] }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/ImageSearch/Domain/Entities/ImageWrapper.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | class ImageWrapper: Codable {
4 |
5 | let uiImage: UIImage?
6 |
7 | init(uiImage: UIImage?) {
8 | self.uiImage = uiImage
9 | }
10 |
11 | enum CodingKeys: String, CodingKey {
12 | case uiImage
13 | }
14 |
15 | required init(from decoder: Decoder) throws {
16 | let container = try decoder.container(keyedBy: CodingKeys.self)
17 | let data = try container.decode(Data.self, forKey: CodingKeys.uiImage)
18 | if let image = UIImage(data: data) {
19 | uiImage = image
20 | } else {
21 | uiImage = nil
22 | }
23 | }
24 |
25 | func encode(to encoder: Encoder) throws {
26 | var container = encoder.container(keyedBy: CodingKeys.self)
27 | if let imageData = uiImage?.jpegData(compressionQuality: 1.0) {
28 | try container.encode(imageData, forKey: .uiImage)
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/ImageSearch/Domain/Entities/Tag.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | protocol TagType {
4 | var id: UUID { get }
5 | var name: String { get }
6 | }
7 |
8 | typealias TagListItemVM = TagType
9 |
10 | struct Tag: Decodable, Identifiable, TagType {
11 | let id = UUID()
12 | let name: String
13 |
14 | enum CodingKeys: String, CodingKey {
15 | case name = "_content"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/ImageSearch/Domain/Entities/Tags.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | protocol TagsType {
4 | var tags: [TagType] { get }
5 | }
6 |
7 | struct Tags: Decodable, TagsType {
8 |
9 | struct HotTags: Decodable {
10 | let tag: [Tag]
11 | }
12 |
13 | let hottags: HotTags
14 | let stat: String
15 | }
16 |
17 | extension Tags {
18 | var tags: [TagType] {
19 | hottags.tag
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/ImageSearch/Domain/Exception/CustomError.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | enum CustomError: LocalizedError {
4 | case app(_ type: AppError? = nil, description: String? = nil)
5 | case server(_ error: Error? = nil, statusCode: Int? = nil, data: Data? = nil)
6 | case internetConnection(_ error: Error? = nil, statusCode: Int? = nil, data: Data? = nil)
7 | case unexpected(_ error: Error? = nil)
8 |
9 | enum AppError {
10 | case apiClient
11 | case database
12 | case different
13 | }
14 |
15 | var errorDescription: String? {
16 | switch self {
17 | case .app:
18 | return NSLocalizedString("The operation couldn’t be completed.", comment: "")
19 | case .server:
20 | return NSLocalizedString("A server error has occurred.", comment: "")
21 | case .internetConnection:
22 | return NSLocalizedString("", comment: "")
23 | default:
24 | return nil
25 | }
26 | }
27 |
28 | var recoverySuggestion: String? {
29 | switch self {
30 | case .app:
31 | return NSLocalizedString("", comment: "")
32 | case .server:
33 | return NSLocalizedString("Please try again later.", comment: "")
34 | case .internetConnection:
35 | return NSLocalizedString("Please check your Internet connection.", comment: "")
36 | default:
37 | return nil
38 | }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/ImageSearch/Domain/Interfaces/Repositories/ImageRepository.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | protocol ImageRepository {
4 | func searchImages(_ imageQuery: ImageQuery) async -> Result<[ImageType], CustomError>
5 | func getImage(url: URL) async -> Data?
6 |
7 | func saveImage(_ image: Image, searchId: String, sortId: Int) async -> Bool?
8 | func getImages(searchId: String) async -> [ImageType]?
9 | func checkImagesAreCached(searchId: String) async -> Bool?
10 | func deleteAllImages() async
11 | }
12 |
--------------------------------------------------------------------------------
/ImageSearch/Domain/Interfaces/Repositories/TagRepository.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | protocol TagRepository {
4 | func getHotTags() async -> Result
5 | }
6 |
--------------------------------------------------------------------------------
/ImageSearch/Domain/Services/ImageCachingService.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | protocol ImageCachingService: Actor {
4 | func subscribeToDidProcess(_ subscriber: AnyObject, handler: @escaping ([ImageSearchResults]) -> ())
5 | func cacheIfNecessary(_ data: [ImageSearchResults]) async
6 | func getCachedImages(searchId: String) async -> [Image]?
7 | }
8 |
9 | actor DefaultImageCachingService: ImageCachingService {
10 |
11 | private let imageRepository: ImageRepository
12 |
13 | // To avoid reading from cache and updating UI while writing to cache may be in progress
14 | private var cachingTask: Task? = nil
15 |
16 | // To prevent images with the same searchId from being read again from the cache
17 | private var searchIdsFromCache: Set = []
18 |
19 | private let didProcess: Event<[ImageSearchResults]> = Event()
20 |
21 | init(imageRepository: ImageRepository) {
22 | self.imageRepository = imageRepository
23 | Task {
24 | await deleteAllImages()
25 | }
26 | }
27 |
28 | // Clear the Image table at the app's start
29 | private func deleteAllImages() async {
30 | await imageRepository.deleteAllImages()
31 | }
32 |
33 | func subscribeToDidProcess(_ subscriber: AnyObject, handler: @escaping ([ImageSearchResults]) -> ()) {
34 | didProcess.subscribe(subscriber) { result in
35 | handler(result)
36 | }
37 | }
38 |
39 | // Called after each new search
40 | func cacheIfNecessary(_ data: [ImageSearchResults]) async {
41 | if data.count <= AppConfiguration.MemorySafety.cacheAfterSearches { return }
42 | if cachingTask != nil { return }
43 |
44 | cachingTask = Task {
45 | searchIdsFromCache = []
46 | let dataPart1 = Array(data.prefix(AppConfiguration.MemorySafety.cacheAfterSearches))
47 | let dataPart2 = Array(data.suffix(data.count - AppConfiguration.MemorySafety.cacheAfterSearches))
48 | let processedPart2 = await processData(dataPart2)
49 | let newData = dataPart1 + processedPart2
50 | didProcess.notify(newData)
51 | try? await Task.sleep(nanoseconds: 500_000_000)
52 | cachingTask = nil
53 | }
54 |
55 | await cachingTask!.value
56 | }
57 |
58 | private func processData(_ data: [ImageSearchResults]) async -> [ImageSearchResults] {
59 | await withTaskGroup(of: ImageSearchResults.self, returning: [ImageSearchResults].self) { taskGroup in
60 |
61 | for search in data {
62 | taskGroup.addTask {
63 | if search.searchResults.first?.thumbnail == nil {
64 | return search
65 | }
66 | guard let imagesAreCached = await self.imageRepository.checkImagesAreCached(searchId: search.id) else {
67 | return search
68 | }
69 | for (index, image) in search.searchResults.enumerated() {
70 | // We don't necessarily need to cache big images in the local DB since they are already cached for a while by iOS
71 | search.searchResults[index] = ImageBehavior.updateImage(image, newWrapper: nil, for: .big)
72 | if !imagesAreCached {
73 | // Cache the thumbnail if it's not already cached
74 | let _ = await self.imageRepository.saveImage(search.searchResults[index], searchId: search.id, sortId: index+1)
75 | }
76 | search.searchResults[index] = ImageBehavior.updateImage(image, newWrapper: nil, for: .thumbnail)
77 | }
78 | return search
79 | }
80 | }
81 |
82 | var processedData = data
83 |
84 | for await editedSearch in taskGroup {
85 | // The tasks are executed concurrently, so we need to make sure that the edited searches are reassembled in the correct order in which they were originally done
86 | for (index, search) in data.enumerated() {
87 | if search.id == editedSearch.id {
88 | processedData[index] = editedSearch
89 | break
90 | }
91 | }
92 | }
93 | return processedData
94 | }
95 | }
96 |
97 | func getCachedImages(searchId: String) async -> [Image]? {
98 | guard cachingTask == nil else { return nil }
99 |
100 | if !searchIdsFromCache.contains(searchId) {
101 | searchIdsFromCache.insert(searchId)
102 | if let images = await imageRepository.getImages(searchId: searchId) as? [Image] {
103 | return images
104 | }
105 | }
106 | return nil
107 | }
108 | }
109 |
110 | extension DefaultImageCachingService {
111 | var toTestSearchIdsFromCache: Set {
112 | searchIdsFromCache
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/ImageSearch/Domain/UseCases/GetBigImageUseCase.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | // callAsFunction() can be used instead of execute() to call instances of the use case class as if they were functions
4 |
5 | protocol GetBigImageUseCase {
6 | func execute(for image: Image) async -> Data?
7 | }
8 |
9 | class DefaultGetBigImageUseCase: GetBigImageUseCase {
10 |
11 | private let imageRepository: ImageRepository
12 |
13 | init(imageRepository: ImageRepository) {
14 | self.imageRepository = imageRepository
15 | }
16 |
17 | func execute(for image: Image) async -> Data? {
18 | if let bigImageURL = ImageBehavior.getFlickrImageURL(image, size: .big) {
19 | if let imageData = await imageRepository.getImage(url: bigImageURL) {
20 | return imageData
21 | }
22 | }
23 | return nil
24 | }
25 | }
26 |
27 |
--------------------------------------------------------------------------------
/ImageSearch/Domain/UseCases/GetHotTagsUseCase.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | // callAsFunction() can be used instead of execute() to call instances of the use case class as if they were functions
4 |
5 | protocol GetHotTagsUseCase {
6 | func execute() async -> Result
7 | }
8 |
9 | class DefaultGetHotTagsUseCase: GetHotTagsUseCase {
10 |
11 | private let tagRepository: TagRepository
12 |
13 | init(tagRepository: TagRepository) {
14 | self.tagRepository = tagRepository
15 | }
16 |
17 | func execute() async -> Result {
18 | let result = await tagRepository.getHotTags()
19 | switch result {
20 | case .success(let tagsType):
21 | return .success(tagsType as! Tags)
22 | case .failure(let error):
23 | return .failure(error)
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/ImageSearch/Domain/UseCases/SearchImagesUseCase.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | // callAsFunction() can be used instead of execute() to call instances of the use case class as if they were functions
4 |
5 | protocol SearchImagesUseCase {
6 | func execute(_ imageQuery: ImageQuery) async -> Result
7 | }
8 |
9 | class DefaultSearchImagesUseCase: SearchImagesUseCase {
10 |
11 | private let imageRepository: ImageRepository
12 |
13 | init(imageRepository: ImageRepository) {
14 | self.imageRepository = imageRepository
15 | }
16 |
17 | private func generateSearchId() -> String {
18 | UUID().uuidString
19 | }
20 |
21 | func execute(_ imageQuery: ImageQuery) async -> Result {
22 |
23 | let result = await imageRepository.searchImages(imageQuery)
24 |
25 | switch result {
26 | case .success(let imagesType):
27 | let thumbnailImages = await withTaskGroup(of: Image.self, returning: [Image].self) { taskGroup in
28 | for image in (imagesType as! [Image]) {
29 | taskGroup.addTask {
30 | guard let thumbnailUrl = ImageBehavior.getFlickrImageURL(image, size: .thumbnail) else { return image }
31 | var tempImage = image
32 | if let thumbnailImageData = await self.imageRepository.getImage(url: thumbnailUrl) {
33 | if let thumbnailImage = Supportive.toUIImage(from: thumbnailImageData) {
34 | let imageWrapper = ImageWrapper(uiImage: thumbnailImage)
35 | tempImage = ImageBehavior.updateImage(tempImage, newWrapper: imageWrapper, for: .thumbnail)
36 | }
37 | }
38 | return tempImage
39 | }
40 | }
41 | var processedImages: [Image] = []
42 | for await result in taskGroup {
43 | if result.thumbnail != nil {
44 | processedImages.append(result)
45 | }
46 | }
47 | return processedImages
48 | }
49 |
50 | return .success(ImageSearchResults(id: generateSearchId(), searchQuery: imageQuery, searchResults: thumbnailImages))
51 | case .failure(let error):
52 | return .failure(error)
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/ImageSearch/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | $(MARKETING_VERSION)
19 | CFBundleVersion
20 | 1
21 | LSRequiresIPhoneOS
22 |
23 | NSPhotoLibraryAddUsageDescription
24 | Access to the photo library is used for sharing
25 | UILaunchStoryboardName
26 | LaunchScreen
27 | UIRequiredDeviceCapabilities
28 |
29 | armv7
30 |
31 | UISupportedInterfaceOrientations
32 |
33 | UIInterfaceOrientationPortrait
34 | UIInterfaceOrientationLandscapeLeft
35 | UIInterfaceOrientationLandscapeRight
36 |
37 | UISupportedInterfaceOrientations~ipad
38 |
39 | UIInterfaceOrientationPortrait
40 | UIInterfaceOrientationPortraitUpsideDown
41 | UIInterfaceOrientationLandscapeLeft
42 | UIInterfaceOrientationLandscapeRight
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/ImageSearch/Presentation/Common/Protocols/Alertable.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | protocol Alertable {}
4 | extension Alertable where Self: UIViewController {
5 |
6 | func showAlert(title: String, message: String, style: UIAlertController.Style = .alert, okHandler: (() -> Void)? = nil) {
7 | let alert = UIAlertController(title: title, message: message, preferredStyle: style)
8 | let action = UIAlertAction(title: "Ok", style: .default) { _ in
9 | if okHandler != nil { okHandler!() }
10 | }
11 | alert.addAction(action)
12 | present(alert, animated: true)
13 | }
14 |
15 | func makeToast(message: String, duration: TimeInterval = AppConfiguration.Other.toastDuration, position: ToastPosition = .bottom) {
16 | self.view.makeToast(message, duration: duration, position: position)
17 | }
18 |
19 | func makeToastActivity(position: ToastPosition = .center) {
20 | self.view.makeToastActivity(position)
21 | }
22 |
23 | func hideToastActivity() {
24 | self.view.hideToastActivity()
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/ImageSearch/Presentation/Common/Protocols/Storyboarded.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | enum StoryboardName: String {
4 | case imageSearch = "ImageSearch"
5 | case imageDetails = "ImageDetails"
6 | case hotTags = "HotTags"
7 | }
8 |
9 | protocol Storyboarded {
10 | static var className: String { get }
11 | static func instantiate(_ bundle: Bundle?, from storyboardName: StoryboardName) -> Self
12 | }
13 |
14 | extension Storyboarded where Self: UIViewController {
15 | static var className: String {
16 | return NSStringFromClass(Self.self).components(separatedBy: ".").last!
17 | }
18 |
19 | static func instantiate(_ bundle: Bundle? = nil, from storyboardName: StoryboardName) -> Self {
20 | let storyboard = UIStoryboard(name: storyboardName.rawValue, bundle: bundle)
21 | return storyboard.instantiateViewController(withIdentifier: className) as! Self
22 | }
23 | }
24 |
25 |
26 |
--------------------------------------------------------------------------------
/ImageSearch/Presentation/ImagesFeature/HotTags/View/SwiftUI/HotTagsView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct HotTagsView: View {
4 |
5 | @StateObject var viewModelBridgeWrapper: HotTagsViewModelBridgeWrapper
6 |
7 | var coordinatorActions: HotTagsCoordinatorActions?
8 |
9 | var body: some View {
10 | VStack {
11 | List {
12 | HStack {
13 | Spacer()
14 | Picker("", selection: $viewModelBridgeWrapper.selectedSegment) {
15 | ForEach(TagsSegmentType.allCases, id: \.self) { option in
16 | Text(NSLocalizedString(option.rawValue, comment: ""))
17 | }
18 | }
19 | .pickerStyle(.segmented)
20 | .frame(width: 152)
21 | .padding(.vertical, 4)
22 | Spacer()
23 | }
24 | .listRowSeparator(.hidden)
25 |
26 | ForEach(viewModelBridgeWrapper.data as! [Tag]) { tag in
27 | TagCell(tag: tag)
28 | .contentShape(Rectangle())
29 | .onTapGesture {
30 | viewModelBridgeWrapper.viewModel?.triggerDidSelect(tagName: tag.name)
31 | if let hostingController = viewModelBridgeWrapper.hostingController {
32 | coordinatorActions?.closeHotTags(hostingController)
33 | }
34 | }
35 | }
36 | }
37 | .listStyle(.plain)
38 | .navigationTitle(viewModelBridgeWrapper.screenTitle)
39 | .toolbar {
40 | Button {
41 | if let hostingController = viewModelBridgeWrapper.hostingController {
42 | coordinatorActions?.closeHotTags(hostingController)
43 | }
44 | } label: {
45 | Text("Done")
46 | .bold()
47 | }
48 | }
49 | .onAppear( perform: {
50 | viewModelBridgeWrapper.viewModel?.getHotTags()
51 | })
52 | }
53 | }
54 | }
55 |
56 | struct TagCell: View {
57 |
58 | var tag: Tag
59 |
60 | var body: some View {
61 | HStack {
62 | Text("\(tag.name)")
63 | .frame(maxWidth: .infinity, alignment: .leading)
64 | .font(.system(size: 18))
65 | }
66 | .padding(8)
67 | }
68 | }
69 |
70 | #Preview {
71 | let viewModelBridgeWrapper = HotTagsViewModelBridgeWrapper(viewModel: DefaultHotTagsViewModel(getHotTagsUseCase: DefaultGetHotTagsUseCase(tagRepository: DefaultTagRepository(apiInteractor: URLSessionAPIInteractor(with: NetworkService()))), didSelect: Event()))
72 | HotTagsView(viewModelBridgeWrapper: viewModelBridgeWrapper)
73 | }
74 |
--------------------------------------------------------------------------------
/ImageSearch/Presentation/ImagesFeature/HotTags/View/SwiftUI/HotTagsViewModelBridgeWrapper.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | class HotTagsViewModelBridgeWrapper: ObservableObject {
4 |
5 | var viewModel: HotTagsViewModel?
6 |
7 | weak var hostingController: UIViewController?
8 |
9 | @Published private(set) var data = [TagListItemVM]()
10 |
11 | var screenTitle: String {
12 | viewModel?.screenTitle ?? ""
13 | }
14 |
15 | var selectedSegment: TagsSegmentType = .week {
16 | didSet {
17 | switch selectedSegment {
18 | case .week:
19 | viewModel?.onSelectedSegmentChange(0)
20 | case .allTimes:
21 | viewModel?.onSelectedSegmentChange(1)
22 | }
23 | }
24 | }
25 |
26 | init(viewModel: HotTagsViewModel?) {
27 | self.viewModel = viewModel
28 | bind()
29 | }
30 |
31 | private func bind() {
32 | viewModel?.data.bind(self, queue: .main) { [weak self] data in
33 | self?.data = data
34 | }
35 |
36 | viewModel?.makeToast.bind(self, queue: .main) { [weak self] message in
37 | guard let self, !message.isEmpty else { return }
38 | hostingController?.view.makeToast(message)
39 | }
40 |
41 | viewModel?.activityIndicatorVisibility.bind(self, queue: .main) { [weak self] value in
42 | guard let hostingController = self?.hostingController else { return }
43 | if value {
44 | hostingController.view.makeToastActivity(.center)
45 | } else {
46 | hostingController.view.hideToastActivity()
47 | }
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/ImageSearch/Presentation/ImagesFeature/HotTags/View/UIKit/HotTags.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
--------------------------------------------------------------------------------
/ImageSearch/Presentation/ImagesFeature/HotTags/View/UIKit/HotTagsViewController.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | struct HotTagsCoordinatorActions {
4 | let closeHotTags: (UIViewController) -> ()
5 | }
6 |
7 | class HotTagsViewController: UIViewController, Storyboarded, Alertable {
8 |
9 | @IBOutlet private weak var tableView: UITableView!
10 | @IBOutlet weak var segmentedControl: UISegmentedControl!
11 |
12 | private var viewModel: HotTagsViewModel!
13 |
14 | private var dataSource: TagsDataSource?
15 |
16 | private var coordinatorActions: HotTagsCoordinatorActions?
17 |
18 | static func instantiate(viewModel: HotTagsViewModel, actions: HotTagsCoordinatorActions) -> HotTagsViewController {
19 | let vc = Self.instantiate(from: .hotTags)
20 | vc.viewModel = viewModel
21 | vc.coordinatorActions = actions
22 | return vc
23 | }
24 |
25 | override func viewDidLoad() {
26 | super.viewDidLoad()
27 | setup()
28 | prepareUI()
29 | viewModel.getHotTags()
30 | }
31 |
32 | private func setup() {
33 | dataSource = viewModel.getDataSource()
34 | tableView.dataSource = dataSource
35 | tableView.delegate = self
36 |
37 | // Bindings
38 | viewModel.data.bind(self, queue: .main) { [weak self] data in
39 | guard let self else { return }
40 | dataSource?.update(data)
41 | tableView.reloadData()
42 | }
43 |
44 | viewModel.makeToast.bind(self, queue: .main) { [weak self] message in
45 | guard let self, !message.isEmpty else { return }
46 | makeToast(message: message)
47 | }
48 |
49 | viewModel.activityIndicatorVisibility.bind(self, queue: .main) { [weak self] value in
50 | guard let self else { return }
51 | if value {
52 | makeToastActivity()
53 | } else {
54 | hideToastActivity()
55 | }
56 | }
57 | }
58 |
59 | private func prepareUI() {
60 | title = viewModel.screenTitle
61 | segmentedControl.setTitle(NSLocalizedString(TagsSegmentType.allCases[0].rawValue, comment: ""), forSegmentAt: 0)
62 | segmentedControl.setTitle(NSLocalizedString(TagsSegmentType.allCases[1].rawValue, comment: ""), forSegmentAt: 1)
63 | }
64 |
65 | // MARK: - Actions
66 |
67 | @IBAction func onSelectedSegmentChange(_ sender: UISegmentedControl) {
68 | viewModel.onSelectedSegmentChange(sender.selectedSegmentIndex)
69 | }
70 |
71 | @IBAction func onDoneButton(_ sender: Any) {
72 | coordinatorActions?.closeHotTags(self)
73 | }
74 | }
75 |
76 | // MARK: - UITableViewDelegate
77 |
78 | extension HotTagsViewController: UITableViewDelegate {
79 |
80 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
81 | let tagName = viewModel.data.value[indexPath.row].name
82 | viewModel.triggerDidSelect(tagName: tagName)
83 | coordinatorActions?.closeHotTags(self)
84 | }
85 | }
86 |
87 |
--------------------------------------------------------------------------------
/ImageSearch/Presentation/ImagesFeature/HotTags/View/UIKit/TagsDataSource.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | class TagsDataSource: NSObject {
4 |
5 | private(set) var data = [TagListItemVM]()
6 |
7 | init(with data: [TagListItemVM]) {
8 | super.init()
9 | self.data = data
10 | }
11 |
12 | func update(_ data: [TagListItemVM]) {
13 | self.data = data
14 | }
15 | }
16 |
17 | // MARK: UITableViewDataSource
18 |
19 | extension TagsDataSource: UITableViewDataSource {
20 |
21 | func numberOfSections(in tableView: UITableView) -> Int {
22 | 1
23 | }
24 |
25 | func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
26 | data.count
27 | }
28 |
29 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
30 | let cell = tableView.dequeueReusableCell(withIdentifier: "TagCell", for: indexPath)
31 | cell.textLabel?.text = data[indexPath.item].name
32 | return cell
33 | }
34 |
35 | func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
36 | false
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/ImageSearch/Presentation/ImagesFeature/HotTags/ViewModel/DefaultHotTagsViewModel.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | enum TagsSegmentType: String, CaseIterable {
4 | case week = "Week"
5 | case allTimes = "All Times"
6 | }
7 |
8 | /* Use case scenarios:
9 | * getHotTagsUseCase.execute()
10 | */
11 |
12 | protocol HotTagsViewModelInput {
13 | func getHotTags()
14 | func triggerDidSelect(tagName: String)
15 | func onSelectedSegmentChange(_ index: Int)
16 | func getDataSource() -> TagsDataSource
17 | }
18 |
19 | protocol HotTagsViewModelOutput {
20 | var data: Observable<[TagListItemVM]> { get }
21 | var makeToast: Observable { get }
22 | var activityIndicatorVisibility: Observable { get }
23 | var screenTitle: String { get }
24 | }
25 |
26 | typealias HotTagsViewModel = HotTagsViewModelInput & HotTagsViewModelOutput
27 |
28 | class DefaultHotTagsViewModel: HotTagsViewModel {
29 |
30 | private let getHotTagsUseCase: GetHotTagsUseCase
31 |
32 | private let didSelect: Event
33 |
34 | private var dataForWeekTags = [Tag]()
35 | private var selectedSegment: TagsSegmentType = .week
36 |
37 | let screenTitle = NSLocalizedString("Hot Tags", comment: "")
38 |
39 | // Bindings
40 | let data: Observable<[TagListItemVM]> = Observable([])
41 | let makeToast: Observable = Observable("")
42 | let activityIndicatorVisibility: Observable = Observable(false)
43 |
44 | private var hotTagsLoadTask: Task? {
45 | willSet { hotTagsLoadTask?.cancel() }
46 | }
47 |
48 | init(getHotTagsUseCase: GetHotTagsUseCase, didSelect: Event) {
49 | self.getHotTagsUseCase = getHotTagsUseCase
50 | self.didSelect = didSelect
51 | }
52 |
53 | deinit {
54 | hotTagsLoadTask?.cancel()
55 | }
56 |
57 | func triggerDidSelect(tagName: String) {
58 | didSelect.notify(tagName)
59 | }
60 |
61 | func getDataSource() -> TagsDataSource {
62 | TagsDataSource(with: data.value)
63 | }
64 |
65 | private func showError(_ msg: String = "") {
66 | makeToast.value = !msg.isEmpty ? msg : NSLocalizedString("An error has occurred", comment: "")
67 | activityIndicatorVisibility.value = false
68 | }
69 |
70 | func getHotTags() {
71 | getFlickrHotTags()
72 | }
73 |
74 | private func getFlickrHotTags() {
75 | activityIndicatorVisibility.value = true
76 |
77 | hotTagsLoadTask = Task { [weak self] in
78 | let result = await self?.getHotTagsUseCase.execute()
79 |
80 | if Task.isCancelled { return }
81 |
82 | guard let self, let result else { return }
83 |
84 | var allHotTags = [Tag]()
85 |
86 | switch result {
87 | case .success(let tags):
88 | allHotTags = composeHotTags(type: .week, weekHotTags: tags.tags as? [Tag])
89 | dataForWeekTags = allHotTags
90 | activityIndicatorVisibility.value = false
91 | case .failure(let error):
92 | let msg = ((error.errorDescription ?? "") + " " + (error.recoverySuggestion ?? "")).trimmingCharacters(in: .whitespacesAndNewlines)
93 | showError(msg)
94 | }
95 |
96 | if selectedSegment == .week {
97 | data.value = allHotTags
98 | }
99 | }
100 | }
101 |
102 | private func composeHotTags(type: TagsSegmentType, weekHotTags: [Tag]? = nil) -> [Tag] {
103 | switch type {
104 | case .week:
105 | return weekHotTags ?? [Tag]()
106 | case .allTimes:
107 | var allTimesHotTags = [Tag]()
108 | for tag in AppConfiguration.Other.allTimesHotTags {
109 | allTimesHotTags.append(Tag(name: tag))
110 | }
111 | return allTimesHotTags
112 | }
113 | }
114 |
115 | func onSelectedSegmentChange(_ index: Int) {
116 | if index == 0 {
117 | selectedSegment = .week
118 | if !dataForWeekTags.isEmpty {
119 | data.value = dataForWeekTags
120 | } else {
121 | data.value = []
122 | if !activityIndicatorVisibility.value {
123 | getHotTags()
124 | }
125 | }
126 | } else if index == 1 {
127 | selectedSegment = .allTimes
128 | data.value = composeHotTags(type: .allTimes)
129 | }
130 | }
131 | }
132 |
133 | extension DefaultHotTagsViewModel {
134 | var toTestHotTagsLoadTask: Task? {
135 | hotTagsLoadTask
136 | }
137 | }
138 |
--------------------------------------------------------------------------------
/ImageSearch/Presentation/ImagesFeature/ImageDetails/View/ImageDetails.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
--------------------------------------------------------------------------------
/ImageSearch/Presentation/ImagesFeature/ImageDetails/View/ImageDetailsViewController.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | class ImageDetailsViewController: UIViewController, Storyboarded, Alertable {
4 |
5 | @IBOutlet private weak var imageView: UIImageView!
6 | @IBOutlet private weak var shareBarButtonItem: UIBarButtonItem!
7 | @IBOutlet private weak var activityIndicator: UIActivityIndicatorView!
8 | @IBOutlet private weak var imageTitle: UILabel!
9 |
10 | private var viewModel: ImageDetailsViewModel!
11 |
12 | static func instantiate(viewModel: ImageDetailsViewModel) -> ImageDetailsViewController {
13 | let vc = Self.instantiate(from: .imageDetails)
14 | vc.viewModel = viewModel
15 | return vc
16 | }
17 |
18 | override func viewDidLoad() {
19 | super.viewDidLoad()
20 |
21 | setup()
22 | prepareUI()
23 |
24 | viewModel.loadBigImage()
25 | }
26 |
27 | private func setup() {
28 | // Bindings
29 | viewModel.data.bind(self, queue: .main) { [weak self] bigImage in
30 | guard let self, let bigImage else { return }
31 | imageView.image = bigImage.uiImage
32 | }
33 |
34 | viewModel.shareImage.bind(self) { [weak self] imageWrappers in
35 | guard let self else { return }
36 | let activityVC = UIActivityViewController(activityItems: imageWrappers.toUIImageArray(), applicationActivities: nil)
37 | activityVC.popoverPresentationController?.barButtonItem = navigationItem.leftBarButtonItem
38 | activityVC.popoverPresentationController?.permittedArrowDirections = .up
39 | present(activityVC, animated: true, completion: nil)
40 | }
41 |
42 | viewModel.makeToast.bind(self, queue: .main) { [weak self] message in
43 | guard let self, !message.isEmpty else { return }
44 | makeToast(message: message)
45 | }
46 |
47 | viewModel.activityIndicatorVisibility.bind(self, queue: .main) { [weak self] value in
48 | guard let self else { return }
49 | if value {
50 | activityIndicator.isHidden = false
51 | activityIndicator.startAnimating()
52 | } else {
53 | activityIndicator.isHidden = true
54 | activityIndicator.stopAnimating()
55 | }
56 | }
57 | }
58 |
59 | private func prepareUI() {
60 | title = viewModel.getTitle()
61 | imageTitle.text = viewModel.image.title
62 | }
63 |
64 | // MARK: - Actions
65 |
66 | @IBAction func onShareButton(_ sender: UIBarButtonItem) {
67 | viewModel.onShareButton()
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/ImageSearch/Presentation/ImagesFeature/ImageDetails/ViewModel/DefaultImageDetailsViewModel.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /* Use case scenarios:
4 | * getBigImageUseCase.execute(for: image)
5 | */
6 |
7 | protocol ImageDetailsViewModelInput {
8 | func loadBigImage()
9 | func getTitle() -> String
10 | func onShareButton()
11 | }
12 |
13 | protocol ImageDetailsViewModelOutput {
14 | var data: Observable { get }
15 | var shareImage: Observable<[ImageWrapper]> { get }
16 | var makeToast: Observable { get }
17 | var activityIndicatorVisibility: Observable { get }
18 | var image: Image { get }
19 | }
20 |
21 | typealias ImageDetailsViewModel = ImageDetailsViewModelInput & ImageDetailsViewModelOutput
22 |
23 | class DefaultImageDetailsViewModel: ImageDetailsViewModel {
24 |
25 | private let getBigImageUseCase: GetBigImageUseCase
26 |
27 | private(set) var image: Image
28 | let imageQuery: ImageQuery
29 | private let didFinish: Event
30 |
31 | // Bindings
32 | let data: Observable = Observable(nil)
33 | let shareImage: Observable<[ImageWrapper]> = Observable([])
34 | let makeToast: Observable = Observable("")
35 | let activityIndicatorVisibility: Observable = Observable(false)
36 |
37 | private var imageLoadTask: Task? {
38 | willSet { imageLoadTask?.cancel() }
39 | }
40 |
41 | init(getBigImageUseCase: GetBigImageUseCase, image: Image, imageQuery: ImageQuery, didFinish: Event) {
42 | self.getBigImageUseCase = getBigImageUseCase
43 | self.image = image
44 | self.imageQuery = imageQuery
45 | self.didFinish = didFinish
46 | }
47 |
48 | deinit {
49 | imageLoadTask?.cancel()
50 | didFinish.notify(image)
51 | }
52 |
53 | private func showError(_ msg: String = "") {
54 | makeToast.value = !msg.isEmpty ? msg : NSLocalizedString("An error has occurred", comment: "")
55 | activityIndicatorVisibility.value = false
56 | }
57 |
58 | func loadBigImage() {
59 | if let bigImage = image.bigImage {
60 | data.value = bigImage
61 | return
62 | }
63 |
64 | activityIndicatorVisibility.value = true
65 |
66 | imageLoadTask = Task { [weak self] in
67 | guard let image = self?.image else { return }
68 |
69 | if let imageData = await self?.getBigImageUseCase.execute(for: image) {
70 |
71 | if Task.isCancelled { return }
72 |
73 | guard let self else { return }
74 | guard !imageData.isEmpty else {
75 | showError()
76 | return
77 | }
78 |
79 | if let bigImage = Supportive.toUIImage(from: imageData) {
80 | let imageWrapper = ImageWrapper(uiImage: bigImage)
81 | self.image = ImageBehavior.updateImage(image, newWrapper: imageWrapper, for: .big)
82 | data.value = imageWrapper
83 |
84 | activityIndicatorVisibility.value = false
85 | } else {
86 | showError()
87 | }
88 | } else {
89 | if !Task.isCancelled {
90 | self?.showError()
91 | }
92 | }
93 | }
94 | }
95 |
96 | func getTitle() -> String {
97 | imageQuery.query
98 | }
99 |
100 | func onShareButton() {
101 | if let bigImage = image.bigImage {
102 | shareImage.value = [bigImage]
103 | } else {
104 | makeToast.value = NSLocalizedString("No image to share", comment: "")
105 | }
106 | }
107 | }
108 |
109 | extension DefaultImageDetailsViewModel {
110 | var toTestImageLoadTask: Task? {
111 | imageLoadTask
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/ImageSearch/Presentation/ImagesFeature/ImageSearch/View/Cells/CollectionViewCell.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | class CollectionViewCell: UICollectionViewCell {
4 | @IBOutlet private weak var containerView: UIView!
5 | @IBOutlet weak var imageView: UIImageView!
6 |
7 | override func awakeFromNib() {
8 | super.awakeFromNib()
9 | containerView.layer.masksToBounds = true
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/ImageSearch/Presentation/ImagesFeature/ImageSearch/View/Cells/CollectionViewHeader.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | class CollectionViewHeader: UICollectionReusableView {
4 | @IBOutlet weak var label: UILabel!
5 | }
6 |
--------------------------------------------------------------------------------
/ImageSearch/Presentation/ImagesFeature/ImageSearch/View/ImageSearch.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | Title
25 | Title
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
134 |
135 |
--------------------------------------------------------------------------------
/ImageSearch/Presentation/ImagesFeature/ImageSearch/View/ImageSearchViewController.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | struct ImageSearchCoordinatorActions {
4 | let showImageDetails: (ImageListItemVM, ImageQuery, Event) -> ()
5 | let showHotTags: (Event) -> ()
6 | }
7 |
8 | class ImageSearchViewController: UIViewController, Storyboarded, Alertable {
9 |
10 | @IBOutlet private weak var searchBar: UISearchBar!
11 | @IBOutlet private weak var collectionView: UICollectionView!
12 | @IBOutlet private weak var collectionViewTopConstraint: NSLayoutConstraint!
13 |
14 | private var viewModel: ImageSearchViewModel!
15 |
16 | private var dataSource: ImagesDataSource?
17 |
18 | private let refreshControl = UIRefreshControl()
19 |
20 | private var coordinatorActions: ImageSearchCoordinatorActions?
21 |
22 | static func instantiate(viewModel: ImageSearchViewModel, actions: ImageSearchCoordinatorActions) -> ImageSearchViewController {
23 | let vc = Self.instantiate(from: .imageSearch)
24 | vc.viewModel = viewModel
25 | vc.coordinatorActions = actions
26 | return vc
27 | }
28 |
29 | override func viewDidLoad() {
30 | super.viewDidLoad()
31 |
32 | setup()
33 | prepareUI()
34 |
35 | // Get some random images at the app's start
36 | viewModel.searchImages(for: "random")
37 | }
38 |
39 | private func setup() {
40 | dataSource = viewModel.getDataSource()
41 | collectionView.dataSource = dataSource
42 | collectionView.delegate = self
43 | searchBar.delegate = self
44 |
45 | // Bindings
46 | viewModel.data.bind(self, queue: .main) { [weak self] data in
47 | guard let self else { return }
48 | dataSource?.update(data)
49 | collectionView.reloadData()
50 | if refreshControl.isRefreshing {
51 | refreshControl.endRefreshing()
52 | }
53 | }
54 |
55 | viewModel.sectionData.bind(self, queue: .main) { [weak self] data in
56 | guard let self else { return }
57 | dataSource?.update(data.0)
58 | collectionView.reloadSections(data.1)
59 | }
60 |
61 | viewModel.scrollTop.bind(self, queue: .main) { [weak self] _ in
62 | self?.scrollTop()
63 | }
64 |
65 | viewModel.makeToast.bind(self, queue: .main) { [weak self] message in
66 | guard let self, !message.isEmpty else { return }
67 | makeToast(message: message)
68 | }
69 |
70 | viewModel.resetSearchBar.bind(self) { [weak self] _ in
71 | guard let self else { return }
72 | searchBar.text = nil
73 | searchBar.resignFirstResponder()
74 | }
75 |
76 | viewModel.activityIndicatorVisibility.bind(self, queue: .main) { [weak self] value in
77 | guard let self else { return }
78 | if value {
79 | makeToastActivity()
80 | searchBar.isUserInteractionEnabled = false
81 | searchBar.placeholder = "..."
82 | } else {
83 | hideToastActivity()
84 | searchBar.isUserInteractionEnabled = true
85 | searchBar.placeholder = NSLocalizedString("Search", comment: "")
86 | }
87 | }
88 |
89 | viewModel.collectionViewTopConstraint.bind(self) { [weak self] value in
90 | guard let self else { return }
91 | collectionViewTopConstraint.constant = CGFloat(value)
92 | UIView.animate(withDuration: 0.25) {
93 | self.view.layoutIfNeeded()
94 | }
95 | }
96 |
97 | // Notifications
98 | NotificationCenter.default.addObserver(self, selector: #selector(deviceOrientationDidChange), name: UIDevice.orientationDidChangeNotification, object: nil)
99 |
100 | // Other
101 | refreshControl.addTarget(self, action: #selector(refreshImageData(_:)), for: .valueChanged)
102 | }
103 |
104 | private func prepareUI() {
105 | title = viewModel.screenTitle
106 | searchBar.isUserInteractionEnabled = false
107 | searchBar.placeholder = "..."
108 | searchBar.layer.borderColor = UIColor.lightGray.cgColor
109 | searchBar.layer.borderWidth = 0.5
110 |
111 | collectionView.refreshControl = refreshControl
112 |
113 | navigationItem.backButtonTitle = ""
114 | }
115 |
116 | private func scrollTop() {
117 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [self] in
118 | if let attributes = collectionView.collectionViewLayout.layoutAttributesForSupplementaryView(ofKind: UICollectionView.elementKindSectionHeader, at: IndexPath(item: 0, section: 0)) {
119 | collectionView.setContentOffset(CGPoint(x: 0, y: attributes.frame.origin.y - collectionView.contentInset.top), animated: true)
120 | }
121 | }
122 | }
123 |
124 | // MARK: - Actions
125 |
126 | @IBAction func onHotTagsBarButtonItem(_ sender: UIBarButtonItem) {
127 | let didSelect = Event()
128 | didSelect.subscribe(self) { [weak self] query in self?.viewModel.searchImages(for: query) }
129 | coordinatorActions?.showHotTags(didSelect)
130 | }
131 |
132 | // MARK: - Other methods
133 |
134 | @objc func deviceOrientationDidChange(_: Notification) {
135 | collectionView.reloadData()
136 | }
137 |
138 | @objc private func refreshImageData(_ sender: Any) {
139 | func endRefreshing() {
140 | DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [self] in
141 | refreshControl.endRefreshing()
142 | }
143 | }
144 | guard let lastQuery = viewModel.lastQuery else {
145 | viewModel.searchImages(for: "random")
146 | endRefreshing()
147 | return
148 | }
149 | viewModel.searchImages(for: lastQuery.query)
150 | endRefreshing()
151 | }
152 | }
153 |
154 | // MARK: - UISearchBarDelegate
155 |
156 | extension ImageSearchViewController: UISearchBarDelegate {
157 |
158 | func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
159 | if let searchBarText = searchBar.text {
160 | viewModel.searchBarSearchButtonClicked(with: searchBarText)
161 | }
162 | }
163 |
164 | func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool {
165 | searchBar.showsCancelButton = true
166 | return true
167 | }
168 |
169 | func searchBarShouldEndEditing(_ searchBar: UISearchBar) -> Bool {
170 | searchBar.showsCancelButton = false
171 | return true
172 | }
173 |
174 | func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
175 | searchBar.text = nil
176 | searchBar.resignFirstResponder()
177 | }
178 | }
179 |
180 | // MARK: - UICollectionViewDelegate
181 |
182 | extension ImageSearchViewController: UICollectionViewDelegate {
183 |
184 | func scrollViewDidScroll(_ scrollView: UIScrollView) {
185 | if scrollView.panGestureRecognizer.translation(in: scrollView.superview).y > 0 {
186 | viewModel.scrollUp()
187 | } else {
188 | viewModel.scrollDown(Float(searchBar.frame.height))
189 | }
190 | }
191 |
192 | func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
193 | let selectedImage = viewModel.data.value[indexPath.section]._searchResults[indexPath.row]
194 | if selectedImage.thumbnail == nil { return }
195 |
196 | let query = viewModel.data.value[indexPath.section].searchQuery
197 |
198 | let didFinish = Event()
199 | didFinish.subscribe(self) { [weak self] image in self?.viewModel.updateImage(image, indexPath: indexPath) }
200 |
201 | coordinatorActions?.showImageDetails(selectedImage, query, didFinish)
202 | }
203 |
204 | func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
205 | let section = viewModel.data.value[indexPath.section]
206 | if section._searchResults[indexPath.row].thumbnail == nil {
207 | viewModel.updateSection(section.id)
208 | }
209 | }
210 | }
211 |
212 | // MARK: - Collection View Flow Layout Delegate
213 |
214 | extension ImageSearchViewController: UICollectionViewDelegateFlowLayout {
215 |
216 | func collectionView(_ collectionView: UICollectionView,
217 | layout collectionViewLayout: UICollectionViewLayout,
218 | sizeForItemAt indexPath: IndexPath) -> CGSize {
219 | var itemsPerRow = CGFloat()
220 | if UIWindow.isLandscape {
221 | itemsPerRow = AppConfiguration.ImageCollection.itemsPerRowInHorizOrient
222 | } else {
223 | itemsPerRow = AppConfiguration.ImageCollection.itemsPerRowInVertOrient
224 | }
225 |
226 | let padding = AppConfiguration.ImageCollection.horizontalSpace
227 | let collectionCellSize = collectionView.frame.size.width - (padding*(itemsPerRow+1))
228 |
229 | let width = collectionCellSize/itemsPerRow
230 | let height = CGFloat(viewModel.getHeightOfCell(width: Float(width)))
231 |
232 | return CGSize(width: width, height: height)
233 | }
234 |
235 | func collectionView(_ collectionView: UICollectionView,
236 | layout collectionViewLayout: UICollectionViewLayout,
237 | insetForSectionAt section: Int) -> UIEdgeInsets {
238 | UIEdgeInsets(
239 | top: AppConfiguration.ImageCollection.verticleSpace,
240 | left: AppConfiguration.ImageCollection.horizontalSpace,
241 | bottom: AppConfiguration.ImageCollection.verticleSpace,
242 | right: AppConfiguration.ImageCollection.horizontalSpace
243 | )
244 | }
245 |
246 | func collectionView(_ collectionView: UICollectionView,
247 | layout collectionViewLayout: UICollectionViewLayout,
248 | minimumLineSpacingForSectionAt section: Int) -> CGFloat {
249 | AppConfiguration.ImageCollection.horizontalSpace
250 | }
251 | }
252 |
--------------------------------------------------------------------------------
/ImageSearch/Presentation/ImagesFeature/ImageSearch/View/ImagesDataSource.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | class ImagesDataSource: NSObject {
4 |
5 | private(set) var data = [ImageSearchResultsListItemVM]()
6 |
7 | init(with data: [ImageSearchResultsListItemVM]) {
8 | super.init()
9 | self.data = data
10 | }
11 |
12 | func update(_ data: [ImageSearchResultsListItemVM]) {
13 | self.data = data
14 | }
15 | }
16 |
17 | // MARK: UICollectionViewDataSource
18 |
19 | extension ImagesDataSource: UICollectionViewDataSource {
20 |
21 | func numberOfSections(in collectionView: UICollectionView) -> Int {
22 | data.count
23 | }
24 |
25 | func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
26 | data[section]._searchResults.count
27 | }
28 |
29 | func collectionView(_ collectionView: UICollectionView,
30 | cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
31 | let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ImageCell", for: indexPath) as! CollectionViewCell
32 | let image = data[indexPath.section]._searchResults[indexPath.row]
33 | cell.imageView.image = image.thumbnail?.uiImage
34 | return cell
35 | }
36 |
37 | func collectionView(_ collectionView: UICollectionView,
38 | viewForSupplementaryElementOfKind kind: String,
39 | at indexPath: IndexPath) -> UICollectionReusableView {
40 | if kind == UICollectionView.elementKindSectionHeader {
41 | if let headerView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "ImageSectionHeader", for: indexPath) as? CollectionViewHeader {
42 | let searchQuery = data[indexPath.section].searchQuery.query
43 | headerView.label.text = searchQuery
44 | return headerView
45 | }
46 | }
47 | return UICollectionReusableView()
48 | }
49 | }
50 |
51 |
--------------------------------------------------------------------------------
/ImageSearch/Presentation/ImagesFeature/ImageSearch/ViewModel/DefaultImageSearchViewModel.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /* Use case scenarios:
4 | * searchImagesUseCase.execute(imageQuery)
5 | * imageCachingService.cacheIfNecessary(data)
6 | * imageCachingService.getCachedImages(searchId: searchId)
7 | */
8 |
9 | protocol ImageSearchViewModelInput {
10 | func searchImages(for query: String)
11 | func searchBarSearchButtonClicked(with query: String)
12 | func scrollUp()
13 | func scrollDown(_ searchBarHeight: Float)
14 | func updateSection(_ searchId: String)
15 | func updateImage(_ image: Image, indexPath: IndexPath)
16 | func getHeightOfCell(width: Float) -> Float
17 | func getDataSource() -> ImagesDataSource
18 | }
19 |
20 | protocol ImageSearchViewModelOutput {
21 | var data: Observable<[ImageSearchResultsListItemVM]> { get }
22 | var sectionData: Observable<([ImageSearchResultsListItemVM], IndexSet)> { get }
23 | var scrollTop: Observable { get }
24 | var makeToast: Observable { get }
25 | var resetSearchBar: Observable { get }
26 | var activityIndicatorVisibility: Observable { get }
27 | var collectionViewTopConstraint: Observable { get }
28 | var lastQuery: ImageQuery? { get }
29 | var screenTitle: String { get }
30 | }
31 |
32 | typealias ImageSearchViewModel = ImageSearchViewModelInput & ImageSearchViewModelOutput
33 |
34 | class DefaultImageSearchViewModel: ImageSearchViewModel {
35 |
36 | private let searchImagesUseCase: SearchImagesUseCase
37 | private let imageCachingService: ImageCachingService
38 |
39 | private(set) var lastQuery: ImageQuery?
40 |
41 | let screenTitle = NSLocalizedString("Image Search", comment: "")
42 |
43 | // Bindings
44 | let data: Observable<[ImageSearchResultsListItemVM]> = Observable([])
45 | let sectionData: Observable<([ImageSearchResultsListItemVM], IndexSet)> = Observable(([],[]))
46 | let scrollTop: Observable = Observable(nil)
47 | let makeToast: Observable = Observable("")
48 | let resetSearchBar: Observable = Observable(nil)
49 | let activityIndicatorVisibility: Observable = Observable(false)
50 | let collectionViewTopConstraint: Observable = Observable(0)
51 |
52 | private var imagesLoadTask: Task? {
53 | willSet { imagesLoadTask?.cancel() }
54 | }
55 |
56 | init(searchImagesUseCase: SearchImagesUseCase, imageCachingService: ImageCachingService) {
57 | self.searchImagesUseCase = searchImagesUseCase
58 | self.imageCachingService = imageCachingService
59 |
60 | setup()
61 | }
62 |
63 | private func setup() {
64 | Task {
65 | await imageCachingService.subscribeToDidProcess(self) { [weak self] data in
66 | self?.data.value = data
67 | }
68 | }
69 | }
70 |
71 | func getDataSource() -> ImagesDataSource {
72 | ImagesDataSource(with: data.value)
73 | }
74 |
75 | private func showError(_ msg: String = "") {
76 | makeToast.value = !msg.isEmpty ? msg : NSLocalizedString("An error has occurred", comment: "")
77 | activityIndicatorVisibility.value = false
78 | }
79 |
80 | private func memorySafetyCheck(data: [ImageSearchResults]) {
81 | if AppConfiguration.MemorySafety.enabled {
82 | Task {
83 | await imageCachingService.cacheIfNecessary(data)
84 | }
85 | }
86 | }
87 |
88 | func searchImages(for query: String) {
89 | guard let imageQuery = ImageQuery(query: query) else {
90 | makeToast.value = NSLocalizedString("Search query error", comment: "")
91 | resetSearchBar.value = nil
92 | return
93 | }
94 |
95 | if activityIndicatorVisibility.value && query == lastQuery?.query { return }
96 | activityIndicatorVisibility.value = true
97 |
98 | imagesLoadTask = Task {
99 |
100 | defer {
101 | memorySafetyCheck(data: data.value as! [ImageSearchResults])
102 | }
103 |
104 | let result = await searchImagesUseCase.execute(imageQuery)
105 |
106 | if Task.isCancelled { return }
107 |
108 | switch result {
109 | case .success(let searchResults):
110 | guard let searchResults = searchResults else {
111 | activityIndicatorVisibility.value = false
112 | return
113 | }
114 |
115 | data.value.insert(searchResults, at: 0)
116 | lastQuery = imageQuery
117 |
118 | activityIndicatorVisibility.value = false
119 | scrollTop.value = nil
120 | case .failure(let error):
121 | let defaultMessage = ((error.errorDescription ?? "") + " " + (error.recoverySuggestion ?? "")).trimmingCharacters(in: .whitespacesAndNewlines)
122 | switch error {
123 | case CustomError.app(_, let customMessage):
124 | showError(customMessage ?? defaultMessage)
125 | default:
126 | showError(defaultMessage)
127 | }
128 | }
129 | }
130 | }
131 |
132 | func searchBarSearchButtonClicked(with query: String) {
133 | searchImages(for: query)
134 | resetSearchBar.value = nil
135 | }
136 |
137 | func scrollUp() {
138 | if collectionViewTopConstraint.value != 0 {
139 | collectionViewTopConstraint.value = 0
140 | }
141 | }
142 |
143 | func scrollDown(_ searchBarHeight: Float) {
144 | if collectionViewTopConstraint.value == 0 {
145 | collectionViewTopConstraint.value = searchBarHeight * -1
146 | }
147 | }
148 |
149 | func getHeightOfCell(width: Float) -> Float {
150 | let baseWidth = AppConfiguration.ImageCollection.baseImageWidth
151 | if width > baseWidth {
152 | return baseWidth
153 | } else {
154 | return width
155 | }
156 | }
157 |
158 | func updateSection(_ searchId: String) {
159 | Task {
160 | guard let images = await imageCachingService.getCachedImages(searchId: searchId) else { return }
161 | guard !images.isEmpty else { return }
162 |
163 | var sectionIndex = Int()
164 |
165 | for (index, search) in data.value.enumerated() {
166 | if search.id == searchId {
167 | if let image = search._searchResults.first, image.thumbnail != nil { return }
168 | search._searchResults = images
169 | sectionIndex = index
170 | break
171 | }
172 | }
173 |
174 | sectionData.value = (data.value, [sectionIndex])
175 | }
176 | }
177 |
178 | func updateImage(_ image: Image, indexPath: IndexPath) {
179 | guard data.value.indices.contains(indexPath.section) else { return }
180 | let search = data.value[indexPath.section]
181 | guard search._searchResults.indices.contains(indexPath.row) else { return }
182 | search._searchResults[indexPath.row] = image
183 | }
184 | }
185 |
186 | extension DefaultImageSearchViewModel {
187 | var toTestImagesLoadTask: Task? {
188 | imagesLoadTask
189 | }
190 | }
191 |
--------------------------------------------------------------------------------
/ImageSearch/Presentation/ImagesFeature/MainCoordinator.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 |
3 | protocol MainCoordinatorDIContainer {
4 | func makeImageSearchViewController(actions: ImageSearchCoordinatorActions) -> ImageSearchViewController
5 | func makeImageDetailsViewController(image: Image, imageQuery: ImageQuery, didFinish: Event) -> ImageDetailsViewController
6 | func makeHotTagsViewController(actions: HotTagsCoordinatorActions, didSelect: Event) -> UIViewController
7 | }
8 |
9 | class MainCoordinator: FlowCoordinator {
10 |
11 | let navigationController: UINavigationController
12 | let dependencyContainer: MainCoordinatorDIContainer
13 |
14 | init(navigationController: UINavigationController, dependencyContainer: MainCoordinatorDIContainer) {
15 | self.navigationController = navigationController
16 | self.dependencyContainer = dependencyContainer
17 | }
18 |
19 | func start(completionHandler: CoordinatorStartCompletionHandler?) {
20 | showImageSearch()
21 | }
22 |
23 | private func showImageSearch() {
24 | let actions = ImageSearchCoordinatorActions(
25 | showImageDetails: showImageDetails,
26 | showHotTags: showHotTags
27 | )
28 | let imageSearchVC = dependencyContainer.makeImageSearchViewController(actions: actions)
29 | navigationController.pushViewController(imageSearchVC, animated: false)
30 | }
31 |
32 | private func showImageDetails(image: ImageListItemVM, imageQuery: ImageQuery, didFinish: Event) {
33 | let imageDetailsVC = dependencyContainer.makeImageDetailsViewController(image: image as! Image, imageQuery: imageQuery, didFinish: didFinish)
34 | navigationController.pushViewController(imageDetailsVC, animated: true)
35 | }
36 |
37 | private func showHotTags(didSelect: Event) {
38 | let actions = HotTagsCoordinatorActions(
39 | closeHotTags: closeHotTags
40 | )
41 | let hotTagsVC = dependencyContainer.makeHotTagsViewController(actions: actions, didSelect: didSelect)
42 | let hotTagsNC = UINavigationController(rootViewController: hotTagsVC)
43 | navigationController.topViewController?.show(hotTagsNC, sender: nil)
44 | }
45 |
46 | private func closeHotTags(viewController: UIViewController) {
47 | viewController.dismiss(animated: true, completion: nil)
48 | }
49 | }
50 |
51 |
--------------------------------------------------------------------------------
/ImageSearch/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "iphone",
5 | "size" : "20x20",
6 | "scale" : "2x"
7 | },
8 | {
9 | "idiom" : "iphone",
10 | "size" : "20x20",
11 | "scale" : "3x"
12 | },
13 | {
14 | "idiom" : "iphone",
15 | "size" : "29x29",
16 | "scale" : "2x"
17 | },
18 | {
19 | "idiom" : "iphone",
20 | "size" : "29x29",
21 | "scale" : "3x"
22 | },
23 | {
24 | "idiom" : "iphone",
25 | "size" : "40x40",
26 | "scale" : "2x"
27 | },
28 | {
29 | "idiom" : "iphone",
30 | "size" : "40x40",
31 | "scale" : "3x"
32 | },
33 | {
34 | "idiom" : "iphone",
35 | "size" : "60x60",
36 | "scale" : "2x"
37 | },
38 | {
39 | "idiom" : "iphone",
40 | "size" : "60x60",
41 | "scale" : "3x"
42 | },
43 | {
44 | "idiom" : "ipad",
45 | "size" : "20x20",
46 | "scale" : "1x"
47 | },
48 | {
49 | "idiom" : "ipad",
50 | "size" : "20x20",
51 | "scale" : "2x"
52 | },
53 | {
54 | "idiom" : "ipad",
55 | "size" : "29x29",
56 | "scale" : "1x"
57 | },
58 | {
59 | "idiom" : "ipad",
60 | "size" : "29x29",
61 | "scale" : "2x"
62 | },
63 | {
64 | "idiom" : "ipad",
65 | "size" : "40x40",
66 | "scale" : "1x"
67 | },
68 | {
69 | "idiom" : "ipad",
70 | "size" : "40x40",
71 | "scale" : "2x"
72 | },
73 | {
74 | "idiom" : "ipad",
75 | "size" : "76x76",
76 | "scale" : "1x"
77 | },
78 | {
79 | "idiom" : "ipad",
80 | "size" : "76x76",
81 | "scale" : "2x"
82 | },
83 | {
84 | "idiom" : "ipad",
85 | "size" : "83.5x83.5",
86 | "scale" : "2x"
87 | },
88 | {
89 | "idiom" : "ios-marketing",
90 | "size" : "1024x1024",
91 | "scale" : "1x"
92 | }
93 | ],
94 | "info" : {
95 | "version" : 1,
96 | "author" : "xcode"
97 | }
98 | }
--------------------------------------------------------------------------------
/ImageSearch/Resources/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "version" : 1,
4 | "author" : "xcode"
5 | }
6 | }
--------------------------------------------------------------------------------
/ImageSearch/Resources/Base.lproj/LaunchScreen.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/ImageSearch/Resources/UAObfuscatedString.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UAObfuscatedString.swift
3 | //
4 | // Created by Matt Long on 05/11/16.
5 | // Copyright (c) 2016 Urban Apps. All rights reserved.
6 | //
7 |
8 | // swiftlint:disable identifier_name
9 |
10 | // MARK: - a-z -
11 | public extension String {
12 | var a: String { return self + "a" }
13 | var b: String { return self + "b" }
14 | var c: String { return self + "c" }
15 | var d: String { return self + "d" }
16 | var e: String { return self + "e" }
17 | var f: String { return self + "f" }
18 | var g: String { return self + "g" }
19 | var h: String { return self + "h" }
20 | var i: String { return self + "i" }
21 | var j: String { return self + "j" }
22 | var k: String { return self + "k" }
23 | var l: String { return self + "l" }
24 | var m: String { return self + "m" }
25 | var n: String { return self + "n" }
26 | var o: String { return self + "o" }
27 | var p: String { return self + "p" }
28 | var q: String { return self + "q" }
29 | var r: String { return self + "r" }
30 | var s: String { return self + "s" }
31 | var t: String { return self + "t" }
32 | var u: String { return self + "u" }
33 | var v: String { return self + "v" }
34 | var w: String { return self + "w" }
35 | var x: String { return self + "x" }
36 | var y: String { return self + "y" }
37 | var z: String { return self + "z" }
38 | }
39 |
40 | /*@objc public extension NSMutableString {
41 | var a: NSMutableString { append("a"); return self }
42 | var b: NSMutableString { append("b"); return self }
43 | var c: NSMutableString { append("c"); return self }
44 | var d: NSMutableString { append("d"); return self }
45 | var e: NSMutableString { append("e"); return self }
46 | var f: NSMutableString { append("f"); return self }
47 | var g: NSMutableString { append("g"); return self }
48 | var h: NSMutableString { append("h"); return self }
49 | var i: NSMutableString { append("i"); return self }
50 | var j: NSMutableString { append("j"); return self }
51 | var k: NSMutableString { append("k"); return self }
52 | var l: NSMutableString { append("l"); return self }
53 | var m: NSMutableString { append("m"); return self }
54 | var n: NSMutableString { append("n"); return self }
55 | var o: NSMutableString { append("o"); return self }
56 | var p: NSMutableString { append("p"); return self }
57 | var q: NSMutableString { append("q"); return self }
58 | var r: NSMutableString { append("r"); return self }
59 | var s: NSMutableString { append("s"); return self }
60 | var t: NSMutableString { append("t"); return self }
61 | var u: NSMutableString { append("u"); return self }
62 | var v: NSMutableString { append("v"); return self }
63 | var w: NSMutableString { append("w"); return self }
64 | var x: NSMutableString { append("x"); return self }
65 | var y: NSMutableString { append("y"); return self }
66 | var z: NSMutableString { append("z"); return self }
67 | }*/
68 |
69 | // MARK: - A-Z -
70 | public extension String {
71 | var A: String { return self + "A" }
72 | var B: String { return self + "B" }
73 | var C: String { return self + "C" }
74 | var D: String { return self + "D" }
75 | var E: String { return self + "E" }
76 | var F: String { return self + "F" }
77 | var G: String { return self + "G" }
78 | var H: String { return self + "H" }
79 | var I: String { return self + "I" }
80 | var J: String { return self + "J" }
81 | var K: String { return self + "K" }
82 | var L: String { return self + "L" }
83 | var M: String { return self + "M" }
84 | var N: String { return self + "N" }
85 | var O: String { return self + "O" }
86 | var P: String { return self + "P" }
87 | var Q: String { return self + "Q" }
88 | var R: String { return self + "R" }
89 | var S: String { return self + "S" }
90 | var T: String { return self + "T" }
91 | var U: String { return self + "U" }
92 | var V: String { return self + "V" }
93 | var W: String { return self + "W" }
94 | var X: String { return self + "X" }
95 | var Y: String { return self + "Y" }
96 | var Z: String { return self + "Z" }
97 | }
98 |
99 | /*@objc public extension NSMutableString {
100 | var A: NSMutableString { append("A"); return self }
101 | var B: NSMutableString { append("B"); return self }
102 | var C: NSMutableString { append("C"); return self }
103 | var D: NSMutableString { append("D"); return self }
104 | var E: NSMutableString { append("E"); return self }
105 | var F: NSMutableString { append("F"); return self }
106 | var G: NSMutableString { append("G"); return self }
107 | var H: NSMutableString { append("H"); return self }
108 | var I: NSMutableString { append("I"); return self }
109 | var J: NSMutableString { append("J"); return self }
110 | var K: NSMutableString { append("K"); return self }
111 | var L: NSMutableString { append("L"); return self }
112 | var M: NSMutableString { append("M"); return self }
113 | var N: NSMutableString { append("N"); return self }
114 | var O: NSMutableString { append("O"); return self }
115 | var P: NSMutableString { append("P"); return self }
116 | var Q: NSMutableString { append("Q"); return self }
117 | var R: NSMutableString { append("R"); return self }
118 | var S: NSMutableString { append("S"); return self }
119 | var T: NSMutableString { append("T"); return self }
120 | var U: NSMutableString { append("U"); return self }
121 | var V: NSMutableString { append("V"); return self }
122 | var W: NSMutableString { append("W"); return self }
123 | var X: NSMutableString { append("X"); return self }
124 | var Y: NSMutableString { append("Y"); return self }
125 | var Z: NSMutableString { append("Z"); return self }
126 | }*/
127 |
128 | // MARK: - Numbers -
129 | public extension String {
130 | var _1: String { return self + "1" }
131 | var _2: String { return self + "2" }
132 | var _3: String { return self + "3" }
133 | var _4: String { return self + "4" }
134 | var _5: String { return self + "5" }
135 | var _6: String { return self + "6" }
136 | var _7: String { return self + "7" }
137 | var _8: String { return self + "8" }
138 | var _9: String { return self + "9" }
139 | var _0: String { return self + "0" }
140 | }
141 |
142 | /*@objc public extension NSMutableString {
143 | var _1: NSMutableString { append("1"); return self }
144 | var _2: NSMutableString { append("2"); return self }
145 | var _3: NSMutableString { append("3"); return self }
146 | var _4: NSMutableString { append("4"); return self }
147 | var _5: NSMutableString { append("5"); return self }
148 | var _6: NSMutableString { append("6"); return self }
149 | var _7: NSMutableString { append("7"); return self }
150 | var _8: NSMutableString { append("8"); return self }
151 | var _9: NSMutableString { append("9"); return self }
152 | var _0: NSMutableString { append("0"); return self }
153 | }*/
154 |
155 | // MARK: - Punctuation -
156 | public extension String {
157 | var space: String { return self + " " }
158 | var point: String { return self + "." }
159 | var dash: String { return self + "-" }
160 | var comma: String { return self + "," }
161 | var semicolon: String { return self + ";" }
162 | var colon: String { return self + ":" }
163 | var apostrophe: String { return self + "'" }
164 | var quotation: String { return self + "\"" }
165 | var plus: String { return self + "+" }
166 | var equals: String { return self + "=" }
167 | var paren_left: String { return self + "(" }
168 | var paren_right: String { return self + ")" }
169 | var asterisk: String { return self + "*" }
170 | var ampersand: String { return self + "&" }
171 | var caret: String { return self + "^" }
172 | var percent: String { return self + "%" }
173 | var `$`: String { return self + "$" }
174 | var pound: String { return self + "#" }
175 | var at: String { return self + "@" }
176 | var exclamation: String { return self + "!" }
177 | var question_mark: String { return self + "?" }
178 | var back_slash: String { return self + "\\" }
179 | var forward_slash: String { return self + "/" }
180 | var curly_left: String { return self + "{" }
181 | var curly_right: String { return self + "}" }
182 | var bracket_left: String { return self + "[" }
183 | var bracket_right: String { return self + "]" }
184 | var bar: String { return self + "|" }
185 | var less_than: String { return self + "<" }
186 | var greater_than: String { return self + ">" }
187 | var underscore: String { return self + "_" }
188 | }
189 |
190 | /*@objc public extension NSMutableString {
191 | var space: NSMutableString { append(" "); return self }
192 | var point: NSMutableString { append("."); return self }
193 | var dash: NSMutableString { append("-"); return self }
194 | var comma: NSMutableString { append(","); return self }
195 | var semicolon: NSMutableString { append(";"); return self }
196 | var colon: NSMutableString { append(":"); return self }
197 | var apostrophe: NSMutableString { append("'"); return self }
198 | var quotation: NSMutableString { append("\""); return self }
199 | var plus: NSMutableString { append("+"); return self }
200 | var equals: NSMutableString { append("="); return self }
201 | var paren_left: NSMutableString { append("("); return self }
202 | var paren_right: NSMutableString { append(")"); return self }
203 | var asterisk: NSMutableString { append("*"); return self }
204 | var ampersand: NSMutableString { append("&"); return self }
205 | var caret: NSMutableString { append("^"); return self }
206 | var percent: NSMutableString { append("%"); return self }
207 | var `$`: NSMutableString { append("$"); return self }
208 | var pound: NSMutableString { append("#"); return self }
209 | var at: NSMutableString { append("@"); return self }
210 | var exclamation: NSMutableString { append("!"); return self }
211 | var question_mark: NSMutableString { append("?"); return self }
212 | var back_slash: NSMutableString { append("\\"); return self }
213 | var forward_slash: NSMutableString { append("/"); return self }
214 | var curly_left: NSMutableString { append("{"); return self }
215 | var curly_right: NSMutableString { append("}"); return self }
216 | var bracket_left: NSMutableString { append("["); return self }
217 | var bracket_right: NSMutableString { append("]"); return self }
218 | var bar: NSMutableString { append("|"); return self }
219 | var less_than: NSMutableString { append("<"); return self }
220 | var greater_than: NSMutableString { append(">"); return self }
221 | var underscore: NSMutableString { append("_"); return self }
222 | }*/
223 |
224 | // MARK: - Aliases -
225 | public extension String {
226 | var dot: String { return point }
227 | }
228 |
229 | /*@objc public extension NSMutableString {
230 | var dot: NSMutableString { return point }
231 | }*/
232 |
233 | // swiftlint:enable identifier_name
234 |
235 |
--------------------------------------------------------------------------------
/ImageSearch/Resources/en.lproj/LaunchScreen.strings:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/ImageSearch/Resources/en.lproj/Localizable.strings:
--------------------------------------------------------------------------------
1 | /*
2 | Localizable.strings
3 | ImageSearch
4 |
5 | Created by Denis Simon on 17.04.2024.
6 | */
7 |
8 | "Image Search" = "Image Search";
9 | "Search" = "Search";
10 | "No image to share" = "No image to share";
11 | "An error has occurred" = "An error has occurred";
12 | "Empty search query" = "Empty search query";
13 | "Search query error" = "Search query error";
14 | "Hot Tags" = "Hot Tags";
15 | "Week" = "Week";
16 | "All Times" = "All Times";
17 | "A server error has occurred." = "A server error has occurred.";
18 | "The operation couldn’t be completed." = "The operation couldn’t be completed.";
19 | "Please try again later." = "Please try again later.";
20 | "Please check your Internet connection." = "Please check your Internet connection.";
21 |
--------------------------------------------------------------------------------
/ImageSearch/Resources/es.lproj/LaunchScreen.strings:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/ImageSearch/Resources/es.lproj/Localizable.strings:
--------------------------------------------------------------------------------
1 | /*
2 | Localizable.strings
3 | ImageSearch
4 |
5 | Created by Denis Simon on 17.04.2024.
6 | */
7 |
8 | "Image Search" = "Búsqueda de imágenes";
9 | "Search" = "Buscar";
10 | "No image to share" = "No hay imagen para compartir";
11 | "An error has occurred" = "Se ha producido un error";
12 | "Empty search query" = "Consulta de búsqueda vacía";
13 | "Search query error" = "Error de consulta de búsqueda";
14 | "Hot Tags" = "Etiquetas calientes";
15 | "Week" = "Semana";
16 | "All Times" = "Todos los horarios";
17 | "A server error has occurred." = "Ha ocurrido un error de servidor.";
18 | "The operation couldn’t be completed." = "La operación no se pudo completar.";
19 | "Please try again later." = "Por favor, inténtelo de nuevo más tarde.";
20 | "Please check your Internet connection." = "Por favor, compruebe su conexión a Internet.";
21 |
--------------------------------------------------------------------------------
/ImageSearchTests/Domain/Behaviors/ImageBehaviorTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import ImageSearch
3 |
4 | class ImageBehaviorTests: XCTestCase {
5 |
6 | static var testImageStub: Image {
7 | var testImage = Image(title: "random1", flickr: Image.FlickrImageParameters(imageID: "id1", farm: 1, server: "server", secret: "secret1"))
8 | testImage = ImageBehavior.updateImage(testImage, newWrapper: ImageWrapper(uiImage: UIImage(systemName: "heart.fill")), for: .thumbnail)
9 | return testImage
10 | }
11 |
12 | func testGetFlickrImageURL() {
13 | let thumbnailUrl = ImageBehavior.getFlickrImageURL(ImageBehaviorTests.testImageStub, size: .thumbnail)
14 |
15 | XCTAssertNotNil(thumbnailUrl)
16 | XCTAssertEqual(thumbnailUrl!.description, "https://farm1.staticflickr.com/server/id1_secret1_m.jpg")
17 |
18 | let bigImageURL = ImageBehavior.getFlickrImageURL(ImageBehaviorTests.testImageStub, size: .big)
19 |
20 | XCTAssertNotNil(bigImageURL)
21 | XCTAssertEqual(bigImageURL!.description, "https://farm1.staticflickr.com/server/id1_secret1_b.jpg")
22 | }
23 |
24 | func testUpdateImage() {
25 | var image = ImageBehaviorTests.testImageStub
26 | XCTAssertNil(image.bigImage)
27 |
28 | let imageWrapper = ImageWrapper(uiImage: UIImage(systemName: "square.and.arrow.up"))
29 | image = ImageBehavior.updateImage(image, newWrapper: imageWrapper, for: .big)
30 | XCTAssertNotNil(image.bigImage)
31 |
32 | image = ImageBehavior.updateImage(image, newWrapper: nil, for: .big)
33 | XCTAssertNil(image.bigImage)
34 |
35 | XCTAssertNotNil(image.thumbnail)
36 | image = ImageBehavior.updateImage(image, newWrapper: nil, for: .thumbnail)
37 | XCTAssertNil(image.thumbnail)
38 | }
39 |
40 | func testDeepCopy() {
41 | let image = ImageBehaviorTests.testImageStub
42 | let imageCopy = image.copy()
43 | XCTAssertTrue(image == imageCopy)
44 | XCTAssertFalse(image === imageCopy)
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/ImageSearchTests/Domain/UseCases/ImageCachingServiceTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import ImageSearch
3 |
4 | class ImageCachingServiceTests: XCTestCase {
5 |
6 | static let searchResultsStub = [
7 | ImageSearchResults(id: "id5", searchQuery: ImageQuery(query: "query5")!, searchResults: [Image(title: "image1", flickr: nil), Image(title: "image2", flickr: nil), Image(title: "image3", flickr: nil), Image(title: "image4", flickr: nil)]),
8 | ImageSearchResults(id: "id4", searchQuery: ImageQuery(query: "query4")!, searchResults: [Image(title: "image1", flickr: nil), Image(title: "image2", flickr: nil), Image(title: "image3", flickr: nil), Image(title: "image4", flickr: nil)]),
9 | ImageSearchResults(id: "id3", searchQuery: ImageQuery(query: "query3")!, searchResults: [Image(title: "image1", flickr: nil), Image(title: "image2", flickr: nil), Image(title: "image3", flickr: nil)]),
10 | ImageSearchResults(id: "id2", searchQuery: ImageQuery(query: "query2")!, searchResults: [Image(title: "image1", flickr: nil), Image(title: "image2", flickr: nil)]),
11 | ImageSearchResults(id: "id1", searchQuery: ImageQuery(query: "query1")!, searchResults: [Image(title: "image1", flickr: nil), Image(title: "image2", flickr: nil)])
12 | ]
13 |
14 | static let cachedImagesStub = [
15 | (image: Image(title: "image1", flickr: nil), searchId: "id2", sortId: 1), (image: Image(title: "image2", flickr: nil), searchId: "id2", sortId: 2),
16 | (image: Image(title: "image1", flickr: nil), searchId: "id1", sortId: 1), (image: Image(title: "image2", flickr: nil), searchId: "id1", sortId: 2)
17 | ]
18 |
19 | static let syncQueue = DispatchQueue(label: "ImageCachingServiceTests")
20 |
21 | class ImageRepositoryMock: ImageRepository {
22 |
23 | var apiMethodsCallsCount = 0
24 | var dbMethodsCallsCount = 0
25 |
26 | var cachedImages: [(image: Image, searchId: String, sortId: Int)] = []
27 |
28 | init(cachedImages: [(image: Image, searchId: String, sortId: Int)] = []) {
29 | if !cachedImages.isEmpty {
30 | self.cachedImages = cachedImages
31 | }
32 | }
33 |
34 | // API methods
35 |
36 | func searchImages(_ imageQuery: ImageQuery) async -> Result<[ImageType], CustomError> {
37 | ImageCachingServiceTests.syncQueue.sync {
38 | apiMethodsCallsCount += 1
39 | }
40 | return .success([])
41 | }
42 |
43 | func getImage(url: URL) async -> Data? {
44 | ImageCachingServiceTests.syncQueue.sync {
45 | apiMethodsCallsCount += 1
46 | }
47 | return UIImage(systemName: "heart.fill")?.pngData()
48 | }
49 |
50 | // DB methods
51 |
52 | func saveImage(_ image: Image, searchId: String, sortId: Int) async -> Bool? {
53 | ImageCachingServiceTests.syncQueue.sync {
54 | dbMethodsCallsCount += 1
55 | cachedImages.append((image, searchId, sortId))
56 | }
57 | return true
58 | }
59 |
60 | func getImages(searchId: String) async -> [ImageType]? {
61 | ImageCachingServiceTests.syncQueue.sync {
62 | dbMethodsCallsCount += 1
63 | }
64 | var images: [ImageType] = []
65 | for image in cachedImages {
66 | if image.searchId == searchId {
67 | ImageCachingServiceTests.syncQueue.sync {
68 | images.append(image.image)
69 | }
70 | }
71 | }
72 | ImageCachingServiceTests.syncQueue.sync {}
73 | return images
74 | }
75 |
76 | func checkImagesAreCached(searchId: String) async -> Bool? {
77 | ImageCachingServiceTests.syncQueue.sync {
78 | dbMethodsCallsCount += 1
79 | }
80 | for image in cachedImages {
81 | if image.searchId == searchId {
82 | return true
83 | }
84 | }
85 | return false
86 | }
87 |
88 | // Called once when initializing the ImageCachingService to clear the Image table
89 | func deleteAllImages() async {}
90 | }
91 |
92 | func testCacheIfNecessary_whenThumbnailsAreNil() async {
93 | var completionCallsCount = 0
94 | var precessedData: [ImageSearchResults] = []
95 |
96 | let imageRepository = ImageRepositoryMock()
97 | let imageCachingService = DefaultImageCachingService(imageRepository: imageRepository)
98 | await imageCachingService.subscribeToDidProcess(self) { newData in
99 | completionCallsCount += 1
100 | precessedData = newData
101 | }
102 |
103 | await imageCachingService.cacheIfNecessary(ImageCachingServiceTests.searchResultsStub)
104 |
105 | XCTAssertEqual(completionCallsCount, 1)
106 | XCTAssertEqual(precessedData.count, 5)
107 | ImageCachingServiceTests.syncQueue.sync {
108 | XCTAssertEqual(imageRepository.apiMethodsCallsCount, 0)
109 | XCTAssertEqual(imageRepository.dbMethodsCallsCount, 0)
110 | XCTAssertEqual(imageRepository.cachedImages.count, 0)
111 | }
112 | }
113 |
114 | func testCacheIfNecessary_whenThumbnailsAreNotNil() async {
115 | var completionCallsCount = 0
116 | var precessedData: [ImageSearchResults] = []
117 |
118 | let imageRepository = ImageRepositoryMock()
119 | let imageCachingService = DefaultImageCachingService(imageRepository: imageRepository)
120 | await imageCachingService.subscribeToDidProcess(self) { newData in
121 | completionCallsCount += 1
122 | precessedData = newData
123 | }
124 |
125 | var image1 = Image(title: "image1", flickr: nil)
126 | image1 = ImageBehavior.updateImage(image1, newWrapper: ImageWrapper(uiImage: UIImage()), for: .thumbnail)
127 | var image2 = Image(title: "image2", flickr: nil)
128 | image2 = ImageBehavior.updateImage(image2, newWrapper: ImageWrapper(uiImage: UIImage()), for: .thumbnail)
129 | var image3 = Image(title: "image3", flickr: nil)
130 | image3 = ImageBehavior.updateImage(image3, newWrapper: ImageWrapper(uiImage: UIImage()), for: .thumbnail)
131 | var image4 = Image(title: "image4", flickr: nil)
132 | image4 = ImageBehavior.updateImage(image4, newWrapper: ImageWrapper(uiImage: UIImage()), for: .thumbnail)
133 | let testSearchResults = [
134 | ImageSearchResults(id: "id5", searchQuery: ImageQuery(query: "query5")!, searchResults: [image1, image2, image3, image4]),
135 | ImageSearchResults(id: "id4", searchQuery: ImageQuery(query: "query4")!, searchResults: [image1, image2, image3, image4]),
136 | ImageSearchResults(id: "id3", searchQuery: ImageQuery(query: "query3")!, searchResults: [image1, image2, image3]),
137 | ImageSearchResults(id: "id2", searchQuery: ImageQuery(query: "query2")!, searchResults: [image1, image2]),
138 | ImageSearchResults(id: "id1", searchQuery: ImageQuery(query: "query1")!, searchResults: [image1, image2])
139 | ]
140 |
141 | await imageCachingService.cacheIfNecessary(testSearchResults)
142 |
143 | XCTAssertEqual(completionCallsCount, 1)
144 | ImageCachingServiceTests.syncQueue.sync {
145 | XCTAssertEqual(imageRepository.apiMethodsCallsCount, 0)
146 | XCTAssertEqual(imageRepository.dbMethodsCallsCount, 6) // checkImagesAreCached(), saveImage() 2 times, checkImagesAreCached(), and saveImage() 2 times
147 | }
148 | XCTAssertEqual(precessedData.count, 5)
149 | for image in precessedData[3].searchResults {
150 | XCTAssertNil(image.thumbnail) // thumbnails of the 2nd search have been cleared from memory
151 | }
152 | for image in precessedData[4].searchResults {
153 | XCTAssertNil(image.thumbnail) // thumbnails of the 1st search have been cleared from memory
154 | }
155 | ImageCachingServiceTests.syncQueue.sync {
156 | XCTAssertEqual(imageRepository.cachedImages.count, 4) // 2 images of the 1st search and 2 images of the 2nd search in ImageCachingServiceTests.searchResultsStub have been cached
157 | }
158 | }
159 |
160 | func testGetCachedImages_whenThereAreNoCachedImages() async {
161 | let imageRepository = ImageRepositoryMock()
162 | let imageCachingService = DefaultImageCachingService(imageRepository: imageRepository)
163 |
164 | let retrievedImagesFromCache = await imageCachingService.getCachedImages(searchId: "id2")
165 |
166 | XCTAssertNotNil(retrievedImagesFromCache)
167 | ImageCachingServiceTests.syncQueue.sync {
168 | XCTAssertEqual(imageRepository.apiMethodsCallsCount, 0)
169 | XCTAssertEqual(imageRepository.dbMethodsCallsCount, 1) // getImages()
170 | }
171 | let count = await imageCachingService.toTestSearchIdsFromCache.count
172 | XCTAssertEqual(count, 1)
173 | XCTAssertEqual(retrievedImagesFromCache!.count, 0)
174 | }
175 |
176 | func testGetCachedImages_whenThereAreCachedImages() async {
177 | let imageRepository = ImageRepositoryMock(cachedImages: ImageCachingServiceTests.cachedImagesStub)
178 | let imageCachingService = DefaultImageCachingService(imageRepository: imageRepository)
179 |
180 | let retrievedImagesFromCache = await imageCachingService.getCachedImages(searchId: "id2")
181 |
182 | XCTAssertNotNil(retrievedImagesFromCache)
183 | ImageCachingServiceTests.syncQueue.sync {
184 | XCTAssertEqual(imageRepository.apiMethodsCallsCount, 0)
185 | XCTAssertEqual(imageRepository.dbMethodsCallsCount, 1) // getImages()
186 | }
187 | let count = await imageCachingService.toTestSearchIdsFromCache.count
188 | XCTAssertEqual(count, 1)
189 | XCTAssertEqual(retrievedImagesFromCache!.count, 2)
190 | }
191 | }
192 |
--------------------------------------------------------------------------------
/ImageSearchTests/Domain/UseCases/ImagesFeatureUseCasesTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import ImageSearch
3 |
4 | class ImagesFeatureUseCasesTests: XCTestCase {
5 |
6 | static let imagesStub = [
7 | Image(title: "random1", flickr: Image.FlickrImageParameters(imageID: "id1", farm: 1, server: "server", secret: "secret1")),
8 | Image(title: "random2", flickr: Image.FlickrImageParameters(imageID: "id2", farm: 1, server: "server", secret: "secret2")),
9 | Image(title: "random3", flickr: Image.FlickrImageParameters(imageID: "id3", farm: 1, server: "server", secret: "secret3"))
10 | ]
11 |
12 | static var testImageStub: Image {
13 | var testImage = Image(title: "random1", flickr: Image.FlickrImageParameters(imageID: "id1", farm: 1, server: "server", secret: "secret1"))
14 | testImage = ImageBehavior.updateImage(testImage, newWrapper: ImageWrapper(uiImage: UIImage(systemName: "heart.fill")), for: .thumbnail)
15 | return testImage
16 | }
17 |
18 | static let imagesDataStub = """
19 | {"photos":{"page":1,"pages":5632,"perpage":2,"total":112631,"photo":[{"id":"53910549451","owner":"183377981@N03","secret":"e5d42720cb","server":"65535","farm":66,"title":"Bees at rest","ispublic":1,"isfriend":0,"isfamily":0},{"id":"53910928365","owner":"44904157@N04","secret":"1b80873d60","server":"65535","farm":66,"title":"Wagon Load of Flowers","ispublic":1,"isfriend":0,"isfamily":0}]},"stat":"ok"}
20 | """.data(using: .utf8)
21 |
22 | static let tagsStub = Tags(
23 | hottags: Tags.HotTags(tag: [Tag(name: "tag1"), Tag(name: "tag2")]),
24 | stat: "ok")
25 |
26 | static let syncQueue = DispatchQueue(label: "ImagesFeatureUseCasesTests")
27 |
28 | class ImageRepositoryMock: ImageRepository {
29 |
30 | let result: Result<[ImageType], CustomError>?
31 | var apiMethodsCallsCount = 0
32 | var dbMethodsCallsCount = 0
33 |
34 | init(result: Result<[ImageType], CustomError>? = nil) {
35 | self.result = result
36 | }
37 |
38 | // API methods
39 |
40 | func searchImages(_ imageQuery: ImageQuery) async -> Result<[ImageType], CustomError> {
41 | ImagesFeatureUseCasesTests.syncQueue.sync {
42 | apiMethodsCallsCount += 1
43 | }
44 | return result!
45 | }
46 |
47 | func getImage(url: URL) async -> Data? {
48 | ImagesFeatureUseCasesTests.syncQueue.sync {
49 | apiMethodsCallsCount += 1
50 | }
51 | return UIImage(systemName: "heart.fill")?.pngData()
52 | }
53 |
54 | // DB methods
55 |
56 | func saveImage(_ image: Image, searchId: String, sortId: Int) async -> Bool? {
57 | ImagesFeatureUseCasesTests.syncQueue.sync {
58 | dbMethodsCallsCount += 1
59 | }
60 | return true
61 | }
62 |
63 | func getImages(searchId: String) async -> [ImageType]? {
64 | ImagesFeatureUseCasesTests.syncQueue.sync {
65 | dbMethodsCallsCount += 1
66 | }
67 | return []
68 | }
69 |
70 | func checkImagesAreCached(searchId: String) async -> Bool? {
71 | ImagesFeatureUseCasesTests.syncQueue.sync {
72 | dbMethodsCallsCount += 1
73 | }
74 | return nil
75 | }
76 |
77 | // Called once when initializing the ImageCachingService to clear the Image table
78 | func deleteAllImages() async {}
79 | }
80 |
81 | class TagRepositoryMock: TagRepository {
82 |
83 | let result: Result
84 | var apiMethodsCallsCount = 0
85 |
86 | init(result: Result) {
87 | self.result = result
88 | }
89 |
90 | func getHotTags() async -> Result {
91 | ImagesFeatureUseCasesTests.syncQueue.sync {
92 | apiMethodsCallsCount += 1
93 | }
94 | return result
95 | }
96 | }
97 |
98 | // MARK: - SearchImagesUseCase
99 |
100 | func testSearchImagesUseCase_whenResultIsSuccess() async {
101 | let imageRepository = ImageRepositoryMock(result: .success(ImagesFeatureUseCasesTests.imagesStub))
102 | let searchImagesUseCase = DefaultSearchImagesUseCase(imageRepository: imageRepository)
103 |
104 | let imageQuery = ImageQuery(query: "random")!
105 | let result = await searchImagesUseCase.execute(imageQuery)
106 | let images = (try? result.get())?.searchResults
107 |
108 | XCTAssertNotNil(images)
109 | XCTAssertEqual(images!.count, 3)
110 | XCTAssertTrue(images!.contains(ImagesFeatureUseCasesTests.testImageStub))
111 |
112 | ImagesFeatureUseCasesTests.syncQueue.sync {
113 | XCTAssertEqual(imageRepository.apiMethodsCallsCount, 4) // searchImages(), and getImage() 3 times
114 | XCTAssertEqual(imageRepository.dbMethodsCallsCount, 0)
115 | }
116 | }
117 |
118 | func testPrepareImages() async {
119 | let diContainer = DIContainer()
120 | let imageRepository = DefaultImageRepository(apiInteractor: diContainer.apiInteractor, imageDBInteractor: diContainer.imageDBInteractor)
121 |
122 | let images = imageRepository.toTestPrepareImages(ImagesFeatureUseCasesTests.imagesDataStub)
123 | XCTAssertNotNil(images)
124 | XCTAssertEqual(images!.count, 2)
125 | XCTAssertEqual(images![0].flickr?.imageID, "53910549451")
126 | XCTAssertEqual(images![1].flickr?.imageID, "53910928365")
127 | }
128 |
129 | func testSearchImagesUseCase_whenResultIsFailure() async {
130 | let imageRepository = ImageRepositoryMock(result: .failure(CustomError.internetConnection()))
131 | let searchImagesUseCase = DefaultSearchImagesUseCase(imageRepository: imageRepository)
132 |
133 | let imageQuery = ImageQuery(query: "random")!
134 | let result = await searchImagesUseCase.execute(imageQuery)
135 | let images = (try? result.get())?.searchResults
136 |
137 | XCTAssertNil(images)
138 |
139 | ImagesFeatureUseCasesTests.syncQueue.sync {
140 | XCTAssertEqual(imageRepository.apiMethodsCallsCount, 1) // searchImages()
141 | XCTAssertEqual(imageRepository.dbMethodsCallsCount, 0)
142 | }
143 | }
144 |
145 | // MARK: - GetBigImageUseCase
146 |
147 | func testGetBigImageUseCase() async {
148 | let imageRepository = ImageRepositoryMock()
149 | let getBigImageUseCase = DefaultGetBigImageUseCase(imageRepository: imageRepository)
150 |
151 | let bigImageData = await getBigImageUseCase.execute(for: ImagesFeatureUseCasesTests.testImageStub)
152 |
153 | XCTAssertNotNil(bigImageData)
154 | XCTAssertTrue(!bigImageData!.isEmpty)
155 | if let expectedImageData = UIImage(systemName: "heart.fill")?.pngData() {
156 | XCTAssertEqual(bigImageData, expectedImageData)
157 | }
158 | ImagesFeatureUseCasesTests.syncQueue.sync {
159 | XCTAssertEqual(imageRepository.apiMethodsCallsCount, 1) // getImage()
160 | XCTAssertEqual(imageRepository.dbMethodsCallsCount, 0)
161 | }
162 | }
163 |
164 | // MARK: - GetHotTagsUseCase
165 |
166 | func testGetHotTagsUseCase_whenResultIsSuccess() async {
167 | let tagRepository = TagRepositoryMock(result: .success(ImagesFeatureUseCasesTests.tagsStub))
168 | let getHotTagsUseCase = DefaultGetHotTagsUseCase(tagRepository: tagRepository)
169 |
170 | let tagsResult = await getHotTagsUseCase.execute()
171 |
172 | let hotTags = try? tagsResult.get().tags
173 |
174 | XCTAssertNotNil(hotTags)
175 | XCTAssertEqual(hotTags!.count, 2)
176 | ImagesFeatureUseCasesTests.syncQueue.sync {
177 | XCTAssertEqual(tagRepository.apiMethodsCallsCount, 1)
178 | }
179 | }
180 |
181 | func testGetHotTagsUseCase_whenResultIsFailure() async {
182 | let tagRepository = TagRepositoryMock(result: .failure(CustomError.internetConnection()))
183 | let getHotTagsUseCase = DefaultGetHotTagsUseCase(tagRepository: tagRepository)
184 |
185 | let tagsResult = await getHotTagsUseCase.execute()
186 |
187 | let hotTags = try? tagsResult.get().tags
188 |
189 | XCTAssertNil(hotTags)
190 | ImagesFeatureUseCasesTests.syncQueue.sync {
191 | XCTAssertEqual(tagRepository.apiMethodsCallsCount, 1)
192 | }
193 | }
194 | }
195 |
--------------------------------------------------------------------------------
/ImageSearchTests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 |
22 |
23 |
--------------------------------------------------------------------------------
/ImageSearchTests/Presentation/HotTagsViewModelTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import ImageSearch
3 |
4 | class HotTagsViewModelTests: XCTestCase {
5 |
6 | var observablesTriggerCount = 0
7 |
8 | static let tagsStub = Tags(
9 | hottags: Tags.HotTags(tag: [Tag(name: "tag1"), Tag(name: "tag2"), Tag(name: "tag3")]),
10 | stat: "ok")
11 |
12 | static let syncQueue = DispatchQueue(label: "HotTagsViewModelTests")
13 |
14 | class TagRepositoryMock: TagRepository {
15 |
16 | let result: Result
17 | var apiMethodsCallsCount = 0
18 |
19 | init(result: Result) {
20 | self.result = result
21 | }
22 |
23 | func getHotTags() async -> Result {
24 | HotTagsViewModelTests.syncQueue.sync {
25 | apiMethodsCallsCount += 1
26 | }
27 | return result
28 | }
29 | }
30 |
31 | override func tearDown() {
32 | super.tearDown()
33 | HotTagsViewModelTests.syncQueue.sync {
34 | observablesTriggerCount = 0
35 | }
36 | }
37 |
38 | private func bind(_ hotTagsViewModel: HotTagsViewModel) {
39 | hotTagsViewModel.data.bind(self) { [weak self] _ in
40 | HotTagsViewModelTests.syncQueue.sync {
41 | self?.observablesTriggerCount += 1
42 | }
43 | }
44 | hotTagsViewModel.makeToast.bind(self) { [weak self] _ in
45 | HotTagsViewModelTests.syncQueue.sync {
46 | self?.observablesTriggerCount += 1
47 | }
48 | }
49 | hotTagsViewModel.activityIndicatorVisibility.bind(self) { [weak self] _ in
50 | HotTagsViewModelTests.syncQueue.sync {
51 | self?.observablesTriggerCount += 1
52 | }
53 | }
54 | }
55 |
56 | func testGetHotTags_whenResultIsSuccess() async throws {
57 | let tagRepository = TagRepositoryMock(result: .success(HotTagsViewModelTests.tagsStub))
58 | let getHotTagsUseCase = DefaultGetHotTagsUseCase(tagRepository: tagRepository)
59 | let didSelect = Event()
60 | let hotTagsViewModel = DefaultHotTagsViewModel(getHotTagsUseCase: getHotTagsUseCase, didSelect: didSelect)
61 | bind(hotTagsViewModel)
62 |
63 | XCTAssertTrue(hotTagsViewModel.data.value.isEmpty)
64 |
65 | hotTagsViewModel.getHotTags()
66 | await hotTagsViewModel.toTestHotTagsLoadTask?.value
67 |
68 | XCTAssertEqual(hotTagsViewModel.data.value.count, 3)
69 | XCTAssertEqual(hotTagsViewModel.makeToast.value, "")
70 | HotTagsViewModelTests.syncQueue.sync {
71 | XCTAssertEqual(observablesTriggerCount, 3) // activityIndicatorVisibility, activityIndicatorVisibility, data
72 | }
73 | }
74 |
75 | func testGetHotTags_whenResultIsFailure() async throws {
76 | let tagRepository = TagRepositoryMock(result: .failure(CustomError.internetConnection()))
77 | let getHotTagsUseCase = DefaultGetHotTagsUseCase(tagRepository: tagRepository)
78 | let didSelect = Event()
79 | let hotTagsViewModel = DefaultHotTagsViewModel(getHotTagsUseCase: getHotTagsUseCase, didSelect: didSelect)
80 | bind(hotTagsViewModel)
81 |
82 | XCTAssertTrue(hotTagsViewModel.data.value.isEmpty)
83 |
84 | hotTagsViewModel.getHotTags()
85 | await hotTagsViewModel.toTestHotTagsLoadTask?.value
86 |
87 | XCTAssertTrue(hotTagsViewModel.data.value.isEmpty)
88 | XCTAssertNotEqual(hotTagsViewModel.makeToast.value, "")
89 | HotTagsViewModelTests.syncQueue.sync {
90 | XCTAssertEqual(observablesTriggerCount, 4) // activityIndicatorVisibility, makeToast, activityIndicatorVisibility, data
91 | }
92 | }
93 |
94 | func testOnSelectedSegmentChange_whenAllTimesSelected() {
95 | let tagRepository = TagRepositoryMock(result: .success(HotTagsViewModelTests.tagsStub))
96 | let getHotTagsUseCase = DefaultGetHotTagsUseCase(tagRepository: tagRepository)
97 | let didSelect = Event()
98 | let hotTagsViewModel = DefaultHotTagsViewModel(getHotTagsUseCase: getHotTagsUseCase, didSelect: didSelect)
99 | bind(hotTagsViewModel)
100 |
101 | XCTAssertTrue(hotTagsViewModel.data.value.isEmpty)
102 |
103 | hotTagsViewModel.onSelectedSegmentChange(1)
104 |
105 | XCTAssertFalse(hotTagsViewModel.data.value.isEmpty)
106 | XCTAssertEqual(hotTagsViewModel.data.value[5].name, "nature")
107 | HotTagsViewModelTests.syncQueue.sync {
108 | XCTAssertEqual(observablesTriggerCount, 1) // data
109 | }
110 | }
111 |
112 | func testOnSelectedSegmentChange_whenWeekSelected() async throws {
113 | let tagRepository = TagRepositoryMock(result: .success(HotTagsViewModelTests.tagsStub))
114 | let getHotTagsUseCase = DefaultGetHotTagsUseCase(tagRepository: tagRepository)
115 | let didSelect = Event()
116 | let hotTagsViewModel = DefaultHotTagsViewModel(getHotTagsUseCase: getHotTagsUseCase, didSelect: didSelect)
117 | bind(hotTagsViewModel)
118 |
119 | XCTAssertTrue(hotTagsViewModel.data.value.isEmpty)
120 |
121 | hotTagsViewModel.onSelectedSegmentChange(1) // triggers 'data'
122 | XCTAssertEqual(hotTagsViewModel.data.value[0].name, "sunset")
123 |
124 | hotTagsViewModel.onSelectedSegmentChange(0) // triggers 'data' (data.value = []), 'activityIndicatorVisibility', 'activityIndicatorVisibility', 'data'
125 | XCTAssertTrue(hotTagsViewModel.data.value.isEmpty)
126 | await hotTagsViewModel.toTestHotTagsLoadTask?.value
127 | XCTAssertEqual(hotTagsViewModel.data.value[0].name, "tag1")
128 |
129 | hotTagsViewModel.onSelectedSegmentChange(1) // triggers 'data'
130 | XCTAssertEqual(hotTagsViewModel.data.value[0].name, "sunset")
131 |
132 | hotTagsViewModel.onSelectedSegmentChange(0) // triggers 'data'
133 | XCTAssertFalse(hotTagsViewModel.data.value.isEmpty)
134 | XCTAssertEqual(hotTagsViewModel.data.value[0].name, "tag1")
135 |
136 | HotTagsViewModelTests.syncQueue.sync {
137 | XCTAssertEqual(observablesTriggerCount, 7) // data, data, activityIndicatorVisibility, activityIndicatorVisibility, data, data, data
138 | }
139 | }
140 | }
141 |
--------------------------------------------------------------------------------
/ImageSearchTests/Presentation/ImageDetailsViewModelTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import ImageSearch
3 |
4 | class ImageDetailsViewModelTests: XCTestCase {
5 |
6 | var imageDetailsViewModel: ImageDetailsViewModel!
7 |
8 | var observablesTriggerCount = 0
9 |
10 | static var testImageStub: Image {
11 | var testImage = Image(title: "random1", flickr: Image.FlickrImageParameters(imageID: "id1", farm: 1, server: "server", secret: "secret1"))
12 | testImage = ImageBehavior.updateImage(testImage, newWrapper: ImageWrapper(uiImage: UIImage(systemName: "heart.fill")), for: .thumbnail)
13 | testImage = ImageBehavior.updateImage(testImage, newWrapper: nil, for: .big)
14 | return testImage
15 | }
16 |
17 | static let syncQueue = DispatchQueue(label: "ImageDetailsViewModelTests")
18 |
19 | class ImageRepositoryMock: ImageRepository {
20 |
21 | var apiMethodsCallsCount = 0
22 | var dbMethodsCallsCount = 0
23 |
24 | // API methods
25 |
26 | func searchImages(_ imageQuery: ImageQuery) async -> Result<[ImageType], CustomError> {
27 | ImageDetailsViewModelTests.syncQueue.sync {
28 | apiMethodsCallsCount += 1
29 | }
30 | return .success([])
31 | }
32 |
33 | func getImage(url: URL) async -> Data? {
34 | ImageDetailsViewModelTests.syncQueue.sync {
35 | apiMethodsCallsCount += 1
36 | }
37 | return UIImage(systemName: "heart.fill")?.pngData()
38 | }
39 |
40 | // DB methods
41 |
42 | func saveImage(_ image: Image, searchId: String, sortId: Int) async -> Bool? {
43 | ImageDetailsViewModelTests.syncQueue.sync {
44 | dbMethodsCallsCount += 1
45 | }
46 | return true
47 | }
48 |
49 | func getImages(searchId: String) async -> [ImageType]? {
50 | ImageDetailsViewModelTests.syncQueue.sync {
51 | dbMethodsCallsCount += 1
52 | }
53 | return []
54 | }
55 |
56 | func checkImagesAreCached(searchId: String) async -> Bool? {
57 | ImageDetailsViewModelTests.syncQueue.sync {
58 | dbMethodsCallsCount += 1
59 | }
60 | return nil
61 | }
62 |
63 | // Called once when initializing the ImageCachingService to clear the Image table
64 | func deleteAllImages() async {}
65 | }
66 |
67 | override func setUp() {
68 | super.setUp()
69 |
70 | let imageRepository = ImageRepositoryMock()
71 | let getBigImageUseCase = DefaultGetBigImageUseCase(imageRepository: imageRepository)
72 |
73 | let didFinish = Event()
74 | imageDetailsViewModel = DefaultImageDetailsViewModel(getBigImageUseCase: getBigImageUseCase, image: ImageDetailsViewModelTests.testImageStub, imageQuery: ImageQuery(query: "random")!, didFinish: didFinish)
75 |
76 | imageDetailsViewModel.data.bind(self) { [weak self] _ in
77 | ImageDetailsViewModelTests.syncQueue.sync {
78 | self?.observablesTriggerCount += 1
79 | }
80 | }
81 |
82 | imageDetailsViewModel.makeToast.bind(self) { [weak self] _ in
83 | ImageDetailsViewModelTests.syncQueue.sync {
84 | self?.observablesTriggerCount += 1
85 | }
86 | }
87 |
88 | imageDetailsViewModel.shareImage.bind(self) { [weak self] _ in
89 | ImageDetailsViewModelTests.syncQueue.sync {
90 | self?.observablesTriggerCount += 1
91 | }
92 | }
93 |
94 | imageDetailsViewModel.activityIndicatorVisibility.bind(self) { [weak self] _ in
95 | ImageDetailsViewModelTests.syncQueue.sync {
96 | self?.observablesTriggerCount += 1
97 | }
98 | }
99 | }
100 |
101 | override func tearDown() {
102 | super.tearDown()
103 | ImageDetailsViewModelTests.syncQueue.sync {
104 | imageDetailsViewModel = nil
105 | }
106 | }
107 |
108 | func testLoadBigImage() async throws {
109 | XCTAssertNil(imageDetailsViewModel.image.bigImage)
110 | XCTAssertNil(imageDetailsViewModel.data.value)
111 |
112 | imageDetailsViewModel.loadBigImage()
113 | await (imageDetailsViewModel as! DefaultImageDetailsViewModel).toTestImageLoadTask?.value
114 |
115 | XCTAssertNotNil(imageDetailsViewModel.image.bigImage)
116 | XCTAssertNotNil(imageDetailsViewModel.data.value)
117 | XCTAssertNotNil(imageDetailsViewModel.data.value?.uiImage)
118 | ImageDetailsViewModelTests.syncQueue.sync {
119 | XCTAssertEqual(observablesTriggerCount, 3) // activityIndicatorVisibility, data, activityIndicatorVisibility
120 | }
121 | }
122 |
123 | func testGetTitle() {
124 | let title = imageDetailsViewModel.getTitle()
125 |
126 | XCTAssertEqual(title, "random")
127 | ImageDetailsViewModelTests.syncQueue.sync {
128 | XCTAssertEqual(observablesTriggerCount, 0)
129 | }
130 | }
131 |
132 | func testSharedImage() async throws {
133 | imageDetailsViewModel.onShareButton()
134 | XCTAssertTrue(imageDetailsViewModel.shareImage.value.isEmpty)
135 | XCTAssertEqual(imageDetailsViewModel.makeToast.value, NSLocalizedString("No image to share", comment: ""))
136 |
137 | imageDetailsViewModel.loadBigImage()
138 | await (imageDetailsViewModel as! DefaultImageDetailsViewModel).toTestImageLoadTask?.value
139 |
140 | imageDetailsViewModel.onShareButton()
141 | XCTAssertFalse(imageDetailsViewModel.shareImage.value.isEmpty)
142 |
143 | ImageDetailsViewModelTests.syncQueue.sync {
144 | XCTAssertEqual(observablesTriggerCount, 5) // makeToast, activityIndicatorVisibility, data, activityIndicatorVisibility, shareImage
145 | }
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/ImageSearchTests/Presentation/ImageSearchViewModelTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import ImageSearch
3 |
4 | class ImageSearchViewModelTests: XCTestCase {
5 |
6 | var observablesTriggerCount = 0
7 |
8 | static let imagesStub = [
9 | Image(title: "random1", flickr: Image.FlickrImageParameters(imageID: "id1", farm: 1, server: "server", secret: "secret1")),
10 | Image(title: "random2", flickr: Image.FlickrImageParameters(imageID: "id2", farm: 1, server: "server", secret: "secret2")),
11 | Image(title: "random3", flickr: Image.FlickrImageParameters(imageID: "id3", farm: 1, server: "server", secret: "secret3"))
12 | ]
13 |
14 | static var testImageStub: Image {
15 | var testImage = Image(title: "random1", flickr: Image.FlickrImageParameters(imageID: "id1", farm: 1, server: "server", secret: "secret1"))
16 | testImage = ImageBehavior.updateImage(testImage, newWrapper: ImageWrapper(uiImage: UIImage(systemName: "heart.fill")), for: .thumbnail)
17 | return testImage
18 | }
19 |
20 | static let searchResultsStub = [
21 | ImageSearchResults(id: "id5", searchQuery: ImageQuery(query: "query5")!, searchResults: [Image(title: "image1", flickr: nil), Image(title: "image2", flickr: nil), Image(title: "image3", flickr: nil), Image(title: "image4", flickr: nil)]),
22 | ImageSearchResults(id: "id4", searchQuery: ImageQuery(query: "query4")!, searchResults: [Image(title: "image1", flickr: nil), Image(title: "image2", flickr: nil), Image(title: "image3", flickr: nil), Image(title: "image4", flickr: nil)]),
23 | ImageSearchResults(id: "id3", searchQuery: ImageQuery(query: "query3")!, searchResults: [Image(title: "image1", flickr: nil), Image(title: "image2", flickr: nil), Image(title: "image3", flickr: nil)]),
24 | ImageSearchResults(id: "id2", searchQuery: ImageQuery(query: "query2")!, searchResults: [Image(title: "image1", flickr: nil), Image(title: "image2", flickr: nil)]),
25 | ImageSearchResults(id: "id1", searchQuery: ImageQuery(query: "query1")!, searchResults: [Image(title: "image1", flickr: nil), Image(title: "image2", flickr: nil)])
26 | ]
27 |
28 | static let cachedImagesStub = [
29 | (image: Image(title: "image1", flickr: nil), searchId: "id2", sortId: 1), (image: Image(title: "image2", flickr: nil), searchId: "id2", sortId: 2),
30 | (image: Image(title: "image1", flickr: nil), searchId: "id1", sortId: 1), (image: Image(title: "image2", flickr: nil), searchId: "id1", sortId: 2)
31 | ]
32 |
33 | static let syncQueue = DispatchQueue(label: "ImageSearchViewModelTests")
34 |
35 | class ImageRepositoryMock: ImageRepository {
36 |
37 | let result: Result<[ImageType], CustomError>?
38 | var cachedImages: [(image: Image, searchId: String, sortId: Int)] = []
39 | var apiMethodsCallsCount = 0
40 | var dbMethodsCallsCount = 0
41 |
42 | init(result: Result<[ImageType], CustomError>? = nil, cachedImages: [(image: Image, searchId: String, sortId: Int)] = []) {
43 | self.result = result
44 | if !cachedImages.isEmpty {
45 | self.cachedImages = cachedImages
46 | }
47 | }
48 |
49 | // API methods
50 |
51 | func searchImages(_ imageQuery: ImageQuery) async -> Result<[ImageType], CustomError> {
52 | ImageSearchViewModelTests.syncQueue.sync {
53 | apiMethodsCallsCount += 1
54 | }
55 | return result!
56 | }
57 |
58 | func getImage(url: URL) async -> Data? {
59 | ImageSearchViewModelTests.syncQueue.sync {
60 | apiMethodsCallsCount += 1
61 | }
62 | return UIImage(systemName: "heart.fill")?.pngData()
63 | }
64 |
65 | // DB methods
66 |
67 | func saveImage(_ image: Image, searchId: String, sortId: Int) async -> Bool? {
68 | ImageSearchViewModelTests.syncQueue.sync {
69 | dbMethodsCallsCount += 1
70 | cachedImages.append((image, searchId, sortId))
71 | }
72 | return true
73 | }
74 |
75 | func getImages(searchId: String) async -> [ImageType]? {
76 | ImageSearchViewModelTests.syncQueue.sync {
77 | dbMethodsCallsCount += 1
78 | }
79 | var images: [ImageType] = []
80 | for image in cachedImages {
81 | if image.searchId == searchId {
82 | ImageSearchViewModelTests.syncQueue.sync {
83 | images.append(image.image)
84 | }
85 | }
86 | }
87 | ImageSearchViewModelTests.syncQueue.sync {}
88 | return images
89 | }
90 |
91 | func checkImagesAreCached(searchId: String) async -> Bool? {
92 | ImageSearchViewModelTests.syncQueue.sync {
93 | dbMethodsCallsCount += 1
94 | }
95 | for image in cachedImages {
96 | if image.searchId == searchId {
97 | return true
98 | }
99 | }
100 | return false
101 | }
102 |
103 | // Called once when initializing the ImageCachingService to clear the Image table
104 | func deleteAllImages() async {}
105 | }
106 |
107 | private func bind(_ imageSearchViewModel: ImageSearchViewModel) {
108 | imageSearchViewModel.data.bind(self) { [weak self] _ in
109 | ImageSearchViewModelTests.syncQueue.sync {
110 | self?.observablesTriggerCount += 1
111 | }
112 | }
113 | imageSearchViewModel.sectionData.bind(self) { [weak self] _ in
114 | ImageSearchViewModelTests.syncQueue.sync {
115 | self?.observablesTriggerCount += 1
116 | }
117 | }
118 | imageSearchViewModel.scrollTop.bind(self) { [weak self] _ in
119 | ImageSearchViewModelTests.syncQueue.sync {
120 | self?.observablesTriggerCount += 1
121 | }
122 | }
123 | imageSearchViewModel.makeToast.bind(self) { [weak self] _ in
124 | ImageSearchViewModelTests.syncQueue.sync {
125 | self?.observablesTriggerCount += 1
126 | }
127 | }
128 | imageSearchViewModel.resetSearchBar.bind(self) { [weak self] _ in
129 | ImageSearchViewModelTests.syncQueue.sync {
130 | self?.observablesTriggerCount += 1
131 | }
132 | }
133 | imageSearchViewModel.activityIndicatorVisibility.bind(self) { [weak self] _ in
134 | ImageSearchViewModelTests.syncQueue.sync {
135 | self?.observablesTriggerCount += 1
136 | }
137 | }
138 | imageSearchViewModel.collectionViewTopConstraint.bind(self) { [weak self] _ in
139 | ImageSearchViewModelTests.syncQueue.sync {
140 | self?.observablesTriggerCount += 1
141 | }
142 | }
143 | }
144 |
145 | func testSearchImage_whenSearchQueryIsNotValid() {
146 | let imageRepository = ImageRepositoryMock(result: .success(ImageSearchViewModelTests.imagesStub))
147 | let searchImagesUseCase = DefaultSearchImagesUseCase(imageRepository: imageRepository)
148 | let imageCachingService = DefaultImageCachingService(imageRepository: imageRepository)
149 | let imageSearchViewModel = DefaultImageSearchViewModel(searchImagesUseCase: searchImagesUseCase, imageCachingService: imageCachingService)
150 | bind(imageSearchViewModel)
151 |
152 | imageSearchViewModel.searchImages(for: "")
153 | XCTAssertEqual(imageSearchViewModel.makeToast.value, NSLocalizedString("Search query error", comment: ""))
154 | XCTAssertTrue(imageSearchViewModel.data.value.isEmpty)
155 |
156 | imageSearchViewModel.searchImages(for: " ")
157 | XCTAssertEqual(imageSearchViewModel.makeToast.value, NSLocalizedString("Search query error", comment: ""))
158 | XCTAssertTrue(imageSearchViewModel.data.value.isEmpty)
159 |
160 | ImageSearchViewModelTests.syncQueue.sync {
161 | XCTAssertEqual(observablesTriggerCount, 4) // makeToast, resetSearchBar, makeToast, resetSearchBar
162 | }
163 | }
164 |
165 | func testSearchImage_whenSearchQueryIsValid_andWhenResultIsSuccess() async throws {
166 | let imageRepository = ImageRepositoryMock(result: .success(ImageSearchViewModelTests.imagesStub))
167 | let searchImagesUseCase = DefaultSearchImagesUseCase(imageRepository: imageRepository)
168 | let imageCachingService = DefaultImageCachingService(imageRepository: imageRepository)
169 | let imageSearchViewModel = DefaultImageSearchViewModel(searchImagesUseCase: searchImagesUseCase, imageCachingService: imageCachingService)
170 | bind(imageSearchViewModel)
171 |
172 | XCTAssertEqual(imageSearchViewModel.data.value.count, 0)
173 |
174 | let query = "random"
175 | imageSearchViewModel.searchImages(for: query)
176 | XCTAssertEqual(imageSearchViewModel.makeToast.value, "")
177 | await imageSearchViewModel.toTestImagesLoadTask?.value
178 |
179 | XCTAssertEqual(imageSearchViewModel.data.value.count, 1)
180 | XCTAssertTrue((imageSearchViewModel.data.value[0]._searchResults as! [Image]).contains(ImageSearchViewModelTests.testImageStub))
181 | if let expectedImageData = UIImage(systemName: "heart.fill")?.pngData() {
182 | XCTAssertEqual((imageSearchViewModel.data.value[0]._searchResults as! [Image])[0].thumbnail?.uiImage?.pngData(), Supportive.toUIImage(from: expectedImageData)?.pngData())
183 | }
184 | XCTAssertEqual(imageSearchViewModel.lastQuery?.query, query)
185 | ImageSearchViewModelTests.syncQueue.sync {
186 | XCTAssertEqual(observablesTriggerCount, 4) // activityIndicatorVisibility, data, activityIndicatorVisibility, scrollTop
187 | }
188 | }
189 |
190 | func testSearchImage_whenSearchQueryIsValid_andWhenResultIsFailure() async throws {
191 | let imageRepository = ImageRepositoryMock(result: .failure(CustomError.internetConnection()))
192 | let searchImagesUseCase = DefaultSearchImagesUseCase(imageRepository: imageRepository)
193 | let imageCachingService = DefaultImageCachingService(imageRepository: imageRepository)
194 | let imageSearchViewModel = DefaultImageSearchViewModel(searchImagesUseCase: searchImagesUseCase, imageCachingService: imageCachingService)
195 | bind(imageSearchViewModel)
196 |
197 | XCTAssertTrue(imageSearchViewModel.data.value.isEmpty)
198 |
199 | imageSearchViewModel.searchImages(for: "random")
200 | XCTAssertEqual(imageSearchViewModel.makeToast.value, "")
201 | await imageSearchViewModel.toTestImagesLoadTask?.value
202 |
203 | XCTAssertTrue(imageSearchViewModel.data.value.isEmpty)
204 | XCTAssertNil(imageSearchViewModel.lastQuery)
205 | ImageSearchViewModelTests.syncQueue.sync {
206 | XCTAssertEqual(observablesTriggerCount, 3) // activityIndicatorVisibility, makeToast, activityIndicatorVisibility
207 | }
208 | }
209 |
210 | func testSearchImage_whenSearchIsRunTwice() async throws {
211 | let imageRepository = ImageRepositoryMock(result: .success(ImageSearchViewModelTests.imagesStub))
212 | let searchImagesUseCase = DefaultSearchImagesUseCase(imageRepository: imageRepository)
213 | let imageCachingService = DefaultImageCachingService(imageRepository: imageRepository)
214 | let imageSearchViewModel = DefaultImageSearchViewModel(searchImagesUseCase: searchImagesUseCase, imageCachingService: imageCachingService)
215 | bind(imageSearchViewModel)
216 |
217 | XCTAssertEqual(imageSearchViewModel.data.value.count, 0)
218 |
219 | imageSearchViewModel.searchImages(for: "query")
220 | await imageSearchViewModel.toTestImagesLoadTask?.value
221 |
222 | XCTAssertEqual(imageSearchViewModel.data.value.count, 1)
223 |
224 | let query1 = "query1"
225 | imageSearchViewModel.searchImages(for: query1)
226 | await imageSearchViewModel.toTestImagesLoadTask?.value
227 |
228 | XCTAssertEqual(imageSearchViewModel.data.value.count, 2)
229 | XCTAssertTrue((imageSearchViewModel.data.value[0]._searchResults as! [Image]).contains(ImageSearchViewModelTests.testImageStub))
230 | XCTAssertTrue((imageSearchViewModel.data.value[1]._searchResults as! [Image]).contains(ImageSearchViewModelTests.testImageStub))
231 | XCTAssertEqual(imageSearchViewModel.lastQuery?.query, query1)
232 | ImageSearchViewModelTests.syncQueue.sync {
233 | XCTAssertEqual(observablesTriggerCount, 8) // activityIndicatorVisibility, data, activityIndicatorVisibility, scrollTop, activityIndicatorVisibility, data, activityIndicatorVisibility, scrollTop
234 | }
235 | }
236 |
237 | func testUpdateSection() async throws {
238 | var cachedImagesStub = ImageSearchViewModelTests.cachedImagesStub
239 | for (index, image) in cachedImagesStub.enumerated() {
240 | cachedImagesStub[index].image = ImageBehavior.updateImage(image.image, newWrapper: ImageWrapper(uiImage: UIImage(systemName: "heart.fill")), for: .thumbnail)
241 | }
242 |
243 | let imageRepository = ImageRepositoryMock(result: .success(ImageSearchViewModelTests.imagesStub), cachedImages: cachedImagesStub)
244 | let searchImagesUseCase = DefaultSearchImagesUseCase(imageRepository: imageRepository)
245 | let imageCachingService = DefaultImageCachingService(imageRepository: imageRepository)
246 | let imageSearchViewModel = DefaultImageSearchViewModel(searchImagesUseCase: searchImagesUseCase, imageCachingService: imageCachingService)
247 | bind(imageSearchViewModel)
248 |
249 | imageSearchViewModel.data.value = ImageSearchViewModelTests.searchResultsStub // 5 searches are done
250 | XCTAssertEqual(imageRepository.cachedImages.count, 4) // 2 images of the 1st search and 2 images of the 2nd search in ImageCachingServiceTests.searchResultsStub are cached
251 | for image in imageSearchViewModel.data.value[3]._searchResults {
252 | XCTAssertNil(image.thumbnail)
253 | }
254 | for image in imageSearchViewModel.data.value[4]._searchResults {
255 | XCTAssertNil(image.thumbnail)
256 | }
257 |
258 | // Get images of the 2nd search from cache and update data
259 | imageSearchViewModel.updateSection("id2")
260 | try await Task.sleep(nanoseconds: 1 * 500_000_000)
261 | for image in imageSearchViewModel.data.value[3]._searchResults {
262 | XCTAssertNotNil(image.thumbnail)
263 | }
264 | for image in imageSearchViewModel.data.value[4]._searchResults {
265 | XCTAssertNil(image.thumbnail)
266 | }
267 |
268 | // Get images of the 1st search from cache and update data
269 | imageSearchViewModel.updateSection("id1")
270 | try await Task.sleep(nanoseconds: 1 * 500_000_000)
271 | for image in imageSearchViewModel.data.value[3]._searchResults {
272 | XCTAssertNotNil(image.thumbnail)
273 | }
274 | for image in imageSearchViewModel.data.value[4]._searchResults {
275 | XCTAssertNotNil(image.thumbnail)
276 | }
277 |
278 | ImageSearchViewModelTests.syncQueue.sync {
279 | XCTAssertEqual(observablesTriggerCount, 3) // data, sectionData, sectionData
280 | }
281 | }
282 | }
283 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Denis Simon
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # iOS-MVVM-Clean-Architecture
2 | [](https://swift.org)
3 | [](https://developer.apple.com/swift/)
4 | [](https://github.com/denissimon/iOS-MVVM-Clean-Architecture/blob/master/LICENSE)
5 |
6 | Example iOS app designed using MVVM-C and Clean Architecture. Uses Swift Concurrency.
7 |
8 | The app retrieves images for any search query or tag via the Flickr API. It has three modules: ImageSearch, ImageDetails, HotTags.
9 |
10 | Swift 6 version can be found in [swift6][Swift6Link] branch.
11 |
12 | [Swift6Link]: https://github.com/denissimon/iOS-MVVM-Clean-Architecture/tree/swift6
13 |
14 |
15 |
16 |  |
17 |  |
18 |  |
19 |
20 |
21 |
22 | ### Architecture concepts used here
23 |
24 | - [Clean Architecture][CleanArchitectureLink]
25 | - [Explicit Architecture][ExplicitArchitectureLink]
26 | - [MVVM][MVVMLink]
27 | - [Flow coordinator][FlowCoordinatorLink] implemented with closure-based actions
28 | - [Dependency Injection][DIContainerLink], DIContainer
29 | - [Protocol-Oriented Programming][POPLink]
30 | - [Data Binding][DataBindingLink] using the lightweight [Observable\][ObservableLink]
31 | - [Closure-based delegation][ClosureBasedDelegationLink] using the lightweight [Event\][EventLink]
32 | - [Pure functional transformations][PureFunctionalTransformationsLink]
33 | - [Delegating entity behavior][DelegatingEntityBehaviorLink]
34 | - [Alternative DTO approach][AlternativeDTOApproachLink]
35 |
36 | [CleanArchitectureLink]: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
37 | [ExplicitArchitectureLink]: https://herbertograca.com/2017/11/16/explicit-architecture-01-ddd-hexagonal-onion-clean-cqrs-how-i-put-it-all-together
38 | [MVVMLink]: https://github.com/denissimon/iOS-MVVM-Clean-Architecture/tree/master/ImageSearch/Presentation/ImagesFeature/ImageSearch
39 | [FlowCoordinatorLink]: https://github.com/denissimon/iOS-MVVM-Clean-Architecture/tree/master/ImageSearch/Coordinator
40 | [DIContainerLink]: https://github.com/denissimon/iOS-MVVM-Clean-Architecture/blob/master/ImageSearch/Coordinator/DIContainer/DIContainer.swift
41 | [POPLink]: https://www.swiftanytime.com/blog/protocol-oriented-programming-in-swift
42 | [DataBindingLink]: https://github.com/denissimon/iOS-MVVM-Clean-Architecture/blob/master/ImageSearch/Presentation/ImagesFeature/ImageSearch/ViewModel/DefaultImageSearchViewModel.swift
43 | [ObservableLink]: https://github.com/denissimon/iOS-MVVM-Clean-Architecture/blob/master/ImageSearch/Common/Utils/SwiftEvents.swift#L86
44 | [ClosureBasedDelegationLink]: https://github.com/denissimon/iOS-MVVM-Clean-Architecture/blob/master/ImageSearch/Presentation/ImagesFeature/HotTags/ViewModel/DefaultHotTagsViewModel.swift
45 | [EventLink]: https://github.com/denissimon/iOS-MVVM-Clean-Architecture/blob/master/ImageSearch/Common/Utils/SwiftEvents.swift
46 | [PureFunctionalTransformationsLink]: https://blog.ploeh.dk/2020/03/02/impureim-sandwich
47 | [DelegatingEntityBehaviorLink]: https://github.com/denissimon/iOS-MVVM-Clean-Architecture/blob/master/ImageSearch/Domain/Entities/Behaviors/ImageBehavior.swift
48 | [AlternativeDTOApproachLink]: https://medium.com/geekculture/why-we-shouldnt-use-data-transfer-objects-in-swift-38dcef529a66
49 |
50 | ### Includes
51 |
52 | - Reusable and universal [NetworkService][NetworkServiceLink] based on URLSession
53 | - Reusable and universal [SQLite][SQLiteAdapterLink] wrapper around SQLite3
54 | - [Image caching service][ImageCachingServiceLink]
55 | - Configurable use of [UIKit][UIKitViewLink] or [SwiftUI][SwiftUIViewLink] for the same screen
56 | - Advanced error handling
57 | - Unit and integration tests for a number of components from all layers
58 |
59 | [NetworkServiceLink]: https://github.com/denissimon/iOS-MVVM-Clean-Architecture/blob/master/ImageSearch/Data/Network/NetworkService/NetworkService.swift
60 | [SQLiteAdapterLink]: https://github.com/denissimon/iOS-MVVM-Clean-Architecture/tree/master/ImageSearch/Data/Persistence/SQLite
61 | [ImageCachingServiceLink]: https://github.com/denissimon/iOS-MVVM-Clean-Architecture/blob/master/ImageSearch/Domain/Services/ImageCachingService.swift
62 | [UIKitViewLink]: https://github.com/denissimon/iOS-MVVM-Clean-Architecture/blob/master/ImageSearch/Presentation/ImagesFeature/HotTags/View/UIKit/HotTagsViewController.swift
63 | [SwiftUIViewLink]: https://github.com/denissimon/iOS-MVVM-Clean-Architecture/blob/master/ImageSearch/Presentation/ImagesFeature/HotTags/View/SwiftUI/HotTagsView.swift
64 |
65 | ### Main layers
66 |
67 | **Presentation (MVVM)**: _coordinators_, _UI elements_, _SwiftUI views_, _UIKit storyboards_, _ViewControllers_, _ViewModels_
68 |
69 | **Domain**: _entities_, _use cases_, _services_, _interfaces_ (for use cases, services and repositories)
70 |
71 | **Data**: _entity repositories_, _APIs_, _API/DB interactors_ (or network services and storages), _adapters_
72 |
73 | ### Use case scenarios
74 |
75 | ImageSearch module:
76 | ```swift
77 | * searchImagesUseCase.execute(imageQuery)
78 | * imageCachingService.cacheIfNecessary(data)
79 | * imageCachingService.getCachedImages(searchId: searchId)
80 | ```
81 |
82 | ImageDetails module:
83 | ```swift
84 | * getBigImageUseCase.execute(for: image)
85 | ```
86 |
87 | HotTags module:
88 | ```swift
89 | * getHotTagsUseCase.execute()
90 | ```
91 |
92 | ### Image caching service
93 |
94 | [ImageCachingService][ImageCachingServiceLink] implements the logic for caching downloaded images and freeing memory. This helps keep the app's memory usage under control, since there can be a lot of downloaded images, and without caching, the app could quickly accumulate hundreds of MB of memory used. Downloaded images are cached and read from the cache automatically.
95 |
96 | ### Reusable components from this project
97 |
98 | - [SwiftEvents](https://github.com/denissimon/SwiftEvents) - the easiest way to implement data binding and notifications. Includes Event\ and Observable\. Has a thread-safe version.
99 | - [URLSessionAdapter](https://github.com/denissimon/URLSessionAdapter) - a Codable wrapper around URLSession for networking
100 | - [SQLiteAdapter](https://github.com/denissimon/SQLiteAdapter) - a simple wrapper around SQLite3
101 |
102 | ### Requirements
103 |
104 | iOS version support: 15.0+. Xcode 13.0+, Swift 5.5+
105 |
--------------------------------------------------------------------------------
/Screenshots/1_iOS-MVVM-Clean-Architecture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/denissimon/iOS-MVVM-Clean-Architecture/5fb12c53eaf99c07b73e03b9a7623dd3feb6bc9e/Screenshots/1_iOS-MVVM-Clean-Architecture.png
--------------------------------------------------------------------------------
/Screenshots/2_iOS-MVVM-Clean-Architecture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/denissimon/iOS-MVVM-Clean-Architecture/5fb12c53eaf99c07b73e03b9a7623dd3feb6bc9e/Screenshots/2_iOS-MVVM-Clean-Architecture.png
--------------------------------------------------------------------------------
/Screenshots/3_iOS-MVVM-Clean-Architecture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/denissimon/iOS-MVVM-Clean-Architecture/5fb12c53eaf99c07b73e03b9a7623dd3feb6bc9e/Screenshots/3_iOS-MVVM-Clean-Architecture.png
--------------------------------------------------------------------------------