diff --git a/i18n/en.json b/i18n/en.json index 9c5eca0951..28aef51623 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1150,6 +1150,7 @@ "locked_folder": "Locked Folder", "log_out": "Log out", "log_out_all_devices": "Log Out All Devices", + "logged_in_as": "Logged in as {user}", "logged_out_all_devices": "Logged out all devices", "logged_out_device": "Logged out device", "login": "Login", @@ -1607,6 +1608,7 @@ "select_album_cover": "Select album cover", "select_all": "Select all", "select_all_duplicates": "Select all duplicates", + "select_all_in": "Select all in {group}", "select_avatar_color": "Select avatar color", "select_face": "Select face", "select_featured_photo": "Select featured photo", @@ -1871,6 +1873,7 @@ "unsaved_change": "Unsaved change", "unselect_all": "Unselect all", "unselect_all_duplicates": "Unselect all duplicates", + "unselect_all_in": "Unselect all in {group}", "unstack": "Un-stack", "unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}", "up_next": "Up next", diff --git a/mobile/lib/utils/thumbnail_utils.dart b/mobile/lib/utils/thumbnail_utils.dart new file mode 100644 index 0000000000..9681815fde --- /dev/null +++ b/mobile/lib/utils/thumbnail_utils.dart @@ -0,0 +1,55 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/utils/translation.dart'; + +String getAltText( + ExifInfo? exifInfo, + DateTime fileCreatedAt, + AssetType type, + List peopleNames, +) { + if (exifInfo?.description != null && exifInfo!.description!.isNotEmpty) { + return exifInfo.description!; + } + final (template, args) = + getAltTextTemplate(exifInfo, fileCreatedAt, type, peopleNames); + return t(template, args); +} + +(String, Map) getAltTextTemplate( + ExifInfo? exifInfo, + DateTime fileCreatedAt, + AssetType type, + List peopleNames, +) { + final isVideo = type == AssetType.video; + final hasLocation = exifInfo?.city != null && exifInfo?.country != null; + final date = DateFormat.yMMMMd().format(fileCreatedAt); + final args = { + "isVideo": isVideo.toString(), + "date": date, + "city": exifInfo?.city ?? "", + "country": exifInfo?.country ?? "", + "person1": peopleNames.elementAtOrNull(0) ?? "", + "person2": peopleNames.elementAtOrNull(1) ?? "", + "person3": peopleNames.elementAtOrNull(2) ?? "", + "additionalCount": (peopleNames.length - 3).toString(), + }; + final template = hasLocation + ? (switch (peopleNames.length) { + 0 => "image_alt_text_date_place", + 1 => "image_alt_text_date_place_1_person", + 2 => "image_alt_text_date_place_2_people", + 3 => "image_alt_text_date_place_3_people", + _ => "image_alt_text_date_place_4_or_more_people" + }) + : (switch (peopleNames.length) { + 0 => "image_alt_text_date", + 1 => "image_alt_text_date_1_person", + 2 => "image_alt_text_date_2_people", + 3 => "image_alt_text_date_3_people", + _ => "image_alt_text_date_4_or_more_people" + }); + return (template, args); +} diff --git a/mobile/lib/widgets/asset_grid/group_divider_title.dart b/mobile/lib/widgets/asset_grid/group_divider_title.dart index 3a411c09db..b9fe8e3c1d 100644 --- a/mobile/lib/widgets/asset_grid/group_divider_title.dart +++ b/mobile/lib/widgets/asset_grid/group_divider_title.dart @@ -1,3 +1,4 @@ +import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -74,10 +75,14 @@ class GroupDividerTitle extends HookConsumerWidget { ? Icon( Icons.check_circle_rounded, color: context.primaryColor, + semanticLabel: + "unselect_all_in".tr(namedArgs: {"group": text}), ) : Icon( Icons.check_circle_outline_rounded, color: context.colorScheme.onSurfaceSecondary, + semanticLabel: + "select_all_in".tr(namedArgs: {"group": text}), ), ), ], diff --git a/mobile/lib/widgets/common/immich_app_bar.dart b/mobile/lib/widgets/common/immich_app_bar.dart index 812de58416..c3bb75afd6 100644 --- a/mobile/lib/widgets/common/immich_app_bar.dart +++ b/mobile/lib/widgets/common/immich_app_bar.dart @@ -66,10 +66,13 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { Icons.face_outlined, size: widgetSize, ) - : UserCircleAvatar( - radius: 17, - size: 31, - user: user, + : Semantics( + label: "logged_in_as".tr(namedArgs: {"user": user.name}), + child: UserCircleAvatar( + radius: 17, + size: 31, + user: user, + ), ), ), ); diff --git a/mobile/lib/widgets/common/immich_thumbnail.dart b/mobile/lib/widgets/common/immich_thumbnail.dart index 58613a43ec..0918348f4c 100644 --- a/mobile/lib/widgets/common/immich_thumbnail.dart +++ b/mobile/lib/widgets/common/immich_thumbnail.dart @@ -6,6 +6,7 @@ import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.da import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/utils/hooks/blurhash_hook.dart'; +import 'package:immich_mobile/utils/thumbnail_utils.dart'; import 'package:immich_mobile/widgets/common/immich_image.dart'; import 'package:immich_mobile/widgets/common/thumbhash_placeholder.dart'; import 'package:octo_image/octo_image.dart'; @@ -77,6 +78,13 @@ class ImmichThumbnail extends HookConsumerWidget { ); } + final assetAltText = getAltText( + asset!.exifInfo, + asset!.fileCreatedAt, + asset!.type, + [], + ); + final thumbnailProviderInstance = ImmichThumbnail.imageProvider( asset: asset, userId: userId, @@ -90,18 +98,21 @@ class ImmichThumbnail extends HookConsumerWidget { return originalErrorWidgetBuilder(ctx, error, stackTrace); } - return OctoImage.fromSet( - placeholderFadeInDuration: Duration.zero, - fadeInDuration: Duration.zero, - fadeOutDuration: const Duration(milliseconds: 100), - octoSet: OctoSet( - placeholderBuilder: blurHashPlaceholderBuilder(blurhash, fit: fit), - errorBuilder: customErrorBuilder, + return Semantics( + label: assetAltText, + child: OctoImage.fromSet( + placeholderFadeInDuration: Duration.zero, + fadeInDuration: Duration.zero, + fadeOutDuration: const Duration(milliseconds: 100), + octoSet: OctoSet( + placeholderBuilder: blurHashPlaceholderBuilder(blurhash, fit: fit), + errorBuilder: customErrorBuilder, + ), + image: thumbnailProviderInstance, + width: width, + height: height, + fit: fit, ), - image: thumbnailProviderInstance, - width: width, - height: height, - fit: fit, ); } } diff --git a/mobile/test/modules/utils/thumbnail_utils_test.dart b/mobile/test/modules/utils/thumbnail_utils_test.dart new file mode 100644 index 0000000000..6fa0127f16 --- /dev/null +++ b/mobile/test/modules/utils/thumbnail_utils_test.dart @@ -0,0 +1,78 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/entities/asset.entity.dart'; +import 'package:immich_mobile/utils/thumbnail_utils.dart'; + +void main() { + final dateTime = DateTime(2025, 04, 25, 12, 13, 14); + final dateTimeString = DateFormat.yMMMMd().format(dateTime); + + test('returns description if it has one', () { + final result = getAltText( + const ExifInfo(description: 'description'), + dateTime, + AssetType.image, + [], + ); + expect(result, 'description'); + }); + + test('returns image alt text with date if no location', () { + final (template, args) = getAltTextTemplate( + const ExifInfo(), + dateTime, + AssetType.image, + [], + ); + expect(template, "image_alt_text_date"); + expect(args["isVideo"], "false"); + expect(args["date"], dateTimeString); + }); + + test('returns image alt text with date and place', () { + final (template, args) = getAltTextTemplate( + const ExifInfo(city: 'city', country: 'country'), + dateTime, + AssetType.video, + [], + ); + expect(template, "image_alt_text_date_place"); + expect(args["isVideo"], "true"); + expect(args["date"], dateTimeString); + expect(args["city"], "city"); + expect(args["country"], "country"); + }); + + test('returns image alt text with date and some people', () { + final (template, args) = getAltTextTemplate( + const ExifInfo(), + dateTime, + AssetType.image, + ["Alice", "Bob"], + ); + expect(template, "image_alt_text_date_2_people"); + expect(args["isVideo"], "false"); + expect(args["date"], dateTimeString); + expect(args["person1"], "Alice"); + expect(args["person2"], "Bob"); + }); + + test('returns image alt text with date and location and many people', () { + final (template, args) = getAltTextTemplate( + const ExifInfo(city: "city", country: 'country'), + dateTime, + AssetType.video, + ["Alice", "Bob", "Carol", "David", "Eve"], + ); + expect(template, "image_alt_text_date_place_4_or_more_people"); + expect(args["isVideo"], "true"); + expect(args["date"], dateTimeString); + expect(args["city"], "city"); + expect(args["country"], "country"); + expect(args["person1"], "Alice"); + expect(args["person2"], "Bob"); + expect(args["person3"], "Carol"); + expect(args["additionalCount"], "2"); + }); +}