Compare commits

..

44 Commits

Author SHA1 Message Date
bwees
bfa2aa4c58 chore: fix failing ci 2026-01-25 20:59:31 -06:00
bwees
8835e54bf4 feat: web editor 2026-01-25 20:44:32 -06:00
bwees
ae9bb0aa80 feat: mobile filter sending/recv 2026-01-25 15:27:05 -06:00
bwees
2e4cfa80a9 feat: server support for filters 2026-01-25 15:26:55 -06:00
bwees
8653e20cc5 fix: selected filter state 2026-01-25 14:25:01 -06:00
bwees
871de53bca feat: mobile filtering 2026-01-25 14:20:38 -06:00
bwees
f5db6dfa09 chore: cleanup 2026-01-25 12:07:39 -06:00
bwees
7f857ab9a0 fix: affine matrix deconstruction 2026-01-24 17:55:30 -06:00
bwees
37297ce420 feat: mirror support 2026-01-24 17:55:29 -06:00
bwees
bdc45da106 chore: cleanup edit import 2026-01-24 17:55:29 -06:00
bwees
8770cf0961 chore: cleanup old filter page 2026-01-24 17:55:29 -06:00
bwees
128e798516 fix: change condition for editable image 2026-01-24 17:55:29 -06:00
bwees
3a87746ce6 chore: editor improvements 2026-01-24 17:55:29 -06:00
bwees
279e706c77 feat: working editor 2026-01-24 17:55:12 -06:00
bwees
7acc0d5c82 fix: update assetEditReady event on web 2026-01-24 17:52:13 -06:00
bwees
48f4282056 fix: websocket backwards compatibility 2026-01-24 17:52:13 -06:00
bwees
74297ec429 feat: send edit information on AssetEditReadyV1 2026-01-24 17:52:13 -06:00
bwees
34b6e2ca99 feat: mobile edits sync 2026-01-24 17:52:13 -06:00
bwees
5bb6bcf70b feat: fix discriminated type parsing 2026-01-24 17:52:11 -06:00
Mert
5414302350 fix(mobile): do not try to load video as image (#25495)
* only load original for images

* early return
2026-01-24 18:38:31 -05:00
Mert
1803692eab feat(mobile): native clients (#21459)
* platform clients

* uppercase http method

* fix hot reload

* custom user agent

* init before app launch

* set defaults

* move to bootstrap

* unrelated change

* disable disk cache by default

* optimized decoding

* remove incremental

* android impl

* memory optimization

* lock approach is slower on ios

* conditional cronet

* clarify parameter

* enable disk cache

* set user agent

* flutter-side decode

* optimized http

* fixed locking

* refactor

* potential race conditions

* embedded cronet

* refactor, fix capacity handling

* fast path for known content length

* ios optimizations

* re-enable cache

* formatting

* bump concurrency

* clear cache button

* fix eviction race condition

* add extra cancellation check

* tighten dispose

* better error handling

* fix disposal

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2026-01-24 19:34:29 +00:00
Brandon Wees
9219d559a0 fix(mobile): share edited asset (#25493)
* fix(mobile): share edited asset

* chore: code review changes
2026-01-24 19:09:47 +00:00
Alex
d6c5a382f8 chore: show loading state when fetching users (#25277)
* chore: show loading state when fetching users

* pr feedback
2026-01-24 17:05:58 +00:00
Alex
deb3a620e1 feat: keep settings for free up space (#25460)
* feat: album exclusion filter in free up space

* feat: make keep options into persistent settings

* chore: refactor

* chore: refactor

* add free up space to app bar dialog

* fix: date selection rerender

* more copywriting

* Update i18n/en.json

Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>

* add file size information

* styling

* clear up stale album id

* keep messaging album on first use

* feedback

* feedback

---------

Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>
2026-01-24 10:40:34 -06:00
Min Idzelis
7e5592fec5 feat: make progressive system config optional (#25486) 2026-01-24 00:18:02 -05:00
Brandon Wees
ccc0961ba3 fix: return original thumbs when edited=false (#25485) 2026-01-23 23:12:18 -06:00
Avalanche Ridings
497003ec57 feat: loop slideshows (#25462)
* Add Repeat to the slideshow in the web UI.

* Fix typo in SlideshowSettingsModal description prop

Fixed spelling

---------

Co-authored-by: generalzero <generalzero@generalzero.org>
2026-01-24 04:09:29 +00:00
Min Idzelis
d0d269677e refactor: rename mobileDevice to mediaQueryManager (#25464) 2026-01-23 20:55:10 -06:00
Brandon Wees
c2775894e1 fix(mobile): backfill asset dimensions to exif table (#25483) 2026-01-24 01:23:46 +00:00
Min Idzelis
357ec1394a feat: generate progressive JPEGs for thumbnails (#25463) 2026-01-24 00:35:07 +00:00
Jason Rasmussen
4fedae4150 refactor: event manager (#25481)
* refactor: event manager

* fix: broken downloadFile endpoint
2026-01-23 18:02:23 -05:00
Jason Rasmussen
b52e8cd570 refactor: asset navbar (#25480) 2026-01-23 16:19:46 -05:00
Daniel Dietzler
984fb12ada refactor: asset media service queries (#25477) 2026-01-23 16:07:57 -05:00
Daniel Dietzler
f88f1265b6 chore: update svelte (#25478) 2026-01-23 14:31:56 -06:00
Jason Rasmussen
af51a11b1b refactor: asset navbar (#25476) 2026-01-23 14:06:19 -05:00
Anukul
d942e7212a fix(web): fix badge value in queues page (#25445)
Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2026-01-23 16:09:19 +00:00
Daniel Dietzler
2792d97027 feat: more factories for small tests (#25396) 2026-01-23 10:54:04 -05:00
Yaros
574d9c34ff feat(mobile): star rating (#24457)
* feat(mobile): star rating

* refactor: use custom rating bar & provider

* refactor: remove user prop from provider

* feat: clear, padding, star size, impl suggestions

* chore: switch to rounded star icons

* fix: alignment & gesturedetector

* feat: rating search filter
2026-01-23 21:17:46 +05:30
renovate[bot]
3cb284c15a chore(deps): update dependency lodash to v4.17.23 [security] (#25441)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-23 16:18:19 +01:00
Mees Frensel
41c5a0ca2f fix(docs): document that fullsize thumbnail might redirect to original (#25416) 2026-01-23 09:42:21 -05:00
Jason Rasmussen
6d9dc46619 chore: include sync dtos (#25470) 2026-01-23 14:24:25 +00:00
Mees Frensel
20dca39143 fix(server): scoped permissions for more endpoints (#25452) 2026-01-23 09:03:57 -05:00
Min Idzelis
84679fb2b2 refactor: use assetCacheManager for OCR data (#25437) 2026-01-22 21:12:56 -06:00
Min Idzelis
a96a08939e refactor: rename preloadManager to imageManager (#25436)
rename: preloadManager to imageManager
2026-01-22 21:11:57 -06:00
208 changed files with 16334 additions and 1931 deletions

View File

@@ -504,16 +504,22 @@ jobs:
CI: true
run: npx playwright test --project=chromium
if: ${{ !cancelled() }}
- name: Archive web results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: success() || failure()
with:
name: e2e-web-test-results-${{ matrix.runner }}
path: e2e/playwright-report/
- name: Run ui tests (web)
env:
CI: true
run: npx playwright test --project=ui
if: ${{ !cancelled() }}
- name: Archive test results
- name: Archive ui results
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: success() || failure()
with:
name: e2e-web-test-results-${{ matrix.runner }}
name: e2e-ui-test-results-${{ matrix.runner }}
path: e2e/playwright-report/
success-check-e2e:
name: End-to-End Tests Success

View File

@@ -104,6 +104,8 @@
"image_preview_description": "Medium-size image with stripped metadata, used when viewing a single asset and for machine learning",
"image_preview_quality_description": "Preview quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness. Setting a low value may affect machine learning quality.",
"image_preview_title": "Preview Settings",
"image_progressive": "Progressive",
"image_progressive_description": "Encode JPEG images progressively for gradual loading display. This has no effect on WebP images.",
"image_quality": "Quality",
"image_resolution": "Resolution",
"image_resolution_description": "Higher resolutions can preserve more detail but take longer to encode, have larger file sizes and can reduce app responsiveness.",
@@ -515,6 +517,7 @@
"all": "All",
"all_albums": "All albums",
"all_people": "All people",
"all_photos": "All photos",
"all_videos": "All videos",
"allow_dark_mode": "Allow dark mode",
"allow_edits": "Allow edits",
@@ -522,6 +525,9 @@
"allow_public_user_to_upload": "Allow public user to upload",
"allowed": "Allowed",
"alt_text_qr_code": "QR code image",
"always_keep": "Always keep",
"always_keep_photos_hint": "Free Up Space will keep all photos on this device.",
"always_keep_videos_hint": "Free Up Space will keep all videos on this device.",
"anti_clockwise": "Anti-clockwise",
"api_key": "API Key",
"api_key_description": "This value will only be shown once. Please be sure to copy it before closing the window.",
@@ -555,6 +561,8 @@
"asset_adding_to_album": "Adding to album…",
"asset_created": "Asset created",
"asset_description_updated": "Asset description has been updated",
"asset_edit_failed": "Asset edit failed",
"asset_edit_success": "Asset edited successfully",
"asset_filename_is_offline": "Asset {filename} is offline",
"asset_has_unassigned_faces": "Asset has unassigned faces",
"asset_hashing": "Hashing…",
@@ -754,13 +762,13 @@
"cleanup_confirm_prompt_title": "Remove from this device?",
"cleanup_deleted_assets": "Moved {count} assets to device trash",
"cleanup_deleting": "Moving to trash...",
"cleanup_filter_description": "Choose which types of assets to remove in the cleanup",
"cleanup_found_assets": "Found {count} backed up assets",
"cleanup_found_assets_with_size": "Found {count} backed up assets ({size})",
"cleanup_icloud_shared_albums_excluded": "iCloud Shared Albums are excluded from the scan",
"cleanup_no_assets_found": "No backed up assets found matching your criteria",
"cleanup_no_assets_found": "No assets found matching the criteria above. Free Up Space can only remove assets that have been backed up to the server",
"cleanup_preview_title": "Assets to remove ({count})",
"cleanup_step3_description": "Scan for photos and videos that have been backed up to the server with the selected cutoff date and filter options",
"cleanup_step4_summary": "{count} assets created before {date} are queued for removal from your device",
"cleanup_step3_description": "Scan for backed up assets matching your date and keep settings.",
"cleanup_step4_summary": "{count} assets (created before {date}) to remove from your local device. Photos will remain accessible from the Immich app.",
"cleanup_trash_hint": "To fully reclaim storage space, open the system gallery app and empty the trash",
"clear": "Clear",
"clear_all": "Clear all",
@@ -858,7 +866,7 @@
"custom_locale": "Custom Locale",
"custom_locale_description": "Format dates and numbers based on the language and the region",
"custom_url": "Custom URL",
"cutoff_date_description": "Remove photos and videos older than",
"cutoff_date_description": "Keep photos from the last…",
"cutoff_day": "{count, plural, one {day} other {days}}",
"cutoff_year": "{count, plural, one {year} other {years}}",
"daily_title_text_date": "E, MMM dd",
@@ -986,6 +994,7 @@
"editor_close_without_save_prompt": "The changes will not be saved",
"editor_close_without_save_title": "Close editor?",
"editor_confirm_reset_all_changes": "Are you sure you want to reset all changes?",
"editor_filters": "Filters",
"editor_flip_horizontal": "Flip horizontal",
"editor_flip_vertical": "Flip vertical",
"editor_orientation": "Orientation",
@@ -1010,6 +1019,7 @@
"error_change_sort_album": "Failed to change album sort order",
"error_delete_face": "Error deleting face from asset",
"error_getting_places": "Error getting places",
"error_loading_albums": "Error loading albums",
"error_loading_image": "Error loading image",
"error_loading_partners": "Error loading partners: {error}",
"error_retrieving_asset_information": "Error retrieving asset information",
@@ -1192,7 +1202,6 @@
"filetype": "Filetype",
"filter": "Filter",
"filter_description": "Conditions to filter the target assets",
"filter_options": "Filter options",
"filter_people": "Filter people",
"filter_places": "Filter places",
"filters": "Filters",
@@ -1206,7 +1215,7 @@
"forgot_pin_code_question": "Forgot your PIN?",
"forward": "Forward",
"free_up_space": "Free Up Space",
"free_up_space_description": "Move backed-up photos and videos to your device's trash to free up space. Your copies on the server remain safe",
"free_up_space_description": "Move backed-up photos and videos to your device's trash to free up space. Your copies on the server remain safe.",
"free_up_space_settings_subtitle": "Free up device storage",
"full_path": "Full path: {path}",
"gcast_enabled": "Google Cast",
@@ -1323,10 +1332,15 @@
"json_editor": "JSON editor",
"json_error": "JSON error",
"keep": "Keep",
"keep_albums": "Keep albums",
"keep_albums_count": "Keeping {count} {count, plural, one {album} other {albums}}",
"keep_all": "Keep All",
"keep_description": "Choose what stays on your device when freeing up space.",
"keep_favorites": "Keep favorites",
"keep_favorites_description": "Favorite assets will not be deleted from your device",
"keep_on_device": "Keep on device",
"keep_on_device_hint": "Select items to keep on this device",
"keep_this_delete_others": "Keep this, delete others",
"keeping": "Keeping: {items}",
"kept_this_deleted_others": "Kept this asset and deleted {count, plural, one {# asset} other {# assets}}",
"keyboard_shortcuts": "Keyboard shortcuts",
"language": "Language",
@@ -1556,6 +1570,7 @@
"next_memory": "Next memory",
"no": "No",
"no_actions_added": "No actions added yet",
"no_albums_found": "No albums found",
"no_albums_message": "Create an album to organize your photos and videos",
"no_albums_with_name_yet": "It looks like you do not have any albums with this name yet.",
"no_albums_yet": "It looks like you do not have any albums yet.",
@@ -1585,6 +1600,7 @@
"no_results_description": "Try a synonym or more general keyword",
"no_shared_albums_message": "Create an album to share photos and videos with people in your network",
"no_uploads_in_progress": "No uploads in progress",
"none": "None",
"not_allowed": "Not allowed",
"not_available": "N/A",
"not_in_any_album": "Not in any album",
@@ -1914,6 +1930,7 @@
"search_filter_media_type_title": "Select media type",
"search_filter_ocr": "Search by OCR",
"search_filter_people_title": "Select people",
"search_filter_star_rating": "Star Rating",
"search_for": "Search for",
"search_for_existing_person": "Search for existing person",
"search_no_more_result": "No more results",
@@ -2118,6 +2135,8 @@
"skip_to_folders": "Skip to folders",
"skip_to_tags": "Skip to tags",
"slideshow": "Slideshow",
"slideshow_repeat": "Repeat slideshow",
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
"slideshow_settings": "Slideshow settings",
"sort_albums_by": "Sort albums by...",
"sort_created": "Date created",

File diff suppressed because one or more lines are too long

View File

@@ -80,6 +80,8 @@ PODS:
- Flutter
- network_info_plus (0.0.1):
- Flutter
- objective_c (0.0.1):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
@@ -158,6 +160,7 @@ DEPENDENCIES:
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
- network_info_plus (from `.symlinks/plugins/network_info_plus/ios`)
- objective_c (from `.symlinks/plugins/objective_c/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
@@ -226,6 +229,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/native_video_player/ios"
network_info_plus:
:path: ".symlinks/plugins/network_info_plus/ios"
objective_c:
:path: ".symlinks/plugins/objective_c/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
@@ -277,6 +282,7 @@ SPEC CHECKSUMS:
maplibre_gl: 3c924e44725147b03dda33430ad216005b40555f
native_video_player: b65c58951ede2f93d103a25366bdebca95081265
network_info_plus: cf61925ab5205dce05a4f0895989afdb6aade5fc
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@@ -140,11 +140,15 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
B231F52D2E93A44A00BC45D1 /* Core */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Core;
sourceTree = "<group>";
};
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Sync;
sourceTree = "<group>";
};
@@ -158,6 +162,8 @@
};
FEE084F22EC172080045228E /* Schemas */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Schemas;
sourceTree = "<group>";
};
@@ -549,14 +555,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";
@@ -585,14 +587,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";

View File

@@ -20,9 +20,8 @@ class RemoteImageRequest {
class RemoteImageApiImpl: NSObject, RemoteImageApi {
private static let delegate = RemoteImageApiDelegate()
static let cacheDir = FileManager.default.temporaryDirectory.appendingPathComponent(
"thumbnails", isDirectory: true)
static let session = {
let cacheDir = FileManager.default.temporaryDirectory.appendingPathComponent("thumbnails", isDirectory: true)
let config = URLSessionConfiguration.default
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
config.httpAdditionalHeaders = ["User-Agent": "Immich_iOS_\(version)"]
@@ -32,7 +31,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
diskCapacity: 1 << 30,
directory: cacheDir
)
config.httpMaximumConnectionsPerHost = 16
config.httpMaximumConnectionsPerHost = 64
return URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
}()

View File

@@ -8,6 +8,6 @@ enum SortUserBy { id }
enum ActionSource { timeline, viewer }
enum CleanupStep { selectDate, filterOptions, scan, delete }
enum CleanupStep { selectDate, scan, delete }
enum AssetFilterType { all, photosOnly, videosOnly }
enum AssetKeepType { none, photosOnly, videosOnly }

View File

@@ -1,52 +1,278 @@
import 'package:flutter/material.dart';
const List<ColorFilter> filters = [
class EditFilter {
final String name;
final double rrBias;
final double rgBias;
final double rbBias;
final double grBias;
final double ggBias;
final double gbBias;
final double brBias;
final double bgBias;
final double bbBias;
final double rOffset;
final double gOffset;
final double bOffset;
const EditFilter({
required this.name,
required this.rrBias,
required this.rgBias,
required this.rbBias,
required this.grBias,
required this.ggBias,
required this.gbBias,
required this.brBias,
required this.bgBias,
required this.bbBias,
required this.rOffset,
required this.gOffset,
required this.bOffset,
});
bool get isIdentity =>
rrBias == 1 &&
rgBias == 0 &&
rbBias == 0 &&
grBias == 0 &&
ggBias == 1 &&
gbBias == 0 &&
brBias == 0 &&
bgBias == 0 &&
bbBias == 1 &&
rOffset == 0 &&
gOffset == 0 &&
bOffset == 0;
factory EditFilter.fromMatrix(List<double> matrix, String name) {
if (matrix.length != 20) {
throw ArgumentError('Color filter matrix must have 20 elements');
}
return EditFilter(
name: name,
rrBias: matrix[0],
rgBias: matrix[1],
rbBias: matrix[2],
grBias: matrix[5],
ggBias: matrix[6],
gbBias: matrix[7],
brBias: matrix[10],
bgBias: matrix[11],
bbBias: matrix[12],
rOffset: matrix[4],
gOffset: matrix[9],
bOffset: matrix[14],
);
}
factory EditFilter.fromDtoParams(Map<String, dynamic> params, String name) {
print(params);
return EditFilter(
name: name,
rrBias: (params['rrBias'] as num).toDouble(),
rgBias: (params['rgBias'] as num).toDouble(),
rbBias: (params['rbBias'] as num).toDouble(),
grBias: (params['grBias'] as num).toDouble(),
ggBias: (params['ggBias'] as num).toDouble(),
gbBias: (params['gbBias'] as num).toDouble(),
brBias: (params['brBias'] as num).toDouble(),
bgBias: (params['bgBias'] as num).toDouble(),
bbBias: (params['bbBias'] as num).toDouble(),
rOffset: (params['rOffset'] as num).toDouble(),
gOffset: (params['gOffset'] as num).toDouble(),
bOffset: (params['bOffset'] as num).toDouble(),
);
}
ColorFilter get colorFilter {
final colorMatrix = <double>[
rrBias,
rgBias,
rbBias,
0,
rOffset,
grBias,
ggBias,
gbBias,
0,
gOffset,
brBias,
bgBias,
bbBias,
0,
bOffset,
0,
0,
0,
1,
0,
];
return ColorFilter.matrix(colorMatrix);
}
Map<String, dynamic> get dtoParameters {
return {
"rrBias": rrBias,
"rgBias": rgBias,
"rbBias": rbBias,
"grBias": grBias,
"ggBias": ggBias,
"gbBias": gbBias,
"brBias": brBias,
"bgBias": bgBias,
"bbBias": bbBias,
"rOffset": rOffset,
"gOffset": gOffset,
"bOffset": bOffset,
};
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! EditFilter) return false;
return rrBias == other.rrBias &&
rgBias == other.rgBias &&
rbBias == other.rbBias &&
grBias == other.grBias &&
ggBias == other.ggBias &&
gbBias == other.gbBias &&
brBias == other.brBias &&
bgBias == other.bgBias &&
bbBias == other.bbBias &&
rOffset == other.rOffset &&
gOffset == other.gOffset &&
bOffset == other.bOffset;
}
@override
int get hashCode =>
name.hashCode ^
rrBias.hashCode ^
rgBias.hashCode ^
rbBias.hashCode ^
grBias.hashCode ^
ggBias.hashCode ^
gbBias.hashCode ^
brBias.hashCode ^
bgBias.hashCode ^
bbBias.hashCode ^
rOffset.hashCode ^
gOffset.hashCode ^
bOffset.hashCode;
}
final List<EditFilter> filters = [
//Original
ColorFilter.matrix([1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0], "Original"),
//Vintage
ColorFilter.matrix([0.8, 0.1, 0.1, 0, 20, 0.1, 0.8, 0.1, 0, 20, 0.1, 0.1, 0.8, 0, 20, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([0.8, 0.1, 0.1, 0, 20, 0.1, 0.8, 0.1, 0, 20, 0.1, 0.1, 0.8, 0, 20, 0, 0, 0, 1, 0], "Vintage"),
//Mood
ColorFilter.matrix([1.2, 0.1, 0.1, 0, 10, 0.1, 1, 0.1, 0, 10, 0.1, 0.1, 1, 0, 10, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.2, 0.1, 0.1, 0, 10, 0.1, 1, 0.1, 0, 10, 0.1, 0.1, 1, 0, 10, 0, 0, 0, 1, 0], "Mood"),
//Crisp
ColorFilter.matrix([1.2, 0, 0, 0, 0, 0, 1.2, 0, 0, 0, 0, 0, 1.2, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.2, 0, 0, 0, 0, 0, 1.2, 0, 0, 0, 0, 0, 1.2, 0, 0, 0, 0, 0, 1, 0], "Crisp"),
//Cool
ColorFilter.matrix([0.9, 0, 0.2, 0, 0, 0, 1, 0.1, 0, 0, 0.1, 0, 1.2, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([0.9, 0, 0.2, 0, 0, 0, 1, 0.1, 0, 0, 0.1, 0, 1.2, 0, 0, 0, 0, 0, 1, 0], "Cool"),
//Blush
ColorFilter.matrix([1.1, 0.1, 0.1, 0, 10, 0.1, 1, 0.1, 0, 10, 0.1, 0.1, 1, 0, 5, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.1, 0.1, 0.1, 0, 10, 0.1, 1, 0.1, 0, 10, 0.1, 0.1, 1, 0, 5, 0, 0, 0, 1, 0], "Blush"),
//Sunkissed
ColorFilter.matrix([1.3, 0, 0.1, 0, 15, 0, 1.1, 0.1, 0, 10, 0, 0, 0.9, 0, 5, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.3, 0, 0.1, 0, 15, 0, 1.1, 0.1, 0, 10, 0, 0, 0.9, 0, 5, 0, 0, 0, 1, 0], "Sunkissed"),
//Fresh
ColorFilter.matrix([1.2, 0, 0, 0, 20, 0, 1.2, 0, 0, 20, 0, 0, 1.1, 0, 20, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.2, 0, 0, 0, 20, 0, 1.2, 0, 0, 20, 0, 0, 1.1, 0, 20, 0, 0, 0, 1, 0], "Fresh"),
//Classic
ColorFilter.matrix([1.1, 0, -0.1, 0, 10, -0.1, 1.1, 0.1, 0, 5, 0, -0.1, 1.1, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.1, 0, -0.1, 0, 10, -0.1, 1.1, 0.1, 0, 5, 0, -0.1, 1.1, 0, 0, 0, 0, 0, 1, 0], "Classic"),
//Lomo-ish
ColorFilter.matrix([1.5, 0, 0.1, 0, 0, 0, 1.45, 0, 0, 0, 0.1, 0, 1.3, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.5, 0, 0.1, 0, 0, 0, 1.45, 0, 0, 0, 0.1, 0, 1.3, 0, 0, 0, 0, 0, 1, 0], "Lomo-ish"),
//Nashville
ColorFilter.matrix([1.2, 0.15, -0.15, 0, 15, 0.1, 1.1, 0.1, 0, 10, -0.05, 0.2, 1.25, 0, 5, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([
1.2,
0.15,
-0.15,
0,
15,
0.1,
1.1,
0.1,
0,
10,
-0.05,
0.2,
1.25,
0,
5,
0,
0,
0,
1,
0,
], "Nashville"),
//Valencia
ColorFilter.matrix([1.15, 0.1, 0.1, 0, 20, 0.1, 1.1, 0, 0, 10, 0.1, 0.1, 1.2, 0, 5, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.15, 0.1, 0.1, 0, 20, 0.1, 1.1, 0, 0, 10, 0.1, 0.1, 1.2, 0, 5, 0, 0, 0, 1, 0], "Valencia"),
//Clarendon
ColorFilter.matrix([1.2, 0, 0, 0, 10, 0, 1.25, 0, 0, 10, 0, 0, 1.3, 0, 10, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.2, 0, 0, 0, 10, 0, 1.25, 0, 0, 10, 0, 0, 1.3, 0, 10, 0, 0, 0, 1, 0], "Clarendon"),
//Moon
ColorFilter.matrix([0.33, 0.33, 0.33, 0, 0, 0.33, 0.33, 0.33, 0, 0, 0.33, 0.33, 0.33, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([
0.33,
0.33,
0.33,
0,
0,
0.33,
0.33,
0.33,
0,
0,
0.33,
0.33,
0.33,
0,
0,
0,
0,
0,
1,
0,
], "Moon"),
//Willow
ColorFilter.matrix([0.5, 0.5, 0.5, 0, 20, 0.5, 0.5, 0.5, 0, 20, 0.5, 0.5, 0.5, 0, 20, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([0.5, 0.5, 0.5, 0, 20, 0.5, 0.5, 0.5, 0, 20, 0.5, 0.5, 0.5, 0, 20, 0, 0, 0, 1, 0], "Willow"),
//Kodak
ColorFilter.matrix([1.3, 0.1, -0.1, 0, 10, 0, 1.25, 0.1, 0, 10, 0, -0.1, 1.1, 0, 5, 0, 0, 0, 1, 0]),
//Frost
ColorFilter.matrix([0.8, 0.2, 0.1, 0, 0, 0.2, 1.1, 0.1, 0, 0, 0.1, 0.1, 1.2, 0, 10, 0, 0, 0, 1, 0]),
//Night Vision
ColorFilter.matrix([0.1, 0.95, 0.2, 0, 0, 0.1, 1.5, 0.1, 0, 0, 0.2, 0.7, 0, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.3, 0.1, -0.1, 0, 10, 0, 1.25, 0.1, 0, 10, 0, -0.1, 1.1, 0, 5, 0, 0, 0, 1, 0], "Kodak"),
//Sunset
ColorFilter.matrix([1.5, 0.2, 0, 0, 0, 0.1, 0.9, 0.1, 0, 0, -0.1, -0.2, 1.3, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.5, 0.2, 0, 0, 0, 0.1, 0.9, 0.1, 0, 0, -0.1, -0.2, 1.3, 0, 0, 0, 0, 0, 1, 0], "Sunset"),
//Noir
ColorFilter.matrix([1.3, -0.3, 0.1, 0, 0, -0.1, 1.2, -0.1, 0, 0, 0.1, -0.2, 1.3, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.3, -0.3, 0.1, 0, 0, -0.1, 1.2, -0.1, 0, 0, 0.1, -0.2, 1.3, 0, 0, 0, 0, 0, 1, 0], "Noir"),
//Dreamy
ColorFilter.matrix([1.1, 0.1, 0.1, 0, 0, 0.1, 1.1, 0.1, 0, 0, 0.1, 0.1, 1.1, 0, 15, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.1, 0.1, 0.1, 0, 0, 0.1, 1.1, 0.1, 0, 0, 0.1, 0.1, 1.1, 0, 15, 0, 0, 0, 1, 0], "Dreamy"),
//Sepia
ColorFilter.matrix([0.393, 0.769, 0.189, 0, 0, 0.349, 0.686, 0.168, 0, 0, 0.272, 0.534, 0.131, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([
0.393,
0.769,
0.189,
0,
0,
0.349,
0.686,
0.168,
0,
0,
0.272,
0.534,
0.131,
0,
0,
0,
0,
0,
1,
0,
], "Sepia"),
//Radium
ColorFilter.matrix([
EditFilter.fromMatrix([
1.438,
-0.062,
-0.062,
@@ -67,9 +293,9 @@ const List<ColorFilter> filters = [
0,
1,
0,
]),
], "Radium"),
//Aqua
ColorFilter.matrix([
EditFilter.fromMatrix([
0.2126,
0.7152,
0.0722,
@@ -90,59 +316,23 @@ const List<ColorFilter> filters = [
0,
1,
0,
]),
], "Aqua"),
//Purple Haze
ColorFilter.matrix([1.3, 0, 1.2, 0, 0, 0, 1.1, 0, 0, 0, 0.2, 0, 1.3, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.3, 0, 1.2, 0, 0, 0, 1.1, 0, 0, 0, 0.2, 0, 1.3, 0, 0, 0, 0, 0, 1, 0], "Purple Haze"),
//Lemonade
ColorFilter.matrix([1.2, 0.1, 0, 0, 0, 0, 1.1, 0.2, 0, 0, 0.1, 0, 0.7, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.2, 0.1, 0, 0, 0, 0, 1.1, 0.2, 0, 0, 0.1, 0, 0.7, 0, 0, 0, 0, 0, 1, 0], "Lemonade"),
//Caramel
ColorFilter.matrix([1.6, 0.2, 0, 0, 0, 0.1, 1.3, 0.1, 0, 0, 0, 0.1, 0.9, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.6, 0.2, 0, 0, 0, 0.1, 1.3, 0.1, 0, 0, 0, 0.1, 0.9, 0, 0, 0, 0, 0, 1, 0], "Caramel"),
//Peachy
ColorFilter.matrix([1.3, 0.5, 0, 0, 0, 0.2, 1.1, 0.3, 0, 0, 0.1, 0.1, 1.2, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.3, 0.5, 0, 0, 0, 0.2, 1.1, 0.3, 0, 0, 0.1, 0.1, 1.2, 0, 0, 0, 0, 0, 1, 0], "Peachy"),
//Neon
ColorFilter.matrix([1, 0, 1, 0, 0, 0, 2, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1, 0, 1, 0, 0, 0, 2, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 1, 0], "Neon"),
//Cold Morning
ColorFilter.matrix([0.9, 0.1, 0.2, 0, 0, 0, 1, 0.1, 0, 0, 0.1, 0, 1.2, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([0.9, 0.1, 0.2, 0, 0, 0, 1, 0.1, 0, 0, 0.1, 0, 1.2, 0, 0, 0, 0, 0, 1, 0], "Cold Morning"),
//Lush
ColorFilter.matrix([0.9, 0.2, 0, 0, 0, 0, 1.2, 0, 0, 0, 0, 0, 1.1, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([0.9, 0.2, 0, 0, 0, 0, 1.2, 0, 0, 0, 0, 0, 1.1, 0, 0, 0, 0, 0, 1, 0], "Lush"),
//Urban Neon
ColorFilter.matrix([1.1, 0, 0.3, 0, 0, 0, 0.9, 0.3, 0, 0, 0.3, 0.1, 1.2, 0, 0, 0, 0, 0, 1, 0]),
EditFilter.fromMatrix([1.1, 0, 0.3, 0, 0, 0, 0.9, 0.3, 0, 0, 0.3, 0.1, 1.2, 0, 0, 0, 0, 0, 1, 0], "Urban Neon"),
//Monochrome
ColorFilter.matrix([0.6, 0.2, 0.2, 0, 0, 0.2, 0.6, 0.2, 0, 0, 0.2, 0.2, 0.7, 0, 0, 0, 0, 0, 1, 0]),
];
const List<String> filterNames = [
'Original',
'Vintage',
'Mood',
'Crisp',
'Cool',
'Blush',
'Sunkissed',
'Fresh',
'Classic',
'Lomo-ish',
'Nashville',
'Valencia',
'Clarendon',
'Moon',
'Willow',
'Kodak',
'Frost',
'Night Vision',
'Sunset',
'Noir',
'Dreamy',
'Sepia',
'Radium',
'Aqua',
'Purple Haze',
'Lemonade',
'Caramel',
'Peachy',
'Neon',
'Cold Morning',
'Lush',
'Urban Neon',
'Monochrome',
EditFilter.fromMatrix([0.6, 0.2, 0.2, 0, 0, 0.2, 0.6, 0.2, 0, 0, 0.2, 0.2, 0.7, 0, 0, 0, 0, 0, 1, 0], "Monochrome"),
];

View File

@@ -56,6 +56,8 @@ sealed class BaseAsset {
bool get isLocalOnly => storage == AssetState.local;
bool get isRemoteOnly => storage == AssetState.remote;
bool get isEditable => isImage && !isMotionPhoto && this is RemoteAsset;
// Overridden in subclasses
AssetState get storage;
String? get localId;

View File

@@ -0,0 +1,22 @@
import "package:openapi/api.dart" as api show AssetEditAction;
enum AssetEditAction { rotate, crop, mirror, filter, other }
extension AssetEditActionExtension on AssetEditAction {
api.AssetEditAction? toDto() {
return switch (this) {
AssetEditAction.rotate => api.AssetEditAction.rotate,
AssetEditAction.crop => api.AssetEditAction.crop,
AssetEditAction.mirror => api.AssetEditAction.mirror,
AssetEditAction.filter => api.AssetEditAction.filter,
AssetEditAction.other => null,
};
}
}
class AssetEdit {
final AssetEditAction action;
final Map<String, dynamic> parameters;
const AssetEdit({required this.action, required this.parameters});
}

View File

@@ -6,6 +6,9 @@ class ExifInfo {
final String? orientation;
final String? timeZone;
final DateTime? dateTimeOriginal;
final int? rating;
final int? width;
final int? height;
// GPS
final double? latitude;
@@ -46,6 +49,9 @@ class ExifInfo {
this.orientation,
this.timeZone,
this.dateTimeOriginal,
this.rating,
this.width,
this.height,
this.isFlipped = false,
this.latitude,
this.longitude,
@@ -71,6 +77,9 @@ class ExifInfo {
other.orientation == orientation &&
other.timeZone == timeZone &&
other.dateTimeOriginal == dateTimeOriginal &&
other.rating == rating &&
other.width == width &&
other.height == height &&
other.latitude == latitude &&
other.longitude == longitude &&
other.city == city &&
@@ -94,6 +103,9 @@ class ExifInfo {
isFlipped.hashCode ^
timeZone.hashCode ^
dateTimeOriginal.hashCode ^
rating.hashCode ^
width.hashCode ^
height.hashCode ^
latitude.hashCode ^
longitude.hashCode ^
city.hashCode ^
@@ -118,6 +130,9 @@ orientation: ${orientation ?? 'NA'},
isFlipped: $isFlipped,
timeZone: ${timeZone ?? 'NA'},
dateTimeOriginal: ${dateTimeOriginal ?? 'NA'},
rating: ${rating ?? 'NA'},
width: ${width ?? 'NA'},
height: ${height ?? 'NA'},
latitude: ${latitude ?? 'NA'},
longitude: ${longitude ?? 'NA'},
city: ${city ?? 'NA'},
@@ -140,6 +155,9 @@ exposureSeconds: ${exposureSeconds ?? 'NA'},
String? orientation,
String? timeZone,
DateTime? dateTimeOriginal,
int? rating,
int? width,
int? height,
double? latitude,
double? longitude,
String? city,
@@ -161,6 +179,9 @@ exposureSeconds: ${exposureSeconds ?? 'NA'},
orientation: orientation ?? this.orientation,
timeZone: timeZone ?? this.timeZone,
dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal,
rating: rating ?? this.rating,
width: width ?? this.width,
height: height ?? this.height,
isFlipped: isFlipped ?? this.isFlipped,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,

View File

@@ -82,7 +82,14 @@ enum StoreKey<T> {
useWifiForUploadPhotos<bool>._(1005),
needBetaMigration<bool>._(1006),
// TODO: Remove this after patching open-api
shouldResetSync<bool>._(1007);
shouldResetSync<bool>._(1007),
// Free up space
cleanupKeepFavorites<bool>._(1008),
cleanupKeepMediaType<int>._(1009),
cleanupKeepAlbumIds<String>._(1010),
cleanupCutoffDaysAgo<int>._(1011),
cleanupDefaultsInitialized<bool>._(1012);
const StoreKey._(this.id);
final int id;

View File

@@ -1,5 +1,6 @@
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
@@ -116,4 +117,12 @@ class AssetService {
Future<List<LocalAlbum>> getSourceAlbums(String localAssetId, {BackupSelection? backupSelection}) {
return _localAssetRepository.getSourceAlbums(localAssetId, backupSelection: backupSelection);
}
Future<List<AssetEdit>> getAssetEdits(String assetId) {
return _remoteAssetRepository.getAssetEdits(assetId);
}
Future<void> editAsset(String assetId, List<AssetEdit> edits) {
return _remoteAssetRepository.editAsset(assetId, edits);
}
}

View File

@@ -118,6 +118,10 @@ class SyncStreamService {
return _syncStreamRepository.deleteAssetsV1(data.cast());
case SyncEntityType.assetExifV1:
return _syncStreamRepository.updateAssetsExifV1(data.cast());
case SyncEntityType.assetEditV1:
return _syncStreamRepository.updateAssetEditsV1(data.cast());
case SyncEntityType.assetEditDeleteV1:
return _syncStreamRepository.deleteAssetEditsV1(data.cast());
case SyncEntityType.assetMetadataV1:
return _syncStreamRepository.updateAssetsMetadataV1(data.cast());
case SyncEntityType.assetMetadataDeleteV1:
@@ -253,6 +257,7 @@ class SyncStreamService {
_logger.info('Processing batch of ${batchData.length} AssetEditReadyV1 events');
final List<SyncAssetV1> assets = [];
final List<SyncAssetEditV1> assetEdits = [];
try {
for (final data in batchData) {
@@ -262,6 +267,7 @@ class SyncStreamService {
final payload = data;
final assetData = payload['asset'];
final editData = payload['edit'];
if (assetData == null) {
continue;
@@ -271,11 +277,28 @@ class SyncStreamService {
if (asset != null) {
assets.add(asset);
// Edits are only send on v2.6.0+
if (editData != null) {
final edits = (editData as List<dynamic>)
.map((e) => SyncAssetEditV1.fromJson(e))
.whereType<SyncAssetEditV1>()
.toList();
assetEdits.addAll(edits);
}
}
}
if (assets.isNotEmpty) {
await _syncStreamRepository.updateAssetsV1(assets, debugLabel: 'websocket-edit');
// edits that are sent replace previous edits, so we delete existing ones first
await _syncStreamRepository.deleteAssetEditsV1(
assets.map((asset) => SyncAssetEditDeleteV1(assetId: asset.id)).toList(),
debugLabel: 'websocket-edit',
);
await _syncStreamRepository.updateAssetEditsV1(assetEdits, debugLabel: 'websocket-edit');
_logger.info('Successfully processed ${assets.length} edited assets');
}
} catch (error, stackTrace) {

View File

@@ -0,0 +1,32 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
class AssetEditEntity extends Table with DriftDefaultsMixin {
const AssetEditEntity();
TextColumn get id => text()();
TextColumn get assetId => text().references(RemoteAssetEntity, #id, onDelete: KeyAction.cascade)();
IntColumn get action => intEnum<AssetEditAction>()();
BlobColumn get parameters => blob().map(editParameterConverter)();
IntColumn get sequence => integer()();
@override
Set<Column> get primaryKey => {id};
}
final JsonTypeConverter2<Map<String, Object?>, Uint8List, Object?> editParameterConverter = TypeConverter.jsonb(
fromJson: (json) => json as Map<String, Object?>,
);
extension AssetEditEntityDataDomainEx on AssetEditEntityData {
AssetEdit toDto() {
return AssetEdit(action: action, parameters: parameters);
}
}

View File

@@ -0,0 +1,748 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'
as i1;
import 'package:immich_mobile/domain/models/asset_edit.model.dart' as i2;
import 'dart:typed_data' as i3;
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart'
as i4;
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'
as i5;
import 'package:drift/internal/modular.dart' as i6;
typedef $$AssetEditEntityTableCreateCompanionBuilder =
i1.AssetEditEntityCompanion Function({
required String id,
required String assetId,
required i2.AssetEditAction action,
required Map<String, Object?> parameters,
required int sequence,
});
typedef $$AssetEditEntityTableUpdateCompanionBuilder =
i1.AssetEditEntityCompanion Function({
i0.Value<String> id,
i0.Value<String> assetId,
i0.Value<i2.AssetEditAction> action,
i0.Value<Map<String, Object?>> parameters,
i0.Value<int> sequence,
});
final class $$AssetEditEntityTableReferences
extends
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$AssetEditEntityTable,
i1.AssetEditEntityData
> {
$$AssetEditEntityTableReferences(
super.$_db,
super.$_table,
super.$_typedResult,
);
static i5.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) =>
i6.ReadDatabaseContainer(db)
.resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i6.ReadDatabaseContainer(db)
.resultSet<i1.$AssetEditEntityTable>('asset_edit_entity')
.assetId,
i6.ReadDatabaseContainer(
db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity').id,
),
);
i5.$$RemoteAssetEntityTableProcessedTableManager get assetId {
final $_column = $_itemColumn<String>('asset_id')!;
final manager = i5
.$$RemoteAssetEntityTableTableManager(
$_db,
i6.ReadDatabaseContainer(
$_db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
)
.filter((f) => f.id.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_assetIdTable($_db));
if (item == null) return manager;
return i0.ProcessedTableManager(
manager.$state.copyWith(prefetchedData: [item]),
);
}
}
class $$AssetEditEntityTableFilterComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$AssetEditEntityTable> {
$$AssetEditEntityTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnFilters<String> get id => $composableBuilder(
column: $table.id,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnWithTypeConverterFilters<i2.AssetEditAction, i2.AssetEditAction, int>
get action => $composableBuilder(
column: $table.action,
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
);
i0.ColumnWithTypeConverterFilters<
Map<String, Object?>,
Map<String, Object>,
i3.Uint8List
>
get parameters => $composableBuilder(
column: $table.parameters,
builder: (column) => i0.ColumnWithTypeConverterFilters(column),
);
i0.ColumnFilters<int> get sequence => $composableBuilder(
column: $table.sequence,
builder: (column) => i0.ColumnFilters(column),
);
i5.$$RemoteAssetEntityTableFilterComposer get assetId {
final i5.$$RemoteAssetEntityTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.assetId,
referencedTable: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i5.$$RemoteAssetEntityTableFilterComposer(
$db: $db,
$table: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
}
class $$AssetEditEntityTableOrderingComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$AssetEditEntityTable> {
$$AssetEditEntityTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnOrderings<String> get id => $composableBuilder(
column: $table.id,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<int> get action => $composableBuilder(
column: $table.action,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<i3.Uint8List> get parameters => $composableBuilder(
column: $table.parameters,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<int> get sequence => $composableBuilder(
column: $table.sequence,
builder: (column) => i0.ColumnOrderings(column),
);
i5.$$RemoteAssetEntityTableOrderingComposer get assetId {
final i5.$$RemoteAssetEntityTableOrderingComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.assetId,
referencedTable: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i5.$$RemoteAssetEntityTableOrderingComposer(
$db: $db,
$table: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
}
class $$AssetEditEntityTableAnnotationComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$AssetEditEntityTable> {
$$AssetEditEntityTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.GeneratedColumn<String> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
i0.GeneratedColumnWithTypeConverter<i2.AssetEditAction, int> get action =>
$composableBuilder(column: $table.action, builder: (column) => column);
i0.GeneratedColumnWithTypeConverter<Map<String, Object?>, i3.Uint8List>
get parameters => $composableBuilder(
column: $table.parameters,
builder: (column) => column,
);
i0.GeneratedColumn<int> get sequence =>
$composableBuilder(column: $table.sequence, builder: (column) => column);
i5.$$RemoteAssetEntityTableAnnotationComposer get assetId {
final i5.$$RemoteAssetEntityTableAnnotationComposer composer =
$composerBuilder(
composer: this,
getCurrentColumn: (t) => t.assetId,
referencedTable: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
getReferencedColumn: (t) => t.id,
builder:
(
joinBuilder, {
$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer,
}) => i5.$$RemoteAssetEntityTableAnnotationComposer(
$db: $db,
$table: i6.ReadDatabaseContainer(
$db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity'),
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
),
);
return composer;
}
}
class $$AssetEditEntityTableTableManager
extends
i0.RootTableManager<
i0.GeneratedDatabase,
i1.$AssetEditEntityTable,
i1.AssetEditEntityData,
i1.$$AssetEditEntityTableFilterComposer,
i1.$$AssetEditEntityTableOrderingComposer,
i1.$$AssetEditEntityTableAnnotationComposer,
$$AssetEditEntityTableCreateCompanionBuilder,
$$AssetEditEntityTableUpdateCompanionBuilder,
(i1.AssetEditEntityData, i1.$$AssetEditEntityTableReferences),
i1.AssetEditEntityData,
i0.PrefetchHooks Function({bool assetId})
> {
$$AssetEditEntityTableTableManager(
i0.GeneratedDatabase db,
i1.$AssetEditEntityTable table,
) : super(
i0.TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
i1.$$AssetEditEntityTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
i1.$$AssetEditEntityTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () => i1
.$$AssetEditEntityTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
i0.Value<String> id = const i0.Value.absent(),
i0.Value<String> assetId = const i0.Value.absent(),
i0.Value<i2.AssetEditAction> action = const i0.Value.absent(),
i0.Value<Map<String, Object?>> parameters =
const i0.Value.absent(),
i0.Value<int> sequence = const i0.Value.absent(),
}) => i1.AssetEditEntityCompanion(
id: id,
assetId: assetId,
action: action,
parameters: parameters,
sequence: sequence,
),
createCompanionCallback:
({
required String id,
required String assetId,
required i2.AssetEditAction action,
required Map<String, Object?> parameters,
required int sequence,
}) => i1.AssetEditEntityCompanion.insert(
id: id,
assetId: assetId,
action: action,
parameters: parameters,
sequence: sequence,
),
withReferenceMapper: (p0) => p0
.map(
(e) => (
e.readTable(table),
i1.$$AssetEditEntityTableReferences(db, table, e),
),
)
.toList(),
prefetchHooksCallback: ({assetId = false}) {
return i0.PrefetchHooks(
db: db,
explicitlyWatchedTables: [],
addJoins:
<
T extends i0.TableManagerState<
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic,
dynamic
>
>(state) {
if (assetId) {
state =
state.withJoin(
currentTable: table,
currentColumn: table.assetId,
referencedTable: i1
.$$AssetEditEntityTableReferences
._assetIdTable(db),
referencedColumn: i1
.$$AssetEditEntityTableReferences
._assetIdTable(db)
.id,
)
as T;
}
return state;
},
getPrefetchedDataCallback: (items) async {
return [];
},
);
},
),
);
}
typedef $$AssetEditEntityTableProcessedTableManager =
i0.ProcessedTableManager<
i0.GeneratedDatabase,
i1.$AssetEditEntityTable,
i1.AssetEditEntityData,
i1.$$AssetEditEntityTableFilterComposer,
i1.$$AssetEditEntityTableOrderingComposer,
i1.$$AssetEditEntityTableAnnotationComposer,
$$AssetEditEntityTableCreateCompanionBuilder,
$$AssetEditEntityTableUpdateCompanionBuilder,
(i1.AssetEditEntityData, i1.$$AssetEditEntityTableReferences),
i1.AssetEditEntityData,
i0.PrefetchHooks Function({bool assetId})
>;
class $AssetEditEntityTable extends i4.AssetEditEntity
with i0.TableInfo<$AssetEditEntityTable, i1.AssetEditEntityData> {
@override
final i0.GeneratedDatabase attachedDatabase;
final String? _alias;
$AssetEditEntityTable(this.attachedDatabase, [this._alias]);
static const i0.VerificationMeta _idMeta = const i0.VerificationMeta('id');
@override
late final i0.GeneratedColumn<String> id = i0.GeneratedColumn<String>(
'id',
aliasedName,
false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
);
static const i0.VerificationMeta _assetIdMeta = const i0.VerificationMeta(
'assetId',
);
@override
late final i0.GeneratedColumn<String> assetId = i0.GeneratedColumn<String>(
'asset_id',
aliasedName,
false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'REFERENCES remote_asset_entity (id) ON DELETE CASCADE',
),
);
@override
late final i0.GeneratedColumnWithTypeConverter<i2.AssetEditAction, int>
action =
i0.GeneratedColumn<int>(
'action',
aliasedName,
false,
type: i0.DriftSqlType.int,
requiredDuringInsert: true,
).withConverter<i2.AssetEditAction>(
i1.$AssetEditEntityTable.$converteraction,
);
@override
late final i0.GeneratedColumnWithTypeConverter<
Map<String, Object?>,
i3.Uint8List
>
parameters =
i0.GeneratedColumn<i3.Uint8List>(
'parameters',
aliasedName,
false,
type: i0.DriftSqlType.blob,
requiredDuringInsert: true,
).withConverter<Map<String, Object?>>(
i1.$AssetEditEntityTable.$converterparameters,
);
static const i0.VerificationMeta _sequenceMeta = const i0.VerificationMeta(
'sequence',
);
@override
late final i0.GeneratedColumn<int> sequence = i0.GeneratedColumn<int>(
'sequence',
aliasedName,
false,
type: i0.DriftSqlType.int,
requiredDuringInsert: true,
);
@override
List<i0.GeneratedColumn> get $columns => [
id,
assetId,
action,
parameters,
sequence,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'asset_edit_entity';
@override
i0.VerificationContext validateIntegrity(
i0.Insertable<i1.AssetEditEntityData> instance, {
bool isInserting = false,
}) {
final context = i0.VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
} else if (isInserting) {
context.missing(_idMeta);
}
if (data.containsKey('asset_id')) {
context.handle(
_assetIdMeta,
assetId.isAcceptableOrUnknown(data['asset_id']!, _assetIdMeta),
);
} else if (isInserting) {
context.missing(_assetIdMeta);
}
if (data.containsKey('sequence')) {
context.handle(
_sequenceMeta,
sequence.isAcceptableOrUnknown(data['sequence']!, _sequenceMeta),
);
} else if (isInserting) {
context.missing(_sequenceMeta);
}
return context;
}
@override
Set<i0.GeneratedColumn> get $primaryKey => {id};
@override
i1.AssetEditEntityData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return i1.AssetEditEntityData(
id: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}id'],
)!,
assetId: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}asset_id'],
)!,
action: i1.$AssetEditEntityTable.$converteraction.fromSql(
attachedDatabase.typeMapping.read(
i0.DriftSqlType.int,
data['${effectivePrefix}action'],
)!,
),
parameters: i1.$AssetEditEntityTable.$converterparameters.fromSql(
attachedDatabase.typeMapping.read(
i0.DriftSqlType.blob,
data['${effectivePrefix}parameters'],
)!,
),
sequence: attachedDatabase.typeMapping.read(
i0.DriftSqlType.int,
data['${effectivePrefix}sequence'],
)!,
);
}
@override
$AssetEditEntityTable createAlias(String alias) {
return $AssetEditEntityTable(attachedDatabase, alias);
}
static i0.JsonTypeConverter2<i2.AssetEditAction, int, int> $converteraction =
const i0.EnumIndexConverter<i2.AssetEditAction>(
i2.AssetEditAction.values,
);
static i0.JsonTypeConverter2<Map<String, Object?>, i3.Uint8List, Object?>
$converterparameters = i4.editParameterConverter;
@override
bool get withoutRowId => true;
@override
bool get isStrict => true;
}
class AssetEditEntityData extends i0.DataClass
implements i0.Insertable<i1.AssetEditEntityData> {
final String id;
final String assetId;
final i2.AssetEditAction action;
final Map<String, Object?> parameters;
final int sequence;
const AssetEditEntityData({
required this.id,
required this.assetId,
required this.action,
required this.parameters,
required this.sequence,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['id'] = i0.Variable<String>(id);
map['asset_id'] = i0.Variable<String>(assetId);
{
map['action'] = i0.Variable<int>(
i1.$AssetEditEntityTable.$converteraction.toSql(action),
);
}
{
map['parameters'] = i0.Variable<i3.Uint8List>(
i1.$AssetEditEntityTable.$converterparameters.toSql(parameters),
);
}
map['sequence'] = i0.Variable<int>(sequence);
return map;
}
factory AssetEditEntityData.fromJson(
Map<String, dynamic> json, {
i0.ValueSerializer? serializer,
}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return AssetEditEntityData(
id: serializer.fromJson<String>(json['id']),
assetId: serializer.fromJson<String>(json['assetId']),
action: i1.$AssetEditEntityTable.$converteraction.fromJson(
serializer.fromJson<int>(json['action']),
),
parameters: i1.$AssetEditEntityTable.$converterparameters.fromJson(
serializer.fromJson<Object?>(json['parameters']),
),
sequence: serializer.fromJson<int>(json['sequence']),
);
}
@override
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'assetId': serializer.toJson<String>(assetId),
'action': serializer.toJson<int>(
i1.$AssetEditEntityTable.$converteraction.toJson(action),
),
'parameters': serializer.toJson<Object?>(
i1.$AssetEditEntityTable.$converterparameters.toJson(parameters),
),
'sequence': serializer.toJson<int>(sequence),
};
}
i1.AssetEditEntityData copyWith({
String? id,
String? assetId,
i2.AssetEditAction? action,
Map<String, Object?>? parameters,
int? sequence,
}) => i1.AssetEditEntityData(
id: id ?? this.id,
assetId: assetId ?? this.assetId,
action: action ?? this.action,
parameters: parameters ?? this.parameters,
sequence: sequence ?? this.sequence,
);
AssetEditEntityData copyWithCompanion(i1.AssetEditEntityCompanion data) {
return AssetEditEntityData(
id: data.id.present ? data.id.value : this.id,
assetId: data.assetId.present ? data.assetId.value : this.assetId,
action: data.action.present ? data.action.value : this.action,
parameters: data.parameters.present
? data.parameters.value
: this.parameters,
sequence: data.sequence.present ? data.sequence.value : this.sequence,
);
}
@override
String toString() {
return (StringBuffer('AssetEditEntityData(')
..write('id: $id, ')
..write('assetId: $assetId, ')
..write('action: $action, ')
..write('parameters: $parameters, ')
..write('sequence: $sequence')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, assetId, action, parameters, sequence);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is i1.AssetEditEntityData &&
other.id == this.id &&
other.assetId == this.assetId &&
other.action == this.action &&
other.parameters == this.parameters &&
other.sequence == this.sequence);
}
class AssetEditEntityCompanion
extends i0.UpdateCompanion<i1.AssetEditEntityData> {
final i0.Value<String> id;
final i0.Value<String> assetId;
final i0.Value<i2.AssetEditAction> action;
final i0.Value<Map<String, Object?>> parameters;
final i0.Value<int> sequence;
const AssetEditEntityCompanion({
this.id = const i0.Value.absent(),
this.assetId = const i0.Value.absent(),
this.action = const i0.Value.absent(),
this.parameters = const i0.Value.absent(),
this.sequence = const i0.Value.absent(),
});
AssetEditEntityCompanion.insert({
required String id,
required String assetId,
required i2.AssetEditAction action,
required Map<String, Object?> parameters,
required int sequence,
}) : id = i0.Value(id),
assetId = i0.Value(assetId),
action = i0.Value(action),
parameters = i0.Value(parameters),
sequence = i0.Value(sequence);
static i0.Insertable<i1.AssetEditEntityData> custom({
i0.Expression<String>? id,
i0.Expression<String>? assetId,
i0.Expression<int>? action,
i0.Expression<i3.Uint8List>? parameters,
i0.Expression<int>? sequence,
}) {
return i0.RawValuesInsertable({
if (id != null) 'id': id,
if (assetId != null) 'asset_id': assetId,
if (action != null) 'action': action,
if (parameters != null) 'parameters': parameters,
if (sequence != null) 'sequence': sequence,
});
}
i1.AssetEditEntityCompanion copyWith({
i0.Value<String>? id,
i0.Value<String>? assetId,
i0.Value<i2.AssetEditAction>? action,
i0.Value<Map<String, Object?>>? parameters,
i0.Value<int>? sequence,
}) {
return i1.AssetEditEntityCompanion(
id: id ?? this.id,
assetId: assetId ?? this.assetId,
action: action ?? this.action,
parameters: parameters ?? this.parameters,
sequence: sequence ?? this.sequence,
);
}
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
if (id.present) {
map['id'] = i0.Variable<String>(id.value);
}
if (assetId.present) {
map['asset_id'] = i0.Variable<String>(assetId.value);
}
if (action.present) {
map['action'] = i0.Variable<int>(
i1.$AssetEditEntityTable.$converteraction.toSql(action.value),
);
}
if (parameters.present) {
map['parameters'] = i0.Variable<i3.Uint8List>(
i1.$AssetEditEntityTable.$converterparameters.toSql(parameters.value),
);
}
if (sequence.present) {
map['sequence'] = i0.Variable<int>(sequence.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('AssetEditEntityCompanion(')
..write('id: $id, ')
..write('assetId: $assetId, ')
..write('action: $action, ')
..write('parameters: $parameters, ')
..write('sequence: $sequence')
..write(')'))
.toString();
}
}

View File

@@ -151,6 +151,9 @@ extension RemoteExifEntityDataDomainEx on RemoteExifEntityData {
domain.ExifInfo toDto() => domain.ExifInfo(
fileSize: fileSize,
dateTimeOriginal: dateTimeOriginal,
rating: rating,
width: width,
height: height,
timeZone: timeZone,
make: make,
model: model,

View File

@@ -54,21 +54,21 @@ abstract class ImageRequest {
}
final descriptor = await ui.ImageDescriptor.encoded(buffer);
buffer.dispose();
if (_isCancelled) {
buffer.dispose();
descriptor.dispose();
return null;
}
final codec = await descriptor.instantiateCodec();
buffer.dispose();
descriptor.dispose();
if (_isCancelled) {
descriptor.dispose();
codec.dispose();
return null;
}
final frame = await codec.getNextFrame();
descriptor.dispose();
codec.dispose();
if (_isCancelled) {
frame.image.dispose();
@@ -105,16 +105,17 @@ abstract class ImageRequest {
rowBytes: rowBytes,
pixelFormat: ui.PixelFormat.rgba8888,
);
final codec = await descriptor.instantiateCodec();
buffer.dispose();
descriptor.dispose();
final codec = await descriptor.instantiateCodec();
if (_isCancelled) {
descriptor.dispose();
codec.dispose();
return null;
}
final frame = await codec.getNextFrame();
descriptor.dispose();
codec.dispose();
if (_isCancelled) {
frame.image.dispose();

View File

@@ -4,6 +4,7 @@ import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.dart';
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
@@ -66,6 +67,7 @@ class IsarDatabaseRepository implements IDatabaseRepository {
AssetFaceEntity,
StoreEntity,
TrashedLocalAssetEntity,
AssetEditEntity,
],
include: {'package:immich_mobile/infrastructure/entities/merged_asset.drift'},
)
@@ -97,7 +99,7 @@ class Drift extends $Drift implements IDatabaseRepository {
}
@override
int get schemaVersion => 17;
int get schemaVersion => 18;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -204,6 +206,9 @@ class Drift extends $Drift implements IDatabaseRepository {
from16To17: (m, v17) async {
await m.addColumn(v17.remoteAssetEntity, v17.remoteAssetEntity.isEdited);
},
from17To18: (m, v18) async {
await m.createTable(v18.assetEditEntity);
},
),
);

View File

@@ -41,9 +41,11 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'
as i19;
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'
as i20;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart'
as i21;
import 'package:drift/internal/modular.dart' as i22;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i22;
import 'package:drift/internal/modular.dart' as i23;
abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e);
@@ -85,9 +87,11 @@ abstract class $Drift extends i0.GeneratedDatabase {
late final i19.$StoreEntityTable storeEntity = i19.$StoreEntityTable(this);
late final i20.$TrashedLocalAssetEntityTable trashedLocalAssetEntity = i20
.$TrashedLocalAssetEntityTable(this);
i21.MergedAssetDrift get mergedAssetDrift => i22.ReadDatabaseContainer(
late final i21.$AssetEditEntityTable assetEditEntity = i21
.$AssetEditEntityTable(this);
i22.MergedAssetDrift get mergedAssetDrift => i23.ReadDatabaseContainer(
this,
).accessor<i21.MergedAssetDrift>(i21.MergedAssetDrift.new);
).accessor<i22.MergedAssetDrift>(i22.MergedAssetDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@@ -119,6 +123,7 @@ abstract class $Drift extends i0.GeneratedDatabase {
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
i11.idxLatLng,
i20.idxTrashedLocalAssetChecksum,
i20.idxTrashedLocalAssetAlbum,
@@ -313,6 +318,13 @@ abstract class $Drift extends i0.GeneratedDatabase {
),
result: [i0.TableUpdate('asset_face_entity', kind: i0.UpdateKind.update)],
),
i0.WritePropagation(
on: i0.TableUpdateQuery.onTableName(
'remote_asset_entity',
limitUpdateKind: i0.UpdateKind.delete,
),
result: [i0.TableUpdate('asset_edit_entity', kind: i0.UpdateKind.delete)],
),
]);
@override
i0.DriftDatabaseOptions get options =>
@@ -372,4 +384,6 @@ class $DriftManager {
_db,
_db.trashedLocalAssetEntity,
);
i21.$$AssetEditEntityTableTableManager get assetEditEntity =>
i21.$$AssetEditEntityTableTableManager(_db, _db.assetEditEntity);
}

View File

@@ -7408,6 +7408,497 @@ i1.GeneratedColumn<bool> _column_101(String aliasedName) =>
),
defaultValue: const CustomExpression('0'),
);
final class Schema18 extends i0.VersionedSchema {
Schema18({required super.database}) : super(version: 18);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAssetChecksum,
idxLocalAssetCloudId,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
remoteAssetCloudIdEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
assetEditEntity,
idxLatLng,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
];
late final Shape20 userEntity = Shape20(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_84,
_column_85,
_column_91,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape28 remoteAssetEntity = Shape28(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_13,
_column_14,
_column_15,
_column_16,
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
_column_86,
_column_101,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape3 stackEntity = Shape3(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
attachedDatabase: database,
),
alias: null,
);
late final Shape26 localAssetEntity = Shape26(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_22,
_column_14,
_column_23,
_column_98,
_column_96,
_column_46,
_column_47,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape9 remoteAlbumEntity = Shape9(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_56,
_column_9,
_column_5,
_column_15,
_column_57,
_column_58,
_column_59,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape19 localAlbumEntity = Shape19(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_5,
_column_31,
_column_32,
_column_90,
_column_33,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape22 localAlbumAssetEntity = Shape22(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_34, _column_35, _column_33],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxLocalAssetCloudId = i1.Index(
'idx_local_asset_cloud_id',
'CREATE INDEX IF NOT EXISTS idx_local_asset_cloud_id ON local_asset_entity (i_cloud_id)',
);
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
'idx_remote_asset_owner_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
late final Shape21 authUserEntity = Shape21(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_2,
_column_84,
_column_85,
_column_92,
_column_93,
_column_7,
_column_94,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_25, _column_26, _column_27],
attachedDatabase: database,
),
alias: null,
);
late final Shape5 partnerEntity = Shape5(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_28, _column_29, _column_30],
attachedDatabase: database,
),
alias: null,
);
late final Shape8 remoteExifEntity = Shape8(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_37,
_column_38,
_column_39,
_column_40,
_column_41,
_column_11,
_column_10,
_column_42,
_column_43,
_column_44,
_column_45,
_column_46,
_column_47,
_column_48,
_column_49,
_column_50,
_column_51,
_column_52,
_column_53,
_column_54,
_column_55,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_36, _column_60],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_60, _column_25, _column_61],
attachedDatabase: database,
),
alias: null,
);
late final Shape27 remoteAssetCloudIdEntity = Shape27(
source: i0.VersionedTable(
entityName: 'remote_asset_cloud_id_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_99,
_column_100,
_column_96,
_column_46,
_column_47,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape11 memoryEntity = Shape11(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_18,
_column_15,
_column_8,
_column_62,
_column_63,
_column_64,
_column_65,
_column_66,
_column_67,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_36, _column_68],
attachedDatabase: database,
),
alias: null,
);
late final Shape14 personEntity = Shape14(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_15,
_column_1,
_column_69,
_column_71,
_column_72,
_column_73,
_column_74,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape15 assetFaceEntity = Shape15(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_36,
_column_76,
_column_77,
_column_78,
_column_79,
_column_80,
_column_81,
_column_82,
_column_83,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_87, _column_88, _column_89],
attachedDatabase: database,
),
alias: null,
);
late final Shape25 trashedLocalAssetEntity = Shape25(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_95,
_column_22,
_column_14,
_column_23,
_column_97,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape29 assetEditEntity = Shape29(
source: i0.VersionedTable(
entityName: 'asset_edit_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_0, _column_36, _column_102, _column_103, _column_104],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
}
class Shape29 extends i0.VersionedTable {
Shape29({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get assetId =>
columnsByName['asset_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get action =>
columnsByName['action']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<i2.Uint8List> get parameters =>
columnsByName['parameters']! as i1.GeneratedColumn<i2.Uint8List>;
i1.GeneratedColumn<int> get sequence =>
columnsByName['sequence']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<int> _column_102(String aliasedName) =>
i1.GeneratedColumn<int>(
'action',
aliasedName,
false,
type: i1.DriftSqlType.int,
);
i1.GeneratedColumn<i2.Uint8List> _column_103(String aliasedName) =>
i1.GeneratedColumn<i2.Uint8List>(
'parameters',
aliasedName,
false,
type: i1.DriftSqlType.blob,
);
i1.GeneratedColumn<int> _column_104(String aliasedName) =>
i1.GeneratedColumn<int>(
'sequence',
aliasedName,
false,
type: i1.DriftSqlType.int,
);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -7425,6 +7916,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16,
required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17,
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -7508,6 +8000,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from16To17(migrator, schema);
return 17;
case 17:
final schema = Schema18(database: database);
final migrator = i1.Migrator(database, schema);
await from17To18(migrator, schema);
return 18;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -7531,6 +8028,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16,
required Future<void> Function(i1.Migrator m, Schema17 schema) from16To17,
required Future<void> Function(i1.Migrator m, Schema18 schema) from17To18,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@@ -7549,5 +8047,6 @@ i1.OnUpgrade stepByStep({
from14To15: from14To15,
from15To16: from15To16,
from16To17: from16To17,
from17To18: from17To18,
),
);

View File

@@ -11,6 +11,13 @@ import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class RemovalCandidatesResult {
final List<LocalAsset> assets;
final int totalBytes;
const RemovalCandidatesResult({required this.assets, required this.totalBytes});
}
class DriftLocalAssetRepository extends DriftDatabaseRepository {
final Drift _db;
@@ -130,11 +137,12 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
return result;
}
Future<List<LocalAsset>> getRemovalCandidates(
Future<RemovalCandidatesResult> getRemovalCandidates(
String userId,
DateTime cutoffDate, {
AssetFilterType filterType = AssetFilterType.all,
AssetKeepType keepMediaType = AssetKeepType.none,
bool keepFavorites = true,
Set<String> keepAlbumIds = const {},
}) async {
final iosSharedAlbumAssets = _db.localAlbumAssetEntity.selectOnly()
..addColumns([_db.localAlbumAssetEntity.assetId])
@@ -149,6 +157,7 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
final query = _db.localAssetEntity.select().join([
innerJoin(_db.remoteAssetEntity, _db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum)),
leftOuterJoin(_db.remoteExifEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteExifEntity.assetId)),
]);
Expression<bool> whereClause =
@@ -159,10 +168,19 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
// Exclude assets that are in iOS shared albums
whereClause = whereClause & _db.localAssetEntity.id.isNotInQuery(iosSharedAlbumAssets);
if (filterType == AssetFilterType.photosOnly) {
whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.image);
} else if (filterType == AssetFilterType.videosOnly) {
if (keepAlbumIds.isNotEmpty) {
final keepAlbumAssets = _db.localAlbumAssetEntity.selectOnly()
..addColumns([_db.localAlbumAssetEntity.assetId])
..where(_db.localAlbumAssetEntity.albumId.isIn(keepAlbumIds));
whereClause = whereClause & _db.localAssetEntity.id.isNotInQuery(keepAlbumAssets);
}
if (keepMediaType == AssetKeepType.photosOnly) {
// Keep photos = delete only videos
whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.video);
} else if (keepMediaType == AssetKeepType.videosOnly) {
// Keep videos = delete only photos
whereClause = whereClause & _db.localAssetEntity.type.equalsValue(AssetType.image);
}
if (keepFavorites) {
@@ -172,7 +190,13 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
query.where(whereClause);
final rows = await query.get();
return rows.map((row) => row.readTable(_db.localAssetEntity).toDto()).toList();
final assets = rows.map((row) => row.readTable(_db.localAssetEntity).toDto()).toList();
final totalBytes = rows.fold<int>(0, (sum, row) {
final fileSize = row.readTableOrNull(_db.remoteExifEntity)?.fileSize;
return sum + (fileSize ?? 0);
});
return RemovalCandidatesResult(assets: assets, totalBytes: totalBytes);
}
Future<List<LocalAsset>> getEmptyCloudIdAssets() {

View File

@@ -1,7 +1,10 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/domain/models/stack.model.dart';
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.dart';
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart' hide ExifInfo;
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
@@ -9,6 +12,7 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:uuid/uuid.dart';
class RemoteAssetRepository extends DriftDatabaseRepository {
final Drift _db;
@@ -255,7 +259,44 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
);
}
Future<void> updateRating(String assetId, int rating) async {
await (_db.remoteExifEntity.update()..where((row) => row.assetId.equals(assetId))).write(
RemoteExifEntityCompanion(rating: Value(rating)),
);
}
Future<int> getCount() {
return _db.managers.remoteAssetEntity.count();
}
Future<List<AssetEdit>> getAssetEdits(String assetId) async {
final query = _db.assetEditEntity.select()
..where((row) => row.assetId.equals(assetId))
..orderBy([(row) => OrderingTerm.asc(row.sequence)]);
return query.map((row) => row.toDto()).get();
}
Future<void> editAsset(String assetId, List<AssetEdit> edits) async {
await _db.transaction(() async {
await _db.batch((batch) async {
// delete existing edits
batch.deleteWhere(_db.assetEditEntity, (row) => row.assetId.equals(assetId));
// insert new edits
for (var i = 0; i < edits.length; i++) {
final edit = edits[i];
final companion = AssetEditEntityCompanion(
id: Value(const Uuid().v4()),
assetId: Value(assetId),
action: Value(edit.action),
parameters: Value(edit.parameters),
sequence: Value(i),
);
batch.insert(_db.assetEditEntity, companion);
}
});
});
}
}

View File

@@ -31,6 +31,7 @@ class SearchApiRepository extends ApiRepository {
takenAfter: filter.date.takenAfter,
takenBefore: filter.date.takenBefore,
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(),
@@ -54,6 +55,7 @@ class SearchApiRepository extends ApiRepository {
takenAfter: filter.date.takenAfter,
takenBefore: filter.date.takenBefore,
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(),

View File

@@ -45,6 +45,7 @@ class SyncApiRepository {
SyncRequestType.usersV1,
SyncRequestType.assetsV1,
SyncRequestType.assetExifsV1,
SyncRequestType.assetEditsV1,
SyncRequestType.assetMetadataV1,
SyncRequestType.partnersV1,
SyncRequestType.partnerAssetsV1,
@@ -149,6 +150,8 @@ const _kResponseMap = <SyncEntityType, Function(Object)>{
SyncEntityType.assetV1: SyncAssetV1.fromJson,
SyncEntityType.assetDeleteV1: SyncAssetDeleteV1.fromJson,
SyncEntityType.assetExifV1: SyncAssetExifV1.fromJson,
SyncEntityType.assetEditV1: SyncAssetEditV1.fromJson,
SyncEntityType.assetEditDeleteV1: SyncAssetEditDeleteV1.fromJson,
SyncEntityType.assetMetadataV1: SyncAssetMetadataV1.fromJson,
SyncEntityType.assetMetadataDeleteV1: SyncAssetMetadataDeleteV1.fromJson,
SyncEntityType.partnerAssetV1: SyncAssetV1.fromJson,

View File

@@ -5,9 +5,11 @@ import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
import 'package:immich_mobile/infrastructure/entities/asset_edit.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
@@ -26,8 +28,8 @@ import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey;
import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole, UserMetadataKey;
import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey, AssetEditAction;
import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole, UserMetadataKey, AssetEditAction;
class SyncStreamRepository extends DriftDatabaseRepository {
final Logger _logger = Logger('DriftSyncStreamRepository');
@@ -58,6 +60,7 @@ class SyncStreamRepository extends DriftDatabaseRepository {
await _db.userEntity.deleteAll();
await _db.userMetadataEntity.deleteAll();
await _db.remoteAssetCloudIdEntity.deleteAll();
await _db.assetEditEntity.deleteAll();
});
await _db.customStatement('PRAGMA foreign_keys = ON');
});
@@ -240,6 +243,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
rating: Value(exif.rating),
projectionType: Value(exif.projectionType),
lens: Value(exif.lensModel),
width: Value(exif.exifImageWidth),
height: Value(exif.exifImageHeight),
);
batch.insert(
@@ -276,6 +281,40 @@ class SyncStreamRepository extends DriftDatabaseRepository {
}
}
Future<void> updateAssetEditsV1(Iterable<SyncAssetEditV1> data, {String debugLabel = 'user'}) async {
try {
await _db.batch((batch) {
for (final edit in data) {
final companion = AssetEditEntityCompanion(
id: Value(edit.id),
assetId: Value(edit.assetId),
action: Value(edit.action.toAssetEditAction()),
parameters: Value(edit.parameters as Map<String, Object?>),
sequence: Value(edit.sequence),
);
batch.insert(_db.assetEditEntity, companion, onConflict: DoUpdate((_) => companion));
}
});
} catch (error, stack) {
_logger.severe('Error: updateAssetEditsV1 - $debugLabel', error, stack);
rethrow;
}
}
Future<void> deleteAssetEditsV1(Iterable<SyncAssetEditDeleteV1> data, {String debugLabel = 'user'}) async {
try {
await _db.batch((batch) {
for (final edit in data) {
batch.deleteWhere(_db.assetEditEntity, (row) => row.assetId.equals(edit.assetId));
}
});
} catch (error, stack) {
_logger.severe('Error: deleteAssetEditsV1 - $debugLabel', error, stack);
rethrow;
}
}
Future<void> deleteAssetsMetadataV1(Iterable<SyncAssetMetadataDeleteV1> data) async {
try {
await _db.batch((batch) {
@@ -765,3 +804,13 @@ extension on String {
extension on UserAvatarColor {
AvatarColor? toAvatarColor() => AvatarColor.values.firstWhereOrNull((c) => c.name == value);
}
extension on api.AssetEditAction {
AssetEditAction toAssetEditAction() => switch (this) {
api.AssetEditAction.crop => AssetEditAction.crop,
api.AssetEditAction.rotate => AssetEditAction.rotate,
api.AssetEditAction.mirror => AssetEditAction.mirror,
api.AssetEditAction.filter => AssetEditAction.filter,
_ => AssetEditAction.other,
};
}

View File

@@ -126,6 +126,41 @@ class SearchDateFilter {
int get hashCode => takenBefore.hashCode ^ takenAfter.hashCode;
}
class SearchRatingFilter {
int? rating;
SearchRatingFilter({this.rating});
SearchRatingFilter copyWith({int? rating}) {
return SearchRatingFilter(rating: rating ?? this.rating);
}
Map<String, dynamic> toMap() {
return <String, dynamic>{'rating': rating};
}
factory SearchRatingFilter.fromMap(Map<String, dynamic> map) {
return SearchRatingFilter(rating: map['rating'] != null ? map['rating'] as int : null);
}
String toJson() => json.encode(toMap());
factory SearchRatingFilter.fromJson(String source) =>
SearchRatingFilter.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() => 'SearchRatingFilter(rating: $rating)';
@override
bool operator ==(covariant SearchRatingFilter other) {
if (identical(this, other)) return true;
return other.rating == rating;
}
@override
int get hashCode => rating.hashCode;
}
class SearchDisplayFilters {
bool isNotInAlbum = false;
bool isArchive = false;
@@ -183,6 +218,7 @@ class SearchFilter {
SearchLocationFilter location;
SearchCameraFilter camera;
SearchDateFilter date;
SearchRatingFilter rating;
SearchDisplayFilters display;
// Enum
@@ -200,6 +236,7 @@ class SearchFilter {
required this.camera,
required this.date,
required this.display,
required this.rating,
required this.mediaType,
});
@@ -220,6 +257,7 @@ class SearchFilter {
display.isNotInAlbum == false &&
display.isArchive == false &&
display.isFavorite == false &&
rating.rating == null &&
mediaType == AssetType.other;
}
@@ -235,6 +273,7 @@ class SearchFilter {
SearchCameraFilter? camera,
SearchDateFilter? date,
SearchDisplayFilters? display,
SearchRatingFilter? rating,
AssetType? mediaType,
}) {
return SearchFilter(
@@ -249,13 +288,14 @@ class SearchFilter {
camera: camera ?? this.camera,
date: date ?? this.date,
display: display ?? this.display,
rating: rating ?? this.rating,
mediaType: mediaType ?? this.mediaType,
);
}
@override
String toString() {
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, camera: $camera, date: $date, display: $display, mediaType: $mediaType, assetId: $assetId)';
return 'SearchFilter(context: $context, filename: $filename, description: $description, language: $language, ocr: $ocr, people: $people, location: $location, camera: $camera, date: $date, display: $display, rating: $rating, mediaType: $mediaType, assetId: $assetId)';
}
@override
@@ -273,6 +313,7 @@ class SearchFilter {
other.camera == camera &&
other.date == date &&
other.display == display &&
other.rating == rating &&
other.mediaType == mediaType;
}
@@ -289,6 +330,7 @@ class SearchFilter {
camera.hashCode ^
date.hashCode ^
display.hashCode ^
rating.hashCode ^
mediaType.hashCode;
}
}

View File

@@ -142,7 +142,7 @@ class SettingsSubPage extends StatelessWidget {
context.locale;
return Scaffold(
appBar: AppBar(centerTitle: false, title: Text(section.title).tr()),
body: Padding(padding: const EdgeInsets.only(bottom: 60.0), child: section.widget),
body: section.widget,
);
}
}

View File

@@ -23,7 +23,7 @@ class FilterImagePage extends HookWidget {
@override
Widget build(BuildContext context) {
final colorFilter = useState<ColorFilter>(filters[0]);
final colorFilter = useState<EditFilter>(filters[0]);
final selectedFilterIndex = useState<int>(0);
Future<ui.Image> createFilteredImage(ui.Image inputImage, ColorFilter filter) {
@@ -42,12 +42,12 @@ class FilterImagePage extends HookWidget {
return completer.future;
}
void applyFilter(ColorFilter filter, int index) {
void applyFilter(EditFilter filter, int index) {
colorFilter.value = filter;
selectedFilterIndex.value = index;
}
Future<Image> applyFilterAndConvert(ColorFilter filter) async {
Future<Image> applyFilterAndConvert(EditFilter filter) async {
final completer = Completer<ui.Image>();
image.image
.resolve(ImageConfiguration.empty)
@@ -58,7 +58,7 @@ class FilterImagePage extends HookWidget {
);
final uiImage = await completer.future;
final filteredUiImage = await createFilteredImage(uiImage, filter);
final filteredUiImage = await createFilteredImage(uiImage, filter.colorFilter);
final byteData = await filteredUiImage.toByteData(format: ui.ImageByteFormat.png);
final pngBytes = byteData!.buffer.asUint8List();
@@ -86,7 +86,7 @@ class FilterImagePage extends HookWidget {
SizedBox(
height: context.height * 0.7,
child: Center(
child: ColorFiltered(colorFilter: colorFilter.value, child: image),
child: ColorFiltered(colorFilter: colorFilter.value.colorFilter, child: image),
),
),
SizedBox(
@@ -99,7 +99,7 @@ class FilterImagePage extends HookWidget {
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _FilterButton(
image: image,
label: filterNames[index],
label: filters[index].name,
filter: filters[index],
isSelected: selectedFilterIndex.value == index,
onTap: () => applyFilter(filters[index], index),
@@ -117,7 +117,7 @@ class FilterImagePage extends HookWidget {
class _FilterButton extends StatelessWidget {
final Image image;
final String label;
final ColorFilter filter;
final EditFilter filter;
final bool isSelected;
final VoidCallback onTap;
@@ -145,7 +145,7 @@ class _FilterButton extends StatelessWidget {
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(10)),
child: ColorFiltered(
colorFilter: filter,
colorFilter: filter.colorFilter,
child: FittedBox(fit: BoxFit.cover, child: image),
),
),

View File

@@ -113,6 +113,7 @@ class PlaceTile extends StatelessWidget {
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.other,
),
),

View File

@@ -43,6 +43,7 @@ class SearchPage extends HookConsumerWidget {
date: prefilter?.date ?? SearchDateFilter(),
display: prefilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
mediaType: prefilter?.mediaType ?? AssetType.other,
rating: prefilter?.rating ?? SearchRatingFilter(),
language: "${context.locale.languageCode}-${context.locale.countryCode}",
),
);

View File

@@ -0,0 +1,624 @@
import 'dart:async';
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:crop_image/crop_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/constants/filters.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/theme.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/utils/editor.utils.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:openapi/api.dart' show CropParameters, RotateParameters, MirrorParameters, MirrorAxis;
@RoutePage()
class DriftEditImagePage extends ConsumerStatefulWidget {
final Image image;
final BaseAsset asset;
final List<AssetEdit> edits;
final ExifInfo exifInfo;
const DriftEditImagePage({
super.key,
required this.image,
required this.asset,
required this.edits,
required this.exifInfo,
});
@override
ConsumerState<DriftEditImagePage> createState() => _DriftEditImagePageState();
}
class _DriftEditImagePageState extends ConsumerState<DriftEditImagePage> with TickerProviderStateMixin {
late final CropController cropController;
Duration _rotationAnimationDuration = const Duration(milliseconds: 250);
int _rotationAngle = 0;
bool _flipHorizontal = false;
bool _flipVertical = false;
EditFilter? _filter;
double? _aspectRatio;
late final originalWidth = widget.exifInfo.isFlipped ? widget.exifInfo.height : widget.exifInfo.width;
late final originalHeight = widget.exifInfo.isFlipped ? widget.exifInfo.width : widget.exifInfo.height;
bool isEditing = false;
String selectedSegment = 'transform';
void initEditor() {
final existingCrop = widget.edits.firstWhereOrNull((edit) => edit.action == AssetEditAction.crop);
Rect crop = existingCrop != null && originalWidth != null && originalHeight != null
? convertCropParametersToRect(
CropParameters.fromJson(existingCrop.parameters)!,
originalWidth!,
originalHeight!,
)
: const Rect.fromLTRB(0, 0, 1, 1);
cropController = CropController(defaultCrop: crop);
final (rotationAngle, flipHorizontal, flipVertical) = normalizeTransformEdits(widget.edits);
final existingFilter = widget.edits.firstWhereOrNull((edit) => edit.action == AssetEditAction.filter);
if (existingFilter != null) {
final parsedFilter = EditFilter.fromDtoParams(existingFilter.parameters, 'Custom');
_filter = filters.firstWhereOrNull((filter) => filter == parsedFilter);
}
// dont animate to initial rotation
_rotationAnimationDuration = const Duration(milliseconds: 0);
_rotationAngle = rotationAngle.toInt();
_flipHorizontal = flipHorizontal;
_flipVertical = flipVertical;
}
Future<void> _saveEditedImage() async {
setState(() {
isEditing = true;
});
final cropParameters = convertRectToCropParameters(cropController.crop, originalWidth ?? 0, originalHeight ?? 0);
final normalizedRotation = (_rotationAngle % 360 + 360) % 360;
final edits = <AssetEdit>[];
if (cropParameters.width != originalWidth || cropParameters.height != originalHeight) {
edits.add(AssetEdit(action: AssetEditAction.crop, parameters: cropParameters.toJson()));
}
if (_flipHorizontal) {
edits.add(
AssetEdit(
action: AssetEditAction.mirror,
parameters: MirrorParameters(axis: MirrorAxis.horizontal).toJson(),
),
);
}
if (_flipVertical) {
edits.add(
AssetEdit(
action: AssetEditAction.mirror,
parameters: MirrorParameters(axis: MirrorAxis.vertical).toJson(),
),
);
}
if (normalizedRotation != 0) {
edits.add(
AssetEdit(
action: AssetEditAction.rotate,
parameters: RotateParameters(angle: normalizedRotation).toJson(),
),
);
}
if (_filter != null && !_filter!.isIdentity) {
edits.add(AssetEdit(action: AssetEditAction.filter, parameters: _filter!.dtoParameters));
}
try {
final completer = ref.read(websocketProvider.notifier).waitForEvent("AssetEditReadyV1", (dynamic data) {
final eventData = data as Map<String, dynamic>;
return eventData["asset"]['id'] == widget.asset.remoteId;
}, const Duration(seconds: 10));
await ref.read(actionProvider.notifier).applyEdits(ActionSource.viewer, edits);
await completer;
ImmichToast.show(context: context, msg: 'asset_edit_success'.tr(), toastType: ToastType.success);
context.pop();
} catch (e) {
if (mounted) {
ImmichToast.show(context: context, msg: 'asset_edit_failed'.tr(), toastType: ToastType.error);
}
return;
} finally {
setState(() {
isEditing = false;
});
}
}
@override
void initState() {
super.initState();
initEditor();
}
@override
void dispose() {
cropController.dispose();
super.dispose();
}
Widget _buildProgressIndicator() {
return const Padding(
padding: EdgeInsets.all(8.0),
child: SizedBox(width: 28, height: 28, child: CircularProgressIndicator(strokeWidth: 2.5)),
);
}
void _rotateLeft() {
setState(() {
_rotationAnimationDuration = const Duration(milliseconds: 150);
_rotationAngle -= 90;
});
}
void _rotateRight() {
setState(() {
_rotationAnimationDuration = const Duration(milliseconds: 150);
_rotationAngle += 90;
});
}
void _flipHorizontally() {
setState(() {
if (_rotationAngle % 180 != 0) {
// When rotated 90 or 270 degrees, flipping horizontally is equivalent to flipping vertically
_flipVertical = !_flipVertical;
} else {
_flipHorizontal = !_flipHorizontal;
}
});
}
void _flipVertically() {
setState(() {
if (_rotationAngle % 180 != 0) {
// When rotated 90 or 270 degrees, flipping vertically is equivalent to flipping horizontally
_flipHorizontal = !_flipHorizontal;
} else {
_flipVertical = !_flipVertical;
}
});
}
void _applyAspectRatio(double? ratio) {
setState(() {
cropController.aspectRatio = ratio;
_aspectRatio = ratio;
});
}
void _applyFilter(EditFilter? filter) {
setState(() {
_filter = filter;
});
}
void _resetEdits() {
setState(() {
cropController.aspectRatio = null;
cropController.crop = const Rect.fromLTRB(0, 0, 1, 1);
_rotationAnimationDuration = const Duration(milliseconds: 250);
_rotationAngle = 0;
_flipHorizontal = false;
_flipVertical = false;
_filter = null;
_aspectRatio = null;
});
}
bool get hasEdits {
final isCropped = cropController.crop != const Rect.fromLTRB(0, 0, 1, 1);
final isRotated = (_rotationAngle % 360 + 360) % 360 != 0;
final isFlipped = _flipHorizontal || _flipVertical;
final isFiltered = _filter != null && !_filter!.isIdentity;
return isCropped || isRotated || isFlipped || isFiltered;
}
@override
Widget build(BuildContext context) {
return Theme(
data: getThemeData(colorScheme: ref.watch(immichThemeProvider).dark, locale: context.locale),
child: Scaffold(
appBar: AppBar(
backgroundColor: Colors.black,
title: Text("edit".tr()),
leading: const ImmichCloseButton(),
actions: [
isEditing
? _buildProgressIndicator()
: ImmichIconButton(
icon: Icons.done_rounded,
color: ImmichColor.primary,
variant: ImmichVariant.ghost,
onPressed: _saveEditedImage,
),
],
),
backgroundColor: Colors.black,
body: SafeArea(
bottom: false,
child: Column(
children: [
Expanded(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
// Calculate the bounding box size needed for the rotated container
final baseWidth = constraints.maxWidth * 0.9;
final baseHeight = constraints.maxHeight * 0.95;
return Center(
child: AnimatedRotation(
turns: _rotationAngle / 360,
duration: _rotationAnimationDuration,
curve: Curves.easeInOut,
child: Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..scaleByDouble(_flipHorizontal ? -1.0 : 1.0, _flipVertical ? -1.0 : 1.0, 1.0, 1.0),
child: Container(
padding: const EdgeInsets.all(10),
width: (_rotationAngle % 180 == 0) ? baseWidth : baseHeight,
height: (_rotationAngle % 180 == 0) ? baseHeight : baseWidth,
child: FutureBuilder(
future: resolveImage(widget.image.image),
builder: (context, data) {
if (!data.hasData) {
return const Center(child: CircularProgressIndicator());
}
return CropImage(
controller: cropController,
image: widget.image,
gridColor: Colors.white,
overlayPainter: MatrixAdjustmentPainter(
image: data.data!,
filter: _filter?.colorFilter,
),
);
},
),
),
),
),
);
},
),
),
AnimatedSize(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
alignment: Alignment.bottomCenter,
clipBehavior: Clip.none,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: ref.watch(immichThemeProvider).dark.surface,
borderRadius: const BorderRadius.only(topLeft: Radius.circular(20), topRight: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedCrossFade(
duration: const Duration(milliseconds: 250),
firstCurve: Curves.easeInOut,
secondCurve: Curves.easeInOut,
sizeCurve: Curves.easeInOut,
crossFadeState: selectedSegment == 'transform'
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
firstChild: _TransformControls(
onRotateLeft: _rotateLeft,
onRotateRight: _rotateRight,
onFlipHorizontal: _flipHorizontally,
onFlipVertical: _flipVertically,
onAspectRatioSelected: _applyAspectRatio,
aspectRatio: _aspectRatio,
),
secondChild: _FilterControls(
currentFilter: _filter,
previewImage: widget.image,
onApplyFilter: _applyFilter,
),
),
Padding(
padding: const EdgeInsets.only(bottom: 36, left: 24, right: 24),
child: Row(
children: [
SegmentedButton(
segments: [
const ButtonSegment<String>(
value: 'transform',
label: Text('Transform'),
icon: Icon(Icons.transform),
),
const ButtonSegment<String>(
value: 'filters',
label: Text('Filters'),
icon: Icon(Icons.color_lens),
),
],
selected: {selectedSegment},
onSelectionChanged: (value) => setState(() {
selectedSegment = value.first;
}),
showSelectedIcon: false,
),
const Spacer(),
ImmichTextButton(
labelText: "Reset",
onPressed: _resetEdits,
variant: ImmichVariant.filled,
expanded: false,
disabled: !hasEdits,
),
],
),
),
],
),
),
),
],
),
),
),
);
}
}
class _AspectRatioButton extends StatelessWidget {
final double? currentAspectRatio;
final double? ratio;
final String label;
final VoidCallback onPressed;
const _AspectRatioButton({
required this.currentAspectRatio,
required this.ratio,
required this.label,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.max,
children: [
IconButton(
iconSize: 36,
icon: Transform.rotate(
angle: (ratio ?? 1.0) < 1.0 ? pi / 2 : 0,
child: Icon(switch (label) {
'Free' => Icons.crop_free_rounded,
'1:1' => Icons.crop_square_rounded,
'16:9' => Icons.crop_16_9_rounded,
'3:2' => Icons.crop_3_2_rounded,
'7:5' => Icons.crop_7_5_rounded,
'9:16' => Icons.crop_16_9_rounded,
'2:3' => Icons.crop_3_2_rounded,
'5:7' => Icons.crop_7_5_rounded,
_ => Icons.crop_free_rounded,
}, color: currentAspectRatio == ratio ? context.primaryColor : context.themeData.iconTheme.color),
),
onPressed: onPressed,
),
Text(label, style: context.textTheme.displayMedium),
],
);
}
}
class _AspectRatioSelector extends StatelessWidget {
final double? currentAspectRatio;
final void Function(double?) onAspectRatioSelected;
const _AspectRatioSelector({required this.currentAspectRatio, required this.onAspectRatioSelected});
@override
Widget build(BuildContext context) {
final aspectRatios = <String, double?>{
'Free': null,
'1:1': 1.0,
'16:9': 16 / 9,
'3:2': 3 / 2,
'7:5': 7 / 5,
'9:16': 9 / 16,
'2:3': 2 / 3,
'5:7': 5 / 7,
};
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: aspectRatios.entries.map((entry) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _AspectRatioButton(
currentAspectRatio: currentAspectRatio,
ratio: entry.value,
label: entry.key,
onPressed: () => onAspectRatioSelected(entry.value),
),
);
}).toList(),
),
);
}
}
class _TransformControls extends StatelessWidget {
final VoidCallback onRotateLeft;
final VoidCallback onRotateRight;
final VoidCallback onFlipHorizontal;
final VoidCallback onFlipVertical;
final void Function(double?) onAspectRatioSelected;
final double? aspectRatio;
const _TransformControls({
required this.onRotateLeft,
required this.onRotateRight,
required this.onFlipHorizontal,
required this.onFlipVertical,
required this.onAspectRatioSelected,
required this.aspectRatio,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(left: 20, right: 20, top: 20, bottom: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
ImmichIconButton(
icon: Icons.rotate_left,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onPressed: onRotateLeft,
),
const SizedBox(width: 8),
ImmichIconButton(
icon: Icons.rotate_right,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onPressed: onRotateRight,
),
],
),
Row(
children: [
ImmichIconButton(
icon: Icons.flip,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onPressed: onFlipHorizontal,
),
const SizedBox(width: 8),
Transform.rotate(
angle: pi / 2,
child: ImmichIconButton(
icon: Icons.flip,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onPressed: onFlipVertical,
),
),
],
),
],
),
),
_AspectRatioSelector(currentAspectRatio: aspectRatio, onAspectRatioSelected: onAspectRatioSelected),
const SizedBox(height: 32),
],
);
}
}
class _FilterControls extends StatelessWidget {
final EditFilter? currentFilter;
final Image previewImage;
final void Function(EditFilter?) onApplyFilter;
const _FilterControls({required this.currentFilter, required this.previewImage, required this.onApplyFilter});
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 24),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: filters.map((filter) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _FilterButton(
image: previewImage,
filter: filter,
isSelected: currentFilter == filter,
onTap: () => onApplyFilter(filter),
),
);
}).toList(),
),
),
),
);
}
}
class _FilterButton extends StatelessWidget {
final Image image;
final EditFilter filter;
final bool isSelected;
final VoidCallback onTap;
const _FilterButton({required this.image, required this.filter, required this.isSelected, required this.onTap});
@override
Widget build(BuildContext context) {
return Column(
children: [
GestureDetector(
onTap: onTap,
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(12)),
border: isSelected ? Border.all(color: context.primaryColor, width: 3) : null,
),
child: ClipRRect(
borderRadius: BorderRadius.all(isSelected ? const Radius.circular(9) : const Radius.circular(12)),
child: ColorFiltered(
colorFilter: filter.colorFilter,
child: Image(image: image.image, fit: BoxFit.cover),
),
),
),
),
const SizedBox(height: 10),
Text(filter.name, style: context.themeData.textTheme.bodyMedium),
],
);
}
}

View File

@@ -1,179 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:crop_image/crop_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart';
import 'package:immich_ui/immich_ui.dart';
/// A widget for cropping an image.
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
/// users to crop an image and then navigate to the [EditImagePage] with the
/// cropped image.
@RoutePage()
class DriftCropImagePage extends HookWidget {
final Image image;
final BaseAsset asset;
const DriftCropImagePage({super.key, required this.image, required this.asset});
@override
Widget build(BuildContext context) {
final cropController = useCropController();
final aspectRatio = useState<double?>(null);
return Scaffold(
appBar: AppBar(
backgroundColor: context.scaffoldBackgroundColor,
title: Text("crop".tr()),
leading: const ImmichCloseButton(),
actions: [
ImmichIconButton(
icon: Icons.done_rounded,
color: ImmichColor.primary,
variant: ImmichVariant.ghost,
onPressed: () async {
final croppedImage = await cropController.croppedImage();
unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true)));
},
),
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: SafeArea(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Column(
children: [
Container(
padding: const EdgeInsets.only(top: 20),
width: constraints.maxWidth * 0.9,
height: constraints.maxHeight * 0.6,
child: CropImage(controller: cropController, image: image, gridColor: Colors.white),
),
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: context.scaffoldBackgroundColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(left: 20, right: 20, bottom: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ImmichIconButton(
icon: Icons.rotate_left,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onPressed: () => cropController.rotateLeft(),
),
ImmichIconButton(
icon: Icons.rotate_right,
variant: ImmichVariant.ghost,
color: ImmichColor.secondary,
onPressed: () => cropController.rotateRight(),
),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: null,
label: 'Free',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 1.0,
label: '1:1',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 16.0 / 9.0,
label: '16:9',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 3.0 / 2.0,
label: '3:2',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 7.0 / 5.0,
label: '7:5',
),
],
),
],
),
),
),
),
],
);
},
),
),
);
}
}
class _AspectRatioButton extends StatelessWidget {
final CropController cropController;
final ValueNotifier<double?> aspectRatio;
final double? ratio;
final String label;
const _AspectRatioButton({
required this.cropController,
required this.aspectRatio,
required this.ratio,
required this.label,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(switch (label) {
'Free' => Icons.crop_free_rounded,
'1:1' => Icons.crop_square_rounded,
'16:9' => Icons.crop_16_9_rounded,
'3:2' => Icons.crop_3_2_rounded,
'7:5' => Icons.crop_7_5_rounded,
_ => Icons.crop_free_rounded,
}, color: aspectRatio.value == ratio ? context.primaryColor : context.themeData.iconTheme.color),
onPressed: () {
cropController.crop = const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9);
aspectRatio.value = ratio;
cropController.aspectRatio = ratio;
},
),
Text(label, style: context.textTheme.displayMedium),
],
);
}
}

View File

@@ -1,171 +0,0 @@
import 'dart:async';
import 'dart:ui';
import 'package:auto_route/auto_route.dart';
import 'package:cancellation_token_http/http.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
/// A stateless widget that provides functionality for editing an image.
///
/// This widget allows users to edit an image provided either as an [Asset] or
/// directly as an [Image]. It ensures that exactly one of these is provided.
///
/// It also includes a conversion method to convert an [Image] to a [Uint8List] to save the image on the user's phone
/// They automatically navigate to the [HomePage] with the edited image saved and they eventually get backed up to the server.
@immutable
@RoutePage()
class DriftEditImagePage extends ConsumerWidget {
final BaseAsset asset;
final Image image;
final bool isEdited;
const DriftEditImagePage({super.key, required this.asset, required this.image, required this.isEdited});
Future<Uint8List> _imageToUint8List(Image image) async {
final Completer<Uint8List> completer = Completer();
image.image
.resolve(const ImageConfiguration())
.addListener(
ImageStreamListener((ImageInfo info, bool _) {
info.image.toByteData(format: ImageByteFormat.png).then((byteData) {
if (byteData != null) {
completer.complete(byteData.buffer.asUint8List());
} else {
completer.completeError('Failed to convert image to bytes');
}
});
}, onError: (exception, stackTrace) => completer.completeError(exception)),
);
return completer.future;
}
void _exitEditing(BuildContext context) {
// this assumes that the only way to get to this page is from the AssetViewerRoute
context.navigator.popUntil((route) => route.data?.name == AssetViewerRoute.name);
}
Future<void> _saveEditedImage(BuildContext context, BaseAsset asset, Image image, WidgetRef ref) async {
try {
final Uint8List imageData = await _imageToUint8List(image);
LocalAsset? localAsset;
try {
localAsset = await ref
.read(fileMediaRepositoryProvider)
.saveLocalAsset(imageData, title: "${p.withoutExtension(asset.name)}_edited.jpg");
} on PlatformException catch (e) {
// OS might not return the saved image back, so we handle that gracefully
// This can happen if app does not have full library access
Logger("SaveEditedImage").warning("Failed to retrieve the saved image back from OS", e);
}
unawaited(ref.read(backgroundSyncProvider).syncLocal(full: true));
_exitEditing(context);
ImmichToast.show(durationInSecond: 3, context: context, msg: 'Image Saved!');
if (localAsset == null) {
return;
}
await ref.read(foregroundUploadServiceProvider).uploadManual([localAsset], CancellationToken());
} catch (e) {
ImmichToast.show(
durationInSecond: 6,
context: context,
msg: "error_saving_image".tr(namedArgs: {'error': e.toString()}),
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: Text("edit".tr()),
backgroundColor: context.scaffoldBackgroundColor,
leading: IconButton(
icon: Icon(Icons.close_rounded, color: context.primaryColor, size: 24),
onPressed: () => _exitEditing(context),
),
actions: <Widget>[
TextButton(
onPressed: isEdited ? () => _saveEditedImage(context, asset, image, ref) : null,
child: Text("save_to_gallery".tr(), style: TextStyle(color: isEdited ? context.primaryColor : Colors.grey)),
),
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: context.height * 0.7, maxWidth: context.width * 0.9),
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(7)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
spreadRadius: 2,
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(7)),
child: Image(image: image.image, fit: BoxFit.contain),
),
),
),
),
bottomNavigationBar: Container(
height: 70,
margin: const EdgeInsets.only(bottom: 60, right: 10, left: 10, top: 10),
decoration: BoxDecoration(
color: context.scaffoldBackgroundColor,
borderRadius: const BorderRadius.all(Radius.circular(30)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
IconButton(
icon: Icon(Icons.crop_rotate_rounded, color: context.themeData.iconTheme.color, size: 25),
onPressed: () {
context.pushRoute(DriftCropImageRoute(asset: asset, image: image));
},
),
Text("crop".tr(), style: context.textTheme.displayMedium),
],
),
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
IconButton(
icon: Icon(Icons.filter, color: context.themeData.iconTheme.color, size: 25),
onPressed: () {
context.pushRoute(DriftFilterImageRoute(asset: asset, image: image));
},
),
Text("filter".tr(), style: context.textTheme.displayMedium),
],
),
],
),
),
);
}
}

View File

@@ -1,159 +0,0 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/constants/filters.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/routing/router.dart';
/// A widget for filtering an image.
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
/// users to add filters to an image and then navigate to the [EditImagePage] with the
/// final composition.'
@RoutePage()
class DriftFilterImagePage extends HookWidget {
final Image image;
final BaseAsset asset;
const DriftFilterImagePage({super.key, required this.image, required this.asset});
@override
Widget build(BuildContext context) {
final colorFilter = useState<ColorFilter>(filters[0]);
final selectedFilterIndex = useState<int>(0);
Future<ui.Image> createFilteredImage(ui.Image inputImage, ColorFilter filter) {
final completer = Completer<ui.Image>();
final size = Size(inputImage.width.toDouble(), inputImage.height.toDouble());
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final paint = Paint()..colorFilter = filter;
canvas.drawImage(inputImage, Offset.zero, paint);
recorder.endRecording().toImage(size.width.round(), size.height.round()).then((image) {
completer.complete(image);
});
return completer.future;
}
void applyFilter(ColorFilter filter, int index) {
colorFilter.value = filter;
selectedFilterIndex.value = index;
}
Future<Image> applyFilterAndConvert(ColorFilter filter) async {
final completer = Completer<ui.Image>();
image.image
.resolve(ImageConfiguration.empty)
.addListener(
ImageStreamListener((ImageInfo info, bool _) {
completer.complete(info.image);
}),
);
final uiImage = await completer.future;
final filteredUiImage = await createFilteredImage(uiImage, filter);
final byteData = await filteredUiImage.toByteData(format: ui.ImageByteFormat.png);
final pngBytes = byteData!.buffer.asUint8List();
return Image.memory(pngBytes, fit: BoxFit.contain);
}
return Scaffold(
appBar: AppBar(
backgroundColor: context.scaffoldBackgroundColor,
title: Text("filter".tr()),
leading: CloseButton(color: context.primaryColor),
actions: [
IconButton(
icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24),
onPressed: () async {
final filteredImage = await applyFilterAndConvert(colorFilter.value);
unawaited(context.pushRoute(DriftEditImageRoute(asset: asset, image: filteredImage, isEdited: true)));
},
),
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: Column(
children: [
SizedBox(
height: context.height * 0.7,
child: Center(
child: ColorFiltered(colorFilter: colorFilter.value, child: image),
),
),
SizedBox(
height: 120,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: filters.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _FilterButton(
image: image,
label: filterNames[index],
filter: filters[index],
isSelected: selectedFilterIndex.value == index,
onTap: () => applyFilter(filters[index], index),
),
);
},
),
),
],
),
);
}
}
class _FilterButton extends StatelessWidget {
final Image image;
final String label;
final ColorFilter filter;
final bool isSelected;
final VoidCallback onTap;
const _FilterButton({
required this.image,
required this.label,
required this.filter,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
GestureDetector(
onTap: onTap,
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(10)),
border: isSelected ? Border.all(color: context.primaryColor, width: 3) : null,
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(10)),
child: ColorFiltered(
colorFilter: filter,
child: FittedBox(fit: BoxFit.cover, child: image),
),
),
),
),
const SizedBox(height: 10),
Text(label, style: context.themeData.textTheme.bodyMedium),
],
);
}
}

View File

@@ -18,6 +18,7 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_s
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';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/feature_check.dart';
@@ -30,6 +31,7 @@ import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dar
import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart';
import 'package:immich_mobile/widgets/search/search_filter/star_rating_picker.dart';
@RoutePage()
class DriftSearchPage extends HookConsumerWidget {
@@ -48,6 +50,7 @@ class DriftSearchPage extends HookConsumerWidget {
camera: preFilter?.camera ?? SearchCameraFilter(),
date: preFilter?.date ?? SearchDateFilter(),
display: preFilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: preFilter?.rating ?? SearchRatingFilter(),
mediaType: preFilter?.mediaType ?? AssetType.other,
language: "${context.locale.languageCode}-${context.locale.countryCode}",
assetId: preFilter?.assetId,
@@ -62,10 +65,15 @@ class DriftSearchPage extends HookConsumerWidget {
final cameraCurrentFilterWidget = useState<Widget?>(null);
final locationCurrentFilterWidget = useState<Widget?>(null);
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
final ratingCurrentFilterWidget = useState<Widget?>(null);
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
final isSearching = useState(false);
final isRatingEnabled = ref
.watch(userMetadataPreferencesProvider)
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
SnackBar searchInfoSnackBar(String message) {
return SnackBar(
content: Text(message, style: context.textTheme.labelLarge),
@@ -369,6 +377,35 @@ class DriftSearchPage extends HookConsumerWidget {
);
}
// STAR RATING PICKER
showStarRatingPicker() {
handleOnSelected(SearchRatingFilter rating) {
filter.value = filter.value.copyWith(rating: rating);
ratingCurrentFilterWidget.value = Text(
'rating_count'.t(args: {'count': rating.rating!}),
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(rating: SearchRatingFilter(rating: null));
ratingCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
child: FilterBottomSheetScaffold(
title: 'rating'.t(context: context),
onSearch: search,
onClear: handleClear,
child: StarRatingPicker(onSelect: handleOnSelected, filter: filter.value.rating),
),
);
}
// DISPLAY OPTION
showDisplayOptionPicker() {
handleOnSelect(Map<DisplayOption, bool> value) {
@@ -629,6 +666,14 @@ class DriftSearchPage extends HookConsumerWidget {
label: 'search_filter_media_type'.t(context: context),
currentFilter: mediaTypeCurrentFilterWidget.value,
),
if (isRatingEnabled) ...[
SearchFilterChip(
icon: Icons.star_outline_rounded,
onTap: showStarRatingPicker,
label: 'search_filter_star_rating'.t(context: context),
currentFilter: ratingCurrentFilterWidget.value,
),
],
SearchFilterChip(
icon: Icons.display_settings_outlined,
onTap: showDisplayOptionPicker,

View File

@@ -1,9 +1,12 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -14,13 +17,22 @@ class EditImageActionButton extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final currentAsset = ref.watch(currentAssetNotifier);
onPress() {
if (currentAsset == null) {
Future<void> onPress() async {
if (currentAsset == null || currentAsset.remoteId == null) {
return;
}
final image = Image(image: getFullImageProvider(currentAsset));
context.pushRoute(DriftEditImageRoute(asset: currentAsset, image: image, isEdited: false));
final imageProvider = getFullImageProvider(currentAsset, edited: false);
final image = Image(image: imageProvider);
final edits = await ref.read(remoteAssetRepositoryProvider).getAssetEdits(currentAsset.remoteId!);
final exifInfo = await ref.read(remoteAssetRepositoryProvider).getExif(currentAsset.remoteId!);
if (exifInfo == null) {
return;
}
await context.pushRoute(DriftEditImageRoute(asset: currentAsset, image: image, edits: edits, exifInfo: exifInfo));
}
return BaseActionButton(

View File

@@ -34,6 +34,7 @@ class SimilarPhotosActionButton extends ConsumerWidget {
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.image,
),
);

View File

@@ -3,12 +3,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
@@ -45,7 +45,7 @@ class ViewerBottomBar extends ConsumerWidget {
if (!isInLockedView) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (asset.type == AssetType.image) const EditImageActionButton(),
if (asset.isEditable) const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
if (isOwner) ...[

View File

@@ -16,11 +16,13 @@ import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -76,7 +78,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime);
final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime);
final timezone = 'GMT${timeZoneOffset.formatAsOffset()}';
return '$date$_kSeparator$time $timezone';
return '${exifInfo?.width}x${exifInfo?.height} $date$_kSeparator$time $timezone';
}
String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) {
@@ -204,6 +206,9 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
final cameraTitle = _getCameraInfoTitle(exifInfo);
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
final isRatingEnabled = ref
.watch(userMetadataPreferencesProvider)
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
// Build file info tile based on asset type
Widget buildFileInfoTile() {
@@ -283,6 +288,38 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
],
// Rating bar
if (isRatingEnabled) ...[
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
Text(
'rating'.t(context: context).toUpperCase(),
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
),
RatingBar(
initialRating: exifInfo?.rating?.toDouble() ?? 0,
filledColor: context.themeData.colorScheme.primary,
unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100),
itemSize: 40,
onRatingUpdate: (rating) async {
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round());
},
onClearRating: () async {
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0);
},
),
],
),
),
],
// Appears in (Albums)
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
// padding at the bottom to avoid cut-off

View File

@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
class RatingBar extends StatefulWidget {
final double initialRating;
final int itemCount;
final double itemSize;
final Color filledColor;
final Color unfilledColor;
final ValueChanged<int>? onRatingUpdate;
final VoidCallback? onClearRating;
final Widget? itemBuilder;
final double starPadding;
const RatingBar({
super.key,
this.initialRating = 0.0,
this.itemCount = 5,
this.itemSize = 40.0,
this.filledColor = Colors.amber,
this.unfilledColor = Colors.grey,
this.onRatingUpdate,
this.onClearRating,
this.itemBuilder,
this.starPadding = 4.0,
});
@override
State<RatingBar> createState() => _RatingBarState();
}
class _RatingBarState extends State<RatingBar> {
late double _currentRating;
@override
void initState() {
super.initState();
_currentRating = widget.initialRating;
}
void _updateRating(Offset localPosition, bool isRTL, {bool isTap = false}) {
final totalWidth = widget.itemCount * widget.itemSize + (widget.itemCount - 1) * widget.starPadding;
double dx = localPosition.dx;
if (isRTL) dx = totalWidth - dx;
double newRating;
if (dx <= 0) {
newRating = 0;
} else if (dx >= totalWidth) {
newRating = widget.itemCount.toDouble();
} else {
double starWithPadding = widget.itemSize + widget.starPadding;
int tappedIndex = (dx / starWithPadding).floor().clamp(0, widget.itemCount - 1);
newRating = tappedIndex + 1.0;
if (isTap && newRating == _currentRating && _currentRating != 0) {
newRating = 0;
}
}
if (_currentRating != newRating) {
setState(() {
_currentRating = newRating;
});
widget.onRatingUpdate?.call(newRating.round());
}
}
@override
Widget build(BuildContext context) {
final isRTL = Directionality.of(context) == TextDirection.rtl;
final double visualAlignmentOffset = 5.0;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Transform.translate(
offset: Offset(isRTL ? visualAlignmentOffset : -visualAlignmentOffset, 0),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (details) => _updateRating(details.localPosition, isRTL, isTap: true),
onPanUpdate: (details) => _updateRating(details.localPosition, isRTL, isTap: false),
child: Row(
mainAxisSize: MainAxisSize.min,
textDirection: isRTL ? TextDirection.rtl : TextDirection.ltr,
children: List.generate(widget.itemCount * 2 - 1, (i) {
if (i.isOdd) {
return SizedBox(width: widget.starPadding);
}
int index = i ~/ 2;
bool filled = _currentRating > index;
return widget.itemBuilder ??
Icon(
Icons.star_rounded,
size: widget.itemSize,
color: filled ? widget.filledColor : widget.unfilledColor,
);
}),
),
),
),
if (_currentRating > 0)
Padding(
padding: const EdgeInsets.only(top: 12.0),
child: GestureDetector(
onTap: () {
setState(() {
_currentRating = 0;
});
widget.onClearRating?.call();
},
child: Text(
'rating_clear'.t(context: context),
style: TextStyle(color: context.themeData.colorScheme.primary),
),
),
),
],
);
}
}

View File

@@ -102,7 +102,7 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
}
}
ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920)}) {
ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080, 1920), bool edited = true}) {
// Create new provider and cache it
final ImageProvider provider;
if (_shouldUseLocalAsset(asset)) {
@@ -120,13 +120,13 @@ ImageProvider getFullImageProvider(BaseAsset asset, {Size size = const Size(1080
} else {
throw ArgumentError("Unsupported asset type: ${asset.runtimeType}");
}
provider = RemoteFullImageProvider(assetId: assetId, thumbhash: thumbhash);
provider = RemoteFullImageProvider(assetId: assetId, thumbhash: thumbhash, assetType: asset.type, edited: edited);
}
return provider;
}
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution}) {
ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnailResolution, bool edited = true}) {
if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
return LocalThumbProvider(id: id, size: size, assetType: asset.type);
@@ -134,7 +134,7 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
final thumbhash = asset is RemoteAsset ? asset.thumbHash ?? "" : "";
return assetId != null ? RemoteThumbProvider(assetId: assetId, thumbhash: thumbhash) : null;
return assetId != null ? RemoteThumbProvider(assetId: assetId, thumbhash: thumbhash, edited: edited) : null;
}
bool _shouldUseLocalAsset(BaseAsset asset) =>

View File

@@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/infrastructure/loaders/image_request.dart';
@@ -13,8 +14,9 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
with CancellableImageProviderMixin<RemoteThumbProvider> {
final String assetId;
final String thumbhash;
final bool edited;
RemoteThumbProvider({required this.assetId, required this.thumbhash});
RemoteThumbProvider({required this.assetId, required this.thumbhash, this.edited = true});
@override
Future<RemoteThumbProvider> obtainKey(ImageConfiguration configuration) {
@@ -35,7 +37,7 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
Stream<ImageInfo> _codec(RemoteThumbProvider key, ImageDecoderCallback decode) {
final request = this.request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, thumbhash: key.thumbhash),
uri: getThumbnailUrlForRemoteId(key.assetId, thumbhash: key.thumbhash, edited: key.edited),
headers: ApiService.getRequestHeaders(),
);
return loadRequest(request, decode);
@@ -45,22 +47,29 @@ class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is RemoteThumbProvider) {
return assetId == other.assetId && thumbhash == other.thumbhash;
return assetId == other.assetId && thumbhash == other.thumbhash && edited == other.edited;
}
return false;
}
@override
int get hashCode => assetId.hashCode ^ thumbhash.hashCode;
int get hashCode => assetId.hashCode ^ thumbhash.hashCode ^ edited.hashCode;
}
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
with CancellableImageProviderMixin<RemoteFullImageProvider> {
final String assetId;
final String thumbhash;
final AssetType assetType;
final bool edited;
RemoteFullImageProvider({required this.assetId, required this.thumbhash});
RemoteFullImageProvider({
required this.assetId,
required this.thumbhash,
required this.assetType,
this.edited = true,
});
@override
Future<RemoteFullImageProvider> obtainKey(ImageConfiguration configuration) {
@@ -71,7 +80,9 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getInitialImage(RemoteThumbProvider(assetId: key.assetId, thumbhash: key.thumbhash)),
initialImage: getInitialImage(
RemoteThumbProvider(assetId: key.assetId, thumbhash: key.thumbhash, edited: key.edited),
),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
@@ -89,33 +100,43 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
}
final headers = ApiService.getRequestHeaders();
final request = this.request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview, thumbhash: key.thumbhash),
final previewRequest = request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(
key.assetId,
type: AssetMediaSize.preview,
thumbhash: key.thumbhash,
edited: key.edited,
),
headers: headers,
);
yield* loadRequest(request, decode);
yield* loadRequest(previewRequest, decode);
if (assetType != AssetType.image || !AppSetting.get(Setting.loadOriginal)) {
return;
}
if (isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
return;
}
if (AppSetting.get(Setting.loadOriginal)) {
final request = this.request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId), headers: headers);
yield* loadRequest(request, decode);
}
final originalRequest = request = RemoteImageRequest(
uri: getOriginalUrlForRemoteId(key.assetId, edited: key.edited),
headers: headers,
);
yield* loadRequest(originalRequest, decode);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is RemoteFullImageProvider) {
return assetId == other.assetId && thumbhash == other.thumbhash;
return assetId == other.assetId && thumbhash == other.thumbhash && edited == other.edited;
}
return false;
}
@override
int get hashCode => assetId.hashCode ^ thumbhash.hashCode;
int get hashCode => assetId.hashCode ^ thumbhash.hashCode ^ edited.hashCode;
}

View File

@@ -1,65 +1,150 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/cleanup.service.dart';
class CleanupState {
final DateTime? selectedDate;
final List<LocalAsset> assetsToDelete;
final int totalBytes;
final bool isScanning;
final bool isDeleting;
final AssetFilterType filterType;
final AssetKeepType keepMediaType;
final bool keepFavorites;
final Set<String> keepAlbumIds;
const CleanupState({
this.selectedDate,
this.assetsToDelete = const [],
this.totalBytes = 0,
this.isScanning = false,
this.isDeleting = false,
this.filterType = AssetFilterType.all,
this.keepMediaType = AssetKeepType.none,
this.keepFavorites = true,
this.keepAlbumIds = const {},
});
CleanupState copyWith({
DateTime? selectedDate,
List<LocalAsset>? assetsToDelete,
int? totalBytes,
bool? isScanning,
bool? isDeleting,
AssetFilterType? filterType,
AssetKeepType? keepMediaType,
bool? keepFavorites,
Set<String>? keepAlbumIds,
}) {
return CleanupState(
selectedDate: selectedDate ?? this.selectedDate,
assetsToDelete: assetsToDelete ?? this.assetsToDelete,
totalBytes: totalBytes ?? this.totalBytes,
isScanning: isScanning ?? this.isScanning,
isDeleting: isDeleting ?? this.isDeleting,
filterType: filterType ?? this.filterType,
keepMediaType: keepMediaType ?? this.keepMediaType,
keepFavorites: keepFavorites ?? this.keepFavorites,
keepAlbumIds: keepAlbumIds ?? this.keepAlbumIds,
);
}
}
final cleanupProvider = StateNotifierProvider<CleanupNotifier, CleanupState>((ref) {
return CleanupNotifier(ref.watch(cleanupServiceProvider), ref.watch(currentUserProvider)?.id);
return CleanupNotifier(
ref.watch(cleanupServiceProvider),
ref.watch(currentUserProvider)?.id,
ref.watch(appSettingsServiceProvider),
);
});
class CleanupNotifier extends StateNotifier<CleanupState> {
final CleanupService _cleanupService;
final String? _userId;
final AppSettingsService _appSettingsService;
CleanupNotifier(this._cleanupService, this._userId) : super(const CleanupState());
CleanupNotifier(this._cleanupService, this._userId, this._appSettingsService) : super(const CleanupState()) {
_loadPersistedSettings();
}
void _loadPersistedSettings() {
final keepFavorites = _appSettingsService.getSetting(AppSettingsEnum.cleanupKeepFavorites);
final keepMediaTypeIndex = _appSettingsService.getSetting(AppSettingsEnum.cleanupKeepMediaType);
final keepAlbumIdsString = _appSettingsService.getSetting(AppSettingsEnum.cleanupKeepAlbumIds);
final cutoffDaysAgo = _appSettingsService.getSetting(AppSettingsEnum.cleanupCutoffDaysAgo);
final keepMediaType = AssetKeepType.values[keepMediaTypeIndex.clamp(0, AssetKeepType.values.length - 1)];
final keepAlbumIds = keepAlbumIdsString.isEmpty ? <String>{} : keepAlbumIdsString.split(',').toSet();
final selectedDate = cutoffDaysAgo >= 0 ? DateTime.now().subtract(Duration(days: cutoffDaysAgo)) : null;
state = state.copyWith(
keepFavorites: keepFavorites,
keepMediaType: keepMediaType,
keepAlbumIds: keepAlbumIds,
selectedDate: selectedDate,
);
}
void setSelectedDate(DateTime? date) {
state = state.copyWith(selectedDate: date, assetsToDelete: []);
if (date != null) {
final daysAgo = DateTime.now().difference(date).inDays;
_appSettingsService.setSetting(AppSettingsEnum.cleanupCutoffDaysAgo, daysAgo);
}
}
void setFilterType(AssetFilterType filterType) {
state = state.copyWith(filterType: filterType, assetsToDelete: []);
void setKeepMediaType(AssetKeepType keepMediaType) {
state = state.copyWith(keepMediaType: keepMediaType, assetsToDelete: []);
_appSettingsService.setSetting(AppSettingsEnum.cleanupKeepMediaType, keepMediaType.index);
}
void setKeepFavorites(bool keepFavorites) {
state = state.copyWith(keepFavorites: keepFavorites, assetsToDelete: []);
_appSettingsService.setSetting(AppSettingsEnum.cleanupKeepFavorites, keepFavorites);
}
void toggleKeepAlbum(String albumId) {
final newKeepAlbumIds = Set<String>.from(state.keepAlbumIds);
if (newKeepAlbumIds.contains(albumId)) {
newKeepAlbumIds.remove(albumId);
} else {
newKeepAlbumIds.add(albumId);
}
state = state.copyWith(keepAlbumIds: newKeepAlbumIds, assetsToDelete: []);
_persistExcludedAlbumIds(newKeepAlbumIds);
}
void setExcludedAlbumIds(Set<String> albumIds) {
state = state.copyWith(keepAlbumIds: albumIds, assetsToDelete: []);
_persistExcludedAlbumIds(albumIds);
}
void _persistExcludedAlbumIds(Set<String> albumIds) {
_appSettingsService.setSetting(AppSettingsEnum.cleanupKeepAlbumIds, albumIds.join(','));
}
void cleanupStaleAlbumIds(Set<String> existingAlbumIds) {
final staleIds = state.keepAlbumIds.difference(existingAlbumIds);
if (staleIds.isNotEmpty) {
final cleanedIds = state.keepAlbumIds.intersection(existingAlbumIds);
state = state.copyWith(keepAlbumIds: cleanedIds);
_persistExcludedAlbumIds(cleanedIds);
}
}
void applyDefaultAlbumSelections(List<(String id, String name)> albums) {
final isInitialized = _appSettingsService.getSetting(AppSettingsEnum.cleanupDefaultsInitialized);
if (isInitialized) return;
final toKeep = _cleanupService.getDefaultKeepAlbumIds(albums);
if (toKeep.isNotEmpty) {
final keepAlbumIds = {...state.keepAlbumIds, ...toKeep};
state = state.copyWith(keepAlbumIds: keepAlbumIds);
_persistExcludedAlbumIds(keepAlbumIds);
}
_appSettingsService.setSetting(AppSettingsEnum.cleanupDefaultsInitialized, true);
}
Future<void> scanAssets() async {
@@ -69,13 +154,15 @@ class CleanupNotifier extends StateNotifier<CleanupState> {
state = state.copyWith(isScanning: true);
try {
final assets = await _cleanupService.getRemovalCandidates(
final result = await _cleanupService.getRemovalCandidates(
_userId,
state.selectedDate!,
filterType: state.filterType,
keepMediaType: state.keepMediaType,
keepFavorites: state.keepFavorites,
keepAlbumIds: state.keepAlbumIds,
);
state = state.copyWith(assetsToDelete: assets, isScanning: false);
state = state.copyWith(assetsToDelete: result.assets, totalBytes: result.totalBytes, isScanning: false);
} catch (e) {
state = state.copyWith(isScanning: false);
rethrow;
@@ -101,6 +188,7 @@ class CleanupNotifier extends StateNotifier<CleanupState> {
}
void reset() {
state = const CleanupState();
// Only reset transient state, keep the persisted filter settings
state = state.copyWith(selectedDate: null, assetsToDelete: [], isScanning: false, isDeleting: false);
}
}

View File

@@ -6,19 +6,20 @@ import 'package:cancellation_token_http/http.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/services/download.service.dart';
import 'package:immich_mobile/services/timeline.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/services/timeline.service.dart';
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -359,6 +360,22 @@ class ActionNotifier extends Notifier<void> {
}
}
Future<ActionResult> updateRating(ActionSource source, int rating) async {
final ids = _getRemoteIdsForSource(source);
if (ids.length != 1) {
_logger.warning('updateRating called with multiple assets, expected single asset');
return ActionResult(count: ids.length, success: false, error: 'Expected single asset for rating update');
}
try {
final isUpdated = await _service.updateRating(ids.first, rating);
return ActionResult(count: 1, success: isUpdated);
} catch (error, stack) {
_logger.severe('Failed to update rating for asset', error, stack);
return ActionResult(count: 1, success: false, error: error.toString());
}
}
Future<ActionResult> stack(String userId, ActionSource source) async {
final ids = _getOwnedRemoteIdsForSource(source);
try {
@@ -453,6 +470,23 @@ class ActionNotifier extends Notifier<void> {
});
}
}
Future<ActionResult> applyEdits(ActionSource source, List<AssetEdit> edits) async {
final ids = _getOwnedRemoteIdsForSource(source);
if (ids.length != 1) {
_logger.warning('applyEdits called with multiple assets, expected single asset');
return ActionResult(count: ids.length, success: false, error: 'Expected single asset for applying edits');
}
try {
await _service.applyEdits(ids.first, edits);
return const ActionResult(count: 1, success: true);
} catch (error, stack) {
_logger.severe('Failed to apply edits to assets', error, stack);
return ActionResult(count: ids.length, success: false, error: error.toString());
}
}
}
extension on Iterable<RemoteAsset> {

View File

@@ -1,7 +1,22 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
final userMetadataRepository = Provider<DriftUserMetadataRepository>(
(ref) => DriftUserMetadataRepository(ref.watch(driftProvider)),
);
final userMetadataProvider = FutureProvider<List<UserMetadata>>((ref) async {
final repository = ref.watch(userMetadataRepository);
final user = ref.watch(currentUserProvider);
if (user == null) return [];
return repository.getUserMetadata(user.id);
});
final userMetadataPreferencesProvider = FutureProvider<Preferences?>((ref) async {
final metadataList = await ref.watch(userMetadataProvider.future);
final metadataWithPrefs = metadataList.firstWhere((meta) => meta.preferences != null);
return metadataWithPrefs.preferences;
});

View File

@@ -206,6 +206,27 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
state.socket?.on('on_upload_success', _handleOnUploadSuccess);
}
Future<void> waitForEvent(String event, bool Function(dynamic)? predicate, Duration timeout) {
final completer = Completer<void>();
void handler(dynamic data) {
if (predicate == null || predicate(data)) {
completer.complete();
state.socket?.off(event, handler);
}
}
state.socket?.on(event, handler);
return completer.future.timeout(
timeout,
onTimeout: () {
state.socket?.off(event, handler);
throw TimeoutException("Timeout waiting for event: $event");
},
);
}
void addPendingChange(PendingAction action, dynamic value) {
final now = DateTime.now();
state = state.copyWith(

View File

@@ -1,12 +1,13 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/stack.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/api.repository.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:openapi/api.dart';
import 'package:openapi/api.dart' hide AssetEditAction;
final assetApiRepositoryProvider = Provider(
(ref) => AssetApiRepository(
@@ -80,8 +81,8 @@ class AssetApiRepository extends ApiRepository {
return _stacksApi.deleteStacks(BulkIdsDto(ids: ids));
}
Future<Response> downloadAsset(String id) {
return _api.downloadAssetWithHttpInfo(id);
Future<Response> downloadAsset(String id, {required bool edited}) {
return _api.downloadAssetWithHttpInfo(id, edited: edited);
}
_mapVisibility(AssetVisibilityEnum visibility) => switch (visibility) {
@@ -101,6 +102,29 @@ class AssetApiRepository extends ApiRepository {
Future<void> updateDescription(String assetId, String description) {
return _api.updateAsset(assetId, UpdateAssetDto(description: description));
}
Future<void> updateRating(String assetId, int rating) {
return _api.updateAsset(assetId, UpdateAssetDto(rating: rating));
}
Future<void> editAsset(String assetId, List<AssetEdit> edits) async {
final editDtos = edits
.map((edit) {
if (edit.action == AssetEditAction.other) {
return null;
}
return AssetEditActionListDtoEditsInner(action: edit.action.toDto()!, parameters: edit.parameters);
})
.whereType<AssetEditActionListDtoEditsInner>()
.toList();
await _api.editAsset(assetId, AssetEditActionListDto(edits: editDtos));
}
Future<void> removeEdits(String assetId) async {
await _api.removeAssetEdits(assetId);
}
}
extension on StackResponseDto {

View File

@@ -112,17 +112,23 @@ class AssetMediaRepository {
: asset is RemoteAsset
? asset.localId
: null;
if (localId != null) {
if (localId != null && !asset.isEdited) {
File? f = await AssetEntity(id: localId, width: 1, height: 1, typeInt: 0).originFile;
downloadedXFiles.add(XFile(f!.path));
if (CurrentPlatform.isIOS) {
tempFiles.add(f);
}
} else if (asset is RemoteAsset) {
} else {
final remoteId = (asset is RemoteAsset) ? asset.id : asset.remoteId;
if (remoteId == null) {
_log.warning("Asset has no remote ID for sharing: $asset");
continue;
}
final tempDir = await getTemporaryDirectory();
final name = asset.name;
final tempFile = await File('${tempDir.path}/$name').create();
final res = await _assetApiRepository.downloadAsset(asset.id);
final res = await _assetApiRepository.downloadAsset(remoteId, edited: true);
if (res.statusCode != 200) {
_log.severe("Download for $name failed", res.toLoggerString());
@@ -132,9 +138,6 @@ class AssetMediaRepository {
await tempFile.writeAsBytes(res.bodyBytes);
downloadedXFiles.add(XFile(tempFile.path));
tempFiles.add(tempFile);
} else {
_log.warning("Asset type not supported for sharing: $asset");
continue;
}
}

View File

@@ -4,6 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
@@ -78,6 +80,7 @@ import 'package:immich_mobile/pages/search/recently_taken.page.dart';
import 'package:immich_mobile/pages/search/search.page.dart';
import 'package:immich_mobile/pages/settings/sync_status.page.dart';
import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart';
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
import 'package:immich_mobile/presentation/pages/dev/ui_showcase.page.dart';
@@ -88,8 +91,8 @@ import 'package:immich_mobile/presentation/pages/drift_album_options.page.dart';
import 'package:immich_mobile/presentation/pages/drift_archive.page.dart';
import 'package:immich_mobile/presentation/pages/drift_asset_selection_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/drift_asset_troubleshoot.page.dart';
import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart';
import 'package:immich_mobile/presentation/pages/drift_create_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_edit.page.dart';
import 'package:immich_mobile/presentation/pages/drift_favorite.page.dart';
import 'package:immich_mobile/presentation/pages/drift_library.page.dart';
import 'package:immich_mobile/presentation/pages/drift_local_album.page.dart';
@@ -106,9 +109,6 @@ import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
import 'package:immich_mobile/presentation/pages/editing/drift_crop.page.dart';
import 'package:immich_mobile/presentation/pages/editing/drift_edit.page.dart';
import 'package:immich_mobile/presentation/pages/editing/drift_filter.page.dart';
import 'package:immich_mobile/presentation/pages/local_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/search/drift_search.page.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
@@ -332,8 +332,6 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftAlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftMapRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftEditImageRoute.page),
AutoRoute(page: DriftCropImageRoute.page),
AutoRoute(page: DriftFilterImageRoute.page),
AutoRoute(page: DriftActivitiesRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftBackupAssetDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: AssetTroubleshootRoute.page, guards: [_authGuard, _duplicateGuard]),

View File

@@ -982,70 +982,24 @@ class DriftCreateAlbumRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [DriftCropImagePage]
class DriftCropImageRoute extends PageRouteInfo<DriftCropImageRouteArgs> {
DriftCropImageRoute({
Key? key,
required Image image,
required BaseAsset asset,
List<PageRouteInfo>? children,
}) : super(
DriftCropImageRoute.name,
args: DriftCropImageRouteArgs(key: key, image: image, asset: asset),
initialChildren: children,
);
static const String name = 'DriftCropImageRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<DriftCropImageRouteArgs>();
return DriftCropImagePage(
key: args.key,
image: args.image,
asset: args.asset,
);
},
);
}
class DriftCropImageRouteArgs {
const DriftCropImageRouteArgs({
this.key,
required this.image,
required this.asset,
});
final Key? key;
final Image image;
final BaseAsset asset;
@override
String toString() {
return 'DriftCropImageRouteArgs{key: $key, image: $image, asset: $asset}';
}
}
/// generated route for
/// [DriftEditImagePage]
class DriftEditImageRoute extends PageRouteInfo<DriftEditImageRouteArgs> {
DriftEditImageRoute({
Key? key,
required BaseAsset asset,
required Image image,
required bool isEdited,
required BaseAsset asset,
required List<AssetEdit> edits,
required ExifInfo exifInfo,
List<PageRouteInfo>? children,
}) : super(
DriftEditImageRoute.name,
args: DriftEditImageRouteArgs(
key: key,
asset: asset,
image: image,
isEdited: isEdited,
asset: asset,
edits: edits,
exifInfo: exifInfo,
),
initialChildren: children,
);
@@ -1058,9 +1012,10 @@ class DriftEditImageRoute extends PageRouteInfo<DriftEditImageRouteArgs> {
final args = data.argsAs<DriftEditImageRouteArgs>();
return DriftEditImagePage(
key: args.key,
asset: args.asset,
image: args.image,
isEdited: args.isEdited,
asset: args.asset,
edits: args.edits,
exifInfo: args.exifInfo,
);
},
);
@@ -1069,22 +1024,25 @@ class DriftEditImageRoute extends PageRouteInfo<DriftEditImageRouteArgs> {
class DriftEditImageRouteArgs {
const DriftEditImageRouteArgs({
this.key,
required this.asset,
required this.image,
required this.isEdited,
required this.asset,
required this.edits,
required this.exifInfo,
});
final Key? key;
final BaseAsset asset;
final Image image;
final bool isEdited;
final BaseAsset asset;
final List<AssetEdit> edits;
final ExifInfo exifInfo;
@override
String toString() {
return 'DriftEditImageRouteArgs{key: $key, asset: $asset, image: $image, isEdited: $isEdited}';
return 'DriftEditImageRouteArgs{key: $key, image: $image, asset: $asset, edits: $edits, exifInfo: $exifInfo}';
}
}
@@ -1104,54 +1062,6 @@ class DriftFavoriteRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [DriftFilterImagePage]
class DriftFilterImageRoute extends PageRouteInfo<DriftFilterImageRouteArgs> {
DriftFilterImageRoute({
Key? key,
required Image image,
required BaseAsset asset,
List<PageRouteInfo>? children,
}) : super(
DriftFilterImageRoute.name,
args: DriftFilterImageRouteArgs(key: key, image: image, asset: asset),
initialChildren: children,
);
static const String name = 'DriftFilterImageRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<DriftFilterImageRouteArgs>();
return DriftFilterImagePage(
key: args.key,
image: args.image,
asset: args.asset,
);
},
);
}
class DriftFilterImageRouteArgs {
const DriftFilterImageRouteArgs({
this.key,
required this.image,
required this.asset,
});
final Key? key;
final Image image;
final BaseAsset asset;
@override
String toString() {
return 'DriftFilterImageRouteArgs{key: $key, image: $image, asset: $asset}';
}
}
/// generated route for
/// [DriftLibraryPage]
class DriftLibraryRoute extends PageRouteInfo<void> {

View File

@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
@@ -214,6 +215,14 @@ class ActionService {
return true;
}
Future<bool> updateRating(String assetId, int rating) async {
// update remote first, then local to ensure consistency
await _assetApiRepository.updateRating(assetId, rating);
await _remoteAssetRepository.updateRating(assetId, rating);
return true;
}
Future<void> stack(String userId, List<String> remoteIds) async {
final stack = await _assetApiRepository.stack(remoteIds);
await _remoteAssetRepository.stack(userId, stack);
@@ -232,6 +241,16 @@ class ActionService {
return _downloadRepository.downloadAllAssets(assets);
}
Future<void> applyEdits(String remoteId, List<AssetEdit> edits) async {
if (edits.isEmpty) {
await _assetApiRepository.removeEdits(remoteId);
} else {
await _assetApiRepository.editAsset(remoteId, edits);
}
await _remoteAssetRepository.editAsset(remoteId, edits);
}
Future<int> _deleteLocalAssets(List<String> localIds) async {
final deletedIds = await _assetMediaRepository.deleteAll(localIds);
if (deletedIds.isEmpty) {

View File

@@ -54,7 +54,12 @@ enum AppSettingsEnum<T> {
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false),
albumGridView<bool>(StoreKey.albumGridView, "albumGridView", false),
backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false),
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30);
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30),
cleanupKeepFavorites<bool>(StoreKey.cleanupKeepFavorites, null, true),
cleanupKeepMediaType<int>(StoreKey.cleanupKeepMediaType, null, 0),
cleanupKeepAlbumIds<String>(StoreKey.cleanupKeepAlbumIds, null, ""),
cleanupCutoffDaysAgo<int>(StoreKey.cleanupCutoffDaysAgo, null, -1),
cleanupDefaultsInitialized<bool>(StoreKey.cleanupDefaultsInitialized, null, false);
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);

View File

@@ -1,6 +1,5 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
@@ -15,17 +14,19 @@ class CleanupService {
const CleanupService(this._localAssetRepository, this._assetMediaRepository);
Future<List<LocalAsset>> getRemovalCandidates(
Future<RemovalCandidatesResult> getRemovalCandidates(
String userId,
DateTime cutoffDate, {
AssetFilterType filterType = AssetFilterType.all,
AssetKeepType keepMediaType = AssetKeepType.none,
bool keepFavorites = true,
Set<String> keepAlbumIds = const {},
}) {
return _localAssetRepository.getRemovalCandidates(
userId,
cutoffDate,
filterType: filterType,
keepMediaType: keepMediaType,
keepFavorites: keepFavorites,
keepAlbumIds: keepAlbumIds,
);
}
@@ -42,4 +43,18 @@ class CleanupService {
return 0;
}
/// Returns album IDs that should be kept by default (e.g., messaging app albums)
Set<String> getDefaultKeepAlbumIds(List<(String id, String name)> albums) {
const messagingApps = ['whatsapp', 'telegram', 'signal', 'messenger', 'viber', 'wechat', 'line'];
final toKeep = <String>{};
for (final (id, name) in albums) {
final albumName = name.toLowerCase();
if (messagingApps.any((app) => albumName.contains(app))) {
toKeep.add(id);
}
}
return toKeep;
}
}

View File

@@ -0,0 +1,124 @@
import 'dart:async';
import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/utils/matrix.utils.dart';
import 'package:openapi/api.dart' hide AssetEditAction;
Rect convertCropParametersToRect(CropParameters parameters, int originalWidth, int originalHeight) {
return Rect.fromLTWH(
parameters.x.toDouble() / originalWidth,
parameters.y.toDouble() / originalHeight,
parameters.width.toDouble() / originalWidth,
parameters.height.toDouble() / originalHeight,
);
}
CropParameters convertRectToCropParameters(Rect rect, int originalWidth, int originalHeight) {
final x = (rect.left * originalWidth).round();
final y = (rect.top * originalHeight).round();
final width = (rect.width * originalWidth).round();
final height = (rect.height * originalHeight).round();
return CropParameters(
x: max(x, 0).clamp(0, originalWidth),
y: max(y, 0).clamp(0, originalHeight),
width: max(width, 0).clamp(0, originalWidth - x),
height: max(height, 0).clamp(0, originalHeight - y),
);
}
AffineMatrix buildAffineFromEdits(List<AssetEdit> edits) {
return AffineMatrix.compose(
edits.map<AffineMatrix>((edit) {
switch (edit.action) {
case AssetEditAction.rotate:
final angleInDegrees = edit.parameters["angle"] as num;
final angleInRadians = angleInDegrees * pi / 180;
return AffineMatrix.rotate(angleInRadians);
case AssetEditAction.mirror:
final axis = edit.parameters["axis"] as String;
return axis == "horizontal" ? AffineMatrix.flipY() : AffineMatrix.flipX();
default:
return AffineMatrix.identity();
}
}).toList(),
);
}
(double, bool, bool) normalizeTransformEdits(List<AssetEdit> edits) {
double rotation = 0;
bool flipX = false;
bool flipY = false;
final matrix = buildAffineFromEdits(edits);
// round to avoid floating point precision issues
int a = matrix.a.round();
int b = matrix.b.round();
int c = matrix.c.round();
int d = matrix.d.round();
// [ +/-1, 0, 0, +/-1 ] indicates a 0° or 180° rotation with possible mirrors
// [ 0, +/-1, +/-1, 0 ] indicates a 90° or 270° rotation with possible mirrors
if (a.abs() == 1 && b.abs() == 0 && c.abs() == 0 && d.abs() == 1) {
rotation = a > 0 ? 0 : 180;
flipX = rotation == 0 ? a < 0 : a > 0;
flipY = rotation == 0 ? d < 0 : d > 0;
} else if (a.abs() == 0 && b.abs() == 1 && c.abs() == 1 && d.abs() == 0) {
rotation = c > 0 ? 90 : 270;
flipX = rotation == 90 ? c < 0 : c > 0;
flipY = rotation == 90 ? b > 0 : b < 0;
}
return (rotation, flipX, flipY);
}
class MatrixAdjustmentPainter extends CustomPainter {
final ui.Image image;
final ColorFilter? filter;
const MatrixAdjustmentPainter({required this.image, this.filter});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..colorFilter = filter;
final srcRect = Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble());
final dstRect = Rect.fromLTWH(0, 0, size.width, size.height);
canvas.drawImageRect(image, srcRect, dstRect, paint);
}
@override
bool shouldRepaint(covariant MatrixAdjustmentPainter oldDelegate) {
return oldDelegate.image != image || oldDelegate.filter != filter;
}
}
/// Helper to resolve an ImageProvider to a ui.Image
Future<ui.Image> resolveImage(ImageProvider provider) {
final completer = Completer<ui.Image>();
final stream = provider.resolve(const ImageConfiguration());
late final ImageStreamListener listener;
listener = ImageStreamListener(
(ImageInfo info, bool sync) {
if (!completer.isCompleted) {
completer.complete(info.image);
}
stream.removeListener(listener);
},
onError: (error, stackTrace) {
if (!completer.isCompleted) {
completer.completeError(error, stackTrace);
}
stream.removeListener(listener);
},
);
stream.addListener(listener);
return completer.future;
}

View File

@@ -1,8 +1,14 @@
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:crop_image/crop_image.dart';
import 'dart:ui'; // Import the dart:ui library for Rect
import 'package:crop_image/crop_image.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
/// A hook that provides a [CropController] instance.
CropController useCropController() {
return useMemoized(() => CropController(defaultCrop: const Rect.fromLTRB(0, 0, 1, 1)));
CropController useCropController({Rect? initialCrop, CropRotation? initialRotation}) {
return useMemoized(
() => CropController(
defaultCrop: initialCrop ?? const Rect.fromLTRB(0, 0, 1, 1),
rotation: initialRotation ?? CropRotation.up,
),
);
}

View File

@@ -0,0 +1,50 @@
import 'dart:math';
class AffineMatrix {
final double a;
final double b;
final double c;
final double d;
final double e;
final double f;
const AffineMatrix(this.a, this.b, this.c, this.d, this.e, this.f);
@override
String toString() {
return 'AffineMatrix(a: $a, b: $b, c: $c, d: $d, e: $e, f: $f)';
}
factory AffineMatrix.identity() {
return const AffineMatrix(1, 0, 0, 1, 0, 0);
}
AffineMatrix multiply(AffineMatrix other) {
return AffineMatrix(
a * other.a + c * other.b,
b * other.a + d * other.b,
a * other.c + c * other.d,
b * other.c + d * other.d,
a * other.e + c * other.f + e,
b * other.e + d * other.f + f,
);
}
factory AffineMatrix.compose([List<AffineMatrix> transformations = const []]) {
return transformations.fold<AffineMatrix>(AffineMatrix.identity(), (acc, matrix) => acc.multiply(matrix));
}
factory AffineMatrix.rotate(double angle) {
final cosAngle = cos(angle);
final sinAngle = sin(angle);
return AffineMatrix(cosAngle, -sinAngle, sinAngle, cosAngle, 0, 0);
}
factory AffineMatrix.flipY() {
return const AffineMatrix(-1, 0, 0, 1, 0, 0);
}
factory AffineMatrix.flipX() {
return const AffineMatrix(1, 0, 0, -1, 0, 0);
}
}

View File

@@ -88,6 +88,7 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
if (version < 20 && Store.isBetaTimelineEnabled) {
await _syncLocalAlbumIsIosSharedAlbum(drift);
await _backfillAssetExifWidthHeight(drift);
}
if (targetVersion >= 12) {
@@ -281,6 +282,22 @@ Future<void> _syncLocalAlbumIsIosSharedAlbum(Drift db) async {
}
}
Future<void> _backfillAssetExifWidthHeight(Drift db) async {
try {
await db.customStatement('''
UPDATE remote_exif_entity AS remote_exif
SET width = asset.width,
height = asset.height
FROM remote_asset_entity AS asset
WHERE remote_exif.asset_id = asset.id;
''');
dPrint(() => "[MIGRATION] Successfully backfilled asset exif width and height");
} catch (error) {
dPrint(() => "[MIGRATION] Error while backfilling asset exif width and height: $error");
}
}
Future<void> migrateDeviceAssetToSqlite(Isar db, Drift drift) async {
try {
final isarDeviceAssets = await db.deviceAssetEntitys.where().findAll();

View File

@@ -16,6 +16,7 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da
import 'package:immich_mobile/providers/locale_provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/pages/common/settings.page.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_profile_info.dart';
@@ -87,6 +88,14 @@ class ImmichAppBarDialog extends HookConsumerWidget {
return buildActionButton(Icons.settings_outlined, "settings", () => context.pushRoute(const SettingsRoute()));
}
buildFreeUpSpaceButton() {
return buildActionButton(
Icons.cleaning_services_outlined,
"free_up_space",
() => context.pushRoute(SettingsSubRoute(section: SettingSection.freeUpSpace)),
);
}
buildAppLogButton() {
return buildActionButton(
Icons.assignment_outlined,
@@ -271,6 +280,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
const AppBarServerInfo(),
if (Store.isBetaTimelineEnabled && isReadonlyModeEnabled) buildReadonlyMessage(),
buildAppLogButton(),
buildFreeUpSpaceButton(),
buildSettingButton(),
buildSignOutButton(),
buildFooter(),

View File

@@ -55,6 +55,7 @@ class ExploreGrid extends StatelessWidget {
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.other,
),
),

View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
class StarRatingPicker extends HookWidget {
const StarRatingPicker({super.key, required this.onSelect, this.filter});
final Function(SearchRatingFilter) onSelect;
final SearchRatingFilter? filter;
@override
Widget build(BuildContext context) {
final selectedRating = useState(filter);
return RadioGroup(
groupValue: selectedRating.value?.rating,
onChanged: (int? newValue) {
if (newValue == null) return;
final newFilter = SearchRatingFilter(rating: newValue);
selectedRating.value = newFilter;
onSelect(newFilter);
},
child: Column(
children: List.generate(
6,
(index) => RadioListTile<int>(
key: Key("star_$index"),
title: Text('rating_count'.t(args: {'count': (index)})),
value: index,
),
),
),
);
}
}

View File

@@ -192,6 +192,7 @@ class AdvancedSettings extends HookConsumerWidget {
);
},
),
const SizedBox(height: 60),
];
return SettingsSubPageScaffold(settings: advancedSettings);

View File

@@ -3,13 +3,16 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/cleanup.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
class FreeUpSpaceSettings extends ConsumerStatefulWidget {
const FreeUpSpaceSettings({super.key});
@@ -21,6 +24,25 @@ class FreeUpSpaceSettings extends ConsumerStatefulWidget {
class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
CleanupStep _currentStep = CleanupStep.selectDate;
bool _hasScanned = false;
bool _isKeepSettingsExpanded = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_initializeAlbumDefaults();
});
}
Future<void> _initializeAlbumDefaults() async {
final albums = await ref.read(localAlbumProvider.future);
final existingAlbumIds = albums.map((a) => a.id).toSet();
final albumsWithNames = albums.map((a) => (a.id, a.name)).toList();
final notifier = ref.read(cleanupProvider.notifier);
notifier.applyDefaultAlbumSelections(albumsWithNames);
notifier.cleanupStaleAlbumIds(existingAlbumIds);
}
void _resetState() {
ref.read(cleanupProvider.notifier).reset();
@@ -35,20 +57,16 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
}
if (state.selectedDate != null) {
return CleanupStep.filterOptions;
return CleanupStep.scan;
}
return CleanupStep.selectDate;
}
void _goToFiltersStep() {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
setState(() => _currentStep = CleanupStep.filterOptions);
}
void _goToScanStep() {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
setState(() => _currentStep = CleanupStep.scan);
_scanAssets();
}
void _setPresetDate(int daysAgo) {
@@ -83,9 +101,17 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
if (picked != null) {
ref.read(cleanupProvider.notifier).setSelectedDate(picked);
setState(() => _hasScanned = false);
}
}
void _onKeepSettingsChanged() {
setState(() {
_hasScanned = false;
_currentStep = CleanupStep.scan;
});
}
Future<void> _scanAssets() async {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
@@ -127,6 +153,11 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
context: context,
builder: (ctx) => _DeleteSuccessDialog(deletedCount: deletedCount),
);
if (mounted) {
context.router.popUntilRoot();
}
return;
}
setState(() => _currentStep = CleanupStep.selectDate);
@@ -145,6 +176,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
final subtitleStyle = context.textTheme.bodyMedium!.copyWith(
color: context.textTheme.bodyMedium!.color!.withAlpha(215),
);
StepStyle styleForState(StepState stepState, {bool isDestructive = false}) {
switch (stepState) {
case StepState.complete:
@@ -174,28 +206,38 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
}
final step1State = hasDate ? StepState.complete : StepState.indexed;
final step2State = hasDate ? StepState.complete : StepState.disabled;
final step3State = hasAssets
final step2State = hasAssets
? StepState.complete
: hasDate
? StepState.indexed
: StepState.disabled;
final step4State = hasAssets ? StepState.indexed : StepState.disabled;
final step3State = hasAssets ? StepState.indexed : StepState.disabled;
String getFilterSubtitle() {
final hasKeepSettings =
state.keepFavorites || state.keepAlbumIds.isNotEmpty || state.keepMediaType != AssetKeepType.none;
String getKeepSettingsSummary() {
final parts = <String>[];
switch (state.filterType) {
case AssetFilterType.all:
parts.add('all'.t(context: context));
case AssetFilterType.photosOnly:
parts.add('photos_only'.t(context: context));
case AssetFilterType.videosOnly:
parts.add('videos_only'.t(context: context));
if (state.keepMediaType == AssetKeepType.photosOnly) {
parts.add('all_photos'.t(context: context));
} else if (state.keepMediaType == AssetKeepType.videosOnly) {
parts.add('all_videos'.t(context: context));
}
if (state.keepFavorites) {
parts.add('keep_favorites'.t(context: context));
parts.add('favorites'.t(context: context).toLowerCase());
}
return parts.join('');
if (state.keepAlbumIds.isNotEmpty) {
parts.add('keep_albums_count'.t(context: context, args: {'count': state.keepAlbumIds.length.toString()}));
}
if (parts.isEmpty) {
return 'none'.t(context: context);
}
return parts.join(', ');
}
return PopScope(
@@ -220,6 +262,126 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
),
),
// Keep on device settings card
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)),
side: BorderSide(
color: hasKeepSettings
? context.colorScheme.primary.withValues(alpha: 0.5)
: context.colorScheme.outlineVariant,
width: hasKeepSettings ? 1.5 : 1,
),
),
color: hasKeepSettings
? context.colorScheme.primaryContainer.withValues(alpha: 0.15)
: context.colorScheme.surfaceContainerLow,
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
initiallyExpanded: _isKeepSettingsExpanded,
onExpansionChanged: (expanded) {
setState(() => _isKeepSettingsExpanded = expanded);
},
leading: Icon(
hasKeepSettings ? Icons.bookmark : Icons.bookmark_border,
color: hasKeepSettings ? context.colorScheme.primary : context.colorScheme.onSurfaceVariant,
),
title: Text(
'keep_on_device'.t(context: context),
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: hasKeepSettings ? context.colorScheme.primary : null,
),
),
subtitle: Text(
hasKeepSettings
? 'keeping'.t(context: context, args: {'items': getKeepSettingsSummary()})
: 'keep_on_device_hint'.t(context: context),
style: context.textTheme.bodySmall?.copyWith(
color: hasKeepSettings ? context.colorScheme.primary : context.colorScheme.onSurfaceVariant,
),
),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('keep_description'.t(context: context), style: subtitleStyle),
const SizedBox(height: 4),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(
'keep_favorites'.t(context: context),
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
),
value: state.keepFavorites,
onChanged: (value) {
ref.read(cleanupProvider.notifier).setKeepFavorites(value);
_onKeepSettingsChanged();
},
),
const SizedBox(height: 8),
_KeepAlbumsSection(
albumIds: state.keepAlbumIds,
onAlbumToggled: (albumId) {
ref.read(cleanupProvider.notifier).toggleKeepAlbum(albumId);
_onKeepSettingsChanged();
},
),
const SizedBox(height: 16),
Text(
'always_keep'.t(context: context),
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
),
const SizedBox(height: 4),
SegmentedButton<AssetKeepType>(
showSelectedIcon: false,
segments: [
const ButtonSegment(value: AssetKeepType.none, label: Text('')),
ButtonSegment(
value: AssetKeepType.photosOnly,
label: Text('photos'.t(context: context)),
icon: const Icon(Icons.photo),
),
ButtonSegment(
value: AssetKeepType.videosOnly,
label: Text('videos'.t(context: context)),
icon: const Icon(Icons.videocam),
),
],
selected: {state.keepMediaType},
onSelectionChanged: (selection) {
ref.read(cleanupProvider.notifier).setKeepMediaType(selection.first);
_onKeepSettingsChanged();
},
),
if (state.keepMediaType != AssetKeepType.none) ...[
const SizedBox(height: 8),
Text(
state.keepMediaType == AssetKeepType.photosOnly
? 'always_keep_photos_hint'.t(context: context)
: 'always_keep_videos_hint'.t(context: context),
style: context.textTheme.bodySmall?.copyWith(
color: context.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
],
),
),
),
),
const SizedBox(height: 8),
Stepper(
physics: const NeverScrollableScrollPhysics(),
currentStep: _currentStep.index,
@@ -314,7 +476,7 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: hasDate ? () => _goToFiltersStep() : null,
onPressed: hasDate ? _goToScanStep : null,
icon: const Icon(Icons.arrow_forward),
label: Text('continue'.t(context: context)),
style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)),
@@ -325,11 +487,11 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
state: step1State,
),
// Step 2: Select Filter Options
// Step 2: Scan Assets
Step(
stepStyle: styleForState(step2State),
title: Text(
'filter_options'.t(context: context),
'scan'.t(context: context),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: step2State == StepState.complete
@@ -339,96 +501,20 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
: context.colorScheme.onSurface,
),
),
subtitle: hasDate
? Text(
getFilterSubtitle(),
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.primary,
fontWeight: FontWeight.w500,
),
)
: null,
content: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('cleanup_filter_description'.t(context: context), style: subtitleStyle),
const SizedBox(height: 16),
SegmentedButton<AssetFilterType>(
segments: [
ButtonSegment(
value: AssetFilterType.all,
label: Text('all'.t(context: context)),
icon: const Icon(Icons.photo_library),
),
ButtonSegment(
value: AssetFilterType.photosOnly,
label: Text('photos'.t(context: context)),
icon: const Icon(Icons.photo),
),
ButtonSegment(
value: AssetFilterType.videosOnly,
label: Text('videos'.t(context: context)),
icon: const Icon(Icons.videocam),
),
],
selected: {state.filterType},
onSelectionChanged: (selection) {
ref.read(cleanupProvider.notifier).setFilterType(selection.first);
setState(() => _hasScanned = false);
},
),
const SizedBox(height: 16),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(
'keep_favorites'.t(context: context),
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
),
subtitle: Text(
'keep_favorites_description'.t(context: context),
style: context.textTheme.bodyMedium!.copyWith(
color: context.textTheme.bodyMedium!.color!.withAlpha(215),
),
),
value: state.keepFavorites,
onChanged: (value) {
ref.read(cleanupProvider.notifier).setKeepFavorites(value);
setState(() => _hasScanned = false);
},
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _goToScanStep,
icon: const Icon(Icons.arrow_forward),
label: Text('continue'.t(context: context)),
style: ElevatedButton.styleFrom(minimumSize: const Size(double.infinity, 48)),
),
],
),
isActive: hasDate,
state: step2State,
),
// Step 3: Scan Assets
Step(
stepStyle: styleForState(step3State),
title: Text(
'scan'.t(context: context),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: step3State == StepState.complete
? context.colorScheme.primary
: step3State == StepState.disabled
? context.colorScheme.onSurface.withValues(alpha: 0.38)
: context.colorScheme.onSurface,
),
),
subtitle: _hasScanned
? Text(
'cleanup_found_assets'.t(
context: context,
args: {'count': state.assetsToDelete.length.toString()},
),
state.totalBytes > 0
? 'cleanup_found_assets_with_size'.t(
context: context,
args: {
'count': state.assetsToDelete.length.toString(),
'size': formatBytes(state.totalBytes),
},
)
: 'cleanup_found_assets'.t(
context: context,
args: {'count': state.assetsToDelete.length.toString()},
),
style: context.textTheme.bodyMedium?.copyWith(
color: state.assetsToDelete.isNotEmpty
? context.colorScheme.primary
@@ -503,17 +589,17 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
],
),
isActive: hasDate,
state: step3State,
state: step2State,
),
// Step 4: Delete Assets
// Step 3: Delete Assets
Step(
stepStyle: styleForState(step4State, isDestructive: true),
stepStyle: styleForState(step3State, isDestructive: true),
title: Text(
'move_to_device_trash'.t(context: context),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: step4State == StepState.disabled
color: step3State == StepState.disabled
? context.colorScheme.onSurface.withValues(alpha: 0.38)
: context.colorScheme.error,
),
@@ -529,15 +615,20 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
border: Border.all(color: context.colorScheme.error.withValues(alpha: 0.3)),
),
child: hasAssets
? Text(
'cleanup_step4_summary'.t(
context: context,
args: {
'count': state.assetsToDelete.length.toString(),
'date': DateFormat.yMMMd().format(state.selectedDate!),
},
),
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'cleanup_step4_summary'.t(
context: context,
args: {
'count': state.assetsToDelete.length.toString(),
'date': DateFormat.yMMMd().format(state.selectedDate!),
},
),
style: context.textTheme.labelLarge?.copyWith(fontSize: 15),
),
],
)
: null,
),
@@ -573,10 +664,11 @@ class _FreeUpSpaceSettingsState extends ConsumerState<FreeUpSpaceSettings> {
],
),
isActive: hasAssets,
state: step4State,
state: step3State,
),
],
),
const SizedBox(height: 60),
],
),
),
@@ -701,3 +793,107 @@ class _DatePresetCard extends StatelessWidget {
);
}
}
class _KeepAlbumsSection extends ConsumerWidget {
final Set<String> albumIds;
final ValueChanged<String> onAlbumToggled;
const _KeepAlbumsSection({required this.albumIds, required this.onAlbumToggled});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albumsAsync = ref.watch(localAlbumProvider);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'keep_albums'.t(context: context),
style: context.textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w500, height: 1.5),
),
const SizedBox(height: 8),
albumsAsync.when(
loading: () => const Center(
child: Padding(padding: EdgeInsets.all(16.0), child: CircularProgressIndicator(strokeWidth: 2)),
),
error: (error, stack) => Text(
'error_loading_albums'.t(context: context),
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.error),
),
data: (albums) {
if (albums.isEmpty) {
return Text(
'no_albums_found'.t(context: context),
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
),
);
}
return Container(
decoration: BoxDecoration(
border: Border.all(color: context.colorScheme.outlineVariant),
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
constraints: const BoxConstraints(maxHeight: 200),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: ListView.builder(
shrinkWrap: true,
itemCount: albums.length,
itemBuilder: (context, index) {
final album = albums[index];
final isSelected = albumIds.contains(album.id);
return _AlbumTile(album: album, isSelected: isSelected, onToggle: () => onAlbumToggled(album.id));
},
),
),
);
},
),
if (albumIds.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'keep_albums_count'.t(context: context, args: {'count': albumIds.length.toString()}),
style: context.textTheme.bodySmall?.copyWith(
color: context.colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
],
],
);
}
}
class _AlbumTile extends StatelessWidget {
final LocalAlbum album;
final bool isSelected;
final VoidCallback onToggle;
const _AlbumTile({required this.album, required this.isSelected, required this.onToggle});
@override
Widget build(BuildContext context) {
return ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
leading: Icon(
isSelected ? Icons.check_circle : Icons.circle_outlined,
color: isSelected ? context.colorScheme.primary : context.colorScheme.onSurfaceVariant,
size: 20,
),
title: Text(
album.name,
style: context.textTheme.bodyMedium?.copyWith(color: isSelected ? context.colorScheme.primary : null),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Text(
album.assetCount.toString(),
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant),
),
onTap: onToggle,
);
}
}

View File

@@ -358,6 +358,7 @@ Class | Method | HTTP request | Description
- [AssetDeltaSyncResponseDto](doc//AssetDeltaSyncResponseDto.md)
- [AssetEditAction](doc//AssetEditAction.md)
- [AssetEditActionCrop](doc//AssetEditActionCrop.md)
- [AssetEditActionFilter](doc//AssetEditActionFilter.md)
- [AssetEditActionListDto](doc//AssetEditActionListDto.md)
- [AssetEditActionListDtoEditsInner](doc//AssetEditActionListDtoEditsInner.md)
- [AssetEditActionMirror](doc//AssetEditActionMirror.md)
@@ -427,6 +428,7 @@ Class | Method | HTTP request | Description
- [ExifResponseDto](doc//ExifResponseDto.md)
- [FaceDto](doc//FaceDto.md)
- [FacialRecognitionConfig](doc//FacialRecognitionConfig.md)
- [FilterParameters](doc//FilterParameters.md)
- [FoldersResponse](doc//FoldersResponse.md)
- [FoldersUpdate](doc//FoldersUpdate.md)
- [ImageFormat](doc//ImageFormat.md)
@@ -574,6 +576,8 @@ Class | Method | HTTP request | Description
- [SyncAlbumUserV1](doc//SyncAlbumUserV1.md)
- [SyncAlbumV1](doc//SyncAlbumV1.md)
- [SyncAssetDeleteV1](doc//SyncAssetDeleteV1.md)
- [SyncAssetEditDeleteV1](doc//SyncAssetEditDeleteV1.md)
- [SyncAssetEditV1](doc//SyncAssetEditV1.md)
- [SyncAssetExifV1](doc//SyncAssetExifV1.md)
- [SyncAssetFaceDeleteV1](doc//SyncAssetFaceDeleteV1.md)
- [SyncAssetFaceV1](doc//SyncAssetFaceV1.md)

View File

@@ -98,6 +98,7 @@ part 'model/asset_delta_sync_dto.dart';
part 'model/asset_delta_sync_response_dto.dart';
part 'model/asset_edit_action.dart';
part 'model/asset_edit_action_crop.dart';
part 'model/asset_edit_action_filter.dart';
part 'model/asset_edit_action_list_dto.dart';
part 'model/asset_edit_action_list_dto_edits_inner.dart';
part 'model/asset_edit_action_mirror.dart';
@@ -167,6 +168,7 @@ part 'model/email_notifications_update.dart';
part 'model/exif_response_dto.dart';
part 'model/face_dto.dart';
part 'model/facial_recognition_config.dart';
part 'model/filter_parameters.dart';
part 'model/folders_response.dart';
part 'model/folders_update.dart';
part 'model/image_format.dart';
@@ -314,6 +316,8 @@ part 'model/sync_album_user_delete_v1.dart';
part 'model/sync_album_user_v1.dart';
part 'model/sync_album_v1.dart';
part 'model/sync_asset_delete_v1.dart';
part 'model/sync_asset_edit_delete_v1.dart';
part 'model/sync_asset_edit_v1.dart';
part 'model/sync_asset_exif_v1.dart';
part 'model/sync_asset_face_delete_v1.dart';
part 'model/sync_asset_face_v1.dart';

View File

@@ -1691,7 +1691,7 @@ class AssetsApi {
/// View asset thumbnail
///
/// Retrieve the thumbnail image for the specified asset.
/// Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.
///
/// Note: This method returns the HTTP [Response].
///
@@ -1747,7 +1747,7 @@ class AssetsApi {
/// View asset thumbnail
///
/// Retrieve the thumbnail image for the specified asset.
/// Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.
///
/// Parameters:
///

View File

@@ -242,6 +242,8 @@ class ApiClient {
return AssetEditActionTypeTransformer().decode(value);
case 'AssetEditActionCrop':
return AssetEditActionCrop.fromJson(value);
case 'AssetEditActionFilter':
return AssetEditActionFilter.fromJson(value);
case 'AssetEditActionListDto':
return AssetEditActionListDto.fromJson(value);
case 'AssetEditActionListDtoEditsInner':
@@ -380,6 +382,8 @@ class ApiClient {
return FaceDto.fromJson(value);
case 'FacialRecognitionConfig':
return FacialRecognitionConfig.fromJson(value);
case 'FilterParameters':
return FilterParameters.fromJson(value);
case 'FoldersResponse':
return FoldersResponse.fromJson(value);
case 'FoldersUpdate':
@@ -674,6 +678,10 @@ class ApiClient {
return SyncAlbumV1.fromJson(value);
case 'SyncAssetDeleteV1':
return SyncAssetDeleteV1.fromJson(value);
case 'SyncAssetEditDeleteV1':
return SyncAssetEditDeleteV1.fromJson(value);
case 'SyncAssetEditV1':
return SyncAssetEditV1.fromJson(value);
case 'SyncAssetExifV1':
return SyncAssetExifV1.fromJson(value);
case 'SyncAssetFaceDeleteV1':

View File

@@ -26,12 +26,14 @@ class AssetEditAction {
static const crop = AssetEditAction._(r'crop');
static const rotate = AssetEditAction._(r'rotate');
static const mirror = AssetEditAction._(r'mirror');
static const filter = AssetEditAction._(r'filter');
/// List of all possible values in this [enum][AssetEditAction].
static const values = <AssetEditAction>[
crop,
rotate,
mirror,
filter,
];
static AssetEditAction? fromJson(dynamic value) => AssetEditActionTypeTransformer().decode(value);
@@ -73,6 +75,7 @@ class AssetEditActionTypeTransformer {
case r'crop': return AssetEditAction.crop;
case r'rotate': return AssetEditAction.rotate;
case r'mirror': return AssetEditAction.mirror;
case r'filter': return AssetEditAction.filter;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');

View File

@@ -0,0 +1,107 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AssetEditActionFilter {
/// Returns a new [AssetEditActionFilter] instance.
AssetEditActionFilter({
required this.action,
required this.parameters,
});
AssetEditAction action;
FilterParameters parameters;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionFilter &&
other.action == action &&
other.parameters == parameters;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(action.hashCode) +
(parameters.hashCode);
@override
String toString() => 'AssetEditActionFilter[action=$action, parameters=$parameters]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'action'] = this.action;
json[r'parameters'] = this.parameters;
return json;
}
/// Returns a new [AssetEditActionFilter] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static AssetEditActionFilter? fromJson(dynamic value) {
upgradeDto(value, "AssetEditActionFilter");
if (value is Map) {
final json = value.cast<String, dynamic>();
return AssetEditActionFilter(
action: AssetEditAction.fromJson(json[r'action'])!,
parameters: FilterParameters.fromJson(json[r'parameters'])!,
);
}
return null;
}
static List<AssetEditActionFilter> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AssetEditActionFilter>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = AssetEditActionFilter.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, AssetEditActionFilter> mapFromJson(dynamic json) {
final map = <String, AssetEditActionFilter>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = AssetEditActionFilter.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of AssetEditActionFilter-objects as value to a dart map
static Map<String, List<AssetEditActionFilter>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AssetEditActionFilter>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AssetEditActionFilter.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'action',
'parameters',
};
}

View File

@@ -19,7 +19,7 @@ class AssetEditActionListDtoEditsInner {
AssetEditAction action;
MirrorParameters parameters;
Map<String, dynamic> parameters;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionListDtoEditsInner &&
@@ -52,7 +52,7 @@ class AssetEditActionListDtoEditsInner {
return AssetEditActionListDtoEditsInner(
action: AssetEditAction.fromJson(json[r'action'])!,
parameters: MirrorParameters.fromJson(json[r'parameters'])!,
parameters: json[r'parameters'],
);
}
return null;

View File

@@ -23,12 +23,14 @@ class AssetMediaSize {
String toJson() => value;
static const original = AssetMediaSize._(r'original');
static const fullsize = AssetMediaSize._(r'fullsize');
static const preview = AssetMediaSize._(r'preview');
static const thumbnail = AssetMediaSize._(r'thumbnail');
/// List of all possible values in this [enum][AssetMediaSize].
static const values = <AssetMediaSize>[
original,
fullsize,
preview,
thumbnail,
@@ -70,6 +72,7 @@ class AssetMediaSizeTypeTransformer {
AssetMediaSize? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'original': return AssetMediaSize.original;
case r'fullsize': return AssetMediaSize.fullsize;
case r'preview': return AssetMediaSize.preview;
case r'thumbnail': return AssetMediaSize.thumbnail;

View File

@@ -0,0 +1,208 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class FilterParameters {
/// Returns a new [FilterParameters] instance.
FilterParameters({
required this.bOffset,
required this.bbBias,
required this.bgBias,
required this.brBias,
required this.gOffset,
required this.gbBias,
required this.ggBias,
required this.grBias,
required this.rOffset,
required this.rbBias,
required this.rgBias,
required this.rrBias,
});
/// B Offset (-255 -> 255)
///
/// Minimum value: -255
/// Maximum value: 255
num bOffset;
/// BB Bias
num bbBias;
/// BG Bias
num bgBias;
/// BR Bias
num brBias;
/// G Offset (-255 -> 255)
///
/// Minimum value: -255
/// Maximum value: 255
num gOffset;
/// GB Bias
num gbBias;
/// GG Bias
num ggBias;
/// GR Bias
num grBias;
/// R Offset (-255 -> 255)
///
/// Minimum value: -255
/// Maximum value: 255
num rOffset;
/// RB Bias
num rbBias;
/// RG Bias
num rgBias;
/// RR Bias
num rrBias;
@override
bool operator ==(Object other) => identical(this, other) || other is FilterParameters &&
other.bOffset == bOffset &&
other.bbBias == bbBias &&
other.bgBias == bgBias &&
other.brBias == brBias &&
other.gOffset == gOffset &&
other.gbBias == gbBias &&
other.ggBias == ggBias &&
other.grBias == grBias &&
other.rOffset == rOffset &&
other.rbBias == rbBias &&
other.rgBias == rgBias &&
other.rrBias == rrBias;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(bOffset.hashCode) +
(bbBias.hashCode) +
(bgBias.hashCode) +
(brBias.hashCode) +
(gOffset.hashCode) +
(gbBias.hashCode) +
(ggBias.hashCode) +
(grBias.hashCode) +
(rOffset.hashCode) +
(rbBias.hashCode) +
(rgBias.hashCode) +
(rrBias.hashCode);
@override
String toString() => 'FilterParameters[bOffset=$bOffset, bbBias=$bbBias, bgBias=$bgBias, brBias=$brBias, gOffset=$gOffset, gbBias=$gbBias, ggBias=$ggBias, grBias=$grBias, rOffset=$rOffset, rbBias=$rbBias, rgBias=$rgBias, rrBias=$rrBias]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'bOffset'] = this.bOffset;
json[r'bbBias'] = this.bbBias;
json[r'bgBias'] = this.bgBias;
json[r'brBias'] = this.brBias;
json[r'gOffset'] = this.gOffset;
json[r'gbBias'] = this.gbBias;
json[r'ggBias'] = this.ggBias;
json[r'grBias'] = this.grBias;
json[r'rOffset'] = this.rOffset;
json[r'rbBias'] = this.rbBias;
json[r'rgBias'] = this.rgBias;
json[r'rrBias'] = this.rrBias;
return json;
}
/// Returns a new [FilterParameters] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static FilterParameters? fromJson(dynamic value) {
upgradeDto(value, "FilterParameters");
if (value is Map) {
final json = value.cast<String, dynamic>();
return FilterParameters(
bOffset: num.parse('${json[r'bOffset']}'),
bbBias: num.parse('${json[r'bbBias']}'),
bgBias: num.parse('${json[r'bgBias']}'),
brBias: num.parse('${json[r'brBias']}'),
gOffset: num.parse('${json[r'gOffset']}'),
gbBias: num.parse('${json[r'gbBias']}'),
ggBias: num.parse('${json[r'ggBias']}'),
grBias: num.parse('${json[r'grBias']}'),
rOffset: num.parse('${json[r'rOffset']}'),
rbBias: num.parse('${json[r'rbBias']}'),
rgBias: num.parse('${json[r'rgBias']}'),
rrBias: num.parse('${json[r'rrBias']}'),
);
}
return null;
}
static List<FilterParameters> listFromJson(dynamic json, {bool growable = false,}) {
final result = <FilterParameters>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = FilterParameters.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, FilterParameters> mapFromJson(dynamic json) {
final map = <String, FilterParameters>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = FilterParameters.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of FilterParameters-objects as value to a dart map
static Map<String, List<FilterParameters>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<FilterParameters>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = FilterParameters.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'bOffset',
'bbBias',
'bgBias',
'brBias',
'gOffset',
'gbBias',
'ggBias',
'grBias',
'rOffset',
'rbBias',
'rgBias',
'rrBias',
};
}

View File

@@ -72,6 +72,7 @@ class Permission {
static const facePeriodRead = Permission._(r'face.read');
static const facePeriodUpdate = Permission._(r'face.update');
static const facePeriodDelete = Permission._(r'face.delete');
static const folderPeriodRead = Permission._(r'folder.read');
static const jobPeriodCreate = Permission._(r'job.create');
static const jobPeriodRead = Permission._(r'job.read');
static const libraryPeriodCreate = Permission._(r'library.create');
@@ -230,6 +231,7 @@ class Permission {
facePeriodRead,
facePeriodUpdate,
facePeriodDelete,
folderPeriodRead,
jobPeriodCreate,
jobPeriodRead,
libraryPeriodCreate,
@@ -423,6 +425,7 @@ class PermissionTypeTransformer {
case r'face.read': return Permission.facePeriodRead;
case r'face.update': return Permission.facePeriodUpdate;
case r'face.delete': return Permission.facePeriodDelete;
case r'folder.read': return Permission.folderPeriodRead;
case r'job.create': return Permission.jobPeriodCreate;
case r'job.read': return Permission.jobPeriodRead;
case r'library.create': return Permission.libraryPeriodCreate;

View File

@@ -0,0 +1,99 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SyncAssetEditDeleteV1 {
/// Returns a new [SyncAssetEditDeleteV1] instance.
SyncAssetEditDeleteV1({
required this.assetId,
});
String assetId;
@override
bool operator ==(Object other) => identical(this, other) || other is SyncAssetEditDeleteV1 &&
other.assetId == assetId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(assetId.hashCode);
@override
String toString() => 'SyncAssetEditDeleteV1[assetId=$assetId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'assetId'] = this.assetId;
return json;
}
/// Returns a new [SyncAssetEditDeleteV1] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SyncAssetEditDeleteV1? fromJson(dynamic value) {
upgradeDto(value, "SyncAssetEditDeleteV1");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SyncAssetEditDeleteV1(
assetId: mapValueOfType<String>(json, r'assetId')!,
);
}
return null;
}
static List<SyncAssetEditDeleteV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncAssetEditDeleteV1>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SyncAssetEditDeleteV1.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SyncAssetEditDeleteV1> mapFromJson(dynamic json) {
final map = <String, SyncAssetEditDeleteV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SyncAssetEditDeleteV1.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SyncAssetEditDeleteV1-objects as value to a dart map
static Map<String, List<SyncAssetEditDeleteV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncAssetEditDeleteV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SyncAssetEditDeleteV1.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'assetId',
};
}

View File

@@ -0,0 +1,131 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SyncAssetEditV1 {
/// Returns a new [SyncAssetEditV1] instance.
SyncAssetEditV1({
required this.action,
required this.assetId,
required this.id,
required this.parameters,
required this.sequence,
});
AssetEditAction action;
String assetId;
String id;
Object parameters;
int sequence;
@override
bool operator ==(Object other) => identical(this, other) || other is SyncAssetEditV1 &&
other.action == action &&
other.assetId == assetId &&
other.id == id &&
other.parameters == parameters &&
other.sequence == sequence;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(action.hashCode) +
(assetId.hashCode) +
(id.hashCode) +
(parameters.hashCode) +
(sequence.hashCode);
@override
String toString() => 'SyncAssetEditV1[action=$action, assetId=$assetId, id=$id, parameters=$parameters, sequence=$sequence]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'action'] = this.action;
json[r'assetId'] = this.assetId;
json[r'id'] = this.id;
json[r'parameters'] = this.parameters;
json[r'sequence'] = this.sequence;
return json;
}
/// Returns a new [SyncAssetEditV1] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SyncAssetEditV1? fromJson(dynamic value) {
upgradeDto(value, "SyncAssetEditV1");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SyncAssetEditV1(
action: AssetEditAction.fromJson(json[r'action'])!,
assetId: mapValueOfType<String>(json, r'assetId')!,
id: mapValueOfType<String>(json, r'id')!,
parameters: mapValueOfType<Object>(json, r'parameters')!,
sequence: mapValueOfType<int>(json, r'sequence')!,
);
}
return null;
}
static List<SyncAssetEditV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncAssetEditV1>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SyncAssetEditV1.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SyncAssetEditV1> mapFromJson(dynamic json) {
final map = <String, SyncAssetEditV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SyncAssetEditV1.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SyncAssetEditV1-objects as value to a dart map
static Map<String, List<SyncAssetEditV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncAssetEditV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SyncAssetEditV1.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'action',
'assetId',
'id',
'parameters',
'sequence',
};
}

View File

@@ -29,6 +29,8 @@ class SyncEntityType {
static const assetV1 = SyncEntityType._(r'AssetV1');
static const assetDeleteV1 = SyncEntityType._(r'AssetDeleteV1');
static const assetExifV1 = SyncEntityType._(r'AssetExifV1');
static const assetEditV1 = SyncEntityType._(r'AssetEditV1');
static const assetEditDeleteV1 = SyncEntityType._(r'AssetEditDeleteV1');
static const assetMetadataV1 = SyncEntityType._(r'AssetMetadataV1');
static const assetMetadataDeleteV1 = SyncEntityType._(r'AssetMetadataDeleteV1');
static const partnerV1 = SyncEntityType._(r'PartnerV1');
@@ -79,6 +81,8 @@ class SyncEntityType {
assetV1,
assetDeleteV1,
assetExifV1,
assetEditV1,
assetEditDeleteV1,
assetMetadataV1,
assetMetadataDeleteV1,
partnerV1,
@@ -164,6 +168,8 @@ class SyncEntityTypeTypeTransformer {
case r'AssetV1': return SyncEntityType.assetV1;
case r'AssetDeleteV1': return SyncEntityType.assetDeleteV1;
case r'AssetExifV1': return SyncEntityType.assetExifV1;
case r'AssetEditV1': return SyncEntityType.assetEditV1;
case r'AssetEditDeleteV1': return SyncEntityType.assetEditDeleteV1;
case r'AssetMetadataV1': return SyncEntityType.assetMetadataV1;
case r'AssetMetadataDeleteV1': return SyncEntityType.assetMetadataDeleteV1;
case r'PartnerV1': return SyncEntityType.partnerV1;

View File

@@ -30,6 +30,7 @@ class SyncRequestType {
static const albumAssetExifsV1 = SyncRequestType._(r'AlbumAssetExifsV1');
static const assetsV1 = SyncRequestType._(r'AssetsV1');
static const assetExifsV1 = SyncRequestType._(r'AssetExifsV1');
static const assetEditsV1 = SyncRequestType._(r'AssetEditsV1');
static const assetMetadataV1 = SyncRequestType._(r'AssetMetadataV1');
static const authUsersV1 = SyncRequestType._(r'AuthUsersV1');
static const memoriesV1 = SyncRequestType._(r'MemoriesV1');
@@ -53,6 +54,7 @@ class SyncRequestType {
albumAssetExifsV1,
assetsV1,
assetExifsV1,
assetEditsV1,
assetMetadataV1,
authUsersV1,
memoriesV1,
@@ -111,6 +113,7 @@ class SyncRequestTypeTypeTransformer {
case r'AlbumAssetExifsV1': return SyncRequestType.albumAssetExifsV1;
case r'AssetsV1': return SyncRequestType.assetsV1;
case r'AssetExifsV1': return SyncRequestType.assetExifsV1;
case r'AssetEditsV1': return SyncRequestType.assetEditsV1;
case r'AssetMetadataV1': return SyncRequestType.assetMetadataV1;
case r'AuthUsersV1': return SyncRequestType.authUsersV1;
case r'MemoriesV1': return SyncRequestType.memoriesV1;

View File

@@ -15,6 +15,7 @@ class SystemConfigGeneratedFullsizeImageDto {
SystemConfigGeneratedFullsizeImageDto({
required this.enabled,
required this.format,
this.progressive = false,
required this.quality,
});
@@ -22,6 +23,8 @@ class SystemConfigGeneratedFullsizeImageDto {
ImageFormat format;
bool progressive;
/// Minimum value: 1
/// Maximum value: 100
int quality;
@@ -30,6 +33,7 @@ class SystemConfigGeneratedFullsizeImageDto {
bool operator ==(Object other) => identical(this, other) || other is SystemConfigGeneratedFullsizeImageDto &&
other.enabled == enabled &&
other.format == format &&
other.progressive == progressive &&
other.quality == quality;
@override
@@ -37,15 +41,17 @@ class SystemConfigGeneratedFullsizeImageDto {
// ignore: unnecessary_parenthesis
(enabled.hashCode) +
(format.hashCode) +
(progressive.hashCode) +
(quality.hashCode);
@override
String toString() => 'SystemConfigGeneratedFullsizeImageDto[enabled=$enabled, format=$format, quality=$quality]';
String toString() => 'SystemConfigGeneratedFullsizeImageDto[enabled=$enabled, format=$format, progressive=$progressive, quality=$quality]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'enabled'] = this.enabled;
json[r'format'] = this.format;
json[r'progressive'] = this.progressive;
json[r'quality'] = this.quality;
return json;
}
@@ -61,6 +67,7 @@ class SystemConfigGeneratedFullsizeImageDto {
return SystemConfigGeneratedFullsizeImageDto(
enabled: mapValueOfType<bool>(json, r'enabled')!,
format: ImageFormat.fromJson(json[r'format'])!,
progressive: mapValueOfType<bool>(json, r'progressive') ?? false,
quality: mapValueOfType<int>(json, r'quality')!,
);
}

View File

@@ -14,12 +14,15 @@ class SystemConfigGeneratedImageDto {
/// Returns a new [SystemConfigGeneratedImageDto] instance.
SystemConfigGeneratedImageDto({
required this.format,
this.progressive = false,
required this.quality,
required this.size,
});
ImageFormat format;
bool progressive;
/// Minimum value: 1
/// Maximum value: 100
int quality;
@@ -30,6 +33,7 @@ class SystemConfigGeneratedImageDto {
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigGeneratedImageDto &&
other.format == format &&
other.progressive == progressive &&
other.quality == quality &&
other.size == size;
@@ -37,15 +41,17 @@ class SystemConfigGeneratedImageDto {
int get hashCode =>
// ignore: unnecessary_parenthesis
(format.hashCode) +
(progressive.hashCode) +
(quality.hashCode) +
(size.hashCode);
@override
String toString() => 'SystemConfigGeneratedImageDto[format=$format, quality=$quality, size=$size]';
String toString() => 'SystemConfigGeneratedImageDto[format=$format, progressive=$progressive, quality=$quality, size=$size]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'format'] = this.format;
json[r'progressive'] = this.progressive;
json[r'quality'] = this.quality;
json[r'size'] = this.size;
return json;
@@ -61,6 +67,7 @@ class SystemConfigGeneratedImageDto {
return SystemConfigGeneratedImageDto(
format: ImageFormat.fromJson(json[r'format'])!,
progressive: mapValueOfType<bool>(json, r'progressive') ?? false,
quality: mapValueOfType<int>(json, r'quality')!,
size: mapValueOfType<int>(json, r'size')!,
);

View File

@@ -297,14 +297,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: ae0db647e668cbb295a3527f0938e4039e004c80099dce2f964102373f5ce0b5
url: "https://pub.dev"
source: hosted
version: "0.19.10"
code_builder:
dependency: transitive
description:
@@ -912,14 +904,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.8.1"
hooks:
dependency: transitive
description:
name: hooks
sha256: "5410b9f4f6c9f01e8ff0eb81c9801ea13a3c3d39f8f0b1613cda08e27eab3c18"
url: "https://pub.dev"
source: hosted
version: "0.20.5"
hooks_riverpod:
dependency: "direct main"
description:
@@ -1265,10 +1249,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.16.0"
mime:
dependency: transitive
description:
@@ -1285,14 +1269,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.4"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: f8872ea6c7a50ce08db9ae280ca2b8efdd973157ce462826c82f3c3051d154ce
url: "https://pub.dev"
source: hosted
version: "0.17.2"
native_video_player:
dependency: "direct main"
description:
@@ -1330,10 +1306,10 @@ packages:
dependency: transitive
description:
name: objective_c
sha256: "55eb67ede1002d9771b3f9264d2c9d30bc364f0267bc1c6cc0883280d5f0c7cb"
sha256: "1f81ed9e41909d44162d7ec8663b2c647c202317cc0b56d3d56f6a13146a0b64"
url: "https://pub.dev"
source: hosted
version: "9.2.2"
version: "9.1.0"
octo_image:
dependency: "direct main"
description:
@@ -1966,10 +1942,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
url: "https://pub.dev"
source: hosted
version: "0.7.7"
version: "0.7.6"
thumbhash:
dependency: "direct main"
description:
@@ -2259,5 +2235,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.10.0 <4.0.0"
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.35.7"

View File

@@ -20,6 +20,7 @@ import 'schema_v14.dart' as v14;
import 'schema_v15.dart' as v15;
import 'schema_v16.dart' as v16;
import 'schema_v17.dart' as v17;
import 'schema_v18.dart' as v18;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -59,6 +60,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v16.DatabaseAtV16(db);
case 17:
return v17.DatabaseAtV17(db);
case 18:
return v18.DatabaseAtV18(db);
default:
throw MissingSchemaException(version, versions);
}
@@ -82,5 +85,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
15,
16,
17,
18,
];
}

File diff suppressed because it is too large Load Diff

View File

@@ -167,10 +167,10 @@ void main() {
);
await insertRemoteAsset(id: 'remote-6', checksum: 'checksum-6', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate, keepFavorites: true);
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepFavorites: true);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-1');
expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-1');
});
test('includes favorites when keepFavorites is false', () async {
@@ -183,15 +183,15 @@ void main() {
);
await insertRemoteAsset(id: 'remote-favorite', checksum: 'checksum-fav', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate, keepFavorites: false);
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepFavorites: false);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-favorite');
expect(candidates[0].isFavorite, true);
expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-favorite');
expect(result.assets[0].isFavorite, true);
});
test('filters by photos only', () async {
// Photo
test('keepMediaType photosOnly returns only videos for deletion', () async {
// Photo - should be kept
await insertLocalAsset(
id: 'local-photo',
checksum: 'checksum-photo',
@@ -201,7 +201,7 @@ void main() {
);
await insertRemoteAsset(id: 'remote-photo', checksum: 'checksum-photo', ownerId: userId);
// Video
// Video - should be deleted
await insertLocalAsset(
id: 'local-video',
checksum: 'checksum-video',
@@ -211,19 +211,19 @@ void main() {
);
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
final candidates = await repository.getRemovalCandidates(
final result = await repository.getRemovalCandidates(
userId,
cutoffDate,
filterType: AssetFilterType.photosOnly,
keepMediaType: AssetKeepType.photosOnly,
);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-photo');
expect(candidates[0].type, AssetType.image);
expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-video');
expect(result.assets[0].type, AssetType.video);
});
test('filters by videos only', () async {
// Photo
test('keepMediaType videosOnly returns only photos for deletion', () async {
// Photo - should be deleted
await insertLocalAsset(
id: 'local-photo',
checksum: 'checksum-photo',
@@ -233,7 +233,7 @@ void main() {
);
await insertRemoteAsset(id: 'remote-photo', checksum: 'checksum-photo', ownerId: userId);
// Video
// Video - should be kept
await insertLocalAsset(
id: 'local-video',
checksum: 'checksum-video',
@@ -243,18 +243,18 @@ void main() {
);
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
final candidates = await repository.getRemovalCandidates(
final result = await repository.getRemovalCandidates(
userId,
cutoffDate,
filterType: AssetFilterType.videosOnly,
keepMediaType: AssetKeepType.videosOnly,
);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-video');
expect(candidates[0].type, AssetType.video);
expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-photo');
expect(result.assets[0].type, AssetType.image);
});
test('returns both photos and videos with filterType.all', () async {
test('returns both photos and videos with keepMediaType.all', () async {
// Photo
await insertLocalAsset(
id: 'local-photo',
@@ -275,10 +275,10 @@ void main() {
);
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate, filterType: AssetFilterType.all);
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepMediaType: AssetKeepType.none);
expect(candidates.length, 2);
final ids = candidates.map((a) => a.id).toSet();
expect(result.assets.length, 2);
final ids = result.assets.map((a) => a.id).toSet();
expect(ids, containsAll(['local-photo', 'local-video']));
});
@@ -311,10 +311,10 @@ void main() {
await insertRemoteAsset(id: 'remote-shared', checksum: 'checksum-shared', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-shared', assetId: 'local-shared');
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
final result = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-regular');
expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-regular');
});
test('includes assets at exact cutoff date', () async {
@@ -327,10 +327,10 @@ void main() {
);
await insertRemoteAsset(id: 'remote-exact', checksum: 'checksum-exact', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
final result = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-exact');
expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-exact');
});
test('returns empty list when no assets match criteria', () async {
@@ -344,9 +344,9 @@ void main() {
);
await insertRemoteAsset(id: 'remote-after', checksum: 'checksum-after', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
final result = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates, isEmpty);
expect(result.assets, isEmpty);
});
test('handles multiple assets with same checksum', () async {
@@ -367,10 +367,10 @@ void main() {
);
await insertRemoteAsset(id: 'remote-dup', checksum: 'checksum-dup', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
final result = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates.length, 2);
expect(candidates.map((a) => a.checksum).toSet(), equals({'checksum-dup'}));
expect(result.assets.length, 2);
expect(result.assets.map((a) => a.checksum).toSet(), equals({'checksum-dup'}));
});
test('includes assets not in any album', () async {
@@ -384,10 +384,10 @@ void main() {
);
await insertRemoteAsset(id: 'remote-no-album', checksum: 'checksum-no-album', ownerId: userId);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
final result = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates.length, 1);
expect(candidates[0].id, 'local-no-album');
expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-no-album');
});
test('excludes asset that is in both regular and iOS shared album', () async {
@@ -409,9 +409,9 @@ void main() {
await insertLocalAlbumAsset(albumId: 'album-regular', assetId: 'local-both');
await insertLocalAlbumAsset(albumId: 'album-shared', assetId: 'local-both');
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
final result = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates, isEmpty);
expect(result.assets, isEmpty);
});
test('excludes assets with null checksum (not backed up)', () async {
@@ -430,9 +430,218 @@ void main() {
),
);
final candidates = await repository.getRemovalCandidates(userId, cutoffDate);
final result = await repository.getRemovalCandidates(userId, cutoffDate);
expect(candidates, isEmpty);
expect(result.assets, isEmpty);
});
test('excludes assets in user-excluded albums', () async {
// Create two regular albums
await insertLocalAlbum(id: 'album-include', name: 'Include Album', isIosSharedAlbum: false);
await insertLocalAlbum(id: 'album-exclude', name: 'Exclude Album', isIosSharedAlbum: false);
// Asset in included album - should be included
await insertLocalAsset(
id: 'local-in-included',
checksum: 'checksum-included',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-included', checksum: 'checksum-included', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-include', assetId: 'local-in-included');
// Asset in excluded album - should NOT be included
await insertLocalAsset(
id: 'local-in-excluded',
checksum: 'checksum-excluded',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-excluded', checksum: 'checksum-excluded', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-exclude', assetId: 'local-in-excluded');
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {'album-exclude'});
expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-in-included');
});
test('excludes assets that are in any of multiple excluded albums', () async {
// Create multiple albums
await insertLocalAlbum(id: 'album-1', name: 'Album 1', isIosSharedAlbum: false);
await insertLocalAlbum(id: 'album-2', name: 'Album 2', isIosSharedAlbum: false);
await insertLocalAlbum(id: 'album-3', name: 'Album 3', isIosSharedAlbum: false);
// Asset in album-1 (excluded) - should NOT be included
await insertLocalAsset(
id: 'local-1',
checksum: 'checksum-1',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-1', checksum: 'checksum-1', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-1', assetId: 'local-1');
// Asset in album-2 (excluded) - should NOT be included
await insertLocalAsset(
id: 'local-2',
checksum: 'checksum-2',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-2', checksum: 'checksum-2', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-2', assetId: 'local-2');
// Asset in album-3 (not excluded) - should be included
await insertLocalAsset(
id: 'local-3',
checksum: 'checksum-3',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-3', checksum: 'checksum-3', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-3', assetId: 'local-3');
final result = await repository.getRemovalCandidates(
userId,
cutoffDate,
keepAlbumIds: {'album-1', 'album-2'},
);
expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-3');
});
test('excludes asset that is in both excluded and non-excluded album', () async {
await insertLocalAlbum(id: 'album-included', name: 'Included Album', isIosSharedAlbum: false);
await insertLocalAlbum(id: 'album-excluded', name: 'Excluded Album', isIosSharedAlbum: false);
// Asset in BOTH albums - should be excluded because it's in an excluded album
await insertLocalAsset(
id: 'local-both',
checksum: 'checksum-both',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-both', checksum: 'checksum-both', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-included', assetId: 'local-both');
await insertLocalAlbumAsset(albumId: 'album-excluded', assetId: 'local-both');
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {'album-excluded'});
expect(result.assets, isEmpty);
});
test('includes all assets when excludedAlbumIds is empty', () async {
await insertLocalAlbum(id: 'album-1', name: 'Album 1', isIosSharedAlbum: false);
await insertLocalAsset(
id: 'local-1',
checksum: 'checksum-1',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-1', checksum: 'checksum-1', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-1', assetId: 'local-1');
await insertLocalAsset(
id: 'local-2',
checksum: 'checksum-2',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-2', checksum: 'checksum-2', ownerId: userId);
// Empty excludedAlbumIds should include all eligible assets
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {});
expect(result.assets.length, 2);
});
test('excludes asset not in any album when album is excluded', () async {
await insertLocalAlbum(id: 'album-excluded', name: 'Excluded Album', isIosSharedAlbum: false);
// Asset NOT in any album - should be included
await insertLocalAsset(
id: 'local-no-album',
checksum: 'checksum-no-album',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-no-album', checksum: 'checksum-no-album', ownerId: userId);
// Asset in excluded album - should NOT be included
await insertLocalAsset(
id: 'local-in-excluded',
checksum: 'checksum-in-excluded',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-in-excluded', checksum: 'checksum-in-excluded', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-excluded', assetId: 'local-in-excluded');
final result = await repository.getRemovalCandidates(userId, cutoffDate, keepAlbumIds: {'album-excluded'});
expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-no-album');
});
test('combines excludedAlbumIds with keepMediaType correctly', () async {
await insertLocalAlbum(id: 'album-excluded', name: 'Excluded Album', isIosSharedAlbum: false);
await insertLocalAlbum(id: 'album-regular', name: 'Regular Album', isIosSharedAlbum: false);
// Photo in excluded album - should NOT be included (album excluded)
await insertLocalAsset(
id: 'local-photo-excluded',
checksum: 'checksum-photo-excluded',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-photo-excluded', checksum: 'checksum-photo-excluded', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-excluded', assetId: 'local-photo-excluded');
// Video in regular album - should be included (keepMediaType photosOnly = delete videos)
await insertLocalAsset(
id: 'local-video',
checksum: 'checksum-video',
createdAt: beforeCutoff,
type: AssetType.video,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-video', checksum: 'checksum-video', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-regular', assetId: 'local-video');
// Photo in regular album - should NOT be included (keepMediaType photosOnly = keep photos)
await insertLocalAsset(
id: 'local-photo-regular',
checksum: 'checksum-photo-regular',
createdAt: beforeCutoff,
type: AssetType.image,
isFavorite: false,
);
await insertRemoteAsset(id: 'remote-photo-regular', checksum: 'checksum-photo-regular', ownerId: userId);
await insertLocalAlbumAsset(albumId: 'album-regular', assetId: 'local-photo-regular');
final result = await repository.getRemovalCandidates(
userId,
cutoffDate,
keepMediaType: AssetKeepType.photosOnly,
keepAlbumIds: {'album-excluded'},
);
expect(result.assets.length, 1);
expect(result.assets[0].id, 'local-video');
});
});
}

View File

@@ -0,0 +1,321 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/utils/editor.utils.dart';
List<AssetEdit> normalizedToEdits(double rotation, bool mirrorH, bool mirrorV) {
List<AssetEdit> edits = [];
if (mirrorH) {
edits.add(const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}));
}
if (mirrorV) {
edits.add(const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}));
}
if (rotation != 0) {
edits.add(AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": rotation}));
}
return edits;
}
bool compareEditAffines(List<AssetEdit> editsA, List<AssetEdit> editsB) {
final normA = buildAffineFromEdits(editsA);
final normB = buildAffineFromEdits(editsB);
return ((normA.a - normB.a).abs() < 0.0001 &&
(normA.b - normB.b).abs() < 0.0001 &&
(normA.c - normB.c).abs() < 0.0001 &&
(normA.d - normB.d).abs() < 0.0001);
}
void main() {
group('normalizeEdits', () {
test('should handle no edits', () {
final edits = <AssetEdit>[];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle a single 90° rotation', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 90}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle a single 180° rotation', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 180}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle a single 270° rotation', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 270}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle a single horizontal mirror', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle a single vertical mirror', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle 90° rotation + horizontal mirror', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 90}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle 90° rotation + vertical mirror', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 90}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle 90° rotation + both mirrors', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 90}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle 180° rotation + horizontal mirror', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 180}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle 180° rotation + vertical mirror', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 180}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle 180° rotation + both mirrors', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 180}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle 270° rotation + horizontal mirror', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 270}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle 270° rotation + vertical mirror', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 270}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle 270° rotation + both mirrors', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 270}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle horizontal mirror + 90° rotation', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 90}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle horizontal mirror + 180° rotation', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 180}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle horizontal mirror + 270° rotation', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 270}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle vertical mirror + 90° rotation', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 90}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle vertical mirror + 180° rotation', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 180}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle vertical mirror + 270° rotation', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 270}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle both mirrors + 90° rotation', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 90}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle both mirrors + 180° rotation', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 180}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3);
expect(compareEditAffines(normalizedEdits, edits), true);
});
test('should handle both mirrors + 270° rotation', () {
final edits = <AssetEdit>[
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "horizontal"}),
const AssetEdit(action: AssetEditAction.mirror, parameters: {"axis": "vertical"}),
const AssetEdit(action: AssetEditAction.rotate, parameters: {"angle": 270}),
];
final result = normalizeTransformEdits(edits);
final normalizedEdits = normalizedToEdits(result.$1, result.$2, result.$3);
expect(compareEditAffines(normalizedEdits, edits), true);
});
});
}

View File

@@ -21,13 +21,14 @@ function dart {
patch --no-backup-if-mismatch -u ../mobile/openapi/lib/api_client.dart <./patch/api_client.dart.patch
patch --no-backup-if-mismatch -u ../mobile/openapi/lib/api.dart <./patch/api.dart.patch
patch --no-backup-if-mismatch -u ../mobile/openapi/pubspec.yaml <./patch/pubspec_immich_mobile.yaml.patch
patch --no-backup-if-mismatch -u ../mobile/openapi/lib/model/asset_edit_action_list_dto_edits_inner.dart <./patch/asset_edit_action_list_dto_edits_inner.dart.patch
# Don't include analysis_options.yaml for the generated openapi files
# so that language servers can properly exclude the mobile/openapi directory
rm ../mobile/openapi/analysis_options.yaml
}
function typescript {
pnpm dlx oazapfts --optimistic --argumentStyle=object --useEnumType immich-openapi-specs.json typescript-sdk/src/fetch-client.ts
pnpm dlx oazapfts --optimistic --argumentStyle=object --useEnumType --allSchemas immich-openapi-specs.json typescript-sdk/src/fetch-client.ts
pnpm --filter @immich/sdk install --frozen-lockfile
pnpm --filter @immich/sdk build
}

View File

@@ -3173,6 +3173,7 @@
"state": "Stable"
}
],
"x-immich-permission": "asset.upload",
"x-immich-state": "Stable"
}
},
@@ -3225,6 +3226,7 @@
"state": "Stable"
}
],
"x-immich-permission": "job.create",
"x-immich-state": "Stable"
}
},
@@ -4277,7 +4279,7 @@
},
"/assets/{id}/thumbnail": {
"get": {
"description": "Retrieve the thumbnail image for the specified asset.",
"description": "Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.",
"operationId": "viewAsset",
"parameters": [
{
@@ -14618,6 +14620,7 @@
"state": "Stable"
}
],
"x-immich-permission": "folder.read",
"x-immich-state": "Stable"
}
},
@@ -14670,6 +14673,7 @@
"state": "Stable"
}
],
"x-immich-permission": "folder.read",
"x-immich-state": "Stable"
}
},
@@ -15793,7 +15797,8 @@
"enum": [
"crop",
"rotate",
"mirror"
"mirror",
"filter"
],
"type": "string"
},
@@ -15816,6 +15821,25 @@
],
"type": "object"
},
"AssetEditActionFilter": {
"properties": {
"action": {
"allOf": [
{
"$ref": "#/components/schemas/AssetEditAction"
}
]
},
"parameters": {
"$ref": "#/components/schemas/FilterParameters"
}
},
"required": [
"action",
"parameters"
],
"type": "object"
},
"AssetEditActionListDto": {
"properties": {
"edits": {
@@ -15830,6 +15854,9 @@
},
{
"$ref": "#/components/schemas/AssetEditActionMirror"
},
{
"$ref": "#/components/schemas/AssetEditActionFilter"
}
]
},
@@ -15898,6 +15925,9 @@
},
{
"$ref": "#/components/schemas/AssetEditActionMirror"
},
{
"$ref": "#/components/schemas/AssetEditActionFilter"
}
]
},
@@ -16301,6 +16331,7 @@
},
"AssetMediaSize": {
"enum": [
"original",
"fullsize",
"preview",
"thumbnail"
@@ -17540,6 +17571,79 @@
],
"type": "object"
},
"FilterParameters": {
"properties": {
"bOffset": {
"description": "B Offset (-255 -> 255)",
"maximum": 255,
"minimum": -255,
"type": "number"
},
"bbBias": {
"description": "BB Bias",
"type": "number"
},
"bgBias": {
"description": "BG Bias",
"type": "number"
},
"brBias": {
"description": "BR Bias",
"type": "number"
},
"gOffset": {
"description": "G Offset (-255 -> 255)",
"maximum": 255,
"minimum": -255,
"type": "number"
},
"gbBias": {
"description": "GB Bias",
"type": "number"
},
"ggBias": {
"description": "GG Bias",
"type": "number"
},
"grBias": {
"description": "GR Bias",
"type": "number"
},
"rOffset": {
"description": "R Offset (-255 -> 255)",
"maximum": 255,
"minimum": -255,
"type": "number"
},
"rbBias": {
"description": "RB Bias",
"type": "number"
},
"rgBias": {
"description": "RG Bias",
"type": "number"
},
"rrBias": {
"description": "RR Bias",
"type": "number"
}
},
"required": [
"bOffset",
"bbBias",
"bgBias",
"brBias",
"gOffset",
"gbBias",
"ggBias",
"grBias",
"rOffset",
"rbBias",
"rgBias",
"rrBias"
],
"type": "object"
},
"FoldersResponse": {
"properties": {
"enabled": {
@@ -18958,6 +19062,7 @@
"face.read",
"face.update",
"face.delete",
"folder.read",
"job.create",
"job.read",
"library.create",
@@ -21507,6 +21612,48 @@
],
"type": "object"
},
"SyncAssetEditDeleteV1": {
"properties": {
"assetId": {
"type": "string"
}
},
"required": [
"assetId"
],
"type": "object"
},
"SyncAssetEditV1": {
"properties": {
"action": {
"allOf": [
{
"$ref": "#/components/schemas/AssetEditAction"
}
]
},
"assetId": {
"type": "string"
},
"id": {
"type": "string"
},
"parameters": {
"type": "object"
},
"sequence": {
"type": "integer"
}
},
"required": [
"action",
"assetId",
"id",
"parameters",
"sequence"
],
"type": "object"
},
"SyncAssetExifV1": {
"properties": {
"assetId": {
@@ -21926,6 +22073,8 @@
"AssetV1",
"AssetDeleteV1",
"AssetExifV1",
"AssetEditV1",
"AssetEditDeleteV1",
"AssetMetadataV1",
"AssetMetadataDeleteV1",
"PartnerV1",
@@ -22188,6 +22337,7 @@
"AlbumAssetExifsV1",
"AssetsV1",
"AssetExifsV1",
"AssetEditsV1",
"AssetMetadataV1",
"AuthUsersV1",
"MemoriesV1",
@@ -22618,6 +22768,10 @@
}
]
},
"progressive": {
"default": false,
"type": "boolean"
},
"quality": {
"maximum": 100,
"minimum": 1,
@@ -22640,6 +22794,10 @@
}
]
},
"progressive": {
"default": false,
"type": "boolean"
},
"quality": {
"maximum": 100,
"minimum": 1,

View File

@@ -0,0 +1,20 @@
--- /tmp/asset_edit_orig.dart 2026-01-20 10:38:05
+++ /tmp/asset_edit_final.dart 2026-01-20 10:40:33
@@ -19,7 +19,7 @@
AssetEditAction action;
- FilterParameters parameters;
+ Map<String, dynamic> parameters;
@override
bool operator ==(Object other) => identical(this, other) || other is AssetEditActionListDtoEditsInner &&
@@ -62,7 +62,7 @@
return AssetEditActionListDtoEditsInner(
action: AssetEditAction.fromJson(json[r'action'])!,
- parameters: FilterParameters.fromJson(json[r'parameters'])!,
+ parameters: json[r'parameters'],
);
}
return null;

View File

@@ -637,14 +637,44 @@ export type AssetEditActionMirror = {
action: AssetEditAction;
parameters: MirrorParameters;
};
export type FilterParameters = {
/** B Offset (-255 -> 255) */
bOffset: number;
/** BB Bias */
bbBias: number;
/** BG Bias */
bgBias: number;
/** BR Bias */
brBias: number;
/** G Offset (-255 -> 255) */
gOffset: number;
/** GB Bias */
gbBias: number;
/** GG Bias */
ggBias: number;
/** GR Bias */
grBias: number;
/** R Offset (-255 -> 255) */
rOffset: number;
/** RB Bias */
rbBias: number;
/** RG Bias */
rgBias: number;
/** RR Bias */
rrBias: number;
};
export type AssetEditActionFilter = {
action: AssetEditAction;
parameters: FilterParameters;
};
export type AssetEditsDto = {
assetId: string;
/** list of edits */
edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror)[];
edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror | AssetEditActionFilter)[];
};
export type AssetEditActionListDto = {
/** list of edits */
edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror)[];
edits: (AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror | AssetEditActionFilter)[];
};
export type AssetMetadataResponseDto = {
key: string;
@@ -1538,10 +1568,12 @@ export type SystemConfigFFmpegDto = {
export type SystemConfigGeneratedFullsizeImageDto = {
enabled: boolean;
format: ImageFormat;
progressive?: boolean;
quality: number;
};
export type SystemConfigGeneratedImageDto = {
format: ImageFormat;
progressive?: boolean;
quality: number;
size: number;
};
@@ -1875,6 +1907,220 @@ export type WorkflowUpdateDto = {
name?: string;
triggerType?: PluginTriggerType;
};
export type SyncAckV1 = {};
export type SyncAlbumDeleteV1 = {
albumId: string;
};
export type SyncAlbumToAssetDeleteV1 = {
albumId: string;
assetId: string;
};
export type SyncAlbumToAssetV1 = {
albumId: string;
assetId: string;
};
export type SyncAlbumUserDeleteV1 = {
albumId: string;
userId: string;
};
export type SyncAlbumUserV1 = {
albumId: string;
role: AlbumUserRole;
userId: string;
};
export type SyncAlbumV1 = {
createdAt: string;
description: string;
id: string;
isActivityEnabled: boolean;
name: string;
order: AssetOrder;
ownerId: string;
thumbnailAssetId: string | null;
updatedAt: string;
};
export type SyncAssetDeleteV1 = {
assetId: string;
};
export type SyncAssetEditDeleteV1 = {
assetId: string;
};
export type SyncAssetEditV1 = {
action: AssetEditAction;
assetId: string;
id: string;
parameters: object;
sequence: number;
};
export type SyncAssetExifV1 = {
assetId: string;
city: string | null;
country: string | null;
dateTimeOriginal: string | null;
description: string | null;
exifImageHeight: number | null;
exifImageWidth: number | null;
exposureTime: string | null;
fNumber: number | null;
fileSizeInByte: number | null;
focalLength: number | null;
fps: number | null;
iso: number | null;
latitude: number | null;
lensModel: string | null;
longitude: number | null;
make: string | null;
model: string | null;
modifyDate: string | null;
orientation: string | null;
profileDescription: string | null;
projectionType: string | null;
rating: number | null;
state: string | null;
timeZone: string | null;
};
export type SyncAssetFaceDeleteV1 = {
assetFaceId: string;
};
export type SyncAssetFaceV1 = {
assetId: string;
boundingBoxX1: number;
boundingBoxX2: number;
boundingBoxY1: number;
boundingBoxY2: number;
id: string;
imageHeight: number;
imageWidth: number;
personId: string | null;
sourceType: string;
};
export type SyncAssetMetadataDeleteV1 = {
assetId: string;
key: string;
};
export type SyncAssetMetadataV1 = {
assetId: string;
key: string;
value: object;
};
export type SyncAssetV1 = {
checksum: string;
deletedAt: string | null;
duration: string | null;
fileCreatedAt: string | null;
fileModifiedAt: string | null;
height: number | null;
id: string;
isEdited: boolean;
isFavorite: boolean;
libraryId: string | null;
livePhotoVideoId: string | null;
localDateTime: string | null;
originalFileName: string;
ownerId: string;
stackId: string | null;
thumbhash: string | null;
"type": AssetTypeEnum;
visibility: AssetVisibility;
width: number | null;
};
export type SyncAuthUserV1 = {
avatarColor: (UserAvatarColor) | null;
deletedAt: string | null;
email: string;
hasProfileImage: boolean;
id: string;
isAdmin: boolean;
name: string;
oauthId: string;
pinCode: string | null;
profileChangedAt: string;
quotaSizeInBytes: number | null;
quotaUsageInBytes: number;
storageLabel: string | null;
};
export type SyncCompleteV1 = {};
export type SyncMemoryAssetDeleteV1 = {
assetId: string;
memoryId: string;
};
export type SyncMemoryAssetV1 = {
assetId: string;
memoryId: string;
};
export type SyncMemoryDeleteV1 = {
memoryId: string;
};
export type SyncMemoryV1 = {
createdAt: string;
data: object;
deletedAt: string | null;
hideAt: string | null;
id: string;
isSaved: boolean;
memoryAt: string;
ownerId: string;
seenAt: string | null;
showAt: string | null;
"type": MemoryType;
updatedAt: string;
};
export type SyncPartnerDeleteV1 = {
sharedById: string;
sharedWithId: string;
};
export type SyncPartnerV1 = {
inTimeline: boolean;
sharedById: string;
sharedWithId: string;
};
export type SyncPersonDeleteV1 = {
personId: string;
};
export type SyncPersonV1 = {
birthDate: string | null;
color: string | null;
createdAt: string;
faceAssetId: string | null;
id: string;
isFavorite: boolean;
isHidden: boolean;
name: string;
ownerId: string;
updatedAt: string;
};
export type SyncResetV1 = {};
export type SyncStackDeleteV1 = {
stackId: string;
};
export type SyncStackV1 = {
createdAt: string;
id: string;
ownerId: string;
primaryAssetId: string;
updatedAt: string;
};
export type SyncUserDeleteV1 = {
userId: string;
};
export type SyncUserMetadataDeleteV1 = {
key: UserMetadataKey;
userId: string;
};
export type SyncUserMetadataV1 = {
key: UserMetadataKey;
userId: string;
value: object;
};
export type SyncUserV1 = {
avatarColor: (UserAvatarColor) | null;
deletedAt: string | null;
email: string;
hasProfileImage: boolean;
id: string;
name: string;
profileChangedAt: string;
};
/**
* List all activities
*/
@@ -5524,6 +5770,7 @@ export enum Permission {
FaceRead = "face.read",
FaceUpdate = "face.update",
FaceDelete = "face.delete",
FolderRead = "folder.read",
JobCreate = "job.create",
JobRead = "job.read",
LibraryCreate = "library.create",
@@ -5653,13 +5900,15 @@ export enum AssetJobName {
export enum AssetEditAction {
Crop = "crop",
Rotate = "rotate",
Mirror = "mirror"
Mirror = "mirror",
Filter = "filter"
}
export enum MirrorAxis {
Horizontal = "horizontal",
Vertical = "vertical"
}
export enum AssetMediaSize {
Original = "original",
Fullsize = "fullsize",
Preview = "preview",
Thumbnail = "thumbnail"
@@ -5810,6 +6059,8 @@ export enum SyncEntityType {
AssetV1 = "AssetV1",
AssetDeleteV1 = "AssetDeleteV1",
AssetExifV1 = "AssetExifV1",
AssetEditV1 = "AssetEditV1",
AssetEditDeleteV1 = "AssetEditDeleteV1",
AssetMetadataV1 = "AssetMetadataV1",
AssetMetadataDeleteV1 = "AssetMetadataDeleteV1",
PartnerV1 = "PartnerV1",
@@ -5860,6 +6111,7 @@ export enum SyncRequestType {
AlbumAssetExifsV1 = "AlbumAssetExifsV1",
AssetsV1 = "AssetsV1",
AssetExifsV1 = "AssetExifsV1",
AssetEditsV1 = "AssetEditsV1",
AssetMetadataV1 = "AssetMetadataV1",
AuthUsersV1 = "AuthUsersV1",
MemoriesV1 = "MemoriesV1",
@@ -5937,3 +6189,8 @@ export enum OAuthTokenEndpointAuthMethod {
ClientSecretPost = "client_secret_post",
ClientSecretBasic = "client_secret_basic"
}
export enum UserMetadataKey {
Preferences = "preferences",
License = "license",
Onboarding = "onboarding"
}

195
pnpm-lock.yaml generated
View File

@@ -489,7 +489,7 @@ importers:
version: 3.0.0(kysely@0.28.2)(postgres@3.4.8)
lodash:
specifier: ^4.17.21
version: 4.17.21
version: 4.17.23
luxon:
specifier: ^3.4.2
version: 3.7.2
@@ -742,7 +742,7 @@ importers:
version: link:../open-api/typescript-sdk
'@immich/ui':
specifier: ^0.59.0
version: 0.59.0(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)
version: 0.59.0(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)
'@mapbox/mapbox-gl-rtl-text':
specifier: 0.2.3
version: 0.2.3(mapbox-gl@1.13.3)
@@ -775,7 +775,7 @@ importers:
version: 0.41.4
'@zoom-image/svelte':
specifier: ^0.3.0
version: 0.3.8(svelte@5.46.4)
version: 0.3.8(svelte@5.48.0)
dom-to-image:
specifier: ^2.6.0
version: 2.6.0
@@ -826,16 +826,16 @@ importers:
version: 5.2.2
svelte-i18n:
specifier: ^4.0.1
version: 4.0.1(svelte@5.46.4)
version: 4.0.1(svelte@5.48.0)
svelte-jsoneditor:
specifier: ^3.10.0
version: 3.11.0(svelte@5.46.4)
version: 3.11.0(svelte@5.48.0)
svelte-maplibre:
specifier: ^1.2.5
version: 1.2.5(svelte@5.46.4)
version: 1.2.5(svelte@5.48.0)
svelte-persisted-store:
specifier: ^0.12.0
version: 0.12.0(svelte@5.46.4)
version: 0.12.0(svelte@5.48.0)
tabbable:
specifier: ^6.2.0
version: 6.4.0
@@ -860,16 +860,16 @@ importers:
version: 3.1.2
'@sveltejs/adapter-static':
specifier: ^3.0.8
version: 3.0.10(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))
version: 3.0.10(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))
'@sveltejs/enhanced-img':
specifier: ^0.9.0
version: 0.9.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 0.9.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/kit':
specifier: ^2.27.1
version: 2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/vite-plugin-svelte':
specifier: 6.2.4
version: 6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@tailwindcss/vite':
specifier: ^4.1.7
version: 4.1.18(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
@@ -878,7 +878,7 @@ importers:
version: 6.9.1
'@testing-library/svelte':
specifier: ^5.2.8
version: 5.3.1(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
version: 5.3.1(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@testing-library/user-event':
specifier: ^14.5.2
version: 14.6.1(@testing-library/dom@10.4.1)
@@ -917,7 +917,7 @@ importers:
version: 6.0.2(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-svelte:
specifier: ^3.12.4
version: 3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.46.4)
version: 3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.48.0)
eslint-plugin-unicorn:
specifier: ^62.0.0
version: 62.0.0(eslint@9.39.2(jiti@2.6.1))
@@ -938,19 +938,19 @@ importers:
version: 4.2.0(prettier@3.8.0)
prettier-plugin-svelte:
specifier: ^3.3.3
version: 3.4.1(prettier@3.8.0)(svelte@5.46.4)
version: 3.4.1(prettier@3.8.0)(svelte@5.48.0)
rollup-plugin-visualizer:
specifier: ^6.0.0
version: 6.0.5(rollup@4.55.1)
svelte:
specifier: 5.46.4
version: 5.46.4
specifier: 5.48.0
version: 5.48.0
svelte-check:
specifier: ^4.1.5
version: 4.3.5(picomatch@4.0.3)(svelte@5.46.4)(typescript@5.9.3)
version: 4.3.5(picomatch@4.0.3)(svelte@5.48.0)(typescript@5.9.3)
svelte-eslint-parser:
specifier: ^1.3.3
version: 1.4.1(svelte@5.46.4)
version: 1.4.1(svelte@5.48.0)
tailwindcss:
specifier: ^4.1.7
version: 4.1.18
@@ -8964,6 +8964,9 @@ packages:
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
log-symbols@4.1.0:
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
engines: {node: '>=10'}
@@ -11605,8 +11608,8 @@ packages:
peerDependencies:
svelte: ^5.30.2
svelte@5.46.4:
resolution: {integrity: sha512-VJwdXrmv9L8L7ZasJeWcCjoIuMRVbhuxbss0fpVnR8yorMmjNDwcjIH08vS6wmSzzzgAG5CADQ1JuXPS2nwt9w==}
svelte@5.48.0:
resolution: {integrity: sha512-+NUe82VoFP1RQViZI/esojx70eazGF4u0O/9ucqZ4rPcOZD+n5EVp17uYsqwdzjUjZyTpGKunHbDziW6AIAVkQ==}
engines: {node: '>=18'}
svg-parser@2.0.4:
@@ -14542,7 +14545,7 @@ snapshots:
html-tags: 3.3.1
html-webpack-plugin: 5.6.5(webpack@5.104.1)
leven: 3.1.0
lodash: 4.17.21
lodash: 4.17.23
open: 8.4.2
p-map: 4.0.0
prompts: 2.4.2
@@ -14659,7 +14662,7 @@ snapshots:
cheerio: 1.0.0-rc.12
feed: 4.2.2
fs-extra: 11.3.2
lodash: 4.17.21
lodash: 4.17.23
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
schema-dts: 1.1.5
@@ -14701,7 +14704,7 @@ snapshots:
combine-promises: 1.2.0
fs-extra: 11.3.2
js-yaml: 4.1.1
lodash: 4.17.21
lodash: 4.17.23
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
schema-dts: 1.1.5
@@ -15014,7 +15017,7 @@ snapshots:
'@mdx-js/react': 3.1.1(@types/react@19.2.8)(react@18.3.1)
clsx: 2.1.1
infima: 0.2.0-alpha.45
lodash: 4.17.21
lodash: 4.17.23
nprogress: 0.2.0
postcss: 8.5.6
prism-react-renderer: 2.4.1(react@18.3.1)
@@ -15112,7 +15115,7 @@ snapshots:
clsx: 2.1.1
eta: 2.2.0
fs-extra: 11.3.2
lodash: 4.17.21
lodash: 4.17.23
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
tslib: 2.8.1
@@ -15187,7 +15190,7 @@ snapshots:
fs-extra: 11.3.2
joi: 17.13.3
js-yaml: 4.1.1
lodash: 4.17.21
lodash: 4.17.23
tslib: 2.8.1
transitivePeerDependencies:
- '@swc/core'
@@ -15212,7 +15215,7 @@ snapshots:
gray-matter: 4.0.3
jiti: 1.21.7
js-yaml: 4.1.1
lodash: 4.17.21
lodash: 4.17.23
micromatch: 4.0.8
p-queue: 6.6.2
prompts: 2.4.2
@@ -15597,7 +15600,7 @@ snapshots:
dependencies:
'@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
lodash: 4.17.21
lodash: 4.17.23
'@grpc/grpc-js@1.14.3':
dependencies:
@@ -15741,19 +15744,19 @@ snapshots:
'@immich/justified-layout-wasm@0.4.3': {}
'@immich/svelte-markdown-preprocess@0.1.0(svelte@5.46.4)':
'@immich/svelte-markdown-preprocess@0.1.0(svelte@5.48.0)':
dependencies:
svelte: 5.46.4
svelte: 5.48.0
'@immich/ui@0.59.0(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)':
'@immich/ui@0.59.0(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)':
dependencies:
'@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.46.4)
'@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.48.0)
'@internationalized/date': 3.10.0
'@mdi/js': 7.4.47
bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)
bits-ui: 2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)
luxon: 3.7.2
simple-icons: 16.4.0
svelte: 5.46.4
svelte: 5.48.0
svelte-highlight: 7.9.0
tailwind-merge: 3.4.0
tailwind-variants: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.18)
@@ -17480,17 +17483,17 @@ snapshots:
dependencies:
acorn: 8.15.0
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))':
'@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))':
dependencies:
'@sveltejs/kit': 2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/kit': 2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/enhanced-img@0.9.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@sveltejs/enhanced-img@0.9.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rollup@4.55.1)(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
magic-string: 0.30.21
sharp: 0.34.5
svelte: 5.46.4
svelte-parse-markup: 0.1.5(svelte@5.46.4)
svelte: 5.48.0
svelte-parse-markup: 0.1.5(svelte@5.48.0)
vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vite-imagetools: 9.0.2(rollup@4.55.1)
zimmerframe: 1.1.4
@@ -17498,11 +17501,11 @@ snapshots:
- rollup
- supports-color
'@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@standard-schema/spec': 1.1.0
'@sveltejs/acorn-typescript': 1.0.8(acorn@8.15.0)
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@types/cookie': 0.6.0
acorn: 8.15.0
cookie: 0.6.0
@@ -17514,28 +17517,28 @@ snapshots:
sade: 1.8.1
set-cookie-parser: 2.7.2
sirv: 3.0.2
svelte: 5.46.4
svelte: 5.48.0
vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
optionalDependencies:
'@opentelemetry/api': 1.9.0
typescript: 5.9.3
'@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
debug: 4.4.3
svelte: 5.46.4
svelte: 5.48.0
vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- supports-color
'@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
deepmerge: 4.3.1
magic-string: 0.30.21
obug: 2.1.1
svelte: 5.46.4
svelte: 5.48.0
vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitefu: 1.1.1(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
transitivePeerDependencies:
@@ -17783,15 +17786,15 @@ snapshots:
picocolors: 1.1.1
redent: 3.0.0
'@testing-library/svelte-core@1.0.0(svelte@5.46.4)':
'@testing-library/svelte-core@1.0.0(svelte@5.48.0)':
dependencies:
svelte: 5.46.4
svelte: 5.48.0
'@testing-library/svelte@5.3.1(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
'@testing-library/svelte@5.3.1(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@testing-library/dom': 10.4.1
'@testing-library/svelte-core': 1.0.0(svelte@5.46.4)
svelte: 5.46.4
'@testing-library/svelte-core': 1.0.0(svelte@5.48.0)
svelte: 5.48.0
optionalDependencies:
vite: 7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@25.0.9)(happy-dom@20.3.0)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
@@ -18673,10 +18676,10 @@ snapshots:
dependencies:
'@namnode/store': 0.1.0
'@zoom-image/svelte@0.3.8(svelte@5.46.4)':
'@zoom-image/svelte@0.3.8(svelte@5.48.0)':
dependencies:
'@zoom-image/core': 0.41.4
svelte: 5.46.4
svelte: 5.48.0
abab@2.0.6:
optional: true
@@ -18842,7 +18845,7 @@ snapshots:
graceful-fs: 4.2.11
is-stream: 2.0.1
lazystream: 1.0.1
lodash: 4.17.21
lodash: 4.17.23
normalize-path: 3.0.0
readable-stream: 4.7.0
@@ -19037,15 +19040,15 @@ snapshots:
binary-extensions@2.3.0: {}
bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4):
bits-ui@2.14.4(@internationalized/date@3.10.0)(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0):
dependencies:
'@floating-ui/core': 1.7.3
'@floating-ui/dom': 1.7.4
'@internationalized/date': 3.10.0
esm-env: 1.2.2
runed: 0.35.1(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)
svelte: 5.46.4
svelte-toolbelt: 0.10.6(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)
runed: 0.35.1(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)
svelte: 5.48.0
svelte-toolbelt: 0.10.6(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)
tabbable: 6.4.0
transitivePeerDependencies:
- '@sveltejs/kit'
@@ -20575,7 +20578,7 @@ snapshots:
'@types/eslint': 9.6.1
eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1))
eslint-plugin-svelte@3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.46.4):
eslint-plugin-svelte@3.14.0(eslint@9.39.2(jiti@2.6.1))(svelte@5.48.0):
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1))
'@jridgewell/sourcemap-codec': 1.5.5
@@ -20587,9 +20590,9 @@ snapshots:
postcss-load-config: 3.1.4(postcss@8.5.6)
postcss-safe-parser: 7.0.1(postcss@8.5.6)
semver: 7.7.3
svelte-eslint-parser: 1.4.1(svelte@5.46.4)
svelte-eslint-parser: 1.4.1(svelte@5.48.0)
optionalDependencies:
svelte: 5.46.4
svelte: 5.48.0
transitivePeerDependencies:
- ts-node
@@ -21575,7 +21578,7 @@ snapshots:
dependencies:
'@types/html-minifier-terser': 6.1.0
html-minifier-terser: 6.1.0
lodash: 4.17.21
lodash: 4.17.23
pretty-error: 4.0.0
tapable: 2.3.0
optionalDependencies:
@@ -21769,7 +21772,7 @@ snapshots:
cli-cursor: 3.1.0
cli-width: 3.0.0
figures: 3.2.0
lodash: 4.17.21
lodash: 4.17.23
mute-stream: 0.0.8
ora: 5.4.1
run-async: 2.4.1
@@ -22408,6 +22411,8 @@ snapshots:
lodash@4.17.21: {}
lodash@4.17.23: {}
log-symbols@4.1.0:
dependencies:
chalk: 4.1.2
@@ -23383,7 +23388,7 @@ snapshots:
node-emoji@1.11.0:
dependencies:
lodash: 4.17.21
lodash: 4.17.23
node-emoji@2.2.0:
dependencies:
@@ -24375,16 +24380,16 @@ snapshots:
dependencies:
prettier: 3.8.0
prettier-plugin-svelte@3.4.1(prettier@3.8.0)(svelte@5.46.4):
prettier-plugin-svelte@3.4.1(prettier@3.8.0)(svelte@5.48.0):
dependencies:
prettier: 3.8.0
svelte: 5.46.4
svelte: 5.48.0
prettier@3.8.0: {}
pretty-error@4.0.0:
dependencies:
lodash: 4.17.21
lodash: 4.17.23
renderkid: 3.0.0
pretty-format@27.5.1:
@@ -24857,7 +24862,7 @@ snapshots:
css-select: 4.3.0
dom-converter: 0.2.0
htmlparser2: 6.1.0
lodash: 4.17.21
lodash: 4.17.23
strip-ansi: 6.0.1
repeat-string@1.6.1: {}
@@ -25005,14 +25010,14 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
runed@0.35.1(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4):
runed@0.35.1(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0):
dependencies:
dequal: 2.0.3
esm-env: 1.2.2
lz-string: 1.5.0
svelte: 5.46.4
svelte: 5.48.0
optionalDependencies:
'@sveltejs/kit': 2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
'@sveltejs/kit': 2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
rw@1.3.3: {}
@@ -25642,23 +25647,23 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
svelte-awesome@3.3.5(svelte@5.46.4):
svelte-awesome@3.3.5(svelte@5.48.0):
dependencies:
svelte: 5.46.4
svelte: 5.48.0
svelte-check@4.3.5(picomatch@4.0.3)(svelte@5.46.4)(typescript@5.9.3):
svelte-check@4.3.5(picomatch@4.0.3)(svelte@5.48.0)(typescript@5.9.3):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
chokidar: 4.0.3
fdir: 6.5.0(picomatch@4.0.3)
picocolors: 1.1.1
sade: 1.8.1
svelte: 5.46.4
svelte: 5.48.0
typescript: 5.9.3
transitivePeerDependencies:
- picomatch
svelte-eslint-parser@1.4.1(svelte@5.46.4):
svelte-eslint-parser@1.4.1(svelte@5.48.0):
dependencies:
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
@@ -25667,7 +25672,7 @@ snapshots:
postcss-scss: 4.0.9(postcss@8.5.6)
postcss-selector-parser: 7.1.1
optionalDependencies:
svelte: 5.46.4
svelte: 5.48.0
svelte-floating-ui@1.5.8:
dependencies:
@@ -25680,7 +25685,7 @@ snapshots:
dependencies:
highlight.js: 11.11.1
svelte-i18n@4.0.1(svelte@5.46.4):
svelte-i18n@4.0.1(svelte@5.48.0):
dependencies:
cli-color: 2.0.4
deepmerge: 4.3.1
@@ -25688,10 +25693,10 @@ snapshots:
estree-walker: 2.0.2
intl-messageformat: 10.7.18
sade: 1.8.1
svelte: 5.46.4
svelte: 5.48.0
tiny-glob: 0.2.9
svelte-jsoneditor@3.11.0(svelte@5.46.4):
svelte-jsoneditor@3.11.0(svelte@5.48.0):
dependencies:
'@codemirror/autocomplete': 6.20.0
'@codemirror/commands': 6.10.1
@@ -25718,42 +25723,42 @@ snapshots:
memoize-one: 6.0.0
natural-compare-lite: 1.4.0
sass: 1.97.1
svelte: 5.46.4
svelte-awesome: 3.3.5(svelte@5.46.4)
svelte: 5.48.0
svelte-awesome: 3.3.5(svelte@5.48.0)
svelte-select: 5.8.3
vanilla-picker: 2.12.3
svelte-maplibre@1.2.5(svelte@5.46.4):
svelte-maplibre@1.2.5(svelte@5.48.0):
dependencies:
d3-geo: 3.1.1
dequal: 2.0.3
just-compare: 2.3.0
maplibre-gl: 5.16.0
pmtiles: 3.2.1
svelte: 5.46.4
svelte: 5.48.0
svelte-parse-markup@0.1.5(svelte@5.46.4):
svelte-parse-markup@0.1.5(svelte@5.48.0):
dependencies:
svelte: 5.46.4
svelte: 5.48.0
svelte-persisted-store@0.12.0(svelte@5.46.4):
svelte-persisted-store@0.12.0(svelte@5.48.0):
dependencies:
svelte: 5.46.4
svelte: 5.48.0
svelte-select@5.8.3:
dependencies:
svelte-floating-ui: 1.5.8
svelte-toolbelt@0.10.6(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4):
svelte-toolbelt@0.10.6(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0):
dependencies:
clsx: 2.1.1
runed: 0.35.1(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.46.4)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.46.4)
runed: 0.35.1(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)
style-to-object: 1.0.14
svelte: 5.46.4
svelte: 5.48.0
transitivePeerDependencies:
- '@sveltejs/kit'
svelte@5.46.4:
svelte@5.48.0:
dependencies:
'@jridgewell/remapping': 2.3.5
'@jridgewell/sourcemap-codec': 1.5.5

View File

@@ -15,7 +15,7 @@ import {
} from 'src/enum';
import { ConcurrentQueueName, FullsizeImageOptions, ImageOptions } from 'src/types';
export interface SystemConfig {
export type SystemConfig = {
backup: {
database: {
enabled: boolean;
@@ -187,7 +187,7 @@ export interface SystemConfig {
user: {
deleteDelay: number;
};
}
};
export type MachineLearningConfig = SystemConfig['machineLearning'];
@@ -319,11 +319,13 @@ export const defaults = Object.freeze<SystemConfig>({
format: ImageFormat.Webp,
size: 250,
quality: 80,
progressive: false,
},
preview: {
format: ImageFormat.Jpeg,
size: 1440,
quality: 80,
progressive: false,
},
colorspace: Colorspace.P3,
extractEmbedded: false,
@@ -331,6 +333,7 @@ export const defaults = Object.freeze<SystemConfig>({
enabled: false,
format: ImageFormat.Jpeg,
quality: 80,
progressive: false,
},
},
newVersionCheck: {

View File

@@ -147,7 +147,8 @@ export class AssetMediaController {
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
@Endpoint({
summary: 'View asset thumbnail',
description: 'Retrieve the thumbnail image for the specified asset.',
description:
'Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async viewAsset(
@@ -202,7 +203,7 @@ export class AssetMediaController {
}
@Post('exist')
@Authenticated()
@Authenticated({ permission: Permission.AssetUpload })
@Endpoint({
summary: 'Check existing assets',
description: 'Checks if multiple assets exist on the server and returns all existing - used by background backup',

View File

@@ -66,7 +66,7 @@ export class AssetController {
}
@Post('jobs')
@Authenticated()
@Authenticated({ permission: Permission.JobCreate })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Run an asset job',

View File

@@ -70,5 +70,33 @@ describe(SystemConfigController.name, () => {
expect(body).toEqual(errorDto.badRequest(['nightlyTasks.databaseCleanup must be a boolean value']));
});
});
describe('image', () => {
it('should accept config without optional progressive property', async () => {
const config = _.cloneDeep(defaults);
delete config.image.thumbnail.progressive;
delete config.image.preview.progressive;
delete config.image.fullsize.progressive;
const { status } = await request(ctx.getHttpServer()).put('/system-config').send(config);
expect(status).toBe(200);
});
it('should accept config with progressive set to true', async () => {
const config = _.cloneDeep(defaults);
config.image.thumbnail.progressive = true;
config.image.preview.progressive = true;
config.image.fullsize.progressive = true;
const { status } = await request(ctx.getHttpServer()).put('/system-config').send(config);
expect(status).toBe(200);
});
it('should reject invalid progressive value', async () => {
const config = _.cloneDeep(defaults);
(config.image.thumbnail.progressive as any) = 'invalid';
const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['image.thumbnail.progressive must be a boolean value']));
});
});
});
});

View File

@@ -3,7 +3,7 @@ import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { ApiTag } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { ViewService } from 'src/services/view.service';
@@ -13,7 +13,7 @@ export class ViewController {
constructor(private service: ViewService) {}
@Get('folder/unique-paths')
@Authenticated()
@Authenticated({ permission: Permission.FolderRead })
@Endpoint({
summary: 'Retrieve unique paths',
description: 'Retrieve a list of unique folder paths from asset original paths.',
@@ -24,7 +24,7 @@ export class ViewController {
}
@Get('folder')
@Authenticated()
@Authenticated({ permission: Permission.FolderRead })
@Endpoint({
summary: 'Retrieve assets by original path',
description: 'Retrieve assets that are children of a specific folder.',

View File

@@ -7,6 +7,7 @@ import { AssetVisibility } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
export enum AssetMediaSize {
Original = 'original',
/**
* An full-sized image extracted/converted from non-web-friendly formats like RAW/HIF.
* or otherwise the original image itself.

View File

@@ -1,12 +1,13 @@
import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger';
import { ClassConstructor, plainToInstance, Transform, Type } from 'class-transformer';
import { ArrayMinSize, IsEnum, IsInt, Min, ValidateNested } from 'class-validator';
import { ArrayMinSize, IsEnum, IsInt, IsNumber, Max, Min, ValidateNested } from 'class-validator';
import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateUUID } from 'src/validation';
export enum AssetEditAction {
Crop = 'crop',
Rotate = 'rotate',
Mirror = 'mirror',
Filter = 'filter',
}
export enum MirrorAxis {
@@ -48,6 +49,68 @@ export class MirrorParameters {
axis!: MirrorAxis;
}
// Sharp supports a 3x3 matrix for color manipulation and rgb offsets
// The matrix representation of a filter is as follows:
// | rrBias rgBias rbBias | | r_offset |
// Image x | grBias ggBias gbBias | + | g_offset |
// | brBias bgBias bbBias | | b_offset |
export class FilterParameters {
@IsNumber()
@ApiProperty({ description: 'RR Bias' })
rrBias!: number;
@IsNumber()
@ApiProperty({ description: 'RG Bias' })
rgBias!: number;
@IsNumber()
@ApiProperty({ description: 'RB Bias' })
rbBias!: number;
@IsNumber()
@ApiProperty({ description: 'GR Bias' })
grBias!: number;
@IsNumber()
@ApiProperty({ description: 'GG Bias' })
ggBias!: number;
@IsNumber()
@ApiProperty({ description: 'GB Bias' })
gbBias!: number;
@IsNumber()
@ApiProperty({ description: 'BR Bias' })
brBias!: number;
@IsNumber()
@ApiProperty({ description: 'BG Bias' })
bgBias!: number;
@IsNumber()
@ApiProperty({ description: 'BB Bias' })
bbBias!: number;
@IsInt()
@Min(-255)
@Max(255)
@ApiProperty({ description: 'R Offset (-255 -> 255)' })
rOffset!: number;
@IsInt()
@Min(-255)
@Max(255)
@ApiProperty({ description: 'G Offset (-255 -> 255)' })
gOffset!: number;
@IsInt()
@Min(-255)
@Max(255)
@ApiProperty({ description: 'B Offset (-255 -> 255)' })
bOffset!: number;
}
class AssetEditActionBase {
@IsEnum(AssetEditAction)
@ApiProperty({ enum: AssetEditAction, enumName: 'AssetEditAction' })
@@ -74,6 +137,12 @@ export class AssetEditActionMirror extends AssetEditActionBase {
@ApiProperty({ type: MirrorParameters })
parameters!: MirrorParameters;
}
export class AssetEditActionFilter extends AssetEditActionBase {
@ValidateNested()
@Type(() => FilterParameters)
@ApiProperty({ type: FilterParameters })
parameters!: FilterParameters;
}
export type AssetEditActionItem =
| {
@@ -87,25 +156,31 @@ export type AssetEditActionItem =
| {
action: AssetEditAction.Mirror;
parameters: MirrorParameters;
}
| {
action: AssetEditAction.Filter;
parameters: FilterParameters;
};
export type AssetEditActionParameter = {
[AssetEditAction.Crop]: CropParameters;
[AssetEditAction.Rotate]: RotateParameters;
[AssetEditAction.Mirror]: MirrorParameters;
[AssetEditAction.Filter]: FilterParameters;
};
type AssetEditActions = AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror;
type AssetEditActions = AssetEditActionCrop | AssetEditActionRotate | AssetEditActionMirror | AssetEditActionFilter;
const actionToClass: Record<AssetEditAction, ClassConstructor<AssetEditActions>> = {
[AssetEditAction.Crop]: AssetEditActionCrop,
[AssetEditAction.Rotate]: AssetEditActionRotate,
[AssetEditAction.Mirror]: AssetEditActionMirror,
[AssetEditAction.Filter]: AssetEditActionFilter,
} as const;
const getActionClass = (item: { action: AssetEditAction }): ClassConstructor<AssetEditActions> =>
actionToClass[item.action];
@ApiExtraModels(AssetEditActionRotate, AssetEditActionMirror, AssetEditActionCrop)
@ApiExtraModels(AssetEditActionRotate, AssetEditActionMirror, AssetEditActionCrop, AssetEditActionFilter)
export class AssetEditActionListDto {
/** list of edits */
@ArrayMinSize(1)

View File

@@ -2,6 +2,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AssetEditAction } from 'src/dtos/editing.dto';
import {
AlbumUserRole,
AssetOrder,
@@ -169,6 +170,24 @@ export class SyncAssetExifV1 {
fps!: number | null;
}
@ExtraModel()
export class SyncAssetEditV1 {
id!: string;
assetId!: string;
@ValidateEnum({ enum: AssetEditAction, name: 'AssetEditAction' })
action!: AssetEditAction;
parameters!: object;
@ApiProperty({ type: 'integer' })
sequence!: number;
}
@ExtraModel()
export class SyncAssetEditDeleteV1 {
assetId!: string;
}
@ExtraModel()
export class SyncAssetMetadataV1 {
assetId!: string;
@@ -354,6 +373,8 @@ export type SyncItem = {
[SyncEntityType.AssetMetadataV1]: SyncAssetMetadataV1;
[SyncEntityType.AssetMetadataDeleteV1]: SyncAssetMetadataDeleteV1;
[SyncEntityType.AssetExifV1]: SyncAssetExifV1;
[SyncEntityType.AssetEditV1]: SyncAssetEditV1;
[SyncEntityType.AssetEditDeleteV1]: SyncAssetEditDeleteV1;
[SyncEntityType.PartnerAssetV1]: SyncAssetV1;
[SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1;
[SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1;

View File

@@ -585,6 +585,9 @@ class SystemConfigGeneratedImageDto {
@Type(() => Number)
@ApiProperty({ type: 'integer' })
size!: number;
@ValidateBoolean({ optional: true, default: false })
progressive?: boolean;
}
class SystemConfigGeneratedFullsizeImageDto {
@@ -600,6 +603,9 @@ class SystemConfigGeneratedFullsizeImageDto {
@Type(() => Number)
@ApiProperty({ type: 'integer' })
quality!: number;
@ValidateBoolean({ optional: true, default: false })
progressive?: boolean;
}
export class SystemConfigImageDto {

View File

@@ -146,6 +146,8 @@ export enum Permission {
FaceUpdate = 'face.update',
FaceDelete = 'face.delete',
FolderRead = 'folder.read',
JobCreate = 'job.create',
JobRead = 'job.read',
@@ -718,6 +720,7 @@ export enum SyncRequestType {
AlbumAssetExifsV1 = 'AlbumAssetExifsV1',
AssetsV1 = 'AssetsV1',
AssetExifsV1 = 'AssetExifsV1',
AssetEditsV1 = 'AssetEditsV1',
AssetMetadataV1 = 'AssetMetadataV1',
AuthUsersV1 = 'AuthUsersV1',
MemoriesV1 = 'MemoriesV1',
@@ -742,6 +745,8 @@ export enum SyncEntityType {
AssetV1 = 'AssetV1',
AssetDeleteV1 = 'AssetDeleteV1',
AssetExifV1 = 'AssetExifV1',
AssetEditV1 = 'AssetEditV1',
AssetEditDeleteV1 = 'AssetEditDeleteV1',
AssetMetadataV1 = 'AssetMetadataV1',
AssetMetadataDeleteV1 = 'AssetMetadataDeleteV1',

View File

@@ -17,3 +17,17 @@ where
"assetId" = $1
order by
"sequence" asc
-- AssetEditRepository.getWithSyncInfo
select
"id",
"assetId",
"sequence",
"action",
"parameters"
from
"asset_edit"
where
"assetId" = $1
order by
"sequence" asc

Some files were not shown because too many files have changed in this diff Show More