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"