This commit is contained in:
2026-06-29 11:53:00 +03:00
parent 186cec8e2a
commit 4b5c6be212
9 changed files with 381 additions and 0 deletions
+15
View File
@@ -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<NavigatorState>();
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),
+13
View File
@@ -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';
}
+18
View File
@@ -0,0 +1,18 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../shared/providers/onboarding_provider.dart';
class OnboardingNotifier extends Notifier<int> {
@override
int build() => 0;
void setPage(int page) => state = page;
Future<void> complete() async {
final service = ref.read(onboardingServiceProvider);
await service.completeOnboarding();
}
}
final onboardingProvider = NotifierProvider<OnboardingNotifier, int>(
OnboardingNotifier.new,
);
+87
View File
@@ -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<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends ConsumerState<OnboardingScreen> {
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),
),
),
),
],
),
),
],
),
),
);
}
}
@@ -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<CashaShimmerText> createState() => _CashaShimmerTextState();
}
class _CashaShimmerTextState extends State<CashaShimmerText>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
double _tilt = 0.0;
double _targetTilt = 0.0;
StreamSubscription<AccelerometerEvent>? _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),
),
);
}
}
@@ -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),
),
),
],
),
),
);
}
}
@@ -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),
),
);
}),
);
}
}
@@ -0,0 +1,8 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../features/dashboard/provider.dart';
import '../services/onboarding_service.dart';
final onboardingServiceProvider = Provider<OnboardingService>((ref) {
final prefs = ref.watch(sharedPreferencesProvider);
return OnboardingService(prefs);
});
@@ -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<void> completeOnboarding() async {
_shownInSession = true;
if (!kDebugMode) {
await _prefs.setBool(_key, true);
}
}
}