implemented comprehensive accessibility (A11y) support across the app
This commit is contained in:
@@ -107,16 +107,26 @@ class _LoadableStateErrorBarTextState extends State<LoadableStateErrorBarText> {
|
|||||||
var bloc = context.watch<LoadableStateBloc>();
|
var bloc = context.watch<LoadableStateBloc>();
|
||||||
final foreground = bloc.connectionForegroundColor(context);
|
final foreground = bloc.connectionForegroundColor(context);
|
||||||
|
|
||||||
return Row(
|
// liveRegion, damit das Auftauchen des Offline-/Fehlerbanners angesagt wird;
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
// Row-Semantik ausgeschlossen (Icon + Text) und Text über das explizite
|
||||||
children: [
|
// Label getragen, sonst liest der Screenreader ihn doppelt.
|
||||||
Icon(bloc.connectionIcon(), size: 14, color: foreground),
|
return Semantics(
|
||||||
const SizedBox(width: 10),
|
liveRegion: true,
|
||||||
Text(
|
container: true,
|
||||||
bloc.connectionText(lastUpdated: widget.lastUpdated),
|
label: bloc.connectionText(lastUpdated: widget.lastUpdated),
|
||||||
style: TextStyle(fontSize: 12, color: foreground),
|
child: ExcludeSemantics(
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(bloc.connectionIcon(), size: 14, color: foreground),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
bloc.connectionText(lastUpdated: widget.lastUpdated),
|
||||||
|
style: TextStyle(fontSize: 12, color: foreground),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ class AppModule {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: isVisible ? 'Modul ausblenden' : 'Modul einblenden',
|
||||||
onPressed: onVisibleChange,
|
onPressed: onVisibleChange,
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
isVisible
|
isVisible
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ class LoginHeader extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) => Column(
|
Widget build(BuildContext context) => Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 40),
|
||||||
Image.asset(
|
// Dekoratives Logo – der Schulname steht direkt darunter als Text.
|
||||||
'assets/logo/icon.png',
|
const ExcludeSemantics(
|
||||||
height: 110,
|
child: Image(
|
||||||
fit: BoxFit.contain,
|
image: AssetImage('assets/logo/icon.png'),
|
||||||
gaplessPlayback: true,
|
height: 110,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
const Text(
|
const Text(
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class FilesSearchDelegate extends SearchDelegate<void> {
|
|||||||
@override
|
@override
|
||||||
Widget? buildLeading(BuildContext context) => IconButton(
|
Widget? buildLeading(BuildContext context) => IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
tooltip: 'Zurück',
|
||||||
onPressed: () => close(context, null),
|
onPressed: () => close(context, null),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -239,6 +239,7 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> {
|
|||||||
),
|
),
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
onPressed: () => copyToClipboard(context, _share.url!),
|
onPressed: () => copyToClipboard(context, _share.url!),
|
||||||
|
tooltip: 'Link kopieren',
|
||||||
icon: const Icon(Icons.copy_outlined),
|
icon: const Icon(Icons.copy_outlined),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ class _ShareePickerPageState extends State<ShareePickerPage> {
|
|||||||
? null
|
? null
|
||||||
: IconButton(
|
: IconButton(
|
||||||
icon: const Icon(Icons.clear),
|
icon: const Icon(Icons.clear),
|
||||||
|
tooltip: 'Leeren',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
_query = '';
|
_query = '';
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
|
|||||||
? null
|
? null
|
||||||
: IconButton(
|
: IconButton(
|
||||||
icon: const Icon(Icons.clear),
|
icon: const Icon(Icons.clear),
|
||||||
|
tooltip: 'Leeren',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
setState(() => _query = '');
|
setState(() => _query = '');
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class GradeAveragesListView extends StatelessWidget {
|
|||||||
Text(getGradeDisplay(grade)),
|
Text(getGradeDisplay(grade)),
|
||||||
const SizedBox(width: 30),
|
const SizedBox(width: 30),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: 'Note entfernen',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
bloc.add(DecrementGrade(grade));
|
bloc.add(DecrementGrade(grade));
|
||||||
},
|
},
|
||||||
@@ -49,6 +50,7 @@ class GradeAveragesListView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: 'Note hinzufügen',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
bloc.add(IncrementGrade(grade));
|
bloc.add(IncrementGrade(grade));
|
||||||
},
|
},
|
||||||
@@ -64,6 +66,7 @@ class GradeAveragesListView extends StatelessWidget {
|
|||||||
maintainSize: true,
|
maintainSize: true,
|
||||||
visible: bloc.canDecrementOrDelete(grade),
|
visible: bloc.canDecrementOrDelete(grade),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
|
tooltip: 'Löschen',
|
||||||
icon: const Icon(Icons.delete),
|
icon: const Icon(Icons.delete),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
bloc.add(ResetGrade(grade));
|
bloc.add(ResetGrade(grade));
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class GradeAveragesView extends StatelessWidget {
|
|||||||
Visibility(
|
Visibility(
|
||||||
visible: bloc.state.grades.isNotEmpty,
|
visible: bloc.state.grades.isNotEmpty,
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
|
tooltip: 'Alle zurücksetzen',
|
||||||
onPressed: () => ConfirmDialog(
|
onPressed: () => ConfirmDialog(
|
||||||
title: 'Zurücksetzen?',
|
title: 'Zurücksetzen?',
|
||||||
content: 'Alle Einträge werden entfernt.',
|
content: 'Alle Einträge werden entfernt.',
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class HolidaysView extends StatelessWidget {
|
|||||||
title: const Text('Schulferien'),
|
title: const Text('Schulferien'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: 'Informationen',
|
||||||
icon: const Icon(Icons.info_outline),
|
icon: const Icon(Icons.info_outline),
|
||||||
onPressed: showDisclaimer,
|
onPressed: showDisclaimer,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ class MarianumDatesView extends StatelessWidget {
|
|||||||
onSelected: (e) => bloc.add(SetPastEventsVisible(e)),
|
onSelected: (e) => bloc.add(SetPastEventsVisible(e)),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: 'Suchen',
|
||||||
icon: const Icon(Icons.search),
|
icon: const Icon(Icons.search),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final events = bloc.getEvents() ?? const <MarianumDate>[];
|
final events = bloc.getEvents() ?? const <MarianumDate>[];
|
||||||
|
|||||||
@@ -22,11 +22,16 @@ class SearchMarianumDates extends SearchDelegate<MarianumDate?> {
|
|||||||
@override
|
@override
|
||||||
List<Widget>? buildActions(BuildContext context) => [
|
List<Widget>? buildActions(BuildContext context) => [
|
||||||
if (query.isNotEmpty)
|
if (query.isNotEmpty)
|
||||||
IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)),
|
IconButton(
|
||||||
|
tooltip: 'Leeren',
|
||||||
|
onPressed: () => query = '',
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget? buildLeading(BuildContext context) => IconButton(
|
Widget? buildLeading(BuildContext context) => IconButton(
|
||||||
|
tooltip: 'Zurück',
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () => close(context, null),
|
onPressed: () => close(context, null),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class MarianumMessageListView extends StatelessWidget {
|
|||||||
title: const Text('Marianum Message'),
|
title: const Text('Marianum Message'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: 'Suchen',
|
||||||
icon: const Icon(Icons.search),
|
icon: const Icon(Icons.search),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final list = bloc.state.data?.messageList;
|
final list = bloc.state.data?.messageList;
|
||||||
|
|||||||
@@ -23,11 +23,16 @@ class SearchMarianumMessages extends SearchDelegate<MarianumMessage?> {
|
|||||||
@override
|
@override
|
||||||
List<Widget>? buildActions(BuildContext context) => [
|
List<Widget>? buildActions(BuildContext context) => [
|
||||||
if (query.isNotEmpty)
|
if (query.isNotEmpty)
|
||||||
IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)),
|
IconButton(
|
||||||
|
tooltip: 'Leeren',
|
||||||
|
onPressed: () => query = '',
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget? buildLeading(BuildContext context) => IconButton(
|
Widget? buildLeading(BuildContext context) => IconButton(
|
||||||
|
tooltip: 'Zurück',
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () => close(context, null),
|
onPressed: () => close(context, null),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:photo_view/photo_view.dart';
|
import 'package:photo_view/photo_view.dart';
|
||||||
|
|
||||||
|
import '../../../../widget/a11y/a11y_labels.dart';
|
||||||
|
|
||||||
class Roomplan extends StatelessWidget {
|
class Roomplan extends StatelessWidget {
|
||||||
const Roomplan({super.key});
|
const Roomplan({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Scaffold(
|
Widget build(BuildContext context) => Scaffold(
|
||||||
appBar: AppBar(title: const Text('Raumplan')),
|
appBar: AppBar(title: const Text('Raumplan')),
|
||||||
body: PhotoView(
|
body: Semantics(
|
||||||
imageProvider: Image.asset('assets/img/raumplan.png').image,
|
image: true,
|
||||||
minScale: 0.5,
|
label: A11yLabels.roomPlan,
|
||||||
maxScale: 2.0,
|
child: PhotoView(
|
||||||
backgroundDecoration: BoxDecoration(
|
imageProvider: Image.asset('assets/img/raumplan.png').image,
|
||||||
color: Theme.of(context).colorScheme.surface,
|
minScale: 0.5,
|
||||||
|
maxScale: 2.0,
|
||||||
|
backgroundDecoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class _OverhangState extends State<Overhang> {
|
|||||||
title: const Text('Mehr'),
|
title: const Text('Mehr'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: 'Einstellungen',
|
||||||
onPressed: () => AppRoutes.openSettings(context),
|
onPressed: () => AppRoutes.openSettings(context),
|
||||||
icon: const Icon(Icons.settings),
|
icon: const Icon(Icons.settings),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ class ModuleSortBody extends StatelessWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: 'Slot entfernen',
|
||||||
icon: const Icon(Icons.remove_circle_outline),
|
icon: const Icon(Icons.remove_circle_outline),
|
||||||
onPressed:
|
onPressed:
|
||||||
modulesSettings.fixedBottomBarSlots >
|
modulesSettings.fixedBottomBarSlots >
|
||||||
@@ -80,6 +81,7 @@ class ModuleSortBody extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
Text('${modulesSettings.fixedBottomBarSlots}'),
|
Text('${modulesSettings.fixedBottomBarSlots}'),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: 'Slot hinzufügen',
|
||||||
icon: const Icon(Icons.add_circle_outline),
|
icon: const Icon(Icons.add_circle_outline),
|
||||||
onPressed:
|
onPressed:
|
||||||
modulesSettings.fixedBottomBarSlots <
|
modulesSettings.fixedBottomBarSlots <
|
||||||
|
|||||||
@@ -277,13 +277,22 @@ class _PushStatusBodyState extends State<_PushStatusBody>
|
|||||||
Widget _stateIcon(PushCheck state, ThemeData theme) {
|
Widget _stateIcon(PushCheck state, ThemeData theme) {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case PushCheck.ok:
|
case PushCheck.ok:
|
||||||
return const Icon(Icons.check_circle_outline, color: Colors.green);
|
return const Icon(
|
||||||
|
Icons.check_circle_outline,
|
||||||
|
color: Colors.green,
|
||||||
|
semanticLabel: 'In Ordnung',
|
||||||
|
);
|
||||||
case PushCheck.fail:
|
case PushCheck.fail:
|
||||||
return Icon(Icons.cancel_outlined, color: theme.colorScheme.error);
|
return Icon(
|
||||||
|
Icons.cancel_outlined,
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
semanticLabel: 'Fehler',
|
||||||
|
);
|
||||||
case PushCheck.unknown:
|
case PushCheck.unknown:
|
||||||
return Icon(
|
return Icon(
|
||||||
Icons.remove_circle_outline,
|
Icons.remove_circle_outline,
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
semanticLabel: 'Unbekannt',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ class ShareChatPicker extends StatelessWidget {
|
|||||||
actions: [
|
actions: [
|
||||||
Builder(
|
Builder(
|
||||||
builder: (ctx) => IconButton(
|
builder: (ctx) => IconButton(
|
||||||
|
tooltip: 'Suchen',
|
||||||
icon: const Icon(Icons.search),
|
icon: const Icon(Icons.search),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final rooms = ctx.read<ChatListBloc>().state.data?.rooms;
|
final rooms = ctx.read<ChatListBloc>().state.data?.rooms;
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ class _ChatListViewState extends State<_ChatListView> {
|
|||||||
title: const Text('Talk'),
|
title: const Text('Talk'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: 'Suchen',
|
||||||
icon: const Icon(Icons.search),
|
icon: const Icon(Icons.search),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final rooms = bloc.state.data?.rooms;
|
final rooms = bloc.state.data?.rooms;
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ class JoinChat extends SearchDelegate<String> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (query.isNotEmpty)
|
if (query.isNotEmpty)
|
||||||
IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)),
|
IconButton(
|
||||||
|
tooltip: 'Leeren',
|
||||||
|
onPressed: () => query = '',
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ class SearchChat extends SearchDelegate<GetRoomResponseObject?> {
|
|||||||
@override
|
@override
|
||||||
List<Widget>? buildActions(BuildContext context) => [
|
List<Widget>? buildActions(BuildContext context) => [
|
||||||
if (query.isNotEmpty)
|
if (query.isNotEmpty)
|
||||||
IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)),
|
IconButton(
|
||||||
|
tooltip: 'Leeren',
|
||||||
|
onPressed: () => query = '',
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../../../../share_intent/remote_file_ref.dart';
|
|||||||
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||||
import '../../../../utils/downloads/download_job.dart';
|
import '../../../../utils/downloads/download_job.dart';
|
||||||
import '../../../../utils/haptics.dart';
|
import '../../../../utils/haptics.dart';
|
||||||
|
import '../../../../widget/a11y/a11y_labels.dart';
|
||||||
import '../../../../widget/demo_restricted.dart';
|
import '../../../../widget/demo_restricted.dart';
|
||||||
import '../../../../widget/downloads/download_trigger.dart';
|
import '../../../../widget/downloads/download_trigger.dart';
|
||||||
import '../data/chat_bubble_styles.dart';
|
import '../data/chat_bubble_styles.dart';
|
||||||
@@ -120,9 +121,7 @@ class _ChatBubbleState extends State<ChatBubble>
|
|||||||
if (!_rendersAsCommentBubble) {
|
if (!_rendersAsCommentBubble) {
|
||||||
base = styles.getSystemStyle();
|
base = styles.getSystemStyle();
|
||||||
} else {
|
} else {
|
||||||
base = widget.isSender
|
base = widget.isSender ? styles.getSelfStyle() : styles.getRemoteStyle();
|
||||||
? styles.getSelfStyle()
|
|
||||||
: styles.getRemoteStyle();
|
|
||||||
}
|
}
|
||||||
switch (widget.matchHighlight) {
|
switch (widget.matchHighlight) {
|
||||||
case SearchHighlight.none:
|
case SearchHighlight.none:
|
||||||
@@ -130,7 +129,9 @@ class _ChatBubbleState extends State<ChatBubble>
|
|||||||
case SearchHighlight.secondary:
|
case SearchHighlight.secondary:
|
||||||
return base.copyWith(
|
return base.copyWith(
|
||||||
borderWidth: 1.5,
|
borderWidth: 1.5,
|
||||||
borderColor: Theme.of(context).colorScheme.primary.withValues(alpha: 0.45),
|
borderColor: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary.withValues(alpha: 0.45),
|
||||||
);
|
);
|
||||||
case SearchHighlight.active:
|
case SearchHighlight.active:
|
||||||
return base.copyWith(
|
return base.copyWith(
|
||||||
@@ -342,68 +343,72 @@ class _BubbleContent extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Container(
|
Widget build(BuildContext context) => MergeSemantics(
|
||||||
constraints: BoxConstraints(
|
child: Container(
|
||||||
maxWidth: MediaQuery.of(context).size.width * 0.9,
|
constraints: BoxConstraints(
|
||||||
minWidth: showActorDisplayName
|
maxWidth: MediaQuery.of(context).size.width * 0.9,
|
||||||
? actorText.size.width
|
minWidth: showActorDisplayName
|
||||||
: timeText.size.width + (isSender ? spacing + timeIconSize : 0) + 3,
|
? actorText.size.width
|
||||||
),
|
: timeText.size.width + (isSender ? spacing + timeIconSize : 0) + 3,
|
||||||
child: Stack(
|
),
|
||||||
children: [
|
child: Stack(
|
||||||
if (showActorDisplayName) Positioned(top: 0, left: 0, child: actorWidget),
|
children: [
|
||||||
Padding(
|
if (showActorDisplayName)
|
||||||
padding: EdgeInsets.only(
|
Positioned(top: 0, left: 0, child: actorWidget),
|
||||||
bottom: showBubbleTime ? 18 : 0,
|
Padding(
|
||||||
top: showActorDisplayName ? 18 : 0,
|
padding: EdgeInsets.only(
|
||||||
),
|
bottom: showBubbleTime ? 18 : 0,
|
||||||
child: Column(
|
top: showActorDisplayName ? 18 : 0,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
),
|
||||||
children: [
|
child: Column(
|
||||||
if (parent != null &&
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
bubbleData.messageType ==
|
|
||||||
GetRoomResponseObjectMessageType.comment) ...[
|
|
||||||
AnswerReference(
|
|
||||||
referenceMessage: parent!,
|
|
||||||
selfId: selfId,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 5),
|
|
||||||
],
|
|
||||||
messageWidget,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (showBubbleTime)
|
|
||||||
Positioned(
|
|
||||||
bottom: 0,
|
|
||||||
right: 0,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
children: [
|
||||||
timeText,
|
if (parent != null &&
|
||||||
if (isSender) ...[
|
bubbleData.messageType ==
|
||||||
SizedBox(width: spacing),
|
GetRoomResponseObjectMessageType.comment) ...[
|
||||||
Icon(
|
AnswerReference(referenceMessage: parent!, selfId: selfId),
|
||||||
isRead ? Icons.done_all_outlined : Icons.done_outlined,
|
const SizedBox(height: 5),
|
||||||
size: timeIconSize,
|
|
||||||
color: timeIconColor,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
|
messageWidget,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (downloadJob?.status.value is DownloadInProgress)
|
if (showBubbleTime)
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
left: 0,
|
child: Row(
|
||||||
child: LinearProgressIndicator(
|
children: [
|
||||||
value: () {
|
timeText,
|
||||||
final s = downloadJob!.status.value as DownloadInProgress;
|
if (isSender) ...[
|
||||||
return s.percent <= 0 ? null : s.percent / 100;
|
SizedBox(width: spacing),
|
||||||
}(),
|
Icon(
|
||||||
|
isRead ? Icons.done_all_outlined : Icons.done_outlined,
|
||||||
|
size: timeIconSize,
|
||||||
|
color: timeIconColor,
|
||||||
|
semanticLabel: isRead
|
||||||
|
? A11yLabels.messageRead
|
||||||
|
: A11yLabels.messageSent,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
if (downloadJob?.status.value is DownloadInProgress)
|
||||||
],
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
left: 0,
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
semanticsLabel: A11yLabels.downloadingFile,
|
||||||
|
value: () {
|
||||||
|
final s = downloadJob!.status.value as DownloadInProgress;
|
||||||
|
return s.percent <= 0 ? null : s.percent / 100;
|
||||||
|
}(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../../../../api/marianumcloud/talk/delete_react_message/delete_react_mes
|
|||||||
import '../../../../api/marianumcloud/talk/react_message/react_message.dart';
|
import '../../../../api/marianumcloud/talk/react_message/react_message.dart';
|
||||||
import '../../../../api/marianumcloud/talk/react_message/react_message_params.dart';
|
import '../../../../api/marianumcloud/talk/react_message/react_message_params.dart';
|
||||||
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||||
|
import '../../../../widget/a11y/a11y_labels.dart';
|
||||||
import '../../../../widget/async_action_button.dart';
|
import '../../../../widget/async_action_button.dart';
|
||||||
import '../../../../widget/demo_restricted.dart';
|
import '../../../../widget/demo_restricted.dart';
|
||||||
import '../../../../widget/emoji_text.dart';
|
import '../../../../widget/emoji_text.dart';
|
||||||
@@ -41,44 +42,58 @@ class ChatBubbleReactions extends StatelessWidget {
|
|||||||
children: reactions.entries.map<Widget>((e) {
|
children: reactions.entries.map<Widget>((e) {
|
||||||
final hasSelfReacted =
|
final hasSelfReacted =
|
||||||
bubbleData.reactionsSelf?.contains(e.key) ?? false;
|
bubbleData.reactionsSelf?.contains(e.key) ?? false;
|
||||||
|
void toggle() {
|
||||||
|
if (guardDemoAction(context)) return;
|
||||||
|
runWithErrorDialog(context, () async {
|
||||||
|
if (hasSelfReacted) {
|
||||||
|
await DeleteReactMessage(
|
||||||
|
chatToken: chatData.token,
|
||||||
|
messageId: bubbleData.id,
|
||||||
|
params: DeleteReactMessageParams(e.key),
|
||||||
|
).run();
|
||||||
|
} else {
|
||||||
|
await ReactMessage(
|
||||||
|
chatToken: chatData.token,
|
||||||
|
messageId: bubbleData.id,
|
||||||
|
params: ReactMessageParams(e.key),
|
||||||
|
).run();
|
||||||
|
}
|
||||||
|
onChanged(renew: true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ownership (eigene Reaktion) wird visuell nur über die
|
||||||
|
// Hintergrundfarbe transportiert – für den Screenreader ins Label.
|
||||||
|
final label = hasSelfReacted
|
||||||
|
? '${e.key} ${e.value}, ${A11yLabels.yourReaction}'
|
||||||
|
: '${e.key} ${e.value}';
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(right: 2.5, left: 2.5),
|
margin: const EdgeInsets.only(right: 2.5, left: 2.5),
|
||||||
child: ActionChip(
|
child: Semantics(
|
||||||
label: Row(
|
button: true,
|
||||||
mainAxisSize: MainAxisSize.min,
|
label: label,
|
||||||
children: [
|
onTap: toggle,
|
||||||
EmojiText(e.key, size: EmojiText.sizeInline),
|
child: ExcludeSemantics(
|
||||||
const SizedBox(width: 4),
|
child: ActionChip(
|
||||||
Text('${e.value}'),
|
label: Row(
|
||||||
],
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
EmojiText(e.key, size: EmojiText.sizeInline),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text('${e.value}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
visualDensity: const VisualDensity(
|
||||||
|
vertical: VisualDensity.minimumDensity,
|
||||||
|
horizontal: VisualDensity.minimumDensity,
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
backgroundColor: hasSelfReacted
|
||||||
|
? Theme.of(context).primaryColor
|
||||||
|
: null,
|
||||||
|
onPressed: toggle,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
visualDensity: const VisualDensity(
|
|
||||||
vertical: VisualDensity.minimumDensity,
|
|
||||||
horizontal: VisualDensity.minimumDensity,
|
|
||||||
),
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
backgroundColor: hasSelfReacted
|
|
||||||
? Theme.of(context).primaryColor
|
|
||||||
: null,
|
|
||||||
onPressed: () {
|
|
||||||
if (guardDemoAction(context)) return;
|
|
||||||
runWithErrorDialog(context, () async {
|
|
||||||
if (hasSelfReacted) {
|
|
||||||
await DeleteReactMessage(
|
|
||||||
chatToken: chatData.token,
|
|
||||||
messageId: bubbleData.id,
|
|
||||||
params: DeleteReactMessageParams(e.key),
|
|
||||||
).run();
|
|
||||||
} else {
|
|
||||||
await ReactMessage(
|
|
||||||
chatToken: chatData.token,
|
|
||||||
messageId: bubbleData.id,
|
|
||||||
params: ReactMessageParams(e.key),
|
|
||||||
).run();
|
|
||||||
}
|
|
||||||
onChanged(renew: true);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
|
|||||||
@@ -259,6 +259,7 @@ class _ReactionsRowState extends State<_ReactionsRow> {
|
|||||||
],
|
],
|
||||||
_groupDivider(context),
|
_groupDivider(context),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: 'Reaktion hinzufügen',
|
||||||
onPressed: busy ? null : () => _showEmojiPicker(context),
|
onPressed: busy ? null : () => _showEmojiPicker(context),
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class ChatSearchAppBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
: '${activeIndex + 1}/$matchCount';
|
: '${activeIndex + 1}/$matchCount';
|
||||||
return AppBar(
|
return AppBar(
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
|
tooltip: 'Zurück',
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: onClose,
|
onPressed: onClose,
|
||||||
),
|
),
|
||||||
@@ -54,10 +55,12 @@ class ChatSearchAppBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: 'Vorheriges Ergebnis',
|
||||||
icon: const Icon(Icons.keyboard_arrow_up),
|
icon: const Icon(Icons.keyboard_arrow_up),
|
||||||
onPressed: onPrevious,
|
onPressed: onPrevious,
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: 'Nächstes Ergebnis',
|
||||||
icon: const Icon(Icons.keyboard_arrow_down),
|
icon: const Icon(Icons.keyboard_arrow_down),
|
||||||
onPressed: onNext,
|
onPressed: onNext,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: 'Antwort verwerfen',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
chatBloc.setReferenceMessageId(null);
|
chatBloc.setReferenceMessageId(null);
|
||||||
_setDraftReply(null);
|
_setDraftReply(null);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import '../../../../routing/app_routes.dart';
|
|||||||
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||||
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||||
import '../../../../utils/haptics.dart';
|
import '../../../../utils/haptics.dart';
|
||||||
|
import '../../../../widget/a11y/a11y_labels.dart';
|
||||||
import '../../../../widget/async_action_button.dart';
|
import '../../../../widget/async_action_button.dart';
|
||||||
import '../../../../widget/confirm_dialog.dart';
|
import '../../../../widget/confirm_dialog.dart';
|
||||||
import '../../../../widget/debug/debug_tile.dart';
|
import '../../../../widget/debug/debug_tile.dart';
|
||||||
@@ -106,6 +107,7 @@ class _ChatTileState extends State<ChatTile> {
|
|||||||
Icons.star,
|
Icons.star,
|
||||||
color: Colors.amberAccent,
|
color: Colors.amberAccent,
|
||||||
size: 15,
|
size: 15,
|
||||||
|
semanticLabel: A11yLabels.favorite,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -123,7 +125,11 @@ class _ChatTileState extends State<ChatTile> {
|
|||||||
),
|
),
|
||||||
if (widget.hasDraft) ...[
|
if (widget.hasDraft) ...[
|
||||||
const SizedBox(width: 5),
|
const SizedBox(width: 5),
|
||||||
const Icon(Icons.edit_outlined, size: 15),
|
const Icon(
|
||||||
|
Icons.edit_outlined,
|
||||||
|
size: 15,
|
||||||
|
semanticLabel: A11yLabels.draft,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -134,17 +140,25 @@ class _ChatTileState extends State<ChatTile> {
|
|||||||
),
|
),
|
||||||
trailing: widget.data.unreadMessages <= 0
|
trailing: widget.data.unreadMessages <= 0
|
||||||
? null
|
? null
|
||||||
: Container(
|
: Semantics(
|
||||||
padding: const EdgeInsets.all(1),
|
label: '${widget.data.unreadMessages} ${A11yLabels.unread}',
|
||||||
decoration: BoxDecoration(
|
child: ExcludeSemantics(
|
||||||
color: Theme.of(context).primaryColor,
|
child: Container(
|
||||||
borderRadius: BorderRadius.circular(30),
|
padding: const EdgeInsets.all(1),
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
constraints: const BoxConstraints(minWidth: 20, minHeight: 20),
|
color: Theme.of(context).primaryColor,
|
||||||
child: Text(
|
borderRadius: BorderRadius.circular(30),
|
||||||
'${widget.data.unreadMessages}',
|
),
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 15),
|
constraints: const BoxConstraints(
|
||||||
textAlign: TextAlign.center,
|
minWidth: 20,
|
||||||
|
minHeight: 20,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${widget.data.unreadMessages}',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 15),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ class SplitViewPlaceholder extends StatelessWidget {
|
|||||||
data: MediaQuery.of(
|
data: MediaQuery.of(
|
||||||
context,
|
context,
|
||||||
).copyWith(invertColors: !AppTheme.isDarkMode(context)),
|
).copyWith(invertColors: !AppTheme.isDarkMode(context)),
|
||||||
child: Image.asset('assets/logo/icon.png', height: 200),
|
// Dekoratives Logo – der „Talk"-Text darunter trägt die Aussage.
|
||||||
|
child: ExcludeSemantics(
|
||||||
|
child: Image.asset('assets/logo/icon.png', height: 200),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 30),
|
const SizedBox(height: 30),
|
||||||
const Text(
|
const Text(
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class CustomEventsView extends StatelessWidget {
|
|||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
|
tooltip: 'Termin erstellen',
|
||||||
onPressed: () => _openCreateDialog(context),
|
onPressed: () => _openCreateDialog(context),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -67,6 +68,7 @@ class CustomEventsView extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.edit_outlined),
|
icon: const Icon(Icons.edit_outlined),
|
||||||
|
tooltip: 'Bearbeiten',
|
||||||
onPressed: () => showDialog(
|
onPressed: () => showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (_) =>
|
builder: (_) =>
|
||||||
@@ -75,6 +77,7 @@ class CustomEventsView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete_outline),
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
tooltip: 'Löschen',
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
showDeleteCustomEventDialog(context, e),
|
showDeleteCustomEventDialog(context, e),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ class LessonSheet {
|
|||||||
static Widget _roomTile(BuildContext context, McTimetableEntry lesson) {
|
static Widget _roomTile(BuildContext context, McTimetableEntry lesson) {
|
||||||
final trailing = IconButton(
|
final trailing = IconButton(
|
||||||
icon: const Icon(Icons.house_outlined),
|
icon: const Icon(Icons.house_outlined),
|
||||||
|
tooltip: 'Raumplan öffnen',
|
||||||
onPressed: () => AppRoutes.openRoomplan(context),
|
onPressed: () => AppRoutes.openRoomplan(context),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,17 @@ class SearchSubjectColors extends SearchDelegate<void> {
|
|||||||
@override
|
@override
|
||||||
List<Widget>? buildActions(BuildContext context) => [
|
List<Widget>? buildActions(BuildContext context) => [
|
||||||
if (query.isNotEmpty)
|
if (query.isNotEmpty)
|
||||||
IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)),
|
IconButton(
|
||||||
|
onPressed: () => query = '',
|
||||||
|
tooltip: 'Leeren',
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget? buildLeading(BuildContext context) => IconButton(
|
Widget? buildLeading(BuildContext context) => IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
tooltip: 'Zurück',
|
||||||
onPressed: () => close(context, null),
|
onPressed: () => close(context, null),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class SubjectColorsView extends StatelessWidget {
|
|||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.search),
|
icon: const Icon(Icons.search),
|
||||||
|
tooltip: 'Suchen',
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
showSearch(context: context, delegate: SearchSubjectColors()),
|
showSearch(context: context, delegate: SearchSubjectColors()),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ class _TimetableState extends State<Timetable> {
|
|||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.home_outlined),
|
icon: const Icon(Icons.home_outlined),
|
||||||
|
tooltip: 'Zur aktuellen Woche',
|
||||||
onPressed: atToday ? null : _jumpToToday,
|
onPressed: atToday ? null : _jumpToToday,
|
||||||
),
|
),
|
||||||
PopupMenuButton<_CalendarAction>(
|
PopupMenuButton<_CalendarAction>(
|
||||||
@@ -179,6 +180,7 @@ class _TimetableState extends State<Timetable> {
|
|||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.home_outlined),
|
icon: const Icon(Icons.home_outlined),
|
||||||
|
tooltip: 'Zur aktuellen Woche',
|
||||||
onPressed: atToday ? null : _jumpToToday,
|
onPressed: atToday ? null : _jumpToToday,
|
||||||
),
|
),
|
||||||
if (canViewForeign)
|
if (canViewForeign)
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ class _OutsideDayColumn extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static String _subtitleFor(Appointment a) {
|
static String _subtitleFor(Appointment a) {
|
||||||
if (isAllDayLike(a)) return 'Ganztägig';
|
if (isAllDayLike(a)) return A11yLabels.allDay;
|
||||||
return '${a.startTime.formatHm()}–${a.endTime.formatHm()}';
|
return '${a.startTime.formatHm()}–${a.endTime.formatHm()}';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +122,7 @@ class _OutsideDayColumn extends StatelessWidget {
|
|||||||
height: kOutsideChipHeight,
|
height: kOutsideChipHeight,
|
||||||
child: _OutsideChip(
|
child: _OutsideChip(
|
||||||
appointment: visible[i],
|
appointment: visible[i],
|
||||||
|
crossedOut: isCrossedOut(visible[i]),
|
||||||
onTap: () => onAppointmentTap(visible[i]),
|
onTap: () => onAppointmentTap(visible[i]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -144,15 +145,28 @@ class _OutsideDayColumn extends StatelessWidget {
|
|||||||
|
|
||||||
class _OutsideChip extends StatelessWidget {
|
class _OutsideChip extends StatelessWidget {
|
||||||
final Appointment appointment;
|
final Appointment appointment;
|
||||||
|
final bool crossedOut;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
const _OutsideChip({required this.appointment, required this.onTap});
|
const _OutsideChip({
|
||||||
|
required this.appointment,
|
||||||
|
required this.crossedOut,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final allDay = isAllDayLike(appointment);
|
final allDay = isAllDayLike(appointment);
|
||||||
final timeLabel = allDay ? null : appointment.startTime.formatHm();
|
final timeLabel = allDay ? null : appointment.startTime.formatHm();
|
||||||
|
final semanticsLabel = [
|
||||||
|
appointment.subject,
|
||||||
|
if (allDay)
|
||||||
|
A11yLabels.allDay
|
||||||
|
else
|
||||||
|
'${appointment.startTime.formatHm()}–${appointment.endTime.formatHm()}',
|
||||||
|
if (crossedOut) A11yLabels.cancelled,
|
||||||
|
].join(', ');
|
||||||
|
|
||||||
// Past chips fade further, future/ongoing ones get a more saturated tint
|
// Past chips fade further, future/ongoing ones get a more saturated tint
|
||||||
// so the strip no longer reads as one uniform grey block.
|
// so the strip no longer reads as one uniform grey block.
|
||||||
@@ -163,47 +177,54 @@ class _OutsideChip extends StatelessWidget {
|
|||||||
: theme.colorScheme.onSurface;
|
: theme.colorScheme.onSurface;
|
||||||
final subjectWeight = isPast ? FontWeight.w400 : FontWeight.w600;
|
final subjectWeight = isPast ? FontWeight.w400 : FontWeight.w600;
|
||||||
|
|
||||||
return Material(
|
return Semantics(
|
||||||
color: appointment.color.withAlpha(backgroundAlpha),
|
button: true,
|
||||||
shape: const RoundedRectangleBorder(
|
label: semanticsLabel,
|
||||||
borderRadius: BorderRadius.all(Radius.circular(7)),
|
onTap: onTap,
|
||||||
),
|
child: ExcludeSemantics(
|
||||||
clipBehavior: Clip.antiAlias,
|
child: Material(
|
||||||
child: InkWell(
|
color: appointment.color.withAlpha(backgroundAlpha),
|
||||||
onTap: onTap,
|
shape: const RoundedRectangleBorder(
|
||||||
child: Padding(
|
borderRadius: BorderRadius.all(Radius.circular(7)),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
),
|
||||||
child: Row(
|
clipBehavior: Clip.antiAlias,
|
||||||
mainAxisSize: MainAxisSize.max,
|
child: InkWell(
|
||||||
children: [
|
onTap: onTap,
|
||||||
Expanded(
|
child: Padding(
|
||||||
child: Text(
|
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
||||||
appointment.subject,
|
child: Row(
|
||||||
maxLines: 1,
|
mainAxisSize: MainAxisSize.max,
|
||||||
overflow: TextOverflow.ellipsis,
|
children: [
|
||||||
softWrap: false,
|
Expanded(
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
child: Text(
|
||||||
color: subjectColor,
|
appointment.subject,
|
||||||
fontWeight: subjectWeight,
|
maxLines: 1,
|
||||||
),
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
softWrap: false,
|
||||||
),
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
if (timeLabel != null) ...[
|
color: subjectColor,
|
||||||
const SizedBox(width: 4),
|
fontWeight: subjectWeight,
|
||||||
Flexible(
|
),
|
||||||
child: Text(
|
|
||||||
timeLabel,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.fade,
|
|
||||||
softWrap: false,
|
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
fontSize: 10,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
if (timeLabel != null) ...[
|
||||||
],
|
const SizedBox(width: 4),
|
||||||
],
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
timeLabel,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.fade,
|
||||||
|
softWrap: false,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -220,18 +241,25 @@ class _OutsideOverflowChip extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
return Material(
|
return Semantics(
|
||||||
color: theme.colorScheme.secondaryContainer,
|
button: true,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
label: A11yLabels.moreAppointments(count),
|
||||||
clipBehavior: Clip.antiAlias,
|
onTap: onTap,
|
||||||
child: InkWell(
|
child: ExcludeSemantics(
|
||||||
onTap: onTap,
|
child: Material(
|
||||||
child: Center(
|
color: theme.colorScheme.secondaryContainer,
|
||||||
child: Text(
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||||
'+$count weitere',
|
clipBehavior: Clip.antiAlias,
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
child: InkWell(
|
||||||
color: theme.colorScheme.onSecondaryContainer,
|
onTap: onTap,
|
||||||
fontWeight: FontWeight.w600,
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'+$count weitere',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSecondaryContainer,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ class _PeriodLabel extends StatelessWidget {
|
|||||||
Icons.coffee_outlined,
|
Icons.coffee_outlined,
|
||||||
size: 12,
|
size: 12,
|
||||||
color: secondaryTextColor.withAlpha(180),
|
color: secondaryTextColor.withAlpha(180),
|
||||||
|
semanticLabel: A11yLabels.breakTime,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -328,18 +329,39 @@ class _DayColumn extends StatelessWidget {
|
|||||||
left: cell.lane * width / cell.laneCount,
|
left: cell.lane * width / cell.laneCount,
|
||||||
width: width / cell.laneCount,
|
width: width / cell.laneCount,
|
||||||
child: switch (cell) {
|
child: switch (cell) {
|
||||||
LaidOutAppointment(:final appointment) => GestureDetector(
|
LaidOutAppointment(:final appointment) => Semantics(
|
||||||
behavior: HitTestBehavior.opaque,
|
button: true,
|
||||||
onTap: () => onAppointmentTap(appointment),
|
label: A11yLabels.appointmentLabel(
|
||||||
child: AppointmentTile(
|
subject: appointment.subject,
|
||||||
appointment: appointment,
|
location: appointment.location ?? '',
|
||||||
|
start: appointment.startTime,
|
||||||
|
end: appointment.endTime,
|
||||||
crossedOut: isCrossedOut(appointment),
|
crossedOut: isCrossedOut(appointment),
|
||||||
),
|
),
|
||||||
|
onTap: () => onAppointmentTap(appointment),
|
||||||
|
child: ExcludeSemantics(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: () => onAppointmentTap(appointment),
|
||||||
|
child: AppointmentTile(
|
||||||
|
appointment: appointment,
|
||||||
|
crossedOut: isCrossedOut(appointment),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
LaidOutOverflow(:final appointments) => GestureDetector(
|
LaidOutOverflow(:final appointments) => Semantics(
|
||||||
behavior: HitTestBehavior.opaque,
|
button: true,
|
||||||
|
label: A11yLabels.moreAppointments(appointments.length),
|
||||||
onTap: () => _showOverflowSheet(context, appointments),
|
onTap: () => _showOverflowSheet(context, appointments),
|
||||||
child: _OverflowTile(count: appointments.length),
|
child: ExcludeSemantics(
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: () =>
|
||||||
|
_showOverflowSheet(context, appointments),
|
||||||
|
child: _OverflowTile(count: appointments.length),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import 'package:syncfusion_flutter_calendar/calendar.dart';
|
|||||||
|
|
||||||
import '../../../../extensions/date_time.dart';
|
import '../../../../extensions/date_time.dart';
|
||||||
import '../../../../utils/haptics.dart';
|
import '../../../../utils/haptics.dart';
|
||||||
|
import '../../../../widget/a11y/a11y_labels.dart';
|
||||||
import '../../../../widget/details_bottom_sheet.dart';
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
import '../data/calendar_layout.dart';
|
import '../data/calendar_layout.dart';
|
||||||
import '../data/calendar_logic.dart';
|
import '../data/calendar_logic.dart';
|
||||||
|
|||||||
@@ -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 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'a11y/a11y_labels.dart';
|
||||||
|
|
||||||
class AppProgressIndicator extends StatelessWidget {
|
class AppProgressIndicator extends StatelessWidget {
|
||||||
final double size;
|
final double size;
|
||||||
final double strokeWidth;
|
final double strokeWidth;
|
||||||
final Color? color;
|
final Color? color;
|
||||||
|
final String semanticsLabel;
|
||||||
|
|
||||||
const AppProgressIndicator._({
|
const AppProgressIndicator._({
|
||||||
required this.size,
|
required this.size,
|
||||||
required this.strokeWidth,
|
required this.strokeWidth,
|
||||||
this.color,
|
this.color,
|
||||||
|
this.semanticsLabel = A11yLabels.loading,
|
||||||
});
|
});
|
||||||
|
|
||||||
const AppProgressIndicator.small({Color? color})
|
const AppProgressIndicator.small({Color? color, String? semanticsLabel})
|
||||||
: this._(size: 16, strokeWidth: 2, color: color);
|
: this._(
|
||||||
|
size: 16,
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: color,
|
||||||
|
semanticsLabel: semanticsLabel ?? A11yLabels.loading,
|
||||||
|
);
|
||||||
|
|
||||||
const AppProgressIndicator.medium({Color? color})
|
const AppProgressIndicator.medium({Color? color, String? semanticsLabel})
|
||||||
: this._(size: 24, strokeWidth: 2.5, color: color);
|
: this._(
|
||||||
|
size: 24,
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
color: color,
|
||||||
|
semanticsLabel: semanticsLabel ?? A11yLabels.loading,
|
||||||
|
);
|
||||||
|
|
||||||
const AppProgressIndicator.large({Color? color})
|
const AppProgressIndicator.large({Color? color, String? semanticsLabel})
|
||||||
: this._(size: 40, strokeWidth: 3, color: color);
|
: this._(
|
||||||
|
size: 40,
|
||||||
|
strokeWidth: 3,
|
||||||
|
color: color,
|
||||||
|
semanticsLabel: semanticsLabel ?? A11yLabels.loading,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -29,6 +48,7 @@ class AppProgressIndicator extends StatelessWidget {
|
|||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: strokeWidth,
|
strokeWidth: strokeWidth,
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(resolved),
|
valueColor: AlwaysStoppedAnimation<Color>(resolved),
|
||||||
|
semanticsLabel: semanticsLabel,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,10 +105,14 @@ class _InlineErrorWrapper extends StatelessWidget {
|
|||||||
child,
|
child,
|
||||||
if (err != null) ...[
|
if (err != null) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Semantics(
|
||||||
err,
|
liveRegion: true,
|
||||||
textAlign: TextAlign.center,
|
container: true,
|
||||||
style: _asyncErrorTextStyle(context),
|
child: Text(
|
||||||
|
err,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: _asyncErrorTextStyle(context),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ Future<String?> showEmojiPicker(
|
|||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
|
tooltip: 'Zurück',
|
||||||
onPressed: () => Navigator.of(pickerCtx).pop(),
|
onPressed: () => Navigator.of(pickerCtx).pop(),
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
photoViewController.rotation += pi / 2;
|
photoViewController.rotation += pi / 2;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
tooltip: 'Drehen',
|
||||||
icon: const Icon(Icons.rotate_right),
|
icon: const Icon(Icons.rotate_right),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:photo_view/photo_view.dart';
|
import 'package:photo_view/photo_view.dart';
|
||||||
|
|
||||||
import '../model/account_data.dart';
|
import '../model/account_data.dart';
|
||||||
|
import 'a11y/a11y_labels.dart';
|
||||||
import 'user_avatar.dart';
|
import 'user_avatar.dart';
|
||||||
|
|
||||||
class LargeProfilePictureView extends StatelessWidget {
|
class LargeProfilePictureView extends StatelessWidget {
|
||||||
@@ -15,18 +16,25 @@ class LargeProfilePictureView extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Scaffold(
|
Widget build(BuildContext context) {
|
||||||
appBar: AppBar(title: Text(isGroup ? 'Gruppenbild' : 'Profilbild')),
|
final label = isGroup ? A11yLabels.groupPicture : A11yLabels.profilePicture;
|
||||||
body: PhotoView(
|
return Scaffold(
|
||||||
minScale: 0.5,
|
appBar: AppBar(title: Text(label)),
|
||||||
maxScale: 3.0,
|
body: Semantics(
|
||||||
imageProvider: Image.network(
|
image: true,
|
||||||
avatarUrl(id: id, isGroup: isGroup, size: 1024),
|
label: label,
|
||||||
headers: {'Authorization': AccountData().getBasicAuthHeader()},
|
child: PhotoView(
|
||||||
).image,
|
minScale: 0.5,
|
||||||
backgroundDecoration: BoxDecoration(
|
maxScale: 3.0,
|
||||||
color: Theme.of(context).colorScheme.surface,
|
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/account_data.dart';
|
||||||
import '../model/endpoint_data.dart';
|
import '../model/endpoint_data.dart';
|
||||||
import '../push/push_avatar.dart';
|
import '../push/push_avatar.dart';
|
||||||
|
import 'a11y/a11y_labels.dart';
|
||||||
import 'avatar_disk_cache.dart';
|
import 'avatar_disk_cache.dart';
|
||||||
|
|
||||||
class UserAvatar extends StatefulWidget {
|
class UserAvatar extends StatefulWidget {
|
||||||
@@ -17,6 +18,10 @@ class UserAvatar extends StatefulWidget {
|
|||||||
final bool isGroup;
|
final bool isGroup;
|
||||||
final int size;
|
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
|
/// Server-side pixel size requested for user avatars. `null` lets the
|
||||||
/// widget pick `(size * 4).clamp(64, 1024)` — enough headroom for typical
|
/// widget pick `(size * 4).clamp(64, 1024)` — enough headroom for typical
|
||||||
/// device pixel ratios. Group avatars ignore this (Spreed serves one
|
/// device pixel ratios. Group avatars ignore this (Spreed serves one
|
||||||
@@ -28,6 +33,7 @@ class UserAvatar extends StatefulWidget {
|
|||||||
this.isGroup = false,
|
this.isGroup = false,
|
||||||
this.size = 20,
|
this.size = 20,
|
||||||
this.requestSize,
|
this.requestSize,
|
||||||
|
this.semanticLabel,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -225,7 +231,9 @@ class _UserAvatarState extends State<UserAvatar> {
|
|||||||
final pending = _pendingAvatars.putIfAbsent(url, () {
|
final pending = _pendingAvatars.putIfAbsent(url, () {
|
||||||
final future = _fetch(url);
|
final future = _fetch(url);
|
||||||
future.whenComplete(() {
|
future.whenComplete(() {
|
||||||
if (identical(_pendingAvatars[url], future)) _pendingAvatars.remove(url);
|
if (identical(_pendingAvatars[url], future)) {
|
||||||
|
_pendingAvatars.remove(url);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return future;
|
return future;
|
||||||
});
|
});
|
||||||
@@ -345,12 +353,24 @@ class _UserAvatarState extends State<UserAvatar> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return CircleAvatar(
|
return Semantics(
|
||||||
radius: radius,
|
image: true,
|
||||||
backgroundColor: theme.primaryColor,
|
label:
|
||||||
foregroundColor: Colors.white,
|
widget.semanticLabel ??
|
||||||
child: ClipOval(
|
(widget.isGroup
|
||||||
child: SizedBox(width: radius * 2, height: radius * 2, child: content),
|
? 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:jiffy/jiffy.dart';
|
||||||
|
import 'package:marianum_mobile/widget/a11y/a11y_labels.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUpAll(() async {
|
||||||
|
// appointmentLabel formatiert über formatHm() (Jiffy) – Locale einmal laden.
|
||||||
|
await Jiffy.setLocale('de');
|
||||||
|
});
|
||||||
|
|
||||||
|
group('A11yLabels.appointmentLabel', () {
|
||||||
|
test('setzt Fach, Ort und Zeitspanne zusammen', () {
|
||||||
|
final label = A11yLabels.appointmentLabel(
|
||||||
|
subject: 'Mathe',
|
||||||
|
location: 'Raum 101',
|
||||||
|
start: DateTime(2026, 5, 8, 8, 0),
|
||||||
|
end: DateTime(2026, 5, 8, 8, 45),
|
||||||
|
crossedOut: false,
|
||||||
|
);
|
||||||
|
expect(label, 'Mathe, Raum 101, 08:00–08:45');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hängt bei Ausfall den Ausfall-Hinweis an', () {
|
||||||
|
final label = A11yLabels.appointmentLabel(
|
||||||
|
subject: 'Deutsch',
|
||||||
|
location: 'Raum 5',
|
||||||
|
start: DateTime(2026, 5, 8, 9, 0),
|
||||||
|
end: DateTime(2026, 5, 8, 9, 45),
|
||||||
|
crossedOut: true,
|
||||||
|
);
|
||||||
|
expect(label, endsWith(A11yLabels.cancelled));
|
||||||
|
expect(label, 'Deutsch, Raum 5, 09:00–09:45, Ausfall');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('glättet Zeilenumbrüche im Ort zu Trennern', () {
|
||||||
|
final label = A11yLabels.appointmentLabel(
|
||||||
|
subject: 'Sport',
|
||||||
|
location: 'Halle\nHr. Müller',
|
||||||
|
start: DateTime(2026, 5, 8, 10, 0),
|
||||||
|
end: DateTime(2026, 5, 8, 10, 45),
|
||||||
|
crossedOut: false,
|
||||||
|
);
|
||||||
|
expect(label, 'Sport, Halle, Hr. Müller, 10:00–10:45');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lässt einen leeren Ort weg', () {
|
||||||
|
final label = A11yLabels.appointmentLabel(
|
||||||
|
subject: 'Pause',
|
||||||
|
location: '',
|
||||||
|
start: DateTime(2026, 5, 8, 11, 0),
|
||||||
|
end: DateTime(2026, 5, 8, 11, 15),
|
||||||
|
crossedOut: false,
|
||||||
|
);
|
||||||
|
expect(label, 'Pause, 11:00–11:15');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('A11yLabels.moreAppointments', () {
|
||||||
|
test('pluralisiert die Overflow-Beschriftung', () {
|
||||||
|
expect(A11yLabels.moreAppointments(3), '+3 weitere Termine');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:marianum_mobile/widget/a11y/a11y_labels.dart';
|
||||||
|
import 'package:marianum_mobile/widget/app_progress_indicator.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
Widget wrap(Widget child) => MaterialApp(home: Scaffold(body: child));
|
||||||
|
|
||||||
|
testWidgets('trägt standardmäßig das Lade-Label an den Screenreader', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(wrap(const AppProgressIndicator.large()));
|
||||||
|
|
||||||
|
final indicator = tester.widget<CircularProgressIndicator>(
|
||||||
|
find.byType(CircularProgressIndicator),
|
||||||
|
);
|
||||||
|
expect(indicator.semanticsLabel, A11yLabels.loading);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('reicht ein spezifisches Label durch', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
wrap(const AppProgressIndicator.small(semanticsLabel: 'Wird gesendet')),
|
||||||
|
);
|
||||||
|
|
||||||
|
final indicator = tester.widget<CircularProgressIndicator>(
|
||||||
|
find.byType(CircularProgressIndicator),
|
||||||
|
);
|
||||||
|
expect(indicator.semanticsLabel, 'Wird gesendet');
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user