mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-03-31 00:39:24 +02:00
775 lines
27 KiB
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),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|