diff --git a/lib/app/router.dart b/lib/app/router.dart index 9ddad0c..87f4e85 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -7,14 +7,29 @@ import '../features/add_transaction/screen.dart'; import '../features/categories/screen.dart'; import '../features/settings/screen.dart'; import '../features/settings/categories/category_manager_screen.dart'; +import '../features/onboarding/screen.dart'; import '../shared/models/transaction.dart'; import '../shared/paywall/paywall_screen.dart'; +import '../shared/providers/onboarding_provider.dart'; final _shellKey = GlobalKey(); final appRouter = GoRouter( initialLocation: '/dashboard', + redirect: (context, state) { + final service = ProviderScope.containerOf(context) + .read(onboardingServiceProvider); + final location = state.uri.toString(); + if (service.shouldShowOnboarding && location != '/onboarding') { + return '/onboarding'; + } + return null; + }, routes: [ + GoRoute( + path: '/onboarding', + builder: (context, state) => const OnboardingScreen(), + ), ShellRoute( navigatorKey: _shellKey, builder: (context, state, child) => AppShell(child: child), diff --git a/lib/core/l10n/app_strings.dart b/lib/core/l10n/app_strings.dart index e493b0a..60bbbe6 100644 --- a/lib/core/l10n/app_strings.dart +++ b/lib/core/l10n/app_strings.dart @@ -261,4 +261,17 @@ class AppStrings { _ru ? 'Изменение высоты карточки' : 'Resizable card height'; String get premiumFeatureAccounts => _ru ? 'До 8 счетов' : 'Up to 8 accounts'; + + String get onboardingWelcome => _ru ? 'Добро пожаловать в' : 'Welcome to'; + String get onboardingMultiCurrencyTitle => + _ru ? 'Все деньги на одном экране' : 'All Your Money, One View'; + String get onboardingMultiCurrencyBody => _ru + ? 'Счета в разных валютах с автоматической конвертацией по актуальным курсам' + : 'Accounts in different currencies, automatically converted at live exchange rates'; + String get onboardingCardsTitle => + _ru ? 'Живые карточки' : 'Cards That Come Alive'; + String get onboardingCardsBody => _ru + ? 'Наклоните телефон — и карточка оживает. Градиенты, цвета и высота — всё настраивается под вас' + : 'Tilt your phone and watch them respond. Customize gradients, colors and height to make them yours'; + String get onboardingGetStarted => _ru ? 'Начать' : 'Get Started'; } diff --git a/lib/features/onboarding/provider.dart b/lib/features/onboarding/provider.dart new file mode 100644 index 0000000..87c447c --- /dev/null +++ b/lib/features/onboarding/provider.dart @@ -0,0 +1,18 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../shared/providers/onboarding_provider.dart'; + +class OnboardingNotifier extends Notifier { + @override + int build() => 0; + + void setPage(int page) => state = page; + + Future complete() async { + final service = ref.read(onboardingServiceProvider); + await service.completeOnboarding(); + } +} + +final onboardingProvider = NotifierProvider( + OnboardingNotifier.new, +); diff --git a/lib/features/onboarding/screen.dart b/lib/features/onboarding/screen.dart new file mode 100644 index 0000000..c5d6d51 --- /dev/null +++ b/lib/features/onboarding/screen.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../core/l10n/locale_provider.dart'; +import 'provider.dart'; +import 'widgets/onboarding_page.dart'; +import 'widgets/onboarding_page_indicator.dart'; + +class OnboardingScreen extends ConsumerStatefulWidget { + const OnboardingScreen({super.key}); + + @override + ConsumerState createState() => _OnboardingScreenState(); +} + +class _OnboardingScreenState extends ConsumerState { + final _controller = PageController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _finish() { + ref.read(onboardingProvider.notifier).complete(); + context.go('/dashboard'); + } + + @override + Widget build(BuildContext context) { + final s = ref.watch(stringsProvider); + final currentPage = ref.watch(onboardingProvider); + + return Scaffold( + body: SafeArea( + child: Column( + children: [ + Expanded( + child: PageView( + controller: _controller, + onPageChanged: (page) => + ref.read(onboardingProvider.notifier).setPage(page), + children: [ + OnboardingPage.welcome(welcomeText: s.onboardingWelcome), + OnboardingPage.content( + icon: Icons.currency_exchange_rounded, + headline: s.onboardingMultiCurrencyTitle, + description: s.onboardingMultiCurrencyBody, + ), + OnboardingPage.content( + icon: Icons.credit_card_rounded, + headline: s.onboardingCardsTitle, + description: s.onboardingCardsBody, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 48), + child: Column( + children: [ + OnboardingPageIndicator( + current: currentPage, + count: 3, + ), + const SizedBox(height: 32), + if (currentPage == 2) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _finish, + child: Text(s.onboardingGetStarted), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/onboarding/widgets/casha_shimmer_text.dart b/lib/features/onboarding/widgets/casha_shimmer_text.dart new file mode 100644 index 0000000..207d9b2 --- /dev/null +++ b/lib/features/onboarding/widgets/casha_shimmer_text.dart @@ -0,0 +1,86 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:sensors_plus/sensors_plus.dart'; + +class CashaShimmerText extends StatefulWidget { + final String text; + final TextStyle? style; + + const CashaShimmerText({ + required this.text, + this.style, + super.key, + }); + + @override + State createState() => _CashaShimmerTextState(); +} + +class _CashaShimmerTextState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + double _tilt = 0.0; + double _targetTilt = 0.0; + StreamSubscription? _sub; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 4), + )..repeat(); + + _sub = accelerometerEventStream( + samplingPeriod: const Duration(milliseconds: 50), + ).listen((e) { + _targetTilt = (e.x / 9.8).clamp(-1.0, 1.0); + }); + } + + @override + void dispose() { + _controller.dispose(); + _sub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final primary = Theme.of(context).colorScheme.primary; + + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + _tilt += (_targetTilt - _tilt) * 0.12; + + final sweep = (_controller.value * 2.0 - 1.0) + _tilt * 0.6; + + return ShaderMask( + shaderCallback: (bounds) { + return LinearGradient( + begin: Alignment(sweep - 0.8, -0.3), + end: Alignment(sweep + 0.8, 0.3), + colors: [ + primary.withValues(alpha: 0.7), + primary, + Colors.white, + primary.withValues(alpha: 0.9), + Colors.white, + primary, + primary.withValues(alpha: 0.7), + ], + stops: [0.0, 0.2, 0.35, 0.45, 0.55, 0.8, 1.0], + ).createShader(bounds); + }, + blendMode: BlendMode.srcIn, + child: child, + ); + }, + child: Text( + widget.text, + style: widget.style?.copyWith(color: Colors.white), + ), + ); + } +} diff --git a/lib/features/onboarding/widgets/onboarding_page.dart b/lib/features/onboarding/widgets/onboarding_page.dart new file mode 100644 index 0000000..60a33a5 --- /dev/null +++ b/lib/features/onboarding/widgets/onboarding_page.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'casha_shimmer_text.dart'; + +class OnboardingPage extends StatelessWidget { + final IconData? icon; + final String? headline; + final String? description; + final String? welcomeText; + final bool isWelcomePage; + + const OnboardingPage.welcome({required this.welcomeText, super.key}) + : icon = null, + headline = null, + description = null, + isWelcomePage = true; + + const OnboardingPage.content({ + required this.icon, + required this.headline, + required this.description, + super.key, + }) : welcomeText = null, + isWelcomePage = false; + + @override + Widget build(BuildContext context) { + if (isWelcomePage) { + final colorScheme = Theme.of(context).colorScheme; + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + welcomeText!, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w300, + color: colorScheme.onSurface.withOpacity(0.4), + letterSpacing: 2, + ), + ), + const SizedBox(height: 12), + CashaShimmerText( + text: 'Casha', + style: Theme.of(context).textTheme.displayLarge?.copyWith( + fontSize: 72, + fontWeight: FontWeight.w900, + letterSpacing: -1, + ), + ), + ], + ), + ); + } + + final colorScheme = Theme.of(context).colorScheme; + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: colorScheme.primary.withOpacity(0.12), + shape: BoxShape.circle, + ), + child: Icon( + icon, + size: 64, + color: colorScheme.primary, + ), + ), + const SizedBox(height: 32), + Text( + headline!, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 12), + Text( + description!, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colorScheme.onSurface.withOpacity(0.6), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/onboarding/widgets/onboarding_page_indicator.dart b/lib/features/onboarding/widgets/onboarding_page_indicator.dart new file mode 100644 index 0000000..49f885f --- /dev/null +++ b/lib/features/onboarding/widgets/onboarding_page_indicator.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +class OnboardingPageIndicator extends StatelessWidget { + final int current; + final int count; + + const OnboardingPageIndicator({ + required this.current, + required this.count, + super.key, + }); + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).colorScheme.primary; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(count, (i) { + final isActive = i == current; + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.symmetric(horizontal: 4), + width: isActive ? 24 : 8, + height: 8, + decoration: BoxDecoration( + color: isActive ? color : color.withOpacity(0.3), + borderRadius: BorderRadius.circular(4), + ), + ); + }), + ); + } +} diff --git a/lib/shared/providers/onboarding_provider.dart b/lib/shared/providers/onboarding_provider.dart new file mode 100644 index 0000000..550a50d --- /dev/null +++ b/lib/shared/providers/onboarding_provider.dart @@ -0,0 +1,8 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../features/dashboard/provider.dart'; +import '../services/onboarding_service.dart'; + +final onboardingServiceProvider = Provider((ref) { + final prefs = ref.watch(sharedPreferencesProvider); + return OnboardingService(prefs); +}); diff --git a/lib/shared/services/onboarding_service.dart b/lib/shared/services/onboarding_service.dart new file mode 100644 index 0000000..908c6ff --- /dev/null +++ b/lib/shared/services/onboarding_service.dart @@ -0,0 +1,25 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class OnboardingService { + static const _key = 'onboarding_completed'; + static bool _shownInSession = false; + + final SharedPreferences _prefs; + + OnboardingService(this._prefs); + + bool get shouldShowOnboarding { + if (kDebugMode) { + return !_shownInSession; + } + return !(_prefs.getBool(_key) ?? false); + } + + Future completeOnboarding() async { + _shownInSession = true; + if (!kDebugMode) { + await _prefs.setBool(_key, true); + } + } +}