chore: accessibility improvements, Semantics wrappers, and tooltip additions across screens

This commit is contained in:
zarzet
2026-03-08 15:08:13 +07:00
parent c35857bb61
commit 75a2bec8d5
26 changed files with 971 additions and 736 deletions
+33
View File
@@ -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
---
+1
View File
@@ -488,6 +488,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
},
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
+128 -120
View File
@@ -1184,6 +1184,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
],
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@@ -1571,44 +1572,61 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}) {
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<ArtistScreen> {
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,
),
],
),
),
],
),
),
),
);
+5
View File
@@ -630,6 +630,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
},
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@@ -851,6 +852,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
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<DownloadedAlbumScreen> {
children: [
IconButton.filledTonal(
onPressed: _exitSelectionMode,
tooltip: MaterialLocalizations.of(
context,
).closeButtonTooltip,
icon: const Icon(Icons.close),
style: IconButton.styleFrom(
backgroundColor: colorScheme.surfaceContainerHighest,
+116 -105
View File
@@ -1332,24 +1332,49 @@ class _HomeTabState extends ConsumerState<HomeTab>
);
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<HomeTab>
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<HomeTab>
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<HomeTab>
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<HomeTab>
),
),
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,
@@ -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),
),
@@ -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<CollectionTrackEntry> 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,
+1
View File
@@ -321,6 +321,7 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
},
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
+5 -1
View File
@@ -84,7 +84,11 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
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(
+1
View File
@@ -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),
),
@@ -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(),
+15 -15
View File
@@ -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 = <int>{};
@@ -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,
),
),
@@ -315,6 +315,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
+390 -347
View File
@@ -15,7 +15,8 @@ class ExtensionDetailPage extends ConsumerStatefulWidget {
const ExtensionDetailPage({super.key, required this.extensionId});
@override
ConsumerState<ExtensionDetailPage> createState() => _ExtensionDetailPageState();
ConsumerState<ExtensionDetailPage> createState() =>
_ExtensionDetailPageState();
}
class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
@@ -65,320 +66,373 @@ class _ExtensionDetailPageState extends ConsumerState<ExtensionDetailPage> {
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<ExtensionDetailPage> {
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<ExtensionDetailPage> {
),
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<String> 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),
@@ -72,6 +72,7 @@ class _ExtensionsPageState extends ConsumerState<ExtensionsPage> {
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
+156 -95
View File
@@ -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<LogScreen> {
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
String _selectedLevel = 'ALL';
@@ -74,7 +75,9 @@ class _LogScreenState extends State<LogScreen> {
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<LogScreen> {
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<LogScreen> {
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<String>(
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<String>(
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<LogScreen> {
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<LogScreen> {
),
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<LogScreen> {
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<LogScreen> {
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<LogScreen> {
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<LogScreen> {
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<LogScreen> {
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(
@@ -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),
),
@@ -66,6 +66,7 @@ class _ProviderPriorityPageState extends ConsumerState<ProviderPriorityPage> {
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: const Icon(Icons.arrow_back),
onPressed: () async {
if (_hasChanges) {
+4
View File
@@ -487,6 +487,9 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
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<SetupScreen> {
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
tooltip: 'Change folder',
icon: const Icon(Icons.edit),
onPressed: _selectDirectory,
),
+12 -10
View File
@@ -17,7 +17,6 @@ class ExtensionDetailsScreen extends ConsumerStatefulWidget {
class _ExtensionDetailsScreenState
extends ConsumerState<ExtensionDetailsScreen> {
@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(
+1
View File
@@ -122,6 +122,7 @@ class _StoreTabState extends ConsumerState<StoreTab> {
prefixIcon: const Icon(Icons.search),
suffixIcon: value.text.isNotEmpty
? IconButton(
tooltip: 'Clear search',
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
+2
View File
@@ -650,6 +650,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
},
),
leading: IconButton(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
@@ -662,6 +663,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
),
actions: [
IconButton(
tooltip: MaterialLocalizations.of(context).showMenuTooltip,
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
+48 -33
View File
@@ -103,6 +103,9 @@ class _TutorialScreenState extends ConsumerState<TutorialScreen> {
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,
),
),
),
),
),
],
+23 -6
View File
@@ -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<double>(begin: showBackButton ? 56 : 24, end: 24).evaluate(animation),
bottom: Tween<double>(begin: 16, end: 24).evaluate(animation),
left: Tween<double>(
begin: showBackButton ? 56 : 24,
end: 24,
).evaluate(animation),
bottom: Tween<double>(
begin: 16,
end: 24,
).evaluate(animation),
),
child: Text(
title,
style: TextStyle(
fontSize: Tween<double>(begin: 20, end: 28).evaluate(animation),
fontSize: Tween<double>(
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,
),
),
],
),
],
@@ -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),
),
@@ -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,