mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-21 15:36:50 +02:00
f29177216d
Enable strict-casts, strict-inference, and strict-raw-types in analysis_options.yaml. Add custom_lint with riverpod_lint. Fix all resulting type warnings with explicit type parameters and safer casts. Also improves APK update checker to detect device ABIs for correct variant selection and fixes Deezer artist name parsing edge case.
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<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
|
|
? '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),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|