From f13a5ba41854fd9ba2fb23d756d942c00329f123 Mon Sep 17 00:00:00 2001 From: idubnori Date: Sat, 6 Dec 2025 23:08:37 +0900 Subject: [PATCH] feat(mobile): add option to show asset owner name in asset list --- i18n/en.json | 1 + i18n/ja.json | 1 + mobile/lib/domain/models/setting.model.dart | 3 +- mobile/lib/domain/models/store.model.dart | 1 + mobile/lib/entities/asset.entity.dart | 4 ++ mobile/lib/pages/album/album_viewer.dart | 1 + .../widgets/images/thumbnail_tile.widget.dart | 37 +++++++++++++++++++ .../widgets/timeline/fixed/segment.model.dart | 35 ++++++++++++++++++ mobile/lib/services/app_settings.service.dart | 3 +- .../widgets/asset_grid/immich_asset_grid.dart | 4 ++ .../asset_grid/immich_asset_grid_view.dart | 36 ++++++++++++++++++ .../widgets/asset_grid/multiselect_grid.dart | 3 ++ .../asset_list_settings.dart | 9 +++++ 13 files changed, 136 insertions(+), 2 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 5903d7850e..b5b014c23a 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2062,6 +2062,7 @@ "theme": "Theme", "theme_selection": "Theme selection", "theme_selection_description": "Automatically set the theme to light or dark based on your browser's system preference", + "theme_setting_asset_list_show_owner_name_title": "Show asset owner name", "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({count})", "theme_setting_colorful_interface_subtitle": "Apply primary color to background surfaces.", diff --git a/i18n/ja.json b/i18n/ja.json index 1fb9fbbd21..08ca75556c 100644 --- a/i18n/ja.json +++ b/i18n/ja.json @@ -2022,6 +2022,7 @@ "theme": "テーマ", "theme_selection": "テーマ選択", "theme_selection_description": "ブラウザのシステム設定に基づいてテーマを明色または暗色に自動的に設定します", + "theme_setting_asset_list_show_owner_name_title": "アセット所有者名を表示", "theme_setting_asset_list_storage_indicator_title": "ストレージに関する情報を表示", "theme_setting_asset_list_tiles_per_row_title": "一行ごとの表示枚数: {count}", "theme_setting_colorful_interface_subtitle": "アクセントカラーを背景にも使用する", diff --git a/mobile/lib/domain/models/setting.model.dart b/mobile/lib/domain/models/setting.model.dart index 2c46507331..ead8aa7b0e 100644 --- a/mobile/lib/domain/models/setting.model.dart +++ b/mobile/lib/domain/models/setting.model.dart @@ -9,7 +9,8 @@ enum Setting { autoPlayVideo(StoreKey.autoPlayVideo, true), preferRemoteImage(StoreKey.preferRemoteImage, false), advancedTroubleshooting(StoreKey.advancedTroubleshooting, false), - enableBackup(StoreKey.enableBackup, false); + enableBackup(StoreKey.enableBackup, false), + showOwnerName(StoreKey.showOwnerName, false); const Setting(this.storeKey, this.defaultValue); diff --git a/mobile/lib/domain/models/store.model.dart b/mobile/lib/domain/models/store.model.dart index a18644cd2a..44bd779c20 100644 --- a/mobile/lib/domain/models/store.model.dart +++ b/mobile/lib/domain/models/store.model.dart @@ -72,6 +72,7 @@ enum StoreKey { autoPlayVideo._(139), albumGridView._(140), + showOwnerName._(141), // Experimental stuff photoManagerCustomFilter._(1000), diff --git a/mobile/lib/entities/asset.entity.dart b/mobile/lib/entities/asset.entity.dart index 0d549457a1..a8ce8888eb 100644 --- a/mobile/lib/entities/asset.entity.dart +++ b/mobile/lib/entities/asset.entity.dart @@ -169,6 +169,10 @@ class Asset { @Enumerated(EnumType.ordinal) AssetVisibilityEnum visibility; + /// Transient field for storing owner name (used in shared albums) + @ignore + String? ownerName; + /// Returns null if the asset has no sync access to the exif info @ignore double? get aspectRatio { diff --git a/mobile/lib/pages/album/album_viewer.dart b/mobile/lib/pages/album/album_viewer.dart index 97853fb96a..cdc649a7f0 100644 --- a/mobile/lib/pages/album/album_viewer.dart +++ b/mobile/lib/pages/album/album_viewer.dart @@ -104,6 +104,7 @@ class AlbumViewer extends HookConsumerWidget { MultiselectGrid( key: const ValueKey("albumViewerMultiselectGrid"), renderListProvider: albumTimelineProvider(album.id), + album: album, topWidget: Container( decoration: BoxDecoration( gradient: LinearGradient( diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index c7628cb472..c20fc025dc 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -19,6 +19,7 @@ class ThumbnailTile extends ConsumerWidget { this.showStorageIndicator = false, this.lockSelection = false, this.heroOffset, + this.ownerName, super.key, }); @@ -28,6 +29,7 @@ class ThumbnailTile extends ConsumerWidget { final bool showStorageIndicator; final bool lockSelection; final int? heroOffset; + final String? ownerName; @override Widget build(BuildContext context, WidgetRef ref) { @@ -45,6 +47,9 @@ class ThumbnailTile extends ConsumerWidget { final bool storageIndicator = ref.watch(settingsProvider.select((s) => s.get(Setting.showStorageIndicator))) && showStorageIndicator; + final bool showOwnerNameSetting = ref.watch(settingsProvider.select((s) => s.get(Setting.showOwnerName))); + final shouldShowOwnerName = showOwnerNameSetting && ownerName != null; + return Stack( children: [ Container(color: lockSelection ? context.colorScheme.surfaceContainerHighest : assetContainerColor), @@ -72,6 +77,14 @@ class ThumbnailTile extends ConsumerWidget { alignment: Alignment.topRight, child: _AssetTypeIcons(asset: asset), ), + if (shouldShowOwnerName) + Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: const EdgeInsets.only(right: 10.0, bottom: 6.0), + child: _OwnerNameLabel(ownerName: ownerName!), + ), + ), if (storageIndicator && asset != null) switch (asset.storage) { AssetState.local => const Align( @@ -229,3 +242,27 @@ class _AssetTypeIcons extends StatelessWidget { ); } } + +class _OwnerNameLabel extends StatelessWidget { + final String ownerName; + + const _OwnerNameLabel({required this.ownerName}); + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(maxWidth: 120), + child: Text( + ownerName, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + shadows: [Shadow(blurRadius: 5.0, color: Color.fromRGBO(0, 0, 0, 0.6), offset: Offset(0.0, 0.0))], + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index b879b33f68..ed5dc304f2 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -18,6 +18,7 @@ import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.pro import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -191,6 +192,38 @@ class _AssetTileWidget extends ConsumerWidget { return lockSelectionAssets.contains(asset); } + String? _getOwnerName(WidgetRef ref) { + final album = ref.watch(currentRemoteAlbumProvider); + if (album == null || !album.isShared) { + return null; + } + + if (asset case RemoteAsset remoteAsset) { + final ownerId = remoteAsset.ownerId; + + // If owner matches album owner + if (album.ownerId == ownerId) { + return album.ownerName; + } + + // Check shared users + final sharedUsersAsync = ref.watch(remoteAlbumSharedUsersProvider(album.id)); + return sharedUsersAsync.maybeWhen( + data: (sharedUsers) { + for (final user in sharedUsers) { + if (user.id == ownerId) { + return user.name; + } + } + return null; + }, + orElse: () => null, + ); + } + + return null; + } + @override Widget build(BuildContext context, WidgetRef ref) { final heroOffset = TabsRouterScope.of(context)?.controller.activeIndex ?? 0; @@ -198,6 +231,7 @@ class _AssetTileWidget extends ConsumerWidget { final lockSelection = _getLockSelectionStatus(ref); final showStorageIndicator = ref.watch(timelineArgsProvider.select((args) => args.showStorageIndicator)); final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); + final ownerName = _getOwnerName(ref); return RepaintBoundary( child: GestureDetector( @@ -208,6 +242,7 @@ class _AssetTileWidget extends ConsumerWidget { lockSelection: lockSelection, showStorageIndicator: showStorageIndicator, heroOffset: heroOffset, + ownerName: ownerName, ), ), ); diff --git a/mobile/lib/services/app_settings.service.dart b/mobile/lib/services/app_settings.service.dart index aa247682a7..c47c9e76d3 100644 --- a/mobile/lib/services/app_settings.service.dart +++ b/mobile/lib/services/app_settings.service.dart @@ -54,7 +54,8 @@ enum AppSettingsEnum { readonlyModeEnabled(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false), albumGridView(StoreKey.albumGridView, "albumGridView", false), backupRequireCharging(StoreKey.backupRequireCharging, null, false), - backupTriggerDelay(StoreKey.backupTriggerDelay, null, 30); + backupTriggerDelay(StoreKey.backupTriggerDelay, null, 30), + showOwnerName(StoreKey.showOwnerName, "showOwnerName", false); const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue); diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid.dart index ab6b350a7b..e128145e43 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid.dart @@ -11,6 +11,7 @@ import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart' import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid_view.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; @@ -33,6 +34,7 @@ class ImmichAssetGrid extends HookConsumerWidget { final bool showDragScroll; final bool showDragScrollLabel; final bool showStack; + final Album? album; const ImmichAssetGrid({ super.key, @@ -54,6 +56,7 @@ class ImmichAssetGrid extends HookConsumerWidget { this.showDragScroll = true, this.showDragScrollLabel = true, this.showStack = false, + this.album, }); @override @@ -115,6 +118,7 @@ class ImmichAssetGrid extends HookConsumerWidget { showDragScroll: showDragScroll, showStack: showStack, showLabel: showDragScrollLabel, + album: album, ), ); } diff --git a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart index 7db03a33aa..19ebf3b3f0 100644 --- a/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart @@ -10,7 +10,9 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/utils/hash.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; @@ -55,6 +57,7 @@ class ImmichAssetGridView extends ConsumerStatefulWidget { final bool showDragScroll; final bool showStack; final bool showLabel; + final Album? album; const ImmichAssetGridView({ super.key, @@ -76,6 +79,7 @@ class ImmichAssetGridView extends ConsumerStatefulWidget { this.showDragScroll = true, this.showStack = false, this.showLabel = true, + this.album, }); @override @@ -153,6 +157,32 @@ class ImmichAssetGridViewState extends ConsumerState { return widget.selectionActive && assets.firstWhereOrNull((e) => !_selectedAssets.contains(e)) == null; } + String? _getOwnerName(Asset asset) { + final album = widget.album; + if (album == null || !album.shared) { + return null; + } + + // Load owner and sharedUsers if not loaded + album.owner.loadSync(); + album.sharedUsers.loadSync(); + + // Check if asset owner matches album owner + final owner = album.owner.value; + if (owner != null && asset.ownerId == fastHash(owner.id)) { + return owner.name; + } + + // Check shared users + for (final user in album.sharedUsers) { + if (asset.ownerId == fastHash(user.id)) { + return user.name; + } + } + + return null; + } + Future _scrollToIndex(int index) async { // if the index is so far down, that the end of the list is reached on the screen // the scroll_position widget crashes. This is a workaround to prevent this. @@ -197,6 +227,7 @@ class ImmichAssetGridViewState extends ConsumerState { ref.read(showControlsProvider.notifier).show = false; } }, + getOwnerName: _getOwnerName, ); } @@ -580,6 +611,7 @@ class _Section extends StatelessWidget { final int heroOffset; final bool showStorageIndicator; final void Function(Asset) onAssetTap; + final String? Function(Asset)? getOwnerName; const _Section({ required this.section, @@ -598,6 +630,7 @@ class _Section extends StatelessWidget { required this.heroOffset, required this.showStorageIndicator, required this.onAssetTap, + this.getOwnerName, }); @override @@ -651,6 +684,7 @@ class _Section extends StatelessWidget { onSelect: (asset) => selectAssets([asset]), onDeselect: (asset) => deselectAssets([asset]), onAssetTap: onAssetTap, + getOwnerName: getOwnerName, ), ], ); @@ -730,6 +764,7 @@ class _AssetRow extends StatelessWidget { final void Function(Asset)? onSelect; final void Function(Asset)? onDeselect; final bool isSelectionActive; + final String? Function(Asset)? getOwnerName; const _AssetRow({ super.key, @@ -751,6 +786,7 @@ class _AssetRow extends StatelessWidget { required this.onAssetTap, this.onSelect, this.onDeselect, + this.getOwnerName, }); @override diff --git a/mobile/lib/widgets/asset_grid/multiselect_grid.dart b/mobile/lib/widgets/asset_grid/multiselect_grid.dart index c0d8a6bea2..340aafe942 100644 --- a/mobile/lib/widgets/asset_grid/multiselect_grid.dart +++ b/mobile/lib/widgets/asset_grid/multiselect_grid.dart @@ -48,6 +48,7 @@ class MultiselectGrid extends HookConsumerWidget { this.unfavorite = false, this.downloadEnabled = true, this.emptyIndicator, + this.album, }); final ProviderListenable> renderListProvider; @@ -65,6 +66,7 @@ class MultiselectGrid extends HookConsumerWidget { final bool unfavorite; final bool editEnabled; final Widget? emptyIndicator; + final Album? album; Widget buildDefaultLoadingIndicator() => const Center(child: CircularProgressIndicator()); Widget buildEmptyIndicator() => emptyIndicator ?? Center(child: const Text("no_assets_to_show").tr()); @@ -418,6 +420,7 @@ class MultiselectGrid extends HookConsumerWidget { topWidget: topWidget, showStack: stackEnabled, showDragScrollLabel: dragScrollLabelEnabled, + album: album, ), error: (error, _) => Center(child: Text(error.toString())), loading: buildLoadingIndicator ?? buildDefaultLoadingIndicator, diff --git a/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart b/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart index 907cd19843..8f623e8fcb 100644 --- a/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart +++ b/mobile/lib/widgets/settings/asset_list_settings/asset_list_settings.dart @@ -17,6 +17,7 @@ class AssetListSettings extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final showStorageIndicator = useAppSettingsState(AppSettingsEnum.storageIndicator); + final showOwnerName = useAppSettingsState(AppSettingsEnum.showOwnerName); final assetListSetting = [ SettingsSwitchListTile( @@ -27,6 +28,14 @@ class AssetListSettings extends HookConsumerWidget { ref.invalidate(settingsProvider); }, ), + SettingsSwitchListTile( + valueNotifier: showOwnerName, + title: 'theme_setting_asset_list_show_owner_name_title'.tr(), + onChanged: (_) { + ref.invalidate(appSettingsServiceProvider); + ref.invalidate(settingsProvider); + }, + ), const LayoutSettings(), const GroupSettings(), ];