Files
SpotiFLAC-Mobile/lib/screens/tutorial_screen.dart

775 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.storefront_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: 'Paste or search...',
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.delayed(const Duration(milliseconds: 50));
setState(() => _progress = i / 100);
}
setState(() {
_isDownloading = false;
_isCompleted = true;
});
await Future.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
? '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,
),
),
),
),
),
],
),
);
},
);
}
}
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);
// Parallax effect logic (simplified for StatelessWidget)
// In a real advanced implementation we'd pass the Controller's listenable
// But for now, let's use entrance animations based on currentIndex == index
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),
],
),
);
}
}