/
siblun
/
ReadFlow
Обзор
Документация
Войти
/
siblun
/
ReadFlow
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
develop
lib/presentation/widgets/end_session_bottom_sheet.dart
654 строки
22 KB
siblun
Add docs
21 апр 2026, 16:13
21 апр 2026, 16:13
c40d018
Код
Авторство
О чём код?
import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:go_router/go_router.dart'; import 'package:read_flow/presentation/widgets/note_editor_widget.dart'; import '../../domain/models/note_model.dart'; import '../theme/app_colors.dart'; import '../theme/app_typography.dart'; /// Модальное окно завершения сессии чтения. /// /// Технические особенности: /// - **Динамический расчет**: В реальном времени вычисляет количество прочитанных страниц /// и скорость (стр/час) на основе ввода пользователя через [TextEditingController]. /// - **UX-анимации**: Использует [AnimationController] для эффекта "тряски" (shake animation) /// при вводе некорректного номера страницы, сопровождая это [HapticFeedback]. /// - **Валидация**: Предотвращает сохранение страниц вне диапазона (меньше текущей или больше общей) /// и выдает предупреждение для аномально коротких сессий (< 2 мин). /// - **Композиция**: Интегрирует [NoteEditorWidget] для бесшовного создания заметки прямо в момент закрытия сессии. class EndSessionBottomSheet extends StatefulWidget { final Duration sessionDuration; final int currentPage; final int totalPages; final int? lastPage; final String bookId; final int? pagesReadInLastSession; final Function(int endPage, String? chapter, Note? note) onSave; final VoidCallback onDelete; const EndSessionBottomSheet({ super.key, required this.sessionDuration, required this.currentPage, required this.totalPages, required this.bookId, this.pagesReadInLastSession, this.lastPage, required this.onSave, required this.onDelete, }); @override State<EndSessionBottomSheet> createState() => _EndSessionBottomSheetState(); } class _EndSessionBottomSheetState extends State<EndSessionBottomSheet> with SingleTickerProviderStateMixin{ late TextEditingController _pageController; late TextEditingController _chapterController; late AnimationController _errorAnimationController; NoteEditorData? _noteData; bool _isSaving = false; bool _showPageError = false; String _pageErrorMessage = ''; static const minSessionDuration = Duration(minutes: 2); int _currentSpeed = 0; int _pagesRead = 0; @override void initState() { super.initState(); _pageController = TextEditingController(text: ''); _chapterController = TextEditingController(); _errorAnimationController = AnimationController( duration: const Duration(milliseconds: 300), vsync: this, ); _pageController.addListener(_validatePage); _pageController.addListener(_updateSpeed); _updateSpeed(); } void _validatePage() { final pageText = _pageController.text.trim(); setState(() { if (_showPageError && pageText.isNotEmpty) { _showPageError = false; _pageErrorMessage = ''; } }); } @override void dispose() { _errorAnimationController.dispose(); _pageController.removeListener(_validatePage); _pageController.removeListener(_updateSpeed); _pageController.dispose(); _chapterController.dispose(); super.dispose(); } void _triggerPageError(String message) { setState(() { _showPageError = true; _pageErrorMessage = message; }); _errorAnimationController.forward(from: 0); HapticFeedback.mediumImpact(); } void _updateSpeed() { final endPage = int.tryParse(_pageController.text.trim()) ?? widget.currentPage; final pagesRead = endPage - (widget.lastPage ?? 0); final seconds = widget.sessionDuration.inSeconds; setState(() { _pagesRead = pagesRead; _currentSpeed = seconds > 0 && pagesRead > 0 ? (pagesRead / seconds * 3600).round() : 0; }); } @override Widget build(BuildContext context) { return Container( constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * 0.9, ), decoration: ShapeDecoration( color: AppColors.background, shape: RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(28.r)), ), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildHandle(), Flexible( child: SingleChildScrollView( physics: const BouncingScrollPhysics(), child: Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom + 24.h, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ _buildStatsHeader(), SizedBox(height: 32.h), _buildPageSection(), SizedBox(height: 32.h), NoteEditorWidget( config: const NoteEditorConfig( hintText: 'Что интересного произошло?', showLocationField: true, showQuoteToggle: true, editorHeight: 160, ), onChanged: (data) => _noteData = data, ), SizedBox(height: 32.h), _buildActions(), ], ), ), ), ), if (MediaQuery.of(context).viewInsets.bottom == 0) SizedBox(height: MediaQuery.of(context).padding.bottom + 16.h), ], ), ); } Widget _buildHandle() { return Container( width: double.infinity, padding: EdgeInsets.only(top: 12.h, bottom: 8.h), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: 32.w, height: 4.h, decoration: ShapeDecoration( color: AppColors.accentSecondary.withValues(alpha: 0.4), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(9999), ), ), ), ], ), ); } Widget _buildStatsHeader() { return Container( padding: EdgeInsets.symmetric(horizontal: 24.w), child: Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ Icon(Icons.access_time_rounded, size: 16.w, color: AppColors.accentSecondary), SizedBox(width: 8.w), Text( 'Время: ${_formatDuration(widget.sessionDuration)}', style: AppTypography.label.copyWith( fontSize: 13.sp, color: AppColors.accentSecondary, fontWeight: FontWeight.w500, ), ), ], ), Row( children: [ Icon(Icons.speed_rounded, size: 16.w, color: AppColors.accentSecondary), SizedBox(width: 8.w), AnimatedSwitcher( duration: Duration(milliseconds: 300), child: Text( _pagesRead > 0 ? '~$_currentSpeed стр/час' : '—', key: ValueKey(_currentSpeed), style: AppTypography.label.copyWith( fontSize: 13.sp, color: AppColors.accentSecondary, fontWeight: FontWeight.w500, ), ), ), ], ), ], ), ], ), ); } String _formatDuration(Duration duration) { final hours = duration.inHours; final minutes = duration.inMinutes.remainder(60); if (hours > 0) return '$hoursч $minutesм'; return '$minutesм ${duration.inSeconds.remainder(60)}с'; } Widget _buildPageSection() { return Container( padding: EdgeInsets.symmetric(horizontal: 24.w), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Center( child: Text( 'На какой странице вы остановились?', textAlign: TextAlign.center, style: AppTypography.h3.copyWith( fontSize: 18.sp, fontWeight: FontWeight.w600, color: AppColors.primaryAction, ), ), ), SizedBox(height: 24.h), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: _buildNumberInput( label: 'СТРАНИЦА', controller: _pageController, hintText: '${widget.currentPage}', isRequired: true, showError: _showPageError, errorMessage: _pageErrorMessage, ), ), SizedBox(width: 12.w), Expanded( child: _buildNumberInput( label: 'ГЛАВА / РАЗДЕЛ', controller: _chapterController, hintText: '12', isRequired: false, reserveSpace: _showPageError, ), ), ], ), SizedBox(height: 12.h), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( 'За сессию: $_pagesRead стр.', style: AppTypography.bodyMedium.copyWith( fontSize: 14.sp, color: _pagesRead > 0 ? AppColors.primaryAction : AppColors.accentSecondary, ), ), if (_pagesRead > 0) ...[ SizedBox(width: 16.w), Text( '~$_currentSpeed стр/час', style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w500, color: AppColors.primaryAction, ), ), ], ], ), if (widget.pagesReadInLastSession != null) ...[ SizedBox(height: 8.h), Center( child: Text( 'В прошлый раз: ${widget.pagesReadInLastSession} стр.', style: AppTypography.bodyMedium.copyWith( fontSize: 14.sp, color: _pagesRead > 0 ? AppColors.primaryAction : AppColors.accentSecondary, ), ), ), ], ], ), ); } Widget _buildNumberInput({ required String label, required TextEditingController controller, required String hintText, bool isRequired = false, bool showError = false, String errorMessage = '', bool reserveSpace = false, }) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: EdgeInsets.only(left: 16.w), child: Row( children: [ Text( label, style: AppTypography.overline.copyWith( fontSize: 11.sp, color: showError ? AppColors.error : AppColors.accentSecondary, ), ), ], ), ), SizedBox(height: 8.h), AnimatedBuilder( animation: _errorAnimationController, builder: (context, child) { final double offset = showError ? (sin(_errorAnimationController.value * pi * 4) * 6) : 0; return Transform.translate( offset: Offset(offset, 0), child: child, ); }, child: AnimatedContainer( duration: const Duration(milliseconds: 200), padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 20.h), decoration: ShapeDecoration( color: AppColors.surface, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16.r), side: showError ? BorderSide(color: AppColors.error, width: 2.w) : BorderSide.none, ), ), child: TextField( controller: controller, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], textAlign: TextAlign.center, style: AppTypography.pageInput.copyWith( fontSize: 30.sp, color: showError ? AppColors.error : AppColors.primaryAction, ), decoration: InputDecoration( border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, errorBorder: InputBorder.none, focusedErrorBorder: InputBorder.none, hintText: hintText, hintStyle: AppTypography.pageInput.copyWith( fontSize: 30.sp, color: AppColors.primaryAction.withValues(alpha: 0.5), ), contentPadding: EdgeInsets.zero, isCollapsed: true, ), ), ), ), SizedBox( height: 16.h, child: Padding( padding: EdgeInsets.only(left: 16.w, top: 4.h), child: Text( isRequired ? 'Текущая: ${widget.currentPage}' : 'Необязательно', style: AppTypography.label.copyWith( fontSize: 10.sp, color: showError ? AppColors.error.withValues(alpha: 0.5) : AppColors.accentSecondary.withValues(alpha: 0.7), ), ), ), ), SizedBox( height: 18.h, child: (showError && errorMessage.isNotEmpty) ? Padding( padding: EdgeInsets.only(left: 16.w, top: 4.h), child: Row( children: [ Icon(Icons.error_outline_rounded, size: 14.w, color: AppColors.error), SizedBox(width: 4.w), Expanded( child: Text( errorMessage, style: AppTypography.label.copyWith( fontSize: 10.sp, color: AppColors.error, ), ), ), ], ), ) : const SizedBox(), ), ], ); } Widget _buildActions() { return Container( padding: EdgeInsets.symmetric(horizontal: 24.w), child: Column( children: [ GestureDetector( onTap: _isSaving ? null : _saveSession, child: Container( width: double.infinity, padding: EdgeInsets.symmetric(vertical: 20.h), decoration: ShapeDecoration( color: AppColors.primaryAction, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(9999), ), shadows: [ BoxShadow( color: AppColors.primaryAction.withValues(alpha: 0.2), blurRadius: 6.r, offset: Offset(0, 4.h), spreadRadius: -4.r, ), BoxShadow( color: AppColors.primaryAction.withValues(alpha: 0.2), blurRadius: 15.r, offset: Offset(0, 10.h), spreadRadius: -3.r, ), ], ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ if (_isSaving) SizedBox( width: 20.w, height: 20.h, child: CircularProgressIndicator( valueColor: AlwaysStoppedAnimation<Color>(AppColors.background), ), ) else Icon(Icons.check_circle_rounded, color: AppColors.background, size: 20.w), SizedBox(width: 12.w), Text( 'Сохранить и выйти', style: AppTypography.buttonText.copyWith(fontSize: 16.sp), ), ], ), ), ), SizedBox(height: 12.h), Material( color: Colors.transparent, child: InkWell( onTap: () => _showDeleteConfirmation(), borderRadius: BorderRadius.circular(12.r), child: Padding( padding: EdgeInsets.symmetric(vertical: 12.h, horizontal: 16.w), child: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.delete_outline_rounded, size: 18.w, color: AppColors.error.withValues(alpha: 0.8), ), SizedBox(width: 8.w), Text( 'Удалить сессию', style: AppTypography.buttonText.copyWith( fontSize: 14.sp, color: AppColors.error.withValues(alpha: 0.8), ), ), ], ), ), ), ), ], ), ); } Future<void> _saveSession() async { if (_isSaving) return; final pageText = _pageController.text.trim(); if (pageText.isEmpty) { _triggerPageError(''); return; } final endPage = int.tryParse(pageText); if (endPage == null) { _triggerPageError('Только числа'); return; } if (endPage < 1 || endPage > widget.totalPages) { _triggerPageError('От 1 до ${widget.totalPages}'); return; } if (endPage < widget.currentPage){ _triggerPageError(''); return; } if (widget.sessionDuration < minSessionDuration) { final confirmed = await _showShortSessionWarning(); if (!confirmed) return; } final chapter = _chapterController.text.trim().isEmpty ? null : _chapterController.text.trim(); Note? note; if (_noteData != null && _noteData!.text.trim().isNotEmpty) { note = _noteData!.toNote(widget.bookId); } setState(() => _isSaving = true); await Future.delayed(const Duration(milliseconds: 100)); if (mounted) { widget.onSave(endPage, chapter, note); context.pop(); context.pop(); } } Future<bool> _showShortSessionWarning() async { final result = await showDialog<bool>( context: context, builder: (ctx) => AlertDialog( backgroundColor: AppColors.cardBackground, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16.r)), title: Text('Сессия слишком короткая', style: AppTypography.h3.copyWith(fontSize: 18.sp)), content: Text( 'Длительность: ${_formatDuration(widget.sessionDuration)}\n\n' 'Сохранить такую короткую сессию?', style: AppTypography.bodyMedium, ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: Text('Отмена'), ), ElevatedButton( onPressed: () => Navigator.pop(ctx, true), style: ElevatedButton.styleFrom( backgroundColor: AppColors.primaryAction, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.r)), ), child: Text('Да, сохранить', style: TextStyle(color: AppColors.background)), ), ], ), ); return result ?? false; } void _showDeleteConfirmation() { showDialog( context: context, builder: (ctx) => AlertDialog( title: Text('Удалить сессию?', style: AppTypography.h3.copyWith(fontSize: 18.sp)), content: Text('Время и прогресс этой сессии не сохранятся.', style: AppTypography.bodyMedium), actions: [ TextButton( onPressed: () => ctx.pop(), child: Text('Нет', style: TextStyle(color: AppColors.accentSecondary)), ), ElevatedButton( onPressed: () { ctx.pop(); context.pop(); widget.onDelete(); }, style: ElevatedButton.styleFrom( backgroundColor: AppColors.error, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.r)), ), child: Text('Да, удалить', style: TextStyle(color: AppColors.background)), ), ], ), ); } }