Files
SpotiFLAC-Mobile/lib/screens/tutorial_screen.dart
T
zarzet 16ce6089fb feat: remove Tidal built-in provider, add extension download dedup/ISRC/Lyrics APIs, and expand l10n/a11y
Remove Tidal from built-in provider registry (metadata, search, download,
URL parsing) and delete tidal.go. Introduce extension runtime APIs for
lyrics lookup (getLyricsLRC), ISRC existence check (checkISRCExists), and
ISRC index management (addToISRCIndex). Refactor extension download response
construction into normalizeExtensionDownloadResult/overlayExtensionDownloadMetadata
helpers with AlreadyExists support and ISRC indexing. Switch download mirrors
to DoRequestWithUserAgent for ISP blocking detection. Add 50+ new
localization keys and accessibility labels across all supported locales.
2026-04-18 22:12:14 +07:00

771 lines
27 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
class TutorialScreen extends ConsumerStatefulWidget {
const TutorialScreen({super.key});
@override
ConsumerState<TutorialScreen> createState() => _TutorialScreenState();
}
class _TutorialScreenState extends ConsumerState<TutorialScreen> {
final PageController _pageController = PageController();
int _currentPage = 0;
static const int _totalPages = 6;
double _responsiveScale({
required BuildContext context,
double min = 0.82,
double max = 1.08,
double baseShortestSide = 390,
}) {
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
final scale = shortestSide / baseShortestSide;
if (scale < min) return min;
if (scale > max) return max;
return scale;
}
double _effectiveTextScale(BuildContext context) {
final textScale = MediaQuery.textScalerOf(context).scale(1.0);
if (textScale < 1.0) return 1.0;
if (textScale > 1.4) return 1.4;
return textScale;
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _nextPage() {
if (_currentPage < _totalPages - 1) {
_pageController.nextPage(
duration: const Duration(milliseconds: 600),
curve: Curves.easeOutQuart,
);
} else {
_completeTutorial();
}
}
void _prevPage() {
_pageController.previousPage(
duration: const Duration(milliseconds: 600),
curve: Curves.easeOutQuart,
);
}
void _completeTutorial() {
ref.read(settingsProvider.notifier).setTutorialComplete();
context.go('/');
}
void _skipTutorial() {
ref.read(settingsProvider.notifier).setTutorialComplete();
context.go('/');
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final l10n = context.l10n;
final isLastPage = _currentPage == _totalPages - 1;
final scale = _responsiveScale(context: context, min: 0.86, max: 1.05);
final textScale = _effectiveTextScale(context);
final topBarPaddingH = 24 * scale;
final topBarPaddingV = 16 * scale;
final pageIndicatorHeight = 8 * scale;
final pageIndicatorWidth = 8 * scale;
final activeIndicatorWidth = 32 * scale;
final bottomGap = (32 * scale) + ((textScale - 1) * 8);
final actionButtonHeight = (56 * scale) + ((textScale - 1) * 6);
return Scaffold(
backgroundColor: colorScheme.surface,
body: SafeArea(
child: Column(
children: [
Padding(
padding: EdgeInsets.symmetric(
horizontal: topBarPaddingH,
vertical: topBarPaddingV,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
AnimatedOpacity(
duration: const Duration(milliseconds: 300),
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,
foregroundColor: colorScheme.onSurfaceVariant,
),
),
),
TextButton(
onPressed: _skipTutorial,
style: TextButton.styleFrom(
foregroundColor: colorScheme.onSurfaceVariant,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
child: Text(
l10n.setupSkip,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
),
Expanded(
child: PageView(
controller: _pageController,
onPageChanged: (page) => setState(() => _currentPage = page),
children: [
_TutorialPage(
index: 0,
currentIndex: _currentPage,
icon: Icons.waving_hand_rounded,
iconColor: Colors.amber,
title: l10n.tutorialWelcomeTitle,
description: l10n.tutorialWelcomeDesc,
content: _buildFeatureList(context, [
(Icons.music_note_rounded, l10n.tutorialWelcomeTip1),
(Icons.high_quality_rounded, l10n.tutorialWelcomeTip2),
(Icons.download_rounded, l10n.tutorialWelcomeTip3),
]),
),
_TutorialPage(
index: 1,
currentIndex: _currentPage,
icon: Icons.search_rounded,
title: l10n.tutorialSearchTitle,
description: l10n.tutorialSearchDesc,
content: const _InteractiveSearchExample(),
),
_TutorialPage(
index: 2,
currentIndex: _currentPage,
icon: Icons.download_rounded,
title: l10n.tutorialDownloadTitle,
description: l10n.tutorialDownloadDesc,
content: const _InteractiveDownloadExample(),
),
_TutorialPage(
index: 3,
currentIndex: _currentPage,
icon: Icons.library_music_rounded,
title: l10n.tutorialLibraryTitle,
description: l10n.tutorialLibraryDesc,
content: _buildFeatureList(context, [
(Icons.offline_pin_rounded, l10n.tutorialLibraryTip1),
(Icons.play_circle_fill, l10n.tutorialLibraryTip2),
(Icons.grid_view_rounded, l10n.tutorialLibraryTip3),
]),
),
_TutorialPage(
index: 4,
currentIndex: _currentPage,
icon: Icons.extension_rounded,
title: l10n.tutorialExtensionsTitle,
description: l10n.tutorialExtensionsDesc,
content: _buildFeatureList(context, [
(Icons.extension_rounded, l10n.tutorialExtensionsTip1),
(
Icons.add_circle_outline_rounded,
l10n.tutorialExtensionsTip2,
),
(Icons.lyrics_rounded, l10n.tutorialExtensionsTip3),
]),
),
_TutorialPage(
index: 5,
currentIndex: _currentPage,
icon: Icons.settings_rounded,
title: l10n.tutorialSettingsTitle,
description: l10n.tutorialSettingsDesc,
content: Column(
children: [
_buildFeatureList(context, [
(
Icons.folder_open_rounded,
l10n.tutorialSettingsTip1,
),
(Icons.tune_rounded, l10n.tutorialSettingsTip2),
(Icons.palette_rounded, l10n.tutorialSettingsTip3),
]),
const SizedBox(height: 24),
_AnimatedReadyCard(text: l10n.tutorialReadyMessage),
],
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_totalPages, (index) {
final isActive = _currentPage == index;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutBack,
margin: EdgeInsets.symmetric(horizontal: 4 * scale),
height: pageIndicatorHeight,
width: isActive
? activeIndicatorWidth
: pageIndicatorWidth,
decoration: BoxDecoration(
color: isActive
? colorScheme.primary
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
),
);
}),
),
SizedBox(height: bottomGap),
SizedBox(
width: double.infinity,
height: actionButtonHeight,
child: FilledButton(
onPressed: _nextPage,
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
),
child: Text(
isLastPage ? l10n.setupGetStarted : l10n.setupNext,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
],
),
),
);
}
Widget _buildFeatureList(
BuildContext context,
List<(IconData, String)> features,
) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
children: features.asMap().entries.map((entry) {
final index = entry.key;
final feature = entry.value;
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: Duration(milliseconds: 600 + (index * 200)),
curve: Curves.easeOutQuart,
builder: (context, value, child) {
return Transform.translate(
offset: Offset(0, 20 * (1 - value)),
child: Opacity(
opacity: value,
child: Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
feature.$1,
size: 24,
color: colorScheme.primary,
),
),
const SizedBox(width: 20),
Expanded(
child: Text(
feature.$2,
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
);
},
);
}).toList(),
);
}
}
class _AnimatedReadyCard extends StatelessWidget {
final String text;
const _AnimatedReadyCard({required this.text});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(28),
),
child: Row(
children: [
Icon(
Icons.lightbulb_rounded,
color: colorScheme.onPrimaryContainer,
size: 28,
),
const SizedBox(width: 16),
Expanded(
child: Text(
text,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: colorScheme.onPrimaryContainer,
),
),
),
],
),
);
}
}
class _InteractiveSearchExample extends StatefulWidget {
const _InteractiveSearchExample();
@override
State<_InteractiveSearchExample> createState() =>
_InteractiveSearchExampleState();
}
class _InteractiveSearchExampleState extends State<_InteractiveSearchExample> {
final TextEditingController _controller = TextEditingController();
bool _showResult = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _controller,
onChanged: (value) {
setState(() {
_showResult = value.isNotEmpty;
});
},
style: TextStyle(color: colorScheme.onSurface, fontSize: 16),
decoration: InputDecoration(
hintText: context.l10n.tutorialSearchHint,
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
prefixIcon: Icon(Icons.search, color: colorScheme.primary),
filled: true,
fillColor: colorScheme.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
),
),
AnimatedSize(
duration: const Duration(milliseconds: 400),
curve: Curves.easeOutBack,
child: _showResult
? Padding(
padding: const EdgeInsets.only(top: 16),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.music_note_rounded,
color: colorScheme.onPrimaryContainer,
size: 28,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
height: 14,
decoration: BoxDecoration(
color: colorScheme.onSurface.withValues(
alpha: 0.1,
),
borderRadius: BorderRadius.circular(7),
),
),
const SizedBox(height: 8),
Container(
width: 100,
height: 12,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
),
],
),
),
const SizedBox(width: 12),
Icon(
Icons.download_rounded,
color: colorScheme.primary,
),
],
),
),
)
: const SizedBox.shrink(),
),
],
),
);
}
}
class _InteractiveDownloadExample extends StatefulWidget {
const _InteractiveDownloadExample();
@override
State<_InteractiveDownloadExample> createState() =>
_InteractiveDownloadExampleState();
}
class _InteractiveDownloadExampleState
extends State<_InteractiveDownloadExample> {
bool _isDownloading = false;
double _progress = 0.0;
bool _isCompleted = false;
void _startDownload() async {
if (_isDownloading || _isCompleted) return;
setState(() {
_isDownloading = true;
_progress = 0.0;
});
for (int i = 0; i <= 100; i += 5) {
if (!mounted) return;
await Future<void>.delayed(const Duration(milliseconds: 50));
setState(() => _progress = i / 100);
}
setState(() {
_isDownloading = false;
_isCompleted = true;
});
await Future<void>.delayed(const Duration(seconds: 2));
if (mounted) {
setState(() {
_isCompleted = false;
_progress = 0.0;
});
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return LayoutBuilder(
builder: (context, constraints) {
final cardWidth = constraints.maxWidth;
final coverSize = (cardWidth * 0.18).clamp(56.0, 80.0);
final buttonPadding = (coverSize * 0.18).clamp(10.0, 14.0);
final buttonIconSize = (coverSize * 0.4).clamp(22.0, 30.0);
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
child: Row(
children: [
Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Icon(
Icons.album_rounded,
size: coverSize * 0.5,
color: colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 20),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: (cardWidth * 0.35).clamp(100.0, 160.0),
height: 14,
decoration: BoxDecoration(
color: colorScheme.onSurface,
borderRadius: BorderRadius.circular(7),
),
),
const SizedBox(height: 10),
if (_isDownloading)
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: _progress,
minHeight: 12,
backgroundColor: colorScheme.surfaceContainerHighest,
color: colorScheme.primary,
),
)
else
Container(
width: (cardWidth * 0.22).clamp(70.0, 100.0),
height: 12,
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant,
borderRadius: BorderRadius.circular(6),
),
),
],
),
),
const SizedBox(width: 16),
Semantics(
button: true,
label: _isCompleted
? context.l10n.tutorialDownloadCompletedSemantics
: _isDownloading
? context.l10n.tutorialDownloadInProgressSemantics
: context.l10n.tutorialStartDownloadSemantics,
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,
),
),
),
),
),
],
),
);
},
);
}
}
class _TutorialPage extends StatelessWidget {
final int index;
final int currentIndex;
final IconData icon;
final String title;
final String description;
final Widget content;
final Color? iconColor;
const _TutorialPage({
required this.index,
required this.currentIndex,
required this.icon,
required this.title,
required this.description,
required this.content,
this.iconColor,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final shortestSide = MediaQuery.sizeOf(context).shortestSide;
final textScale = MediaQuery.textScalerOf(
context,
).scale(1.0).clamp(1.0, 1.4);
final scale = (shortestSide / 390).clamp(0.86, 1.05);
final topGap = (24 * scale).clamp(16.0, 24.0);
final iconPadding = (24 * scale).clamp(18.0, 24.0);
final iconSize = (56 * scale).clamp(44.0, 56.0);
final iconTextGap = (48 * scale).clamp(28.0, 48.0);
final descriptionGap = (20 * scale).clamp(12.0, 20.0);
final contentGap = (56 * scale) + ((textScale - 1) * 10);
final bottomGap = (32 * scale).clamp(20.0, 32.0);
final isActive = currentIndex == index;
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
physics: const BouncingScrollPhysics(),
child: Column(
children: [
SizedBox(height: topGap),
AnimatedContainer(
duration: const Duration(milliseconds: 500),
curve: Curves.easeOutBack,
transform: Matrix4.translationValues(0, isActive ? 0 : -20, 0),
padding: EdgeInsets.all(iconPadding),
decoration: BoxDecoration(
color: (iconColor ?? colorScheme.primary).withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Icon(
icon,
size: iconSize,
color: iconColor ?? colorScheme.primary,
),
),
SizedBox(height: iconTextGap),
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: isActive ? 1.0 : 0.0,
curve: Curves.easeOut,
child: Text(
title,
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
letterSpacing: -0.5,
),
textAlign: TextAlign.center,
),
),
SizedBox(height: descriptionGap),
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: isActive ? 1.0 : 0.0,
curve: Curves.easeOut,
child: Text(
description,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.5,
fontSize: 16 * (1 + ((textScale - 1) * 0.1)),
),
textAlign: TextAlign.center,
),
),
SizedBox(height: contentGap),
content, // The content itself now handles its own internal animations
SizedBox(height: bottomGap),
],
),
);
}
}