diff --git a/lib/app.dart b/lib/app.dart index 0f1c405..0870ebf 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -19,6 +19,20 @@ class NullFeedApp extends ConsumerWidget { themeMode: ThemeMode.dark, routerConfig: router, debugShowCheckedModeBanner: false, + // Honor the platform Dynamic Type setting, but clamp the extremes so the + // fixed-size cards and rows don't break at very large accessibility sizes. + builder: (context, child) { + final mq = MediaQuery.of(context); + return MediaQuery( + data: mq.copyWith( + textScaler: mq.textScaler.clamp( + minScaleFactor: 0.8, + maxScaleFactor: 1.3, + ), + ), + child: child!, + ); + }, ); } } diff --git a/lib/config/theme.dart b/lib/config/theme.dart index 5022197..59aeca1 100644 --- a/lib/config/theme.dart +++ b/lib/config/theme.dart @@ -11,7 +11,9 @@ class NullFeedTheme { static const Color backgroundColor = Color(0xFF0A0A0A); static const Color textPrimary = Color(0xFFFFFFFF); static const Color textSecondary = Color(0xFFB3B3B3); - static const Color textMuted = Color(0xFF666666); + // Lightened from 0xFF666666 to meet WCAG AA (>=4.5:1): 4.5:1 on cardColor, + // 5.4:1 on backgroundColor. The old value was ~2.9:1 on cards. + static const Color textMuted = Color(0xFF858585); static const Color dividerColor = Color(0xFF2A2A2A); static const Color errorColor = Color(0xFFCF6679); static const Color successColor = Color(0xFF4CAF50); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index da0c7d1..e18e1f6 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -593,6 +593,7 @@ class _VideoPlayerScreenState extends ConsumerState { alignment: Alignment.topLeft, child: IconButton( icon: const Icon(Icons.arrow_back, color: Colors.white), + tooltip: 'Back', onPressed: _navigateBack, ), ), @@ -604,21 +605,24 @@ class _VideoPlayerScreenState extends ConsumerState { top: 16, right: 16, child: SafeArea( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - '360p', - style: TextStyle( - color: Colors.white70, - fontSize: 12, - fontWeight: FontWeight.bold, + child: Semantics( + label: 'Playing preview quality, 360p', + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + '360p', + style: TextStyle( + color: Colors.white70, + fontSize: 12, + fontWeight: FontWeight.bold, + ), ), ), ), @@ -683,6 +687,7 @@ class _ControlsOverlay extends StatelessWidget { children: [ IconButton( icon: const Icon(Icons.arrow_back, color: Colors.white), + tooltip: 'Back', onPressed: onBack, ), const Spacer(), @@ -704,6 +709,8 @@ class _ControlsOverlay extends StatelessWidget { IconButton( iconSize: 48, icon: const Icon(Icons.replay_10, color: Colors.white), + tooltip: + 'Skip back ${AppConstants.skipBackwardSeconds} seconds', onPressed: () { onSeekRelative(-AppConstants.skipBackwardSeconds); onInteraction(); @@ -720,6 +727,7 @@ class _ControlsOverlay extends StatelessWidget { : Icons.play_circle_filled, color: Colors.white, ), + tooltip: value.isPlaying ? 'Pause' : 'Play', onPressed: () { onPlayPause(); onInteraction(); @@ -730,6 +738,8 @@ class _ControlsOverlay extends StatelessWidget { IconButton( iconSize: 48, icon: const Icon(Icons.forward_10, color: Colors.white), + tooltip: + 'Skip forward ${AppConstants.skipForwardSeconds} seconds', onPressed: () { onSeekRelative(AppConstants.skipForwardSeconds); onInteraction(); @@ -760,6 +770,15 @@ class _ControlsOverlay extends StatelessWidget { ? position.inMilliseconds / duration.inMilliseconds : 0, height: 4, + semanticLabel: 'Video position', + semanticValueBuilder: (fraction) { + final at = Duration( + milliseconds: (fraction * duration.inMilliseconds) + .round(), + ); + return '${_formatDuration(at)} of ' + '${_formatDuration(duration)}'; + }, onSeek: (fraction) { final target = Duration( milliseconds: (fraction * duration.inMilliseconds) @@ -864,7 +883,9 @@ class _SpeedButton extends StatelessWidget { ), ], child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + // vertical: 12 keeps the tap target at the 44pt minimum (20pt icon + + // 24pt padding). + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), child: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/widgets/channel_card.dart b/lib/widgets/channel_card.dart index b4429b3..970795e 100644 --- a/lib/widgets/channel_card.dart +++ b/lib/widgets/channel_card.dart @@ -40,118 +40,136 @@ class _ChannelCardState extends State { curve: Curves.easeOut, child: Card( clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: widget.onTap == null ? null : _handleTap, - onLongPress: widget.onMenu, - onTapDown: (_) => setState(() => _isPressed = true), - onTapUp: (_) => setState(() => _isPressed = false), - onTapCancel: () => setState(() => _isPressed = false), - child: Stack( - fit: StackFit.expand, - children: [ - // Banner image - if (widget.channel.bannerUrl != null) - CachedNetworkImage( - imageUrl: widget.channel.bannerUrl!, - fit: BoxFit.cover, - errorWidget: (_, __, ___) => - Container(color: NullFeedTheme.cardColor), - placeholder: (_, __) => - Container(color: NullFeedTheme.cardColor), - ) - else - Container( - color: NullFeedTheme.cardColor, - child: Center( - child: Icon( - Icons.subscriptions, - size: 40, - color: NullFeedTheme.primaryColor.withValues(alpha: 0.3), - ), - ), - ), - - // Gradient overlay - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.85), - ], - stops: const [0.3, 1.0], - ), - ), - ), - - // Actions menu button - if (widget.onMenu != null) - Positioned( - top: 4, - right: 4, - child: IconButton( - icon: const Icon(Icons.more_vert, size: 20), - color: Colors.white70, - style: IconButton.styleFrom( - backgroundColor: Colors.black.withValues(alpha: 0.4), - ), - visualDensity: VisualDensity.compact, - onPressed: widget.onMenu, - tooltip: 'Channel actions', - ), - ), - - // Channel info - Positioned( - left: 12, - right: 12, - bottom: 12, - child: Row( + child: Stack( + fit: StackFit.expand, + children: [ + // Main tappable surface: one merged node labelled with the channel + // name. The actions menu lives in its own node below. + Semantics( + button: widget.onTap != null, + label: '${widget.channel.name} channel', + excludeSemantics: true, + onTap: widget.onTap == null ? null : _handleTap, + child: InkWell( + onTap: widget.onTap == null ? null : _handleTap, + onLongPress: widget.onMenu, + onTapDown: (_) => setState(() => _isPressed = true), + onTapUp: (_) => setState(() => _isPressed = false), + onTapCancel: () => setState(() => _isPressed = false), + child: Stack( + fit: StackFit.expand, children: [ - if (widget.channel.avatarUrl != null) - CircleAvatar( - radius: 18, - backgroundImage: CachedNetworkImageProvider( - widget.channel.avatarUrl!, - ), + // Banner image + if (widget.channel.bannerUrl != null) + CachedNetworkImage( + imageUrl: widget.channel.bannerUrl!, + fit: BoxFit.cover, + errorWidget: (_, __, ___) => + Container(color: NullFeedTheme.cardColor), + placeholder: (_, __) => + Container(color: NullFeedTheme.cardColor), ) else - CircleAvatar( - radius: 18, - backgroundColor: NullFeedTheme.primaryColor.withValues( - alpha: 0.3, - ), - child: Text( - widget.channel.name.isNotEmpty - ? widget.channel.name[0].toUpperCase() - : '?', - style: const TextStyle( - color: NullFeedTheme.primaryColor, - fontWeight: FontWeight.bold, - fontSize: 14, + Container( + color: NullFeedTheme.cardColor, + child: Center( + child: Icon( + Icons.subscriptions, + size: 40, + color: NullFeedTheme.primaryColor.withValues( + alpha: 0.3, + ), ), ), ), - const SizedBox(width: 10), - Expanded( - child: Text( - widget.channel.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w600, + + // Gradient overlay + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.85), + ], + stops: const [0.3, 1.0], ), ), ), + + // Channel info + Positioned( + left: 12, + right: 12, + bottom: 12, + child: Row( + children: [ + if (widget.channel.avatarUrl != null) + CircleAvatar( + radius: 18, + backgroundImage: CachedNetworkImageProvider( + widget.channel.avatarUrl!, + ), + ) + else + CircleAvatar( + radius: 18, + backgroundColor: NullFeedTheme.primaryColor + .withValues(alpha: 0.3), + child: Text( + widget.channel.name.isNotEmpty + ? widget.channel.name[0].toUpperCase() + : '?', + style: const TextStyle( + color: NullFeedTheme.primaryColor, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + widget.channel.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), ], ), ), - ], - ), + ), + + // Actions menu button — a separate, operable node on top. + if (widget.onMenu != null) + Positioned( + top: 4, + right: 4, + child: IconButton( + icon: const Icon(Icons.more_vert, size: 20), + color: Colors.white70, + style: IconButton.styleFrom( + backgroundColor: Colors.black.withValues(alpha: 0.4), + ), + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints( + minWidth: 44, + minHeight: 44, + ), + onPressed: widget.onMenu, + tooltip: 'Channel actions', + ), + ), + ], ), ), ); diff --git a/lib/widgets/content_row.dart b/lib/widgets/content_row.dart index 4a4725a..04d74de 100644 --- a/lib/widgets/content_row.dart +++ b/lib/widgets/content_row.dart @@ -27,6 +27,11 @@ class ContentRow extends StatelessWidget { @override Widget build(BuildContext context) { final padding = AdaptiveLayout.contentPadding(context); + // Grow the row with the (root-clamped) text scale so card titles keep their + // room as Dynamic Type gets larger. + final rowHeight = + AppConstants.contentRowHeight * + MediaQuery.textScalerOf(context).scale(1.0); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -36,7 +41,7 @@ class ContentRow extends StatelessWidget { child: Text(title, style: Theme.of(context).textTheme.titleLarge), ), SizedBox( - height: AppConstants.contentRowHeight, + height: rowHeight, child: errorText != null ? _buildError(context, padding) : isLoading diff --git a/lib/widgets/progress_bar.dart b/lib/widgets/progress_bar.dart index 6d7ba37..37403a1 100644 --- a/lib/widgets/progress_bar.dart +++ b/lib/widgets/progress_bar.dart @@ -8,6 +8,14 @@ class NullFeedProgressBar extends StatelessWidget { final Color? backgroundColor; final void Function(double)? onSeek; + /// Accessibility label for the seek slider. Only used when [onSeek] is set. + final String? semanticLabel; + + /// Formats a 0–1 position into the value a screen reader announces (e.g. + /// "1:23 of 4:56"). Falls back to a percentage. Only used when [onSeek] is + /// set — the slider's increase/decrease values are derived from it too. + final String Function(double fraction)? semanticValueBuilder; + const NullFeedProgressBar({ super.key, required this.progress, @@ -15,6 +23,8 @@ class NullFeedProgressBar extends StatelessWidget { this.foregroundColor, this.backgroundColor, this.onSeek, + this.semanticLabel, + this.semanticValueBuilder, }); @override @@ -34,30 +44,45 @@ class NullFeedProgressBar extends StatelessWidget { ); if (onSeek != null) { - return GestureDetector( - onTapDown: (details) { - final box = context.findRenderObject() as RenderBox?; - if (box != null) { - final fraction = (details.localPosition.dx / box.size.width).clamp( - 0.0, - 1.0, - ); - onSeek!(fraction); - } - }, - onHorizontalDragUpdate: (details) { - final box = context.findRenderObject() as RenderBox?; - if (box != null) { - final fraction = (details.localPosition.dx / box.size.width).clamp( - 0.0, - 1.0, - ); - onSeek!(fraction); - } - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: bar, + final seek = onSeek!; + final clamped = progress.clamp(0.0, 1.0); + // Let assistive tech step through the track in 5% increments when fine + // touch isn't an option. + final up = (clamped + 0.05).clamp(0.0, 1.0); + final down = (clamped - 0.05).clamp(0.0, 1.0); + String valueFor(double fraction) => + semanticValueBuilder?.call(fraction) ?? + '${(fraction * 100).round()}%'; + return Semantics( + slider: true, + label: semanticLabel ?? 'Seek bar', + value: valueFor(clamped), + increasedValue: valueFor(up), + decreasedValue: valueFor(down), + onIncrease: () => seek(up), + onDecrease: () => seek(down), + excludeSemantics: true, + child: GestureDetector( + onTapDown: (details) { + final box = context.findRenderObject() as RenderBox?; + if (box != null) { + final fraction = (details.localPosition.dx / box.size.width) + .clamp(0.0, 1.0); + seek(fraction); + } + }, + onHorizontalDragUpdate: (details) { + final box = context.findRenderObject() as RenderBox?; + if (box != null) { + final fraction = (details.localPosition.dx / box.size.width) + .clamp(0.0, 1.0); + seek(fraction); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: bar, + ), ), ); } diff --git a/lib/widgets/video_card.dart b/lib/widgets/video_card.dart index 25d6fa5..581c97e 100644 --- a/lib/widgets/video_card.dart +++ b/lib/widgets/video_card.dart @@ -37,6 +37,16 @@ class _VideoCardState extends ConsumerState { return null; } + /// One merged, human-readable label for the play target — title, channel and + /// download state — instead of a pile of separate text/icon nodes. + String _semanticLabel(String? offlineStatus) { + final parts = [widget.video.title]; + final channel = widget.channel; + if (channel != null) parts.add(channel.name); + if (offlineStatus == 'complete') parts.add('downloaded'); + return parts.join(', '); + } + Future _onActivate() async { HapticFeedback.selectionClick(); // Every home-feed card plays its video on tap; channel navigation lives @@ -57,6 +67,7 @@ class _VideoCardState extends ConsumerState { @override Widget build(BuildContext context) { const cardWidth = AppConstants.videoCardWidth; + final offlineStatus = ref.watch(offlineStatusProvider(widget.video.id)); return AnimatedScale( scale: _isPressed ? 0.97 : 1.0, @@ -69,31 +80,48 @@ class _VideoCardState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ // Thumbnail + progress + title are one tap target that plays. - Material( - type: MaterialType.transparency, - child: InkWell( - onTap: _onActivate, - onTapDown: (_) => setState(() => _isPressed = true), - onTapUp: (_) => setState(() => _isPressed = false), - onTapCancel: () => setState(() => _isPressed = false), - borderRadius: BorderRadius.circular(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // Thumbnail - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: AspectRatio( - aspectRatio: AppConstants.cardAspectRatio, - child: Stack( - fit: StackFit.expand, - children: [ - if (_thumbnailUrl != null) - CachedNetworkImage( - imageUrl: _thumbnailUrl!, - fit: BoxFit.cover, - errorWidget: (_, __, ___) => Container( + Semantics( + button: true, + label: _semanticLabel(offlineStatus), + excludeSemantics: true, + onTap: _onActivate, + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: _onActivate, + onTapDown: (_) => setState(() => _isPressed = true), + onTapUp: (_) => setState(() => _isPressed = false), + onTapCancel: () => setState(() => _isPressed = false), + borderRadius: BorderRadius.circular(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Thumbnail + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: AspectRatio( + aspectRatio: AppConstants.cardAspectRatio, + child: Stack( + fit: StackFit.expand, + children: [ + if (_thumbnailUrl != null) + CachedNetworkImage( + imageUrl: _thumbnailUrl!, + fit: BoxFit.cover, + errorWidget: (_, __, ___) => Container( + color: NullFeedTheme.cardColor, + child: const Icon( + Icons.play_circle_outline, + color: NullFeedTheme.textMuted, + size: 40, + ), + ), + placeholder: (_, __) => + Container(color: NullFeedTheme.cardColor), + ) + else + Container( color: NullFeedTheme.cardColor, child: const Icon( Icons.play_circle_outline, @@ -101,95 +129,85 @@ class _VideoCardState extends ConsumerState { size: 40, ), ), - placeholder: (_, __) => - Container(color: NullFeedTheme.cardColor), - ) - else - Container( - color: NullFeedTheme.cardColor, - child: const Icon( - Icons.play_circle_outline, - color: NullFeedTheme.textMuted, - size: 40, - ), - ), - // Duration badge - if (widget.video.durationSeconds > 0) - Positioned( - right: 6, - bottom: 6, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.8), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - widget.video.formattedDuration, - style: const TextStyle( - color: Colors.white, - fontSize: 11, - fontWeight: FontWeight.w500, + // Duration badge + if (widget.video.durationSeconds > 0) + Positioned( + right: 6, + bottom: 6, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: 0.8, + ), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + widget.video.formattedDuration, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w500, + ), ), ), ), - ), - // Offline badge - if (ref.watch( - offlineStatusProvider(widget.video.id), - ) == - 'complete') - Positioned( - left: 6, - bottom: 6, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(4), - ), - child: const Icon( - Icons.offline_pin, - color: NullFeedTheme.successColor, - size: 16, + // Offline badge + if (offlineStatus == 'complete') + Positioned( + left: 6, + bottom: 6, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: 0.7, + ), + borderRadius: BorderRadius.circular(4), + ), + child: const Icon( + Icons.offline_pin, + color: NullFeedTheme.successColor, + size: 16, + ), ), ), - ), - ], + ], + ), ), ), - ), - // Progress bar - if (widget.showProgress && widget.video.watchProgress > 0) - Padding( - padding: const EdgeInsets.only(top: 2), - child: NullFeedProgressBar( - progress: widget.video.watchProgress, - height: 3, + // Progress bar + if (widget.showProgress && widget.video.watchProgress > 0) + Padding( + padding: const EdgeInsets.only(top: 2), + child: NullFeedProgressBar( + progress: widget.video.watchProgress, + height: 3, + ), ), - ), - // Title - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - widget.video.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: NullFeedTheme.textPrimary, - fontSize: 13, - fontWeight: FontWeight.w500, + // Title + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + widget.video.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: NullFeedTheme.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w500, + ), ), ), - ), - ], + ], + ), ), ), ), @@ -198,36 +216,42 @@ class _VideoCardState extends ConsumerState { if (widget.channel != null) Padding( padding: const EdgeInsets.only(top: 4), - child: Material( - type: MaterialType.transparency, - child: InkWell( - onTap: _openChannel, - borderRadius: BorderRadius.circular(8), - child: Row( - children: [ - if (widget.channel!.avatarUrl != null) - Padding( - padding: const EdgeInsets.only(right: 6), - child: CircleAvatar( - radius: 10, - backgroundImage: CachedNetworkImageProvider( - widget.channel!.avatarUrl!, + child: Semantics( + button: true, + label: 'Go to ${widget.channel!.name}', + excludeSemantics: true, + onTap: _openChannel, + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: _openChannel, + borderRadius: BorderRadius.circular(8), + child: Row( + children: [ + if (widget.channel!.avatarUrl != null) + Padding( + padding: const EdgeInsets.only(right: 6), + child: CircleAvatar( + radius: 10, + backgroundImage: CachedNetworkImageProvider( + widget.channel!.avatarUrl!, + ), + backgroundColor: NullFeedTheme.cardColor, ), - backgroundColor: NullFeedTheme.cardColor, ), - ), - Expanded( - child: Text( - widget.channel!.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: NullFeedTheme.textMuted, - fontSize: 12, + Expanded( + child: Text( + widget.channel!.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: NullFeedTheme.textMuted, + fontSize: 12, + ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/widgets/video_list_tile.dart b/lib/widgets/video_list_tile.dart index 6c175b3..6d65c19 100644 --- a/lib/widgets/video_list_tile.dart +++ b/lib/widgets/video_list_tile.dart @@ -48,6 +48,36 @@ class _VideoListTileState extends ConsumerState { return null; } + /// One merged, human-readable label — title, date, watched and download + /// state — instead of a pile of separate text/icon nodes. + String _semanticLabel(String? offlineStatus) { + final v = widget.video; + final parts = [v.title]; + if (v.uploadedAt != null) { + parts.add(DateFormat.yMMMd().format(v.uploadedAt!)); + } + if (v.isWatched) parts.add('watched'); + final state = _downloadStateLabel(offlineStatus); + if (state != null) parts.add(state); + return parts.join(', '); + } + + String? _downloadStateLabel(String? offlineStatus) { + switch (widget.video.status) { + case VideoStatus.complete: + if (offlineStatus == 'complete') return 'downloaded'; + if (offlineStatus == 'downloading') return 'downloading to device'; + return null; + case VideoStatus.downloading: + case VideoStatus.pending: + return 'downloading'; + case VideoStatus.cataloged: + return null; + case VideoStatus.failed: + return 'download failed'; + } + } + Widget _buildTrailingWidget() { final offlineStatus = ref.watch(offlineStatusProvider(widget.video.id)); final offlineProgress = ref.watch(offlineProgressProvider); @@ -63,8 +93,8 @@ class _VideoListTileState extends ConsumerState { if (offlineStatus == 'downloading') { final progress = offlineProgress[widget.video.id]; return SizedBox( - width: 36, - height: 36, + width: 44, + height: 44, child: Stack( alignment: Alignment.center, children: [ @@ -76,19 +106,24 @@ class _VideoListTileState extends ConsumerState { value: progress, ), ), - InkWell( - onTap: () { + IconButton( + icon: const Icon( + Icons.stop_rounded, + size: 14, + color: NullFeedTheme.textMuted, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 44, + minHeight: 44, + ), + tooltip: 'Cancel download', + onPressed: () { ref .read(offlineServiceProvider) .cancelDownload(widget.video.id); ref.read(offlineVideosProvider.notifier).refresh(); }, - borderRadius: BorderRadius.circular(18), - child: const Icon( - Icons.stop_rounded, - size: 14, - color: NullFeedTheme.textMuted, - ), ), ], ), @@ -114,8 +149,8 @@ class _VideoListTileState extends ConsumerState { case VideoStatus.downloading: case VideoStatus.pending: return SizedBox( - width: 36, - height: 36, + width: 44, + height: 44, child: Stack( alignment: Alignment.center, children: [ @@ -130,14 +165,19 @@ class _VideoListTileState extends ConsumerState { ), ), if (widget.onCancel != null) - InkWell( - onTap: widget.onCancel, - borderRadius: BorderRadius.circular(18), - child: const Icon( + IconButton( + icon: const Icon( Icons.stop_rounded, size: 14, color: NullFeedTheme.textMuted, ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 44, + minHeight: 44, + ), + tooltip: 'Cancel download', + onPressed: widget.onCancel, ), ], ), @@ -163,6 +203,7 @@ class _VideoListTileState extends ConsumerState { @override Widget build(BuildContext context) { final isTappable = widget.onTap != null; + final offlineStatus = ref.watch(offlineStatusProvider(widget.video.id)); return Opacity( opacity: isTappable || widget.video.isPlayable ? 1.0 : 0.7, @@ -170,147 +211,167 @@ class _VideoListTileState extends ConsumerState { scale: _isPressed ? 0.98 : 1.0, duration: const Duration(milliseconds: 120), curve: Curves.easeOut, - child: InkWell( - onTap: isTappable ? _handleTap : null, - onLongPress: widget.onMenu, - onTapDown: isTappable - ? (_) => setState(() => _isPressed = true) - : null, - onTapUp: isTappable - ? (_) => setState(() => _isPressed = false) - : null, - onTapCancel: isTappable - ? () => setState(() => _isPressed = false) - : null, - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - // Thumbnail - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: SizedBox( - width: 160, - height: 90, - child: Stack( - fit: StackFit.expand, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + // Play target: thumbnail + info as one labelled node. The + // trailing controls keep their own nodes so they stay operable. + Expanded( + child: Semantics( + button: isTappable, + label: _semanticLabel(offlineStatus), + excludeSemantics: true, + onTap: isTappable ? _handleTap : null, + child: InkWell( + onTap: isTappable ? _handleTap : null, + onLongPress: widget.onMenu, + onTapDown: isTappable + ? (_) => setState(() => _isPressed = true) + : null, + onTapUp: isTappable + ? (_) => setState(() => _isPressed = false) + : null, + onTapCancel: isTappable + ? () => setState(() => _isPressed = false) + : null, + borderRadius: BorderRadius.circular(8), + child: Row( children: [ - if (_thumbnailUrl != null) - CachedNetworkImage( - imageUrl: _thumbnailUrl!, - fit: BoxFit.cover, - errorWidget: (_, __, ___) => Container( - color: NullFeedTheme.cardColor, - child: const Icon( - Icons.play_circle_outline, - color: NullFeedTheme.textMuted, - ), - ), - ) - else - Container( - color: NullFeedTheme.cardColor, - child: const Icon( - Icons.play_circle_outline, - color: NullFeedTheme.textMuted, + // Thumbnail + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: SizedBox( + width: 160, + height: 90, + child: Stack( + fit: StackFit.expand, + children: [ + if (_thumbnailUrl != null) + CachedNetworkImage( + imageUrl: _thumbnailUrl!, + fit: BoxFit.cover, + errorWidget: (_, __, ___) => Container( + color: NullFeedTheme.cardColor, + child: const Icon( + Icons.play_circle_outline, + color: NullFeedTheme.textMuted, + ), + ), + ) + else + Container( + color: NullFeedTheme.cardColor, + child: const Icon( + Icons.play_circle_outline, + color: NullFeedTheme.textMuted, + ), + ), + // Duration + if (widget.video.durationSeconds > 0) + Positioned( + right: 4, + bottom: 4, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: 0.8, + ), + borderRadius: BorderRadius.circular(3), + ), + child: Text( + widget.video.formattedDuration, + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + // Watch progress bar + if (widget.video.watchProgress > 0) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: NullFeedProgressBar( + progress: widget.video.watchProgress, + height: 3, + ), + ), + ], ), ), - // Duration - if (widget.video.durationSeconds > 0) - Positioned( - right: 4, - bottom: 4, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 1, - ), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.8), - borderRadius: BorderRadius.circular(3), - ), - child: Text( - widget.video.formattedDuration, + ), + const SizedBox(width: 12), + // Video info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.video.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, style: const TextStyle( - color: Colors.white, - fontSize: 10, + color: NullFeedTheme.textPrimary, + fontSize: 14, fontWeight: FontWeight.w500, ), ), - ), - ), - // Watch progress bar - if (widget.video.watchProgress > 0) - Positioned( - left: 0, - right: 0, - bottom: 0, - child: NullFeedProgressBar( - progress: widget.video.watchProgress, - height: 3, - ), + const SizedBox(height: 4), + Row( + children: [ + if (widget.video.uploadedAt != null) + Text( + DateFormat.yMMMd().format( + widget.video.uploadedAt!, + ), + style: const TextStyle( + color: NullFeedTheme.textMuted, + fontSize: 12, + ), + ), + if (widget.video.isWatched) ...[ + const SizedBox(width: 8), + const Icon( + Icons.check_circle, + size: 14, + color: NullFeedTheme.successColor, + ), + ], + ], + ), + ], ), + ), ], ), ), ), - const SizedBox(width: 12), - // Video info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.video.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: NullFeedTheme.textPrimary, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 4), - Row( - children: [ - if (widget.video.uploadedAt != null) - Text( - DateFormat.yMMMd().format( - widget.video.uploadedAt!, - ), - style: const TextStyle( - color: NullFeedTheme.textMuted, - fontSize: 12, - ), - ), - if (widget.video.isWatched) ...[ - const SizedBox(width: 8), - const Icon( - Icons.check_circle, - size: 14, - color: NullFeedTheme.successColor, - ), - ], - ], - ), - ], + ), + // Status-aware trailing widget + _buildTrailingWidget(), + if (widget.onMenu != null) + IconButton( + icon: const Icon( + Icons.more_vert, + color: NullFeedTheme.textMuted, ), - ), - // Status-aware trailing widget - _buildTrailingWidget(), - if (widget.onMenu != null) - IconButton( - icon: const Icon( - Icons.more_vert, - color: NullFeedTheme.textMuted, - ), - visualDensity: VisualDensity.compact, - onPressed: widget.onMenu, - tooltip: 'More actions', + visualDensity: VisualDensity.compact, + constraints: const BoxConstraints( + minWidth: 44, + minHeight: 44, ), - ], - ), + onPressed: widget.onMenu, + tooltip: 'More actions', + ), + ], ), ), ), diff --git a/test/widgets/accessibility_test.dart b/test/widgets/accessibility_test.dart new file mode 100644 index 0000000..5c0a7b3 --- /dev/null +++ b/test/widgets/accessibility_test.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart'; +import 'package:nullfeed/config/theme.dart'; +import 'package:nullfeed/models/channel.dart'; +import 'package:nullfeed/models/video.dart'; +import 'package:nullfeed/providers/offline_provider.dart'; +import 'package:nullfeed/widgets/channel_card.dart'; +import 'package:nullfeed/widgets/progress_bar.dart'; +import 'package:nullfeed/widgets/video_card.dart'; +import 'package:nullfeed/widgets/video_list_tile.dart'; + +Video makeVideo({ + String id = 'v1', + String title = 'Big Buck Bunny', + VideoStatus status = VideoStatus.complete, + bool isWatched = false, + DateTime? uploadedAt, +}) { + // youtubeVideoId left blank so the cards render their offline placeholder + // instead of reaching out for a network thumbnail during the test. + return Video( + id: id, + youtubeVideoId: '', + channelId: 'c1', + title: title, + status: status, + isWatched: isWatched, + uploadedAt: uploadedAt, + ); +} + +Channel makeChannel({String id = 'c1', String name = 'Blender'}) { + return Channel(id: id, youtubeChannelId: 'UC1', name: name, slug: 'blender'); +} + +/// Pumps [child] with the offline status for [videoId] overridden so no Hive +/// setup is needed. +Future pumpWidget( + WidgetTester tester, + Widget child, { + String? offlineStatus, + String videoId = 'v1', +}) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + offlineStatusProvider(videoId).overrideWithValue(offlineStatus), + ], + child: MaterialApp( + theme: NullFeedTheme.darkTheme, + home: Scaffold(body: child), + ), + ), + ); + await tester.pump(); +} + +void main() { + testWidgets( + 'seek bar exposes a slider with label, value and adjust actions', + (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: NullFeedProgressBar( + progress: 0.5, + semanticLabel: 'Video position', + onSeek: (_) {}, + ), + ), + ), + ), + ); + + final finder = find.bySemanticsLabel('Video position'); + expect(finder, findsOneWidget); + + final node = tester.getSemantics(finder); + final data = node.getSemanticsData(); + // Operable as a slider: role, value, and adjust actions (which are only + // wired when onIncrease/onDecrease are provided). + expect(data.flagsCollection.isSlider, isTrue); + expect(data.hasAction(SemanticsAction.increase), isTrue); + expect(data.hasAction(SemanticsAction.decrease), isTrue); + expect(node.value, '50%'); + // +/-5% steps are reflected in the announced increase/decrease values. + expect(node.increasedValue, '55%'); + expect(node.decreasedValue, '45%'); + + handle.dispose(); + }, + ); + + testWidgets('video card merges into one labelled play button', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + + await pumpWidget( + tester, + VideoCard( + video: makeVideo(title: 'Big Buck Bunny'), + channel: makeChannel(name: 'Blender'), + ), + ); + + // Title + channel collapse into a single play node... + expect(find.bySemanticsLabel('Big Buck Bunny, Blender'), findsOneWidget); + // ...with the channel link as its own separate node. + expect(find.bySemanticsLabel('Go to Blender'), findsOneWidget); + + handle.dispose(); + }); + + testWidgets('video card play label includes the downloaded state', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + + await pumpWidget( + tester, + VideoCard( + video: makeVideo(title: 'Big Buck Bunny'), + channel: makeChannel(name: 'Blender'), + ), + offlineStatus: 'complete', + ); + + expect( + find.bySemanticsLabel('Big Buck Bunny, Blender, downloaded'), + findsOneWidget, + ); + + handle.dispose(); + }); + + testWidgets('channel card exposes a label and a separate actions button', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + + await pumpWidget( + tester, + ChannelCard( + channel: makeChannel(name: 'Blender'), + onTap: () {}, + onMenu: () {}, + ), + ); + + expect(find.bySemanticsLabel('Blender channel'), findsOneWidget); + // The ⋯ button keeps its own operable node (tooltip-based label). + expect(find.byTooltip('Channel actions'), findsOneWidget); + + handle.dispose(); + }); + + testWidgets('list tile merges title, date and download state', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + final uploaded = DateTime.utc(2026, 1, 2); + + await pumpWidget( + tester, + VideoListTile( + video: makeVideo( + title: 'Big Buck Bunny', + status: VideoStatus.complete, + uploadedAt: uploaded, + ), + onTap: () {}, + ), + offlineStatus: 'complete', + ); + + final date = DateFormat.yMMMd().format(uploaded); + expect( + find.bySemanticsLabel('Big Buck Bunny, $date, downloaded'), + findsOneWidget, + ); + + handle.dispose(); + }); + + testWidgets('list tile reflects a downloading state and an operable cancel', ( + tester, + ) async { + final handle = tester.ensureSemantics(); + + await pumpWidget( + tester, + VideoListTile( + video: makeVideo(title: 'Clip', status: VideoStatus.downloading), + onTap: () {}, + onCancel: () {}, + ), + ); + + expect(find.bySemanticsLabel('Clip, downloading'), findsOneWidget); + expect(find.byTooltip('Cancel download'), findsOneWidget); + + handle.dispose(); + }); +}