diff --git a/gitprofile.xcodeproj/project.pbxproj b/gitprofile.xcodeproj/project.pbxproj index 4368a36..98f02e9 100644 --- a/gitprofile.xcodeproj/project.pbxproj +++ b/gitprofile.xcodeproj/project.pbxproj @@ -65,6 +65,7 @@ 8BEAA1A32C7E595F00AEDB6D /* RepositoryItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BEAA1A22C7E595F00AEDB6D /* RepositoryItemView.swift */; }; 8BF98C852C82264300E5139C /* RepositoryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF98C842C82264300E5139C /* RepositoryListView.swift */; }; 8E28FCCA2DB157FC00533133 /* CacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E28FCC92DB157F900533133 /* CacheManager.swift */; }; + 8E28FCCE2DB1EA5600533133 /* GetRecentSearchedUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E28FCCD2DB1EA5600533133 /* GetRecentSearchedUseCase.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -128,6 +129,7 @@ 8BEAA1A22C7E595F00AEDB6D /* RepositoryItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryItemView.swift; sourceTree = ""; }; 8BF98C842C82264300E5139C /* RepositoryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryListView.swift; sourceTree = ""; }; 8E28FCC92DB157F900533133 /* CacheManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheManager.swift; sourceTree = ""; }; + 8E28FCCD2DB1EA5600533133 /* GetRecentSearchedUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetRecentSearchedUseCase.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -250,6 +252,7 @@ 8BCD6DFC2C8A928500A92BC8 /* GetStarredReposUseCase.swift */, 8BCD6E062C8AC90D00A92BC8 /* GetUserOrgsUseCase.swift */, 8BCD6E0F2C8C44B100A92BC8 /* SearchUserUseCase.swift */, + 8E28FCCD2DB1EA5600533133 /* GetRecentSearchedUseCase.swift */, ); path = UseCase; sourceTree = ""; @@ -421,6 +424,7 @@ 8B5AD3AD2C84E85800AA2571 /* ApiConfig.xcconfig in Resources */, 8B5AD3962C84A58400AA2571 /* github_languages.json in Resources */, 8B6EC86F2C71DEE600CF7B77 /* Assets.xcassets in Resources */, + 8E455A9E2D6EB7A4002E37D2 /* UserListViewController.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -449,6 +453,7 @@ 8BCD6DE22C85C0C500A92BC8 /* UserDataComponent.swift in Sources */, 8B6EC8832C72D94500CF7B77 /* UserListView.swift in Sources */, 8B5AD3832C83B7BE00AA2571 /* NetworkComponent.swift in Sources */, + 8E28FCCE2DB1EA5600533133 /* GetRecentSearchedUseCase.swift in Sources */, 8BEAA1A12C7E316500AEDB6D /* SlidingTabView.swift in Sources */, 8BCD6E032C8AC28F00A92BC8 /* GetUserOrgsNetworkCall.swift in Sources */, 8BCD6DF92C8A8A2400A92BC8 /* PaginatedNetworkCall.swift in Sources */, diff --git a/gitprofile/Data/Remote/GetAllUsersNetworkCall.swift b/gitprofile/Data/Remote/GetAllUsersNetworkCall.swift index a2e3fc9..5043251 100644 --- a/gitprofile/Data/Remote/GetAllUsersNetworkCall.swift +++ b/gitprofile/Data/Remote/GetAllUsersNetworkCall.swift @@ -31,7 +31,8 @@ class GetAllUsersNetworkCall { ) { urlComponents.queryItems = params - let urlRequest = NetworkComponent.createUrlRequest(url: self.urlComponents.url!, method: "GET") + var urlRequest = NetworkComponent.createUrlRequest(url: self.urlComponents.url!, method: "GET") + urlRequest.cachePolicy = .returnCacheDataElseLoad logger.log(message: String(describing: urlRequest)) // switch strategy { // case .cacheOverRemote: diff --git a/gitprofile/Data/Remote/NetworkComponent.swift b/gitprofile/Data/Remote/NetworkComponent.swift index 769e603..5a83723 100644 --- a/gitprofile/Data/Remote/NetworkComponent.swift +++ b/gitprofile/Data/Remote/NetworkComponent.swift @@ -31,6 +31,10 @@ struct NetworkComponent { let urlCache = URLCache(memoryCapacity: memoryCapacity, diskCapacity: diskCapacity, diskPath: "gitprofile") let urlSessionConfig = NetworkComponent.createDefaultURLSessionConfig(urlCache) self.session = URLSession(configuration: urlSessionConfig) + + // Shared Global: async image download + URLCache.shared.memoryCapacity = memoryCapacity + URLCache.shared.diskCapacity = diskCapacity } static func createDefaultURLSessionConfig(_ urlCache: URLCache) -> URLSessionConfiguration { diff --git a/gitprofile/Data/Repository/UserDetailsRepository.swift b/gitprofile/Data/Repository/UserDetailsRepository.swift index cbbfe63..9671e6b 100644 --- a/gitprofile/Data/Repository/UserDetailsRepository.swift +++ b/gitprofile/Data/Repository/UserDetailsRepository.swift @@ -8,6 +8,7 @@ import Foundation protocol UserDetailsRepository { + func getAllUserDetails() -> [UserDetailsResponse] func getAllUserDetails(username: String) -> [UserDetailsResponse] func getUserDetails(username: String) -> UserDetailsResponse? func saveUserDetails(userDetails: UserDetailsResponse) @@ -76,4 +77,16 @@ class UserDetailsRepositoryImpl : UserDetailsRepository { return matchingUsers } + func getAllUserDetails() -> [UserDetailsResponse] { + var collections: [UserDetailsResponse] = [] + let prefixes = getPrefixes() + + for prefix in prefixes { + let key = cacheKey(for: prefix) + if let userDetails: UserDetailsResponse = cacheManager.retrieve(forKey: key){ + collections.append(userDetails) + } + } + return collections + } } diff --git a/gitprofile/Data/UserNetworkDataManager.swift b/gitprofile/Data/UserNetworkDataManager.swift index 0907773..8206cc5 100644 --- a/gitprofile/Data/UserNetworkDataManager.swift +++ b/gitprofile/Data/UserNetworkDataManager.swift @@ -9,7 +9,8 @@ import Foundation protocol UserDataManager { - func loadUsers() async -> Result<[UserResponse], Error> + func loadUsers(_ strategy: FetchStrategy) async -> Result<[UserResponse], Error> + func findAllUserDetails() async -> Result<[UserDetailsResponse], Error> func findAllUserDetails(username: String) async -> Result<[UserDetailsResponse], Error> func findUserDetails(username: String) async -> Result func findUserRepos(username: String) async -> Result, Error> @@ -30,8 +31,16 @@ class UserNetworkDataManager : UserDataManager { self.component = factory.create() } - func loadUsers() async -> Result<[UserResponse], Error> { + func loadUsers(_ strategy: FetchStrategy) async -> Result<[UserResponse], Error> { let userRepo = component.providesUsersRepository() + + if strategy == .cacheOverRemote { + let cachedUsers = userRepo.getUsers() + if !cachedUsers.isEmpty { + return .success(cachedUsers) + } + } + let queryParams = [ URLQueryItem(name: "since", value: "\(userRepo.getNextPage())"), URLQueryItem(name: "per_page", value: pageSize) @@ -52,6 +61,12 @@ class UserNetworkDataManager : UserDataManager { } } + func findAllUserDetails() async -> Result<[UserDetailsResponse], any Error> { + let userDetailsRepo = component.providesUserDetailsRepository() + let userDetails = userDetailsRepo.getAllUserDetails() + return .success(userDetails) + } + func findAllUserDetails(username: String) async -> Result<[UserDetailsResponse], Error> { let userDetailsRepo = component.providesUserDetailsRepository() let userDetails = userDetailsRepo.getAllUserDetails(username: username) @@ -105,12 +120,6 @@ class UserNetworkDataManager : UserDataManager { ] logger.log(message: "ApiCall: GetRepositories for user: \(username), params: \(queryParams)") component.providesGetRepositoriesNetworkCall().execute(username: username, params: queryParams, completion: handler) - }, map: { repos in - repos.sorted(by: { - guard let updatedAtA = $0.updatedAt else { return false } - guard let updateAtB = $1.updatedAt else { return false } - return updatedAtA > updateAtB - }) }) } diff --git a/gitprofile/Domain/DI/UserDomainComponent.swift b/gitprofile/Domain/DI/UserDomainComponent.swift index 5271a68..54f9eb6 100644 --- a/gitprofile/Domain/DI/UserDomainComponent.swift +++ b/gitprofile/Domain/DI/UserDomainComponent.swift @@ -13,6 +13,7 @@ protocol UserDomainComponent { func providesGetStarredReposUseCase() -> GetStarredReposUseCase func providesGetUserOrgsUseCase() -> GetUserOrgsUseCase func providesSearchUserUseCase() -> SearchUserUseCase + func providesGetRecentSearchedUseCase() -> GetRecentSearchedUseCase } private class UserComponentImpl : UserDomainComponent { @@ -26,7 +27,8 @@ private class UserComponentImpl : UserDomainComponent { private static let getStarredReposUseCase = GetStarredReposUseCase(ServiceLocator.dataManager) private static let getUserOrgsUseCase = GetUserOrgsUseCase(ServiceLocator.dataManager) private static let searchUserUseCase = SearchUserUseCase(ServiceLocator.dataManager) - + private static let getSearchedUsersUseCase = GetRecentSearchedUseCase(ServiceLocator.dataManager) + func providesGetUsersUseCase() -> GetUsersUseCase { return UserComponentImpl.getUserUseCase } @@ -50,6 +52,10 @@ private class UserComponentImpl : UserDomainComponent { func providesSearchUserUseCase() -> SearchUserUseCase { return UserComponentImpl.searchUserUseCase } + + func providesGetRecentSearchedUseCase() -> GetRecentSearchedUseCase { + return UserComponentImpl.getSearchedUsersUseCase + } } class UserDomainComponentFactory { diff --git a/gitprofile/Domain/UseCase/GetRecentSearchedUseCase.swift b/gitprofile/Domain/UseCase/GetRecentSearchedUseCase.swift new file mode 100644 index 0000000..f60c5dd --- /dev/null +++ b/gitprofile/Domain/UseCase/GetRecentSearchedUseCase.swift @@ -0,0 +1,43 @@ +// +// GetRecentSearchedUseCase.swift +// GitHub Profile +// +// Created by Aljan Porquillo on 4/18/25. +// + +import Foundation + +class GetRecentSearchedUseCase { + + private let cacheManager = CacheManager.shared + private let dataManager: UserDataManager + + init(_ dataManager: UserDataManager) { + self.dataManager = dataManager + } + + func execute() async -> LoadableViewState<[UserUiModel]> { + return await dataManager.findAllUserDetails() + .fold( + onSuccess: { userDetails in + return .loaded( + oldData: userDetails + .compactMap { detail in + guard let id = detail.id, let login = detail.login else { + return nil + } + return UserUiModel( + id: id, + login: login, + avatarUrl: detail.avatarUrl + ) + } + .reversed() + ) + }, + onFailure: { error in + return .failure(message: error.localizedDescription) + } + ) + } +} diff --git a/gitprofile/Domain/UseCase/GetUsersUseCase.swift b/gitprofile/Domain/UseCase/GetUsersUseCase.swift index 35a2016..89f72ae 100644 --- a/gitprofile/Domain/UseCase/GetUsersUseCase.swift +++ b/gitprofile/Domain/UseCase/GetUsersUseCase.swift @@ -15,8 +15,8 @@ class GetUsersUseCase { self.dataManager = dataManager } - func execute() async -> LoadableViewState<[UserUiModel]> { - return await dataManager.loadUsers() + func execute(_ strategy: FetchStrategy) async -> LoadableViewState<[UserUiModel]> { + return await dataManager.loadUsers(strategy) .fold(onSuccess: { users in .success(data: users.map { user in UserUiModel( diff --git a/gitprofile/Domain/UseCase/SearchUserUseCase.swift b/gitprofile/Domain/UseCase/SearchUserUseCase.swift index 2be99bc..992c3bc 100644 --- a/gitprofile/Domain/UseCase/SearchUserUseCase.swift +++ b/gitprofile/Domain/UseCase/SearchUserUseCase.swift @@ -29,7 +29,7 @@ class SearchUserUseCase { return .loaded(oldData: uiModels) } - let filtered = await dataManager.loadUsers() + let filtered = await dataManager.loadUsers(.cacheOverRemote) .fold(onSuccess: { users in users.filter { guard let login = $0.login, let _ = $0.id else { diff --git a/gitprofile/Domain/UserDomainManager.swift b/gitprofile/Domain/UserDomainManager.swift index b2f452d..6ddf067 100644 --- a/gitprofile/Domain/UserDomainManager.swift +++ b/gitprofile/Domain/UserDomainManager.swift @@ -8,10 +8,11 @@ import Foundation protocol UserDomainManager { - func getUsers() async -> LoadableViewState<[UserUiModel]> + func getUsers(strategy: FetchStrategy) async -> LoadableViewState<[UserUiModel]> func getUserRepos(username: String) async -> LoadableViewState<[UserReposUiModel]> func getUserDetails(username: String) async -> GenericViewState func getStarredRepos(username: String) async -> LoadableViewState<[UserStarredReposUiModel]> func getUserOrgs(username: String) async -> LoadableViewState<[UserOrgsUiModel]> func searchUser(query: String) async -> LoadableViewState<[UserUiModel]> + func getRecentSearches() async -> LoadableViewState<[UserUiModel]> } diff --git a/gitprofile/Domain/UserUseCaseManager.swift b/gitprofile/Domain/UserUseCaseManager.swift index 0bf4403..b3af8ee 100644 --- a/gitprofile/Domain/UserUseCaseManager.swift +++ b/gitprofile/Domain/UserUseCaseManager.swift @@ -15,9 +15,9 @@ class UserUseCaseManager : UserDomainManager { self.component = factory.create() } - func getUsers() async -> LoadableViewState<[UserUiModel]> { + func getUsers(strategy: FetchStrategy) async -> LoadableViewState<[UserUiModel]> { return await component.providesGetUsersUseCase() - .execute() + .execute(strategy) } func getUserRepos(username: String) async -> LoadableViewState<[UserReposUiModel]> { @@ -44,4 +44,10 @@ class UserUseCaseManager : UserDomainManager { return await component.providesSearchUserUseCase() .execute(username: query) } + + func getRecentSearches() async -> LoadableViewState<[UserUiModel]> { + return await component.providesGetRecentSearchedUseCase() + .execute() + } + } diff --git a/gitprofile/View/Users/SearchUserProxyStore.swift b/gitprofile/View/Users/SearchUserProxyStore.swift index b9f330c..9cb5ed2 100644 --- a/gitprofile/View/Users/SearchUserProxyStore.swift +++ b/gitprofile/View/Users/SearchUserProxyStore.swift @@ -23,7 +23,7 @@ class SearchUserProxyStore: ObservableObject { private func startObserver() { searchSubject - .debounce(for: .milliseconds(500), scheduler: RunLoop.main) + .debounce(for: .milliseconds(700), scheduler: RunLoop.main) .sink { [weak self] searchText in self?.userStore.send(.search(query: searchText)) } diff --git a/gitprofile/View/Users/UserListView.swift b/gitprofile/View/Users/UserListView.swift index 29b1dde..780bc79 100644 --- a/gitprofile/View/Users/UserListView.swift +++ b/gitprofile/View/Users/UserListView.swift @@ -17,6 +17,8 @@ struct UserListView: View { @State private var searchQuery = "" @State private var defaultQueryHint = String(describing: Bundle.main.infoDictionary?["USERNAME"] ?? "") + @Environment(\.isSearching) private var isSearching + init() { let initialUsersStore = UserStore( initialState: .init(viewState: .initial), @@ -40,13 +42,13 @@ struct UserListView: View { } .onChange(of: searchQuery) { if searchQuery.isEmpty { - usersStore.send(.paginate) + usersStore.send(.paginate(.cacheOverRemote)) } else { searchAppStore.dispatch(query: searchQuery) } } .task { - usersStore.send(.paginate) + usersStore.send(.paginate(.invalidateRemotely)) } } @@ -56,8 +58,8 @@ struct UserListView: View { case.initial: ProgressView() .progressViewStyle(.circular) - case .success(let repos): - listView(repos: repos, true) + case .success(let users): + listView(users: users, true) case .failure(let message): VStack { Text("Ops! Sorry, we run into an error") @@ -66,38 +68,79 @@ struct UserListView: View { .multilineTextAlignment(.center) .font(.caption) }.padding(.horizontal, 16) - case .loaded(let oldUsers): - listView(repos: oldUsers, false) + case .loaded(let users): + listView(users: users, false) case .endOfPaginatedReached(_): EmptyView() } } @ViewBuilder - private func listView(repos: [UserUiModel], _ showLoading: Bool) -> some View { - List { - ForEach(repos, id: \.id) { user in - UserCardView(user: user) { - self.selectedUser = user.login - self.shouldShowDestination = true - } + private func listView(users: [UserUiModel], _ showLoading: Bool) -> some View { + SearchAwareListView( + users: users, + showLoading: showLoading, + showRecentSearchTitle: usersStore.state.lastAction == .recentSearch, + selectedUser: $selectedUser, + shouldShowDestination: $shouldShowDestination, + searchQuery: $searchQuery, + onSearchAction: { action in + usersStore.send(action) + } + ) + } +} + +struct SearchAwareListView : View { + + @Environment(\.isSearching) private var isSearching + + let users: [UserUiModel] + let showLoading: Bool + let showRecentSearchTitle: Bool + + @Binding var selectedUser: String + @Binding var shouldShowDestination: Bool + @Binding var searchQuery: String + + let onSearchAction: (UserAction) -> Void + + var body: some View { + VStack(alignment: .leading) { + if !users.isEmpty, showRecentSearchTitle { + Text("Recent Searches...") + .font(.subheadline) + .fontWeight(.bold) + .padding(.top) + .padding(.leading) } - if showLoading { - HStack { - ProgressView().onAppear { - Task { - if searchQuery.isEmpty { - usersStore.send(.paginate) + List { + ForEach(users, id: \.id) { user in + UserCardView(user: user) { + self.selectedUser = user.login + self.shouldShowDestination = true + } + } + if showLoading { + HStack { + ProgressView().onAppear { + Task { + if searchQuery.isEmpty { + onSearchAction(.paginate(.invalidateRemotely)) + } } } } + .frame(maxWidth: .infinity) + .listRowSeparator(.hidden) } - .frame(maxWidth: .infinity) - .listRowSeparator(.hidden) } } .listStyle(.plain) .scrollDismissesKeyboard(.immediately) .scrollContentBackground(.hidden) + .onChange(of: isSearching) { _, isSearching in + onSearchAction(isSearching ? .recentSearch : .paginate(.cacheOverRemote)) + } } } diff --git a/gitprofile/View/Users/UserState.swift b/gitprofile/View/Users/UserState.swift index 3d25bcc..62c8a24 100644 --- a/gitprofile/View/Users/UserState.swift +++ b/gitprofile/View/Users/UserState.swift @@ -6,25 +6,41 @@ // struct UserState: Equatable { - var viewState: LoadableViewState<[UserUiModel]> = .initial + var lastAction: UserAction? } enum UserAction: Equatable { case invalidate - case paginate + case paginate(FetchStrategy) + case recentSearch case search(query: String) } let userReducer: (UserState, UserAction) async -> UserState = { state, action in + let domainManager = ServiceLocator.domainManager + var newState = state + newState.lastAction = action + switch action { case .invalidate: newState.viewState = .initial case .search(let query): - newState.viewState = await ServiceLocator.domainManager.searchUser(query: query) - case .paginate: - newState.viewState = await ServiceLocator.domainManager.getUsers() + newState.viewState = await domainManager.searchUser(query: query) + case .paginate(let strategy): + newState.viewState = await domainManager.getUsers(strategy: strategy) + case .recentSearch: + let recentSearchedResult = await domainManager.getRecentSearches() + if case .loaded(let recentSearches) = recentSearchedResult { + if !recentSearches.isEmpty { + newState.viewState = .loaded(oldData: recentSearches) + } else { + newState.lastAction = nil + } + } else if case .failure(let message) = recentSearchedResult { + newState.viewState = .failure(message: message) + } } return newState }