implemented comprehensive accessibility (A11y) support across the app
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import '../../extensions/date_time.dart';
|
||||
|
||||
/// Zentrale deutschsprachige Screenreader-Labels. Single Source of Truth für
|
||||
/// alle `Semantics`/`semanticLabel`/`tooltip`-Texte – und der eine Punkt, an
|
||||
/// dem später echtes l10n andockt (die App ist aktuell fest auf `de`).
|
||||
abstract final class A11yLabels {
|
||||
// Zustände
|
||||
static const loading = 'Lädt';
|
||||
static const downloadingFile = 'Lädt herunter';
|
||||
|
||||
// Chat
|
||||
static const messageRead = 'Gelesen';
|
||||
static const messageSent = 'Gesendet';
|
||||
static const unread = 'Ungelesen';
|
||||
static const favorite = 'Favorit';
|
||||
static const draft = 'Entwurf';
|
||||
static const yourReaction = 'deine Reaktion';
|
||||
|
||||
// Bilder
|
||||
static const profilePicture = 'Profilbild';
|
||||
static const groupPicture = 'Gruppenbild';
|
||||
static const roomPlan = 'Raumplan der Schule als Grafik';
|
||||
|
||||
// Stundenplan
|
||||
static const cancelled = 'Ausfall';
|
||||
static const breakTime = 'Pause';
|
||||
static const allDay = 'Ganztägig';
|
||||
|
||||
/// Beschreibt eine Stundenplan-Kachel für den Screenreader, z.B.
|
||||
/// „Mathe, Raum 101, 08:00–08:45, Ausfall". Zeilenumbrüche in [location]
|
||||
/// (Raum/Lehrer stehen dort mehrzeilig) werden zu Trennern geglättet.
|
||||
static String appointmentLabel({
|
||||
required String subject,
|
||||
required String location,
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
required bool crossedOut,
|
||||
}) {
|
||||
final parts = <String>[subject];
|
||||
final loc = location.replaceAll('\n', ', ').trim();
|
||||
if (loc.isNotEmpty) parts.add(loc);
|
||||
parts.add('${start.formatHm()}–${end.formatHm()}');
|
||||
if (crossedOut) parts.add(cancelled);
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
/// „+3 weitere Termine" für die zusammengefasste Overflow-Kachel.
|
||||
static String moreAppointments(int count) => '+$count weitere Termine';
|
||||
}
|
||||
@@ -1,24 +1,43 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'a11y/a11y_labels.dart';
|
||||
|
||||
class AppProgressIndicator extends StatelessWidget {
|
||||
final double size;
|
||||
final double strokeWidth;
|
||||
final Color? color;
|
||||
final String semanticsLabel;
|
||||
|
||||
const AppProgressIndicator._({
|
||||
required this.size,
|
||||
required this.strokeWidth,
|
||||
this.color,
|
||||
this.semanticsLabel = A11yLabels.loading,
|
||||
});
|
||||
|
||||
const AppProgressIndicator.small({Color? color})
|
||||
: this._(size: 16, strokeWidth: 2, color: color);
|
||||
const AppProgressIndicator.small({Color? color, String? semanticsLabel})
|
||||
: this._(
|
||||
size: 16,
|
||||
strokeWidth: 2,
|
||||
color: color,
|
||||
semanticsLabel: semanticsLabel ?? A11yLabels.loading,
|
||||
);
|
||||
|
||||
const AppProgressIndicator.medium({Color? color})
|
||||
: this._(size: 24, strokeWidth: 2.5, color: color);
|
||||
const AppProgressIndicator.medium({Color? color, String? semanticsLabel})
|
||||
: this._(
|
||||
size: 24,
|
||||
strokeWidth: 2.5,
|
||||
color: color,
|
||||
semanticsLabel: semanticsLabel ?? A11yLabels.loading,
|
||||
);
|
||||
|
||||
const AppProgressIndicator.large({Color? color})
|
||||
: this._(size: 40, strokeWidth: 3, color: color);
|
||||
const AppProgressIndicator.large({Color? color, String? semanticsLabel})
|
||||
: this._(
|
||||
size: 40,
|
||||
strokeWidth: 3,
|
||||
color: color,
|
||||
semanticsLabel: semanticsLabel ?? A11yLabels.loading,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -29,6 +48,7 @@ class AppProgressIndicator extends StatelessWidget {
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: strokeWidth,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(resolved),
|
||||
semanticsLabel: semanticsLabel,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,10 +105,14 @@ class _InlineErrorWrapper extends StatelessWidget {
|
||||
child,
|
||||
if (err != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
err,
|
||||
textAlign: TextAlign.center,
|
||||
style: _asyncErrorTextStyle(context),
|
||||
Semantics(
|
||||
liveRegion: true,
|
||||
container: true,
|
||||
child: Text(
|
||||
err,
|
||||
textAlign: TextAlign.center,
|
||||
style: _asyncErrorTextStyle(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -20,6 +20,7 @@ Future<String?> showEmojiPicker(
|
||||
title: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'Zurück',
|
||||
onPressed: () => Navigator.of(pickerCtx).pop(),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
),
|
||||
|
||||
@@ -249,6 +249,7 @@ class _FileViewerState extends State<FileViewer> {
|
||||
photoViewController.rotation += pi / 2;
|
||||
});
|
||||
},
|
||||
tooltip: 'Drehen',
|
||||
icon: const Icon(Icons.rotate_right),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
|
||||
import '../model/account_data.dart';
|
||||
import 'a11y/a11y_labels.dart';
|
||||
import 'user_avatar.dart';
|
||||
|
||||
class LargeProfilePictureView extends StatelessWidget {
|
||||
@@ -15,18 +16,25 @@ class LargeProfilePictureView extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: Text(isGroup ? 'Gruppenbild' : 'Profilbild')),
|
||||
body: PhotoView(
|
||||
minScale: 0.5,
|
||||
maxScale: 3.0,
|
||||
imageProvider: Image.network(
|
||||
avatarUrl(id: id, isGroup: isGroup, size: 1024),
|
||||
headers: {'Authorization': AccountData().getBasicAuthHeader()},
|
||||
).image,
|
||||
backgroundDecoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
Widget build(BuildContext context) {
|
||||
final label = isGroup ? A11yLabels.groupPicture : A11yLabels.profilePicture;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(label)),
|
||||
body: Semantics(
|
||||
image: true,
|
||||
label: label,
|
||||
child: PhotoView(
|
||||
minScale: 0.5,
|
||||
maxScale: 3.0,
|
||||
imageProvider: Image.network(
|
||||
avatarUrl(id: id, isGroup: isGroup, size: 1024),
|
||||
headers: {'Authorization': AccountData().getBasicAuthHeader()},
|
||||
).image,
|
||||
backgroundDecoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:http/http.dart' as http;
|
||||
import '../model/account_data.dart';
|
||||
import '../model/endpoint_data.dart';
|
||||
import '../push/push_avatar.dart';
|
||||
import 'a11y/a11y_labels.dart';
|
||||
import 'avatar_disk_cache.dart';
|
||||
|
||||
class UserAvatar extends StatefulWidget {
|
||||
@@ -17,6 +18,10 @@ class UserAvatar extends StatefulWidget {
|
||||
final bool isGroup;
|
||||
final int size;
|
||||
|
||||
/// Screenreader-Beschreibung. Aufrufer, die den Namen kennen, sollten ihn
|
||||
/// mitgeben; sonst greift ein generisches „Profilbild"/„Gruppenbild".
|
||||
final String? semanticLabel;
|
||||
|
||||
/// Server-side pixel size requested for user avatars. `null` lets the
|
||||
/// widget pick `(size * 4).clamp(64, 1024)` — enough headroom for typical
|
||||
/// device pixel ratios. Group avatars ignore this (Spreed serves one
|
||||
@@ -28,6 +33,7 @@ class UserAvatar extends StatefulWidget {
|
||||
this.isGroup = false,
|
||||
this.size = 20,
|
||||
this.requestSize,
|
||||
this.semanticLabel,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -225,7 +231,9 @@ class _UserAvatarState extends State<UserAvatar> {
|
||||
final pending = _pendingAvatars.putIfAbsent(url, () {
|
||||
final future = _fetch(url);
|
||||
future.whenComplete(() {
|
||||
if (identical(_pendingAvatars[url], future)) _pendingAvatars.remove(url);
|
||||
if (identical(_pendingAvatars[url], future)) {
|
||||
_pendingAvatars.remove(url);
|
||||
}
|
||||
});
|
||||
return future;
|
||||
});
|
||||
@@ -345,12 +353,24 @@ class _UserAvatarState extends State<UserAvatar> {
|
||||
);
|
||||
}
|
||||
|
||||
return CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: theme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
child: ClipOval(
|
||||
child: SizedBox(width: radius * 2, height: radius * 2, child: content),
|
||||
return Semantics(
|
||||
image: true,
|
||||
label:
|
||||
widget.semanticLabel ??
|
||||
(widget.isGroup
|
||||
? A11yLabels.groupPicture
|
||||
: A11yLabels.profilePicture),
|
||||
child: CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: theme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
child: ClipOval(
|
||||
child: SizedBox(
|
||||
width: radius * 2,
|
||||
height: radius * 2,
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user