Compare commits

...

19 Commits

Author SHA1 Message Date
Adam Gastineau
649c32e4c8 Playing with AppBar 2026-07-20 10:04:17 -07:00
Adam Gastineau
90dc328c22 chore(mobile): update maplibre_gl to 0.26.2 (#29772) 2026-07-16 07:47:48 -07:00
shenlong
a5623c41ec chore: pump flutter to 3.44.6 (#29886)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-16 20:17:03 +05:30
Adam Gastineau
3a016f4451 chore(mobile): drop support for iOS 14 (#29780)
chore(mobile): drop support for iOS 14 in advance of iOS and Xcode 27
2026-07-16 07:45:22 -07:00
Adam Gastineau
0af8284456 chore(mobile): expose signing and bundle identities through envvars and config (#29077)
* chore(mobile): expose signing and bundle identities through envvars and config

* Move instructions to now shared docs/developer/setup.md
2026-07-16 07:44:50 -07:00
renovate[bot]
ad7a9bd189 chore(deps): update prom/prometheus docker digest to 3c42b89 (#29667) 2026-07-16 14:01:41 +02:00
Ryan Rockey
80cdfebbc7 fix: zero byte image uploads (#29426) 2026-07-16 13:00:07 +02:00
Diogo Correia
c50a807bc1 feat(web): add wake lock when uploading assets (#29820) 2026-07-16 12:41:37 +02:00
bo0tzz
26e07548a7 chore: add GHSA as contact in security.txt (#29940) 2026-07-16 12:30:44 +02:00
cevdet
f73c53c563 chore(seo): remove redundant twitter metadata tags (#29801) 2026-07-16 12:29:38 +02:00
Mert
d2744f52b1 fix(server): disable heif security limit (#29954)
disable security limit
2026-07-16 04:40:33 -05:00
Yuxin Wang
d8ed7d7bb9 feat(cli): Add --visibility flag to immich CLI upload subcommand (#29614) 2026-07-15 14:11:03 -05:00
renovate[bot]
834cc54abc chore(deps): update ghcr.io/jdx/mise docker tag to v2026.7.7 (#29893) 2026-07-15 18:45:35 +00:00
Daniel Dietzler
eda605c534 fix(web): clear birth date (#29959) 2026-07-15 18:32:53 +00:00
Daniel Dietzler
b75e6520bb chore: mobile code checks for i18n changes (#29958) 2026-07-15 20:20:42 +02:00
Timon
4c2d2284aa refactor(server): album user route params (#29503) 2026-07-15 18:15:41 +00:00
Daniel Dietzler
5d29f3edc1 fix: mobile translations (#29955) 2026-07-15 18:02:29 +00:00
Mees Frensel
e00dcf7850 fix(web): URI encode slug and reduce confusion for users (#29796)
Co-authored-by: bwees <brandonwees@gmail.com>
2026-07-15 19:32:05 +02:00
Andreas Heinz
e7f773197f fix(server/workflow): add trigger for external libraries AssetCreate (#29597) 2026-07-15 19:30:33 +02:00
54 changed files with 1130 additions and 251 deletions

View File

@@ -34,6 +34,7 @@ jobs:
filters: |
mobile:
- 'mobile/**'
- 'i18n/en.json'
force-filters: |
- '.github/workflows/static_analysis.yml'
force-events: 'workflow_dispatch,release'

View File

@@ -85,7 +85,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:a75c5a35bc21d7afe69551eefa3cb1e1fb1775fe759408007a66b54ec3de1f29
image: prom/prometheus@sha256:3c42b892cf723fa54d2f262c37a0e1f80aa8c8ddb1da7b9b0df9455a35a7f893
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus

View File

@@ -99,6 +99,21 @@ To see local changes to `@immich/ui` in Immich, do the following:
2. Run `mise //mobile:translation` to generate the translation file.
3. Change to the `mobile/` directory and run `flutter run` to start the app.
##### iOS Code Signing
The Immich Apple Team ID and bundle IDs are specified in `mobile/ios/Signing.xcconfig`. For local development, we provide an override mechanism.
Create `mobile/ios/Signing.local.xcconfig` and populate it with the necessary values needed to build and sign Immich yourself. This local override file is gitignored.
```
IMMICH_TEAM_ID = ABCDE12345
IMMICH_BUNDLE_ID_PROD = com.customuniqueid.immich
IMMICH_BUNDLE_ID_DEV = com.customuniqueid.immichdev
IMMICH_GROUP_ID = group.com.customuniqueid.immich
```
The environment values are used across Immich's targets and schemes to prevent redundant edits by contributors.
#### Translation
To add a new translation text, enter the key-value pair in the `i18n/en.json` in the root of the immich project. Then run:

View File

@@ -99,6 +99,7 @@ Options:
-H, --include-hidden Include hidden folders (default: false, env: IMMICH_INCLUDE_HIDDEN)
-a, --album Automatically create albums based on folder name (default: false, env: IMMICH_AUTO_CREATE_ALBUM)
-A, --album-name <name> Add all assets to specified album (env: IMMICH_ALBUM_NAME)
--visibility <visibility> Set the visibility of uploaded assets (choices: "archive", "timeline", "hidden", "locked", env: IMMICH_VISIBILITY)
-n, --dry-run Don't perform any actions, just show what will be done (default: false, env: IMMICH_DRY_RUN)
-c, --concurrency <number> Number of assets to upload at the same time (default: 4, env: IMMICH_UPLOAD_CONCURRENCY)
-j, --json-output Output detailed information in json format (default: false, env: IMMICH_JSON_OUTPUT)
@@ -176,6 +177,12 @@ By default, hidden files are skipped. If you want to include hidden files, use t
immich upload --include-hidden --recursive directory/
```
You can set the visibility of uploaded assets to `archive`, `timeline`, `hidden`, or `locked` with the `--visibility` option:
```bash
immich upload --visibility archive --recursive directory/
```
You can use the `--json-output` option to get a json printed which includes
three keys: `newFiles`, `duplicates` and `newAssets`. Due to some logging
output you will need to strip the first three lines of output to get the json.

View File

@@ -814,7 +814,6 @@
"custom_date": "Custom date",
"custom_locale": "Custom locale",
"custom_locale_description": "Format dates, times, and numbers based on the selected language and region",
"custom_url": "Custom URL",
"cutoff_date_description": "Keep photos from the last…",
"cutoff_day": "{count, plural, one {day} other {days}}",
"cutoff_year": "{count, plural, one {year} other {years}}",
@@ -1948,7 +1947,9 @@
"shared_link_app_bar_title": "Shared Links",
"shared_link_clipboard_copied_massage": "Copied to clipboard",
"shared_link_create_error": "Error while creating shared link",
"shared_link_custom_url_description": "Access this shared link with a custom URL",
"shared_link_custom_url_description": "Access this shared link with a custom URL name",
"shared_link_custom_url_title": "Custom URL",
"shared_link_custom_url_warning": "Warning: a custom URL name with a forward slash may not behave as you expect.",
"shared_link_edit_description_hint": "Enter the share description",
"shared_link_edit_expire_after_option_day": "1 day",
"shared_link_edit_expire_after_option_days": "{count} days",

View File

@@ -26,6 +26,9 @@ Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Local signing overrides.
Signing.local.xcconfig
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3

View File

@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
platform :ios, '14.0'
platform :ios, '15.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -45,7 +45,7 @@ post_install do |installer|
installer.generated_projects.each do |project|
project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '14.0'
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
end
end
end

View File

@@ -96,6 +96,7 @@
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
51516E0F2A0000000000C0F1 /* Signing.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Signing.xcconfig; sourceTree = "<group>"; };
8AB817AA297EDEC88B23F3F6 /* Pods_ShareExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ShareExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
937632897A02DE9C249F20A6 /* Pods-ShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.debug.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.debug.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
@@ -256,6 +257,7 @@
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
51516E0F2A0000000000C0F1 /* Signing.xcconfig */,
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
FAC6F8B62D287F120078CB2F /* ShareExtension */,
@@ -714,6 +716,7 @@
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 51516E0F2A0000000000C0F1 /* Signing.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
@@ -744,7 +747,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
CUSTOM_GROUP_ID = group.app.immich.share.profile;
CUSTOM_GROUP_ID = "$(IMMICH_GROUP_ID).profile";
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -757,7 +760,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
NEW_SETTING = "";
SDKROOT = iphoneos;
@@ -777,16 +780,16 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
DEVELOPMENT_TEAM = "$(IMMICH_TEAM_ID)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 3.0.0;
PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.profile;
PRODUCT_BUNDLE_IDENTIFIER = "$(IMMICH_BUNDLE_ID_DEV).profile";
PRODUCT_NAME = "Immich-Profile";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -797,6 +800,7 @@
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 51516E0F2A0000000000C0F1 /* Signing.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
@@ -827,7 +831,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
CUSTOM_GROUP_ID = group.app.immich.share.debug;
CUSTOM_GROUP_ID = "$(IMMICH_GROUP_ID).debug";
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
@@ -846,7 +850,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
NEW_SETTING = "";
ONLY_ACTIVE_ARCH = YES;
@@ -857,6 +861,7 @@
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 51516E0F2A0000000000C0F1 /* Signing.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
@@ -887,7 +892,7 @@
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
CUSTOM_GROUP_ID = group.app.immich.share;
CUSTOM_GROUP_ID = "$(IMMICH_GROUP_ID)";
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -900,7 +905,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
NEW_SETTING = "";
SDKROOT = iphoneos;
@@ -922,16 +927,16 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
DEVELOPMENT_TEAM = "$(IMMICH_TEAM_ID)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 3.0.0;
PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.debug;
PRODUCT_BUNDLE_IDENTIFIER = "$(IMMICH_BUNDLE_ID_DEV).debug";
PRODUCT_NAME = "Immich-Debug";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -951,16 +956,16 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
DEVELOPMENT_TEAM = "$(IMMICH_TEAM_ID)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 3.0.0;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich;
PRODUCT_BUNDLE_IDENTIFIER = "$(IMMICH_BUNDLE_ID_PROD)";
PRODUCT_NAME = Immich;
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -984,7 +989,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
DEVELOPMENT_TEAM = "$(IMMICH_TEAM_ID)";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1001,7 +1006,7 @@
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.debug.Widget;
PRODUCT_BUNDLE_IDENTIFIER = "$(IMMICH_BUNDLE_ID_DEV).debug.Widget";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
@@ -1027,7 +1032,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
DEVELOPMENT_TEAM = "$(IMMICH_TEAM_ID)";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1043,7 +1048,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.Widget;
PRODUCT_BUNDLE_IDENTIFIER = "$(IMMICH_BUNDLE_ID_PROD).Widget";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -1067,7 +1072,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
DEVELOPMENT_TEAM = "$(IMMICH_TEAM_ID)";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1083,7 +1088,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.profile.Widget;
PRODUCT_BUNDLE_IDENTIFIER = "$(IMMICH_BUNDLE_ID_DEV).profile.Widget";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -1106,7 +1111,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
DEVELOPMENT_TEAM = "$(IMMICH_TEAM_ID)";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1123,7 +1128,7 @@
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.debug.ShareExtension;
PRODUCT_BUNDLE_IDENTIFIER = "$(IMMICH_BUNDLE_ID_DEV).debug.ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
@@ -1149,7 +1154,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
DEVELOPMENT_TEAM = "$(IMMICH_TEAM_ID)";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1165,7 +1170,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.ShareExtension;
PRODUCT_BUNDLE_IDENTIFIER = "$(IMMICH_BUNDLE_ID_PROD).ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;
@@ -1189,7 +1194,7 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 240;
DEVELOPMENT_TEAM = 2W7AC6T8T5;
DEVELOPMENT_TEAM = "$(IMMICH_TEAM_ID)";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
@@ -1205,7 +1210,7 @@
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 1.0;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = app.futo.immich.profile.ShareExtension;
PRODUCT_BUNDLE_IDENTIFIER = "$(IMMICH_BUNDLE_ID_DEV).profile.ShareExtension";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SKIP_INSTALL = YES;

View File

@@ -122,8 +122,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-sharing",
"state" : {
"revision" : "3bfc408cc2d0bee2287c174da6b1c76768377818",
"version" : "2.7.4"
"revision" : "8244fe63bf43e58188ab13851ad693eecf6a9e90",
"version" : "2.9.1"
}
},
{

View File

@@ -0,0 +1,29 @@
// Xcode Signing Constants
//
// These are integrated into the Xcode project at build time to allow for dynamic signing configs
// across our many targets.
//
// Create `Signing.local.xcconfig` overriding each variable to provide your own personal signing
// overrides during local development:
// IMMICH_TEAM_ID = ABCDE12345
// IMMICH_BUNDLE_ID_PROD = com.customuniqueid.immich
// IMMICH_BUNDLE_ID_DEV = com.customuniqueid.immichdev
// IMMICH_GROUP_ID = group.com.customuniqueid.immich
// Apple Developer Team ID used for automatically provisioning signing credentials
IMMICH_TEAM_ID = 2W7AC6T8T5
// Root bundle identifier for the Release configuration.
// Extension suffixes (`.ShareExtension`/`.Widget`) will be appended
IMMICH_BUNDLE_ID_PROD = app.alextran.immich
// Root bundle identifier for the Debug and Profile configurations.
// Build scheme suffixes (`.debug`/`.profile`) will be appended
IMMICH_BUNDLE_ID_DEV = app.futo.immich
// Root App Group identifier.
// Build scheme suffixes (`.debug`/`.profile`) will be appended
IMMICH_GROUP_ID = group.app.immich.share
// Optional per-developer configuration override
#include? "Signing.local.xcconfig"

View File

@@ -132,10 +132,10 @@ class SharedLinkEditPage extends HookConsumerWidget {
textInputAction: TextInputAction.done,
autofocus: false,
decoration: InputDecoration(
labelText: slugController.text.isNotEmpty ? context.t.custom_url : null,
labelText: slugController.text.isNotEmpty ? context.t.shared_link_custom_url_title : null,
labelStyle: const TextStyle(fontWeight: FontWeight.bold),
border: const OutlineInputBorder(),
hintText: context.t.custom_url,
hintText: context.t.shared_link_custom_url_title,
prefixText: slugController.text.isNotEmpty ? '/s/' : null,
prefixStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),

View File

@@ -1,49 +1,17 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/presentation/widgets/feature_message/feature_message_dialog.widget.dart';
import 'package:immich_mobile/providers/feature_message.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/widgets/common/page_chrome/tab_page.dart';
import 'package:immich_mobile/widgets/common/page_chrome/types.dart';
@RoutePage()
class MainTimelinePage extends ConsumerStatefulWidget {
class MainTimelinePage extends TabPage {
const MainTimelinePage({super.key});
@override
ConsumerState<MainTimelinePage> createState() => _MainTimelinePageState();
}
class _MainTimelinePageState extends ConsumerState<MainTimelinePage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted) {
return;
}
final service = ref.read(featureMessageServiceProvider);
if (!service.shouldShow()) {
return;
}
await service.markSeen();
if (!mounted) {
return;
}
await showFeatureMessageDialog(context);
});
}
Widget scopeBuilder(BuildContext context, Widget child) => timelineChromeScope(context, child);
@override
Widget build(BuildContext context) {
final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false));
return Timeline(
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
topSliverWidgetHeight: hasMemories ? 200 : 0,
showStorageIndicator: true,
);
}
ProviderListenable<PageChromeContent> get content => timelineChromeProvider;
}

View File

@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -7,64 +5,70 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/page_chrome/tab_page.dart';
import 'package:immich_mobile/widgets/common/page_chrome/types.dart';
import 'package:immich_mobile/widgets/common/primary_app_bar.dart';
@RoutePage()
class DriftAlbumsPage extends ConsumerStatefulWidget {
class DriftAlbumsPage extends TabPage {
const DriftAlbumsPage({super.key});
@override
ConsumerState<DriftAlbumsPage> createState() => _DriftAlbumsPageState();
}
class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
final ScrollController _scrollController = ScrollController();
Future<void> onRefresh() async {
await ref.read(remoteAlbumProvider.notifier).refresh();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
ProviderListenable<PageChromeContent> get content => Provider<PageChromeContent>((ref) {
final albumCount = ref.watch(remoteAlbumProvider.select((state) => state.albums.length));
final showScrollbar = albumCount > 20;
final scrollView = CustomScrollView(
controller: _scrollController,
return PageChromeContent(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
ImmichSliverAppBar(
snap: false,
floating: false,
pinned: true,
actions: [
IconButton(
onPressed: () => context.pushRoute(const DriftCreateAlbumRoute()),
icon: const Icon(Icons.add_rounded),
),
],
showUploadButton: false,
),
AlbumSelector(
onAlbumSelected: (album) {
context.router.push(RemoteAlbumRoute(album: album));
},
),
],
appBar: const PrimaryAppBar(
snap: false,
floating: false,
pinned: true,
showUploadButton: false,
actions: [_CreateAlbumAction()],
),
viewportBuilder: (scrollView) => _AlbumRefreshWrapper(showScrollbar: showScrollbar, scrollView: scrollView),
slivers: [const _AlbumSelectorSliver()],
);
});
}
class _CreateAlbumAction extends StatelessWidget {
const _CreateAlbumAction();
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: () => context.pushRoute(const DriftCreateAlbumRoute()),
icon: const Icon(Icons.add_rounded),
);
}
}
class _AlbumSelectorSliver extends StatelessWidget {
const _AlbumSelectorSliver();
@override
Widget build(BuildContext context) {
return AlbumSelector(onAlbumSelected: (album) => context.router.push(RemoteAlbumRoute(album: album)));
}
}
class _AlbumRefreshWrapper extends ConsumerWidget {
final bool showScrollbar;
final CustomScrollView scrollView;
const _AlbumRefreshWrapper({required this.showScrollbar, required this.scrollView});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = PrimaryScrollController.of(context);
return RefreshIndicator(
onRefresh: onRefresh,
onRefresh: () => ref.read(remoteAlbumProvider.notifier).refresh(),
edgeOffset: 100,
child: showScrollbar
? RawScrollbar(
controller: _scrollController,
controller: controller,
interactive: true,
thickness: 8,
radius: const Radius.circular(4),

View File

@@ -16,27 +16,23 @@ import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/page_chrome/tab_page.dart';
import 'package:immich_mobile/widgets/common/page_chrome/types.dart';
import 'package:immich_mobile/widgets/common/primary_app_bar.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
class DriftLibraryPage extends ConsumerWidget {
class DriftLibraryPage extends TabPage {
const DriftLibraryPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return const Scaffold(
body: CustomScrollView(
slivers: [
ImmichSliverAppBar(snap: false, floating: false, pinned: true, showUploadButton: false),
_ActionButtonGrid(),
_CollectionCards(),
_QuickAccessButtonList(),
],
),
ProviderListenable<PageChromeContent> get content => Provider<PageChromeContent>((ref) {
return const PageChromeContent(
appBar: PrimaryAppBar(snap: false, floating: false, pinned: true, showUploadButton: false),
slivers: [_ActionButtonGrid(), _CollectionCards(), _QuickAccessButtonList()],
);
}
});
}
class _ActionButtonGrid extends ConsumerWidget {

View File

@@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
@@ -16,17 +17,23 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/general_bottom_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/feature_message/feature_message_dialog.widget.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline_scroll.provider.dart';
import 'package:immich_mobile/providers/feature_message.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/page_chrome/types.dart';
import 'package:immich_mobile/widgets/common/primary_app_bar.dart';
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
class Timeline extends ConsumerWidget {
@@ -37,7 +44,7 @@ class Timeline extends ConsumerWidget {
this.bottomSliverWidget,
this.showStorageIndicator = false,
this.withStack = false,
this.appBar = const ImmichSliverAppBar(floating: true, pinned: false, snap: false),
this.appBar = const PrimaryAppBar(floating: true, pinned: false, snap: false),
this.bottomSheet = const GeneralBottomSheet(minChildSize: 0.23),
this.groupBy,
this.withScrubber = true,
@@ -748,3 +755,225 @@ class CustomScaleGestureRecognizer extends ScaleGestureRecognizer {
acceptGesture(pointer);
}
}
// ---------------------------------------------------------------------------
// PageChrome (Photos) path
//
// The shared `PageChrome` owns the Scaffold + scroll view + app bar. The
// timeline contributes a [PageChromeContent] via [timelineChromeProvider],
// read inside [timelineChromeScope] (which overrides `timelineArgsProvider`
// from the real layout constraints). The controller/observer/scroll logic
// lives in [timelineScrollProvider] so the chrome can read the controller
// before it builds the scroll view (same frame).
// ---------------------------------------------------------------------------
/// Wraps the chrome body in the timeline's `ProviderScope`, overriding
/// `timelineArgsProvider` from the actual scroll-view constraints via a
/// `LayoutBuilder` (built during layout, so the value is real, not a guess).
Widget timelineChromeScope(BuildContext context, Widget child) {
return LayoutBuilder(
builder: (context, constraints) {
return Consumer(
builder: (context, ref, _) {
final columnCount = ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow));
return ProviderScope(
overrides: [
timelineArgsProvider.overrideWithValue(
TimelineArgs(
maxWidth: constraints.maxWidth,
maxHeight: constraints.maxHeight,
columnCount: columnCount,
showStorageIndicator: true,
),
),
],
child: _TimelineChromePopScope(child: child),
);
},
);
},
);
}
/// Pops out of multiselect (instead of the route) while a selection is active.
class _TimelineChromePopScope extends ConsumerWidget {
final Widget child;
const _TimelineChromePopScope({required this.child});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
return PopScope(
canPop: !isMultiSelectEnabled,
onPopInvokedWithResult: (didPop, _) {
if (!didPop && isMultiSelectEnabled) {
ref.read(multiSelectProvider.notifier).reset();
}
},
child: child,
);
}
}
final timelineChromeProvider = Provider<PageChromeContent>((ref) {
final scroll = ref.watch(timelineScrollProvider.notifier);
final physics = ref.watch(timelineScrollProvider.select((s) => s.physics));
final maxHeight = ref.watch(timelineArgsProvider.select((args) => args.maxHeight));
final isSelectionMode = ref.watch(multiSelectProvider.select((s) => s.forceEnable));
return PageChromeContent(
controller: scroll.controller,
physics: physics,
scrollCacheExtent: ScrollCacheExtent.pixels(maxHeight * 2),
appBar: isSelectionMode
? const SelectionSliverAppBar()
: const PrimaryAppBar(floating: true, pinned: false, snap: false),
slivers: const [_TimelineChromeContent()],
viewportBuilder: (scrollView) => _TimelineChromeViewport(scrollView: scrollView),
floatingActionButton: const DownloadStatusFloatingButton(),
overlays: const [_TimelineChromeOverlays()],
);
}, dependencies: [timelineServiceProvider, timelineArgsProvider, timelineScrollProvider, multiSelectProvider]);
/// The timeline's content slivers (memory lane, the segmented grid, bottom
/// padding). Mounted by the chrome inside its scroll view, so the ambient
/// `PrimaryScrollController` is the chrome's controller.
class _TimelineChromeContent extends ConsumerWidget {
const _TimelineChromeContent();
@override
Widget build(BuildContext context, WidgetRef ref) {
// A reload event forces the grid to rebuild.
ref.listen(timelineSegmentProvider, (_, __) {});
final asyncSegments = ref.watch(timelineSegmentProvider);
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false));
return asyncSegments.when(
loading: () => const SliverToBoxAdapter(child: SizedBox.shrink()),
error: (_, __) => const SliverToBoxAdapter(child: SizedBox.shrink()),
data: (segments) {
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
const bottomSheetOpenModifier = 120.0;
final contentBottomPadding = context.padding.bottom + (isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
return SliverMainAxisGroup(
slivers: [
if (hasMemories) const SliverToBoxAdapter(child: DriftMemoryLane()),
_SliverSegmentedList(
segments: segments,
delegate: SliverChildBuilderDelegate((ctx, index) {
if (index >= childCount) {
return null;
}
final segment = segments.findByIndex(index);
return segment?.builder(ctx, index) ?? const SizedBox.shrink();
}, childCount: childCount, addAutomaticKeepAlives: false, addRepaintBoundaries: false),
),
SliverPadding(padding: EdgeInsets.only(bottom: contentBottomPadding)),
],
);
},
);
}
}
/// Wraps the shared scroll view in the pinch-to-scale gesture, drag selection
/// region and the scrubber.
class _TimelineChromeViewport extends ConsumerWidget {
final CustomScrollView scrollView;
const _TimelineChromeViewport({required this.scrollView});
@override
Widget build(BuildContext context, WidgetRef ref) {
final scroll = ref.read(timelineScrollProvider.notifier);
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final maxHeight = ref.watch(timelineArgsProvider.select((args) => args.maxHeight));
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false));
final segments = ref.watch(timelineSegmentProvider).valueOrNull ?? const [];
final topPadding = context.padding.top + kToolbarHeight + 10;
const bottomSheetOpenModifier = 120.0;
final contentBottomPadding = context.padding.bottom + (isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
final scrubberBottomPadding = contentBottomPadding + kScrubberThumbHeight;
final timeline = Scrubber(
layoutSegments: segments,
timelineHeight: maxHeight,
topPadding: topPadding,
bottomPadding: scrubberBottomPadding,
monthSegmentSnappingOffset: hasMemories ? 200 : 0,
hasAppBar: true,
child: scrollView,
);
return RawGestureDetector(
gestures: {
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>(
() => CustomScaleGestureRecognizer(),
(CustomScaleGestureRecognizer recognizer) {
recognizer.onStart = (_) => scroll.onScaleStart();
recognizer.onUpdate = (details) => scroll.onScaleUpdate(details.scale, segments);
},
),
},
child: TimelineDragRegion(
onStart: !isReadonlyModeEnabled ? scroll.setDragStartIndex : null,
onAssetEnter: scroll.handleDragAssetEnter,
onEnd: !isReadonlyModeEnabled ? scroll.stopDrag : null,
onScroll: scroll.dragScroll,
onScrollStart: scroll.onScrollStart,
child: timeline,
),
);
}
}
/// The multiselect status button + bottom sheet stacked on top of the timeline.
class _TimelineChromeOverlays extends HookConsumerWidget {
const _TimelineChromeOverlays();
@override
Widget build(BuildContext context, WidgetRef ref) {
// Show the "what's new" feature message once, when the timeline mounts.
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!context.mounted) {
return;
}
final service = ref.read(featureMessageServiceProvider);
if (!service.shouldShow()) {
return;
}
await service.markSeen();
if (!context.mounted) {
return;
}
await showFeatureMessageDialog(context);
});
return null;
}, const []);
final isSelectionMode = ref.watch(multiSelectProvider.select((s) => s.forceEnable));
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
final isVisible = !isSelectionMode && isMultiSelectEnabled;
if (!isVisible) {
return const SizedBox.shrink();
}
return Stack(
clipBehavior: Clip.none,
children: [
Positioned(
top: MediaQuery.paddingOf(context).top,
left: 25,
child: const SizedBox(height: kToolbarHeight, child: Center(child: _MultiSelectStatusButton())),
),
const GeneralBottomSheet(minChildSize: 0.23),
],
);
}
}

View File

@@ -0,0 +1,282 @@
import 'dart:async';
import 'dart:collection';
import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
/// Transient interaction state of the chrome timeline that drives rebuilds of
/// the content slivers + viewport (scale/drag/physics).
class TimelineScrollState {
final int perRow;
final double scaleFactor;
final bool dragging;
final ScrollPhysics? physics;
const TimelineScrollState({this.perRow = 4, this.scaleFactor = 3.0, this.dragging = false, this.physics});
TimelineScrollState copyWith({int? perRow, double? scaleFactor, bool? dragging, ScrollPhysics? physics}) {
return TimelineScrollState(
perRow: perRow ?? this.perRow,
scaleFactor: scaleFactor ?? this.scaleFactor,
dragging: dragging ?? this.dragging,
// physics is nullable-by-design; pass explicitly to clear.
physics: physics,
);
}
}
/// Owns the chrome timeline's [ScrollController] and everything bound to it:
/// the iOS status-bar-tap observer, the scroll event subscription, position
/// restore, pinch-to-scale and drag-selection logic.
///
/// This lives in a provider (not a widget [State]) so the shared `PageChrome`
/// can read the controller *before* it builds the scroll view, keeping the
/// timeline's chrome same-frame. Scoped under the timeline's `ProviderScope`
/// (via [timelineServiceProvider]/[timelineArgsProvider]) so each timeline gets
/// its own instance.
class TimelineScrollController extends Notifier<TimelineScrollState> with WidgetsBindingObserver {
late final ScrollController controller;
StreamSubscription? _eventSubscription;
// Drag selection
TimelineAssetIndex? _dragAnchorIndex;
final Set<BaseAsset> _draggedAssets = HashSet();
double _baseScaleFactor = 3.0;
int? _restoreAssetIndex;
@override
TimelineScrollState build() {
WidgetsBinding.instance.addObserver(this);
controller = ScrollController(onAttach: _restoreAssetPosition);
_eventSubscription = EventStream.shared.listen(_onEvent);
final perRow = ref.read(appConfigProvider.select((config) => config.timeline.tilesPerRow));
_baseScaleFactor = 7.0 - perRow;
ref.listen(multiSelectProvider.select((s) => s.isEnabled), _onMultiSelectionToggled);
// Remember scroll position across width changes (orientation / rail toggle)
// so it can be restored after the segments regenerate.
ref.listen(timelineArgsProvider.select((a) => a.maxWidth), (previous, next) {
if (previous != null && previous != next) {
final segments = ref.read(timelineSegmentProvider).valueOrNull;
if (segments != null && controller.hasClients) {
_restoreAssetIndex = _getCurrentAssetIndex(segments);
}
}
});
ref.onDispose(() {
WidgetsBinding.instance.removeObserver(this);
controller.dispose();
_eventSubscription?.cancel();
});
return TimelineScrollState(perRow: perRow, scaleFactor: _baseScaleFactor);
}
// Capture iOS status bar tap
@override
void handleStatusBarTap() => scrollToTop();
void _onEvent(Event event) {
switch (event) {
case ScrollToTopEvent():
scrollToTop();
case ScrollToDateEvent scrollToDateEvent:
scrollToDate(scrollToDateEvent.date);
default:
break;
}
}
void _restoreAssetPosition(_) {
if (_restoreAssetIndex == null) {
return;
}
final asyncSegments = ref.read(timelineSegmentProvider);
asyncSegments.whenData((segments) {
final targetSegment = segments.lastWhereOrNull((segment) => segment.firstAssetIndex <= _restoreAssetIndex!);
if (targetSegment != null) {
final assetIndexInSegment = _restoreAssetIndex! - targetSegment.firstAssetIndex;
final newColumnCount = ref.read(timelineArgsProvider).columnCount;
final rowIndexInSegment = (assetIndexInSegment / newColumnCount).floor();
final targetRowIndex = targetSegment.firstIndex + 1 + rowIndexInSegment;
final targetOffset = targetSegment.indexToLayoutOffset(targetRowIndex);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (controller.hasClients) {
controller.jumpTo(targetOffset.clamp(0.0, controller.position.maxScrollExtent));
}
});
}
});
_restoreAssetIndex = null;
}
void _onMultiSelectionToggled(_, bool isEnabled) {
EventStream.shared.emit(MultiSelectToggleEvent(isEnabled));
}
int? getCurrentAssetIndex(List<Segment> segments) => _getCurrentAssetIndex(segments);
int? _getCurrentAssetIndex(List<Segment> segments) {
final currentOffset = controller.offset.clamp(0.0, controller.position.maxScrollExtent);
final segment = segments.findByOffset(currentOffset) ?? segments.lastOrNull;
int? targetAssetIndex;
if (segment != null) {
final rowIndex = segment.getMinChildIndexForScrollOffset(currentOffset);
if (rowIndex > segment.firstIndex) {
final rowIndexInSegment = rowIndex - (segment.firstIndex + 1);
final assetsPerRow = ref.read(timelineArgsProvider).columnCount;
final assetIndexInSegment = rowIndexInSegment * assetsPerRow;
targetAssetIndex = segment.firstAssetIndex + assetIndexInSegment;
} else {
targetAssetIndex = segment.firstAssetIndex;
}
}
return targetAssetIndex;
}
void scrollToTop() {
if (!controller.hasClients) {
return;
}
final timelineState = ref.read(timelineStateProvider.notifier);
timelineState.setScrubbing(true);
controller
.animateTo(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut)
.whenComplete(() => timelineState.setScrubbing(false));
}
void scrollToDate(DateTime date) {
final timelineState = ref.read(timelineStateProvider.notifier);
final asyncSegments = ref.read(timelineSegmentProvider);
asyncSegments.whenData((segments) {
final targetSegment = segments.firstWhereOrNull((segment) {
if (segment.bucket is TimeBucket) {
final segmentDate = (segment.bucket as TimeBucket).date;
return segmentDate.year == date.year && segmentDate.month == date.month && segmentDate.day == date.day;
}
return false;
});
final fallbackSegment =
targetSegment ??
segments.firstWhereOrNull((segment) {
if (segment.bucket is TimeBucket) {
final segmentDate = (segment.bucket as TimeBucket).date;
return segmentDate.year == date.year && segmentDate.month == date.month;
}
return false;
});
if (fallbackSegment != null) {
final targetOffset = fallbackSegment.startOffset - 50;
timelineState.setScrubbing(true);
controller
.animateTo(
targetOffset.clamp(0.0, controller.position.maxScrollExtent),
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
)
.whenComplete(() => timelineState.setScrubbing(false));
} else {
timelineState.setScrubbing(false);
}
});
}
// Pinch to scale
void onScaleStart() {
_baseScaleFactor = state.scaleFactor;
}
void onScaleUpdate(double scale, List<Segment> segments) {
final newScaleFactor = math.max(math.min(5.0, _baseScaleFactor * scale), 1.0);
final newPerRow = 7 - newScaleFactor.toInt();
if (newPerRow != state.perRow) {
_restoreAssetIndex = _getCurrentAssetIndex(segments);
state = state.copyWith(scaleFactor: newScaleFactor, perRow: newPerRow, physics: state.physics);
ref.read(settingsProvider).write(SettingsKey.timelineTilesPerRow, newPerRow);
}
}
// Drag selection
void setDragStartIndex(TimelineAssetIndex index) {
_dragAnchorIndex = index;
state = state.copyWith(dragging: true, physics: const ClampingScrollPhysics());
}
void stopDrag() {
WidgetsBinding.instance.addPostFrameCallback((_) {
// Reset physics post frame to prevent a sudden change on iOS.
state = state.copyWith(dragging: state.dragging, physics: null);
});
_draggedAssets.clear();
state = state.copyWith(dragging: false, physics: state.physics);
final timelineState = ref.read(timelineStateProvider.notifier);
Future.delayed(const Duration(milliseconds: 300), () {
timelineState.setScrolling(false);
});
}
void dragScroll(ScrollDirection direction) {
controller.animateTo(
controller.offset + (direction == ScrollDirection.forward ? 175 : -175),
duration: const Duration(milliseconds: 125),
curve: Curves.easeOut,
);
}
void onScrollStart() {
ref.read(timelineStateProvider.notifier).setScrolling(true);
}
void handleDragAssetEnter(TimelineAssetIndex index) {
if (_dragAnchorIndex == null || !state.dragging) {
return;
}
final timelineService = ref.read(timelineServiceProvider);
final dragAnchorIndex = _dragAnchorIndex!;
final startIndex = math.min(dragAnchorIndex.assetIndex, index.assetIndex);
final endIndex = math.max(dragAnchorIndex.assetIndex, index.assetIndex);
final count = endIndex - startIndex + 1;
if (timelineService.hasRange(startIndex, count)) {
final selectedAssets = timelineService.getAssets(startIndex, count);
final multiSelectNotifier = ref.read(multiSelectProvider.notifier);
for (final asset in _draggedAssets) {
multiSelectNotifier.deselectAsset(asset);
}
_draggedAssets.clear();
for (final asset in selectedAssets) {
multiSelectNotifier.selectAsset(asset);
_draggedAssets.add(asset);
}
}
}
}
final timelineScrollProvider = NotifierProvider<TimelineScrollController, TimelineScrollState>(
TimelineScrollController.new,
dependencies: [timelineServiceProvider, timelineArgsProvider, multiSelectProvider, timelineSegmentProvider],
);

View File

@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/widgets/common/page_chrome/types.dart';
/// The shared page chrome. Authored once; tabs never instantiate it directly
/// (they extend `TabPage`, whose `build` renders this beneath the tab's scope).
///
/// Owns the [Scaffold], the single [CustomScrollView] and its
/// [PrimaryScrollController]. Reads the active tab's [PageChromeContent] from
/// [content] (available in the same frame, since this is a descendant of the
/// tab's scope) and renders its app bar + content slivers.
class PageChrome extends ConsumerStatefulWidget {
final ProviderListenable<PageChromeContent> content;
const PageChrome({super.key, required this.content});
@override
ConsumerState<PageChrome> createState() => _PageChromeState();
}
class _PageChromeState extends ConsumerState<PageChrome> {
ScrollController? _ownController;
@override
void dispose() {
_ownController?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final content = ref.watch(widget.content);
// The tab's controller, or a chrome-owned default created lazily.
final controller = content.controller ?? (_ownController ??= ScrollController());
final scrollView = CustomScrollView(
controller: controller,
physics: content.physics,
scrollCacheExtent: content.scrollCacheExtent,
slivers: [content.appBar, ...content.slivers],
);
Widget body = content.viewportBuilder != null ? content.viewportBuilder!(scrollView) : scrollView;
if (content.overlays.isNotEmpty) {
body = Stack(clipBehavior: Clip.none, children: [body, ...content.overlays]);
}
body = Scaffold(
// Removes the built-in Scaffold `handleStatusBarTap`, avoiding duplicate
// events when a tab (e.g. the timeline) provides its own.
primary: false,
resizeToAvoidBottomInset: false,
floatingActionButton: content.floatingActionButton,
body: body,
);
// The chrome owns the single scroll view; mark its controller primary so
// the scrubber's `PrimaryScrollController.of` and iOS status-bar-tap both
// resolve it. Wrapping the Scaffold preserves status-bar-tap resolution.
return PrimaryScrollController(controller: controller, child: body);
}
}

View File

@@ -0,0 +1,30 @@
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/widgets/common/page_chrome/page_chrome.dart';
import 'package:immich_mobile/widgets/common/page_chrome/types.dart';
/// The base type for a page rendered as a primary tab.
///
/// Extending [TabPage] is the *only* place that knows the page is a tab: a
/// subclass declares its chrome [content] provider and, if needed, the
/// [scopeBuilder] the provider must be read within. The [PageChrome] is
/// rendered automatically beneath that scope, so it reads the tab's content
/// provider as a descendant (same frame). Nothing beneath the chrome has any
/// knowledge of tabs.
abstract class TabPage extends ConsumerWidget {
const TabPage({super.key});
/// The provider that yields this tab's chrome content (app bar + slivers +
/// controller + decorators). Read beneath [scopeBuilder].
ProviderListenable<PageChromeContent> get content;
/// Produces the scope the [content] provider must be read within (e.g. the
/// timeline's `LayoutBuilder` + `timelineArgsProvider` override). Defaults to
/// the identity scope.
Widget scopeBuilder(BuildContext context, Widget child) => child;
@override
Widget build(BuildContext context, WidgetRef ref) {
return scopeBuilder(context, PageChrome(content: content));
}
}

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
/// Everything a tab contributes to the single shared `PageChrome`.
///
/// Published per-tab via a `Provider<PageChromeContent>`; the chrome reads the
/// active tab's value (within that tab's scope) and renders it. Data flows
/// downward, so the chrome renders with the active tab's content in the same
/// frame.
class PageChromeContent {
/// The app bar sliver placed first in the shared scroll view. The tab decides
/// which bar it wants (e.g. a primary bar, or a selection bar during
/// multiselect).
final Widget appBar;
/// The tab's content slivers, rendered after [appBar].
final List<Widget> slivers;
/// Scroll controller for the shared scroll view. Provided by the tab when it
/// needs custom behaviour (e.g. the timeline's position restore). When `null`,
/// the chrome creates and owns a default controller.
final ScrollController? controller;
/// Optionally decorates the shared scroll view, e.g. wrapping it in the
/// timeline's `Scrubber`. Receives the built scroll view and returns the
/// widget to place in the body.
final Widget Function(CustomScrollView scrollView)? viewportBuilder;
final Widget? floatingActionButton;
/// Widgets stacked on top of the scroll view (e.g. bottom sheet, multiselect
/// status button).
final List<Widget> overlays;
final ScrollPhysics? physics;
/// Forwarded to the shared [CustomScrollView] (e.g. the timeline's larger
/// cache extent).
final ScrollCacheExtent? scrollCacheExtent;
const PageChromeContent({
required this.appBar,
this.slivers = const [],
this.controller,
this.viewportBuilder,
this.floatingActionButton,
this.overlays = const [],
this.physics,
this.scrollCacheExtent,
});
}

View File

@@ -20,17 +20,23 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_dialog.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
import 'package:immich_ui/immich_ui.dart';
class ImmichSliverAppBar extends ConsumerWidget {
/// The app's standard app bar. Composes Immich-specific indicators (sync,
/// backup, profile, cast) and the multiselect dim behaviour onto the generic
/// [ImmichSliverAppBar] primitive from the ui package.
class PrimaryAppBar extends ConsumerWidget {
final List<Widget>? actions;
final bool showUploadButton;
final bool floating;
final bool pinned;
final bool snap;
final Widget? title;
final Widget? leading;
final bool automaticallyImplyLeading;
final double? expandedHeight;
const ImmichSliverAppBar({
const PrimaryAppBar({
super.key,
this.actions,
this.showUploadButton = true,
@@ -38,6 +44,8 @@ class ImmichSliverAppBar extends ConsumerWidget {
this.pinned = false,
this.snap = true,
this.title,
this.leading,
this.automaticallyImplyLeading = false,
this.expandedHeight,
});
@@ -47,38 +55,27 @@ class ImmichSliverAppBar extends ConsumerWidget {
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
return SliverIgnorePointer(
ignoring: isMultiSelectEnabled,
sliver: SliverAnimatedOpacity(
duration: Durations.medium1,
opacity: isMultiSelectEnabled ? 0 : 1,
sliver: SliverAppBar(
backgroundColor: context.colorScheme.surface,
surfaceTintColor: context.colorScheme.surfaceTint,
elevation: 0,
scrolledUnderElevation: 1.0,
floating: floating,
pinned: pinned,
snap: snap,
expandedHeight: expandedHeight,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(bottom: Radius.circular(5))),
automaticallyImplyLeading: false,
centerTitle: false,
title: title ?? const _ImmichLogoWithText(),
actions: [
const _SyncStatusIndicator(),
if (isCasting && !isReadonlyModeEnabled)
IconButton(
onPressed: () => showDialog(context: context, builder: (context) => const CastDialog()),
icon: Icon(isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded),
),
if (actions != null) ...actions!,
if (showUploadButton && !isReadonlyModeEnabled) const _BackupIndicator(),
const _ProfileIndicator(),
const SizedBox(width: 8),
],
),
),
return ImmichSliverAppBar(
floating: floating,
pinned: pinned,
snap: snap,
expandedHeight: expandedHeight,
dimmed: isMultiSelectEnabled,
automaticallyImplyLeading: automaticallyImplyLeading,
leading: leading,
title: title ?? const _ImmichLogoWithText(),
actions: [
const _SyncStatusIndicator(),
if (isCasting && !isReadonlyModeEnabled)
IconButton(
onPressed: () => showDialog(context: context, builder: (context) => const CastDialog()),
icon: Icon(isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded),
),
if (actions != null) ...actions!,
if (showUploadButton && !isReadonlyModeEnabled) const _BackupIndicator(),
const _ProfileIndicator(),
const SizedBox(width: 8),
],
);
}
}

View File

@@ -1,29 +1,29 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools."aqua:flutter/flutter"]]
version = "3.44.1"
version = "3.44.6"
backend = "aqua:flutter/flutter"
[tools."aqua:flutter/flutter"."platforms.linux-arm64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.6-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-arm64-musl"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.6-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.6-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.linux-x64-musl"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.1-stable.tar.xz"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_3.44.6-stable.tar.xz"
[tools."aqua:flutter/flutter"."platforms.macos-arm64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.44.1-stable.zip"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_arm64_3.44.6-stable.zip"
[tools."aqua:flutter/flutter"."platforms.macos-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.44.1-stable.zip"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos_3.44.6-stable.zip"
[tools."aqua:flutter/flutter"."platforms.windows-x64"]
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.44.1-stable.zip"
url = "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_3.44.6-stable.zip"
[[tools."github:CQLabs/homebrew-dcm"]]
version = "1.37.0"

View File

@@ -1,5 +1,5 @@
[tools]
"aqua:flutter/flutter" = "3.44.1"
"aqua:flutter/flutter" = "3.44.6"
java = "21.0.2"
[tools."github:CQLabs/homebrew-dcm"]

View File

@@ -681,8 +681,10 @@ class AlbumsApi {
/// Parameters:
///
/// * [String] id (required):
/// Album ID
///
/// * [String] userId (required):
/// Album user ID, or \"me\" to reference the current user.
Future<Response> removeUserFromAlbumWithHttpInfo(String id, String userId, { Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/albums/{id}/user/{userId}'
@@ -718,8 +720,10 @@ class AlbumsApi {
/// Parameters:
///
/// * [String] id (required):
/// Album ID
///
/// * [String] userId (required):
/// Album user ID, or \"me\" to reference the current user.
Future<void> removeUserFromAlbum(String id, String userId, { Future<void>? abortTrigger, }) async {
final response = await removeUserFromAlbumWithHttpInfo(id, userId, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
@@ -798,8 +802,10 @@ class AlbumsApi {
/// Parameters:
///
/// * [String] id (required):
/// Album ID
///
/// * [String] userId (required):
/// Album user ID, or \"me\" to reference the current user.
///
/// * [UpdateAlbumUserDto] updateAlbumUserDto (required):
Future<Response> updateAlbumUserWithHttpInfo(String id, String userId, UpdateAlbumUserDto updateAlbumUserDto, { Future<void>? abortTrigger, }) async {
@@ -837,8 +843,10 @@ class AlbumsApi {
/// Parameters:
///
/// * [String] id (required):
/// Album ID
///
/// * [String] userId (required):
/// Album user ID, or \"me\" to reference the current user.
///
/// * [UpdateAlbumUserDto] updateAlbumUserDto (required):
Future<void> updateAlbumUser(String id, String userId, UpdateAlbumUserDto updateAlbumUserDto, { Future<void>? abortTrigger, }) async {

View File

@@ -1,4 +1,5 @@
export 'src/color_override.dart';
export 'src/components/app_bar/app_bar.dart';
export 'src/components/close_button.dart';
export 'src/components/column_button.dart';
export 'src/components/form.dart';

View File

@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
/// A generic, domain-free sliver app bar primitive.
///
/// It only handles layout, theming and the optional dim animation. Callers are
/// responsible for supplying the [title], [leading] and [actions] widgets.
class ImmichSliverAppBar extends StatelessWidget {
final Widget? title;
final Widget? leading;
final bool automaticallyImplyLeading;
final List<Widget> actions;
final bool floating;
final bool pinned;
final bool snap;
final double? expandedHeight;
/// When `true`, the bar fades out and stops receiving pointer events.
final bool dimmed;
const ImmichSliverAppBar({
super.key,
this.title,
this.leading,
this.automaticallyImplyLeading = false,
this.actions = const [],
this.floating = true,
this.pinned = false,
this.snap = true,
this.expandedHeight,
this.dimmed = false,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return SliverIgnorePointer(
ignoring: dimmed,
sliver: SliverAnimatedOpacity(
duration: ImmichDuration.moderate,
opacity: dimmed ? 0 : 1,
sliver: SliverAppBar(
backgroundColor: colorScheme.surface,
surfaceTintColor: colorScheme.surfaceTint,
elevation: ImmichElevation.none,
scrolledUnderElevation: ImmichElevation.xs,
floating: floating,
pinned: pinned,
snap: snap,
expandedHeight: expandedHeight,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(bottom: Radius.circular(5))),
automaticallyImplyLeading: automaticallyImplyLeading,
leading: leading,
centerTitle: false,
title: title,
actions: actions,
),
),
);
}
}

View File

@@ -1056,26 +1056,26 @@ packages:
dependency: "direct main"
description:
name: maplibre_gl
sha256: "5c7b1008396b2a321bada7d986ed60f9423406fbc7bd16f7ce91b385dfa054cd"
sha256: b676f124a2fcf88c4dafedc7b462155e6396d61ce7af46354b4de11b45c9812f
url: "https://pub.dev"
source: hosted
version: "0.22.0"
version: "0.26.2"
maplibre_gl_platform_interface:
dependency: transitive
description:
name: maplibre_gl_platform_interface
sha256: "08ee0a2d0853ea945a0ab619d52c0c714f43144145cd67478fc6880b52f37509"
sha256: "1f0ca8a99f03fa9434618ee21f4e42dd615830a7dd973e632b11c73ffec993a8"
url: "https://pub.dev"
source: hosted
version: "0.22.0"
version: "0.26.2"
maplibre_gl_web:
dependency: transitive
description:
name: maplibre_gl_web
sha256: "2b13d4b1955a9a54e38a718f2324e56e4983c080fc6de316f6f4b5458baacb58"
sha256: bbf022f29ceef26d73f63e584819fbd02fbaf4eb1facf5234206c78936cf8f1c
url: "https://pub.dev"
source: hosted
version: "0.22.0"
version: "0.26.2"
matcher:
dependency: transitive
description:
@@ -2014,4 +2014,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.12.0 <4.0.0"
flutter: "3.44.1"
flutter: "3.44.6"

View File

@@ -6,7 +6,7 @@ version: 3.0.3+3056
environment:
sdk: '>=3.12.0 <4.0.0'
flutter: 3.44.1
flutter: 3.44.6
dependencies:
async: ^2.13.1
@@ -44,7 +44,7 @@ dependencies:
intl: ^0.20.2
local_auth: ^2.3.0
logging: ^1.3.0
maplibre_gl: ^0.22.0
maplibre_gl: ^0.26.0
native_video_player:
git:
url: https://github.com/immich-app/native_video_player

View File

@@ -2702,6 +2702,7 @@
"name": "id",
"required": true,
"in": "path",
"description": "Album ID",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
@@ -2712,6 +2713,14 @@
"name": "userId",
"required": true,
"in": "path",
"description": "Album user ID, or \"me\" to reference the current user.",
"x-immich-history": [
{
"version": "v3",
"state": "Updated",
"description": "\"me\" as a value is deprecated"
}
],
"schema": {
"type": "string"
}
@@ -2762,6 +2771,7 @@
"name": "id",
"required": true,
"in": "path",
"description": "Album ID",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
@@ -2772,6 +2782,14 @@
"name": "userId",
"required": true,
"in": "path",
"description": "Album user ID, or \"me\" to reference the current user.",
"x-immich-history": [
{
"version": "v3",
"state": "Updated",
"description": "\"me\" as a value is deprecated"
}
],
"schema": {
"type": "string"
}

View File

@@ -4,7 +4,14 @@ import path from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
import { describe, expect, it, MockedFunction, vi } from 'vitest';
import { AssetRejectReason, AssetUploadAction, checkBulkUpload, defaults, getSupportedMediaTypes } from '@immich/sdk';
import {
AssetRejectReason,
AssetUploadAction,
AssetVisibility,
checkBulkUpload,
defaults,
getSupportedMediaTypes,
} from '@immich/sdk';
import createFetchMock from 'vitest-fetch-mock';
import {
@@ -102,6 +109,20 @@ describe('uploadFiles', () => {
await expect(uploadFiles([testFilePath], { concurrency: 1 })).resolves.toEqual([]);
});
it('uploads assets with the specified visibility', async () => {
fetchMocker.doMockIf(new RegExp(`${baseUrl}/assets$`), function () {
return {
status: 200,
body: JSON.stringify({ id: 'fc5621b1-86f6-44a1-9905-403e607df9f5', status: 'created' }),
};
});
await uploadFiles([testFilePath], { concurrency: 1, visibility: AssetVisibility.Hidden });
const formData = fetchMocker.mock.calls[0]?.[1]?.body as FormData;
expect(formData.get('visibility')).toBe('hidden');
});
});
describe('checkForDuplicates', () => {

View File

@@ -4,6 +4,7 @@ import {
AssetMediaResponseDto,
AssetMediaStatus,
AssetUploadAction,
AssetVisibility,
Permission,
addAssetsToAlbum,
checkBulkUpload,
@@ -39,6 +40,7 @@ export interface UploadOptionsDto {
deleteDuplicates?: boolean;
album?: boolean;
albumName?: string;
visibility?: AssetVisibility;
includeHidden?: boolean;
concurrency: number;
progress?: boolean;
@@ -306,10 +308,8 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
return { newFiles, duplicates };
};
export const uploadFiles = async (
files: string[],
{ dryRun, concurrency, progress }: UploadOptionsDto,
): Promise<Asset[]> => {
export const uploadFiles = async (files: string[], options: UploadOptionsDto): Promise<Asset[]> => {
const { dryRun, concurrency, progress } = options;
if (files.length === 0) {
console.log('All assets were already uploaded, nothing to do.');
return [];
@@ -358,7 +358,7 @@ export const uploadFiles = async (
throw new Error(`Stats not found for ${filepath}`);
}
const response = await uploadFile(filepath, stats);
const response = await uploadFile(filepath, stats, options);
newAssets.push({ id: response.id, filepath });
if (response.status === AssetMediaStatus.Duplicate) {
duplicateCount++;
@@ -400,7 +400,11 @@ export const uploadFiles = async (
return newAssets;
};
const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaResponseDto> => {
const uploadFile = async (
input: string,
stats: Stats,
{ visibility }: UploadOptionsDto,
): Promise<AssetMediaResponseDto> => {
const { baseUrl, headers } = defaults;
const formData = new FormData();
@@ -409,6 +413,9 @@ const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaRespon
formData.append('fileSize', String(stats.size));
formData.append('isFavorite', 'false');
formData.append('assetData', new UploadFile(input, stats.size));
if (visibility) {
formData.append('visibility', visibility);
}
const sidecarPath = findSidecar(input);
if (sidecarPath) {

View File

@@ -1,4 +1,5 @@
#! /usr/bin/env node
import { AssetVisibility } from '@immich/sdk';
import { Command, Option } from 'commander';
import os from 'node:os';
import path from 'node:path';
@@ -58,6 +59,11 @@ program
.env('IMMICH_ALBUM_NAME')
.conflicts('album'),
)
.addOption(
new Option('--visibility <visibility>', 'Set the visibility of uploaded assets')
.choices(Object.values(AssetVisibility))
.env('IMMICH_VISIBILITY'),
)
.addOption(
new Option('-n, --dry-run', "Don't perform any actions, just show what will be done")
.env('IMMICH_DRY_RUN')

View File

@@ -56,7 +56,7 @@ FROM builder AS plugins
ARG TARGETPLATFORM
COPY --from=ghcr.io/jdx/mise:2026.7.2@sha256:51d92371cc08e3c4f0e52f58bb3ec1975a6dc966b7430a4da37dcfc6ae772f30 /usr/local/bin/mise /usr/local/bin/mise
COPY --from=ghcr.io/jdx/mise:2026.7.7@sha256:ead960a5f0d2834a78e4640b8e1579d0fcf08cc52a16810dba33b06abf13282e /usr/local/bin/mise /usr/local/bin/mise
WORKDIR /app
COPY ./mise.toml ./mise.toml

View File

@@ -2,7 +2,7 @@
FROM ghcr.io/immich-app/base-server-dev:202607132058@sha256:968709a3668167f11ef8ece5962e72ea8a4231e841f98bd5e0f35da4cc295cc7 AS dev
COPY --from=ghcr.io/jdx/mise:2026.7.2@sha256:51d92371cc08e3c4f0e52f58bb3ec1975a6dc966b7430a4da37dcfc6ae772f30 /usr/local/bin/mise /usr/local/bin/mise
COPY --from=ghcr.io/jdx/mise:2026.7.7@sha256:ead960a5f0d2834a78e4640b8e1579d0fcf08cc52a16810dba33b06abf13282e /usr/local/bin/mise /usr/local/bin/mise
RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \

View File

@@ -7,6 +7,7 @@ import {
AlbumsAddAssetsDto,
AlbumsAddAssetsResponseDto,
AlbumStatisticsResponseDto,
AlbumUserParamDto,
CreateAlbumDto,
GetAlbumsDto,
UpdateAlbumDto,
@@ -18,7 +19,7 @@ import { MapMarkerResponseDto } from 'src/dtos/map.dto';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { AlbumService } from 'src/services/album.service';
import { ParseMeUUIDPipe, UUIDParamDto } from 'src/validation';
import { UUIDParamDto } from 'src/validation';
@ApiTags(ApiTag.Albums)
@Controller('albums')
@@ -175,8 +176,7 @@ export class AlbumController {
})
updateAlbumUser(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Param('userId', new ParseMeUUIDPipe({ version: '4' })) userId: string,
@Param() { id, userId }: AlbumUserParamDto,
@Body() dto: UpdateAlbumUserDto,
): Promise<void> {
return this.service.updateUser(auth, id, userId, dto);
@@ -190,11 +190,7 @@ export class AlbumController {
description: 'Remove a user from an album. Use an ID of "me" to leave a shared album.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
removeUserFromAlbum(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Param('userId', new ParseMeUUIDPipe({ version: '4' })) userId: string,
): Promise<void> {
removeUserFromAlbum(@Auth() auth: AuthDto, @Param() { id, userId }: AlbumUserParamDto): Promise<void> {
return this.service.removeUser(auth, id, userId);
}
}

View File

@@ -1,6 +1,7 @@
import { ShallowDehydrateObject } from 'kysely';
import { createZodDto } from 'nestjs-zod';
import { AlbumUser, AuthSharedLink } from 'src/database';
import { HistoryBuilder } from 'src/decorators';
import { BulkIdErrorReasonSchema } from 'src/dtos/asset-ids.response.dto';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { UserResponseSchema, mapUser } from 'src/dtos/user.dto';
@@ -140,6 +141,19 @@ export const AlbumResponseSchema = z
})
.meta({ id: 'AlbumResponseDto' });
const AlbumUserParamSchema = z.object({
id: z.uuidv4().describe('Album ID'),
// TODO: disallow 'me' as a shortcut in v4 and type userId as uuidv4
userId: z
.string()
.refine((value) => value === 'me' || z.uuidv4().safeParse(value).success, {
error: 'Must be a UUID v4 or "me"',
})
.describe('Album user ID, or "me" to reference the current user.')
.meta(new HistoryBuilder().updated('v3', '"me" as a value is deprecated').getExtensions()),
});
export class AlbumUserParamDto extends createZodDto(AlbumUserParamSchema) {}
export class AddUsersDto extends createZodDto(AddUsersSchema) {}
export class AlbumUserCreateDto extends createZodDto(AlbumUserCreateSchema) {}
export class CreateAlbumDto extends createZodDto(CreateAlbumSchema) {}

View File

@@ -1,4 +1,4 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { BadRequestException, CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { PATH_METADATA } from '@nestjs/common/constants';
import { Reflector } from '@nestjs/core';
import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils';
@@ -129,6 +129,9 @@ export class FileUploadInterceptor implements NestInterceptor {
hash?.destroy();
return callback(error);
}
if (size === 0) {
return callback(new BadRequestException('File is empty'));
}
callback(null, {
path,
size,

View File

@@ -42,7 +42,7 @@ type EventMap = {
AlbumInvite: [{ id: string; userId: string; senderName: string }];
// asset events
AssetCreate: [{ asset: Asset; file: UploadFile }];
AssetCreate: [{ asset: Pick<Asset, 'id' | 'ownerId'>; file?: UploadFile }];
AssetTag: [{ assetId: string }];
AssetUntag: [{ assetId: string }];
AssetHide: [{ assetId: string; userId: string }];

View File

@@ -411,7 +411,7 @@ export class MediaRepository {
}
async getImageMetadata(input: string | Buffer): Promise<ImageDimensions & { isTransparent: boolean }> {
const { width = 0, height = 0, hasAlpha = false } = await sharp(input).metadata();
const { width = 0, height = 0, hasAlpha = false } = await sharp(input, { unlimited: true }).metadata();
return { width, height, isTransparent: hasAlpha };
}

View File

@@ -20,14 +20,7 @@ export const render = (index: string, meta: OpenGraphTags) => {
<meta property="og:type" content="website" />
<meta property="og:title" content="${title}" />
<meta property="og:description" content="${description}" />
${imageUrl ? `<meta property="og:image" content="${imageUrl}" />` : ''}
<!-- Twitter Meta Tags -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="${title}" />
<meta name="twitter:description" content="${description}" />
${imageUrl ? `<meta name="twitter:image" content="${imageUrl}" />` : ''}`;
${imageUrl ? `<meta property="og:image" content="${imageUrl}" />` : ''}`;
return index.replace('<!-- metadata:tags -->', tags);
};

View File

@@ -576,6 +576,10 @@ describe(LibraryService.name, () => {
}),
]);
expect(mocks.event.emit).toHaveBeenCalledWith('AssetCreate', {
asset: { id: asset.id, ownerId: library.ownerId },
});
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.SidecarCheck,

View File

@@ -275,6 +275,12 @@ export class LibraryService extends BaseService {
this.logger.log(`Imported ${assetIds.length} ${progressMessage} file(s) into library ${job.libraryId}`);
await Promise.all(
assetIds.map((assetId) =>
this.eventRepository.emit('AssetCreate', { asset: { id: assetId, ownerId: library.ownerId } }),
),
);
await this.queuePostSyncJobs(assetIds);
return JobStatus.Success;

View File

@@ -239,7 +239,9 @@ export class UserService extends BaseService {
@OnEvent({ name: 'AssetCreate' })
async onAssetCreate({ asset, file }: ArgOf<'AssetCreate'>) {
await this.userRepository.updateUsage(asset.ownerId, file.size);
if (file) {
await this.userRepository.updateUsage(asset.ownerId, file.size);
}
}
@OnJob({ name: JobName.UserSyncUsage, queue: QueueName.BackgroundTask })

View File

@@ -1,4 +1,4 @@
import { ArgumentMetadata, FileValidator, Injectable, ParseUUIDPipe } from '@nestjs/common';
import { FileValidator, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
import { createZodDto } from 'nestjs-zod';
import sanitize from 'sanitize-filename';
@@ -75,16 +75,6 @@ export function IsNotSiblingOf<
);
}
@Injectable()
export class ParseMeUUIDPipe extends ParseUUIDPipe {
async transform(value: string, metadata: ArgumentMetadata) {
if (value == 'me') {
return value;
}
return super.transform(value, metadata);
}
}
@Injectable()
export class FileNotEmptyValidator extends FileValidator {
constructor(private requiredFields: string[]) {

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import SharedLinkExpiration from '$lib/components/SharedLinkExpiration.svelte';
import { Field, Input, PasswordInput, Switch, Text } from '@immich/ui';
import { Field, HelperText, Input, PasswordInput, Switch, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
type Props = {
@@ -32,8 +32,11 @@
<div class="mt-4 flex flex-col gap-4">
<div>
<Field label={$t('custom_url')} description={$t('shared_link_custom_url_description')}>
<Field label={$t('shared_link_custom_url_title')} description={$t('shared_link_custom_url_description')}>
<Input bind:value={slug} autocomplete="off" />
{#if slug.includes('/')}
<HelperText class="text-warning">{$t('shared_link_custom_url_warning')}</HelperText>
{/if}
</Field>
{#if slug}
<Text size="tiny" color="muted" class="pt-2 break-all">/s/{encodeURIComponent(slug)}</Text>

View File

@@ -15,7 +15,7 @@
let birthDate = $derived(person.birthDate ? DateTime.fromISO(person.birthDate) : undefined);
const onSubmit = async () => {
const success = await handleUpdatePersonBirthDate(person, birthDate?.toISODate() ?? '');
const success = await handleUpdatePersonBirthDate(person, birthDate?.toISODate() ?? null);
if (success) {
onClose();
}

View File

@@ -24,6 +24,20 @@ describe('Route', () => {
});
});
describe(Route.viewSharedLink.name, () => {
it('should work with key', () => {
expect(Route.viewSharedLink({ key: 'uuid-key' })).toBe('/share/uuid-key');
});
it('should work with key and slug', () => {
expect(Route.viewSharedLink({ key: 'uuid-key', slug: 'custom-slug' })).toBe('/s/custom-slug');
});
it('should URI encode slug', () => {
expect(Route.viewSharedLink({ key: 'uuid-key', slug: 'albums/the-moon?' })).toBe('/s/albums%2Fthe-moon%3F');
});
});
describe(Route.tags.name, () => {
it('should work', () => {
expect(Route.tags()).toBe('/tags');

View File

@@ -120,7 +120,8 @@ export const Route = {
// shared links
sharedLinks: (params?: { filter?: SharedLinkTab }) => '/shared-links' + asQueryString(params),
editSharedLink: ({ id }: { id: string }) => `/shared-links/${id}/edit`,
viewSharedLink: ({ slug, key }: { slug?: string | null; key: string }) => (slug ? `/s/${slug}` : `/share/${key}`),
viewSharedLink: ({ slug, key }: { slug?: string | null; key: string }) =>
slug ? `/s/${encodeURIComponent(slug)}` : `/share/${key}`,
// settings
userSettings: (params?: { isOpen?: OpenQueryParam }) => '/user-settings' + asQueryString(params),

View File

@@ -99,7 +99,7 @@ const handleShowPerson = async (person: { id: string }) => {
}
};
export const handleUpdatePersonBirthDate = async (person: PersonResponseDto, birthDate: string) => {
export const handleUpdatePersonBirthDate = async (person: PersonResponseDto, birthDate: string | null) => {
const $t = await getFormatter();
try {

View File

@@ -60,9 +60,7 @@ export const getSharedLinkActions = ($t: MessageFormatter, sharedLink: SharedLin
};
export const asUrl = (sharedLink: SharedLinkResponseDto) => {
const path = sharedLink.slug
? `s/${encodeURIComponent(sharedLink.slug)}`
: `share/${encodeURIComponent(sharedLink.key)}`;
const path = Route.viewSharedLink(sharedLink);
return new URL(path, serverConfigManager.value.externalDomain || globalThis.location.origin).href;
};

View File

@@ -0,0 +1,50 @@
import { on } from 'svelte/events';
import { browser } from '$app/environment';
const isSupported = browser && 'wakeLock' in navigator;
let sentinel: WakeLockSentinel | undefined;
let acquiring = false;
export async function acquireWakeLock() {
if (!isSupported) {
return;
}
// eslint-disable-next-line tscompat/tscompat
if (sentinel && !sentinel.released) {
return;
}
if (acquiring) {
return;
}
acquiring = true;
try {
// eslint-disable-next-line tscompat/tscompat
sentinel = await navigator.wakeLock.request('screen');
} catch (error) {
console.warn('Failed to acquire wake lock:', error);
} finally {
acquiring = false;
}
}
export async function releaseWakeLock() {
if (sentinel) {
const toReleaseSentinel = sentinel;
// Unset first to avoid race condition after await
sentinel = undefined;
// eslint-disable-next-line tscompat/tscompat
await toReleaseSentinel.release();
}
}
if (isSupported) {
// Wake lock is cleared when user changes to a different tab,
// so we need to reacquire the wake lock when they come back.
on(globalThis, 'visibilitychange', () => {
if (sentinel && document.visibilityState === 'visible') {
void acquireWakeLock();
}
});
}

View File

@@ -56,8 +56,10 @@
{#if shareType === SharedLinkType.Individual}
<div class="text-sm">
{$t('individual_share')} |
<span class="text-primary">{sharedLink.description || ''}</span>
{$t('individual_share')}
{#if description !== ''}
| <span class="text-primary">{description}</span>
{/if}
</div>
{/if}

View File

@@ -256,20 +256,6 @@
).href}
/>
{/if}
<!-- Twitter Meta Tags -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={page.data.meta.title} />
<meta name="twitter:description" content={page.data.meta.description} />
{#if page.data.meta.imageUrl}
<meta
name="twitter:image"
content={new URL(
page.data.meta.imageUrl,
serverConfigManager.value.externalDomain || globalThis.location.origin,
).href}
/>
{/if}
{/if}
</svelte:head>

View File

@@ -2,6 +2,7 @@
import { locale } from '$lib/stores/preferences.store';
import { uploadAssetsStore } from '$lib/stores/upload';
import { uploadExecutionQueue } from '$lib/utils/file-uploader';
import { acquireWakeLock, releaseWakeLock } from '$lib/utils/wakelock.svelte';
import { Icon, IconButton, toastManager } from '@immich/ui';
import { mdiCancel, mdiCloudUploadOutline, mdiCog, mdiWindowMinimize } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -15,11 +16,21 @@
let { stats, isDismissible, isUploading, remainingUploads } = uploadAssetsStore;
let hasRemaining = $derived($remainingUploads > 0);
$effect(() => {
if ($isUploading) {
showDetail = true;
}
});
$effect(() => {
if (hasRemaining) {
void acquireWakeLock();
} else {
void releaseWakeLock();
}
});
</script>
{#if $isUploading}

View File

@@ -1,7 +1,8 @@
# This site is running an Immich instance.
# Immich-related security problems should be reported to the Immich security team.
# Security problems related to this instance should be reported to its administration.
Policy: https://github.com/immich-app/immich/blob/main/SECURITY.md
Policy: https://github.com/immich-app/immich?tab=security-ov-file
Contact: https://github.com/immich-app/immich/security/advisories/new
Contact: mailto:security@immich.app
Preferred-Languages: en
Expires: 2027-05-01T23:59:00.000Z