diff --git a/Immich Gallery.xcodeproj/project.xcworkspace/xcuserdata/harleywakeman.xcuserdatad/IDEFindNavigatorScopes.plist b/Immich Gallery.xcodeproj/project.xcworkspace/xcuserdata/harleywakeman.xcuserdatad/IDEFindNavigatorScopes.plist new file mode 100644 index 0000000..5dd5da8 --- /dev/null +++ b/Immich Gallery.xcodeproj/project.xcworkspace/xcuserdata/harleywakeman.xcuserdatad/IDEFindNavigatorScopes.plist @@ -0,0 +1,5 @@ + + + + + diff --git a/Immich Gallery.xcodeproj/project.xcworkspace/xcuserdata/harleywakeman.xcuserdatad/UserInterfaceState.xcuserstate b/Immich Gallery.xcodeproj/project.xcworkspace/xcuserdata/harleywakeman.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..6e39366 Binary files /dev/null and b/Immich Gallery.xcodeproj/project.xcworkspace/xcuserdata/harleywakeman.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/Immich Gallery.xcodeproj/xcuserdata/harleywakeman.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/Immich Gallery.xcodeproj/xcuserdata/harleywakeman.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..73e3c40 --- /dev/null +++ b/Immich Gallery.xcodeproj/xcuserdata/harleywakeman.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,24 @@ + + + + + + + + + diff --git a/Immich Gallery.xcodeproj/xcuserdata/harleywakeman.xcuserdatad/xcschemes/xcschememanagement.plist b/Immich Gallery.xcodeproj/xcuserdata/harleywakeman.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..0070edf --- /dev/null +++ b/Immich Gallery.xcodeproj/xcuserdata/harleywakeman.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,19 @@ + + + + + SchemeUserState + + Immich Gallery.xcscheme_^#shared#^_ + + orderHint + 0 + + TopShelfExtension.xcscheme_^#shared#^_ + + orderHint + 1 + + + + diff --git a/Immich Gallery/Models/ImmichModels.swift b/Immich Gallery/Models/ImmichModels.swift index cdbb5ed..43dc104 100644 --- a/Immich Gallery/Models/ImmichModels.swift +++ b/Immich Gallery/Models/ImmichModels.swift @@ -22,7 +22,7 @@ struct ImmichAsset: Codable, Identifiable, Equatable { let resized: Bool? let thumbhash: String? let fileModifiedAt: String - let fileCreatedAt: String + let fileCreatedAt: String // Time media was created let localDateTime: String let updatedAt: String let isFavorite: Bool @@ -51,6 +51,55 @@ struct ImmichAsset: Codable, Identifiable, Equatable { } } +extension Array where Element == ImmichAsset { + func filtered(years: Set, devices: Set, locations: Set) -> [ImmichAsset] { + return self.filter { asset in + // 1. Filter by Year (extracted from localDateTime: "YYYY-MM-DD...") + let assetYear = Int(asset.localDateTime.prefix(4)) ?? 0 + let yearMatch = years.isEmpty || years.contains(assetYear) + + // 2. Filter by Device Model (from ExifInfo) + let deviceModel = asset.exifInfo?.model ?? "Unknown" + let deviceMatch = devices.isEmpty || devices.contains(deviceModel) + + // 3. Filter by Location (matches City, State, or Country) + let city = asset.exifInfo?.city ?? "" + + let locationMatch = locations.isEmpty || + locations.contains(city) + + // Asset must pass all active filters + return yearMatch && deviceMatch && locationMatch + } + } + + func sorted(by sortField: String, sortOrder: String = "desc") -> [ImmichAsset] { + let ascending = (sortOrder.lowercased() == "asc") + + return self.sorted { lhs, rhs in + var lhsValue = "" + var rhsValue = "" + + if sortField == "localDateTime" { + lhsValue = lhs.localDateTime + rhsValue = rhs.localDateTime + } else if sortField == "originalFileName" { + lhsValue = lhs.originalFileName + rhsValue = rhs.originalFileName + } else { + lhsValue = lhs.localDateTime + rhsValue = rhs.localDateTime + } + + if ascending { + return lhsValue < rhsValue + } else { + return lhsValue > rhsValue + } + } + } +} + enum AssetType: String, Codable { case image = "IMAGE" case video = "VIDEO" diff --git a/Immich Gallery/Services/AssetProvider.swift b/Immich Gallery/Services/AssetProvider.swift index 9f3e823..c14cc6f 100644 --- a/Immich Gallery/Services/AssetProvider.swift +++ b/Immich Gallery/Services/AssetProvider.swift @@ -41,6 +41,9 @@ struct AssetProviderFactory { protocol AssetProvider { func fetchAssets(page: Int, limit: Int) async throws -> SearchResult func fetchRandomAssets(limit: Int) async throws -> SearchResult + func fetchAllCities() async throws -> [String] + func fetchAllYears() async throws -> [Int] + func fetchAllDevices() async throws -> [String] } class AlbumAssetProvider: AssetProvider { @@ -75,7 +78,6 @@ class AlbumAssetProvider: AssetProvider { return cachedAssets } - // Fetch the album with full asset list; Immich includes assets unless withoutAssets is true let album = try await albumService.getAlbumInfo(albumId: albumId, withoutAssets: false) cachedAssets = album.assets return album.assets @@ -124,6 +126,37 @@ class AlbumAssetProvider: AssetProvider { ) } + func fetchAllCities() async throws -> [String] { + let assets = try await loadAlbumAssets() + let cities = assets.compactMap { asset in + if let city = asset.exifInfo?.city, !city.isEmpty { + return city + } + return nil + } + return Array(Set(cities)).sorted() + } + + func fetchAllYears() async throws -> [Int] { + let assets = try await loadAlbumAssets() + let years = assets.compactMap { asset -> Int? in + let yearString = asset.localDateTime.prefix(4) + return Int(yearString) + } + return Array(Set(years)).sorted(by: >) + } + + func fetchAllDevices() async throws -> [String] { + let assets = try await loadAlbumAssets() + let devices = assets.compactMap { asset in + if let model = asset.exifInfo?.model, !model.isEmpty { + return model + } + return nil + } + return Array(Set(devices)).sorted() + } + private func sortAssets(_ assets: [ImmichAsset]) -> [ImmichAsset] { let sortOrder = currentSortOrder() return assets.sorted { lhs, rhs in @@ -144,24 +177,15 @@ class AlbumAssetProvider: AssetProvider { } private func currentSortOrder() -> SortOrder { - let storedValue = UserDefaults.standard.string(forKey: UserDefaultsKeys.assetSortOrder) ?? "desc" + let storedValue = UserDefaults.standard.string(forKey: "assetSortOrder") ?? "desc" return storedValue.lowercased() == "asc" ? .oldestFirst : .newestFirst } private func captureDate(for asset: ImmichAsset) -> Date { - if let date = parseDate(asset.exifInfo?.dateTimeOriginal) { - return date - } - if let date = parseDate(asset.fileCreatedAt) { - return date - } - if let date = parseDate(asset.fileModifiedAt) { - return date - } - if let date = parseDate(asset.updatedAt) { - return date - } - + if let date = parseDate(asset.exifInfo?.dateTimeOriginal) { return date } + if let date = parseDate(asset.fileCreatedAt) { return date } + if let date = parseDate(asset.fileModifiedAt) { return date } + if let date = parseDate(asset.updatedAt) { return date } return .distantPast } @@ -196,13 +220,10 @@ class GeneralAssetProvider: AssetProvider { } func fetchAssets(page: Int, limit: Int) async throws -> SearchResult { - // If config is provided, use it; otherwise fall back to individual parameters if let config = config { return try await assetService.fetchAssets(config: config, page: page, limit: limit, isAllPhotos: isAllPhotos) } else { - return try await assetService.fetchAssets( - page: page, - limit: limit, + return try await assetService.fetchAllFilteredAndSortedAssets( albumId: nil, personId: personId, tagId: tagId, @@ -215,7 +236,6 @@ class GeneralAssetProvider: AssetProvider { } func fetchRandomAssets(limit: Int) async throws -> SearchResult { - // If config is provided, use it; otherwise fall back to individual parameters if let config = config { return try await assetService.fetchRandomAssets(config: config, limit: limit) } else { @@ -228,4 +248,16 @@ class GeneralAssetProvider: AssetProvider { ) } } + + func fetchAllCities() async throws -> [String] { + return try await assetService.fetchAllCities() + } + + func fetchAllYears() async throws -> [Int] { + return try await assetService.fetchAllYears() + } + + func fetchAllDevices() async throws -> [String] { + return try await assetService.fetchAllDevices() + } } diff --git a/Immich Gallery/Services/AssetService.swift b/Immich Gallery/Services/AssetService.swift index d895f7c..4790133 100644 --- a/Immich Gallery/Services/AssetService.swift +++ b/Immich Gallery/Services/AssetService.swift @@ -14,55 +14,129 @@ class AssetService: ObservableObject { self.networkService = networkService } - func fetchAssets(page: Int = 1, limit: Int? = nil, albumId: String? = nil, personId: String? = nil, tagId: String? = nil, city: String? = nil, isAllPhotos: Bool = false, isFavorite: Bool = false, folderPath: String? = nil) async throws -> SearchResult { - // Use separate sort order for All Photos tab vs everything else - let sortOrder = isAllPhotos - ? UserDefaults.standard.allPhotosSortOrder - : (UserDefaults.standard.string(forKey: "assetSortOrder") ?? "desc") - var searchRequest: [String: Any] = [ - "page": page, - "withPeople": true, - "order": sortOrder, - "withExif": true, - ] + func fetchAllFilteredAndSortedAssets(albumId: String? = nil, personId: String? = nil, tagId: String? = nil, city: String? = nil, isAllPhotos: Bool = false, isFavorite: Bool = false, folderPath: String? = nil) async throws -> SearchResult { + // 1. Setup Filters and Sorting + let allPhotosFilteredLocations = UserDefaults.standard.allPhotosFilteredLocations + let allPhotosFilteredYears = UserDefaults.standard.allPhotosFilteredYears + let allPhotosFilteredDevices = UserDefaults.standard.allPhotosFilteredDevices + let sortField = isAllPhotos ? UserDefaults.standard.allPhotosSortField : "localDateTime" + let sortOrder = isAllPhotos ? UserDefaults.standard.allPhotosSortOrder : "desc" + + var allItems: [ImmichAsset] = [] + var currentPage = 1 + var hasMorePages = true + let pageSize = 1000 // Use a large page size for efficiency + var serverTotal = 0 - if let limit = limit { - searchRequest["size"] = limit - } + // 2. Pagination Loop + while hasMorePages { + var searchRequest: [String: Any] = [ + "page": currentPage, + "size": pageSize, + "withPeople": true, + "order": sortOrder, + "withExif": true, + ] - if let albumId = albumId { - searchRequest["albumIds"] = [albumId] - } - if let personId = personId { - searchRequest["personIds"] = [personId] - } - if let tagId = tagId { - searchRequest["tagIds"] = [tagId] - } - if isFavorite { - searchRequest["isFavorite"] = true - } - if let city = city { - searchRequest["city"] = city - } - if let folderPath = folderPath, !folderPath.isEmpty { - searchRequest["originalPath"] = folderPath - searchRequest["path"] = folderPath - searchRequest["originalPathPrefix"] = folderPath + // Add optional filters to the API request + if let albumId = albumId { searchRequest["albumIds"] = [albumId] } + if let personId = personId { searchRequest["personIds"] = [personId] } + if let tagId = tagId { searchRequest["tagIds"] = [tagId] } + if isFavorite { searchRequest["isFavorite"] = true } + if let city = city { searchRequest["city"] = city } + if let folderPath = folderPath, !folderPath.isEmpty { + searchRequest["originalPath"] = folderPath + searchRequest["path"] = folderPath + searchRequest["originalPathPrefix"] = folderPath + } + + let result: SearchResponse = try await networkService.makeRequest( + endpoint: "/api/search/metadata", + method: .POST, + body: searchRequest, + responseType: SearchResponse.self + ) + + // Capture total on first page + if currentPage == 1 { serverTotal = result.assets.total } + + // Add raw items from this page + allItems.append(contentsOf: result.assets.items) + + // Check if there is a next page + if let nextPage = result.assets.nextPage, !nextPage.isEmpty { + currentPage += 1 + } else { + hasMorePages = false + } } - let result: SearchResponse = try await networkService.makeRequest( - endpoint: "/api/search/metadata", - method: .POST, - body: searchRequest, - responseType: SearchResponse.self + + // 3. Apply Local Filtering to the COMPLETE combined set + let filteredAssets = allItems.filtered( + years: allPhotosFilteredYears, + devices: allPhotosFilteredDevices, + locations: allPhotosFilteredLocations ) + + // 4. Apply Sorting to the COMPLETE filtered set + let sortedAssets = filteredAssets.sorted(by: sortField, sortOrder: sortOrder) + return SearchResult( - assets: result.assets.items, - total: result.assets.total, - nextPage: result.assets.nextPage + assets: sortedAssets, + total: sortedAssets.count, // Updated to reflect filtered count + nextPage: nil // No next page since we fetched everything ) } + func fetchAssets(page: Int = 1, limit: Int? = nil, albumId: String? = nil, personId: String? = nil, tagId: String? = nil, city: String? = nil, isAllPhotos: Bool = false, isFavorite: Bool = false, folderPath: String? = nil) async throws -> SearchResult { + // Use separate sort order for All Photos tab vs everything else + let sortOrder = isAllPhotos + ? UserDefaults.standard.allPhotosSortOrder + : (UserDefaults.standard.string(forKey: "assetSortOrder") ?? "desc") + var searchRequest: [String: Any] = [ + "page": page, + "withPeople": true, + "order": sortOrder, + "withExif": true, + ] + + if let limit = limit { + searchRequest["size"] = limit + } + + if let albumId = albumId { + searchRequest["albumIds"] = [albumId] + } + if let personId = personId { + searchRequest["personIds"] = [personId] + } + if let tagId = tagId { + searchRequest["tagIds"] = [tagId] + } + if isFavorite { + searchRequest["isFavorite"] = true + } + if let city = city { + searchRequest["city"] = city + } + if let folderPath = folderPath, !folderPath.isEmpty { + searchRequest["originalPath"] = folderPath + searchRequest["path"] = folderPath + searchRequest["originalPathPrefix"] = folderPath + } + let result: SearchResponse = try await networkService.makeRequest( + endpoint: "/api/search/metadata", + method: .POST, + body: searchRequest, + responseType: SearchResponse.self + ) + return SearchResult( + assets: result.assets.items, + total: result.assets.total, + nextPage: result.assets.nextPage + ) + } + /// Fetches assets using slideshow configuration func fetchAssets(config: SlideshowConfig, page: Int = 1, limit: Int = 50, isAllPhotos: Bool = false) async throws -> SearchResult { // Use separate sort order for All Photos tab vs everything else @@ -100,6 +174,83 @@ class AssetService: ObservableObject { nextPage: result.assets.nextPage ) } + + func fetchAllCities() async throws -> [String] { + let assets: [ImmichAsset] = try await networkService.makeRequest( + endpoint: "/api/search/cities", + method: .GET, + responseType: [ImmichAsset].self + ) + + let cities = assets.compactMap { asset in + if let city = asset.exifInfo?.city, !city.isEmpty { + return city + } + return nil + } + + return Array(Set(cities)).sorted() + } + + func fetchAllYears() async throws -> [Int] { + let endpoint = "/api/timeline/buckets?isTrashed=false" + + struct Bucket: Codable { + let timeBucket: String + } + + let response: [Bucket] = try await networkService.makeRequest( + endpoint: endpoint, + method: .GET, + responseType: [Bucket].self + ) + + let years = response.compactMap { bucket in + let yearString = bucket.timeBucket.prefix(4) + return Int(yearString) + } + + return Array(Set(years)).sorted(by: >) + } + + func fetchAllDevices() async throws -> [String] { + var allDeviceModels = Set() + var currentPage = 1 + var hasMorePages = true + let pageSize = 1000 + + while hasMorePages { + let body: [String: Any] = [ + "page": currentPage, + "size": pageSize, + "withExif": true + ] + + let response: SearchResponse = try await networkService.makeRequest( + endpoint: "/api/search/metadata", + method: .POST, + body: body, + responseType: SearchResponse.self + ) + + let pageModels = response.assets.items.compactMap { asset in + if let model = asset.exifInfo?.model, !model.isEmpty { + return model + } + return nil + } + + allDeviceModels.formUnion(pageModels) + + if response.assets.items.count < pageSize || response.assets.nextPage == nil { + hasMorePages = false + } else { + currentPage += 1 + } + } + + return Array(allDeviceModels).sorted() + } func loadImage(assetId: String, size: String = "thumbnail") async throws -> UIImage? { let endpoint = "/api/assets/\(assetId)/thumbnail?format=webp&size=\(size)" diff --git a/Immich Gallery/Services/TimezoneService.swift b/Immich Gallery/Services/TimezoneService.swift new file mode 100644 index 0000000..978473a --- /dev/null +++ b/Immich Gallery/Services/TimezoneService.swift @@ -0,0 +1,64 @@ +import Foundation + +struct TimeZoneService { + + static let all: [String] = [ + "Pacific/Honolulu", + "America/Anchorage", + "America/Los_Angeles", + "America/Denver", + "America/Chicago", + "America/New_York", + "America/Sao_Paulo", + "Europe/London", + "Europe/Paris", + "Europe/Moscow", + "Africa/Cairo", + "Africa/Johannesburg", + "Asia/Dubai", + "Asia/Kolkata", + "Asia/Shanghai", + "Asia/Tokyo", + "Australia/Sydney", + "Pacific/Auckland" + ] + + static var timeZones: [TimeZone] { + all.compactMap { TimeZone(identifier: $0) } + } + + static func convertToLocalTime( + isoString: String, + timeZoneIdentifier: String, + format: String = "yyyy-MM-dd HH:mm:ss" + ) -> String { + let isoFormatter = ISO8601DateFormatter() + + // Try parsing with fractional seconds first (e.g., .123Z) + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + var date = isoFormatter.date(from: isoString) + + // Fallback: Try parsing without fractional seconds (your specific case) + if date == nil { + isoFormatter.formatOptions = [.withInternetDateTime] + date = isoFormatter.date(from: isoString) + } + + guard let validDate = date else { + return isoString + } + + let timeZone = TimeZone(identifier: timeZoneIdentifier) ?? .current + + let formatter = DateFormatter() + formatter.timeZone = timeZone + formatter.dateFormat = format + + let dateString = formatter.string(from: validDate) + let abbreviation = timeZone.abbreviation(for: validDate) ?? "" + + return abbreviation.isEmpty + ? dateString + : "\(dateString) \(abbreviation)" + } +} diff --git a/Immich Gallery/Views/AssetGridView.swift b/Immich Gallery/Views/AssetGridView.swift index 2b5fcc3..1457954 100644 --- a/Immich Gallery/Views/AssetGridView.swift +++ b/Immich Gallery/Views/AssetGridView.swift @@ -12,24 +12,37 @@ struct AssetGridView: View { @ObservedObject var authService: AuthenticationService @ObservedObject private var thumbnailCache = ThumbnailCache.shared let assetProvider: AssetProvider - let albumId: String? // Optional album ID to filter assets - let personId: String? // Optional person ID to filter assets - let tagId: String? // Optional tag ID to filter assets - let city: String? // Optional city to filter assets - let isAllPhotos: Bool // Whether this is the All Photos tab - let isFavorite: Bool // Whether this is showing favorite assets - let onAssetsLoaded: (([ImmichAsset]) -> Void)? // Callback for when assets are loaded - let deepLinkAssetId: String? // Asset ID to highlight from deep link + + @AppStorage("allPhotosSortField") private var allPhotosSortField = "localDateTime" + @AppStorage("allPhotosSortOrder") private var allPhotosSortOrder = "desc" + + @State private var filterYears: Set = UserDefaults.standard.allPhotosFilteredYears + @State private var filterLocations: Set = UserDefaults.standard.allPhotosFilteredLocations + @State private var filterDevices: Set = UserDefaults.standard.allPhotosFilteredDevices + + @State private var showingSortModal = false + @State private var showingFilterModal = false + + // Context attributes + let albumId: String? + let personId: String? + let tagId: String? + let city: String? + let isAllPhotos: Bool + let isFavorite: Bool + + let onAssetsLoaded: (([ImmichAsset]) -> Void)? + let deepLinkAssetId: String? + @State private var assets: [ImmichAsset] = [] @State private var isLoading = false @State private var isLoadingMore = false @State private var errorMessage: String? @State private var selectedAsset: ImmichAsset? @State private var showingFullScreen = false - @State private var currentAssetIndex: Int = 0 // Track current asset index for highlighting + @State private var currentAssetIndex: Int = 0 @FocusState private var focusedAssetId: String? - @State private var isProgrammaticFocusChange = false // Flag to track programmatic focus changes - @State private var shouldScrollToAsset: String? // Asset ID to scroll to + @State private var isProgrammaticFocusChange = false @State private var nextPage: String? @State private var hasMoreAssets = true @State private var loadMoreTask: Task? @@ -45,148 +58,52 @@ struct AssetGridView: View { var body: some View { ZStack { - // Background SharedGradientBackground() if isLoading { - ProgressView("Loading photos...") - .foregroundColor(.white) - .scaleEffect(1.5) + loadingOverlay } else if let errorMessage = errorMessage { - VStack { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 60)) - .foregroundColor(.orange) - Text("Error") - .font(.title) - .foregroundColor(.white) - Text(errorMessage) - .foregroundColor(.gray) - .multilineTextAlignment(.center) - .padding() - Button("Retry") { - loadAssets() - } - .buttonStyle(.borderedProminent) - } + errorView(message: errorMessage) } else if assets.isEmpty { - VStack { - Image(systemName: "photo.on.rectangle.angled") - .font(.system(size: 60)) - .foregroundColor(.gray) - Text(getEmptyStateTitle()) - .font(.title) - .foregroundColor(.white) - Text(getEmptyStateMessage()) - .foregroundColor(.gray) - } + emptyStateView } else { - VStack { + VStack(spacing: 0) { + if isAllPhotos { + topToolbar + } + ScrollViewReader { proxy in ScrollView { LazyVGrid(columns: columns, spacing: 50) { - ForEach(assets) { asset in - Button(action: { - print("AssetGridView: Asset selected: \(asset.id)") - selectedAsset = asset - if let index = assets.firstIndex(of: asset) { - currentAssetIndex = index - print("AssetGridView: Set currentAssetIndex to \(index)") - } - showingFullScreen = true - }) { - AssetThumbnailView( - asset: asset, - assetService: assetService, - isFocused: focusedAssetId == asset.id - ) - } - .frame(width: 300, height: 360) - .id(asset.id) // Add id for ScrollViewReader - .focused($focusedAssetId, equals: asset.id) -// .scaleEffect(focusedAssetId == asset.id ? 1.1 : 1.0) - .animation(.easeInOut(duration: 0.2), value: focusedAssetId) - .onAppear { - // More efficient index check using enumerated - if let index = assets.firstIndex(of: asset) { - let threshold = max(assets.count - 100, 0) // Load when 20 items away from end - if index >= threshold && hasMoreAssets && !isLoadingMore { - debouncedLoadMore() - } - - // Check if this is the asset we need to scroll to - if shouldScrollToAsset == asset.id { - print("AssetGridView: Target asset appeared in grid - \(asset.id)") - } - } - } - .buttonStyle(CardButtonStyle()) - } - - // Loading indicator at the bottom - if isLoadingMore { - HStack { - Spacer() - ProgressView("Loading more...") - .foregroundColor(.white) - .scaleEffect(1.2) - Spacer() - } - .frame(height: 100) - .padding() - } - } - .padding(.horizontal) - .padding(.top, 20) - .padding(.bottom, 40) - .onChange(of: focusedAssetId) { newFocusedId in - print("AssetGridView: focusedAssetId changed to \(newFocusedId ?? "nil"), isProgrammatic: \(isProgrammaticFocusChange)") - - // Update currentAssetIndex when focus changes - if let focusedId = newFocusedId, - let focusedAsset = assets.first(where: { $0.id == focusedId }), - let index = assets.firstIndex(of: focusedAsset) { - currentAssetIndex = index - print("AssetGridView: Updated currentAssetIndex to \(index) for focused asset") - } - - // Scroll to the focused asset when it changes - if let focusedId = newFocusedId { - if isProgrammaticFocusChange { - print("AssetGridView: Programmatic focus change - scrolling to asset ID: \(focusedId)") - withAnimation(.easeInOut(duration: 0.5)) { - proxy.scrollTo(focusedId, anchor: .center) - } - // Reset the flag after scrolling - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - isProgrammaticFocusChange = false + ForEach(assets) { asset in + Button(action: { + openAsset(asset) + }) { + AssetThumbnailView( + asset: asset, + assetService: assetService, + isFocused: focusedAssetId == asset.id + ) } - } else { - print("AssetGridView: User navigation - not scrolling") - } - } - } - .onChange(of: shouldScrollToAsset) { assetId in - if let assetId = assetId { - print("AssetGridView: shouldScrollToAsset triggered - scrolling to asset ID: \(assetId)") - // Use a more robust scrolling approach with proper timing - DispatchQueue.main.async { - withAnimation(.easeInOut(duration: 0.5)) { - proxy.scrollTo(assetId, anchor: .center) + .frame(width: 300, height: 360) + .id(asset.id) + .focused($focusedAssetId, equals: asset.id) + .onAppear { + checkForLoadMore(item: asset) } + .buttonStyle(CardButtonStyle()) } - // Clear the trigger after a longer delay to ensure scroll completes - DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { - shouldScrollToAsset = nil - } + + if isLoadingMore { + bottomLoadingIndicator } } - .onChange(of: deepLinkAssetId) { assetId in - if let assetId = assetId { - print("AssetGridView: Deep link asset ID received: \(assetId)") - handleDeepLinkAsset(assetId) - } + .padding(.horizontal) + .padding(.top, 20) + .padding(.bottom, 40) } + .onChange(of: focusedAssetId) { _, newId in + handleFocusChange(proxy: proxy, newId: newId) } } } @@ -195,74 +112,140 @@ struct AssetGridView: View { .fullScreenCover(isPresented: $showingFullScreen) { if let selectedAsset = selectedAsset { FullScreenImageView( - asset: selectedAsset, - assets: assets, - currentIndex: assets.firstIndex(of: selectedAsset) ?? 0, - assetService: assetService, + asset: selectedAsset, + assets: assets, + currentIndex: currentAssetIndex, + assetService: assetService, authenticationService: authService, currentAssetIndex: $currentAssetIndex ) } } - .fullScreenCover(isPresented: $showingSlideshow) { - let imageAssets = assets.filter { $0.type == .image } - if !imageAssets.isEmpty { - let _ = print("currentAssetIndex test", currentAssetIndex) - // Find the index of the current asset in the filtered image assets - let startingIndex = currentAssetIndex < assets.count ? - (imageAssets.firstIndex(of: assets[currentAssetIndex]) ?? 0) : 0 - SlideshowView(albumId: albumId, personId: personId, tagId: tagId, city: city, startingIndex: startingIndex, isFavorite: isFavorite) - } + .sheet(isPresented: $showingFilterModal) { + FilterSettingsView( + allAssets: assets, + assetProvider: assetProvider, // Pass the provider + selectedYears: $filterYears, + selectedLocations: $filterLocations, + selectedDevices: $filterDevices, + onApply: { + applyFilters() + } + ) } - .onPlayPauseCommand(perform: { - print("Play pause tapped in AssetGridView - starting slideshow") - startSlideshow() - }) - .onAppear { - print("Appared") - if assets.isEmpty { - loadAssets() - } + .sheet(isPresented: $showingSortModal) { + SortSettingsView( + sortField: $allPhotosSortField, + sortOrder: $allPhotosSortOrder, + onApply: { + showingSortModal = false + loadAssets() + } + ) } - .onDisappear { - // Cancel any pending load more tasks when view disappears - loadMoreTask?.cancel() + .onPlayPauseCommand(perform: startSlideshow) + .onAppear { if assets.isEmpty { loadAssets() } } + .onDisappear { loadMoreTask?.cancel() } + } + + // MARK: - Subviews + + private var loadingOverlay: some View { + ProgressView("Loading photos...") + .foregroundColor(.white) + .scaleEffect(1.5) + } + + private func errorView(message: String) -> some View { + VStack(spacing: 20) { + Image(systemName: "exclamationmark.triangle").font(.system(size: 60)).foregroundColor(.orange) + Text("Error").font(.title).foregroundColor(.white) + Text(message).foregroundColor(.gray).multilineTextAlignment(.center).padding() + Button("Retry") { loadAssets() }.buttonStyle(.borderedProminent) } - .onChange(of: showingFullScreen) { _, isShowing in - print("AssetGridView: showingFullScreen changed to \(isShowing)") - // When fullscreen is dismissed, highlight the current asset - if !isShowing && currentAssetIndex < assets.count { - let currentAsset = assets[currentAssetIndex] - print("AssetGridView: Fullscreen dismissed, currentAssetIndex: \(currentAssetIndex), asset ID: \(currentAsset.id)") - - // Use a more robust approach with proper state management - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - // First, trigger the scroll - print("AssetGridView: Setting shouldScrollToAsset to \(currentAsset.id)") - shouldScrollToAsset = currentAsset.id - - // Then set the focus after a short delay to ensure scroll starts - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - print("AssetGridView: Setting focusedAssetId to \(currentAsset.id)") - print("AssetGridView: Setting isProgrammaticFocusChange to true") - isProgrammaticFocusChange = true - focusedAssetId = currentAsset.id - print("AssetGridView: focusedAssetId set to \(currentAsset.id)") - } + } + + private var emptyStateView: some View { + VStack(spacing: 20) { + Image(systemName: "photo.on.rectangle.angled").font(.system(size: 60)).foregroundColor(.gray) + Text(getEmptyStateTitle()).font(.title).foregroundColor(.white) + Text(getEmptyStateMessage()).foregroundColor(.gray) + + if !filterYears.isEmpty || !filterLocations.isEmpty || !filterDevices.isEmpty { + Button("Clear All Filters") { + clearFilters() } + .buttonStyle(.bordered) } } - .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name(NotificationNames.startAutoSlideshow))) { notification in - startSlideshow() + } + + private var topToolbar: some View { + HStack(spacing: 30) { + Spacer() + + // Filter Button + Button(action: { showingFilterModal = true }) { + let count = filterYears.count + filterLocations.count + filterDevices.count + Label { + Text("Filter \(count > 0 ? "(\(count))" : "")") + } icon: { + Image(systemName: count > 0 ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") + } + } + .buttonStyle(.bordered) + + // Sort Button + Button(action: { showingSortModal = true }) { + Label { + Text("Sort: \(formatSortLabel(allPhotosSortField)) (\(allPhotosSortOrder.uppercased()))") + } icon: { + Image(systemName: "arrow.up.arrow.down") + } + } + .buttonStyle(.bordered) + .padding(.trailing, 60) } + .padding(.bottom, 20) } - private func loadAssets() { - guard authService.isAuthenticated else { - errorMessage = "Not authenticated. Please check your credentials." - return + private var bottomLoadingIndicator: some View { + HStack { + Spacer() + ProgressView().foregroundColor(.white).scaleEffect(1.2) + Spacer() } - + .frame(height: 100) + } + + // MARK: - Logic & Actions + + private func openAsset(_ asset: ImmichAsset) { + selectedAsset = asset + currentAssetIndex = assets.firstIndex(of: asset) ?? 0 + showingFullScreen = true + } + + private func applyFilters() { + UserDefaults.standard.allPhotosFilteredYears = filterYears + UserDefaults.standard.allPhotosFilteredLocations = filterLocations + UserDefaults.standard.allPhotosFilteredDevices = filterDevices + showingFilterModal = false + loadAssets() + } + + private func clearFilters() { + filterYears.removeAll() + filterLocations.removeAll() + filterDevices.removeAll() + UserDefaults.standard.allPhotosFilteredYears = [] + UserDefaults.standard.allPhotosFilteredLocations = [] + UserDefaults.standard.allPhotosFilteredDevices = [] + loadAssets() + } + + private func loadAssets() { + guard authService.isAuthenticated else { return } isLoading = true errorMessage = nil nextPage = nil @@ -275,14 +258,9 @@ struct AssetGridView: View { self.assets = searchResult.assets self.nextPage = searchResult.nextPage self.isLoading = false - // If there's no nextPage, we've reached the end self.hasMoreAssets = searchResult.nextPage != nil - - // Notify parent view about loaded assets onAssetsLoaded?(searchResult.assets) } - - // Preload thumbnails for better performance ThumbnailCache.shared.preloadThumbnails(for: searchResult.assets) } catch { await MainActor.run { @@ -292,136 +270,89 @@ struct AssetGridView: View { } } } - - private func debouncedLoadMore() { - // Immediately set loading state to prevent multiple triggers + + private func checkForLoadMore(item: ImmichAsset) { guard !isLoadingMore && hasMoreAssets else { return } - - isLoadingMore = true - - // Cancel any existing load more task + if let index = assets.firstIndex(of: item), index >= assets.count - 40 { + debouncedLoadMore() + } + } + + private func debouncedLoadMore() { loadMoreTask?.cancel() - - // Create a new debounced task + isLoadingMore = true loadMoreTask = Task { - try? await Task.sleep(nanoseconds: 300_000_000) // 300ms delay - - // Check if task was cancelled during sleep - if Task.isCancelled { - await MainActor.run { - isLoadingMore = false - } - return - } - - await MainActor.run { - loadMoreAssets() - } + try? await Task.sleep(nanoseconds: 300_000_000) + if !Task.isCancelled { await MainActor.run { loadMoreAssets() } } } } - + private func loadMoreAssets() { - guard hasMoreAssets && nextPage != nil else { + guard hasMoreAssets && nextPage != nil else { isLoadingMore = false - return + return } - Task { do { - // Extract page number from nextPage string let pageNumber = extractPageFromNextPage(nextPage!) let searchResult = try await assetProvider.fetchAssets(page: pageNumber, limit: 200) - await MainActor.run { if !searchResult.assets.isEmpty { self.assets.append(contentsOf: searchResult.assets) self.nextPage = searchResult.nextPage - - // If there's no nextPage, we've reached the end self.hasMoreAssets = searchResult.nextPage != nil } else { self.hasMoreAssets = false } self.isLoadingMore = false } - - // Preload thumbnails for newly loaded assets ThumbnailCache.shared.preloadThumbnails(for: searchResult.assets) } catch { - await MainActor.run { - print("Failed to load more assets: \(error)") - self.isLoadingMore = false - } + await MainActor.run { self.isLoadingMore = false } } } } private func extractPageFromNextPage(_ nextPageString: String) -> Int { - // Optimized page extraction with caching - if let pageNumber = Int(nextPageString) { - return pageNumber + if let pageNumber = Int(nextPageString) { return pageNumber } + return (assets.count / 100) + 2 + } + + private func handleFocusChange(proxy: ScrollViewProxy, newId: String?) { + if let id = newId, let asset = assets.first(where: { $0.id == id }) { + currentAssetIndex = assets.firstIndex(of: asset) ?? 0 + + if isProgrammaticFocusChange { + withAnimation(.easeInOut) { + proxy.scrollTo(id, anchor: .center) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isProgrammaticFocusChange = false + } + } } - - // Try to extract from URL parameters more efficiently - if nextPageString.contains("page="), - let url = URL(string: nextPageString), - let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let pageParam = components.queryItems?.first(where: { $0.name == "page" }), - let pageNumber = Int(pageParam.value ?? "2") { - return pageNumber + } + + private func formatSortLabel(_ field: String) -> String { + switch field { + case "localDateTime": return "Date Taken" + case "originalFileName": return "File Name" + default: return field.capitalized } - - // Default fallback - calculate based on current assets count - return (assets.count / 100) + 2 } - + private func getEmptyStateTitle() -> String { - if personId != nil { - return "No Photos of Person" - } else if albumId != nil { - return "No Photos in Album" - } else { - return "No Photos Found" - } + let count = filterYears.count + filterLocations.count + filterDevices.count + return count > 0 ? "No Results for Filters" : "No Photos Found" } private func getEmptyStateMessage() -> String { - if personId != nil { - return "This person has no photos" - } else if albumId != nil { - return "This album is empty" - } else { - return "Your photos will appear here" - } + let count = filterYears.count + filterLocations.count + filterDevices.count + return count > 0 ? "Try adjusting your filter settings." : "Your photos will appear here." } - + private func startSlideshow() { - // Stop auto-slideshow timer before starting slideshow NotificationCenter.default.post(name: NSNotification.Name("stopAutoSlideshowTimer"), object: nil) showingSlideshow = true } - - private func handleDeepLinkAsset(_ assetId: String) { - // Check if the asset is already loaded - if assets.contains(where: { $0.id == assetId }) { - print("AssetGridView: Asset \(assetId) found in loaded assets, scrolling and focusing") - focusedAssetId = assetId - isProgrammaticFocusChange = true - } else { - print("AssetGridView: Asset \(assetId) not found in current loaded assets") - // For now, just load the first page and hope the asset is there - // In a more complex implementation, we could search for the asset across pages - if assets.isEmpty { - loadAssets() - // After loading, try to find the asset again - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - if let foundAsset = assets.first(where: { $0.id == assetId }) { - focusedAssetId = foundAsset.id - isProgrammaticFocusChange = true - } - } - } - } - } } - diff --git a/Immich Gallery/Views/FilterSettingsView.swift b/Immich Gallery/Views/FilterSettingsView.swift new file mode 100644 index 0000000..ea2e57e --- /dev/null +++ b/Immich Gallery/Views/FilterSettingsView.swift @@ -0,0 +1,266 @@ +// +// FilterSettingsView.swift +// Immich Gallery +// +// Created by Harley Wakeman on 1/18/26. +// + +import SwiftUI + +struct FilterSettingsView: View { + let allAssets: [ImmichAsset] + @Binding var selectedYears: Set + @Binding var selectedLocations: Set + @Binding var selectedDevices: Set + var onApply: () -> Void + let assetProvider: AssetProvider + + // Local state so changes only apply on "Apply" click + @State private var localYears: Set + @State private var localLocations: Set + @State private var localDevices: Set + + // State to hold data fetched from provider/assets + @State private var availableYears: [Int] = [] + @State private var availableLocations: [String] = [] + @State private var availableDevices: [String] = [] + @State private var isLoading = true + + // MARK: - Focus + enum FilterFocus: Hashable { + case resetButton + case applyButton + case middleColumn + case rightColumn + } + + @FocusState private var focusedField: FilterFocus? + + init( + allAssets: [ImmichAsset], + assetProvider: AssetProvider, + selectedYears: Binding>, + selectedLocations: Binding>, + selectedDevices: Binding>, + onApply: @escaping () -> Void + ) { + self.allAssets = allAssets + self.assetProvider = assetProvider + self._selectedYears = selectedYears + self._selectedLocations = selectedLocations + self._selectedDevices = selectedDevices + self.onApply = onApply + + _localYears = State(initialValue: selectedYears.wrappedValue) + _localLocations = State(initialValue: selectedLocations.wrappedValue) + _localDevices = State(initialValue: selectedDevices.wrappedValue) + } + + var body: some View { + ZStack { + Rectangle() + .fill(.ultraThinMaterial) + .ignoresSafeArea() + + if isLoading { + ProgressView("Loading filters...") + .scaleEffect(2) + } else { + VStack(spacing: 0) { + + // Header + VStack(spacing: 10) { + Text("Filter Photos") + .font(.system(size: 70, weight: .bold)) + .foregroundColor(.white) + + Text("Select options to narrow results") + .font(.title3) + .foregroundColor(.gray) + } + .padding(.top, 60) + .padding(.bottom, 40) + + // Filter Columns + HStack(alignment: .top, spacing: 40) { + FilterColumn( + title: "Years", + items: availableYears, + selection: $localYears, + position: .left + ) { + focusedField = .applyButton + } + + FilterColumn( + title: "Locations", + items: availableLocations, + selection: $localLocations, + position: .middle + ) { + focusedField = .applyButton + } + // Anchor for Apply → Up + .focused($focusedField, equals: .middleColumn) + + FilterColumn( + title: "Devices", + items: availableDevices, + selection: $localDevices, + position: .right + ) { + focusedField = .applyButton + } + // Anchor for Apply → Right + .focused($focusedField, equals: .rightColumn) + } + .padding(.horizontal, 60) + + Spacer() + + // Bottom Buttons + HStack(spacing: 40) { + Button("Reset All") { + localYears.removeAll() + localLocations.removeAll() + localDevices.removeAll() + } + .buttonStyle(.bordered) + .focused($focusedField, equals: .resetButton) + + Button { + selectedYears = localYears + selectedLocations = localLocations + selectedDevices = localDevices + onApply() + } label: { + Label("Apply Filters", systemImage: "checkmark.circle.fill") + .padding(.horizontal, 40) + } + .buttonStyle(.borderedProminent) + .focused($focusedField, equals: .applyButton) + .onMoveCommand { direction in + switch direction { + case .left: + focusedField = .resetButton + + case .up: + focusedField = .middleColumn + + case .right: + focusedField = .rightColumn + + case .down: + break // explicitly do nothing + + default: + break + } + } + } + .padding(.bottom, 80) + } + } + } + .task { + await loadData() + } + } + + // MARK: - Data Loading + private func loadData() async { + do { + async let fetchedCities = assetProvider.fetchAllCities() + async let fetchedYears = assetProvider.fetchAllYears() + async let fetchedDevices = assetProvider.fetchAllDevices() + + let (cities, years, devices) = try await (fetchedCities, fetchedYears, fetchedDevices) + + await MainActor.run { + availableYears = years + availableLocations = cities + availableDevices = devices + isLoading = false + } + } catch { + print("Error loading filter data: \(error)") + await MainActor.run { + isLoading = false + } + } + } +} + +// MARK: - Filter Column +struct FilterColumn: View { + enum Position { + case left + case middle + case right + } + + let title: String + let items: [T] + @Binding var selection: Set + let position: Position + let onExitToApply: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + Text(title) + .font(.headline) + .foregroundColor(.white.opacity(0.6)) + .padding(.leading, 20) + + ScrollView { + VStack(spacing: 15) { + ForEach(items.indices, id: \.self) { index in + let item = items[index] + + Button { + if selection.contains(item) { + selection.remove(item) + } else { + selection.insert(item) + } + } label: { + HStack { + Text(item.description) + .font(.title3) + + Spacer() + + if selection.contains(item) { + Image(systemName: "checkmark") + .foregroundColor(.blue) + .fontWeight(.bold) + } + } + .padding(.horizontal, 30) + .frame(maxWidth: .infinity) + .frame(height: 100) + } + .buttonStyle(.card) + .onMoveCommand { direction in + switch direction { + case .down where index == items.count - 1: + onExitToApply() + + case .left where position == .left: + onExitToApply() + + case .right where position == .right: + onExitToApply() + + default: + break + } + } + } + } + .padding(10) + } + .frame(width: 550) + } + } +} diff --git a/Immich Gallery/Views/LockScreenStyleOverlay.swift b/Immich Gallery/Views/LockScreenStyleOverlay.swift index 0ebc3af..c6ad812 100644 --- a/Immich Gallery/Views/LockScreenStyleOverlay.swift +++ b/Immich Gallery/Views/LockScreenStyleOverlay.swift @@ -17,6 +17,8 @@ struct LockScreenStyleOverlay: View { @State private var currentTime = Date() @State private var timeUpdateTimer: Timer? + @AppStorage("timeZone") private var timeZoneIdentifier: String = TimeZone.current.identifier + private var use24HourClock: Bool { UserDefaults.standard.bool(forKey: "use24HourClock") } @@ -32,7 +34,6 @@ struct LockScreenStyleOverlay: View { VStack(alignment: .trailing, spacing: 24) { // Increased spacing for tvOS // MARK: - Clock and Date Display if isSlideshowMode { - VStack(alignment: .trailing, spacing: 12) { // Increased spacing // Current time in large text Text(formatCurrentTime()) @@ -80,68 +81,31 @@ struct LockScreenStyleOverlay: View { } // MARK: - Date with elegant styling + let assetDate = asset.exifInfo?.dateTimeOriginal ?? asset.fileCreatedAt HStack(spacing: 0) { Image(systemName: "calendar") .font(.system(size: 20)) .foregroundColor(.white.opacity(0.85)) - Text(getDisplayDate()) - .font(.system(size: 20, weight: .regular, design: .rounded)) - .foregroundColor(.white) + Text(TimeZoneService.convertToLocalTime( + isoString: assetDate, + timeZoneIdentifier: timeZoneIdentifier, + format: use24HourClock ? "yyyy-MM-dd HH:mm" : "yyyy-MM-dd h:mm a" + )) + .font(.system(size: 20, weight: .regular, design: .rounded)) + .foregroundColor(.white) } } } .padding(.horizontal, 24) .padding(.vertical, 12) - .background( - RoundedRectangle(cornerRadius: 12) - .fill(Color.black.opacity(0.6))) - } - // .padding(40) // Overall padding for the entire overlay to push it in from edges - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) // Align content to top right - .onAppear { - if isSlideshowMode { - startTimeUpdate() - } - } - .onDisappear { - stopTimeUpdate() + .background(RoundedRectangle(cornerRadius: 12).fill(Color.black.opacity(0.6))) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .onAppear { if isSlideshowMode { startTimeUpdate() } } + .onDisappear { stopTimeUpdate() } } - // MARK: - Logic Functions (Unchanged) - private func getDisplayDate() -> String { - if let dateTimeOriginal = asset.exifInfo?.dateTimeOriginal { - return formatDisplayDate(dateTimeOriginal) - } else { - return formatDisplayDate(asset.fileCreatedAt) - } - } - - private func formatDisplayDate(_ dateString: String) -> String { - let formatter = DateFormatter() - - // Try EXIF date format first - formatter.dateFormat = "yyyy:MM:dd HH:mm:ss" - if let date = formatter.date(from: dateString) { - let displayFormatter = DateFormatter() - displayFormatter.dateStyle = .medium - displayFormatter.timeStyle = .short - return displayFormatter.string(from: date) - } - - // Try ISO date format - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" - formatter.timeZone = TimeZone(abbreviation: "UTC") // Important for 'Z' suffix - if let date = formatter.date(from: dateString) { - let displayFormatter = DateFormatter() - displayFormatter.dateStyle = .medium - displayFormatter.timeStyle = .short - return displayFormatter.string(from: date) - } - - return dateString - } - + // MARK: - Helpers private func getLocationString() -> String? { if let city = asset.exifInfo?.city, let state = asset.exifInfo?.state, let country = asset.exifInfo?.country { return "\(city), \(state), \(country)" @@ -153,11 +117,11 @@ struct LockScreenStyleOverlay: View { return nil } - // MARK: - Time Management for Slideshow Mode (Unchanged logic) - + // MARK: - Time Management for Slideshow Mode private func formatCurrentTime() -> String { let formatter = DateFormatter() formatter.dateFormat = use24HourClock ? "HH:mm" : "h:mm a" + formatter.timeZone = TimeZone(identifier: timeZoneIdentifier) return formatter.string(from: currentTime) } @@ -165,6 +129,7 @@ struct LockScreenStyleOverlay: View { let formatter = DateFormatter() formatter.dateStyle = .full // e.g., "Friday, July 5, 2025" formatter.timeStyle = .none + formatter.timeZone = TimeZone(identifier: timeZoneIdentifier) return formatter.string(from: currentTime) } @@ -173,7 +138,6 @@ struct LockScreenStyleOverlay: View { stopTimeUpdate() // Update time immediately currentTime = Date() - // Set up timer to update every second timeUpdateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in currentTime = Date() @@ -189,7 +153,7 @@ struct LockScreenStyleOverlay: View { #Preview { let (_, _, _, assetService, _, _, _, _) = MockServiceFactory.createMockServices() - + // Create mock assets for preview let mockAssets = [ ImmichAsset( @@ -222,6 +186,6 @@ struct LockScreenStyleOverlay: View { exifInfo: nil ) ] - + SlideshowView(albumId: nil, personId: nil, tagId: nil, startingIndex: 0, isFavorite: false) } diff --git a/Immich Gallery/Views/Settings/SettingsView.swift b/Immich Gallery/Views/Settings/SettingsView.swift index 0e62980..e4c42a3 100644 --- a/Immich Gallery/Views/Settings/SettingsView.swift +++ b/Immich Gallery/Views/Settings/SettingsView.swift @@ -70,14 +70,14 @@ struct SettingsView: View { @AppStorage("showTagsTab") private var showTagsTab = false @AppStorage("showFoldersTab") private var showFoldersTab = false @AppStorage("defaultStartupTab") private var defaultStartupTab = "photos" - @AppStorage("assetSortOrder") private var assetSortOrder = "desc" @AppStorage("use24HourClock") private var use24HourClock = true @AppStorage("enableReflectionsInSlideshow") private var enableReflectionsInSlideshow = true @AppStorage("enableKenBurnsEffect") private var enableKenBurnsEffect = false @AppStorage("enableThumbnailAnimation") private var enableThumbnailAnimation = false @AppStorage("enableSlideshowShuffle") private var enableSlideshowShuffle = false - @AppStorage("allPhotosSortOrder") private var allPhotosSortOrder = "desc" + @AppStorage("assetSortOrder") private var assetSortOrder = "desc" @AppStorage("navigationStyle") private var navigationStyle = NavigationStyle.tabs.rawValue + @AppStorage("timeZone") private var timeZone = TimeZone.current.identifier @AppStorage("enableTopShelf", store: UserDefaults(suiteName: AppConstants.appGroupIdentifier)) private var enableTopShelf = true @AppStorage("topShelfStyle", store: UserDefaults(suiteName: AppConstants.appGroupIdentifier)) private var topShelfStyle = "carousel" @AppStorage("topShelfImageSelection", store: UserDefaults(suiteName: AppConstants.appGroupIdentifier)) private var topShelfImageSelection = "recent" @@ -332,6 +332,21 @@ struct SettingsView: View { .frame(width: 300, alignment: .trailing) ) ) + + SettingsRow( + icon: "globe", + title: "Timezone", + subtitle: "Choose your timezone", + content: AnyView( + Picker("Timezone", selection: $timeZone) { + ForEach(TimeZoneService.timeZones, id: \.identifier) { tz in + Text(tz.identifier).tag(tz) + } + } + .pickerStyle(.menu) + .frame(width: 300, alignment: .trailing) + ) + ) } ) } @@ -386,20 +401,6 @@ struct SettingsView: View { // Sorting Settings Section SettingsSection(title: "Sorting") { AnyView(VStack(spacing: 12) { - SettingsRow( - icon: "photo.on.rectangle", - title: "All Photos Sort Order", - subtitle: "Order photos in the All Photos tab", - content: AnyView( - Picker("All Photos Sort Order", selection: $allPhotosSortOrder) { - Text("Newest First").tag("desc") - Text("Oldest First").tag("asc") - } - .pickerStyle(.menu) - .frame(width: 300, alignment: .trailing) - ) - ) - SettingsRow( icon: "arrow.up.arrow.down", title: "Albums & Collections Sort Order", diff --git a/Immich Gallery/Views/Settings/UserDefaults.swift b/Immich Gallery/Views/Settings/UserDefaults.swift index 1b4bc65..f267fec 100644 --- a/Immich Gallery/Views/Settings/UserDefaults.swift +++ b/Immich Gallery/Views/Settings/UserDefaults.swift @@ -79,6 +79,43 @@ extension UserDefaults { set { set(newValue, forKey: UserDefaultsKeys.navigationStyle) } } + var allPhotosFilteredLocations: Set { + get { + let array = stringArray(forKey: "allPhotosFilteredLocations") ?? [] + return Set(array) + } + set { + set(Array(newValue), forKey: "allPhotosFilteredLocations") + } + } + + var allPhotosFilteredYears: Set { + get { + // Retrieve the array of integers; default to empty if not found + let array = array(forKey: "allPhotosFilteredYears") as? [Int] ?? [] + return Set(array) + } + set { + // Convert Set back to an Array for storage + set(Array(newValue), forKey: "allPhotosFilteredYears") + } + } + + var allPhotosFilteredDevices: Set { + get { + let array = stringArray(forKey: "allPhotosFilteredDevices") ?? [] + return Set(array) + } + set { + set(Array(newValue), forKey: "allPhotosFilteredDevices") + } + } + + var allPhotosSortField: String { + get { string(forKey: UserDefaultsKeys.allPhotosSortField) ?? "localDateTime" } + set { set(newValue, forKey: UserDefaultsKeys.allPhotosSortField) } + } + var allPhotosSortOrder: String { get { string(forKey: UserDefaultsKeys.allPhotosSortOrder) ?? "desc" } set { set(newValue, forKey: UserDefaultsKeys.allPhotosSortOrder) } diff --git a/Immich Gallery/Views/SortSettingsView.swift b/Immich Gallery/Views/SortSettingsView.swift new file mode 100644 index 0000000..c5c1b81 --- /dev/null +++ b/Immich Gallery/Views/SortSettingsView.swift @@ -0,0 +1,71 @@ +// +// SortSettingsView.swift +// Immich Gallery +// +// Created by Harley Wakeman on 1/18/26. +// + +import SwiftUI + +struct SortSettingsView: View { + @Binding var sortField: String + @Binding var sortOrder: String + var onApply: () -> Void + + let fields = [ + ("Date Taken", "localDateTime"), + ("File Name", "originalFileName") + ] + + var body: some View { + ZStack { + Rectangle().fill(.ultraThinMaterial).ignoresSafeArea() + + VStack(spacing: 40) { + Text("Sort Options").font(.system(size: 60, weight: .bold)) + + HStack(spacing: 60) { + // Field Selection + VStack(alignment: .leading) { + Text("Sort By").font(.headline).foregroundColor(.gray) + ForEach(fields, id: \.1) { label, value in + Button(action: { sortField = value }) { + HStack { + Text(label) + Spacer() + if sortField == value { Image(systemName: "checkmark") } + } + .frame(width: 400) + } + } + } + + // Order Selection + VStack(alignment: .leading) { + Text("Order").font(.headline).foregroundColor(.gray) + Button(action: { sortOrder = "desc" }) { + HStack { + Text("Descending") + Spacer() + if sortOrder == "desc" { Image(systemName: "checkmark") } + } + .frame(width: 400) + } + Button(action: { sortOrder = "asc" }) { + HStack { + Text("Ascending") + Spacer() + if sortOrder == "asc" { Image(systemName: "checkmark") } + } + .frame(width: 400) + } + } + } + + Button("Apply") { onApply() } + .buttonStyle(.borderedProminent) + .padding(.top, 40) + } + } + } +} diff --git a/Shared/AppConstants.swift b/Shared/AppConstants.swift index 65f2120..1776b14 100644 --- a/Shared/AppConstants.swift +++ b/Shared/AppConstants.swift @@ -31,14 +31,18 @@ struct UserDefaultsKeys { static let enableKenBurnsEffect = "enableKenBurnsEffect" static let enableThumbnailAnimation = "enableThumbnailAnimation" static let enableSlideshowShuffle = "enableSlideshowShuffle" + static let allPhotosFilteredLocations = "allPhotosFilteredLocations" + static let allPhotosFilteredYears = "allPhotosFilteredYears" + static let allPhotosFilteredDevices = "allPhotosFilteredDevices" + static let allPhotosSortField = "allPhotosSortField" static let allPhotosSortOrder = "allPhotosSortOrder" + static let assetSortOrder = "assetSortOrder" static let navigationStyle = "navigationStyle" static let enableTopShelf = "enableTopShelf" static let topShelfStyle = "topShelfStyle" static let topShelfImageSelection = "topShelfImageSelection" static let defaultStartupTab = "defaultStartupTab" static let lastSeenVersion = "lastSeenVersion" - static let assetSortOrder = "assetSortOrder" // Art Mode settings static let artModeLevel = "artModeLevel"