feat: improve mobile screen reader accessibility (#17876)

* WIP: adding screen reader support to mobile

* implemented getAltText

* implemented alt text solution that stores the alt text in the DB, which isn't really great

* moved alt text computation to immich_thumbnail.dart

* added unit tests

* revert unintended changes

* Added text to remaining buttons in Photo page

* fixed import

* Fixed issue of easy_localization not parsing select blocks

* Transferred the new screen reader help to web frontend

* remove unused property

* npm run format:fix

* code review

* revert unwanted change

* dart fmt

* revert web changes

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
Andreas Tollkötter 2025-06-13 16:39:59 +02:00 committed by GitHub
parent ed5b260eeb
commit f54cfa7a5a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 170 additions and 15 deletions

View File

@ -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",

View File

@ -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<String> 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<String, String>) getAltTextTemplate(
ExifInfo? exifInfo,
DateTime fileCreatedAt,
AssetType type,
List<String> 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);
}

View File

@ -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}),
),
),
],

View File

@ -66,12 +66,15 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
Icons.face_outlined,
size: widgetSize,
)
: UserCircleAvatar(
: Semantics(
label: "logged_in_as".tr(namedArgs: {"user": user.name}),
child: UserCircleAvatar(
radius: 17,
size: 31,
user: user,
),
),
),
);
}

View File

@ -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,7 +98,9 @@ class ImmichThumbnail extends HookConsumerWidget {
return originalErrorWidgetBuilder(ctx, error, stackTrace);
}
return OctoImage.fromSet(
return Semantics(
label: assetAltText,
child: OctoImage.fromSet(
placeholderFadeInDuration: Duration.zero,
fadeInDuration: Duration.zero,
fadeOutDuration: const Duration(milliseconds: 100),
@ -102,6 +112,7 @@ class ImmichThumbnail extends HookConsumerWidget {
width: width,
height: height,
fit: fit,
),
);
}
}

View File

@ -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");
});
}