diff --git a/CHANGELOG.md b/CHANGELOG.md index 49976882..ec8b7254 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,43 @@ ### Fixed - **Deezer Downloads Timing Out**: Deezer downloads were failing with "context deadline exceeded" on larger files. Now uses the proper download timeout, matching Tidal and Qobuz. +- **iOS Local Library Scan Fails**: Local library scanning was failing on iOS because the app lost access to user-picked folders after the FilePicker session ended. Implemented iOS security-scoped bookmark system: + - When a library folder is picked on iOS, a security-scoped bookmark is created and persisted in settings (`localLibraryBookmark`) + - Before each scan, the bookmark is resolved and security-scoped access is started; access is released in `finally` block after scan completes + - `cleanupMissingFiles` also activates the bookmark before checking file existence on iOS + - New `AppDelegate.swift` method channel handlers: `createIosBookmarkFromPath`, `startAccessingIosBookmark`, `stopAccessingIosBookmark`, `resolveIosBookmark` + - New `PlatformBridge` methods: `createIosBookmarkFromPath()`, `startAccessingIosBookmark()`, `stopAccessingIosBookmark()` + - All scan call-sites (Library Settings, Queue tab, Local Album screen) now pass the iOS bookmark to `startScan()` ### Added - **Amazon Music Extension**: Available in `extension/Amazon-SpotiFLAC/` — same functionality as before, now as an installable extension. +- **Accessibility Tooltips**: Added localized tooltips to all `IconButton` and `PopupMenuButton` widgets across the entire UI for screen reader and long-press discoverability + - Back buttons use `MaterialLocalizations.backButtonTooltip` + - Close buttons use `MaterialLocalizations.closeButtonTooltip` + - Menu buttons use `MaterialLocalizations.showMenuTooltip` + - Search buttons use `MaterialLocalizations.searchFieldLabel` + - Contextual actions use descriptive labels: "Play track", "Dismiss", "Clear search", "Change folder", "Refresh" + - Screens affected: Album, Artist, Playlist, Downloaded Album, Local Album, Home, Search, Queue, Library Playlists, Library Tracks Folder, Setup, Tutorial, Track Metadata, Store, Extension Store Details, and all Settings sub-pages (About, Appearance, Cache Management, Donate, Download, Extensions, Extension Detail, Library, Log, Options, Provider Priority) +- **Semantics Wrappers**: Added `Semantics` widgets to interactive elements that previously had no accessibility information + - Album tiles in Artist screen: announces selection state and album name + - Recently downloaded track tiles in Home tab: announces track name and artist + - Explore items (albums/artists/playlists) in Home tab: announces item type and name + - Color palette picker in Appearance settings: announces selected state and color hex value + - Download button demo in Tutorial screen: added `ExcludeSemantics` on icon to prevent duplicate screen reader announcements + - Queue tab playlist cards: announces playlist name and item count + - Queue tab downloaded album cards: announces album name, artist, and track count + - Queue tab local album cards: announces album name, artist, and track count + - Queue tab play button on completed downloads: announces track name and artist with `ExcludeSemantics` on icon + - Queue tab download status indicators: "Finalizing download", "Download completed", "Downloaded file missing" labels with `ExcludeSemantics` on icons + +### Improved + +- **Code Formatting**: Reformatted and corrected indentation across multiple files to comply with Dart style guidelines + - `extension_detail_page.dart`: Fixed `SliverAppBar` and all subsequent slivers indentation (was 2 spaces short) + - `log_screen.dart`: Fixed `SliverAppBar` indentation alignment + - `donate_page.dart`: Reformatted ternary expressions and `_cr` function body + - `library_tracks_folder_screen.dart`: Minor line-break formatting --- diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index f226a80f..ba8d9d1d 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -488,6 +488,7 @@ class _AlbumScreenState extends ConsumerState { }, ), leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index cdf58522..a1c37333 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -1184,6 +1184,7 @@ class _ArtistScreenState extends ConsumerState { ], ), leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( @@ -1571,44 +1572,61 @@ class _ArtistScreenState extends ConsumerState { }) { final isSelected = _selectedAlbumIds.contains(album.id); - return GestureDetector( - onTap: () { - if (_isSelectionMode) { - _toggleAlbumSelection(album.id); - } else { - _navigateToAlbum(album); - } - }, - onLongPress: () { - if (!_isSelectionMode) { - _enterSelectionMode(album.id); - } - }, - child: Container( - width: tileSize, - height: sectionHeight, - margin: const EdgeInsets.symmetric(horizontal: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: album.coverUrl != null - ? CachedNetworkImage( - imageUrl: album.coverUrl!, - width: tileSize, - height: tileSize, - fit: BoxFit.cover, - memCacheWidth: (tileSize * 2).round(), - cacheManager: CoverCacheManager.instance, - placeholder: (context, url) => Container( + return Semantics( + button: true, + selected: _isSelectionMode && isSelected, + label: _isSelectionMode + ? 'Select album ${album.name}' + : 'Open album ${album.name}', + child: GestureDetector( + onTap: () { + if (_isSelectionMode) { + _toggleAlbumSelection(album.id); + } else { + _navigateToAlbum(album); + } + }, + onLongPress: () { + if (!_isSelectionMode) { + _enterSelectionMode(album.id); + } + }, + child: Container( + width: tileSize, + height: sectionHeight, + margin: const EdgeInsets.symmetric(horizontal: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: album.coverUrl != null + ? CachedNetworkImage( + imageUrl: album.coverUrl!, width: tileSize, height: tileSize, - color: colorScheme.surfaceContainerHighest, - ), - errorWidget: (context, url, error) => Container( + fit: BoxFit.cover, + memCacheWidth: (tileSize * 2).round(), + cacheManager: CoverCacheManager.instance, + placeholder: (context, url) => Container( + width: tileSize, + height: tileSize, + color: colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + width: tileSize, + height: tileSize, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.album, + color: colorScheme.onSurfaceVariant, + size: 40, + ), + ), + ) + : Container( width: tileSize, height: tileSize, color: colorScheme.surfaceContainerHighest, @@ -1618,97 +1636,87 @@ class _ArtistScreenState extends ConsumerState { size: 40, ), ), - ) - : Container( - width: tileSize, - height: tileSize, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.album, - color: colorScheme.onSurfaceVariant, - size: 40, + ), + if (_isSelectionMode) + Positioned.fill( + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.3) + : Colors.black.withValues(alpha: 0.1), + border: isSelected + ? Border.all(color: colorScheme.primary, width: 3) + : null, + ), + ), + ), + if (_isSelectionMode) + Positioned( + top: 8, + right: 8, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 28, + height: 28, + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary + : colorScheme.surface.withValues(alpha: 0.9), + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, ), ), - ), - if (_isSelectionMode) - Positioned.fill( - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: isSelected - ? colorScheme.primary.withValues(alpha: 0.3) - : Colors.black.withValues(alpha: 0.1), - border: isSelected - ? Border.all(color: colorScheme.primary, width: 3) + child: isSelected + ? Icon( + Icons.check, + color: colorScheme.onPrimary, + size: 18, + ) : null, ), ), - ), - if (_isSelectionMode) - Positioned( - top: 8, - right: 8, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: 28, - height: 28, - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : colorScheme.surface.withValues(alpha: 0.9), - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), - ), - child: isSelected - ? Icon( - Icons.check, - color: colorScheme.onPrimary, - size: 18, - ) - : null, - ), - ), - ], - ), - const SizedBox(height: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - album.name, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w500, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(height: 2), - Text( - album.totalTracks > 0 - ? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}' - : album.releaseDate.length >= 4 - ? album.releaseDate.substring(0, 4) - : album.releaseDate, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), ], ), - ), - ], + const SizedBox(height: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + album.name, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(height: 2), + Text( + album.totalTracks > 0 + ? '${album.releaseDate.length >= 4 ? album.releaseDate.substring(0, 4) : album.releaseDate} ${context.l10n.tracksCount(album.totalTracks)}' + : album.releaseDate.length >= 4 + ? album.releaseDate.substring(0, 4) + : album.releaseDate, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), ), ), ); diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 425dfd4b..468d7aff 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -630,6 +630,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { }, ), leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( @@ -851,6 +852,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { trailing: _isSelectionMode ? null : IconButton( + tooltip: 'Play track', onPressed: () => _openFile(track), icon: Icon(Icons.play_arrow, color: colorScheme.primary), style: IconButton.styleFrom( @@ -1326,6 +1328,9 @@ class _DownloadedAlbumScreenState extends ConsumerState { children: [ IconButton.filledTonal( onPressed: _exitSelectionMode, + tooltip: MaterialLocalizations.of( + context, + ).closeButtonTooltip, icon: const Icon(Icons.close), style: IconButton.styleFrom( backgroundColor: colorScheme.surfaceContainerHighest, diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index b20d7b90..f1c3f807 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -1332,24 +1332,49 @@ class _HomeTabState extends ConsumerState ); return KeyedSubtree( key: ValueKey(item.id), - child: GestureDetector( - onTap: () => _navigateToMetadataScreen(item), - child: Container( - width: coverSize, - margin: const EdgeInsets.only(right: 12), - child: Column( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: embeddedCoverPath != null - ? Image.file( - File(embeddedCoverPath), - width: coverSize, - height: coverSize, - fit: BoxFit.cover, - cacheWidth: (coverSize * 2).round(), - cacheHeight: (coverSize * 2).round(), - errorBuilder: (_, _, _) => Container( + child: Semantics( + button: true, + label: 'Open track ${item.trackName} by ${item.artistName}', + child: GestureDetector( + onTap: () => _navigateToMetadataScreen(item), + child: Container( + width: coverSize, + margin: const EdgeInsets.only(right: 12), + child: Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: embeddedCoverPath != null + ? Image.file( + File(embeddedCoverPath), + width: coverSize, + height: coverSize, + fit: BoxFit.cover, + cacheWidth: (coverSize * 2).round(), + cacheHeight: (coverSize * 2).round(), + errorBuilder: (_, _, _) => Container( + width: coverSize, + height: coverSize, + color: + colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + size: 32, + ), + ), + ) + : item.coverUrl != null + ? CachedNetworkImage( + imageUrl: item.coverUrl!, + width: coverSize, + height: coverSize, + fit: BoxFit.cover, + memCacheWidth: (coverSize * 2).round(), + memCacheHeight: (coverSize * 2).round(), + cacheManager: CoverCacheManager.instance, + ) + : Container( width: coverSize, height: coverSize, color: colorScheme.surfaceContainerHighest, @@ -1359,38 +1384,18 @@ class _HomeTabState extends ConsumerState size: 32, ), ), - ) - : item.coverUrl != null - ? CachedNetworkImage( - imageUrl: item.coverUrl!, - width: coverSize, - height: coverSize, - fit: BoxFit.cover, - memCacheWidth: (coverSize * 2).round(), - memCacheHeight: (coverSize * 2).round(), - cacheManager: CoverCacheManager.instance, - ) - : Container( - width: coverSize, - height: coverSize, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, - size: 32, - ), - ), - ), - const SizedBox(height: 6), - Text( - item.trackName, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: colorScheme.onSurfaceVariant), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - ), - ], + ), + const SizedBox(height: 6), + Text( + item.trackName, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ], + ), ), ), ), @@ -1494,31 +1499,45 @@ class _HomeTabState extends ConsumerState final cardSize = _exploreCardSize(context); final iconSize = cardSize * 0.3; - return GestureDetector( - onTap: () => _navigateToExploreItem(item), - child: SizedBox( - width: cardSize, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: Column( - crossAxisAlignment: isArtist - ? CrossAxisAlignment.center - : CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular( - isArtist ? cardSize / 2 : 8, - ), - child: item.coverUrl != null && item.coverUrl!.isNotEmpty - ? CachedNetworkImage( - imageUrl: item.coverUrl!, - width: cardSize, - height: cardSize, - fit: BoxFit.cover, - memCacheWidth: (cardSize * 2).round(), - memCacheHeight: (cardSize * 2).round(), - cacheManager: CoverCacheManager.instance, - errorWidget: (context, url, error) => Container( + return Semantics( + button: true, + label: 'Open ${item.type} ${item.name}', + child: GestureDetector( + onTap: () => _navigateToExploreItem(item), + child: SizedBox( + width: cardSize, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Column( + crossAxisAlignment: isArtist + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular( + isArtist ? cardSize / 2 : 8, + ), + child: item.coverUrl != null && item.coverUrl!.isNotEmpty + ? CachedNetworkImage( + imageUrl: item.coverUrl!, + width: cardSize, + height: cardSize, + fit: BoxFit.cover, + memCacheWidth: (cardSize * 2).round(), + memCacheHeight: (cardSize * 2).round(), + cacheManager: CoverCacheManager.instance, + errorWidget: (context, url, error) => Container( + width: cardSize, + height: cardSize, + color: colorScheme.surfaceContainerHighest, + child: Icon( + _getIconForType(item.type), + color: colorScheme.onSurfaceVariant, + size: iconSize, + ), + ), + ) + : Container( width: cardSize, height: cardSize, color: colorScheme.surfaceContainerHighest, @@ -1528,42 +1547,32 @@ class _HomeTabState extends ConsumerState size: iconSize, ), ), - ) - : Container( - width: cardSize, - height: cardSize, - color: colorScheme.surfaceContainerHighest, - child: Icon( - _getIconForType(item.type), - color: colorScheme.onSurfaceVariant, - size: iconSize, - ), - ), - ), - const SizedBox(height: 8), - Text( - item.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: isArtist ? TextAlign.center : TextAlign.start, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w500, - color: colorScheme.onSurface, ), - ), - if (item.artists.isNotEmpty && !isArtist) - ClickableArtistName( - artistName: item.artists, - coverUrl: item.coverUrl, - extensionId: item.providerId, + const SizedBox(height: 8), + Text( + item.name, maxLines: 1, overflow: TextOverflow.ellipsis, + textAlign: isArtist ? TextAlign.center : TextAlign.start, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontSize: 11, + fontWeight: FontWeight.w500, + color: colorScheme.onSurface, ), ), - ], + if (item.artists.isNotEmpty && !isArtist) + ClickableArtistName( + artistName: item.artists, + coverUrl: item.coverUrl, + extensionId: item.providerId, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontSize: 11, + ), + ), + ], + ), ), ), ), @@ -2018,6 +2027,7 @@ class _HomeTabState extends ConsumerState ), ), IconButton( + tooltip: 'Dismiss', icon: Icon( Icons.close, size: 20, @@ -4379,6 +4389,7 @@ class _QuickPicksPageViewState extends State<_QuickPicksPageView> { ), ), IconButton( + tooltip: MaterialLocalizations.of(context).showMenuTooltip, icon: Icon( Icons.more_vert, color: widget.colorScheme.onSurfaceVariant, diff --git a/lib/screens/library_playlists_screen.dart b/lib/screens/library_playlists_screen.dart index df68b16f..503937f6 100644 --- a/lib/screens/library_playlists_screen.dart +++ b/lib/screens/library_playlists_screen.dart @@ -32,6 +32,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget { backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index ab437ec6..d6d1fc5e 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -435,6 +435,9 @@ class _LibraryTracksFolderScreenState children: [ IconButton.filledTonal( onPressed: _exitSelectionMode, + tooltip: MaterialLocalizations.of( + context, + ).closeButtonTooltip, icon: const Icon(Icons.close), style: IconButton.styleFrom( backgroundColor: colorScheme.surfaceContainerHighest, @@ -800,6 +803,9 @@ class _LibraryTracksFolderScreenState }, ), leading: IconButton( + tooltip: _isSelectionMode + ? MaterialLocalizations.of(context).closeButtonTooltip + : MaterialLocalizations.of(context).backButtonTooltip, icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( @@ -818,7 +824,8 @@ class _LibraryTracksFolderScreenState ); } - Widget _buildHeaderActionPlaceholder() => const SizedBox(width: 48, height: 48); + Widget _buildHeaderActionPlaceholder() => + const SizedBox(width: 48, height: 48); Widget _buildDownloadAllCenterButton(List entries) { final tracks = entries.map((e) => e.track).toList(growable: false); @@ -1139,6 +1146,7 @@ class _CollectionTrackTile extends ConsumerWidget { trailing: isSelectionMode ? null : IconButton( + tooltip: MaterialLocalizations.of(context).showMenuTooltip, icon: Icon( Icons.more_vert, color: colorScheme.onSurfaceVariant, diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index dc0f12ad..7ffbd18e 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -321,6 +321,7 @@ class _PlaylistScreenState extends ConsumerState { }, ), leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index a348ecac..4523fbee 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -84,7 +84,11 @@ class _SearchScreenState extends ConsumerState { autofocus: widget.query.isEmpty, ), actions: [ - IconButton(icon: const Icon(Icons.search), onPressed: _search), + IconButton( + tooltip: MaterialLocalizations.of(context).searchFieldLabel, + icon: const Icon(Icons.search), + onPressed: _search, + ), ], ), body: Column( diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index 8ef0268c..81fd1aa5 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -28,6 +28,7 @@ class AboutPage extends StatelessWidget { backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index f5a4560b..4884328a 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -30,6 +30,7 @@ class AppearanceSettingsPage extends ConsumerWidget { backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), @@ -347,11 +348,21 @@ class _ColorPalettePicker extends StatelessWidget { child: Row( children: _colors.map((color) { final isSelected = color.toARGB32() == currentColor; + final colorHex = color + .toARGB32() + .toRadixString(16) + .padLeft(8, '0') + .toUpperCase(); return Padding( padding: const EdgeInsets.only(right: 12), - child: GestureDetector( - onTap: () => onColorSelected(color), - child: _ColorPaletteItem(color: color, isSelected: isSelected), + child: Semantics( + button: true, + selected: isSelected, + label: 'Select accent color $colorHex', + child: GestureDetector( + onTap: () => onColorSelected(color), + child: _ColorPaletteItem(color: color, isSelected: isSelected), + ), ), ); }).toList(), diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index 80466739..175b0793 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -24,6 +24,7 @@ class DonatePage extends StatelessWidget { backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), @@ -215,13 +216,17 @@ class _RecentDonorsCard extends StatelessWidget { Icon( Icons.emoji_events_outlined, size: 32, - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.4, + ), ), const SizedBox(height: 8), Text( 'No supporters yet — be the first!', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.6, + ), ), ), ], @@ -468,9 +473,12 @@ class _CryptoWalletItem extends StatelessWidget { int _cr(String v) { int r = 0x1F; - for (final c in v.codeUnits) { r = (r * 31 + c) & 0x7FFFFFFF; } + for (final c in v.codeUnits) { + r = (r * 31 + c) & 0x7FFFFFFF; + } return r; } + // Highlighted supporters (hashes of names): none for now. const _cv = {}; @@ -487,16 +495,10 @@ class _SupporterChip extends StatelessWidget { const goldAccentColor = Color(0xFFB8860B); const goldDarkChipColor = Color(0xFF3A3000); - final chipColor = e - ? goldChipColor - : colorScheme.secondaryContainer; - final accentColor = e - ? goldAccentColor - : colorScheme.primary; + final chipColor = e ? goldChipColor : colorScheme.secondaryContainer; + final accentColor = e ? goldAccentColor : colorScheme.primary; final isDark = Theme.of(context).brightness == Brightness.dark; - final effectiveChipColor = e && isDark - ? goldDarkChipColor - : chipColor; + final effectiveChipColor = e && isDark ? goldDarkChipColor : chipColor; return Material( color: effectiveChipColor, @@ -533,9 +535,7 @@ class _SupporterChip extends StatelessWidget { Text( name, style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: e - ? accentColor - : colorScheme.onSecondaryContainer, + color: e ? accentColor : colorScheme.onSecondaryContainer, fontWeight: e ? FontWeight.w600 : FontWeight.w500, ), ), diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index fbf92f63..1987bb2d 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -315,6 +315,7 @@ class _DownloadSettingsPageState extends ConsumerState { backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index 72a50a8e..0a702946 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -15,7 +15,8 @@ class ExtensionDetailPage extends ConsumerStatefulWidget { const ExtensionDetailPage({super.key, required this.extensionId}); @override - ConsumerState createState() => _ExtensionDetailPageState(); + ConsumerState createState() => + _ExtensionDetailPageState(); } class _ExtensionDetailPageState extends ConsumerState { @@ -65,320 +66,373 @@ class _ExtensionDetailPageState extends ConsumerState { body: CustomScrollView( slivers: [ SliverAppBar( - expandedHeight: 120 + topPadding, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.pop(context), - ), - flexibleSpace: LayoutBuilder( - builder: (context, constraints) { - final maxHeight = 120 + topPadding; - final minHeight = kToolbarHeight + topPadding; - final expandRatio = ((constraints.maxHeight - minHeight) / - (maxHeight - minHeight)) - .clamp(0.0, 1.0); - final leftPadding = 56 - (32 * expandRatio); - return FlexibleSpaceBar( - expandedTitleScale: 1.0, - titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), - title: Text( - extension.displayName, - style: TextStyle( - fontSize: 20 + (8 * expandRatio), - fontWeight: FontWeight.bold, - color: colorScheme.onSurface, + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), + ), + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final maxHeight = 120 + topPadding; + final minHeight = kToolbarHeight + topPadding; + final expandRatio = + ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); + final leftPadding = 56 - (32 * expandRatio); + return FlexibleSpaceBar( + expandedTitleScale: 1.0, + titlePadding: EdgeInsets.only( + left: leftPadding, + bottom: 16, ), - ), - ); - }, + title: Text( + extension.displayName, + style: TextStyle( + fontSize: 20 + (8 * expandRatio), + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + ); + }, + ), ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(20), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: hasError - ? colorScheme.errorContainer - : colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(16), - ), - child: extension.iconPath != null && extension.iconPath!.isNotEmpty - ? ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Image.file( - File(extension.iconPath!), - width: 56, - height: 56, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => Icon( - hasError ? Icons.error_outline : Icons.extension, - size: 28, - color: hasError - ? colorScheme.error - : colorScheme.onPrimaryContainer, + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues( + alpha: 0.3, + ), + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: hasError + ? colorScheme.errorContainer + : colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: + extension.iconPath != null && + extension.iconPath!.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Image.file( + File(extension.iconPath!), + width: 56, + height: 56, + fit: BoxFit.cover, + errorBuilder: + (context, error, stackTrace) => Icon( + hasError + ? Icons.error_outline + : Icons.extension, + size: 28, + color: hasError + ? colorScheme.error + : colorScheme + .onPrimaryContainer, + ), ), + ) + : Icon( + hasError + ? Icons.error_outline + : Icons.extension, + size: 28, + color: hasError + ? colorScheme.error + : colorScheme.onPrimaryContainer, ), - ) - : Icon( - hasError ? Icons.error_outline : Icons.extension, - size: 28, - color: hasError - ? colorScheme.error - : colorScheme.onPrimaryContainer, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - extension.displayName, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - Text( - 'v${extension.version}', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], ), - ), - Switch( - value: extension.enabled, - onChanged: hasError - ? null - : (enabled) => ref - .read(extensionProvider.notifier) - .setExtensionEnabled(widget.extensionId, enabled), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + extension.displayName, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(fontWeight: FontWeight.bold), + ), + Text( + 'v${extension.version}', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Switch( + value: extension.enabled, + onChanged: hasError + ? null + : (enabled) => ref + .read(extensionProvider.notifier) + .setExtensionEnabled( + widget.extensionId, + enabled, + ), + ), + ], + ), + if (extension.description.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + extension.description, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), ), ], - ), - if (extension.description.isNotEmpty) ...[ const SizedBox(height: 16), - Text( - extension.description, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - const SizedBox(height: 16), - _InfoRow(label: context.l10n.extensionAuthor, value: extension.author), - _InfoRow(label: context.l10n.extensionId, value: extension.id), - _InfoRow(label: context.l10n.extensionsVersion(extension.version), value: ''), - if (hasError && extension.errorMessage != null) _InfoRow( - label: context.l10n.extensionError, - value: extension.errorMessage!, - isError: true, + label: context.l10n.extensionAuthor, + value: extension.author, ), - ], + _InfoRow( + label: context.l10n.extensionId, + value: extension.id, + ), + _InfoRow( + label: context.l10n.extensionsVersion( + extension.version, + ), + value: '', + ), + if (hasError && extension.errorMessage != null) + _InfoRow( + label: context.l10n.extensionError, + value: extension.errorMessage!, + isError: true, + ), + ], + ), ), ), ), - ), - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.extensionCapabilities), - ), - SliverToBoxAdapter( - child: SettingsGroup( - children: [ - _CapabilityItem( - icon: Icons.search, - title: context.l10n.extensionMetadataProvider, - enabled: extension.hasMetadataProvider, - ), - _CapabilityItem( - icon: Icons.download, - title: context.l10n.extensionDownloadProvider, - enabled: extension.hasDownloadProvider, - ), - _CapabilityItem( - icon: Icons.lyrics, - title: context.l10n.extensionLyricsProvider, - enabled: extension.hasLyricsProvider, - ), - _CapabilityItem( - icon: Icons.manage_search, - title: context.l10n.extensionsSearchProvider, - enabled: extension.hasCustomSearch, - subtitle: extension.searchBehavior?.placeholder, - ), - _CapabilityItem( - icon: Icons.compare_arrows, - title: context.l10n.extensionCustomTrackMatching, - enabled: extension.hasCustomMatching, - subtitle: extension.trackMatching?.strategy != null - ? context.l10n.extensionStrategy(extension.trackMatching!.strategy!) - : null, - ), - _CapabilityItem( - icon: Icons.auto_fix_high, - title: context.l10n.extensionPostProcessing, - enabled: extension.hasPostProcessing, - subtitle: extension.postProcessing?.hooks.isNotEmpty == true - ? context.l10n.extensionHooksAvailable(extension.postProcessing!.hooks.length) - : null, - ), - _CapabilityItem( - icon: Icons.link, - title: context.l10n.extensionUrlHandler, - enabled: extension.hasURLHandler, - subtitle: extension.urlHandler?.patterns.isNotEmpty == true - ? context.l10n.extensionPatternsCount(extension.urlHandler!.patterns.length) - : null, - showDivider: false, - ), - ], - ), - ), - - if (extension.hasURLHandler && extension.urlHandler!.patterns.isNotEmpty) ...[ SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.extensionUrlHandler), + child: SettingsSectionHeader( + title: context.l10n.extensionCapabilities, + ), ), SliverToBoxAdapter( child: SettingsGroup( children: [ - _URLHandlerInfo( - patterns: extension.urlHandler!.patterns, + _CapabilityItem( + icon: Icons.search, + title: context.l10n.extensionMetadataProvider, + enabled: extension.hasMetadataProvider, + ), + _CapabilityItem( + icon: Icons.download, + title: context.l10n.extensionDownloadProvider, + enabled: extension.hasDownloadProvider, + ), + _CapabilityItem( + icon: Icons.lyrics, + title: context.l10n.extensionLyricsProvider, + enabled: extension.hasLyricsProvider, + ), + _CapabilityItem( + icon: Icons.manage_search, + title: context.l10n.extensionsSearchProvider, + enabled: extension.hasCustomSearch, + subtitle: extension.searchBehavior?.placeholder, + ), + _CapabilityItem( + icon: Icons.compare_arrows, + title: context.l10n.extensionCustomTrackMatching, + enabled: extension.hasCustomMatching, + subtitle: extension.trackMatching?.strategy != null + ? context.l10n.extensionStrategy( + extension.trackMatching!.strategy!, + ) + : null, + ), + _CapabilityItem( + icon: Icons.auto_fix_high, + title: context.l10n.extensionPostProcessing, + enabled: extension.hasPostProcessing, + subtitle: extension.postProcessing?.hooks.isNotEmpty == true + ? context.l10n.extensionHooksAvailable( + extension.postProcessing!.hooks.length, + ) + : null, + ), + _CapabilityItem( + icon: Icons.link, + title: context.l10n.extensionUrlHandler, + enabled: extension.hasURLHandler, + subtitle: extension.urlHandler?.patterns.isNotEmpty == true + ? context.l10n.extensionPatternsCount( + extension.urlHandler!.patterns.length, + ) + : null, + showDivider: false, ), ], ), ), - ], - if (extension.hasDownloadProvider && extension.qualityOptions.isNotEmpty) ...[ - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.extensionQualityOptions), - ), - SliverToBoxAdapter( - child: SettingsGroup( - children: extension.qualityOptions.asMap().entries.map((entry) { - final index = entry.key; - final quality = entry.value; - return _QualityOptionItem( - quality: quality, - showDivider: index < extension.qualityOptions.length - 1, - ); - }).toList(), - ), - ), - ], - - if (extension.hasPostProcessing && extension.postProcessing!.hooks.isNotEmpty) ...[ - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.extensionPostProcessingHooks), - ), - SliverToBoxAdapter( - child: SettingsGroup( - children: extension.postProcessing!.hooks.asMap().entries.map((entry) { - final index = entry.key; - final hook = entry.value; - return _PostProcessingHookItem( - hook: hook, - showDivider: index < extension.postProcessing!.hooks.length - 1, - ); - }).toList(), - ), - ), - ], - - if (extension.permissions.isNotEmpty) ...[ - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.extensionPermissions), - ), - SliverToBoxAdapter( - child: SettingsGroup( - children: extension.permissions.asMap().entries.map((entry) { - final index = entry.key; - final permission = entry.value; - return _PermissionItem( - permission: permission, - showDivider: index < extension.permissions.length - 1, - ); - }).toList(), - ), - ), - ], - - if (extension.settings.isNotEmpty) ...[ - SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.extensionSettings), - ), - if (_isLoadingSettings) - const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator()), + if (extension.hasURLHandler && + extension.urlHandler!.patterns.isNotEmpty) ...[ + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.extensionUrlHandler, ), - ) - else + ), SliverToBoxAdapter( child: SettingsGroup( - children: extension.settings.asMap().entries.map((entry) { + children: [ + _URLHandlerInfo(patterns: extension.urlHandler!.patterns), + ], + ), + ), + ], + + if (extension.hasDownloadProvider && + extension.qualityOptions.isNotEmpty) ...[ + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.extensionQualityOptions, + ), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: extension.qualityOptions.asMap().entries.map(( + entry, + ) { final index = entry.key; - final setting = entry.value; - return _SettingItem( - setting: setting, - value: _settings[setting.key] ?? setting.defaultValue, - showDivider: index < extension.settings.length - 1, - onChanged: (value) => _updateSetting(setting.key, value), - extensionId: widget.extensionId, + final quality = entry.value; + return _QualityOptionItem( + quality: quality, + showDivider: index < extension.qualityOptions.length - 1, ); }).toList(), ), ), - ], + ], - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: OutlinedButton.icon( - onPressed: () => _confirmRemove(context), - icon: const Icon(Icons.delete_outline), - label: Text(context.l10n.extensionRemoveButton), - style: OutlinedButton.styleFrom( - foregroundColor: colorScheme.error, - side: BorderSide(color: colorScheme.error), - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + if (extension.hasPostProcessing && + extension.postProcessing!.hooks.isNotEmpty) ...[ + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.extensionPostProcessingHooks, + ), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: extension.postProcessing!.hooks.asMap().entries.map( + (entry) { + final index = entry.key; + final hook = entry.value; + return _PostProcessingHookItem( + hook: hook, + showDivider: + index < extension.postProcessing!.hooks.length - 1, + ); + }, + ).toList(), + ), + ), + ], + + if (extension.permissions.isNotEmpty) ...[ + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.extensionPermissions, + ), + ), + SliverToBoxAdapter( + child: SettingsGroup( + children: extension.permissions.asMap().entries.map((entry) { + final index = entry.key; + final permission = entry.value; + return _PermissionItem( + permission: permission, + showDivider: index < extension.permissions.length - 1, + ); + }).toList(), + ), + ), + ], + + if (extension.settings.isNotEmpty) ...[ + SliverToBoxAdapter( + child: SettingsSectionHeader( + title: context.l10n.extensionSettings, + ), + ), + if (_isLoadingSettings) + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ), + ) + else + SliverToBoxAdapter( + child: SettingsGroup( + children: extension.settings.asMap().entries.map((entry) { + final index = entry.key; + final setting = entry.value; + return _SettingItem( + setting: setting, + value: _settings[setting.key] ?? setting.defaultValue, + showDivider: index < extension.settings.length - 1, + onChanged: (value) => + _updateSetting(setting.key, value), + extensionId: widget.extensionId, + ); + }).toList(), + ), + ), + ], + + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: OutlinedButton.icon( + onPressed: () => _confirmRemove(context), + icon: const Icon(Icons.delete_outline), + label: Text(context.l10n.extensionRemoveButton), + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.error, + side: BorderSide(color: colorScheme.error), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), ), ), ), ), - ), - const SliverToBoxAdapter(child: SizedBox(height: 32)), - ], + const SliverToBoxAdapter(child: SizedBox(height: 32)), + ], + ), ), - ), ); } @@ -397,9 +451,7 @@ class _ExtensionDetailPageState extends ConsumerState { context: context, builder: (context) => AlertDialog( title: Text(context.l10n.dialogRemoveExtension), - content: Text( - context.l10n.dialogRemoveExtensionMessage, - ), + content: Text(context.l10n.dialogRemoveExtensionMessage), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), @@ -407,9 +459,7 @@ class _ExtensionDetailPageState extends ConsumerState { ), FilledButton( onPressed: () => Navigator.pop(context, true), - style: FilledButton.styleFrom( - backgroundColor: colorScheme.error, - ), + style: FilledButton.styleFrom(backgroundColor: colorScheme.error), child: Text(context.l10n.dialogRemove), ), ], @@ -504,10 +554,7 @@ class _CapabilityItem extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - style: Theme.of(context).textTheme.bodyLarge, - ), + Text(title, style: Theme.of(context).textTheme.bodyLarge), if (subtitle != null && enabled) ...[ const SizedBox(height: 2), Text( @@ -544,18 +591,15 @@ class _PermissionItem extends StatelessWidget { final String permission; final bool showDivider; - const _PermissionItem({ - required this.permission, - this.showDivider = true, - }); + const _PermissionItem({required this.permission, this.showDivider = true}); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - + IconData icon = Icons.security; String description = permission; - + if (permission.startsWith('network:')) { icon = Icons.language; description = 'Network access to: ${permission.substring(8)}'; @@ -673,9 +717,8 @@ class _SettingItemState extends State<_SettingItem> { if (widget.setting.description != null) ...[ Text( widget.setting.description!, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), ), const SizedBox(height: 12), ], @@ -702,7 +745,8 @@ class _SettingItemState extends State<_SettingItem> { mainAxisSize: MainAxisSize.min, children: [ InkWell( - onTap: widget.setting.type == 'string' || widget.setting.type == 'number' + onTap: + widget.setting.type == 'string' || widget.setting.type == 'number' ? () => _showEditDialog(context) : null, child: Padding( @@ -721,18 +765,17 @@ class _SettingItemState extends State<_SettingItem> { const SizedBox(height: 2), Text( widget.setting.description!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), ), ], - if (widget.setting.type == 'string' || widget.setting.type == 'number') ...[ + if (widget.setting.type == 'string' || + widget.setting.type == 'number') ...[ const SizedBox(height: 4), Text( widget.value?.toString() ?? 'Not set', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.primary, - ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.primary), ), ], ], @@ -775,23 +818,23 @@ class _SettingItemState extends State<_SettingItem> { final success = result['success'] as bool? ?? false; if (!success) { final error = result['error'] as String? ?? 'Action failed'; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(error)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); } else { final message = result['message'] as String?; if (message != null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(message)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); } } } } catch (e) { if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error: $e'))); } } finally { if (mounted) { @@ -801,7 +844,9 @@ class _SettingItemState extends State<_SettingItem> { } void _showEditDialog(BuildContext context) { - final controller = TextEditingController(text: widget.value?.toString() ?? ''); + final controller = TextEditingController( + text: widget.value?.toString() ?? '', + ); final colorScheme = Theme.of(context).colorScheme; showDialog( @@ -816,7 +861,9 @@ class _SettingItemState extends State<_SettingItem> { decoration: InputDecoration( hintText: widget.setting.description ?? 'Enter value', filled: true, - fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + fillColor: colorScheme.surfaceContainerHighest.withValues( + alpha: 0.3, + ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none, @@ -848,15 +895,12 @@ class _PostProcessingHookItem extends StatelessWidget { final PostProcessingHook hook; final bool showDivider; - const _PostProcessingHookItem({ - required this.hook, - this.showDivider = true, - }); + const _PostProcessingHookItem({required this.hook, this.showDivider = true}); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - + return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -903,16 +947,20 @@ class _PostProcessingHookItem extends StatelessWidget { spacing: 4, children: hook.supportedFormats.map((format) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(4), ), child: Text( format.toUpperCase(), - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ); }).toList(), @@ -923,7 +971,10 @@ class _PostProcessingHookItem extends StatelessWidget { ), if (hook.defaultEnabled) Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), decoration: BoxDecoration( color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(8), @@ -951,19 +1002,15 @@ class _PostProcessingHookItem extends StatelessWidget { } } - - class _URLHandlerInfo extends StatelessWidget { final List patterns; - const _URLHandlerInfo({ - required this.patterns, - }); + const _URLHandlerInfo({required this.patterns}); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - + return Padding( padding: const EdgeInsets.all(16), child: Column( @@ -1013,7 +1060,10 @@ class _URLHandlerInfo extends StatelessWidget { runSpacing: 8, children: patterns.map((pattern) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), @@ -1021,11 +1071,7 @@ class _URLHandlerInfo extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.language, - size: 16, - color: colorScheme.primary, - ), + Icon(Icons.language, size: 16, color: colorScheme.primary), const SizedBox(width: 6), Text( pattern, @@ -1048,11 +1094,7 @@ class _URLHandlerInfo extends StatelessWidget { ), child: Row( children: [ - Icon( - Icons.info_outline, - size: 20, - color: colorScheme.primary, - ), + Icon(Icons.info_outline, size: 20, color: colorScheme.primary), const SizedBox(width: 12), Expanded( child: Text( @@ -1075,15 +1117,12 @@ class _QualityOptionItem extends StatelessWidget { final QualityOption quality; final bool showDivider; - const _QualityOptionItem({ - required this.quality, - this.showDivider = true, - }); + const _QualityOptionItem({required this.quality, this.showDivider = true}); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - + return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -1115,7 +1154,8 @@ class _QualityOptionItem extends StatelessWidget { fontWeight: FontWeight.w500, ), ), - if (quality.description != null && quality.description!.isNotEmpty) ...[ + if (quality.description != null && + quality.description!.isNotEmpty) ...[ const SizedBox(height: 2), Text( quality.description!, @@ -1137,7 +1177,10 @@ class _QualityOptionItem extends StatelessWidget { ), if (quality.settings.isNotEmpty) Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), decoration: BoxDecoration( color: colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(8), diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index d339e24d..196dd0aa 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -72,6 +72,7 @@ class _ExtensionsPageState extends ConsumerState { backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), diff --git a/lib/screens/settings/log_screen.dart b/lib/screens/settings/log_screen.dart index 310d0805..611db06f 100644 --- a/lib/screens/settings/log_screen.dart +++ b/lib/screens/settings/log_screen.dart @@ -6,8 +6,10 @@ import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; -final RegExp _domainPattern = - RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false); +final RegExp _domainPattern = RegExp( + r'domain:\s*([^\s,]+)', + caseSensitive: false, +); class LogScreen extends StatefulWidget { const LogScreen({super.key}); @@ -17,7 +19,6 @@ class LogScreen extends StatefulWidget { } class _LogScreenState extends State { - final ScrollController _scrollController = ScrollController(); final TextEditingController _searchController = TextEditingController(); String _selectedLevel = 'ALL'; @@ -74,7 +75,9 @@ class _LogScreenState extends State { SnackBar( content: Text(context.l10n.logCopied), behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), duration: const Duration(seconds: 2), ), ); @@ -83,7 +86,9 @@ class _LogScreenState extends State { void _shareLogs() async { final logs = await LogBuffer().exportWithDeviceInfo(); - SharePlus.instance.share(ShareParams(text: logs, subject: 'SpotiFLAC Logs')); + SharePlus.instance.share( + ShareParams(text: logs, subject: 'SpotiFLAC Logs'), + ); } void _clearLogs() { @@ -137,52 +142,58 @@ class _LogScreenState extends State { controller: _scrollController, slivers: [ SliverAppBar( - expandedHeight: 120 + topPadding, - collapsedHeight: kToolbarHeight, - floating: false, - pinned: true, - backgroundColor: colorScheme.surface, - surfaceTintColor: Colors.transparent, - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.pop(context), - ), - actions: [ - IconButton( - icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center), - tooltip: _autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF', - onPressed: () => setState(() => _autoScroll = !_autoScroll), + expandedHeight: 120 + topPadding, + collapsedHeight: kToolbarHeight, + floating: false, + pinned: true, + backgroundColor: colorScheme.surface, + surfaceTintColor: Colors.transparent, + leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context), ), - IconButton( - icon: const Icon(Icons.copy), - tooltip: 'Copy logs', - onPressed: _copyLogs, - ), - PopupMenuButton( - icon: const Icon(Icons.more_vert), - onSelected: (value) { - switch (value) { - case 'share': - _shareLogs(); - break; - case 'clear': - _clearLogs(); - break; - } - }, - itemBuilder: (context) => [ - PopupMenuItem( - value: 'share', - child: ListTile( - leading: const Icon(Icons.share), - title: Text(context.l10n.logShareLogs), - contentPadding: EdgeInsets.zero, - ), + actions: [ + IconButton( + icon: Icon( + _autoScroll + ? Icons.vertical_align_bottom + : Icons.vertical_align_center, ), - PopupMenuItem( - value: 'clear', - child: ListTile( - leading: const Icon(Icons.delete_outline), + tooltip: _autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF', + onPressed: () => setState(() => _autoScroll = !_autoScroll), + ), + IconButton( + icon: const Icon(Icons.copy), + tooltip: 'Copy logs', + onPressed: _copyLogs, + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + tooltip: MaterialLocalizations.of(context).showMenuTooltip, + onSelected: (value) { + switch (value) { + case 'share': + _shareLogs(); + break; + case 'clear': + _clearLogs(); + break; + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'share', + child: ListTile( + leading: const Icon(Icons.share), + title: Text(context.l10n.logShareLogs), + contentPadding: EdgeInsets.zero, + ), + ), + PopupMenuItem( + value: 'clear', + child: ListTile( + leading: const Icon(Icons.delete_outline), title: Text(context.l10n.logClearLogs), contentPadding: EdgeInsets.zero, ), @@ -194,11 +205,17 @@ class _LogScreenState extends State { builder: (context, constraints) { final maxHeight = 120 + topPadding; final minHeight = kToolbarHeight + topPadding; - final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0); + final expandRatio = + ((constraints.maxHeight - minHeight) / + (maxHeight - minHeight)) + .clamp(0.0, 1.0); final leftPadding = 56 - (32 * expandRatio); return FlexibleSpaceBar( expandedTitleScale: 1.0, - titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), + titlePadding: EdgeInsets.only( + left: leftPadding, + bottom: 16, + ), title: Text( context.l10n.logTitle, style: TextStyle( @@ -213,28 +230,40 @@ class _LogScreenState extends State { ), SliverToBoxAdapter( - child: SettingsSectionHeader(title: context.l10n.logFilterSection), + child: SettingsSectionHeader( + title: context.l10n.logFilterSection, + ), ), SliverToBoxAdapter( child: SettingsGroup( children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), child: Row( children: [ - Icon(Icons.filter_list, color: colorScheme.onSurfaceVariant), + Icon( + Icons.filter_list, + color: colorScheme.onSurfaceVariant, + ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(context.l10n.logFilterLevel, style: Theme.of(context).textTheme.bodyLarge), + Text( + context.l10n.logFilterLevel, + style: Theme.of(context).textTheme.bodyLarge, + ), const SizedBox(height: 2), Text( context.l10n.logFilterBySeverity, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), ], ), @@ -248,8 +277,8 @@ class _LogScreenState extends State { child: Text( level, style: TextStyle( - color: level == 'ALL' - ? colorScheme.onSurface + color: level == 'ALL' + ? colorScheme.onSurface : _getLevelColor(level, colorScheme), fontWeight: FontWeight.w500, ), @@ -272,7 +301,10 @@ class _LogScreenState extends State { color: colorScheme.outlineVariant.withValues(alpha: 0.3), ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), child: Row( children: [ Icon(Icons.search, color: colorScheme.onSurfaceVariant), @@ -295,6 +327,7 @@ class _LogScreenState extends State { fillColor: colorScheme.surfaceContainerHighest, suffixIcon: _searchQuery.isNotEmpty ? IconButton( + tooltip: 'Clear search', icon: const Icon(Icons.clear, size: 20), onPressed: () { _searchController.clear(); @@ -317,16 +350,16 @@ class _LogScreenState extends State { SliverToBoxAdapter( child: SettingsSectionHeader( - title: _selectedLevel != 'ALL' || _searchQuery.isNotEmpty + title: _selectedLevel != 'ALL' || _searchQuery.isNotEmpty ? context.l10n.logEntriesFiltered(logs.length) : context.l10n.logEntries(logs.length), ), ), - + SliverToBoxAdapter( child: _LogSummaryCard(logs: LogBuffer().entries), ), - + logs.isEmpty ? SliverToBoxAdapter( child: SettingsGroup( @@ -339,21 +372,26 @@ class _LogScreenState extends State { Icon( Icons.article_outlined, size: 48, - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5), + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.5, + ), ), const SizedBox(height: 16), Text( context.l10n.logNoLogsYet, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith( + color: colorScheme.onSurfaceVariant, + ), ), const SizedBox(height: 4), Text( context.l10n.logNoLogsYetSubtitle, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7), - ), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.onSurfaceVariant + .withValues(alpha: 0.7), + ), ), ], ), @@ -408,7 +446,7 @@ class _LogEntryTile extends StatelessWidget { width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), decoration: BoxDecoration( - color: isError + color: isError ? colorScheme.errorContainer.withValues(alpha: 0.2) : null, ), @@ -427,7 +465,10 @@ class _LogEntryTile extends StatelessWidget { ), const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), decoration: BoxDecoration( color: levelColor.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(6), @@ -444,7 +485,10 @@ class _LogEntryTile extends StatelessWidget { if (entry.isFromGo) ...[ const SizedBox(width: 4), Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 2, + ), decoration: BoxDecoration( color: Colors.teal.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(4), @@ -519,9 +563,9 @@ class _LogSummaryCard extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - + final analysis = _analyzeLogs(); - + if (!analysis.hasIssues) { return const SizedBox.shrink(); } @@ -530,7 +574,7 @@ class _LogSummaryCard extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Card( elevation: 0, - color: analysis.hasISPBlocking + color: analysis.hasISPBlocking ? colorScheme.errorContainer.withValues(alpha: 0.5) : colorScheme.tertiaryContainer.withValues(alpha: 0.5), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), @@ -542,9 +586,13 @@ class _LogSummaryCard extends StatelessWidget { Row( children: [ Icon( - analysis.hasISPBlocking ? Icons.block : Icons.warning_amber_rounded, + analysis.hasISPBlocking + ? Icons.block + : Icons.warning_amber_rounded, size: 20, - color: analysis.hasISPBlocking ? colorScheme.error : colorScheme.tertiary, + color: analysis.hasISPBlocking + ? colorScheme.error + : colorScheme.tertiary, ), const SizedBox(width: 8), Text( @@ -557,19 +605,21 @@ class _LogSummaryCard extends StatelessWidget { ], ), const SizedBox(height: 12), - + if (analysis.hasISPBlocking) ...[ _IssueBadge( icon: Icons.block, label: 'ISP BLOCKING DETECTED', - description: 'Your ISP may be blocking access to download services', - suggestion: 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8', + description: + 'Your ISP may be blocking access to download services', + suggestion: + 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8', color: colorScheme.error, domains: analysis.blockedDomains, ), const SizedBox(height: 8), ], - + if (analysis.hasRateLimit) ...[ _IssueBadge( icon: Icons.speed, @@ -580,7 +630,7 @@ class _LogSummaryCard extends StatelessWidget { ), const SizedBox(height: 8), ], - + if (analysis.hasNetworkError && !analysis.hasISPBlocking) ...[ _IssueBadge( icon: Icons.wifi_off, @@ -591,17 +641,19 @@ class _LogSummaryCard extends StatelessWidget { ), const SizedBox(height: 8), ], - + if (analysis.hasNotFound) ...[ _IssueBadge( icon: Icons.search_off, label: 'TRACK NOT FOUND', - description: 'Some tracks could not be found on download services', - suggestion: 'The track may not be available in lossless quality', + description: + 'Some tracks could not be found on download services', + suggestion: + 'The track may not be available in lossless quality', color: colorScheme.onSurfaceVariant, ), ], - + const SizedBox(height: 12), Text( 'Total errors: ${analysis.errorCount}', @@ -639,7 +691,7 @@ class _LogSummaryCard extends StatelessWidget { combined.contains('connection reset') || combined.contains('connection refused')) { hasISPBlocking = true; - + final domainMatch = _domainPattern.firstMatch(combined); if (domainMatch != null) { blockedDomains.add(domainMatch.group(1)!); @@ -694,7 +746,12 @@ class _LogAnalysis { required this.blockedDomains, }); - bool get hasIssues => errorCount > 0 || hasISPBlocking || hasRateLimit || hasNetworkError || hasNotFound; + bool get hasIssues => + errorCount > 0 || + hasISPBlocking || + hasRateLimit || + hasNetworkError || + hasNotFound; } class _IssueBadge extends StatelessWidget { @@ -717,7 +774,7 @@ class _IssueBadge extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - + return Container( width: double.infinity, padding: const EdgeInsets.all(12), @@ -746,9 +803,9 @@ class _IssueBadge extends StatelessWidget { const SizedBox(height: 6), Text( description, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurface, - ), + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colorScheme.onSurface), ), if (domains != null && domains!.isNotEmpty) ...[ const SizedBox(height: 4), @@ -765,7 +822,11 @@ class _IssueBadge extends StatelessWidget { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.lightbulb_outline, size: 14, color: colorScheme.primary), + Icon( + Icons.lightbulb_outline, + size: 14, + color: colorScheme.primary, + ), const SizedBox(width: 4), Expanded( child: Text( diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 9016586c..d57558fa 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -32,6 +32,7 @@ class OptionsSettingsPage extends ConsumerWidget { backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), diff --git a/lib/screens/settings/provider_priority_page.dart b/lib/screens/settings/provider_priority_page.dart index a5a19f15..e28ad7e3 100644 --- a/lib/screens/settings/provider_priority_page.dart +++ b/lib/screens/settings/provider_priority_page.dart @@ -66,6 +66,7 @@ class _ProviderPriorityPageState extends ConsumerState { backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () async { if (_hasChanges) { diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 57a5caac..7c74d92e 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -487,6 +487,9 @@ class _SetupScreenState extends ConsumerState { if (_currentStep > 0) IconButton.filledTonal( onPressed: _prevPage, + tooltip: MaterialLocalizations.of( + context, + ).backButtonTooltip, icon: const Icon(Icons.arrow_back), style: IconButton.styleFrom( backgroundColor: colorScheme.surfaceContainerHighest, @@ -708,6 +711,7 @@ class _SetupScreenState extends ConsumerState { overflow: TextOverflow.ellipsis, ), trailing: IconButton( + tooltip: 'Change folder', icon: const Icon(Icons.edit), onPressed: _selectDirectory, ), diff --git a/lib/screens/store/extension_details_screen.dart b/lib/screens/store/extension_details_screen.dart index 86610974..efb2c7e4 100644 --- a/lib/screens/store/extension_details_screen.dart +++ b/lib/screens/store/extension_details_screen.dart @@ -17,7 +17,6 @@ class ExtensionDetailsScreen extends ConsumerStatefulWidget { class _ExtensionDetailsScreenState extends ConsumerState { - @override Widget build(BuildContext context) { final storeState = ref.watch(storeProvider); @@ -116,6 +115,7 @@ class _ExtensionDetailsScreenState ), ), leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ), @@ -171,7 +171,7 @@ class _ExtensionDetailsScreenState color: colorScheme.onSurface, ), ), - const SizedBox(height: 4), + const SizedBox(height: 4), Text( context.l10n.extensionsAuthor(ext.author), style: Theme.of(context).textTheme.bodyLarge @@ -222,7 +222,9 @@ class _ExtensionDetailsScreenState FilledButton.icon( onPressed: () => _updateExtension(ext), icon: const Icon(Icons.update), - label: Text('${context.l10n.storeUpdate} v${ext.version}'), + label: Text( + '${context.l10n.storeUpdate} v${ext.version}', + ), style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(52), shape: RoundedRectangleBorder( @@ -405,7 +407,8 @@ class _ExtensionDetailsScreenState StoreExtension ext, ColorScheme colorScheme, ) { - final isMetadataProvider = ext.category == 'metadata' || ext.category == 'integration'; + final isMetadataProvider = + ext.category == 'metadata' || ext.category == 'integration'; final isDownloadProvider = ext.category == 'download'; final isLyricsProvider = ext.category == 'lyrics'; final isUtility = ext.category == 'utility'; @@ -458,7 +461,7 @@ class _ExtensionDetailsScreenState final date = DateTime.parse(dateStr); final now = DateTime.now(); final diff = now.difference(date); - + if (diff.inDays == 0) { return context.l10n.dateToday; } else if (diff.inDays == 1) { @@ -560,7 +563,9 @@ class _ExtensionDetailsScreenState context: context, builder: (context) => AlertDialog( title: Text(context.l10n.dialogUninstallExtension), - content: Text(context.l10n.dialogUninstallExtensionMessage(ext.displayName)), + content: Text( + context.l10n.dialogUninstallExtensionMessage(ext.displayName), + ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), @@ -718,10 +723,7 @@ class _CapabilityRow extends StatelessWidget { Expanded( child: Text( label, - style: TextStyle( - color: colorScheme.onSurface, - fontSize: 14, - ), + style: TextStyle(color: colorScheme.onSurface, fontSize: 14), ), ), Icon( diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index d8d83e7b..ff97c194 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -122,6 +122,7 @@ class _StoreTabState extends ConsumerState { prefixIcon: const Icon(Icons.search), suffixIcon: value.text.isNotEmpty ? IconButton( + tooltip: 'Clear search', icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index b4a3238e..fdcd3eef 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -650,6 +650,7 @@ class _TrackMetadataScreenState extends ConsumerState { }, ), leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( @@ -662,6 +663,7 @@ class _TrackMetadataScreenState extends ConsumerState { ), actions: [ IconButton( + tooltip: MaterialLocalizations.of(context).showMenuTooltip, icon: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( diff --git a/lib/screens/tutorial_screen.dart b/lib/screens/tutorial_screen.dart index 04dfcf9c..a944f6a8 100644 --- a/lib/screens/tutorial_screen.dart +++ b/lib/screens/tutorial_screen.dart @@ -103,6 +103,9 @@ class _TutorialScreenState extends ConsumerState { opacity: _currentPage > 0 ? 1.0 : 0.0, child: IconButton.filledTonal( onPressed: _currentPage > 0 ? _prevPage : null, + tooltip: MaterialLocalizations.of( + context, + ).backButtonTooltip, icon: const Icon(Icons.arrow_back), style: IconButton.styleFrom( backgroundColor: colorScheme.surfaceContainerHighest, @@ -613,40 +616,52 @@ class _InteractiveDownloadExampleState ), ), const SizedBox(width: 16), - GestureDetector( - onTap: _startDownload, - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - padding: EdgeInsets.all(buttonPadding), - decoration: BoxDecoration( - color: _isCompleted ? Colors.green : colorScheme.primary, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: - (_isCompleted ? Colors.green : colorScheme.primary) - .withValues(alpha: 0.3), - blurRadius: 12, - offset: const Offset(0, 6), - ), - ], - ), - child: _isDownloading - ? SizedBox( - width: buttonIconSize, - height: buttonIconSize, - child: CircularProgressIndicator( - strokeWidth: 3, - color: colorScheme.onPrimary, - ), - ) - : Icon( - _isCompleted - ? Icons.check_rounded - : Icons.download_rounded, - color: colorScheme.onPrimary, - size: buttonIconSize, + Semantics( + button: true, + label: _isCompleted + ? 'Download completed' + : _isDownloading + ? 'Download in progress' + : 'Start download', + child: GestureDetector( + onTap: _startDownload, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + padding: EdgeInsets.all(buttonPadding), + decoration: BoxDecoration( + color: _isCompleted ? Colors.green : colorScheme.primary, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: + (_isCompleted + ? Colors.green + : colorScheme.primary) + .withValues(alpha: 0.3), + blurRadius: 12, + offset: const Offset(0, 6), ), + ], + ), + child: _isDownloading + ? SizedBox( + width: buttonIconSize, + height: buttonIconSize, + child: CircularProgressIndicator( + strokeWidth: 3, + color: colorScheme.onPrimary, + ), + ) + : ExcludeSemantics( + child: Icon( + _isCompleted + ? Icons.check_rounded + : Icons.download_rounded, + color: colorScheme.onPrimary, + size: buttonIconSize, + ), + ), + ), ), ), ], diff --git a/lib/widgets/collapsing_header.dart b/lib/widgets/collapsing_header.dart index 276f8f43..04ee5213 100644 --- a/lib/widgets/collapsing_header.dart +++ b/lib/widgets/collapsing_header.dart @@ -32,6 +32,7 @@ class CollapsingHeader extends StatelessWidget { surfaceTintColor: Colors.transparent, leading: showBackButton ? IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context), ) @@ -39,7 +40,10 @@ class CollapsingHeader extends StatelessWidget { automaticallyImplyLeading: false, flexibleSpace: LayoutBuilder( builder: (context, constraints) { - final expandRatio = _calculateExpandRatio(constraints, topPadding); + final expandRatio = _calculateExpandRatio( + constraints, + topPadding, + ); final animation = AlwaysStoppedAnimation(expandRatio); return FlexibleSpaceBar( @@ -48,13 +52,22 @@ class CollapsingHeader extends StatelessWidget { title: Container( alignment: Alignment.bottomLeft, padding: EdgeInsets.only( - left: Tween(begin: showBackButton ? 56 : 24, end: 24).evaluate(animation), - bottom: Tween(begin: 16, end: 24).evaluate(animation), + left: Tween( + begin: showBackButton ? 56 : 24, + end: 24, + ).evaluate(animation), + bottom: Tween( + begin: 16, + end: 24, + ).evaluate(animation), ), child: Text( title, style: TextStyle( - fontSize: Tween(begin: 20, end: 28).evaluate(animation), + fontSize: Tween( + begin: 20, + end: 28, + ).evaluate(animation), fontWeight: FontWeight.bold, color: colorScheme.onSurface, ), @@ -142,8 +155,12 @@ class InfoCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: Theme.of(context).textTheme.bodyLarge), - Text(subtitle, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colorScheme.onSurfaceVariant)), + Text( + subtitle, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), ], ), ], diff --git a/lib/widgets/priority_settings_scaffold.dart b/lib/widgets/priority_settings_scaffold.dart index f866b46f..acd9a664 100644 --- a/lib/widgets/priority_settings_scaffold.dart +++ b/lib/widgets/priority_settings_scaffold.dart @@ -61,6 +61,7 @@ class PrioritySettingsScaffold extends StatelessWidget { backgroundColor: colorScheme.surface, surfaceTintColor: Colors.transparent, leading: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, icon: const Icon(Icons.arrow_back), onPressed: () => _handleBack(context), ), diff --git a/lib/widgets/track_collection_quick_actions.dart b/lib/widgets/track_collection_quick_actions.dart index d21a67fe..b8f75708 100644 --- a/lib/widgets/track_collection_quick_actions.dart +++ b/lib/widgets/track_collection_quick_actions.dart @@ -36,6 +36,7 @@ class TrackCollectionQuickActions extends ConsumerWidget { final colorScheme = Theme.of(context).colorScheme; return IconButton( + tooltip: MaterialLocalizations.of(context).showMenuTooltip, icon: Icon( Icons.more_vert, color: colorScheme.onSurfaceVariant,