From 31748883098696f59faedff6c7e66fd193ce2b07 Mon Sep 17 00:00:00 2001 From: Sir Date: Tue, 10 Jun 2025 00:06:32 +0700 Subject: [PATCH 1/4] feat: change flashcard page background to match app theme --- .../pages/home/flashcard_page.dart | 166 ++++++++++-------- pubspec.lock | 16 +- 2 files changed, 99 insertions(+), 83 deletions(-) diff --git a/lib/presentation/pages/home/flashcard_page.dart b/lib/presentation/pages/home/flashcard_page.dart index c13992b..102a224 100644 --- a/lib/presentation/pages/home/flashcard_page.dart +++ b/lib/presentation/pages/home/flashcard_page.dart @@ -51,84 +51,100 @@ class _FlashcardPageState extends State { @override Widget build(BuildContext context) { - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) async { - if (didPop) return; - context.go('/'); - }, - child: Scaffold( - backgroundColor: CustomTheme.lightbeige, - body: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildAppBar(context), - const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: _buildSearchBar(), - ), - BlocBuilder( - builder: (context, state) { - if (state.status == FlashcardStatus.loading) { - return const Padding( - padding: EdgeInsets.only(top: 16.0), - child: Center(child: CircularProgressIndicator()), - ); - } else if (state.status == FlashcardStatus.failure) { - return Center( - child: Text( - 'Error: ${state.errorMessage ?? 'Unknown error'}', - ), - ); - } else if (state.status == FlashcardStatus.success) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!state.searchResult) - SizedBox( - height: 30, - child: Center( - child: Text( - 'No result', - style: TextStyle( - fontSize: 18, - color: Colors.grey.shade600, + return Container( + width: MediaQuery.of(context).size.width, + height: MediaQuery.of(context).size.height, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + CustomTheme.loginGradientStart, + CustomTheme.loginGradientEnd, + ], + begin: FractionalOffset(0.5, 0.0), + end: FractionalOffset(0.5, 1.0), + stops: [0.0, 1.0], + tileMode: TileMode.clamp, + ), + ), + child: PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + context.go('/'); + }, + child: Scaffold( + backgroundColor: Colors.transparent, + body: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildAppBar(context), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: _buildSearchBar(), + ), + BlocBuilder( + builder: (context, state) { + if (state.status == FlashcardStatus.loading) { + return const Padding( + padding: EdgeInsets.only(top: 16.0), + child: Center(child: CircularProgressIndicator()), + ); + } else if (state.status == FlashcardStatus.failure) { + return Center( + child: Text( + 'Error: ${state.errorMessage ?? 'Unknown error'}', + ), + ); + } else if (state.status == FlashcardStatus.success) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!state.searchResult) + SizedBox( + height: 30, + child: Center( + child: Text( + 'No result', + style: TextStyle( + fontSize: 18, + color: Colors.grey.shade600, + ), ), ), ), - ), - if (state.groupedDecks != null && - state.groupedDecks!.data.isNotEmpty) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: - state.groupedDecks!.data.map((group) { - return group.decks.isNotEmpty - ? FlashcardTag( - key: ValueKey(group.tag.id), - tagId: group.tag.id, - title: group.tag.name, - decks: group.decks, - ) - : const SizedBox.shrink(); - }).toList(), - ) - else if (state.groupedDecks == null) - const Padding( - padding: EdgeInsets.only(left: 16.0, top: 8.0), - child: Text('No decks available'), - ), - ], - ); - } else { - return const SizedBox.shrink(); - } - }, - ), - const SizedBox(height: 20), - ], + if (state.groupedDecks != null && + state.groupedDecks!.data.isNotEmpty) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: + state.groupedDecks!.data.map((group) { + return group.decks.isNotEmpty + ? FlashcardTag( + key: ValueKey(group.tag.id), + tagId: group.tag.id, + title: group.tag.name, + decks: group.decks, + ) + : const SizedBox.shrink(); + }).toList(), + ) + else if (state.groupedDecks == null) + const Padding( + padding: EdgeInsets.only(left: 16.0, top: 8.0), + child: Text('No decks available'), + ), + ], + ); + } else { + return const SizedBox.shrink(); + } + }, + ), + const SizedBox(height: 20), + ], + ), ), ), ), diff --git a/pubspec.lock b/pubspec.lock index e097c26..0da8462 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -58,10 +58,10 @@ packages: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.12.0" bloc: dependency: transitive description: @@ -250,10 +250,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" url: "https://pub.dev" source: hosted - version: "1.3.3" + version: "1.3.2" ffi: dependency: transitive description: @@ -689,10 +689,10 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" + sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec url: "https://pub.dev" source: hosted - version: "10.0.9" + version: "10.0.8" leak_tracker_flutter_testing: dependency: transitive description: @@ -1134,10 +1134,10 @@ packages: dependency: transitive description: name: vm_service - sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" url: "https://pub.dev" source: hosted - version: "15.0.0" + version: "14.3.1" watcher: dependency: transitive description: From 07fb82528c346d5ddbd2e9820abdd23c483d7e0a Mon Sep 17 00:00:00 2001 From: Sir Date: Tue, 10 Jun 2025 00:17:00 +0700 Subject: [PATCH 2/4] feat: blur effect for revise flashcard mode --- lib/config/router.dart | 34 ++- .../pages/home/revise_flashcard_page.dart | 258 +++++++++++++++++ .../pages/home/widgets/flashcard_options.dart | 2 +- .../pages/home/widgets/revise_card.dart | 264 ++++++++++++++++++ .../pages/home/widgets/revise_card_list.dart | 113 ++++++++ 5 files changed, 652 insertions(+), 19 deletions(-) create mode 100644 lib/presentation/pages/home/revise_flashcard_page.dart create mode 100644 lib/presentation/pages/home/widgets/revise_card.dart create mode 100644 lib/presentation/pages/home/widgets/revise_card_list.dart diff --git a/lib/config/router.dart b/lib/config/router.dart index 4a0ed6e..247c8ef 100644 --- a/lib/config/router.dart +++ b/lib/config/router.dart @@ -9,10 +9,10 @@ import 'package:lacquer/presentation/pages/auth/login_page.dart'; import 'package:lacquer/presentation/pages/auth/verify_page.dart'; import 'package:lacquer/presentation/pages/camera/camera_page.dart'; import 'package:lacquer/presentation/pages/camera/about_screen.dart'; -import 'package:lacquer/services/ai_service.dart'; import 'package:lacquer/presentation/pages/home/add_new_word_page.dart'; import 'package:lacquer/presentation/pages/home/edit_card_list_page.dart'; import 'package:lacquer/presentation/pages/home/dictionary_page.dart'; +import 'package:lacquer/presentation/pages/home/revise_flashcard_page.dart'; import 'package:lacquer/presentation/pages/home/quiz_page.dart'; import 'package:lacquer/presentation/pages/profile/profile_page.dart'; import 'package:lacquer/presentation/pages/home/flashcard_page.dart'; @@ -21,8 +21,6 @@ import 'package:lacquer/presentation/pages/friends/friends_page.dart'; import 'package:lacquer/features/profile/bloc/profile_bloc.dart'; import 'package:lacquer/features/profile/bloc/profile_event.dart'; import 'package:lacquer/presentation/pages/home/translator_page.dart'; -import 'package:lacquer/presentation/pages/chat/chat_screen.dart'; -import 'package:lacquer/presentation/pages/profile/badge_collection_page_simple.dart'; import 'package:lacquer/presentation/pages/mainscreen.dart'; import 'package:flutter/widgets.dart'; @@ -38,14 +36,13 @@ class RouteName { static const String profile = '/profile'; static const String flashcards = '/flashcards'; static String learn(String deckId) => '/learn/$deckId'; + static String revise(String deckId) => '/revise/$deckId'; static String edit(String deckId) => '/edit/$deckId'; static const String dictionary = '/dictionary'; static const String translator = '/translator'; static const String friends = '/friends'; static const String quiz = '/quiz'; static String addNewWord(String deckId) => '/add-new-word/$deckId'; - static const String chat = '/chat'; - static const String badges = '/badges'; static const publicRoutes = [login, forgotPassword, verify, register]; } @@ -103,10 +100,8 @@ final router = GoRouter( noTransitionRoute( path: RouteName.about, builder: (context, state) { - final extra = state.extra as Map; - final imagePath = extra['imagePath'] as String; - final aiResult = extra['aiResult'] as AIResult?; - return AboutScreen(imagePath: imagePath, aiResult: aiResult); + final imagePath = state.extra as String; + return AboutScreen(imagePath: imagePath); }, ), noTransitionRoute( @@ -136,6 +131,17 @@ final router = GoRouter( return LearningFlashcardPage(deckId: deckId); }, ), + noTransitionRoute( + path: '/revise/:deckId', + builder: (context, state) { + final deckId = state.pathParameters['deckId']!; + return ReviseFlashcardPage(deckId: deckId); + }, + ), + noTransitionRoute( + path: RouteName.translator, + builder: (context, state) => const TranslatorScreen(), + ), noTransitionRoute( path: RouteName.translator, builder: (context, state) => const TranslatorScreen(), @@ -154,13 +160,5 @@ final router = GoRouter( return AddNewWordPage(deckId: deckId); }, ), - noTransitionRoute( - path: RouteName.chat, - builder: (context, state) => const ChatScreen(), - ), - noTransitionRoute( - path: RouteName.badges, - builder: (context, state) => const BadgeCollectionPage(), - ), ], -); \ No newline at end of file +); diff --git a/lib/presentation/pages/home/revise_flashcard_page.dart b/lib/presentation/pages/home/revise_flashcard_page.dart new file mode 100644 index 0000000..1528cf0 --- /dev/null +++ b/lib/presentation/pages/home/revise_flashcard_page.dart @@ -0,0 +1,258 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lacquer/config/router.dart'; +import 'package:lacquer/config/theme.dart'; +import 'package:lacquer/features/flashcard/bloc/flashcard_bloc.dart'; +import 'package:lacquer/features/flashcard/bloc/flashcard_event.dart'; +import 'package:lacquer/features/flashcard/bloc/flashcard_state.dart'; +import 'package:lacquer/presentation/pages/home/widgets/revise_card_list.dart'; +import 'package:lacquer/presentation/pages/home/widgets/speech_adjustment.dart'; + +class ReviseFlashcardPage extends StatefulWidget { + final String deckId; + const ReviseFlashcardPage({super.key, required this.deckId}); + @override + State createState() => _ReviseFlashcardPageState(); +} + +class _ReviseFlashcardPageState extends State { + double _progress = 0.0; + double _speechRate = 0.5; + String _selectedAccent = 'en-US'; + @override + void initState() { + super.initState(); + context.read().add(LoadDeckByIdRequested(widget.deckId)); + } + + void _updateProgress(double progress) { + setState(() { + _progress = progress.clamp(0.0, 1.0); + }); + } + + void _showTtsSettings() async { + final result = await showDialog>( + context: context, + builder: + (context) => SpeechAdjustment( + initialSpeed: _speechRate, + initialAccent: + _accents.entries + .firstWhere( + (entry) => entry.value == _selectedAccent, + orElse: () => const MapEntry('US English', 'en-US'), + ) + .key, + ), + ); + if (result != null) { + setState(() { + _speechRate = result['speed'] as double; + _selectedAccent = result['accent'] as String; + }); + } + } + + static final Map _accents = { + 'US English': 'en-US', + 'UK English': 'en-GB', + 'Australian': 'en-AU', + 'Indian': 'en-IN', + }; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: CustomTheme.lightbeige, + body: BlocBuilder( + builder: (context, state) { + if (state.status == FlashcardStatus.loading) { + return const Center(child: CircularProgressIndicator()); + } else if (state.status == FlashcardStatus.failure) { + return Center( + child: Text('Error: ${state.errorMessage ?? 'Unknown error'}'), + ); + } else if (state.status == FlashcardStatus.success && + state.selectedDeck != null) { + final deck = state.selectedDeck!; + return Stack( + children: [ + _buildAppBar(context, deck.title), + Column( + children: [ + const SizedBox(height: 100), + if (deck.cards != null && deck.cards!.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 200, + child: ClipRRect( + borderRadius: BorderRadius.circular(4.0), + child: LinearProgressIndicator( + value: _progress, + backgroundColor: Colors.grey[300], + valueColor: AlwaysStoppedAnimation( + const Color.fromARGB(255, 104, 175, 106), + ), + minHeight: 12.0, + ), + ), + ), + const SizedBox(width: 12), + Text( + '${(_progress * 100).toStringAsFixed(0)}%', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ], + ), + ), + Expanded( + child: + (deck.cards == null || deck.cards!.isEmpty) + ? Center( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24.0, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.menu_book_outlined, + size: 80, + color: Colors.grey, + ), + const SizedBox(height: 16), + const Text( + 'No cards yet', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.black54, + ), + ), + const SizedBox(height: 8), + const Text( + 'Start by adding your first card to this deck.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: Colors.black45, + ), + ), + const SizedBox(height: 20), + ElevatedButton.icon( + onPressed: + () => context.go( + RouteName.addNewWord( + widget.deckId, + ), + ), + icon: const Icon( + Icons.add, + color: Colors.white, + ), + label: const Text('Add New Card'), + style: ElevatedButton.styleFrom( + backgroundColor: + CustomTheme.mainColor1, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 8, + ), + ), + ), + ), + ], + ), + ), + ) + : ReviseCardList( + deckId: widget.deckId, + cards: deck.cards!, + onScrollProgress: _updateProgress, + speechRate: _speechRate, + selectedAccent: _selectedAccent, + ), + ), + ], + ), + ], + ); + } + return const Center(child: Text('No deck data available')); + }, + ), + ); + } + + Widget _buildAppBar(BuildContext context, String? title) { + return ClipRRect( + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(8), + bottomRight: Radius.circular(8), + ), + child: Container( + height: 90, + color: CustomTheme.mainColor1, + padding: const EdgeInsets.only(top: 30), + child: Center( + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(width: 10), + IconButton( + icon: const Icon( + FontAwesomeIcons.arrowLeft, + color: Colors.white, + ), + onPressed: () { + context.go(RouteName.flashcards); + }, + ), + Text( + title ?? '', + style: const TextStyle( + fontSize: 24, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 15), + const Spacer(), + IconButton( + icon: const Icon(FontAwesomeIcons.gear, color: Colors.white), + onPressed: _showTtsSettings, + ), + IconButton( + icon: const Icon( + FontAwesomeIcons.ellipsisVertical, + color: Colors.white, + ), + onPressed: null, + ), + const SizedBox(width: 10), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/presentation/pages/home/widgets/flashcard_options.dart b/lib/presentation/pages/home/widgets/flashcard_options.dart index 30d42a9..ba6e36b 100644 --- a/lib/presentation/pages/home/widgets/flashcard_options.dart +++ b/lib/presentation/pages/home/widgets/flashcard_options.dart @@ -48,7 +48,7 @@ class _FlashcardOptionDialogState extends State { "title": "Revise", "subtitle": "", "action": () { - print("Revise clicked"); + context.go(RouteName.revise(widget.id)); }, }, { diff --git a/lib/presentation/pages/home/widgets/revise_card.dart b/lib/presentation/pages/home/widgets/revise_card.dart new file mode 100644 index 0000000..362e4d6 --- /dev/null +++ b/lib/presentation/pages/home/widgets/revise_card.dart @@ -0,0 +1,264 @@ +import 'dart:async'; +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter_tts/flutter_tts.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:lacquer/config/theme.dart'; +import 'package:lacquer/features/flashcard/dtos/card_dto.dart'; +import 'package:lacquer/presentation/utils/wave_clipper.dart'; + +class ReviseCard extends StatefulWidget { + final CardDto card; + final double speechRate; + final String selectedAccent; + + const ReviseCard({ + super.key, + required this.card, + required this.speechRate, + required this.selectedAccent, + }); + + @override + State createState() => _ReviseCardState(); +} + +class _ReviseCardState extends State { + late FlutterTts flutterTts; + bool _showMeaning = false; + int _countdown = 5; + Timer? _timer; + + @override + void initState() { + super.initState(); + + flutterTts = FlutterTts(); + _initializeTts(); + + _startCountdownTimer(); + } + + void _startCountdownTimer() { + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_countdown <= 1) { + timer.cancel(); + if (mounted) { + setState(() { + _countdown = 0; + _showMeaning = true; + }); + } + } else { + if (mounted) { + setState(() { + _countdown--; + }); + } + } + }); + } + + Future _initializeTts() async { + await flutterTts.setLanguage(widget.selectedAccent); + await flutterTts.setSpeechRate(widget.speechRate); + await flutterTts.setPitch(1.0); + } + + Future _speak(String text) async { + await flutterTts.setLanguage(widget.selectedAccent); + await flutterTts.setSpeechRate(widget.speechRate); + await flutterTts.setPitch(1.0); + await flutterTts.speak(text); + } + + @override + void dispose() { + flutterTts.stop(); + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + + return Center( + child: Padding( + padding: const EdgeInsets.only(top: 0), + child: Container( + width: size.width - 50, + height: size.height - 180, + decoration: BoxDecoration( + color: CustomTheme.flashcardColor, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: const Color.fromRGBO(0, 0, 0, 0.2), + blurRadius: 10, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + children: [ + _buildTop(card: widget.card, size: size), + _buildMeaning(card: widget.card), + ], + ), + ), + ), + ); + } + + Widget _buildTop({required CardDto card, required Size size}) { + return ClipPath( + clipper: WaveClipper(), + child: Container( + height: (size.height - 180) / 2, + width: double.infinity, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Stack( + children: [ + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + card.word ?? '', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 36, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + if ((card.pronunciation ?? '').isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + '[${card.pronunciation}]', + style: const TextStyle( + fontSize: 20, + fontStyle: FontStyle.italic, + color: Colors.grey, + ), + ), + ], + ], + ), + ), + Positioned( + bottom: 24, + right: 12, + child: IconButton( + onPressed: () { + if ((card.word ?? '').isNotEmpty) { + _speak(card.word!); + } + }, + icon: const Icon( + FontAwesomeIcons.volumeHigh, + color: Colors.grey, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildMeaning({required CardDto card}) { + final blurValue = _showMeaning ? 0.0 : 10.0; + + return Expanded( + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: CustomTheme.flashcardColor, + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(16), + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Column( + children: [ + // Meaning content centered + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (card.meaning?.type?.isNotEmpty ?? false) + ImageFiltered( + imageFilter: ImageFilter.blur( + sigmaX: blurValue, + sigmaY: blurValue, + ), + child: Text( + card.meaning!.type!, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + const SizedBox(height: 8), + if (card.meaning?.definition?.isNotEmpty ?? false) + ImageFiltered( + imageFilter: ImageFilter.blur( + sigmaX: blurValue, + sigmaY: blurValue, + ), + child: Text( + card.meaning!.definition!, + style: const TextStyle( + fontSize: 20, + color: Colors.white, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + + // Countdown & Show Button + Align( + alignment: Alignment.bottomCenter, + child: ElevatedButton.icon( + onPressed: + _showMeaning + ? null + : () { + setState(() { + _showMeaning = true; + _timer?.cancel(); + }); + }, + icon: const Icon(Icons.visibility, color: Colors.white), + label: Text( + _showMeaning ? 'Shown' : 'Show (${_countdown}s)', + style: const TextStyle(color: Colors.white), + ), + style: ElevatedButton.styleFrom( + backgroundColor: const Color.fromARGB(255, 141, 188, 24), + padding: const EdgeInsets.symmetric( + horizontal: 28, + vertical: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/pages/home/widgets/revise_card_list.dart b/lib/presentation/pages/home/widgets/revise_card_list.dart new file mode 100644 index 0000000..be4855e --- /dev/null +++ b/lib/presentation/pages/home/widgets/revise_card_list.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lacquer/config/router.dart'; +import 'package:lacquer/features/flashcard/dtos/card_dto.dart'; +import 'package:lacquer/presentation/pages/home/widgets/revise_card.dart'; + +class ReviseCardList extends StatefulWidget { + final String deckId; + final List cards; + final Function(double)? onScrollProgress; + final double speechRate; + final String selectedAccent; + + const ReviseCardList({ + super.key, + required this.deckId, + required this.cards, + this.onScrollProgress, + required this.speechRate, + required this.selectedAccent, + }); + + @override + State createState() => _ReviseCardListState(); +} + +class _ReviseCardListState extends State { + late final PageController _pageController; + int _highestPageReached = 0; + + @override + void initState() { + super.initState(); + _pageController = PageController(); + } + + void _onPageChanged(int index) { + if (index > _highestPageReached) { + _highestPageReached = index; + final maxPages = widget.cards.length; + final progress = maxPages > 0 ? index / maxPages : 0.0; + widget.onScrollProgress?.call(progress.clamp(0.0, 1.0)); + } + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final hasCards = widget.cards.isNotEmpty; + final totalPages = hasCards ? widget.cards.length + 1 : 0; + + return PageView.builder( + controller: _pageController, + itemCount: totalPages, + onPageChanged: _onPageChanged, + itemBuilder: (context, index) { + if (index < widget.cards.length) { + return ReviseCard( + card: widget.cards[index], + speechRate: widget.speechRate, + selectedAccent: widget.selectedAccent, + ); + } else { + return _buildCompletionCard(context, widget.deckId); + } + }, + ); + } +} + +Widget _buildCompletionCard(BuildContext context, String deckId) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.emoji_events, size: 80, color: Colors.amber), + const SizedBox(height: 24), + const Text( + '🎉 Congratulations!', + style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + const Text( + 'You have completed the deck.', + style: TextStyle(fontSize: 18), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + ElevatedButton.icon( + onPressed: () { + context.go(RouteName.flashcards); + }, + icon: const Icon(Icons.check), + label: const Text('Got it'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12), + backgroundColor: Colors.green, + textStyle: const TextStyle(fontSize: 16), + ), + ), + ], + ), + ), + ); +} From d3991f152abc5e08cdb031663b5febe0c6efa622 Mon Sep 17 00:00:00 2001 From: Sir Date: Tue, 10 Jun 2025 00:24:13 +0700 Subject: [PATCH 3/4] feat: add revision flashcard mode --- .../pages/home/revise_flashcard_page.dart | 12 +- .../pages/home/widgets/revise_card.dart | 2 - .../pages/home/widgets/revise_card_list.dart | 183 ++++++++++++++---- .../widgets/revise_instruction_dialog.dart | 90 +++++++++ 4 files changed, 249 insertions(+), 38 deletions(-) create mode 100644 lib/presentation/pages/home/widgets/revise_instruction_dialog.dart diff --git a/lib/presentation/pages/home/revise_flashcard_page.dart b/lib/presentation/pages/home/revise_flashcard_page.dart index 1528cf0..04fd49c 100644 --- a/lib/presentation/pages/home/revise_flashcard_page.dart +++ b/lib/presentation/pages/home/revise_flashcard_page.dart @@ -8,6 +8,7 @@ import 'package:lacquer/features/flashcard/bloc/flashcard_bloc.dart'; import 'package:lacquer/features/flashcard/bloc/flashcard_event.dart'; import 'package:lacquer/features/flashcard/bloc/flashcard_state.dart'; import 'package:lacquer/presentation/pages/home/widgets/revise_card_list.dart'; +import 'package:lacquer/presentation/pages/home/widgets/revise_instruction_dialog.dart'; import 'package:lacquer/presentation/pages/home/widgets/speech_adjustment.dart'; class ReviseFlashcardPage extends StatefulWidget { @@ -243,10 +244,15 @@ class _ReviseFlashcardPageState extends State { ), IconButton( icon: const Icon( - FontAwesomeIcons.ellipsisVertical, + FontAwesomeIcons.question, color: Colors.white, ), - onPressed: null, + onPressed: () { + showDialog( + context: context, + builder: (context) => const ReviseCardInstructionsDialog(), + ); + }, ), const SizedBox(width: 10), ], @@ -255,4 +261,4 @@ class _ReviseFlashcardPageState extends State { ), ); } -} \ No newline at end of file +} diff --git a/lib/presentation/pages/home/widgets/revise_card.dart b/lib/presentation/pages/home/widgets/revise_card.dart index 362e4d6..cbaf99e 100644 --- a/lib/presentation/pages/home/widgets/revise_card.dart +++ b/lib/presentation/pages/home/widgets/revise_card.dart @@ -186,7 +186,6 @@ class _ReviseCardState extends State { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: Column( children: [ - // Meaning content centered Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -226,7 +225,6 @@ class _ReviseCardState extends State { ), ), - // Countdown & Show Button Align( alignment: Alignment.bottomCenter, child: ElevatedButton.icon( diff --git a/lib/presentation/pages/home/widgets/revise_card_list.dart b/lib/presentation/pages/home/widgets/revise_card_list.dart index be4855e..b06cae3 100644 --- a/lib/presentation/pages/home/widgets/revise_card_list.dart +++ b/lib/presentation/pages/home/widgets/revise_card_list.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import 'package:lacquer/config/router.dart'; import 'package:lacquer/features/flashcard/dtos/card_dto.dart'; import 'package:lacquer/presentation/pages/home/widgets/revise_card.dart'; +import 'dart:math'; class ReviseCardList extends StatefulWidget { final String deckId; @@ -24,51 +25,167 @@ class ReviseCardList extends StatefulWidget { State createState() => _ReviseCardListState(); } -class _ReviseCardListState extends State { - late final PageController _pageController; - int _highestPageReached = 0; +class _ReviseCardListState extends State + with SingleTickerProviderStateMixin { + List _cardStack = []; + Offset _dragOffset = Offset.zero; + double _rotation = 0.0; + bool _isDragging = false; @override void initState() { super.initState(); - _pageController = PageController(); + _cardStack = List.from(widget.cards); } - void _onPageChanged(int index) { - if (index > _highestPageReached) { - _highestPageReached = index; - final maxPages = widget.cards.length; - final progress = maxPages > 0 ? index / maxPages : 0.0; - widget.onScrollProgress?.call(progress.clamp(0.0, 1.0)); - } + void _handleDragUpdate(DragUpdateDetails details) { + setState(() { + _dragOffset += details.delta; + _rotation = _dragOffset.dx / 300; + _isDragging = true; + }); } - @override - void dispose() { - _pageController.dispose(); - super.dispose(); + void _handleDragEnd(DragEndDetails details) { + final threshold = 150; + final draggedRight = _dragOffset.dx > threshold; + final draggedLeft = _dragOffset.dx < -threshold; + + if (draggedRight || draggedLeft) { + final card = _cardStack.last; + setState(() { + _cardStack.removeLast(); + _dragOffset = Offset.zero; + _rotation = 0.0; + _isDragging = false; + }); + + if (draggedRight) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() { + _cardStack.insert(0, card); + }); + }); + } + + final initialCount = widget.cards.length; + final remainingCount = _cardStack.length; + final progress = + initialCount > 0 + ? (initialCount - remainingCount) / initialCount + : 0.0; + widget.onScrollProgress?.call(progress.clamp(0.0, 1.0)); + } else { + setState(() { + _dragOffset = Offset.zero; + _rotation = 0.0; + _isDragging = false; + }); + } } @override Widget build(BuildContext context) { - final hasCards = widget.cards.isNotEmpty; - final totalPages = hasCards ? widget.cards.length + 1 : 0; - - return PageView.builder( - controller: _pageController, - itemCount: totalPages, - onPageChanged: _onPageChanged, - itemBuilder: (context, index) { - if (index < widget.cards.length) { - return ReviseCard( - card: widget.cards[index], - speechRate: widget.speechRate, - selectedAccent: widget.selectedAccent, - ); - } else { - return _buildCompletionCard(context, widget.deckId); - } - }, + if (_cardStack.isEmpty) { + return _buildCompletionCard(context, widget.deckId); + } + + return Stack( + children: + List.generate(min(3, _cardStack.length), (index) { + final cardIndex = _cardStack.length - 1 - index; + final card = _cardStack[cardIndex]; + final isTopCard = index == 0; + + final offset = isTopCard ? _dragOffset : Offset.zero; + final angle = isTopCard ? _rotation : 0.0; + + final transform = Transform.translate( + offset: offset, + child: Transform.rotate( + angle: angle, + child: ReviseCard( + card: card, + speechRate: widget.speechRate, + selectedAccent: widget.selectedAccent, + ), + ), + ); + + return Positioned( + top: index * 10.0, + left: 0, + right: 0, + child: + isTopCard + ? Stack( + children: [ + if (_isDragging) + Positioned.fill( + child: Row( + children: [ + Expanded( + child: Container( + color: + _dragOffset.dx < -50 + ? Colors.red.withValues( + alpha: 0.8, + ) + : Colors.red.withValues( + alpha: 0.2, + ), + child: Center( + child: Text( + 'Forget', + style: TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + + Expanded( + child: Container( + color: + _dragOffset.dx > 50 + ? Colors.green.withValues( + alpha: 0.8, + ) + : Colors.green.withValues( + alpha: 0.2, + ), + child: Center( + child: Text( + 'Remember', + style: TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ], + ), + ), + GestureDetector( + onPanUpdate: _handleDragUpdate, + onPanEnd: _handleDragEnd, + child: transform, + ), + ], + ) + : Transform.scale( + scale: 1 - (index * 0.03), + child: transform, + ), + ); + }).reversed.toList(), ); } } diff --git a/lib/presentation/pages/home/widgets/revise_instruction_dialog.dart b/lib/presentation/pages/home/widgets/revise_instruction_dialog.dart new file mode 100644 index 0000000..5479b27 --- /dev/null +++ b/lib/presentation/pages/home/widgets/revise_instruction_dialog.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; + +class ReviseCardInstructionsDialog extends StatelessWidget { + const ReviseCardInstructionsDialog({super.key}); + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.info_outline, + size: 48, + color: Colors.amber, + semanticLabel: 'Instructions', + ), + const SizedBox(height: 16), + const Text( + 'How to Revise Flashcards', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Row( + children: [ + Column( + children: [ + Icon(FontAwesomeIcons.leftLong), + const SizedBox(height: 8), + Text( + 'Swipe Left to mark\n a card as known', + style: TextStyle(fontSize: 14), + ), + ], + ), + const Spacer(), + Column( + children: [ + Icon(FontAwesomeIcons.rightLong), + const SizedBox(height: 8), + Text( + 'Swipe Right to mark\n a card as forgotten', + style: TextStyle(fontSize: 14), + ), + ], + ), + ], + ), + const SizedBox(height: 24), + Semantics( + button: true, + label: 'Close instructions', + child: ElevatedButton.icon( + onPressed: () { + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.check, color: Colors.white), + label: const Text( + 'Got it', + style: TextStyle(color: Colors.white), + ), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + backgroundColor: Colors.green, + textStyle: const TextStyle(fontSize: 16), + ), + ), + ), + ], + ), + ), + ); + } +} From 4744b65beca64a81aabe9a2b2463d8fce9ad891c Mon Sep 17 00:00:00 2001 From: Sir Date: Tue, 10 Jun 2025 00:25:46 +0700 Subject: [PATCH 4/4] fix UI of swipe revise card --- .../pages/home/widgets/revise_card_list.dart | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/lib/presentation/pages/home/widgets/revise_card_list.dart b/lib/presentation/pages/home/widgets/revise_card_list.dart index b06cae3..162406e 100644 --- a/lib/presentation/pages/home/widgets/revise_card_list.dart +++ b/lib/presentation/pages/home/widgets/revise_card_list.dart @@ -129,12 +129,8 @@ class _ReviseCardListState extends State child: Container( color: _dragOffset.dx < -50 - ? Colors.red.withValues( - alpha: 0.8, - ) - : Colors.red.withValues( - alpha: 0.2, - ), + ? Colors.red + : Colors.red, child: Center( child: Text( 'Forget', @@ -152,12 +148,8 @@ class _ReviseCardListState extends State child: Container( color: _dragOffset.dx > 50 - ? Colors.green.withValues( - alpha: 0.8, - ) - : Colors.green.withValues( - alpha: 0.2, - ), + ? Colors.green + : Colors.green, child: Center( child: Text( 'Remember',