Compare commits

..

1 Commits

Author SHA1 Message Date
Thomas Way 319b468519 chore(mobile): use declarative ui in search
The search page makes use of imperative state, which is buggy and
unergonomic - it fights against how flutter wants widgets to be
written. Using declarative state simplifies the code and fixes bugs.
2026-04-05 03:34:46 +01:00
40 changed files with 388 additions and 725 deletions
+14 -14
View File
@@ -28,17 +28,17 @@ For the full list, refer to the [Immich source code](https://github.com/immich-a
## Video formats
| Format | Extension(s) | Supported? | Notes |
| :---------- | :-------------------------- | :----------------: | :---- |
| `3GPP` | `.3gp` `.3gpp` | :white_check_mark: | |
| `AVI` | `.avi` | :white_check_mark: | |
| `FLV` | `.flv` | :white_check_mark: | |
| `M4V` | `.m4v` | :white_check_mark: | |
| `MATROSKA` | `.mkv` | :white_check_mark: | |
| `MP2T` | `.mts` `.m2ts` `.m2t` `.ts` | :white_check_mark: | |
| `MP4` | `.mp4` `.insv` | :white_check_mark: | |
| `MPEG` | `.mpg` `.mpe` `.mpeg` | :white_check_mark: | |
| `MXF` | `.mxf` | :white_check_mark: | |
| `QUICKTIME` | `.mov` | :white_check_mark: | |
| `WEBM` | `.webm` | :white_check_mark: | |
| `WMV` | `.wmv` | :white_check_mark: | |
| Format | Extension(s) | Supported? | Notes |
| :---------- | :-------------------- | :----------------: | :---- |
| `3GPP` | `.3gp` `.3gpp` | :white_check_mark: | |
| `AVI` | `.avi` | :white_check_mark: | |
| `FLV` | `.flv` | :white_check_mark: | |
| `M4V` | `.m4v` | :white_check_mark: | |
| `MATROSKA` | `.mkv` | :white_check_mark: | |
| `MP2T` | `.mts` `.m2ts` `.m2t` | :white_check_mark: | |
| `MP4` | `.mp4` `.insv` | :white_check_mark: | |
| `MPEG` | `.mpg` `.mpe` `.mpeg` | :white_check_mark: | |
| `MXF` | `.mxf` | :white_check_mark: | |
| `QUICKTIME` | `.mov` | :white_check_mark: | |
| `WEBM` | `.webm` | :white_check_mark: | |
| `WMV` | `.wmv` | :white_check_mark: | |
-1
View File
@@ -173,7 +173,6 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
'.mpeg',
'.mpg',
'.mts',
'.ts',
'.vob',
'.webm',
'.wmv',
@@ -6,7 +6,6 @@ import {
generateTimelineData,
TimelineAssetConfig,
TimelineData,
toAssetResponseDto,
} from 'src/ui/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
@@ -31,10 +30,6 @@ test.describe('search gallery-viewer', () => {
};
test.beforeAll(async () => {
test.fail(
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
);
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
@@ -49,10 +44,7 @@ test.describe('search gallery-viewer', () => {
await context.route('**/api/search/metadata', async (route, request) => {
if (request.method() === 'POST') {
const searchAssets = assets
.slice(0, 5)
.filter((asset) => !changes.assetDeletions.includes(asset.id))
.map((asset) => toAssetResponseDto(asset));
const searchAssets = assets.slice(0, 5).filter((asset) => !changes.assetDeletions.includes(asset.id));
return route.fulfill({
status: 200,
contentType: 'application/json',
+7 -29
View File
@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@@ -16,7 +16,6 @@
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
A01DD69B2F7F43B40049AB63 /* ImageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A01DD6982F7F43B40049AB63 /* ImageRequest.swift */; };
B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */; };
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; };
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; };
@@ -103,7 +102,6 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A01DD6982F7F43B40049AB63 /* ImageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRequest.swift; sourceTree = "<group>"; };
B1FBA9EE014DE20271B0FE77 /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = "<group>"; };
B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorkerApiImpl.swift; sourceTree = "<group>"; };
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = "<group>"; };
@@ -139,23 +137,20 @@
);
target = F0B57D372DF764BD00DC5BCC /* WidgetExtension */;
};
FE1BB4572F83196E0087DBF9 /* Exceptions for "Utility" folder in "Runner" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Mutex.swift,
);
target = 97C146ED1CF9000F007C117D /* Runner */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
B231F52D2E93A44A00BC45D1 /* Core */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Core;
sourceTree = "<group>";
};
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Sync;
sourceTree = "<group>";
};
@@ -167,16 +162,10 @@
path = WidgetExtension;
sourceTree = "<group>";
};
FE1BB4562F8319560087DBF9 /* Utility */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
FE1BB4572F83196E0087DBF9 /* Exceptions for "Utility" folder in "Runner" target */,
);
path = Utility;
sourceTree = "<group>";
};
FEE084F22EC172080045228E /* Schemas */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Schemas;
sourceTree = "<group>";
};
@@ -284,7 +273,6 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
FE1BB4562F8319560087DBF9 /* Utility */,
FEE084F22EC172080045228E /* Schemas */,
B231F52D2E93A44A00BC45D1 /* Core */,
B25D37792E72CA15008B6CA7 /* Connectivity */,
@@ -339,7 +327,6 @@
FED3B1952E253E9B0030FD97 /* Images */ = {
isa = PBXGroup;
children = (
A01DD6982F7F43B40049AB63 /* ImageRequest.swift */,
FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */,
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */,
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */,
@@ -571,14 +558,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -607,14 +590,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -629,7 +608,6 @@
files = (
65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */,
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
A01DD69B2F7F43B40049AB63 /* ImageRequest.swift in Sources */,
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */,
FE5499F32F1197D8006016CB /* LocalImages.g.swift in Sources */,
FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */,
@@ -1,12 +1,7 @@
import Foundation
enum ImageProcessing {
static let queue = {
let q = OperationQueue()
q.name = "thumbnail.processing"
q.qualityOfService = .userInitiated
q.maxConcurrentOperationCount = ProcessInfo.processInfo.activeProcessorCount * 2
return q
}()
static let queue = DispatchQueue(label: "thumbnail.processing", qos: .userInitiated, attributes: .concurrent)
static let semaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount * 2)
static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil)
}
@@ -1,14 +0,0 @@
import Foundation
struct RequestRegistry<T: AnyObject & Sendable>: ~Copyable, Sendable {
private let requests = Mutex<[Int64: T]>([:])
func add(requestId: Int64, request: T) {
requests.withLock { $0[requestId] = request }
}
@discardableResult
func remove(requestId: Int64) -> T? {
requests.withLock { $0.removeValue(forKey: requestId) }
}
}
+52 -24
View File
@@ -4,18 +4,13 @@ import MobileCoreServices
import Photos
class LocalImageRequest {
weak var operation: Operation?
weak var workItem: DispatchWorkItem?
var isCancelled = false
let callback: (Result<[String: Int64]?, any Error>) -> Void
init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
self.callback = callback
}
func cancel() {
isCancelled = true
operation?.cancel()
}
}
class LocalImageApiImpl: LocalImageApi {
@@ -36,7 +31,9 @@ class LocalImageApiImpl: LocalImageApi {
return requestOptions
}()
private static let registry = RequestRegistry<LocalImageRequest>()
private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated)
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated)
private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default)
private static var rgbaFormat = vImage_CGImageFormat(
bitsPerComponent: 8,
@@ -45,6 +42,7 @@ class LocalImageApiImpl: LocalImageApi {
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
renderingIntent: .defaultIntent
)!
private static var requests = [Int64: LocalImageRequest]()
private static let assetCache = {
let assetCache = NSCache<NSString, PHAsset>()
assetCache.countLimit = 10000
@@ -52,7 +50,7 @@ class LocalImageApiImpl: LocalImageApi {
}()
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
ImageProcessing.queue.addOperation {
ImageProcessing.queue.async {
guard let data = Data(base64Encoded: thumbhash)
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
@@ -68,14 +66,23 @@ class LocalImageApiImpl: LocalImageApi {
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
let request = LocalImageRequest(callback: completion)
let operation = BlockOperation {
let item = DispatchWorkItem {
if request.isCancelled {
return completion(ImageProcessing.cancelledResult)
}
ImageProcessing.semaphore.wait()
defer {
ImageProcessing.semaphore.signal()
}
if request.isCancelled {
return completion(ImageProcessing.cancelledResult)
}
guard let asset = Self.requestAsset(assetId: assetId)
else {
Self.registry.remove(requestId: requestId)
Self.remove(requestId: requestId)
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
return
}
@@ -100,11 +107,12 @@ class LocalImageApiImpl: LocalImageApi {
)
if request.isCancelled {
Self.remove(requestId: requestId)
return completion(ImageProcessing.cancelledResult)
}
guard let data = imageData else {
Self.registry.remove(requestId: requestId)
Self.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not get image data for \(assetId)", details: nil)))
}
@@ -114,6 +122,7 @@ class LocalImageApiImpl: LocalImageApi {
if request.isCancelled {
free(pointer)
Self.remove(requestId: requestId)
return completion(ImageProcessing.cancelledResult)
}
@@ -121,7 +130,7 @@ class LocalImageApiImpl: LocalImageApi {
"pointer": Int64(Int(bitPattern: pointer)),
"length": Int64(length),
]))
Self.registry.remove(requestId: requestId)
Self.remove(requestId: requestId)
return
}
@@ -142,7 +151,7 @@ class LocalImageApiImpl: LocalImageApi {
guard let image = image,
let cgImage = image.cgImage else {
Self.registry.remove(requestId: requestId)
Self.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
}
@@ -162,32 +171,51 @@ class LocalImageApiImpl: LocalImageApi {
"pointer": Int64(Int(bitPattern: buffer.data)),
"width": Int64(buffer.width),
"height": Int64(buffer.height),
"rowBytes": Int64(buffer.rowBytes),
"rowBytes": Int64(buffer.rowBytes)
]))
Self.registry.remove(requestId: requestId)
Self.remove(requestId: requestId)
} catch {
Self.registry.remove(requestId: requestId)
Self.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil)))
}
}
request.operation = operation
Self.registry.add(requestId: requestId, request: request)
ImageProcessing.queue.addOperation(operation)
request.workItem = item
Self.add(requestId: requestId, request: request)
ImageProcessing.queue.async(execute: item)
}
func cancelRequest(requestId: Int64) {
Self.registry.remove(requestId: requestId)?.cancel()
Self.cancel(requestId: requestId)
}
private static func add(requestId: Int64, request: LocalImageRequest) -> Void {
requestQueue.sync { requests[requestId] = request }
}
private static func remove(requestId: Int64) -> Void {
requestQueue.sync { requests[requestId] = nil }
}
private static func cancel(requestId: Int64) -> Void {
requestQueue.async {
guard let request = requests.removeValue(forKey: requestId) else { return }
request.isCancelled = true
guard let item = request.workItem else { return }
if item.isCancelled {
cancelQueue.async { request.callback(ImageProcessing.cancelledResult) }
}
}
}
private static func requestAsset(assetId: String) -> PHAsset? {
if let cached = assetCache.object(forKey: assetId as NSString) {
return cached
}
var asset: PHAsset?
assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) }
if asset != nil { return asset }
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject
else { return nil }
assetCache.setObject(asset, forKey: assetId as NSString)
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }
return asset
}
}
+21 -11
View File
@@ -14,15 +14,11 @@ class RemoteImageRequest {
self.task = task
self.completion = completion
}
func cancel() {
isCancelled = true
task?.cancel()
}
}
class RemoteImageApiImpl: NSObject, RemoteImageApi {
private static let registry = RequestRegistry<RemoteImageRequest>()
private static var lock = os_unfair_lock()
private static var requests = [Int64: RemoteImageRequest]()
private static var rgbaFormat = vImage_CGImageFormat(
bitsPerComponent: 8,
bitsPerPixel: 32,
@@ -47,15 +43,20 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
let request = RemoteImageRequest(id: requestId, task: task, completion: completion)
Self.registry.add(requestId: requestId, request: request)
os_unfair_lock_lock(&Self.lock)
Self.requests[requestId] = request
os_unfair_lock_unlock(&Self.lock)
task.resume()
}
private static func handleCompletion(requestId: Int64, encoded: Bool, data: Data?, response: URLResponse?, error: Error?) {
guard let request = registry.remove(requestId: requestId) else {
return
os_unfair_lock_lock(&Self.lock)
guard let request = requests[requestId] else {
return os_unfair_lock_unlock(&Self.lock)
}
requests[requestId] = nil
os_unfair_lock_unlock(&Self.lock)
if let error = error {
if request.isCancelled || (error as NSError).code == NSURLErrorCancelled {
@@ -72,7 +73,10 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil)))
}
ImageProcessing.queue.addOperation {
ImageProcessing.queue.async {
ImageProcessing.semaphore.wait()
defer { ImageProcessing.semaphore.signal() }
if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult)
}
@@ -126,7 +130,13 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
}
func cancelRequest(requestId: Int64) {
Self.registry.remove(requestId: requestId)?.cancel()
os_unfair_lock_lock(&Self.lock)
let request = Self.requests[requestId]
os_unfair_lock_unlock(&Self.lock)
guard let request = request else { return }
request.isCancelled = true
request.task?.cancel()
}
func clearCache(completion: @escaping (Result<Int64, any Error>) -> Void) {
-54
View File
@@ -1,54 +0,0 @@
import Darwin
// Can be replaced with std Mutex when the deployment target is iOS 18+
struct Mutex<Value: ~Copyable>: ~Copyable, @unchecked Sendable {
struct _Buffer: ~Copyable {
var lock: os_unfair_lock = .init()
var value: Value
init(value: consuming Value) {
self.value = value
}
deinit {}
}
let _buffer: UnsafeMutablePointer<_Buffer>
init(_ initialValue: consuming sending Value) {
_buffer = .allocate(capacity: 1)
_buffer.initialize(to: _Buffer(value: initialValue))
}
deinit {
_buffer.deinitialize(count: 1)
_buffer.deallocate()
}
@discardableResult
borrowing func withLock<Result: ~Copyable, E: Error>(
_ body: (inout sending Value) throws(E) -> sending Result
) throws(E) -> sending Result {
os_unfair_lock_lock(&_buffer.pointee.lock)
defer { os_unfair_lock_unlock(&_buffer.pointee.lock) }
return try body(&_buffer.pointee.value)
}
}
// Can be replaced with OSAllocatedUnfairLock when the deployment target is iOS 16+
typealias UnfairLock = Mutex<Void>
extension Mutex where Value == Void {
init() {
self.init(())
}
@discardableResult
borrowing func withLock<Result: ~Copyable, E: Error>(
_ body: () throws(E) -> sending Result
) throws(E) -> sending Result {
os_unfair_lock_lock(&_buffer.pointee.lock)
defer { os_unfair_lock_unlock(&_buffer.pointee.lock) }
return try body()
}
}
@@ -16,6 +16,8 @@ class SearchApiRepository extends ApiRepository {
type = AssetTypeEnum.VIDEO;
}
final dateRange = filter.date.asDateTimeRange();
if ((filter.context != null && filter.context!.isNotEmpty) ||
(filter.assetId != null && filter.assetId!.isNotEmpty)) {
return _api.searchSmart(
@@ -28,14 +30,14 @@ class SearchApiRepository extends ApiRepository {
city: filter.location.city,
make: filter.camera.make,
model: filter.camera.model,
takenAfter: filter.date.takenAfter,
takenBefore: filter.date.takenBefore,
takenAfter: dateRange?.start,
takenBefore: dateRange?.end,
visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline,
rating: filter.rating.rating,
isFavorite: filter.display.isFavorite ? true : null,
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
personIds: filter.people.map((e) => e.id).toList(),
tagIds: filter.tagIds,
tagIds: filter.tags.map((t) => t.id).toList(),
type: type,
page: page,
size: 100,
@@ -53,14 +55,14 @@ class SearchApiRepository extends ApiRepository {
city: filter.location.city,
make: filter.camera.make,
model: filter.camera.model,
takenAfter: filter.date.takenAfter,
takenBefore: filter.date.takenBefore,
takenAfter: dateRange?.start,
takenBefore: dateRange?.end,
visibility: filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline,
rating: filter.rating.rating,
isFavorite: filter.display.isFavorite ? true : null,
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
personIds: filter.people.map((e) => e.id).toList(),
tagIds: filter.tagIds,
tagIds: filter.tags.map((t) => t.id).toList(),
type: type,
page: page,
size: 1000,
@@ -0,0 +1,91 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
sealed class DateFilterInputModel {
const DateFilterInputModel();
bool get isEmpty => asDateTimeRange() == null;
DateTimeRange<DateTime>? asDateTimeRange();
String asHumanReadable(BuildContext context) {
final date = asDateTimeRange();
if (date == null) return '';
if (date.end.difference(date.start).inHours < 24) {
return DateFormat.yMMMd().format(date.start.toLocal());
} else {
return 'search_filter_date_interval'.t(
context: context,
args: {
"start": DateFormat.yMMMd().format(date.start.toLocal()),
"end": DateFormat.yMMMd().format(date.end.toLocal()),
},
);
}
}
}
class RecentMonthRangeFilter extends DateFilterInputModel {
final int monthDelta;
const RecentMonthRangeFilter(this.monthDelta);
@override
DateTimeRange<DateTime> asDateTimeRange() {
final now = DateTime.now();
// Note that DateTime's constructor properly handles month overflow.
final from = DateTime(now.year, now.month - monthDelta, 1);
return DateTimeRange<DateTime>(start: from, end: now);
}
@override
String asHumanReadable(BuildContext context) {
return 'last_months'.t(context: context, args: {"count": monthDelta.toString()});
}
}
class YearFilter extends DateFilterInputModel {
final int year;
const YearFilter(this.year);
@override
DateTimeRange<DateTime> asDateTimeRange() {
final now = DateTime.now();
final from = DateTime(year, 1, 1);
if (now.year == year) {
// To not go beyond today if the user picks the current year
return DateTimeRange<DateTime>(start: from, end: now);
}
final to = DateTime(year, 12, 31, 23, 59, 59);
return DateTimeRange<DateTime>(start: from, end: to);
}
@override
String asHumanReadable(BuildContext context) {
return 'in_year'.tr(namedArgs: {"year": year.toString()});
}
}
class EmptyDateFilter extends DateFilterInputModel {
const EmptyDateFilter();
@override
DateTimeRange<DateTime>? asDateTimeRange() => null;
}
class CustomDateFilter extends DateFilterInputModel {
final DateTime start;
final DateTime end;
const CustomDateFilter._(this.start, this.end);
factory CustomDateFilter.fromRange(DateTimeRange<DateTime> range) {
return CustomDateFilter._(range.start, range.end.add(const Duration(hours: 23, minutes: 59, seconds: 59)));
}
@override
DateTimeRange<DateTime> asDateTimeRange() {
return DateTimeRange<DateTime>(start: start, end: end);
}
}
@@ -2,7 +2,9 @@
import 'dart:convert';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/domain/models/tag.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/models/search/date_filter.model.dart';
class SearchLocationFilter {
String? country;
@@ -214,11 +216,11 @@ class SearchFilter {
String? ocr;
String? language;
String? assetId;
List<String>? tagIds;
List<Tag> tags;
Set<PersonDto> people;
SearchLocationFilter location;
SearchCameraFilter camera;
SearchDateFilter date;
DateFilterInputModel date;
SearchRatingFilter rating;
SearchDisplayFilters display;
@@ -232,7 +234,7 @@ class SearchFilter {
this.ocr,
this.language,
this.assetId,
this.tagIds,
this.tags = const [],
required this.people,
required this.location,
required this.camera,
@@ -248,15 +250,14 @@ class SearchFilter {
(description == null || (description!.isEmpty)) &&
(assetId == null || (assetId!.isEmpty)) &&
(ocr == null || (ocr!.isEmpty)) &&
(tagIds ?? []).isEmpty &&
tags.isEmpty &&
people.isEmpty &&
location.country == null &&
location.state == null &&
location.city == null &&
camera.make == null &&
camera.model == null &&
date.takenBefore == null &&
date.takenAfter == null &&
date.isEmpty &&
display.isNotInAlbum == false &&
display.isArchive == false &&
display.isFavorite == false &&
@@ -272,10 +273,10 @@ class SearchFilter {
String? ocr,
String? assetId,
Set<PersonDto>? people,
List<String>? tagIds,
List<Tag>? tags,
SearchLocationFilter? location,
SearchCameraFilter? camera,
SearchDateFilter? date,
DateFilterInputModel? date,
SearchDisplayFilters? display,
SearchRatingFilter? rating,
AssetType? mediaType,
@@ -294,13 +295,13 @@ class SearchFilter {
display: display ?? this.display,
rating: rating ?? this.rating,
mediaType: mediaType ?? this.mediaType,
tagIds: tagIds ?? this.tagIds,
tags: tags ?? this.tags,
);
}
@override
String toString() {
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, tagIds: $tagIds, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)';
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, tags: $tags, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)';
}
@override
@@ -314,7 +315,7 @@ class SearchFilter {
other.ocr == ocr &&
other.assetId == assetId &&
other.people == people &&
other.tagIds == tagIds &&
other.tags == tags &&
other.location == location &&
other.camera == camera &&
other.date == date &&
@@ -332,7 +333,7 @@ class SearchFilter {
ocr.hashCode ^
assetId.hashCode ^
people.hashCode ^
tagIds.hashCode ^
tags.hashCode ^
location.hashCode ^
camera.hashCode ^
date.hashCode ^
@@ -8,6 +8,7 @@ import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/models/search/date_filter.model.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
@@ -111,7 +112,7 @@ class PlaceTile extends StatelessWidget {
people: {},
location: SearchLocationFilter(city: name),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
date: const EmptyDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.other,
+10 -10
View File
@@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/models/search/date_filter.model.dart';
import 'package:immich_mobile/providers/search/paginated_search.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -40,7 +41,7 @@ class SearchPage extends HookConsumerWidget {
people: prefilter?.people ?? {},
location: prefilter?.location ?? SearchLocationFilter(),
camera: prefilter?.camera ?? SearchCameraFilter(),
date: prefilter?.date ?? SearchDateFilter(),
date: prefilter?.date ?? const EmptyDateFilter(),
display: prefilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
mediaType: prefilter?.mediaType ?? AssetType.other,
rating: prefilter?.rating ?? SearchRatingFilter(),
@@ -242,15 +243,17 @@ class SearchPage extends HookConsumerWidget {
final firstDate = DateTime(1900);
final lastDate = DateTime.now();
final stored = filter.value.date.asDateTimeRange();
final dateRange = stored != null
? DateTimeRange(start: DateUtils.dateOnly(stored.start), end: DateUtils.dateOnly(stored.end))
: DateTimeRange(start: lastDate, end: lastDate);
final date = await showDateRangePicker(
context: context,
firstDate: firstDate,
lastDate: lastDate,
currentDate: DateTime.now(),
initialDateRange: DateTimeRange(
start: filter.value.date.takenAfter ?? lastDate,
end: filter.value.date.takenBefore ?? lastDate,
),
initialDateRange: dateRange,
helpText: 'search_filter_date_title'.tr(),
cancelText: 'cancel'.tr(),
confirmText: 'select'.tr(),
@@ -264,7 +267,7 @@ class SearchPage extends HookConsumerWidget {
);
if (date == null) {
filter.value = filter.value.copyWith(date: SearchDateFilter());
filter.value = filter.value.copyWith(date: const EmptyDateFilter());
dateRangeCurrentFilterWidget.value = null;
unawaited(search());
@@ -272,10 +275,7 @@ class SearchPage extends HookConsumerWidget {
}
filter.value = filter.value.copyWith(
date: SearchDateFilter(
takenAfter: date.start,
takenBefore: date.end.add(const Duration(hours: 23, minutes: 59, seconds: 59)),
),
date: CustomDateFilter.fromRange(date),
);
// If date range is less than 24 hours, set the end date to the end of the day
@@ -16,6 +16,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
import 'package:immich_mobile/models/search/date_filter.model.dart';
import 'package:immich_mobile/presentation/widgets/search/quick_date_picker.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@@ -57,26 +58,14 @@ class DriftSearchPage extends HookConsumerWidget {
people: {},
location: SearchLocationFilter(),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
date: const EmptyDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.other,
language: "${context.locale.languageCode}-${context.locale.countryCode}",
tagIds: [],
),
);
final dateInputFilter = useState<DateFilterInputModel?>(null);
final peopleCurrentFilterWidget = useState<Widget?>(null);
final dateRangeCurrentFilterWidget = useState<Widget?>(null);
final cameraCurrentFilterWidget = useState<Widget?>(null);
final locationCurrentFilterWidget = useState<Widget?>(null);
final tagCurrentFilterWidget = useState<Widget?>(null);
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
final ratingCurrentFilterWidget = useState<Widget?>(null);
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
final userPreferences = ref.watch(userMetadataPreferencesProvider);
search(SearchFilter f) {
@@ -107,14 +96,60 @@ class DriftSearchPage extends HookConsumerWidget {
Future.microtask(() {
textSearchController.clear();
search(preFilter);
if (preFilter.location.city != null) {
locationCurrentFilterWidget.value = Text(preFilter.location.city!, style: context.textTheme.labelLarge);
}
});
return null;
}, [preFilter]);
Widget? chipLabel(String text) => text.isEmpty ? null : Text(text, style: context.textTheme.labelLarge);
Widget? peopleChip() {
final label = filter.value.people.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', ');
return chipLabel(label);
}
Widget? locationChip() {
final l = filter.value.location;
final parts = [if (l.country != null) l.country!, if (l.state != null) l.state!, if (l.city != null) l.city!];
return chipLabel(parts.join(', '));
}
Widget? tagChip() {
final label = filter.value.tags.map((t) => t.value).join(', ');
return chipLabel(label);
}
Widget? cameraChip() {
final c = filter.value.camera;
return chipLabel('${c.make ?? ''} ${c.model ?? ''}'.trim());
}
Widget? dateChip() {
final d = filter.value.date;
return d.isEmpty ? null : chipLabel(d.asHumanReadable(context));
}
Widget? mediaTypeChip() {
final mt = filter.value.mediaType;
if (mt == AssetType.other) return null;
return chipLabel(mt == AssetType.image ? 'image'.t(context: context) : 'video'.t(context: context));
}
Widget? ratingChip() {
final r = filter.value.rating.rating;
return r == null ? null : chipLabel('rating_count'.t(args: {'count': r}));
}
Widget? displayChip() {
final d = filter.value.display;
final parts = [
if (d.isNotInAlbum) 'search_filter_display_option_not_in_album'.t(context: context),
if (d.isArchive) 'archive'.t(context: context),
if (d.isFavorite) 'favorite'.t(context: context),
];
return chipLabel(parts.join(', '));
}
showPeoplePicker() {
var people = filter.value.people;
@@ -122,17 +157,6 @@ class DriftSearchPage extends HookConsumerWidget {
people = value;
}
handleClear() {
peopleCurrentFilterWidget.value = null;
search(filter.value.copyWith(people: {}));
}
handleApply() {
final label = people.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', ');
peopleCurrentFilterWidget.value = label.isNotEmpty ? Text(label, style: context.textTheme.labelLarge) : null;
search(filter.value.copyWith(people: people));
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
@@ -141,8 +165,8 @@ class DriftSearchPage extends HookConsumerWidget {
child: FilterBottomSheetScaffold(
title: 'search_filter_people_title'.t(context: context),
expanded: true,
onSearch: handleApply,
onClear: handleClear,
onSearch: () => search(filter.value.copyWith(people: people)),
onClear: () => search(filter.value.copyWith(people: {})),
child: PeoplePicker(onSelect: handleOnSelect, filter: filter.value.people),
),
),
@@ -150,22 +174,10 @@ class DriftSearchPage extends HookConsumerWidget {
}
showTagPicker() {
var tagIds = filter.value.tagIds ?? [];
String tagLabel = '';
var tags = filter.value.tags;
handleOnSelect(Iterable<Tag> tags) {
tagIds = tags.map((t) => t.id).toList();
tagLabel = tags.map((t) => t.value).join(', ');
}
handleClear() {
tagCurrentFilterWidget.value = null;
search(filter.value.copyWith(tagIds: []));
}
handleApply() {
tagCurrentFilterWidget.value = tagLabel.isNotEmpty ? Text(tagLabel, style: context.textTheme.labelLarge) : null;
search(filter.value.copyWith(tagIds: tagIds));
handleOnSelect(Iterable<Tag> selected) {
tags = selected.toList();
}
showFilterBottomSheet(
@@ -176,9 +188,9 @@ class DriftSearchPage extends HookConsumerWidget {
child: FilterBottomSheetScaffold(
title: 'search_filter_tags_title'.t(context: context),
expanded: true,
onSearch: handleApply,
onClear: handleClear,
child: TagPicker(onSelect: handleOnSelect, filter: (filter.value.tagIds ?? []).toSet()),
onSearch: () => search(filter.value.copyWith(tags: tags)),
onClear: () => search(filter.value.copyWith(tags: [])),
child: TagPicker(onSelect: handleOnSelect, filter: filter.value.tags.map((t) => t.id).toSet()),
),
),
);
@@ -191,31 +203,14 @@ class DriftSearchPage extends HookConsumerWidget {
location = SearchLocationFilter(country: value['country'], city: value['city'], state: value['state']);
}
handleClear() {
locationCurrentFilterWidget.value = null;
search(filter.value.copyWith(location: SearchLocationFilter()));
}
handleApply() {
final locationText = [
if (location.country != null) location.country!,
if (location.state != null) location.state!,
if (location.city != null) location.city!,
];
locationCurrentFilterWidget.value = locationText.isNotEmpty
? Text(locationText.join(', '), style: context.textTheme.labelLarge)
: null;
search(filter.value.copyWith(location: location));
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: true,
child: FilterBottomSheetScaffold(
title: 'search_filter_location_title'.t(context: context),
onSearch: handleApply,
onClear: handleClear,
onSearch: () => search(filter.value.copyWith(location: location)),
onClear: () => search(filter.value.copyWith(location: SearchLocationFilter())),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Container(
@@ -237,28 +232,14 @@ class DriftSearchPage extends HookConsumerWidget {
camera = SearchCameraFilter(make: value['make'], model: value['model']);
}
handleClear() {
cameraCurrentFilterWidget.value = null;
search(filter.value.copyWith(camera: SearchCameraFilter()));
}
handleApply() {
final make = camera.make ?? '';
final model = camera.model ?? '';
cameraCurrentFilterWidget.value = (make.isNotEmpty || model.isNotEmpty)
? Text('$make $model', style: context.textTheme.labelLarge)
: null;
search(filter.value.copyWith(camera: camera));
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: true,
child: FilterBottomSheetScaffold(
title: 'search_filter_camera_title'.t(context: context),
onSearch: handleApply,
onClear: handleClear,
onSearch: () => search(filter.value.copyWith(camera: camera)),
onClear: () => search(filter.value.copyWith(camera: SearchCameraFilter())),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: CameraPicker(onSelect: handleOnSelect, filter: filter.value.camera),
@@ -268,42 +249,17 @@ class DriftSearchPage extends HookConsumerWidget {
}
datePicked(DateFilterInputModel? selectedDate) {
dateInputFilter.value = selectedDate;
if (selectedDate == null) {
dateRangeCurrentFilterWidget.value = null;
search(filter.value.copyWith(date: SearchDateFilter()));
return;
}
final date = selectedDate.asDateTimeRange();
dateRangeCurrentFilterWidget.value = Text(
selectedDate.asHumanReadable(context),
style: context.textTheme.labelLarge,
);
search(
filter.value.copyWith(
date: SearchDateFilter(
takenAfter: date.start,
takenBefore: date.end.add(const Duration(hours: 23, minutes: 59, seconds: 59)),
),
),
);
search(filter.value.copyWith(date: selectedDate ?? const EmptyDateFilter()));
}
showDatePicker() async {
final firstDate = DateTime(1900);
final lastDate = DateTime.now();
var dateRange = DateTimeRange(
start: filter.value.date.takenAfter ?? lastDate,
end: filter.value.date.takenBefore ?? lastDate,
);
// datePicked() may increase the date, this will make the date picker fail an assertion
// Fixup the end date to be at most now.
if (dateRange.end.isAfter(lastDate)) {
dateRange = DateTimeRange(start: dateRange.start, end: lastDate);
}
final stored = filter.value.date.asDateTimeRange();
final dateRange = stored != null
? DateTimeRange(start: DateUtils.dateOnly(stored.start), end: DateUtils.dateOnly(stored.end))
: DateTimeRange(start: lastDate, end: lastDate);
final date = await showDateRangePicker(
context: context,
@@ -338,7 +294,7 @@ class DriftSearchPage extends HookConsumerWidget {
expanded: true,
onClear: () => datePicked(null),
child: QuickDatePicker(
currentInput: dateInputFilter.value,
currentInput: filter.value.date,
onRequestPicker: () {
context.pop();
showDatePicker();
@@ -360,27 +316,12 @@ class DriftSearchPage extends HookConsumerWidget {
mediaType = assetType;
}
handleClear() {
mediaTypeCurrentFilterWidget.value = null;
search(filter.value.copyWith(mediaType: AssetType.other));
}
handleApply() {
mediaTypeCurrentFilterWidget.value = mediaType != AssetType.other
? Text(
mediaType == AssetType.image ? 'image'.t(context: context) : 'video'.t(context: context),
style: context.textTheme.labelLarge,
)
: null;
search(filter.value.copyWith(mediaType: mediaType));
}
showFilterBottomSheet(
context: context,
child: FilterBottomSheetScaffold(
title: 'search_filter_media_type_title'.t(context: context),
onSearch: handleApply,
onClear: handleClear,
onSearch: () => search(filter.value.copyWith(mediaType: mediaType)),
onClear: () => search(filter.value.copyWith(mediaType: AssetType.other)),
child: MediaTypePicker(onSelect: handleOnSelected, filter: filter.value.mediaType),
),
);
@@ -394,25 +335,13 @@ class DriftSearchPage extends HookConsumerWidget {
rating = value;
}
handleClear() {
ratingCurrentFilterWidget.value = null;
search(filter.value.copyWith(rating: SearchRatingFilter(rating: null)));
}
handleApply() {
ratingCurrentFilterWidget.value = rating.rating != null
? Text('rating_count'.t(args: {'count': rating.rating!}), style: context.textTheme.labelLarge)
: null;
search(filter.value.copyWith(rating: rating));
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
child: FilterBottomSheetScaffold(
title: 'rating'.t(context: context),
onSearch: handleApply,
onClear: handleClear,
onSearch: () => search(filter.value.copyWith(rating: rating)),
onClear: () => search(filter.value.copyWith(rating: SearchRatingFilter(rating: null))),
child: StarRatingPicker(onSelect: handleOnSelected, filter: filter.value.rating),
),
);
@@ -430,33 +359,16 @@ class DriftSearchPage extends HookConsumerWidget {
);
}
handleClear() {
displayOptionCurrentFilterWidget.value = null;
search(
filter.value.copyWith(
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
),
);
}
handleApply() {
final filterText = [
if (display.isNotInAlbum) 'search_filter_display_option_not_in_album'.t(context: context),
if (display.isArchive) 'archive'.t(context: context),
if (display.isFavorite) 'favorite'.t(context: context),
];
displayOptionCurrentFilterWidget.value = filterText.isNotEmpty
? Text(filterText.join(', '), style: context.textTheme.labelLarge)
: null;
search(filter.value.copyWith(display: display));
}
showFilterBottomSheet(
context: context,
child: FilterBottomSheetScaffold(
title: 'display_options'.t(context: context),
onSearch: handleApply,
onClear: handleClear,
onSearch: () => search(filter.value.copyWith(display: display)),
onClear: () => search(
filter.value.copyWith(
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
),
),
child: DisplayOptionPicker(onSelect: handleOnSelect, filter: filter.value.display),
),
);
@@ -631,52 +543,52 @@ class DriftSearchPage extends HookConsumerWidget {
icon: Icons.people_alt_outlined,
onTap: showPeoplePicker,
label: 'people'.t(context: context),
currentFilter: peopleCurrentFilterWidget.value,
currentFilter: peopleChip(),
),
SearchFilterChip(
icon: Icons.location_on_outlined,
onTap: showLocationPicker,
label: 'search_filter_location'.t(context: context),
currentFilter: locationCurrentFilterWidget.value,
currentFilter: locationChip(),
),
if (userPreferences.valueOrNull?.tagsEnabled ?? false)
SearchFilterChip(
icon: Icons.sell_outlined,
onTap: showTagPicker,
label: 'tags'.t(context: context),
currentFilter: tagCurrentFilterWidget.value,
currentFilter: tagChip(),
),
SearchFilterChip(
icon: Icons.camera_alt_outlined,
onTap: showCameraPicker,
label: 'camera'.t(context: context),
currentFilter: cameraCurrentFilterWidget.value,
currentFilter: cameraChip(),
),
SearchFilterChip(
icon: Icons.date_range_outlined,
onTap: showQuickDatePicker,
label: 'search_filter_date'.t(context: context),
currentFilter: dateRangeCurrentFilterWidget.value,
currentFilter: dateChip(),
),
SearchFilterChip(
key: const Key('media_type_chip'),
icon: Icons.video_collection_outlined,
onTap: showMediaTypePicker,
label: 'search_filter_media_type'.t(context: context),
currentFilter: mediaTypeCurrentFilterWidget.value,
currentFilter: mediaTypeChip(),
),
if (userPreferences.valueOrNull?.ratingsEnabled ?? false)
SearchFilterChip(
icon: Icons.star_outline_rounded,
onTap: showStarRatingPicker,
label: 'search_filter_star_rating'.t(context: context),
currentFilter: ratingCurrentFilterWidget.value,
currentFilter: ratingChip(),
),
SearchFilterChip(
icon: Icons.display_settings_outlined,
onTap: showDisplayOptionPicker,
label: 'search_filter_display_options'.t(context: context),
currentFilter: displayOptionCurrentFilterWidget.value,
currentFilter: displayChip(),
),
],
),
@@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/models/search/date_filter.model.dart';
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
@@ -34,7 +35,7 @@ class SimilarPhotosActionButton extends ConsumerWidget {
people: {},
location: SearchLocationFilter(),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
date: const EmptyDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.image,
@@ -10,19 +10,20 @@ class TrashBottomBar extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return Align(
alignment: Alignment.bottomCenter,
child: Container(
color: context.themeData.canvasColor,
padding: const EdgeInsets.symmetric(vertical: 8),
child: const SafeArea(
top: false,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
DeleteTrashActionButton(source: ActionSource.timeline),
RestoreTrashActionButton(source: ActionSource.timeline),
],
return SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
height: 64,
child: Container(
color: context.themeData.canvasColor,
child: const Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
DeleteTrashActionButton(source: ActionSource.timeline),
RestoreTrashActionButton(source: ActionSource.timeline),
],
),
),
),
),
@@ -2,85 +2,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
sealed class DateFilterInputModel {
DateTimeRange<DateTime> asDateTimeRange();
String asHumanReadable(BuildContext context) {
// General implementation for arbitrary date and time ranges
// If date range is less than 24 hours, set the end date to the end of the day
final date = asDateTimeRange();
if (date.end.difference(date.start).inHours < 24) {
return DateFormat.yMMMd().format(date.start.toLocal());
} else {
return 'search_filter_date_interval'.t(
context: context,
args: {
"start": DateFormat.yMMMd().format(date.start.toLocal()),
"end": DateFormat.yMMMd().format(date.end.toLocal()),
},
);
}
}
}
class RecentMonthRangeFilter extends DateFilterInputModel {
final int monthDelta;
RecentMonthRangeFilter(this.monthDelta);
@override
DateTimeRange<DateTime> asDateTimeRange() {
final now = DateTime.now();
// Note that DateTime's constructor properly handles month overflow.
final from = DateTime(now.year, now.month - monthDelta, 1);
return DateTimeRange<DateTime>(start: from, end: now);
}
@override
String asHumanReadable(BuildContext context) {
return 'last_months'.t(context: context, args: {"count": monthDelta.toString()});
}
}
class YearFilter extends DateFilterInputModel {
final int year;
YearFilter(this.year);
@override
DateTimeRange<DateTime> asDateTimeRange() {
final now = DateTime.now();
final from = DateTime(year, 1, 1);
if (now.year == year) {
// To not go beyond today if the user picks the current year
return DateTimeRange<DateTime>(start: from, end: now);
}
final to = DateTime(year, 12, 31, 23, 59, 59);
return DateTimeRange<DateTime>(start: from, end: to);
}
@override
String asHumanReadable(BuildContext context) {
return 'in_year'.tr(namedArgs: {"year": year.toString()});
}
}
class CustomDateFilter extends DateFilterInputModel {
final DateTime start;
final DateTime end;
CustomDateFilter(this.start, this.end);
factory CustomDateFilter.fromRange(DateTimeRange<DateTime> range) {
return CustomDateFilter(range.start, range.end);
}
@override
DateTimeRange<DateTime> asDateTimeRange() {
return DateTimeRange<DateTime>(start: start, end: end);
}
}
import 'package:immich_mobile/models/search/date_filter.model.dart';
enum _QuickPickerType { last1Month, last3Months, last9Months, year, custom }
@@ -102,7 +24,7 @@ class QuickDatePicker extends HookWidget {
});
static int _initialYearFromModel(DateFilterInputModel? model) {
return model?.asDateTimeRange().start.year ?? DateTime.now().year;
return model?.asDateTimeRange()?.start.year ?? DateTime.now().year;
}
static _QuickPickerType? _selectionFromModel(DateFilterInputModel? model) {
@@ -149,7 +71,7 @@ class QuickDatePicker extends HookWidget {
// Even if it's already toggled it should always open the full date picker, RadioListTiles don't do that by default
// so we wrap it in a InkWell
Widget _exactPicker(BuildContext context) {
final hasPreviousInput = currentInput != null && currentInput is CustomDateFilter;
final hasPreviousInput = currentInput is CustomDateFilter;
return InkWell(
onTap: onRequestPicker,
@@ -182,9 +104,9 @@ class QuickDatePicker extends HookWidget {
if (value == null) return;
final _ = switch (value) {
_QuickPickerType.custom => onRequestPicker(),
_QuickPickerType.last1Month => onSelect(RecentMonthRangeFilter(1)),
_QuickPickerType.last3Months => onSelect(RecentMonthRangeFilter(3)),
_QuickPickerType.last9Months => onSelect(RecentMonthRangeFilter(9)),
_QuickPickerType.last1Month => onSelect(const RecentMonthRangeFilter(1)),
_QuickPickerType.last3Months => onSelect(const RecentMonthRangeFilter(3)),
_QuickPickerType.last9Months => onSelect(const RecentMonthRangeFilter(9)),
// When a year is selected the combobox triggers onSelect() on its own.
// Here we handle the radio button being selected which can only ever be the initial year
_QuickPickerType.year => onSelect(YearFilter(_initialYear)),
@@ -1,6 +1,5 @@
import 'dart:math';
import 'package:async/async.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/colors.dart';
@@ -8,63 +7,26 @@ import 'package:immich_mobile/models/cast/cast_manager_state.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/widgets/asset_viewer/animated_play_pause.dart';
class VideoControls extends ConsumerStatefulWidget {
class VideoControls extends HookConsumerWidget {
final String videoPlayerName;
static const List<Shadow> _controlShadows = [Shadow(color: Colors.black87, blurRadius: 6, offset: Offset(0, 1))];
const VideoControls({super.key, required this.videoPlayerName});
@override
ConsumerState<VideoControls> createState() => _VideoControlsState();
}
class _VideoControlsState extends ConsumerState<VideoControls> {
late final RestartableTimer _hideTimer;
AutoDisposeStateNotifierProvider<VideoPlayerNotifier, VideoPlayerState> get _provider =>
videoPlayerProvider(widget.videoPlayerName);
@override
void initState() {
super.initState();
_hideTimer = RestartableTimer(const Duration(seconds: 5), _onHideTimer);
}
@override
void didUpdateWidget(covariant VideoControls oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.videoPlayerName != widget.videoPlayerName) {
_hideTimer.reset();
}
}
@override
void dispose() {
_hideTimer.cancel();
super.dispose();
}
void _onHideTimer() {
if (!mounted) return;
if (ref.read(_provider).status == VideoPlaybackStatus.playing) {
ref.read(assetViewerProvider.notifier).setControls(false);
}
}
void _toggle(bool isCasting) {
void _toggle(WidgetRef ref, bool isCasting) {
if (isCasting) {
ref.read(castProvider.notifier).toggle();
return;
} else {
ref.read(videoPlayerProvider(videoPlayerName).notifier).toggle();
}
ref.read(_provider.notifier).toggle();
}
void _onSeek(bool isCasting, double value) {
void _onSeek(WidgetRef ref, bool isCasting, double value) {
final seekTo = Duration(microseconds: value.toInt());
if (isCasting) {
@@ -72,30 +34,38 @@ class _VideoControlsState extends ConsumerState<VideoControls> {
return;
}
ref.read(_provider.notifier).seekTo(seekTo);
ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekTo);
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final provider = videoPlayerProvider(videoPlayerName);
final cast = ref.watch(castProvider);
final isCasting = cast.isCasting;
final (position, duration) = isCasting
? ref.watch(castProvider.select((c) => (c.currentTime, c.duration)))
: ref.watch(_provider.select((v) => (v.position, v.duration)));
: ref.watch(provider.select((v) => (v.position, v.duration)));
final videoStatus = ref.watch(_provider.select((v) => v.status));
final videoStatus = ref.watch(provider.select((v) => v.status));
final isPlaying = isCasting
? cast.castState == CastState.playing
: videoStatus == VideoPlaybackStatus.playing || videoStatus == VideoPlaybackStatus.buffering;
final isFinished = !isCasting && videoStatus == VideoPlaybackStatus.completed;
ref.listen(assetViewerProvider.select((v) => v.showingControls), (prev, showing) {
if (showing && prev != showing) _hideTimer.reset();
final hideTimer = useTimer(const Duration(seconds: 5), () {
if (!context.mounted) return;
if (ref.read(provider).status == VideoPlaybackStatus.playing) {
ref.read(assetViewerProvider.notifier).setControls(false);
}
});
ref.listen(_provider.select((v) => v.status), (_, __) => _hideTimer.reset());
final notifier = ref.read(_provider.notifier);
ref.listen(assetViewerProvider.select((v) => v.showingControls), (prev, showing) {
if (showing && prev != showing) hideTimer.reset();
});
ref.listen(provider.select((v) => v.status), (_, __) => hideTimer.reset());
final notifier = ref.read(provider.notifier);
final isLoaded = duration != Duration.zero;
return Padding(
@@ -110,13 +80,9 @@ class _VideoControlsState extends ConsumerState<VideoControls> {
padding: const EdgeInsets.all(12),
constraints: const BoxConstraints(),
icon: isFinished
? const Icon(Icons.replay, color: Colors.white, shadows: VideoControls._controlShadows)
: AnimatedPlayPause(
color: Colors.white,
playing: isPlaying,
shadows: VideoControls._controlShadows,
),
onPressed: () => _toggle(isCasting),
? const Icon(Icons.replay, color: Colors.white, shadows: _controlShadows)
: AnimatedPlayPause(color: Colors.white, playing: isPlaying, shadows: _controlShadows),
onPressed: () => _toggle(ref, isCasting),
),
const Spacer(),
Text(
@@ -125,7 +91,7 @@ class _VideoControlsState extends ConsumerState<VideoControls> {
color: Colors.white,
fontWeight: FontWeight.w500,
fontFeatures: [FontFeature.tabularFigures()],
shadows: VideoControls._controlShadows,
shadows: _controlShadows,
),
),
const SizedBox(width: 12),
@@ -141,7 +107,7 @@ class _VideoControlsState extends ConsumerState<VideoControls> {
padding: EdgeInsets.zero,
onChangeStart: (_) => notifier.hold(),
onChangeEnd: (_) => notifier.release(),
onChanged: isLoaded ? (value) => _onSeek(isCasting, value) : null,
onChanged: isLoaded ? (value) => _onSeek(ref, isCasting, value) : null,
),
],
),
+2 -1
View File
@@ -5,6 +5,7 @@ import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/search/search_curated_content.model.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/models/search/date_filter.model.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/search/thumbnail_with_info.dart';
@@ -53,7 +54,7 @@ class ExploreGrid extends StatelessWidget {
people: {},
location: SearchLocationFilter(city: content.label),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
date: const EmptyDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.other,
+3 -11
View File
@@ -15,36 +15,30 @@ class DatabaseBackupDto {
DatabaseBackupDto({
required this.filename,
required this.filesize,
required this.timezone,
});
String filename;
num filesize;
String timezone;
@override
bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupDto &&
other.filename == filename &&
other.filesize == filesize &&
other.timezone == timezone;
other.filesize == filesize;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(filename.hashCode) +
(filesize.hashCode) +
(timezone.hashCode);
(filesize.hashCode);
@override
String toString() => 'DatabaseBackupDto[filename=$filename, filesize=$filesize, timezone=$timezone]';
String toString() => 'DatabaseBackupDto[filename=$filename, filesize=$filesize]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'filename'] = this.filename;
json[r'filesize'] = this.filesize;
json[r'timezone'] = this.timezone;
return json;
}
@@ -59,7 +53,6 @@ class DatabaseBackupDto {
return DatabaseBackupDto(
filename: mapValueOfType<String>(json, r'filename')!,
filesize: num.parse('${json[r'filesize']}'),
timezone: mapValueOfType<String>(json, r'timezone')!,
);
}
return null;
@@ -109,7 +102,6 @@ class DatabaseBackupDto {
static const requiredKeys = <String>{
'filename',
'filesize',
'timezone',
};
}
+1 -5
View File
@@ -17721,15 +17721,11 @@
},
"filesize": {
"type": "number"
},
"timezone": {
"type": "string"
}
},
"required": [
"filename",
"filesize",
"timezone"
"filesize"
],
"type": "object"
},
@@ -63,7 +63,6 @@ export type DatabaseBackupDeleteDto = {
export type DatabaseBackupDto = {
filename: string;
filesize: number;
timezone: string;
};
export type DatabaseBackupListResponseDto = {
backups: DatabaseBackupDto[];
-1
View File
@@ -4,7 +4,6 @@ import { IsString } from 'class-validator';
export class DatabaseBackupDto {
filename!: string;
filesize!: number;
timezone!: string;
}
export class DatabaseBackupListResponseDto {
@@ -75,10 +75,6 @@ export interface EnvData {
server: string;
};
versionCheck: {
url: string;
};
network: {
trustedProxies: string[];
};
@@ -324,10 +320,6 @@ const getEnv = (): EnvData => {
licensePublicKey: isProd ? productionKeys : stagingKeys,
versionCheck: {
url: isProd ? 'https://version.immich.cloud/version' : 'https://version.dev.immich.cloud/version',
},
network: {
trustedProxies: dto.IMMICH_TRUSTED_PROXIES ?? ['linklocal', 'uniquelocal'],
},
@@ -66,8 +66,7 @@ export class ServerInfoRepository {
async getLatestRelease(): Promise<VersionResponse> {
try {
const { versionCheck } = this.configRepository.getEnv();
const response = await fetch(versionCheck.url);
const response = await fetch('https://version.immich.cloud/version');
if (!response.ok) {
throw new Error(`Version check request failed with status ${response.status}: ${await response.text()}`);
@@ -111,7 +111,6 @@ const validVideos = [
'.mpg',
'.mts',
'.mxf',
'.ts',
'.vob',
'.webm',
'.wmv',
@@ -283,7 +283,6 @@ export class DatabaseBackupService {
async listBackups(): Promise<DatabaseBackupListResponseDto> {
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
const files = await this.storageRepository.readdir(backupsFolder);
const timezone = DateTime.local().zoneName;
const validFiles = files
.filter((fn) => isValidDatabaseBackupName(fn))
@@ -293,7 +292,7 @@ export class DatabaseBackupService {
const backups = await Promise.all(
validFiles.map(async (filename) => {
const stats = await this.storageRepository.stat(path.join(backupsFolder, filename));
return { filename, filesize: stats.size, timezone };
return { filename, filesize: stats.size };
}),
);
+11 -1
View File
@@ -2,8 +2,9 @@ import { DateTime } from 'luxon';
import { SemVer } from 'semver';
import { defaults } from 'src/config';
import { serverVersion } from 'src/constants';
import { JobName, JobStatus, SystemMetadataKey } from 'src/enum';
import { ImmichEnvironment, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
import { VersionService } from 'src/services/version.service';
import { mockEnvData } from 'test/repositories/config.repository.mock';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@@ -72,6 +73,15 @@ describe(VersionService.name, () => {
});
describe('handVersionCheck', () => {
beforeEach(() => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.Production }));
});
it('should not run in dev mode', async () => {
mocks.config.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.Development }));
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Skipped);
});
it('should not run if the last check was < 60 minutes ago', async () => {
mocks.systemMetadata.get.mockResolvedValue({
checkedAt: DateTime.utc().minus({ minutes: 5 }).toISO(),
+6 -1
View File
@@ -4,7 +4,7 @@ import semver, { SemVer } from 'semver';
import { serverVersion } from 'src/constants';
import { OnEvent, OnJob } from 'src/decorators';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { DatabaseLock, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum';
import { DatabaseLock, ImmichEnvironment, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';
import { VersionCheckMetadata } from 'src/types';
@@ -71,6 +71,11 @@ export class VersionService extends BaseService {
try {
this.logger.debug('Running version check');
const { environment } = this.configRepository.getEnv();
if (environment === ImmichEnvironment.Development) {
return JobStatus.Skipped;
}
const { newVersionCheck } = await this.getConfig({ withCache: true });
if (!newVersionCheck.enabled) {
return JobStatus.Skipped;
-1
View File
@@ -83,7 +83,6 @@ describe('mimeTypes', () => {
{ mimetype: 'video/mp2t', extension: '.m2t' },
{ mimetype: 'video/mp2t', extension: '.m2ts' },
{ mimetype: 'video/mp2t', extension: '.mts' },
{ mimetype: 'video/mp2t', extension: '.ts' },
{ mimetype: 'video/mp4', extension: '.mp4' },
{ mimetype: 'video/mpeg', extension: '.mpe' },
{ mimetype: 'video/mpeg', extension: '.mpeg' },
-1
View File
@@ -114,7 +114,6 @@ const video: Record<string, string[]> = {
'.mpg': ['video/mpeg'],
'.mts': ['video/mp2t'],
'.mxf': ['application/mxf'],
'.ts': ['video/mp2t'],
'.vob': ['video/mpeg'],
'.webm': ['video/webm'],
'.wmv': ['video/x-ms-wmv'],
@@ -44,10 +44,6 @@ const envData: EnvData = {
server: 'server-public-key',
},
versionCheck: {
url: 'https://version.immich.cloud/version',
},
network: {
trustedProxies: [],
},
@@ -276,6 +276,7 @@
const handleStopSlideshow = async () => {
try {
if (document.fullscreenElement) {
document.body.style.cursor = '';
await document.exitFullscreen();
}
} catch (error) {
@@ -337,7 +338,7 @@
onAction?.(action);
};
let isFullScreen = $derived(!!fullscreenElement);
let isFullScreen = $derived(fullscreenElement !== null);
$effect(() => {
if (album && !album.isActivityEnabled && activityManager.commentCount === 0) {
@@ -85,7 +85,6 @@
});
onDestroy(() => {
setCursorStyle('');
if (unsubscribeRestart) {
unsubscribeRestart();
}
@@ -1,54 +0,0 @@
import { locale } from '$lib/stores/preferences.store';
import { renderWithTooltips } from '$tests/helpers';
import { screen } from '@testing-library/svelte';
import { DateTime } from 'luxon';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import MaintenanceBackupEntry from './MaintenanceBackupEntry.svelte';
vi.mock('$lib/services/database-backups.service', () => ({
getDatabaseBackupActions: () => ({
Download: { type: 'command', title: 'Download', onAction: vi.fn() },
Delete: { type: 'command', title: 'Delete', onAction: vi.fn() },
}),
handleRestoreDatabaseBackup: vi.fn(),
}));
describe('MaintenanceBackupEntry', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-24T12:00:00Z'));
locale.set('en');
});
afterEach(() => {
vi.useRealTimers();
});
it('renders relative backup time using the user timezone instead of UTC', () => {
const backupTimestamp = '20260324T110000';
const expectedRelativeTime = DateTime.fromFormat(backupTimestamp, "yyyyMMdd'T'HHmmss", {
zone: 'Asia/Tokyo',
})
.toLocal()
.toRelative({ locale: 'en' });
const utcRelativeTime = DateTime.fromFormat(backupTimestamp, "yyyyMMdd'T'HHmmss", {
zone: 'UTC',
})
.toLocal()
.toRelative({ locale: 'en' });
expect(expectedRelativeTime).toBeTruthy();
expect(expectedRelativeTime).not.toEqual(utcRelativeTime);
renderWithTooltips(MaintenanceBackupEntry, {
expectedVersion: '1.2.3',
filename: 'immich-db-backup-20260324T110000-v1.2.3-snapshot.sql.gz',
filesize: 1024,
timezone: 'Asia/Tokyo',
});
expect(screen.getByText(expectedRelativeTime!)).toBeInTheDocument();
});
});
@@ -13,17 +13,16 @@
filename: string;
filesize: number;
expectedVersion: string;
timezone?: string;
};
const { filename, filesize, expectedVersion, timezone }: Props = $props();
const { filename, filesize, expectedVersion }: Props = $props();
const filesizeText = $derived(getBytesWithUnit(filesize, 1));
const backupDateTime = $derived.by(() => {
const dateMatch = filename.match(/\d+T\d+/);
if (dateMatch) {
return DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: timezone }).toLocal();
return DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: 'utc' }).toLocal();
}
return null;
});
@@ -51,13 +51,12 @@
const unknownDateKey = $t('unknown_date');
for (const backup of backups) {
const timezone = backup.timezone;
const dateMatch = backup.filename.match(/\d+T\d+/);
let dateKey: string;
let dt: DateTime;
if (dateMatch) {
dt = DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: timezone });
dt = DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: 'utc' });
dateKey = dt.toFormat('LLLL d, yyyy');
} else {
dt = DateTime.fromMillis(0);
@@ -129,7 +128,6 @@
filename={backup.filename}
filesize={backup.filesize}
expectedVersion={props.expectedVersion}
timezone={backup.timezone}
/>
{/each}
</Stack>
@@ -46,7 +46,6 @@ export function layoutTimelineMonth(timelineManager: TimelineManager, month: Tim
} else {
// Move to next row
cumulativeHeight += currentRowHeight;
currentRowHeight = 0;
cumulativeWidth = 0;
timelineDayRow++;
timelineDayCol = 0;
@@ -60,7 +59,7 @@ export function layoutTimelineMonth(timelineManager: TimelineManager, month: Tim
timelineDayCol++;
cumulativeWidth += timelineDay.width + timelineManager.gap;
}
currentRowHeight = Math.max(currentRowHeight, timelineDay.height + timelineManager.headerHeight);
currentRowHeight = timelineDay.height + timelineManager.headerHeight;
}
// Add the height of the final row
@@ -1,6 +1,5 @@
import { sdkMock } from '$lib/__mocks__/sdk.mock';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { layoutTimelineMonth } from '$lib/managers/timeline-manager/internal/layout-support.svelte';
import { getTimelineMonthByDate } from '$lib/managers/timeline-manager/internal/search-support.svelte';
import { AbortError } from '$lib/utils';
import { fromISODateTimeUTCToObject } from '$lib/utils/timeline-util';
@@ -772,100 +771,6 @@ describe('TimelineManager', () => {
});
});
describe('layoutTimelineMonth', () => {
let timelineManager: TimelineManager;
beforeEach(async () => {
timelineManager = new TimelineManager();
sdkMock.getTimeBuckets.mockResolvedValue([]);
await timelineManager.updateViewport({ width: 1588, height: 1000 });
});
it('uses tallest day height when multiple days share a row', () => {
const day20Asset = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
}),
);
const day10Asset = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-10T12:00:00.000Z'),
}),
);
timelineManager.upsertAssets([day20Asset, day10Asset]);
const month = timelineManager.months[0];
expect(month.timelineDays).toHaveLength(2);
const [tallDay, shortDay] = month.timelineDays;
vi.spyOn(tallDay, 'layout').mockImplementation(() => {
tallDay.width = 400;
tallDay.height = 300;
});
vi.spyOn(shortDay, 'layout').mockImplementation(() => {
shortDay.width = 200;
shortDay.height = 150;
});
layoutTimelineMonth(timelineManager, month);
// Both days fit in one row: 400 + 12 (gap) + 200 = 612 < 1588
expect(tallDay.row).toBe(0);
expect(shortDay.row).toBe(0);
// Month height should use the tallest day: 300 + 48 (headerHeight) = 348
expect(month.height).toBe(300 + timelineManager.headerHeight);
});
it('resets row height tracking when starting a new row', () => {
const day30Asset = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-30T12:00:00.000Z'),
}),
);
const day20Asset = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-20T12:00:00.000Z'),
}),
);
const day10Asset = deriveLocalDateTimeFromFileCreatedAt(
timelineAssetFactory.build({
fileCreatedAt: fromISODateTimeUTCToObject('2024-01-10T12:00:00.000Z'),
}),
);
timelineManager.upsertAssets([day30Asset, day20Asset, day10Asset]);
const month = timelineManager.months[0];
expect(month.timelineDays).toHaveLength(3);
const [day1, day2, day3] = month.timelineDays;
// Row 0: day1 (wide, tall) fills the row
vi.spyOn(day1, 'layout').mockImplementation(() => {
day1.width = 1500;
day1.height = 400;
});
// Row 1: day2 and day3 share a row
vi.spyOn(day2, 'layout').mockImplementation(() => {
day2.width = 300;
day2.height = 200;
});
vi.spyOn(day3, 'layout').mockImplementation(() => {
day3.width = 300;
day3.height = 100;
});
layoutTimelineMonth(timelineManager, month);
expect(day1.row).toBe(0);
expect(day2.row).toBe(1);
expect(day3.row).toBe(1);
const headerHeight = timelineManager.headerHeight;
// Row 0: 400 + 48 = 448. Row 1: max(200, 100) + 48 = 248. Total = 696
expect(month.height).toBe(400 + headerHeight + (200 + headerHeight));
});
});
describe('showAssetOwners', () => {
const LS_KEY = 'album-show-asset-owners';