This commit is contained in:
Dmitry 2025-11-27 23:49:42 +03:00
parent 76fadd931e
commit 9fc720c10d
6 changed files with 720 additions and 255 deletions

View file

@ -55,10 +55,10 @@ class PackDtoConverter {
? null ? null
: canOpenForAdVal : canOpenForAdVal
? AssetPackTip( ? AssetPackTip(
'asset:icons/ad.webp', 'icons/ad.webp',
position: PackTipPosition.bottomRight, position: PackTipPosition.bottomRight,
) )
: AssetPackTip('asset:icons/lock.webp', : AssetPackTip('icons/lock.webp',
position: PackTipPosition.bottomRight,), position: PackTipPosition.bottomRight,),
version: model.version, version: model.version,
); );

View file

@ -8,9 +8,6 @@ import 'package:yx_scope_flutter/yx_scope_flutter.dart';
import 'package:yx_state_flutter/yx_state_flutter.dart'; import 'package:yx_state_flutter/yx_state_flutter.dart';
import 'package:mnemo_cards_web_v2/di/app_scope/app_scope_container.dart'; import 'package:mnemo_cards_web_v2/di/app_scope/app_scope_container.dart';
import 'package:mnemo_cards_web_v2/domain/services/statistics_service.dart';
import 'package:mnemo_cards_web_v2/presentation/widgets/simple_chart.dart';
import 'package:mnemo_cards_web_v2/presentation/widgets/stats_card.dart';
/// Profile page /// Profile page
/// ///
@ -156,9 +153,6 @@ class _ProfilePageState extends State<ProfilePage> {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
// Get statistics
final statistics = userScope.statisticsService.getStatistics(user);
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
@ -168,22 +162,26 @@ class _ProfilePageState extends State<ProfilePage> {
_buildUserHeader(context, user), _buildUserHeader(context, user),
const SizedBox(height: 24), const SizedBox(height: 24),
// Statistics cards
_buildStatisticsSection(context, statistics),
const SizedBox(height: 24),
// Daily progress chart
SimpleChart(data: statistics.dailyProgress),
const SizedBox(height: 24),
// Account info card // Account info card
_buildAccountInfoCard(context, user), _buildAccountInfoCard(context, user),
const SizedBox(height: 24), const SizedBox(height: 24),
// Subscription section
_buildSubscriptionSection(context, user),
const SizedBox(height: 24),
// Settings section // Settings section
_buildSettingsSection(context), _buildSettingsSection(context),
const SizedBox(height: 24), const SizedBox(height: 24),
// Data & Privacy section
_buildDataPrivacySection(context),
const SizedBox(height: 24),
// About section
_buildAboutSection(context),
const SizedBox(height: 24),
// Logout button // Logout button
_buildLogoutButton(context), _buildLogoutButton(context),
], ],
@ -197,157 +195,416 @@ class _ProfilePageState extends State<ProfilePage> {
final theme = Theme.of(context); final theme = Theme.of(context);
return Card( return Card(
elevation: 2, elevation: 4,
child: Padding( shape: RoundedRectangleBorder(
padding: const EdgeInsets.all(16.0), borderRadius: BorderRadius.circular(20),
child: Row( ),
children: [ child: Container(
// Avatar decoration: BoxDecoration(
CircleAvatar( borderRadius: BorderRadius.circular(20),
radius: 32, gradient: LinearGradient(
backgroundColor: theme.colorScheme.primary, begin: Alignment.topLeft,
child: Text( end: Alignment.bottomRight,
(user.name?.isNotEmpty == true colors: [
? user.name![0] theme.colorScheme.primary.withOpacity(0.1),
: user.email?.isNotEmpty == true theme.colorScheme.primary.withOpacity(0.05),
? user.email![0] ],
: 'U') ),
.toUpperCase(), ),
style: TextStyle( child: Padding(
fontSize: 28, padding: const EdgeInsets.all(20.0),
color: theme.colorScheme.onPrimary, child: Row(
fontWeight: FontWeight.bold, children: [
// Avatar with shadow
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withOpacity(0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
), ),
), child: CircleAvatar(
), radius: 40,
const SizedBox(width: 16), backgroundColor: theme.colorScheme.primary,
// User info child: Text(
Expanded( (user.name?.isNotEmpty == true
child: Column( ? user.name![0]
crossAxisAlignment: CrossAxisAlignment.start, : user.email?.isNotEmpty == true
children: [ ? user.email![0]
Text( : 'U')
user.name ?? user.email ?? 'User', .toUpperCase(),
style: theme.textTheme.titleLarge?.copyWith( style: TextStyle(
fontSize: 32,
color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
if (user.email != null) ...[ ),
const SizedBox(height: 4),
Text(
user.email!,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.textTheme.bodySmall?.color,
),
),
],
if (user.subscription == true) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.star,
size: 16,
color: theme.colorScheme.onPrimaryContainer,
),
const SizedBox(width: 4),
Text(
'Premium',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
],
), ),
), const SizedBox(width: 20),
], // User info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user.name ?? user.email ?? 'User',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
fontSize: 22,
),
),
if (user.email != null) ...[
const SizedBox(height: 6),
Text(
user.email!,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
],
if (user.subscription == true) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
theme.colorScheme.primaryContainer,
theme.colorScheme.primaryContainer.withOpacity(0.8),
],
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: theme.colorScheme.primary.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.star,
size: 18,
color: theme.colorScheme.onPrimaryContainer,
),
const SizedBox(width: 6),
Text(
'Premium',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
],
),
),
],
],
),
),
],
),
), ),
), ),
); );
} }
Widget _buildStatisticsSection( Widget _buildSubscriptionSection(BuildContext context, UserDto user) {
BuildContext context, final theme = Theme.of(context);
UserStatistics statistics,
) {
// Format study time
final hours = statistics.studyTime.inHours;
final minutes = statistics.studyTime.inMinutes % 60;
final studyTimeStr = hours > 0 ? '${hours}h ${minutes}m' : '${minutes}m';
return Row(
children: [
Expanded(
child: StatsCard(
icon: Icons.school,
label: 'Learned Words',
value: statistics.learnedWords.toString(),
color: Colors.blue,
),
),
const SizedBox(width: 12),
Expanded(
child: StatsCard(
icon: Icons.quiz,
label: 'Tests Completed',
value: statistics.testsCompleted.toString(),
color: Colors.green,
),
),
const SizedBox(width: 12),
Expanded(
child: StatsCard(
icon: Icons.timer,
label: 'Study Time',
value: studyTimeStr,
color: Colors.orange,
),
),
],
);
}
Widget _buildAccountInfoCard(BuildContext context, UserDto user) {
return Card( return Card(
elevation: 2, elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(20.0),
child: Text( child: Row(
'Account Info', children: [
style: Theme.of(context).textTheme.titleMedium?.copyWith( Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.star,
color: theme.colorScheme.primary,
size: 24,
),
),
const SizedBox(width: 12),
Text(
'Подписка',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
),
],
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.all(16.0),
child: user.subscription == true
? Row(
children: [
Icon(
Icons.check_circle,
color: Colors.green,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Premium активна',
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'У вас есть доступ ко всем функциям',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
],
)
: Row(
children: [
Icon(
Icons.info_outline,
color: theme.colorScheme.onSurfaceVariant,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Бесплатная версия',
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
'Обновитесь до Premium для доступа ко всем функциям',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
],
),
);
}
Widget _buildDataPrivacySection(BuildContext context) {
final theme = Theme.of(context);
return Card(
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(20.0),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.security,
color: theme.colorScheme.primary,
size: 24,
),
),
const SizedBox(width: 12),
Text(
'Данные и приватность',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.download),
title: const Text('Экспорт данных'),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () {
// TODO: Implement data export
},
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Удалить аккаунт'),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () {
// TODO: Implement account deletion
},
),
],
),
);
}
Widget _buildAboutSection(BuildContext context) {
final theme = Theme.of(context);
return Card(
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(20.0),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.info,
color: theme.colorScheme.primary,
size: 24,
),
),
const SizedBox(width: 12),
Text(
'О приложении',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.description),
title: const Text('Версия'),
trailing: const Text('1.0.0'),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.privacy_tip),
title: const Text('Политика конфиденциальности'),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () {
// TODO: Open privacy policy
},
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.description),
title: const Text('Условия использования'),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () {
// TODO: Open terms of service
},
),
],
),
);
}
Widget _buildAccountInfoCard(BuildContext context, UserDto user) {
final theme = Theme.of(context);
return Card(
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(20.0),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.account_circle,
color: theme.colorScheme.primary,
size: 24,
),
),
const SizedBox(width: 12),
Text(
'Информация об аккаунте',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
), ),
), ),
const Divider(height: 1), const Divider(height: 1),
_StatRow( _StatRow(
icon: Icons.collections_bookmark, icon: Icons.collections_bookmark,
label: 'Packs', label: 'Паки',
value: user.packs.length.toString(), value: user.packs.length.toString(),
color: Colors.blue,
), ),
const Divider(height: 1), const Divider(height: 1),
_StatRow( _StatRow(
icon: Icons.shopping_bag, icon: Icons.shopping_bag,
label: 'Purchases', label: 'Покупки',
value: user.purchases.length.toString(), value: user.purchases.length.toString(),
color: Colors.green,
), ),
], ],
), ),
@ -355,6 +612,8 @@ class _ProfilePageState extends State<ProfilePage> {
} }
Widget _buildSettingsSection(BuildContext context) { Widget _buildSettingsSection(BuildContext context) {
final theme = Theme.of(context);
return ScopeBuilder<AppScopeContainer>( return ScopeBuilder<AppScopeContainer>(
builder: (context, appScope) { builder: (context, appScope) {
if (appScope == null) { if (appScope == null) {
@ -367,17 +626,37 @@ class _ProfilePageState extends State<ProfilePage> {
final isDark = themeState.mode == ThemeMode.dark; final isDark = themeState.mode == ThemeMode.dark;
return Card( return Card(
elevation: 2, elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(20.0),
child: Text( child: Row(
'Settings', children: [
style: Theme.of(context).textTheme.titleMedium?.copyWith( Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.settings,
color: theme.colorScheme.primary,
size: 24,
),
),
const SizedBox(width: 12),
Text(
'Настройки',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
),
],
), ),
), ),
const Divider(height: 1), const Divider(height: 1),
@ -417,6 +696,8 @@ class _ProfilePageState extends State<ProfilePage> {
} }
Widget _buildLogoutButton(BuildContext context) { Widget _buildLogoutButton(BuildContext context) {
final theme = Theme.of(context);
return SizedBox( return SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton.icon( child: ElevatedButton.icon(
@ -429,10 +710,20 @@ class _ProfilePageState extends State<ProfilePage> {
) )
: const Icon(Icons.logout), : const Icon(Icons.logout),
label: Text( label: Text(
_isLoggingOut ? 'Logging out...' : 'Logout', _isLoggingOut ? 'Выход...' : 'Выйти',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
), ),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 2,
backgroundColor: theme.colorScheme.errorContainer,
foregroundColor: theme.colorScheme.onErrorContainer,
), ),
), ),
); );
@ -445,31 +736,53 @@ class _StatRow extends StatelessWidget {
required this.icon, required this.icon,
required this.label, required this.label,
required this.value, required this.value,
this.color,
}); });
final IconData icon; final IconData icon;
final String label; final String label;
final String value; final String value;
final Color? color;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final iconColor = color ?? theme.colorScheme.primary;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0), padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 16.0),
child: Row( child: Row(
children: [ children: [
Icon(icon, size: 24), Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: iconColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 22, color: iconColor),
),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Text( child: Text(
label, label,
style: const TextStyle(fontSize: 16), style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
), ),
), ),
Text( Container(
value, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
style: const TextStyle( decoration: BoxDecoration(
fontSize: 16, color: iconColor.withOpacity(0.1),
fontWeight: FontWeight.bold, borderRadius: BorderRadius.circular(12),
),
child: Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: iconColor,
),
), ),
), ),
], ],

View file

@ -32,6 +32,7 @@ class StatisticsPage extends StatefulWidget {
class _StatisticsPageState extends State<StatisticsPage> with TickerProviderStateMixin { class _StatisticsPageState extends State<StatisticsPage> with TickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
bool _hasLoaded = false;
@override @override
void initState() { void initState() {
@ -116,6 +117,18 @@ class _StatisticsPageState extends State<StatisticsPage> with TickerProviderStat
return const Center(child: Text('User scope not available')); return const Center(child: Text('User scope not available'));
} }
// Загружаем статистику при первом отображении страницы
if (!_hasLoaded) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
userScope.statisticsStateManager.loadStatistics();
setState(() {
_hasLoaded = true;
});
}
});
}
return StateBuilder<StatisticsState>( return StateBuilder<StatisticsState>(
stateReadable: userScope.statisticsStateManager, stateReadable: userScope.statisticsStateManager,
builder: (context, state, _) { builder: (context, state, _) {

View file

@ -1,8 +1,11 @@
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:mnemo_cards_common/mnemo_cards_common.dart'; import 'package:mnemo_cards_common/mnemo_cards_common.dart';
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
import '../../di/user_scope/user_scope.dart';
import '../../domain/config/api_config_v2.dart'; import '../../domain/config/api_config_v2.dart';
import '../../presentation/theme/app_colors.dart'; import '../../presentation/theme/app_colors.dart';
import 'mnemo_text.dart'; import 'mnemo_text.dart';
@ -35,17 +38,24 @@ class _CardViewerState extends State<CardViewer> {
late PageController _pageController; late PageController _pageController;
late int _currentIndex; late int _currentIndex;
final Map<int, bool> _flippedCards = {}; final Map<int, bool> _flippedCards = {};
final FocusNode _focusNode = FocusNode();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_currentIndex = widget.initialIndex; _currentIndex = widget.initialIndex;
_pageController = PageController(initialPage: widget.initialIndex); _pageController = PageController(initialPage: widget.initialIndex);
// Устанавливаем фокус для обработки клавиатуры
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
} }
@override @override
void dispose() { void dispose() {
_pageController.dispose(); _pageController.dispose();
_focusNode.dispose();
super.dispose(); super.dispose();
} }
@ -57,95 +67,213 @@ class _CardViewerState extends State<CardViewer> {
}); });
} }
void _goToPrevious() {
if (widget.cards.isEmpty) return;
final newIndex = _currentIndex == 0
? widget.cards.length - 1 // Циклический переход: с первой на последнюю
: _currentIndex - 1;
_pageController.animateToPage(
newIndex,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
void _goToNext() {
if (widget.cards.isEmpty) return;
final newIndex = _currentIndex == widget.cards.length - 1
? 0 // Циклический переход: с последней на первую
: _currentIndex + 1;
_pageController.animateToPage(
newIndex,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
bool _handleKeyEvent(KeyEvent event) {
if (event is KeyDownEvent) {
if (event.logicalKey == LogicalKeyboardKey.space) {
_goToNext();
return true;
} else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
_goToPrevious();
return true;
} else if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
_goToNext();
return true;
}
}
return false;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final packColor = widget.packColor ?? AppColors.borderGray; final packColor = widget.packColor ?? AppColors.borderGray;
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
return Scaffold( return KeyboardListener(
backgroundColor: theme.scaffoldBackgroundColor, focusNode: _focusNode,
body: SafeArea( onKeyEvent: _handleKeyEvent,
child: Stack( child: Scaffold(
children: [ backgroundColor: theme.scaffoldBackgroundColor,
// PageView с карточками body: SafeArea(
PageView.builder( child: Stack(
controller: _pageController, children: [
itemCount: widget.cards.length, // PageView с карточками
onPageChanged: (index) { PageView.builder(
setState(() { controller: _pageController,
_currentIndex = index; itemCount: widget.cards.length,
}); onPageChanged: (index) {
}, setState(() {
itemBuilder: (context, index) { _currentIndex = index;
return AnimatedBuilder( });
animation: _pageController, // Обновляем фокус для обработки клавиатуры
builder: (context, child) { _focusNode.requestFocus();
double value = 1.0; },
if (_pageController.position.haveDimensions) { itemBuilder: (context, index) {
value = (_pageController.page ?? 0) - index; return AnimatedBuilder(
value = (1 - (value.abs() * 0.3)).clamp(0.7, 1.0); animation: _pageController,
builder: (context, child) {
double value = 1.0;
if (_pageController.position.haveDimensions) {
value = (_pageController.page ?? 0) - index;
value = (1 - (value.abs() * 0.3)).clamp(0.7, 1.0);
}
return Center(
child: Transform.scale(
scale: value,
child: _buildCard(
widget.cards[index],
index,
packColor,
),
),
);
},
);
},
),
// Кнопка закрытия
Positioned(
top: 16,
left: 16,
child: IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface.withOpacity(0.7),
shape: BoxShape.circle,
),
child: Icon(
Icons.close,
color: colorScheme.onSurface,
),
),
),
),
// Кнопка избранного
Positioned(
top: 16,
right: 80,
child: ScopeBuilder<UserScope>(
builder: (context, userScope) {
if (userScope == null) {
return const SizedBox.shrink();
} }
return Center( if (widget.cards.isEmpty) {
child: Transform.scale( return const SizedBox.shrink();
scale: value, }
child: _buildCard(
widget.cards[index], final currentCard = widget.cards[_currentIndex];
index, final isFavorite = userScope.favoritesStateManager.isFavorite(currentCard.id);
packColor,
return IconButton(
onPressed: () async {
await userScope.favoritesStateManager.toggleFavorite(currentCard.id);
if (mounted) {
setState(() {});
}
},
icon: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface.withOpacity(0.9),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border,
color: isFavorite ? Colors.red : colorScheme.onSurface,
size: 24,
), ),
), ),
); );
}, },
); ),
}, ),
),
// Индикатор текущей карточки
// Кнопка закрытия Positioned(
Positioned( top: 16,
top: 16, right: 16,
left: 16, child: Container(
child: IconButton( padding: const EdgeInsets.symmetric(
onPressed: () => Navigator.of(context).pop(), horizontal: 16,
icon: Container( vertical: 8,
padding: const EdgeInsets.all(8), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surface.withOpacity(0.7), color: colorScheme.surface.withOpacity(0.7),
shape: BoxShape.circle, borderRadius: BorderRadius.circular(20),
), ),
child: Icon( child: Text(
Icons.close, '${_currentIndex + 1} / ${widget.cards.length}',
color: colorScheme.onSurface, style: TextStyle(
color: colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
), ),
), ),
), ),
),
// Кнопки навигации
// Индикатор текущей карточки Positioned(
Positioned( bottom: 40,
top: 16, left: 20,
right: 16, child: _buildNavigationButton(
child: Container( icon: Icons.arrow_back,
padding: const EdgeInsets.symmetric( onPressed: _goToPrevious,
horizontal: 16, colorScheme: colorScheme,
vertical: 8,
),
decoration: BoxDecoration(
color: colorScheme.surface.withOpacity(0.7),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'${_currentIndex + 1} / ${widget.cards.length}',
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
), ),
), ),
), Positioned(
], bottom: 40,
right: 20,
child: _buildNavigationButton(
icon: Icons.arrow_forward,
onPressed: _goToNext,
colorScheme: colorScheme,
),
),
],
),
), ),
), ),
); );
@ -211,6 +339,33 @@ class _CardViewerState extends State<CardViewer> {
isFront: false, isFront: false,
); );
} }
Widget _buildNavigationButton({
required IconData icon,
required VoidCallback onPressed,
required ColorScheme colorScheme,
}) {
return Container(
decoration: BoxDecoration(
color: colorScheme.surface.withOpacity(0.9),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withOpacity(0.2),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: IconButton(
onPressed: onPressed,
icon: Icon(icon),
iconSize: 28,
color: colorScheme.onSurface,
padding: const EdgeInsets.all(12),
),
);
}
} }
class _CardSide extends StatelessWidget { class _CardSide extends StatelessWidget {

View file

@ -25,12 +25,8 @@ class _MainShellState extends State<MainShell> {
case 0: case 0:
context.go('/home'); context.go('/home');
case 1: case 1:
context.go('/games');
case 2:
context.go('/tasks');
case 3:
context.go('/statistics'); context.go('/statistics');
case 4: case 2:
context.go('/profile'); context.go('/profile');
} }
} }
@ -40,39 +36,38 @@ class _MainShellState extends State<MainShell> {
final location = GoRouterState.of(context).uri.path; final location = GoRouterState.of(context).uri.path;
if (location.startsWith('/home')) { if (location.startsWith('/home')) {
_selectedIndex = 0; _selectedIndex = 0;
} else if (location.startsWith('/games')) {
_selectedIndex = 1;
} else if (location.startsWith('/tasks')) {
_selectedIndex = 2;
} else if (location.startsWith('/statistics')) { } else if (location.startsWith('/statistics')) {
_selectedIndex = 3; _selectedIndex = 1;
} else if (location.startsWith('/profile')) { } else if (location.startsWith('/profile')) {
_selectedIndex = 4; _selectedIndex = 2;
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
_updateSelectedIndex(context); _updateSelectedIndex(context);
final theme = Theme.of(context);
return Scaffold( return Scaffold(
body: widget.child, body: widget.child,
bottomNavigationBar: BottomNavigationBar( bottomNavigationBar: BottomNavigationBar(
currentIndex: _selectedIndex, currentIndex: _selectedIndex,
onTap: _onItemTapped, onTap: _onItemTapped,
type: BottomNavigationBarType.fixed,
selectedItemColor: theme.colorScheme.primary,
unselectedItemColor: theme.colorScheme.onSurfaceVariant,
backgroundColor: theme.colorScheme.surface,
selectedLabelStyle: const TextStyle(
fontWeight: FontWeight.w600,
),
unselectedLabelStyle: const TextStyle(
fontWeight: FontWeight.w500,
),
items: const [ items: const [
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.home), icon: Icon(Icons.home),
label: 'Темы', label: 'Темы',
), ),
BottomNavigationBarItem(
icon: Icon(Icons.games),
label: 'Игры',
),
BottomNavigationBarItem(
icon: Icon(Icons.assignment),
label: 'Задания',
),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.analytics_outlined), icon: Icon(Icons.analytics_outlined),
label: 'Статистика', label: 'Статистика',

View file

@ -172,20 +172,9 @@ class PackCardVertical extends StatelessWidget {
_ => Alignment.topRight, _ => Alignment.topRight,
}; };
final borderRadius = switch (tip.position) { final borderRadius = const BorderRadius.all(
PackTipPosition.topRight => const BorderRadius.only( Radius.circular(12.0),
topRight: Radius.circular(12.0), );
bottomLeft: Radius.circular(12.0),
),
PackTipPosition.bottomRight => const BorderRadius.only(
bottomRight: Radius.circular(12.0),
topLeft: Radius.circular(12.0),
),
_ => const BorderRadius.only(
topRight: Radius.circular(12.0),
bottomLeft: Radius.circular(12.0),
),
};
return Positioned.fill( return Positioned.fill(
child: Align( child: Align(