From e1f10843689904bb4dca6911b27e99ff50fb361e Mon Sep 17 00:00:00 2001 From: Sir Date: Tue, 27 May 2025 01:10:14 +0700 Subject: [PATCH 01/28] feat(learningPage): add get deck by id API, change route of learning page --- lib/config/router.dart | 12 +- .../flashcard/bloc/flashcard_bloc.dart | 39 +++--- .../flashcard/bloc/flashcard_event.dart | 12 +- .../flashcard/data/flashcard_api_client.dart | 41 +++---- .../flashcard/data/flashcard_repository.dart | 6 +- .../pages/home/flashcard_page.dart | 5 +- .../pages/home/learning_flashcard_page.dart | 97 ++++++++------- .../pages/home/widgets/flashcard_options.dart | 68 ++++++----- .../home/widgets/flip_card_component.dart | 91 -------------- .../horizontal_learning_card_list.dart | 113 ------------------ lib/presentation/utils/flip_card_list.dart | 57 --------- 11 files changed, 148 insertions(+), 393 deletions(-) delete mode 100644 lib/presentation/pages/home/widgets/flip_card_component.dart delete mode 100644 lib/presentation/pages/home/widgets/horizontal_learning_card_list.dart delete mode 100644 lib/presentation/utils/flip_card_list.dart diff --git a/lib/config/router.dart b/lib/config/router.dart index 00e0be6..f985f6e 100644 --- a/lib/config/router.dart +++ b/lib/config/router.dart @@ -32,6 +32,7 @@ class RouteName { static const String camera = '/camera'; static const String about = '/about'; static const String flashcards = '/flashcards'; + static const String dictionary = '/dictionary'; static const String translator = '/translator'; static const String friends = '/friends'; @@ -39,6 +40,8 @@ class RouteName { static const String chat = '/chat'; static const String history = '/history'; + static String learn(String deckId) => '/learn/$deckId'; + static const publicRoutes = [login, forgotPassword, verify, register]; } @@ -116,13 +119,10 @@ final router = GoRouter( builder: (context, state) => const QuizPage(), ), noTransitionRoute( - path: '/learn', + path: '/learn/:deckId', builder: (context, state) { - final data = state.extra as Map; - final cards = data['cards'] as List; - final title = data['title'] as String; - - return LearningFlashcardPage(title: title, cards: cards); + final deckId = state.pathParameters['deckId']!; + return LearningFlashcardPage(deckId: deckId); }, ), noTransitionRoute( diff --git a/lib/features/flashcard/bloc/flashcard_bloc.dart b/lib/features/flashcard/bloc/flashcard_bloc.dart index 89c57d6..600bc32 100644 --- a/lib/features/flashcard/bloc/flashcard_bloc.dart +++ b/lib/features/flashcard/bloc/flashcard_bloc.dart @@ -14,7 +14,7 @@ class FlashcardBloc extends Bloc { on(_onLoadTagsRequested); on(_onCreateTagRequested); on(_onUpdateTagRequested); - // on(_onLoadDeckByIdRequested); + on(_onLoadDeckByIdRequested); on(_onDeleteDeckRequested); on(_onUpdateDeckRequested); on(_onDeleteTagRequested); @@ -146,25 +146,24 @@ class FlashcardBloc extends Bloc { } } - // Future _onLoadDeckByIdRequested( - // LoadDeckByIdRequested event, - // Emitter emit, - // ) async { - // emit(state.copyWith(status: FlashcardStatus.loading)); - - // try { - // final deck = await repository.getDeckById(event.deckId); - // emit(state.copyWith( - // status: FlashcardStatus.success, - // selectedDeck: deck, - // )); - // } catch (e) { - // emit(state.copyWith( - // status: FlashcardStatus.failure, - // errorMessage: e.toString(), - // )); - // } - // } + Future _onLoadDeckByIdRequested( + LoadDeckByIdRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: FlashcardStatus.loading)); + + try { + final deck = await repository.getDeckById(event.deckId); + emit(state.copyWith(status: FlashcardStatus.success, selectedDeck: deck)); + } catch (e) { + emit( + state.copyWith( + status: FlashcardStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } Future _onDeleteDeckRequested( DeleteDeckRequested event, diff --git a/lib/features/flashcard/bloc/flashcard_event.dart b/lib/features/flashcard/bloc/flashcard_event.dart index 914e7b0..4e0e90f 100644 --- a/lib/features/flashcard/bloc/flashcard_event.dart +++ b/lib/features/flashcard/bloc/flashcard_event.dart @@ -50,14 +50,14 @@ class CreateTagRequested extends FlashcardEvent { List get props => [name]; } -// class LoadDeckByIdRequested extends FlashcardEvent { -// final String deckId; +class LoadDeckByIdRequested extends FlashcardEvent { + final String deckId; -// const LoadDeckByIdRequested(this.deckId); + const LoadDeckByIdRequested(this.deckId); -// @override -// List get props => [deckId]; -// } + @override + List get props => [deckId]; +} class DeleteDeckRequested extends FlashcardEvent { final String deckId; diff --git a/lib/features/flashcard/data/flashcard_api_client.dart b/lib/features/flashcard/data/flashcard_api_client.dart index 08cbd6a..838f544 100644 --- a/lib/features/flashcard/data/flashcard_api_client.dart +++ b/lib/features/flashcard/data/flashcard_api_client.dart @@ -199,28 +199,25 @@ class FlashcardApiClient { } } - // Future getDeckById(String deckId) async { - // try { - // final token = await authLocalDataSource.getToken(); - - // final options = Options(headers: { - // if (token != null) 'Authorization': 'Bearer $token', - // }); - - // final response = await dio.get( - // '/decks/$deckId', - // options: options, - // ); - - // return CreateDeckResponseDto.fromJson(response.data); - // } on DioException catch (e) { - // if (e.response != null) { - // throw Exception(e.response!.data['message']); - // } else { - // throw Exception(e.message); - // } - // } - // } + Future getDeckById(String deckId) async { + try { + final token = await authLocalDataSource.getToken(); + + final options = Options( + headers: {if (token != null) 'Authorization': 'Bearer $token'}, + ); + + final response = await dio.get('/deck/$deckId', options: options); + + return CreateDeckResponseDto.fromJson(response.data); + } on DioException catch (e) { + if (e.response != null) { + throw Exception(e.response!.data['message']); + } else { + throw Exception(e.message); + } + } + } Future deleteDeck(String deckId) async { try { diff --git a/lib/features/flashcard/data/flashcard_repository.dart b/lib/features/flashcard/data/flashcard_repository.dart index bce04fd..cecca21 100644 --- a/lib/features/flashcard/data/flashcard_repository.dart +++ b/lib/features/flashcard/data/flashcard_repository.dart @@ -60,9 +60,9 @@ class FlashcardRepository { return apiClient.createTag(tagDto); } - // Future getDeckById(String deckId) async { - // return apiClient.getDeckById(deckId); - // } + Future getDeckById(String deckId) async { + return apiClient.getDeckById(deckId); + } Future deleteDeck(String deckId) async { return apiClient.deleteDeck(deckId); diff --git a/lib/presentation/pages/home/flashcard_page.dart b/lib/presentation/pages/home/flashcard_page.dart index d690355..c13992b 100644 --- a/lib/presentation/pages/home/flashcard_page.dart +++ b/lib/presentation/pages/home/flashcard_page.dart @@ -22,7 +22,10 @@ class _FlashcardPageState extends State { @override void initState() { super.initState(); - context.read().add(const LoadDecksRequested()); + final flashcardState = context.read().state; + if (flashcardState.groupedDecks == null) { + context.read().add(LoadDecksRequested()); + } } @override diff --git a/lib/presentation/pages/home/learning_flashcard_page.dart b/lib/presentation/pages/home/learning_flashcard_page.dart index 9d33ffe..68ab06b 100644 --- a/lib/presentation/pages/home/learning_flashcard_page.dart +++ b/lib/presentation/pages/home/learning_flashcard_page.dart @@ -1,71 +1,80 @@ import 'package:flutter/material.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'; -class LearningFlashcardPage extends StatelessWidget { - final String title; - final List cards; - const LearningFlashcardPage({ - super.key, - required this.title, - required this.cards, - }); +class LearningFlashcardPage extends StatefulWidget { + final String deckId; + const LearningFlashcardPage({super.key, required this.deckId}); + + @override + State createState() => _LearningFlashcardPageState(); +} + +class _LearningFlashcardPageState extends State { @override Widget build(BuildContext context) { return Scaffold( backgroundColor: CustomTheme.lightbeige, body: Stack( children: [ - _buildAppBar(title), + _buildAppBar(context), Column( mainAxisAlignment: MainAxisAlignment.center, - // children: [HorizontalLearningCardList(flashcardItems: cards)], + children: const [Text('something')], ), ], ), ); } - Widget _buildAppBar(String title) { - return Container( - height: 150, - width: double.infinity, - decoration: const BoxDecoration( - color: CustomTheme.cinnabar, - borderRadius: BorderRadius.only( - bottomLeft: Radius.circular(20), - bottomRight: Radius.circular(20), - ), + Widget _buildAppBar(BuildContext context) { + return ClipRRect( + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(8), + bottomRight: Radius.circular(8), ), - alignment: Alignment.topCenter, - padding: const EdgeInsets.only(top: 30), - child: Row( - children: [ - SizedBox(width: 10), - IconButton( - icon: Icon(FontAwesomeIcons.arrowLeft, color: Colors.white), - onPressed: null, - ), - Expanded( - child: Center( - child: Text( - title, - style: TextStyle( - fontSize: 24, + 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, - fontWeight: FontWeight.bold, ), + onPressed: () { + context.go(RouteName.flashcards); + }, ), - ), - ), - SizedBox(width: 15), - IconButton( - icon: Icon(FontAwesomeIcons.plus, color: Colors.white), - onPressed: null, + Expanded( + child: Center( + child: Text( + 'Flashcards', + style: const TextStyle( + fontSize: 24, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 15), + IconButton( + icon: const Icon(FontAwesomeIcons.plus, color: Colors.white), + onPressed: null, + ), + const SizedBox(width: 10), + ], ), - SizedBox(width: 10), - ], + ), ), ); } diff --git a/lib/presentation/pages/home/widgets/flashcard_options.dart b/lib/presentation/pages/home/widgets/flashcard_options.dart index ba3e28c..aa4efd3 100644 --- a/lib/presentation/pages/home/widgets/flashcard_options.dart +++ b/lib/presentation/pages/home/widgets/flashcard_options.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.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/presentation/pages/home/widgets/flashcard_confirm_delete.dart'; import 'package:lacquer/presentation/pages/home/widgets/flashcard_topic_edit.dart'; @@ -23,41 +25,47 @@ class FlashcardOptionDialog extends StatefulWidget { class _FlashcardOptionDialogState extends State { int selectedIndex = -1; + late final List> options; - final List> options = [ - { - "icon": FontAwesomeIcons.play, - "title": "Explore", - "subtitle": "10 new cards", - "action": () { - print("revise"); + @override + void initState() { + super.initState(); + + options = [ + { + "icon": FontAwesomeIcons.play, + "title": "Explore", + "subtitle": "10 new cards", + "action": () { + context.go(RouteName.learn(widget.id)); + }, }, - }, - { - "icon": FontAwesomeIcons.rotateRight, - "title": "Revise", - "subtitle": "Repeat 10 cards", - "action": () { - print("revise"); + { + "icon": FontAwesomeIcons.rotateRight, + "title": "Revise", + "subtitle": "Repeat 10 cards", + "action": () { + print("Revise clicked"); + }, }, - }, - { - "icon": FontAwesomeIcons.question, - "title": "Multi-choice Questions", - "subtitle": "", - "action": () { - print("revise"); + { + "icon": FontAwesomeIcons.question, + "title": "Multi-choice Questions", + "subtitle": "", + "action": () { + print("Multi-choice Questions clicked"); + }, }, - }, - { - "icon": FontAwesomeIcons.penToSquare, - "title": "Fill In Game", - "subtitle": "", - "action": () { - print("revise"); + { + "icon": FontAwesomeIcons.penToSquare, + "title": "Fill In Game", + "subtitle": "", + "action": () { + print("Fill In Game clicked"); + }, }, - }, - ]; + ]; + } @override Widget build(BuildContext context) { diff --git a/lib/presentation/pages/home/widgets/flip_card_component.dart b/lib/presentation/pages/home/widgets/flip_card_component.dart deleted file mode 100644 index 1ad8c81..0000000 --- a/lib/presentation/pages/home/widgets/flip_card_component.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flip_card/flip_card.dart'; - -class FlipCardComp extends StatelessWidget { - final String frontText; - final String backText; - final String imagePath; - final String pronunciation; - - const FlipCardComp({ - super.key, - required this.frontText, - required this.backText, - required this.imagePath, - required this.pronunciation, - }); - - @override - Widget build(BuildContext context) { - return FlipCard( - fill: Fill.fillBack, - direction: FlipDirection.HORIZONTAL, - side: CardSide.FRONT, - front: SizedBox( - height: 500, - child: Card( - color: const Color.fromARGB(255, 253, 245, 221), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - elevation: 4, - child: Center( - child: Padding( - padding: EdgeInsets.all(16), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Image.asset( - imagePath, - width: 270, - height: 270, - fit: BoxFit.contain, - ), - SizedBox(height: 50), - Text( - frontText, - style: TextStyle(fontSize: 50), - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - ), - ), - back: SizedBox( - height: 500, - child: Card( - color: const Color.fromARGB(255, 253, 245, 221), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - elevation: 4, - child: Center( - child: Padding( - padding: EdgeInsets.all(16), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - backText, - style: TextStyle(fontSize: 50), - textAlign: TextAlign.center, - ), - Text( - pronunciation, - style: TextStyle( - fontSize: 40, - color: const Color.fromARGB(133, 0, 0, 0), - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/presentation/pages/home/widgets/horizontal_learning_card_list.dart b/lib/presentation/pages/home/widgets/horizontal_learning_card_list.dart deleted file mode 100644 index 8417cae..0000000 --- a/lib/presentation/pages/home/widgets/horizontal_learning_card_list.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:lacquer/presentation/utils/flip_card_list.dart'; -import 'package:lacquer/presentation/pages/home/widgets/flip_card_component.dart'; - -class HorizontalLearningCardList extends StatefulWidget { - final List flashcardItems; - - const HorizontalLearningCardList({super.key, required this.flashcardItems}); - - @override - State createState() => - _HorizontalLearningCardListState(); -} - -class _HorizontalLearningCardListState - extends State { - late final PageController controller; - int _currentPage = 0; - - @override - void initState() { - super.initState(); - controller = PageController(viewportFraction: 0.8, initialPage: 0); - controller.addListener(_updatePage); - } - - @override - void dispose() { - controller.removeListener(_updatePage); - controller.dispose(); - super.dispose(); - } - - void _updatePage() { - if (controller.page == null) return; - final newPage = controller.page!.round(); - if (newPage != _currentPage) { - setState(() { - _currentPage = newPage; - }); - } - } - - @override - Widget build(BuildContext context) { - return Column( - children: [ - SizedBox(height: 100), - SizedBox( - height: 600, - child: PageView.builder( - controller: controller, - itemCount: widget.flashcardItems.length, - physics: const BouncingScrollPhysics(), - itemBuilder: (context, index) { - return AnimatedBuilder( - animation: controller, - builder: (context, child) { - double scale = 1.0; - if (controller.hasClients && - controller.position.haveDimensions) { - double page = - controller.page ?? controller.initialPage.toDouble(); - scale = (1 - (page - index).abs() * 0.2).clamp(0.85, 1.0); - } else { - scale = index == controller.initialPage ? 1.0 : 0.85; - } - - return Center( - child: Transform.scale( - scale: scale, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 1), - child: FlipCardComp( - frontText: widget.flashcardItems[index].frontText, - backText: widget.flashcardItems[index].backText, - imagePath: widget.flashcardItems[index].imagePath, - pronunciation: - widget.flashcardItems[index].pronunciation, - ), - ), - ), - ); - }, - ); - }, - ), - ), - - // Padding( - // padding: const EdgeInsets.symmetric(vertical: 16.0), - // child: Row( - // mainAxisAlignment: MainAxisAlignment.center, - // children: List.generate(widget.flashcardItems.length, (index) { - // return Container( - // width: 12.0, - // height: 12.0, - // margin: const EdgeInsets.symmetric(horizontal: 4.0), - // decoration: BoxDecoration( - // shape: BoxShape.circle, - // color: - // _currentPage == index - // ? CustomTheme.cinnabar - // : Colors.grey.withAlpha((255 * 0.5).toInt()), - // ), - // ); - // }), - // ), - // ), - ], - ); - } -} diff --git a/lib/presentation/utils/flip_card_list.dart b/lib/presentation/utils/flip_card_list.dart deleted file mode 100644 index 9087080..0000000 --- a/lib/presentation/utils/flip_card_list.dart +++ /dev/null @@ -1,57 +0,0 @@ -class FlipCardModel { - final String frontText; - final String backText; - final String imagePath; - final String pronunciation; - - const FlipCardModel({ - required this.frontText, - required this.backText, - required this.imagePath, - required this.pronunciation, - }); -} - -final List communicationFlashcard = [ - FlipCardModel( - frontText: "Mèo", - backText: "Cat", - imagePath: "assets/images/app_mascot.png", - pronunciation: "/kӕt/", - ), - - FlipCardModel( - frontText: "Mèo", - backText: "Cat", - imagePath: "assets/images/app_mascot.png", - pronunciation: "/kӕt/", - ), - - FlipCardModel( - frontText: "Mèo", - backText: "Cat", - imagePath: "assets/images/app_mascot.png", - pronunciation: "/kӕt/", - ), - - FlipCardModel( - frontText: "Mèo", - backText: "Cat", - imagePath: "assets/images/app_mascot.png", - pronunciation: "/kӕt/", - ), - - FlipCardModel( - frontText: "Mèo", - backText: "Cat", - imagePath: "assets/images/app_mascot.png", - pronunciation: "/kӕt/", - ), - - FlipCardModel( - frontText: "Mèo", - backText: "Cat", - imagePath: "assets/images/app_mascot.png", - pronunciation: "/kӕt/", - ), -]; From 9e3b0286d7d353687655ce0dbbc2cde51b593dbf Mon Sep 17 00:00:00 2001 From: Sir Date: Tue, 27 May 2025 17:39:34 +0700 Subject: [PATCH 02/28] fix: fix createDeckResponseDto to receive Card object --- .../flashcard/bloc/flashcard_bloc.dart | 2 +- .../flashcard/bloc/flashcard_event.dart | 14 ++-- .../flashcard/data/flashcard_api_client.dart | 42 ++++++------ .../flashcard/data/flashcard_repository.dart | 13 +++- lib/features/flashcard/dtos/card_dto.dart | 38 +++++++++++ .../flashcard/dtos/create_deck_dto.dart | 44 ++++-------- .../pages/home/learning_flashcard_page.dart | 68 ++++++++++++++++--- .../home/widgets/flashcard_topic_create.dart | 2 +- .../pages/home/widgets/learning_card.dart | 17 +++++ 9 files changed, 166 insertions(+), 74 deletions(-) create mode 100644 lib/features/flashcard/dtos/card_dto.dart create mode 100644 lib/presentation/pages/home/widgets/learning_card.dart diff --git a/lib/features/flashcard/bloc/flashcard_bloc.dart b/lib/features/flashcard/bloc/flashcard_bloc.dart index 600bc32..a6f2a79 100644 --- a/lib/features/flashcard/bloc/flashcard_bloc.dart +++ b/lib/features/flashcard/bloc/flashcard_bloc.dart @@ -32,7 +32,7 @@ class FlashcardBloc extends Bloc { title: event.title, description: event.description, tags: event.tags, - cardIds: event.cardIds, + cards: event.cards, imageFile: event.imageFile, ); diff --git a/lib/features/flashcard/bloc/flashcard_event.dart b/lib/features/flashcard/bloc/flashcard_event.dart index 4e0e90f..566ce30 100644 --- a/lib/features/flashcard/bloc/flashcard_event.dart +++ b/lib/features/flashcard/bloc/flashcard_event.dart @@ -1,6 +1,8 @@ import 'package:equatable/equatable.dart'; import 'dart:io'; +import 'package:lacquer/features/flashcard/dtos/card_dto.dart'; + abstract class FlashcardEvent extends Equatable { const FlashcardEvent(); @@ -12,25 +14,19 @@ class CreateDeckRequested extends FlashcardEvent { final String title; final String description; final List tags; - final List cardIds; + final List cards; final File? imageFile; const CreateDeckRequested({ required this.title, required this.description, required this.tags, - required this.cardIds, + required this.cards, this.imageFile, }); @override - List get props => [ - title, - description, - tags, - cardIds, - imageFile?.path, - ]; + List get props => [title, description, tags, cards, imageFile?.path]; } class LoadDecksRequested extends FlashcardEvent { diff --git a/lib/features/flashcard/data/flashcard_api_client.dart b/lib/features/flashcard/data/flashcard_api_client.dart index 838f544..a1fa4bb 100644 --- a/lib/features/flashcard/data/flashcard_api_client.dart +++ b/lib/features/flashcard/data/flashcard_api_client.dart @@ -25,7 +25,7 @@ class FlashcardApiClient { 'title': deckDto.title, 'description': deckDto.description, 'tags': deckDto.tags, - 'cards': deckDto.cardIds, + 'cards': deckDto.cards, }); if (imageFile != null) { @@ -90,6 +90,26 @@ class FlashcardApiClient { } } + Future> getDeckById(String deckId) async { + try { + final token = await authLocalDataSource.getToken(); + + final options = Options( + headers: {if (token != null) 'Authorization': 'Bearer $token'}, + ); + + final response = await dio.get('/deck/$deckId', options: options); + + return response.data as Map; + } on DioException catch (e) { + if (e.response != null) { + throw Exception(e.response!.data['message']); + } else { + throw Exception(e.message); + } + } + } + Future> getTags() async { try { final token = await authLocalDataSource.getToken(); @@ -199,26 +219,6 @@ class FlashcardApiClient { } } - Future getDeckById(String deckId) async { - try { - final token = await authLocalDataSource.getToken(); - - final options = Options( - headers: {if (token != null) 'Authorization': 'Bearer $token'}, - ); - - final response = await dio.get('/deck/$deckId', options: options); - - return CreateDeckResponseDto.fromJson(response.data); - } on DioException catch (e) { - if (e.response != null) { - throw Exception(e.response!.data['message']); - } else { - throw Exception(e.message); - } - } - } - Future deleteDeck(String deckId) async { try { final token = await authLocalDataSource.getToken(); diff --git a/lib/features/flashcard/data/flashcard_repository.dart b/lib/features/flashcard/data/flashcard_repository.dart index cecca21..6701905 100644 --- a/lib/features/flashcard/data/flashcard_repository.dart +++ b/lib/features/flashcard/data/flashcard_repository.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:lacquer/features/flashcard/dtos/card_dto.dart'; import 'dart:io'; import 'package:lacquer/features/flashcard/dtos/create_tag_dto.dart'; import 'package:lacquer/features/flashcard/dtos/grouped_decks_dto.dart'; @@ -17,14 +18,14 @@ class FlashcardRepository { required String title, required String description, required List tags, - required List cardIds, + required List cards, File? imageFile, }) async { final deckDto = CreateDeckDto( title: title, description: description, tags: tags, - cardIds: cardIds, + cards: cards, ); return apiClient.createDeck(deckDto, imageFile); @@ -61,7 +62,13 @@ class FlashcardRepository { } Future getDeckById(String deckId) async { - return apiClient.getDeckById(deckId); + final response = await apiClient.getDeckById(deckId); + final responseData = response; + if (responseData['success'] == true) { + return CreateDeckResponseDto.fromJson(responseData['data']); + } else { + throw Exception(responseData['message'] ?? 'Failed to retrieve deck'); + } } Future deleteDeck(String deckId) async { diff --git a/lib/features/flashcard/dtos/card_dto.dart b/lib/features/flashcard/dtos/card_dto.dart new file mode 100644 index 0000000..0015ec2 --- /dev/null +++ b/lib/features/flashcard/dtos/card_dto.dart @@ -0,0 +1,38 @@ +class CardDto { + final String? id; + final String? word; + final List? pronunciations; + final List? img; + final List? meanings; + final String? description; + + CardDto({ + this.id, + this.word, + this.pronunciations, + this.img, + this.meanings, + this.description, + }); + + factory CardDto.fromJson(Map json) { + return CardDto( + id: json['_id'] as String?, + word: json['word'] as String?, + pronunciations: (json['pronunciations'] as List?)?.cast(), + img: (json['img'] as List?)?.cast(), + meanings: (json['meanings'] as List?)?.cast(), + description: json['description'] as String?, + ); + } + + Map toJson() { + return { + '_id': id, + 'word': word, + 'pronunciations': pronunciations, + 'img': img, + 'meanings': meanings, + }; + } +} diff --git a/lib/features/flashcard/dtos/create_deck_dto.dart b/lib/features/flashcard/dtos/create_deck_dto.dart index 0943519..8e7ebe1 100644 --- a/lib/features/flashcard/dtos/create_deck_dto.dart +++ b/lib/features/flashcard/dtos/create_deck_dto.dart @@ -1,14 +1,16 @@ +import 'package:lacquer/features/flashcard/dtos/card_dto.dart'; + class CreateDeckDto { final String title; final String description; final List tags; - final List cardIds; + final List cards; CreateDeckDto({ required this.title, required this.description, required this.tags, - required this.cardIds, + required this.cards, }); Map toJson() { @@ -16,12 +18,11 @@ class CreateDeckDto { 'title': title, 'description': description, 'tags': tags, - 'cards': cardIds, + 'cards': cards.map((card) => card.toJson()).toList(), }; } factory CreateDeckDto.fromJson(Map json) { - // Handle tags parsing - they might be objects or strings List parsedTags = []; final tagsData = json['tags']; if (tagsData is List) { @@ -29,24 +30,17 @@ class CreateDeckDto { if (tag is String) { parsedTags.add(tag); } else if (tag is Map) { - // If tag is an object, extract the ID or name parsedTags.add(tag['_id'] as String? ?? tag['id'] as String? ?? ''); } } } - // Handle cards parsing - they might be objects or strings - List parsedCards = []; + List parsedCards = []; final cardsData = json['cards']; if (cardsData is List) { for (var card in cardsData) { - if (card is String) { - parsedCards.add(card); - } else if (card is Map) { - // If card is an object, extract the ID - parsedCards.add( - card['_id'] as String? ?? card['id'] as String? ?? '', - ); + if (card is Map) { + parsedCards.add(CardDto.fromJson(card)); } } } @@ -55,7 +49,7 @@ class CreateDeckDto { title: json['title'] as String, description: json['description'] as String, tags: parsedTags, - cardIds: parsedCards, + cards: parsedCards, ); } } @@ -66,7 +60,7 @@ class CreateDeckResponseDto { final String? description; final String? img; final List? tags; - final List? cardIds; + final List? cards; final String? userId; final DateTime? createdAt; @@ -76,13 +70,12 @@ class CreateDeckResponseDto { this.description, this.img, this.tags, - this.cardIds, + this.cards, this.userId, this.createdAt, }); factory CreateDeckResponseDto.fromJson(Map json) { - // Handle tags parsing - they might be objects or strings List? parsedTags; final tagsData = json['tags']; if (tagsData is List) { @@ -91,25 +84,18 @@ class CreateDeckResponseDto { if (tag is String) { parsedTags.add(tag); } else if (tag is Map) { - // If tag is an object, extract the ID or name parsedTags.add(tag['_id'] as String? ?? tag['id'] as String? ?? ''); } } } - // Handle cards parsing - they might be objects or strings - List? parsedCards; + List? parsedCards; final cardsData = json['cards']; if (cardsData is List) { parsedCards = []; for (var card in cardsData) { - if (card is String) { - parsedCards.add(card); - } else if (card is Map) { - // If card is an object, extract the ID - parsedCards.add( - card['_id'] as String? ?? card['id'] as String? ?? '', - ); + if (card is Map) { + parsedCards.add(CardDto.fromJson(card)); } } } @@ -120,7 +106,7 @@ class CreateDeckResponseDto { description: json['description'] as String?, img: json['img'] as String?, tags: parsedTags, - cardIds: parsedCards, + cards: parsedCards, userId: json['userId'] as String?, createdAt: json['createdAt'] != null diff --git a/lib/presentation/pages/home/learning_flashcard_page.dart b/lib/presentation/pages/home/learning_flashcard_page.dart index 68ab06b..ec18c75 100644 --- a/lib/presentation/pages/home/learning_flashcard_page.dart +++ b/lib/presentation/pages/home/learning_flashcard_page.dart @@ -1,8 +1,12 @@ 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'; class LearningFlashcardPage extends StatefulWidget { final String deckId; @@ -14,23 +18,67 @@ class LearningFlashcardPage extends StatefulWidget { } class _LearningFlashcardPageState extends State { + @override + void initState() { + super.initState(); + context.read().add(LoadDeckByIdRequested(widget.deckId)); + } + @override Widget build(BuildContext context) { return Scaffold( backgroundColor: CustomTheme.lightbeige, - body: Stack( - children: [ - _buildAppBar(context), - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: const [Text('something')], - ), - ], + 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) { + print('fetch success'); + final deck = state.selectedDeck!; + return Stack( + children: [ + _buildAppBar(context, deck.title), + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Deck: ${deck.title}', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 20), + if (deck.cards != null && deck.cards!.isNotEmpty) + ...deck.cards! + .map( + (card) => Padding( + padding: const EdgeInsets.symmetric( + vertical: 8.0, + ), + child: Text(card.word ?? ''), + ), + ) + .toList() + else + const Text('No cards available in this deck'), + ], + ), + ], + ); + } + return const Center(child: Text('No deck data available')); + }, ), ); } - Widget _buildAppBar(BuildContext context) { + Widget _buildAppBar(BuildContext context, String? title) { return ClipRRect( borderRadius: const BorderRadius.only( bottomLeft: Radius.circular(8), @@ -57,7 +105,7 @@ class _LearningFlashcardPageState extends State { Expanded( child: Center( child: Text( - 'Flashcards', + title ?? '', style: const TextStyle( fontSize: 24, color: Colors.white, diff --git a/lib/presentation/pages/home/widgets/flashcard_topic_create.dart b/lib/presentation/pages/home/widgets/flashcard_topic_create.dart index e5b2d65..731ee7a 100644 --- a/lib/presentation/pages/home/widgets/flashcard_topic_create.dart +++ b/lib/presentation/pages/home/widgets/flashcard_topic_create.dart @@ -49,7 +49,7 @@ class _FlashcardTopicCreateState extends State { title: _titleController.text, description: 'Description here', tags: [_selectedTagId!], - cardIds: [], + cards: [], imageFile: _selectedImage, ), ); diff --git a/lib/presentation/pages/home/widgets/learning_card.dart b/lib/presentation/pages/home/widgets/learning_card.dart new file mode 100644 index 0000000..65652b9 --- /dev/null +++ b/lib/presentation/pages/home/widgets/learning_card.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class LearningCard extends StatefulWidget { + final String cardId; + + const LearningCard({super.key, required this.cardId}); + + @override + State createState() => _LearningCardState(); +} + +class _LearningCardState extends State { + @override + Widget build(BuildContext context) { + return Scaffold(); + } +} From 8662e600a94808ef6e0d260f805c310abbe8eb2e Mon Sep 17 00:00:00 2001 From: Sir Date: Wed, 28 May 2025 16:30:19 +0700 Subject: [PATCH 03/28] feat(learningFlashcardPage): add LearningCard --- .../pages/home/learning_flashcard_page.dart | 23 +--- .../pages/home/widgets/learning_card.dart | 119 +++++++++++++++++- 2 files changed, 116 insertions(+), 26 deletions(-) diff --git a/lib/presentation/pages/home/learning_flashcard_page.dart b/lib/presentation/pages/home/learning_flashcard_page.dart index ec18c75..b11f423 100644 --- a/lib/presentation/pages/home/learning_flashcard_page.dart +++ b/lib/presentation/pages/home/learning_flashcard_page.dart @@ -7,6 +7,7 @@ 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/learning_card.dart'; class LearningFlashcardPage extends StatefulWidget { final String deckId; @@ -38,7 +39,6 @@ class _LearningFlashcardPageState extends State { ); } else if (state.status == FlashcardStatus.success && state.selectedDeck != null) { - print('fetch success'); final deck = state.selectedDeck!; return Stack( children: [ @@ -46,26 +46,9 @@ class _LearningFlashcardPageState extends State { Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - 'Deck: ${deck.title}', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), + LearningCard(card: deck.cards![0]), const SizedBox(height: 20), - if (deck.cards != null && deck.cards!.isNotEmpty) - ...deck.cards! - .map( - (card) => Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, - ), - child: Text(card.word ?? ''), - ), - ) - .toList() - else + if (deck.cards == null && deck.cards!.isEmpty) const Text('No cards available in this deck'), ], ), diff --git a/lib/presentation/pages/home/widgets/learning_card.dart b/lib/presentation/pages/home/widgets/learning_card.dart index 65652b9..22adbf5 100644 --- a/lib/presentation/pages/home/widgets/learning_card.dart +++ b/lib/presentation/pages/home/widgets/learning_card.dart @@ -1,17 +1,124 @@ +import 'dart:math'; + import 'package:flutter/material.dart'; +import 'package:lacquer/features/flashcard/dtos/card_dto.dart'; class LearningCard extends StatefulWidget { - final String cardId; - - const LearningCard({super.key, required this.cardId}); - + final CardDto card; + const LearningCard({super.key, required this.card}); @override State createState() => _LearningCardState(); } -class _LearningCardState extends State { +class _LearningCardState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + bool _isFront = true; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + ); + } + + void _flipCard() { + if (_isFront) { + _controller.forward(); + } else { + _controller.reverse(); + } + _isFront = !_isFront; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { - return Scaffold(); + return Center( + child: Padding( + padding: const EdgeInsets.only(top: 90), + child: GestureDetector( + onTap: _flipCard, + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final angle = _controller.value * pi; + final isUnder = angle > (pi / 2); + + return Transform( + alignment: Alignment.center, + transform: + Matrix4.identity() + ..setEntry(3, 2, 0.001) + ..rotateY(angle), + child: + isUnder + ? Transform( + alignment: Alignment.center, + transform: Matrix4.rotationY(pi), + child: _buildBack(widget.card), + ) + : _buildFront(widget.card), + ); + }, + ), + ), + ), + ); + } + + Widget _buildFront(CardDto card) { + final size = MediaQuery.of(context).size; + return Container( + width: (size.width - 50), + height: (size.height - 180), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: const Color.fromRGBO(0, 0, 0, 0.1), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + alignment: Alignment.center, + child: Text( + card.word ?? '', + style: TextStyle(fontSize: 24, color: Colors.black), + ), + ); + } + + Widget _buildBack(CardDto card) { + final size = MediaQuery.of(context).size; + return Container( + width: (size.width - 50), + height: (size.height - 200), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: const Color.fromRGBO(0, 0, 0, 0.1), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + alignment: Alignment.center, + child: const Text( + 'Back', + style: TextStyle(fontSize: 24, color: Colors.black), + ), + ); } } From 64fdc8c3b01264526136d5fb80e1f506be4bf7ac Mon Sep 17 00:00:00 2001 From: Sir Date: Fri, 30 May 2025 12:45:29 +0700 Subject: [PATCH 04/28] fix: change CardDto to match new API feat: learning card component --- lib/config/theme.dart | 1 + lib/features/flashcard/dtos/card_dto.dart | 42 +++-- .../pages/home/widgets/learning_card.dart | 172 +++++++++++++++--- lib/presentation/utils/wave_clipper.dart | 41 +++++ 4 files changed, 222 insertions(+), 34 deletions(-) create mode 100644 lib/presentation/utils/wave_clipper.dart diff --git a/lib/config/theme.dart b/lib/config/theme.dart index 0072700..1323887 100644 --- a/lib/config/theme.dart +++ b/lib/config/theme.dart @@ -14,6 +14,7 @@ class CustomTheme { static const Color mainColor1 = Color(0xFF6D2323); static const Color mainColor2 = Color(0xFFE5D0AC); static const Color mainColor3 = Color(0xFFFEF9E1); + static const Color flashcardColor = Color.fromARGB(255, 224, 78, 78); static const Color chatbotprimary = Colors.purple; static const Color chatbotsecondary = Colors.blue; diff --git a/lib/features/flashcard/dtos/card_dto.dart b/lib/features/flashcard/dtos/card_dto.dart index 0015ec2..305526c 100644 --- a/lib/features/flashcard/dtos/card_dto.dart +++ b/lib/features/flashcard/dtos/card_dto.dart @@ -1,17 +1,15 @@ class CardDto { final String? id; final String? word; - final List? pronunciations; - final List? img; - final List? meanings; + final String? pronunciation; + final CardMeaningDto? meaning; final String? description; CardDto({ this.id, this.word, - this.pronunciations, - this.img, - this.meanings, + this.pronunciation, + this.meaning, this.description, }); @@ -19,9 +17,11 @@ class CardDto { return CardDto( id: json['_id'] as String?, word: json['word'] as String?, - pronunciations: (json['pronunciations'] as List?)?.cast(), - img: (json['img'] as List?)?.cast(), - meanings: (json['meanings'] as List?)?.cast(), + pronunciation: json['pronunciation'] as String?, + meaning: + json['meaning'] != null + ? CardMeaningDto.fromJson(json['meaning'] as Map) + : null, description: json['description'] as String?, ); } @@ -30,9 +30,27 @@ class CardDto { return { '_id': id, 'word': word, - 'pronunciations': pronunciations, - 'img': img, - 'meanings': meanings, + 'pronunciation': pronunciation, + 'meaning': meaning?.toJson(), + 'description': description, }; } } + +class CardMeaningDto { + final String? type; + final String? definition; + + CardMeaningDto({this.type, this.definition}); + + factory CardMeaningDto.fromJson(Map json) { + return CardMeaningDto( + type: json['type'] as String?, + definition: json['definition'] as String?, + ); + } + + Map toJson() { + return {'type': type, 'definition': definition}; + } +} diff --git a/lib/presentation/pages/home/widgets/learning_card.dart b/lib/presentation/pages/home/widgets/learning_card.dart index 22adbf5..35965ff 100644 --- a/lib/presentation/pages/home/widgets/learning_card.dart +++ b/lib/presentation/pages/home/widgets/learning_card.dart @@ -1,7 +1,9 @@ import 'dart:math'; - import 'package:flutter/material.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 LearningCard extends StatefulWidget { final CardDto card; @@ -76,48 +78,174 @@ class _LearningCardState extends State Widget _buildFront(CardDto card) { final size = MediaQuery.of(context).size; + return Container( - width: (size.width - 50), - height: (size.height - 180), + width: size.width - 50, + height: size.height - 180, decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), + color: CustomTheme.flashcardColor, + borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( - color: const Color.fromRGBO(0, 0, 0, 0.1), - blurRadius: 12, - offset: const Offset(0, 6), + color: const Color.fromRGBO(0, 0, 0, 0.2), + blurRadius: 10, + offset: Offset(0, 6), ), ], ), - alignment: Alignment.center, - child: Text( - card.word ?? '', - style: TextStyle(fontSize: 24, color: Colors.black), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + card.word ?? '', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 36, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + if ((card.pronunciation ?? '').isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + '[${card.pronunciation}]', + style: const TextStyle( + fontSize: 20, + fontStyle: FontStyle.italic, + color: Colors.white70, + ), + ), + ], + const SizedBox(height: 16), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.white24, + borderRadius: BorderRadius.circular(2), + ), + ), + ], ), ); } Widget _buildBack(CardDto card) { final size = MediaQuery.of(context).size; + return Container( - width: (size.width - 50), - height: (size.height - 200), + width: size.width - 50, + height: size.height - 180, decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), + color: CustomTheme.flashcardColor, + borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( - color: const Color.fromRGBO(0, 0, 0, 0.1), - blurRadius: 12, + color: const Color.fromRGBO(0, 0, 0, 0.2), + blurRadius: 10, offset: const Offset(0, 6), ), ], ), - alignment: Alignment.center, - child: const Text( - 'Back', - style: TextStyle(fontSize: 24, color: Colors.black), + child: Column( + children: [ + 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: () {}, + icon: const Icon( + FontAwesomeIcons.volumeHigh, + color: Colors.grey, + ), + ), + ), + ], + ), + ), + ), + Expanded( + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: CustomTheme.flashcardColor, + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(16), + ), + ), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (card.meaning?.type?.isNotEmpty ?? false) ...[ + 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) + Text( + card.meaning!.definition!, + style: const TextStyle( + fontSize: 20, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ), + ], ), ); } diff --git a/lib/presentation/utils/wave_clipper.dart b/lib/presentation/utils/wave_clipper.dart new file mode 100644 index 0000000..ef1895f --- /dev/null +++ b/lib/presentation/utils/wave_clipper.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +class WaveClipper extends CustomClipper { + final double waveHeight; + + WaveClipper({this.waveHeight = 30}); + + @override + Path getClip(Size size) { + final path = Path(); + path.lineTo(0, size.height - waveHeight); + + final firstControlPoint = Offset(size.width / 4, size.height); + final firstEndPoint = Offset(size.width / 2, size.height - waveHeight / 2); + path.quadraticBezierTo( + firstControlPoint.dx, + firstControlPoint.dy, + firstEndPoint.dx, + firstEndPoint.dy, + ); + + final secondControlPoint = Offset( + 3 * size.width / 4, + size.height - waveHeight, + ); + final secondEndPoint = Offset(size.width, size.height - waveHeight); + path.quadraticBezierTo( + secondControlPoint.dx, + secondControlPoint.dy, + secondEndPoint.dx, + secondEndPoint.dy, + ); + + path.lineTo(size.width, 0); + path.close(); + return path; + } + + @override + bool shouldReclip(covariant CustomClipper oldClipper) => false; +} From d8ac4f19679765c3a041d1f64c4f71a4afc95fc8 Mon Sep 17 00:00:00 2001 From: Sir Date: Sat, 31 May 2025 15:46:48 +0700 Subject: [PATCH 05/28] feat(learningcard): add flutter_tts dependency and apply text to speech for learning card --- .../pages/home/widgets/learning_card.dart | 16 +++++++++++++++- pubspec.lock | 8 ++++++++ pubspec.yaml | 5 +++-- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/lib/presentation/pages/home/widgets/learning_card.dart b/lib/presentation/pages/home/widgets/learning_card.dart index 35965ff..854c1bd 100644 --- a/lib/presentation/pages/home/widgets/learning_card.dart +++ b/lib/presentation/pages/home/widgets/learning_card.dart @@ -4,6 +4,7 @@ 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'; +import 'package:flutter_tts/flutter_tts.dart'; class LearningCard extends StatefulWidget { final CardDto card; @@ -16,6 +17,7 @@ class _LearningCardState extends State with SingleTickerProviderStateMixin { late AnimationController _controller; bool _isFront = true; + late FlutterTts flutterTts; @override void initState() { @@ -24,6 +26,13 @@ class _LearningCardState extends State duration: const Duration(milliseconds: 500), vsync: this, ); + flutterTts = FlutterTts(); + } + + Future _speak(String text) async { + await flutterTts.setLanguage("en-US"); + await flutterTts.setPitch(1.0); + await flutterTts.speak(text); } void _flipCard() { @@ -38,6 +47,7 @@ class _LearningCardState extends State @override void dispose() { _controller.dispose(); + flutterTts.stop(); super.dispose(); } @@ -192,7 +202,11 @@ class _LearningCardState extends State bottom: 24, right: 12, child: IconButton( - onPressed: () {}, + onPressed: () { + if ((card.word ?? '').isNotEmpty) { + _speak(card.word!); + } + }, icon: const Icon( FontAwesomeIcons.volumeHigh, color: Colors.grey, diff --git a/pubspec.lock b/pubspec.lock index 9318516..e097c26 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -368,6 +368,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_tts: + dependency: "direct main" + description: + name: flutter_tts + sha256: baa3cb6b4990318460fe28bfa8c7869399e97223971532c02bd97c5e876aa3c5 + url: "https://pub.dev" + source: hosted + version: "4.2.2" flutter_web_plugins: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 8d0c81d..d1e78cc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,10 +32,11 @@ dependencies: camera: ^0.11.1 path: ^1.9.1 http_parser: ^4.1.2 - # Location and weather services + # Location and weather services geolocator: ^13.0.1 geocoding: ^3.0.0 - # Image saving + flutter_tts: ^4.2.2 + # Image saving gal: ^2.3.0 mime: ^2.0.0 # QR code scanner From aa5e4ee8a5cba2cc9dc0b46b33af169c8bd5d070 Mon Sep 17 00:00:00 2001 From: Sir Date: Sat, 31 May 2025 16:08:57 +0700 Subject: [PATCH 06/28] feat: add learning card list --- .../pages/home/learning_flashcard_page.dart | 7 +++--- .../home/widgets/learning_card_list.dart | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 lib/presentation/pages/home/widgets/learning_card_list.dart diff --git a/lib/presentation/pages/home/learning_flashcard_page.dart b/lib/presentation/pages/home/learning_flashcard_page.dart index b11f423..1b0af83 100644 --- a/lib/presentation/pages/home/learning_flashcard_page.dart +++ b/lib/presentation/pages/home/learning_flashcard_page.dart @@ -7,7 +7,7 @@ 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/learning_card.dart'; +import 'package:lacquer/presentation/pages/home/widgets/learning_card_list.dart'; class LearningFlashcardPage extends StatefulWidget { final String deckId; @@ -44,11 +44,10 @@ class _LearningFlashcardPageState extends State { children: [ _buildAppBar(context, deck.title), Column( - mainAxisAlignment: MainAxisAlignment.center, children: [ - LearningCard(card: deck.cards![0]), + Expanded(child: LearningCardList(cards: deck.cards ?? [])), const SizedBox(height: 20), - if (deck.cards == null && deck.cards!.isEmpty) + if (deck.cards == null || deck.cards!.isEmpty) const Text('No cards available in this deck'), ], ), diff --git a/lib/presentation/pages/home/widgets/learning_card_list.dart b/lib/presentation/pages/home/widgets/learning_card_list.dart new file mode 100644 index 0000000..86c4c06 --- /dev/null +++ b/lib/presentation/pages/home/widgets/learning_card_list.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:lacquer/features/flashcard/dtos/card_dto.dart'; +import 'package:lacquer/presentation/pages/home/widgets/learning_card.dart'; + +class LearningCardList extends StatefulWidget { + final List cards; + + const LearningCardList({super.key, required this.cards}); + + @override + State createState() => _LearningCardListState(); +} + +class _LearningCardListState extends State { + @override + Widget build(BuildContext context) { + return PageView.builder( + itemCount: widget.cards.length, + itemBuilder: (context, index) { + return LearningCard(card: widget.cards[index]); + }, + ); + } +} From dc2edbc99e655ac559e746565631d90747f85c61 Mon Sep 17 00:00:00 2001 From: Sir Date: Sat, 31 May 2025 22:11:26 +0700 Subject: [PATCH 07/28] feat(learningflashcard): add progress bar --- .../pages/home/learning_flashcard_page.dart | 49 ++++++++++++++++++- .../pages/home/widgets/learning_card.dart | 4 +- .../home/widgets/learning_card_list.dart | 33 ++++++++++++- 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/lib/presentation/pages/home/learning_flashcard_page.dart b/lib/presentation/pages/home/learning_flashcard_page.dart index 1b0af83..40fa07b 100644 --- a/lib/presentation/pages/home/learning_flashcard_page.dart +++ b/lib/presentation/pages/home/learning_flashcard_page.dart @@ -19,12 +19,20 @@ class LearningFlashcardPage extends StatefulWidget { } class _LearningFlashcardPageState extends State { + double _progress = 0.0; + @override void initState() { super.initState(); context.read().add(LoadDeckByIdRequested(widget.deckId)); } + void _updateProgress(double progress) { + setState(() { + _progress = progress.clamp(0.0, 1.0); + }); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -45,7 +53,46 @@ class _LearningFlashcardPageState extends State { _buildAppBar(context, deck.title), Column( children: [ - Expanded(child: LearningCardList(cards: deck.cards ?? [])), + const SizedBox(height: 100), + 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: LearningCardList( + cards: deck.cards ?? [], + onScrollProgress: _updateProgress, + ), + ), const SizedBox(height: 20), if (deck.cards == null || deck.cards!.isEmpty) const Text('No cards available in this deck'), diff --git a/lib/presentation/pages/home/widgets/learning_card.dart b/lib/presentation/pages/home/widgets/learning_card.dart index 854c1bd..b9a9957 100644 --- a/lib/presentation/pages/home/widgets/learning_card.dart +++ b/lib/presentation/pages/home/widgets/learning_card.dart @@ -8,7 +8,9 @@ import 'package:flutter_tts/flutter_tts.dart'; class LearningCard extends StatefulWidget { final CardDto card; + const LearningCard({super.key, required this.card}); + @override State createState() => _LearningCardState(); } @@ -55,7 +57,7 @@ class _LearningCardState extends State Widget build(BuildContext context) { return Center( child: Padding( - padding: const EdgeInsets.only(top: 90), + padding: const EdgeInsets.only(top: 0), child: GestureDetector( onTap: _flipCard, child: AnimatedBuilder( diff --git a/lib/presentation/pages/home/widgets/learning_card_list.dart b/lib/presentation/pages/home/widgets/learning_card_list.dart index 86c4c06..9a8a904 100644 --- a/lib/presentation/pages/home/widgets/learning_card_list.dart +++ b/lib/presentation/pages/home/widgets/learning_card_list.dart @@ -4,18 +4,49 @@ import 'package:lacquer/presentation/pages/home/widgets/learning_card.dart'; class LearningCardList extends StatefulWidget { final List cards; + final Function(double)? onScrollProgress; - const LearningCardList({super.key, required this.cards}); + const LearningCardList({ + super.key, + required this.cards, + this.onScrollProgress, + }); @override State createState() => _LearningCardListState(); } class _LearningCardListState 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 - 1; + 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) { return PageView.builder( + controller: _pageController, itemCount: widget.cards.length, + onPageChanged: _onPageChanged, itemBuilder: (context, index) { return LearningCard(card: widget.cards[index]); }, From 9ee42d9447fd6a20b70d52299a2078fc2e151a52 Mon Sep 17 00:00:00 2001 From: Sir Date: Sun, 1 Jun 2025 01:59:32 +0700 Subject: [PATCH 08/28] feat(learningcard): add Speech Adjustment to change speed and accent of speech --- .../pages/home/learning_flashcard_page.dart | 63 +++++++-- .../pages/home/widgets/flashcard_topic.dart | 3 +- .../pages/home/widgets/learning_card.dart | 43 ++++-- .../home/widgets/learning_card_list.dart | 10 +- .../pages/home/widgets/speech_adjustment.dart | 125 ++++++++++++++++++ 5 files changed, 218 insertions(+), 26 deletions(-) create mode 100644 lib/presentation/pages/home/widgets/speech_adjustment.dart diff --git a/lib/presentation/pages/home/learning_flashcard_page.dart b/lib/presentation/pages/home/learning_flashcard_page.dart index 40fa07b..b2bd6eb 100644 --- a/lib/presentation/pages/home/learning_flashcard_page.dart +++ b/lib/presentation/pages/home/learning_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/learning_card_list.dart'; +import 'package:lacquer/presentation/pages/home/widgets/speech_adjustment.dart'; class LearningFlashcardPage extends StatefulWidget { final String deckId; @@ -20,6 +21,8 @@ class LearningFlashcardPage extends StatefulWidget { class _LearningFlashcardPageState extends State { double _progress = 0.0; + double _speechRate = 0.5; + String _selectedAccent = 'en-US'; @override void initState() { @@ -33,6 +36,37 @@ class _LearningFlashcardPageState extends State { }); } + 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( @@ -91,6 +125,8 @@ class _LearningFlashcardPageState extends State { child: LearningCardList( cards: deck.cards ?? [], onScrollProgress: _updateProgress, + speechRate: _speechRate, + selectedAccent: _selectedAccent, ), ), const SizedBox(height: 20), @@ -131,21 +167,24 @@ class _LearningFlashcardPageState extends State { context.go(RouteName.flashcards); }, ), - Expanded( - child: Center( - child: Text( - title ?? '', - style: const TextStyle( - fontSize: 24, - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), + 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.plus, color: Colors.white), + icon: const Icon( + FontAwesomeIcons.ellipsisVertical, + color: Colors.white, + ), onPressed: null, ), const SizedBox(width: 10), diff --git a/lib/presentation/pages/home/widgets/flashcard_topic.dart b/lib/presentation/pages/home/widgets/flashcard_topic.dart index 3c26239..2acf2e8 100644 --- a/lib/presentation/pages/home/widgets/flashcard_topic.dart +++ b/lib/presentation/pages/home/widgets/flashcard_topic.dart @@ -27,6 +27,7 @@ class FlashcardTopicState extends State { @override Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; return GestureDetector( onTap: () { showDialog( @@ -49,7 +50,7 @@ class FlashcardTopicState extends State { child: Material( borderRadius: BorderRadius.circular(12), child: Container( - width: 350, + width: size.width - 30, height: 200, decoration: BoxDecoration( color: Colors.white, diff --git a/lib/presentation/pages/home/widgets/learning_card.dart b/lib/presentation/pages/home/widgets/learning_card.dart index b9a9957..c3a754f 100644 --- a/lib/presentation/pages/home/widgets/learning_card.dart +++ b/lib/presentation/pages/home/widgets/learning_card.dart @@ -8,8 +8,15 @@ import 'package:flutter_tts/flutter_tts.dart'; class LearningCard extends StatefulWidget { final CardDto card; + final double speechRate; + final String selectedAccent; - const LearningCard({super.key, required this.card}); + const LearningCard({ + super.key, + required this.card, + required this.speechRate, + required this.selectedAccent, + }); @override State createState() => _LearningCardState(); @@ -29,10 +36,18 @@ class _LearningCardState extends State vsync: this, ); flutterTts = FlutterTts(); + _initializeTts(); + } + + 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("en-US"); + await flutterTts.setLanguage(widget.selectedAccent); + await flutterTts.setSpeechRate(widget.speechRate); await flutterTts.setPitch(1.0); await flutterTts.speak(text); } @@ -203,16 +218,20 @@ class _LearningCardState extends State Positioned( bottom: 24, right: 12, - child: IconButton( - onPressed: () { - if ((card.word ?? '').isNotEmpty) { - _speak(card.word!); - } - }, - icon: const Icon( - FontAwesomeIcons.volumeHigh, - color: Colors.grey, - ), + child: Row( + children: [ + IconButton( + onPressed: () { + if ((card.word ?? '').isNotEmpty) { + _speak(card.word!); + } + }, + icon: const Icon( + FontAwesomeIcons.volumeHigh, + color: Colors.grey, + ), + ), + ], ), ), ], diff --git a/lib/presentation/pages/home/widgets/learning_card_list.dart b/lib/presentation/pages/home/widgets/learning_card_list.dart index 9a8a904..d0f3427 100644 --- a/lib/presentation/pages/home/widgets/learning_card_list.dart +++ b/lib/presentation/pages/home/widgets/learning_card_list.dart @@ -5,11 +5,15 @@ import 'package:lacquer/presentation/pages/home/widgets/learning_card.dart'; class LearningCardList extends StatefulWidget { final List cards; final Function(double)? onScrollProgress; + final double speechRate; + final String selectedAccent; const LearningCardList({ super.key, required this.cards, this.onScrollProgress, + required this.speechRate, + required this.selectedAccent, }); @override @@ -48,7 +52,11 @@ class _LearningCardListState extends State { itemCount: widget.cards.length, onPageChanged: _onPageChanged, itemBuilder: (context, index) { - return LearningCard(card: widget.cards[index]); + return LearningCard( + card: widget.cards[index], + speechRate: widget.speechRate, + selectedAccent: widget.selectedAccent, + ); }, ); } diff --git a/lib/presentation/pages/home/widgets/speech_adjustment.dart b/lib/presentation/pages/home/widgets/speech_adjustment.dart new file mode 100644 index 0000000..d1f6aa4 --- /dev/null +++ b/lib/presentation/pages/home/widgets/speech_adjustment.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; + +class SpeechAdjustment extends StatefulWidget { + final double initialSpeed; + final String initialAccent; + + const SpeechAdjustment({ + super.key, + required this.initialSpeed, + required this.initialAccent, + }); + + @override + State createState() => _SpeechAdjustmentState(); +} + +class _SpeechAdjustmentState extends State { + late double _speechSpeed; + late String _selectedAccent; + + final Map _accents = { + 'US English': 'en-US', + 'UK English': 'en-GB', + 'Australian': 'en-AU', + 'Indian': 'en-IN', + }; + + @override + void initState() { + super.initState(); + _speechSpeed = widget.initialSpeed.clamp(0, 1.0); + + _selectedAccent = + _accents.containsKey(widget.initialAccent) + ? widget.initialAccent + : 'US English'; + } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 40), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + "Speech Settings", + style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 20), + + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text("Speed", style: TextStyle(fontSize: 16)), + Text(_speechSpeed.toStringAsFixed(2)), + ], + ), + Slider( + value: _speechSpeed, + min: 0.0, + max: 1.0, + divisions: 15, + label: _speechSpeed.toStringAsFixed(2), + onChanged: (value) { + setState(() { + _speechSpeed = value; + }); + }, + ), + + const SizedBox(height: 20), + + const Align( + alignment: Alignment.centerLeft, + child: Text("Accent", style: TextStyle(fontSize: 16)), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + value: _selectedAccent, + items: + _accents.keys.map((accent) { + return DropdownMenuItem( + value: accent, + child: Text(accent), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _selectedAccent = value; + }); + } + }, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + + const SizedBox(height: 24), + + ElevatedButton( + onPressed: () { + final languageCode = _accents[_selectedAccent] ?? 'en-US'; + Navigator.of( + context, + ).pop({'speed': _speechSpeed, 'accent': languageCode}); + }, + child: const Text("Apply"), + ), + ], + ), + ), + ); + } +} From da3ed7b4e4aa586b080957f4d53025c367b6f201 Mon Sep 17 00:00:00 2001 From: Sir Date: Mon, 2 Jun 2025 10:15:09 +0700 Subject: [PATCH 09/28] fix(learningcard): fix clamp of speech speed --- lib/presentation/pages/home/learning_flashcard_page.dart | 3 +-- lib/presentation/pages/home/widgets/speech_adjustment.dart | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/presentation/pages/home/learning_flashcard_page.dart b/lib/presentation/pages/home/learning_flashcard_page.dart index b2bd6eb..132ade3 100644 --- a/lib/presentation/pages/home/learning_flashcard_page.dart +++ b/lib/presentation/pages/home/learning_flashcard_page.dart @@ -63,8 +63,7 @@ class _LearningFlashcardPageState extends State { static final Map _accents = { 'US English': 'en-US', 'UK English': 'en-GB', - 'Australian': 'en-AU', - 'Indian': 'en-IN', + 'Indian Accent': 'en-IN', }; @override diff --git a/lib/presentation/pages/home/widgets/speech_adjustment.dart b/lib/presentation/pages/home/widgets/speech_adjustment.dart index d1f6aa4..ac118f4 100644 --- a/lib/presentation/pages/home/widgets/speech_adjustment.dart +++ b/lib/presentation/pages/home/widgets/speech_adjustment.dart @@ -28,7 +28,7 @@ class _SpeechAdjustmentState extends State { @override void initState() { super.initState(); - _speechSpeed = widget.initialSpeed.clamp(0, 1.0); + _speechSpeed = widget.initialSpeed.clamp(0.1, 1.0); _selectedAccent = _accents.containsKey(widget.initialAccent) @@ -61,9 +61,9 @@ class _SpeechAdjustmentState extends State { ), Slider( value: _speechSpeed, - min: 0.0, + min: 0.1, max: 1.0, - divisions: 15, + divisions: 9, label: _speechSpeed.toStringAsFixed(2), onChanged: (value) { setState(() { From 4d46e5044ea85d9cfcc43ea90c857fa673fb363b Mon Sep 17 00:00:00 2001 From: Sir Date: Mon, 2 Jun 2025 11:43:00 +0700 Subject: [PATCH 10/28] feat(editcard): add Edit Card UI --- lib/config/router.dart | 3 + .../pages/home/edit_card_list_page.dart | 121 ++++++++++++++++++ .../pages/home/widgets/card_item.dart | 78 +++++++++++ .../pages/home/widgets/card_item_list.dart | 25 ++++ .../pages/home/widgets/flashcard_options.dart | 10 ++ 5 files changed, 237 insertions(+) create mode 100644 lib/presentation/pages/home/edit_card_list_page.dart create mode 100644 lib/presentation/pages/home/widgets/card_item.dart create mode 100644 lib/presentation/pages/home/widgets/card_item_list.dart diff --git a/lib/config/router.dart b/lib/config/router.dart index f985f6e..8863214 100644 --- a/lib/config/router.dart +++ b/lib/config/router.dart @@ -10,6 +10,8 @@ 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/presentation/pages/home/dictionary_page.dart'; +import 'package:lacquer/presentation/pages/home/edit_card_list_page.dart'; +import 'package:lacquer/presentation/pages/profile/profile_page.dart'; import 'package:lacquer/presentation/pages/home/flashcard_page.dart'; import 'package:lacquer/presentation/pages/home/learning_flashcard_page.dart'; import 'package:lacquer/presentation/pages/friends/friends_page.dart'; @@ -41,6 +43,7 @@ class RouteName { static const String history = '/history'; static String learn(String deckId) => '/learn/$deckId'; + static String edit(String deckId) => '/edit/$deckId'; static const publicRoutes = [login, forgotPassword, verify, register]; } diff --git a/lib/presentation/pages/home/edit_card_list_page.dart b/lib/presentation/pages/home/edit_card_list_page.dart new file mode 100644 index 0000000..30d8ebf --- /dev/null +++ b/lib/presentation/pages/home/edit_card_list_page.dart @@ -0,0 +1,121 @@ +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/card_item_list.dart'; + +class EditCardListPage extends StatefulWidget { + final String deckId; + + const EditCardListPage({super.key, required this.deckId}); + + @override + State createState() => _EditCardListPageState(); +} + +class _EditCardListPageState extends State { + @override + void initState() { + super.initState(); + context.read().add(LoadDeckByIdRequested(widget.deckId)); + } + + @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: 80), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + ), + Expanded(child: CardItemList(cards: deck.cards ?? [])), + const SizedBox(height: 20), + if (deck.cards == null || deck.cards!.isEmpty) + const Text('No cards available in this deck'), + ], + ), + ], + ); + } + 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 Spacer(), + IconButton( + icon: const Icon(FontAwesomeIcons.gear, color: Colors.white), + onPressed: null, + ), + IconButton( + icon: const Icon( + FontAwesomeIcons.ellipsisVertical, + color: Colors.white, + ), + onPressed: null, + ), + const SizedBox(width: 10), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/pages/home/widgets/card_item.dart b/lib/presentation/pages/home/widgets/card_item.dart new file mode 100644 index 0000000..9c31605 --- /dev/null +++ b/lib/presentation/pages/home/widgets/card_item.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:lacquer/features/flashcard/dtos/card_dto.dart'; + +class CardItem extends StatefulWidget { + final CardDto card; + + const CardItem({super.key, required this.card}); + + @override + State createState() => _CardItemState(); +} + +class _CardItemState extends State + with SingleTickerProviderStateMixin { + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4), + child: Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Container( + width: 4, + decoration: BoxDecoration( + color: Colors.grey, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.card.word ?? '', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 6), + Text( + widget.card.meaning?.definition ?? '', + style: const TextStyle( + fontSize: 16, + color: Colors.black87, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/pages/home/widgets/card_item_list.dart b/lib/presentation/pages/home/widgets/card_item_list.dart new file mode 100644 index 0000000..e8a8404 --- /dev/null +++ b/lib/presentation/pages/home/widgets/card_item_list.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:lacquer/features/flashcard/dtos/card_dto.dart'; +import 'card_item.dart'; + +class CardItemList extends StatelessWidget { + final List cards; + + const CardItemList({super.key, required this.cards}); + + @override + Widget build(BuildContext context) { + if (cards.isEmpty) { + return const Center(child: Text('No cards available.')); + } + + return ListView.separated( + padding: const EdgeInsets.all(12.0), + itemCount: cards.length, + separatorBuilder: (context, index) => const SizedBox(height: 8), + itemBuilder: (context, index) { + return CardItem(card: cards[index]); + }, + ); + } +} diff --git a/lib/presentation/pages/home/widgets/flashcard_options.dart b/lib/presentation/pages/home/widgets/flashcard_options.dart index aa4efd3..17cc4b5 100644 --- a/lib/presentation/pages/home/widgets/flashcard_options.dart +++ b/lib/presentation/pages/home/widgets/flashcard_options.dart @@ -145,6 +145,16 @@ class _FlashcardOptionDialogState extends State { ); }, ), + IconButton( + icon: Icon( + FontAwesomeIcons.listUl, + size: 20, + color: Colors.black, + ), + onPressed: () { + context.go(RouteName.edit(widget.id)); + }, + ), IconButton( icon: Icon( FontAwesomeIcons.trashCan, From 5cc94ca81fe7dfcbfc5c28c36f86f0a74656310e Mon Sep 17 00:00:00 2001 From: Sir Date: Tue, 3 Jun 2025 00:58:26 +0700 Subject: [PATCH 11/28] fix: add Navigator for flashcard options dialog --- lib/presentation/pages/home/edit_card_list_page.dart | 2 +- lib/presentation/pages/home/widgets/flashcard_options.dart | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/presentation/pages/home/edit_card_list_page.dart b/lib/presentation/pages/home/edit_card_list_page.dart index 30d8ebf..a63a439 100644 --- a/lib/presentation/pages/home/edit_card_list_page.dart +++ b/lib/presentation/pages/home/edit_card_list_page.dart @@ -101,7 +101,7 @@ class _EditCardListPageState extends State { ), const Spacer(), IconButton( - icon: const Icon(FontAwesomeIcons.gear, color: Colors.white), + icon: const Icon(FontAwesomeIcons.plus, color: Colors.white), onPressed: null, ), IconButton( diff --git a/lib/presentation/pages/home/widgets/flashcard_options.dart b/lib/presentation/pages/home/widgets/flashcard_options.dart index 17cc4b5..09456d5 100644 --- a/lib/presentation/pages/home/widgets/flashcard_options.dart +++ b/lib/presentation/pages/home/widgets/flashcard_options.dart @@ -152,6 +152,7 @@ class _FlashcardOptionDialogState extends State { color: Colors.black, ), onPressed: () { + Navigator.pop(context); context.go(RouteName.edit(widget.id)); }, ), From 43a91a76cb68d50ba6688653b824c555a22c91db Mon Sep 17 00:00:00 2001 From: Sir Date: Tue, 27 May 2025 01:10:14 +0700 Subject: [PATCH 12/28] feat(learningPage): add get deck by id API, change route of learning page --- lib/config/router.dart | 45 ++--- .../flashcard/data/flashcard_api_client.dart | 20 +++ .../flashcard/data/flashcard_repository.dart | 8 +- .../pages/home/learning_flashcard_page.dart | 159 +++--------------- 4 files changed, 70 insertions(+), 162 deletions(-) diff --git a/lib/config/router.dart b/lib/config/router.dart index 8863214..0cd3d43 100644 --- a/lib/config/router.dart +++ b/lib/config/router.dart @@ -9,18 +9,16 @@ 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/presentation/pages/home/dictionary_page.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/profile/profile_page.dart'; import 'package:lacquer/presentation/pages/home/flashcard_page.dart'; import 'package:lacquer/presentation/pages/home/learning_flashcard_page.dart'; 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/quiz_page.dart'; import 'package:lacquer/presentation/pages/home/translator_page.dart'; -import 'package:lacquer/presentation/pages/chat/chat_screen.dart'; -import 'package:lacquer/presentation/pages/history/history_page.dart'; import 'package:lacquer/presentation/pages/mainscreen.dart'; import 'package:flutter/widgets.dart'; @@ -33,17 +31,14 @@ class RouteName { static const String register = '/register'; static const String camera = '/camera'; static const String about = '/about'; + static const String profile = '/profile'; static const String flashcards = '/flashcards'; - + static String learn(String deckId) => '/learn/$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 const String chat = '/chat'; - static const String history = '/history'; - - static String learn(String deckId) => '/learn/$deckId'; - static String edit(String deckId) => '/edit/$deckId'; + static String addNewWord(String deckId) => '/add-new-word/$deckId'; static const publicRoutes = [login, forgotPassword, verify, register]; } @@ -105,6 +100,10 @@ final router = GoRouter( return AboutScreen(imagePath: imagePath); }, ), + noTransitionRoute( + path: RouteName.profile, + builder: (context, state) => const ProfilePage(), + ), noTransitionRoute( path: RouteName.flashcards, builder: (context, state) => const FlashcardPage(), @@ -117,10 +116,6 @@ final router = GoRouter( path: RouteName.friends, builder: (context, state) => const FriendsPage(), ), - noTransitionRoute( - path: RouteName.quiz, - builder: (context, state) => const QuizPage(), - ), noTransitionRoute( path: '/learn/:deckId', builder: (context, state) { @@ -133,12 +128,22 @@ final router = GoRouter( builder: (context, state) => const TranslatorScreen(), ), noTransitionRoute( - path: '/chat', - builder: (context, state) => const ChatScreen(), + path: RouteName.translator, + builder: (context, state) => const TranslatorScreen(), ), noTransitionRoute( - path: RouteName.history, - builder: (context, state) => const HistoryPage(), + path: '/edit/:deckId', + builder: (context, state) { + final deckId = state.pathParameters['deckId']!; + return EditCardListPage(deckId: deckId); + }, + ), + noTransitionRoute( + path: '/add-new-word/:deckId', + builder: (context, state) { + final deckId = state.pathParameters['deckId']!; + return AddNewWordPage(deckId: deckId); + }, ), ], -); +); \ No newline at end of file diff --git a/lib/features/flashcard/data/flashcard_api_client.dart b/lib/features/flashcard/data/flashcard_api_client.dart index a1fa4bb..52b86ba 100644 --- a/lib/features/flashcard/data/flashcard_api_client.dart +++ b/lib/features/flashcard/data/flashcard_api_client.dart @@ -219,6 +219,26 @@ class FlashcardApiClient { } } + Future getDeckById(String deckId) async { + try { + final token = await authLocalDataSource.getToken(); + + final options = Options( + headers: {if (token != null) 'Authorization': 'Bearer $token'}, + ); + + final response = await dio.get('/deck/$deckId', options: options); + + return CreateDeckResponseDto.fromJson(response.data); + } on DioException catch (e) { + if (e.response != null) { + throw Exception(e.response!.data['message']); + } else { + throw Exception(e.message); + } + } + } + Future deleteDeck(String deckId) async { try { final token = await authLocalDataSource.getToken(); diff --git a/lib/features/flashcard/data/flashcard_repository.dart b/lib/features/flashcard/data/flashcard_repository.dart index 6701905..09565cf 100644 --- a/lib/features/flashcard/data/flashcard_repository.dart +++ b/lib/features/flashcard/data/flashcard_repository.dart @@ -62,13 +62,7 @@ class FlashcardRepository { } Future getDeckById(String deckId) async { - final response = await apiClient.getDeckById(deckId); - final responseData = response; - if (responseData['success'] == true) { - return CreateDeckResponseDto.fromJson(responseData['data']); - } else { - throw Exception(responseData['message'] ?? 'Failed to retrieve deck'); - } + return apiClient.getDeckById(deckId); } Future deleteDeck(String deckId) async { diff --git a/lib/presentation/pages/home/learning_flashcard_page.dart b/lib/presentation/pages/home/learning_flashcard_page.dart index 132ade3..3f01848 100644 --- a/lib/presentation/pages/home/learning_flashcard_page.dart +++ b/lib/presentation/pages/home/learning_flashcard_page.dart @@ -3,12 +3,9 @@ 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: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/learning_card_list.dart'; -import 'package:lacquer/presentation/pages/home/widgets/speech_adjustment.dart'; class LearningFlashcardPage extends StatefulWidget { final String deckId; @@ -20,129 +17,23 @@ class LearningFlashcardPage extends StatefulWidget { } class _LearningFlashcardPageState 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', - 'Indian Accent': '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), - 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: LearningCardList( - cards: deck.cards ?? [], - onScrollProgress: _updateProgress, - speechRate: _speechRate, - selectedAccent: _selectedAccent, - ), - ), - const SizedBox(height: 20), - if (deck.cards == null || deck.cards!.isEmpty) - const Text('No cards available in this deck'), - ], - ), - ], - ); - } - return const Center(child: Text('No deck data available')); - }, + body: Stack( + children: [ + _buildAppBar(context), + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [Text('something')], + ), + ], ), ); } - Widget _buildAppBar(BuildContext context, String? title) { + Widget _buildAppBar(BuildContext context) { return ClipRRect( borderRadius: const BorderRadius.only( bottomLeft: Radius.circular(8), @@ -166,30 +57,28 @@ class _LearningFlashcardPageState extends State { context.go(RouteName.flashcards); }, ), - Text( - title ?? '', - style: const TextStyle( - fontSize: 24, - color: Colors.white, - fontWeight: FontWeight.bold, + Expanded( + child: Center( + child: Text( + 'Flashcards', + style: const TextStyle( + fontSize: 24, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), ), ), - const Spacer(), + const SizedBox(width: 15), IconButton( - icon: const Icon(FontAwesomeIcons.gear, color: Colors.white), - onPressed: _showTtsSettings, - ), - IconButton( - icon: const Icon( - FontAwesomeIcons.ellipsisVertical, - color: Colors.white, - ), + icon: const Icon(FontAwesomeIcons.plus, color: Colors.white), onPressed: null, ), const SizedBox(width: 10), ], ), ), + ), ), ); } From d66b3294ccd8df23b33f77a5078631e8351aec2a Mon Sep 17 00:00:00 2001 From: Sir Date: Mon, 2 Jun 2025 11:43:00 +0700 Subject: [PATCH 13/28] feat(editcard): add Edit Card UI --- lib/presentation/pages/home/edit_card_list_page.dart | 4 ++++ lib/presentation/pages/home/widgets/flashcard_options.dart | 3 +++ 2 files changed, 7 insertions(+) diff --git a/lib/presentation/pages/home/edit_card_list_page.dart b/lib/presentation/pages/home/edit_card_list_page.dart index a63a439..b9ef8b4 100644 --- a/lib/presentation/pages/home/edit_card_list_page.dart +++ b/lib/presentation/pages/home/edit_card_list_page.dart @@ -101,7 +101,11 @@ class _EditCardListPageState extends State { ), const Spacer(), IconButton( +<<<<<<< HEAD icon: const Icon(FontAwesomeIcons.plus, color: Colors.white), +======= + icon: const Icon(FontAwesomeIcons.gear, color: Colors.white), +>>>>>>> 3eeb313 (feat(editcard): add Edit Card UI) onPressed: null, ), IconButton( diff --git a/lib/presentation/pages/home/widgets/flashcard_options.dart b/lib/presentation/pages/home/widgets/flashcard_options.dart index 09456d5..56550ce 100644 --- a/lib/presentation/pages/home/widgets/flashcard_options.dart +++ b/lib/presentation/pages/home/widgets/flashcard_options.dart @@ -152,7 +152,10 @@ class _FlashcardOptionDialogState extends State { color: Colors.black, ), onPressed: () { +<<<<<<< HEAD Navigator.pop(context); +======= +>>>>>>> 3eeb313 (feat(editcard): add Edit Card UI) context.go(RouteName.edit(widget.id)); }, ), From 7ce73aa0ee71c5e6f49f4e7054db8aa02211a5aa Mon Sep 17 00:00:00 2001 From: Sir Date: Tue, 3 Jun 2025 10:28:29 +0700 Subject: [PATCH 14/28] fix: bug when rebase --- .../flashcard/data/flashcard_api_client.dart | 20 ------------------- .../flashcard/data/flashcard_repository.dart | 8 +++++++- .../pages/home/edit_card_list_page.dart | 4 ---- .../pages/home/learning_flashcard_page.dart | 4 ---- .../pages/home/widgets/flashcard_options.dart | 3 --- .../pages/profile/profile_page.dart | 2 +- 6 files changed, 8 insertions(+), 33 deletions(-) diff --git a/lib/features/flashcard/data/flashcard_api_client.dart b/lib/features/flashcard/data/flashcard_api_client.dart index 52b86ba..a1fa4bb 100644 --- a/lib/features/flashcard/data/flashcard_api_client.dart +++ b/lib/features/flashcard/data/flashcard_api_client.dart @@ -219,26 +219,6 @@ class FlashcardApiClient { } } - Future getDeckById(String deckId) async { - try { - final token = await authLocalDataSource.getToken(); - - final options = Options( - headers: {if (token != null) 'Authorization': 'Bearer $token'}, - ); - - final response = await dio.get('/deck/$deckId', options: options); - - return CreateDeckResponseDto.fromJson(response.data); - } on DioException catch (e) { - if (e.response != null) { - throw Exception(e.response!.data['message']); - } else { - throw Exception(e.message); - } - } - } - Future deleteDeck(String deckId) async { try { final token = await authLocalDataSource.getToken(); diff --git a/lib/features/flashcard/data/flashcard_repository.dart b/lib/features/flashcard/data/flashcard_repository.dart index 09565cf..6701905 100644 --- a/lib/features/flashcard/data/flashcard_repository.dart +++ b/lib/features/flashcard/data/flashcard_repository.dart @@ -62,7 +62,13 @@ class FlashcardRepository { } Future getDeckById(String deckId) async { - return apiClient.getDeckById(deckId); + final response = await apiClient.getDeckById(deckId); + final responseData = response; + if (responseData['success'] == true) { + return CreateDeckResponseDto.fromJson(responseData['data']); + } else { + throw Exception(responseData['message'] ?? 'Failed to retrieve deck'); + } } Future deleteDeck(String deckId) async { diff --git a/lib/presentation/pages/home/edit_card_list_page.dart b/lib/presentation/pages/home/edit_card_list_page.dart index b9ef8b4..a63a439 100644 --- a/lib/presentation/pages/home/edit_card_list_page.dart +++ b/lib/presentation/pages/home/edit_card_list_page.dart @@ -101,11 +101,7 @@ class _EditCardListPageState extends State { ), const Spacer(), IconButton( -<<<<<<< HEAD icon: const Icon(FontAwesomeIcons.plus, color: Colors.white), -======= - icon: const Icon(FontAwesomeIcons.gear, color: Colors.white), ->>>>>>> 3eeb313 (feat(editcard): add Edit Card UI) onPressed: null, ), IconButton( diff --git a/lib/presentation/pages/home/learning_flashcard_page.dart b/lib/presentation/pages/home/learning_flashcard_page.dart index 3f01848..68ab06b 100644 --- a/lib/presentation/pages/home/learning_flashcard_page.dart +++ b/lib/presentation/pages/home/learning_flashcard_page.dart @@ -1,10 +1,7 @@ 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:go_router/go_router.dart'; -import 'package:lacquer/config/router.dart'; import 'package:lacquer/config/theme.dart'; class LearningFlashcardPage extends StatefulWidget { @@ -78,7 +75,6 @@ class _LearningFlashcardPageState extends State { ], ), ), - ), ), ); } diff --git a/lib/presentation/pages/home/widgets/flashcard_options.dart b/lib/presentation/pages/home/widgets/flashcard_options.dart index 56550ce..09456d5 100644 --- a/lib/presentation/pages/home/widgets/flashcard_options.dart +++ b/lib/presentation/pages/home/widgets/flashcard_options.dart @@ -152,10 +152,7 @@ class _FlashcardOptionDialogState extends State { color: Colors.black, ), onPressed: () { -<<<<<<< HEAD Navigator.pop(context); -======= ->>>>>>> 3eeb313 (feat(editcard): add Edit Card UI) context.go(RouteName.edit(widget.id)); }, ), diff --git a/lib/presentation/pages/profile/profile_page.dart b/lib/presentation/pages/profile/profile_page.dart index c5ccfa7..2d5523e 100644 --- a/lib/presentation/pages/profile/profile_page.dart +++ b/lib/presentation/pages/profile/profile_page.dart @@ -713,4 +713,4 @@ class _buildQRButton extends StatelessWidget { ), ); } -} +} \ No newline at end of file From 4bf622c004a4bd8ab337b10b2b4d75d8f6395a11 Mon Sep 17 00:00:00 2001 From: Sir Date: Tue, 3 Jun 2025 15:53:36 +0700 Subject: [PATCH 15/28] feat: add New Word page --- .../pages/home/add_new_word_page.dart | 453 ++++++++++++++++++ .../pages/home/edit_card_list_page.dart | 4 +- 2 files changed, 456 insertions(+), 1 deletion(-) create mode 100644 lib/presentation/pages/home/add_new_word_page.dart diff --git a/lib/presentation/pages/home/add_new_word_page.dart b/lib/presentation/pages/home/add_new_word_page.dart new file mode 100644 index 0000000..bd1d059 --- /dev/null +++ b/lib/presentation/pages/home/add_new_word_page.dart @@ -0,0 +1,453 @@ +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/features/dictionary/bloc/dictionary_bloc.dart'; +import 'package:lacquer/features/dictionary/bloc/dictionary_event.dart'; +import 'package:lacquer/features/dictionary/bloc/dictionary_state.dart'; +import 'package:lacquer/presentation/pages/home/dictionary_page.dart'; + +class AddNewWordPage extends StatefulWidget { + final String deckId; + + const AddNewWordPage({super.key, required this.deckId}); + + @override + State createState() => _AddNewWordPageState(); +} + +class _AddNewWordPageState extends State { + final FocusNode _focusNode = FocusNode(); + final TextEditingController _searchController = TextEditingController(); + List suggestions = []; + List searchResults = []; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + context.read().add(LoadDeckByIdRequested(widget.deckId)); + _focusNode.addListener(() {}); + } + + @override + void dispose() { + _focusNode.dispose(); + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: CustomTheme.lightbeige, + body: MultiBlocListener( + listeners: [ + BlocListener( + listener: (context, state) { + if (state.status == FlashcardStatus.success) { + setState(() { + _isLoading = false; + }); + } else if (state.status == FlashcardStatus.loading) { + setState(() { + _isLoading = true; + }); + } + }, + ), + BlocListener( + listener: (context, state) { + switch (state) { + case DictionaryStateSearchInProgress(): + case DictionaryStateWordDetailsLoading(): + setState(() { + _isLoading = true; + }); + break; + case DictionaryStateSearchSuggestions(): + suggestions = state.suggestions; + setState(() { + _isLoading = false; + }); + break; + case DictionaryStateSearchSuccess(): + searchResults = state.searchResults; + setState(() { + _isLoading = false; + }); + break; + case DictionaryStateWordDetailsSuccess(): + // For now, just display the word details (no addition to deck) + setState(() { + _isLoading = false; + }); + break; + default: + setState(() { + _isLoading = false; + }); + break; + } + }, + ), + ], + child: 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: [ + SingleChildScrollView( + child: Column( + children: [ + _buildAppBar(context, deck.title), + const SizedBox(height: 10), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: _buildSearchBar(), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + decoration: BoxDecoration( + color: CustomTheme.mainColor3, + border: const Border( + left: BorderSide( + color: Colors.grey, + width: 1.0, + ), + right: BorderSide( + color: Colors.grey, + width: 1.0, + ), + bottom: BorderSide( + color: Colors.grey, + width: 1.0, + ), + top: BorderSide.none, + ), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(8), + bottomRight: Radius.circular(8), + ), + ), + child: + _focusNode.hasFocus && suggestions.isNotEmpty + ? _buildSuggestionsList() + : searchResults.isNotEmpty + ? _buildSearchResults() + : _buildEmptyState(), + ), + ), + if (context.watch().state + is DictionaryStateWordDetailsSuccess) + _buildWordDetails( + context.watch().state + as DictionaryStateWordDetailsSuccess, + ), + ], + ), + ), + if (_isLoading) _buildLoadingScreenWidget(), + ], + ); + } + 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.edit(widget.deckId)); + }, + ), + Text( + 'Add New Word to $title', + style: const TextStyle( + fontSize: 24, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + const SizedBox(width: 10), + ], + ), + ), + ), + ); + } + + Widget _buildSearchBar() { + return TextField( + controller: _searchController, + focusNode: _focusNode, + decoration: InputDecoration( + hintText: 'Search for a word to add', + prefixIcon: const Icon(Icons.search), + suffixIcon: + _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + setState(() { + _searchController.clear(); + suggestions.clear(); + searchResults.clear(); + }); + }, + ) + : null, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)), + ), + onChanged: (value) { + setState(() { + if (value.isNotEmpty) { + _onSuggestions(value); + } else { + suggestions.clear(); + searchResults.clear(); + } + }); + }, + onSubmitted: (value) { + if (value.isNotEmpty) { + _focusNode.unfocus(); + _onSearch(value); + } + }, + ); + } + + Widget _buildSuggestionsList() { + return ListView.separated( + shrinkWrap: true, + itemCount: suggestions.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final suggestion = suggestions[index]; + return ListTile( + title: Text(suggestion, style: const TextStyle(fontSize: 16)), + onTap: () { + _searchController.text = suggestion; + _focusNode.unfocus(); + _onSearch(suggestion); + }, + ); + }, + ); + } + + Widget _buildSearchResults() { + return ListView.separated( + shrinkWrap: true, + itemCount: searchResults.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final result = searchResults[index]; + return ListTile( + title: Text( + result.word, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (result.pronunciation.isNotEmpty) + Text( + result.pronunciation, + style: const TextStyle(fontSize: 14, color: Colors.grey), + ), + ...result.meanings.entries.map( + (entry) => Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + '- (${entry.key}) ${entry.value.join(", ")}', + style: const TextStyle(fontSize: 14), + ), + ), + ), + ], + ), + onTap: () { + _onGetWord(result.word); + }, + ); + }, + ); + } + + Widget _buildEmptyState() { + if (_focusNode.hasFocus && suggestions.isEmpty) { + return const Center( + child: Text( + 'No suggestions found', + style: TextStyle(fontSize: 16, color: Colors.grey), + ), + ); + } + return const Center( + child: Text( + 'Start typing to see suggestions', + style: TextStyle(fontSize: 16, color: Colors.grey), + ), + ); + } + + Widget _buildLoadingScreenWidget() { + return Stack( + children: [ + ModalBarrier( + dismissible: false, + color: Colors.black.withValues(alpha: 0.3), + ), + const Center(child: CircularProgressIndicator()), + ], + ); + } + + Widget _buildWordDetails(DictionaryStateWordDetailsSuccess state) { + final vocabulary = state.vocabulary; + return Padding( + padding: const EdgeInsets.all(16.0), + child: Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + vocabulary.word, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + if (vocabulary.pronunciation.isNotEmpty) + Text( + vocabulary.pronunciation, + style: const TextStyle( + fontSize: 16, + fontStyle: FontStyle.italic, + color: Colors.grey, + ), + ), + const SizedBox(height: 10), + ...vocabulary.wordTypes.map((wordType) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + wordType.type, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 5), + ...wordType.definitions.map( + (def) => Padding( + padding: const EdgeInsets.only(left: 10), + child: Text( + '- $def', + style: const TextStyle(fontSize: 16), + ), + ), + ), + if (wordType.examples.isNotEmpty) ...[ + const SizedBox(height: 10), + const Text( + 'Examples:', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ...wordType.examples.map( + (example) => Padding( + padding: const EdgeInsets.only(left: 10), + child: Text( + '- ${example.english} (${example.vietnamese})', + style: const TextStyle(fontSize: 14), + ), + ), + ), + ], + const SizedBox(height: 10), + ], + ); + }), + ], + ), + ), + ), + ); + } + + // Functions + void _onSearch(String query) { + if (query.isEmpty) return; + _focusNode.unfocus(); + _searchController.text = query; + context.read().add( + DictionaryEventSearch( + query: query, + lang: 'en', // Assuming English for flashcards + ), + ); + } + + void _onGetWord(String word) { + if (word.isEmpty) return; + context.read().add( + DictionaryEventGetWord(word: word, lang: 'en'), + ); + } + + void _onSuggestions(String prefix) { + if (prefix.isEmpty) return; + context.read().add( + DictionaryEventSuggestions(prefix: prefix, lang: 'en'), + ); + } +} diff --git a/lib/presentation/pages/home/edit_card_list_page.dart b/lib/presentation/pages/home/edit_card_list_page.dart index a63a439..6647996 100644 --- a/lib/presentation/pages/home/edit_card_list_page.dart +++ b/lib/presentation/pages/home/edit_card_list_page.dart @@ -102,7 +102,9 @@ class _EditCardListPageState extends State { const Spacer(), IconButton( icon: const Icon(FontAwesomeIcons.plus, color: Colors.white), - onPressed: null, + onPressed: () { + context.go(RouteName.addNewWord(widget.deckId)); + }, ), IconButton( icon: const Icon( From b03915b8c76391dc14382fa2f0dbb3010e18cfaf Mon Sep 17 00:00:00 2001 From: Sir Date: Tue, 3 Jun 2025 22:06:58 +0700 Subject: [PATCH 16/28] feat: add search and add card to deck --- .../dictionary/dtos/search_result_dto.dart | 8 ++-- .../flashcard/bloc/flashcard_bloc.dart | 31 ++++++++++++++ .../flashcard/bloc/flashcard_event.dart | 10 +++++ .../flashcard/data/flashcard_api_client.dart | 21 ++++++++++ .../flashcard/data/flashcard_repository.dart | 17 ++++++++ .../pages/home/add_new_word_page.dart | 41 +++++++++++++++++++ 6 files changed, 124 insertions(+), 4 deletions(-) diff --git a/lib/features/dictionary/dtos/search_result_dto.dart b/lib/features/dictionary/dtos/search_result_dto.dart index 3dd6487..d00d79d 100644 --- a/lib/features/dictionary/dtos/search_result_dto.dart +++ b/lib/features/dictionary/dtos/search_result_dto.dart @@ -54,6 +54,7 @@ class Data { } class Vocabulary { + final String id; final List img; final String word; final String pronunciation; @@ -62,6 +63,7 @@ class Vocabulary { final List examples; const Vocabulary({ + required this.id, required this.img, required this.word, required this.pronunciation, @@ -72,6 +74,7 @@ class Vocabulary { factory Vocabulary.fromJson(Map json) { return Vocabulary( + id: json['_id'] ?? '', img: (json['img'] as List?)?.map((e) => e.toString()).toList() ?? [], @@ -124,10 +127,7 @@ class Example { final String english; final String vietnamese; - const Example({ - required this.english, - required this.vietnamese, - }); + const Example({required this.english, required this.vietnamese}); factory Example.fromJson(Map json) { return Example( diff --git a/lib/features/flashcard/bloc/flashcard_bloc.dart b/lib/features/flashcard/bloc/flashcard_bloc.dart index a6f2a79..760d8c9 100644 --- a/lib/features/flashcard/bloc/flashcard_bloc.dart +++ b/lib/features/flashcard/bloc/flashcard_bloc.dart @@ -19,6 +19,7 @@ class FlashcardBloc extends Bloc { on(_onUpdateDeckRequested); on(_onDeleteTagRequested); on(_onSearchDecksRequested); + on(_onAddCardToDeckRequested); } Future _onCreateDeckRequested( @@ -282,6 +283,36 @@ class FlashcardBloc extends Bloc { ); } + Future _onAddCardToDeckRequested( + AddCardToDeckRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: FlashcardStatus.loading)); + + try { + await repository.addCardToDeck( + deckId: event.deckId, + cardId: event.cardId, + ); + + final updatedDeck = await repository.getDeckById(event.deckId); + + emit( + state.copyWith( + status: FlashcardStatus.success, + selectedDeck: updatedDeck, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: FlashcardStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + GroupedDecksResponseDto? filterDecksByName( GroupedDecksResponseDto groupedDecks, String query, diff --git a/lib/features/flashcard/bloc/flashcard_event.dart b/lib/features/flashcard/bloc/flashcard_event.dart index 566ce30..65bb00e 100644 --- a/lib/features/flashcard/bloc/flashcard_event.dart +++ b/lib/features/flashcard/bloc/flashcard_event.dart @@ -110,3 +110,13 @@ class SearchDecksRequested extends FlashcardEvent { @override List get props => [query]; } + +class AddCardToDeckRequested extends FlashcardEvent { + final String deckId; + final String cardId; + + const AddCardToDeckRequested({required this.deckId, required this.cardId}); + + @override + List get props => [deckId, cardId]; +} diff --git a/lib/features/flashcard/data/flashcard_api_client.dart b/lib/features/flashcard/data/flashcard_api_client.dart index a1fa4bb..e75e703 100644 --- a/lib/features/flashcard/data/flashcard_api_client.dart +++ b/lib/features/flashcard/data/flashcard_api_client.dart @@ -290,4 +290,25 @@ class FlashcardApiClient { } } } + + Future addCardToDeck({ + required String deckId, + required String cardId, + }) async { + final token = await authLocalDataSource.getToken(); + + final options = Options( + headers: {if (token != null) 'Authorization': 'Bearer $token'}, + ); + + final response = await dio.post( + '/deck/$deckId/cards', + data: {'cardId': cardId}, + options: options, + ); + + if (response.data['success'] != true) { + throw Exception(response.data['message'] ?? 'Failed to add card to deck'); + } + } } diff --git a/lib/features/flashcard/data/flashcard_repository.dart b/lib/features/flashcard/data/flashcard_repository.dart index 6701905..99de4be 100644 --- a/lib/features/flashcard/data/flashcard_repository.dart +++ b/lib/features/flashcard/data/flashcard_repository.dart @@ -108,4 +108,21 @@ class FlashcardRepository { Future deleteTag(String tagId) async { await apiClient.deleteTag(tagId); } + + Future addCardToDeck({ + required String deckId, + required String cardId, + }) async { + try { + await apiClient.addCardToDeck(deckId: deckId, cardId: cardId); + } on DioException catch (e) { + if (e.response != null) { + throw Exception( + e.response!.data['message'] ?? 'Failed to add card to deck', + ); + } else { + throw Exception(e.message); + } + } + } } diff --git a/lib/presentation/pages/home/add_new_word_page.dart b/lib/presentation/pages/home/add_new_word_page.dart index bd1d059..962a100 100644 --- a/lib/presentation/pages/home/add_new_word_page.dart +++ b/lib/presentation/pages/home/add_new_word_page.dart @@ -414,6 +414,43 @@ class _AddNewWordPageState extends State { ), ], const SizedBox(height: 10), + Center( + child: TextButton( + onPressed: () { + final state = + context.read().state + as DictionaryStateWordDetailsSuccess; + context.read().add( + AddCardToDeckRequested( + deckId: widget.deckId, + cardId: state.vocabulary.id, + ), + ); + context.go(RouteName.edit(widget.deckId)); + }, + style: TextButton.styleFrom( + backgroundColor: CustomTheme.flashcardColor.withAlpha( + (255 * 0.1).round(), + ), + foregroundColor: CustomTheme.flashcardColor, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide(color: CustomTheme.flashcardColor), + ), + ), + child: const Text( + 'Add to Deck', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), ], ); }), @@ -439,6 +476,10 @@ class _AddNewWordPageState extends State { void _onGetWord(String word) { if (word.isEmpty) return; + setState(() { + suggestions.clear(); + searchResults.clear(); + }); context.read().add( DictionaryEventGetWord(word: word, lang: 'en'), ); From 44130e9da0b68444b6c6a59202f11041399c5f89 Mon Sep 17 00:00:00 2001 From: Sir Date: Wed, 4 Jun 2025 00:49:13 +0700 Subject: [PATCH 17/28] fix: not update card on flashcard topic --- .../flashcard/bloc/flashcard_bloc.dart | 3 + .../pages/home/learning_flashcard_page.dart | 157 +++++++++++++++--- 2 files changed, 138 insertions(+), 22 deletions(-) diff --git a/lib/features/flashcard/bloc/flashcard_bloc.dart b/lib/features/flashcard/bloc/flashcard_bloc.dart index 760d8c9..e0f2edf 100644 --- a/lib/features/flashcard/bloc/flashcard_bloc.dart +++ b/lib/features/flashcard/bloc/flashcard_bloc.dart @@ -295,12 +295,15 @@ class FlashcardBloc extends Bloc { cardId: event.cardId, ); + final groupedDecks = await repository.getDecks(); + final updatedDeck = await repository.getDeckById(event.deckId); emit( state.copyWith( status: FlashcardStatus.success, selectedDeck: updatedDeck, + groupedDecks: groupedDecks, ), ); } catch (e) { diff --git a/lib/presentation/pages/home/learning_flashcard_page.dart b/lib/presentation/pages/home/learning_flashcard_page.dart index 68ab06b..5f27d17 100644 --- a/lib/presentation/pages/home/learning_flashcard_page.dart +++ b/lib/presentation/pages/home/learning_flashcard_page.dart @@ -1,36 +1,145 @@ 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/learning_card_list.dart'; +import 'package:lacquer/presentation/pages/home/widgets/speech_adjustment.dart'; class LearningFlashcardPage extends StatefulWidget { final String deckId; - const LearningFlashcardPage({super.key, required this.deckId}); - @override State createState() => _LearningFlashcardPageState(); } class _LearningFlashcardPageState 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: Stack( - children: [ - _buildAppBar(context), - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: const [Text('something')], - ), - ], + 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), + 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: LearningCardList( + cards: deck.cards ?? [], + onScrollProgress: _updateProgress, + speechRate: _speechRate, + selectedAccent: _selectedAccent, + ), + ), + const SizedBox(height: 20), + if (deck.cards == null || deck.cards!.isEmpty) + const Text('No cards available in this deck'), + ], + ), + ], + ); + } + return const Center(child: Text('No deck data available')); + }, ), ); } - Widget _buildAppBar(BuildContext context) { + Widget _buildAppBar(BuildContext context, String? title) { return ClipRRect( borderRadius: const BorderRadius.only( bottomLeft: Radius.circular(8), @@ -54,21 +163,25 @@ class _LearningFlashcardPageState extends State { context.go(RouteName.flashcards); }, ), - Expanded( - child: Center( - child: Text( - 'Flashcards', - style: const TextStyle( - fontSize: 24, - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), + Text( + title ?? '', + style: const TextStyle( + fontSize: 24, + color: Colors.white, + fontWeight: FontWeight.bold, ), ), const SizedBox(width: 15), + const Spacer(), IconButton( - icon: const Icon(FontAwesomeIcons.plus, color: Colors.white), + 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), From 089ea505f39f79ef2d25e331a3dd7b971e73b53d Mon Sep 17 00:00:00 2001 From: Sir Date: Wed, 4 Jun 2025 01:23:20 +0700 Subject: [PATCH 18/28] chore: add isDone field to getDeckDto --- lib/features/flashcard/dtos/get_deck_dto.dart | 6 +++-- .../pages/home/widgets/flashcard_tag.dart | 1 + .../pages/home/widgets/flashcard_topic.dart | 27 ++++++++++--------- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/lib/features/flashcard/dtos/get_deck_dto.dart b/lib/features/flashcard/dtos/get_deck_dto.dart index b7e6742..7a918ed 100644 --- a/lib/features/flashcard/dtos/get_deck_dto.dart +++ b/lib/features/flashcard/dtos/get_deck_dto.dart @@ -7,6 +7,7 @@ class GetDeckDto { final List>? cards; final DateTime? createdAt; final DateTime? updatedAt; + final bool? isDone; GetDeckDto({ required this.id, @@ -17,10 +18,10 @@ class GetDeckDto { this.cards, this.createdAt, this.updatedAt, + this.isDone, }); factory GetDeckDto.fromJson(Map json) { - // Handle tags parsing - they might be objects or strings List parsedTags = []; final tagsData = json['tags']; if (tagsData is List) { @@ -28,7 +29,6 @@ class GetDeckDto { if (tag is String) { parsedTags.add(tag); } else if (tag is Map) { - // If tag is an object, extract the ID or name parsedTags.add(tag['_id'] as String? ?? tag['id'] as String? ?? ''); } } @@ -52,6 +52,7 @@ class GetDeckDto { json['updatedAt'] != null ? DateTime.parse(json['updatedAt'] as String) : null, + isDone: json['isDone'] as bool? ?? false, ); } @@ -65,6 +66,7 @@ class GetDeckDto { 'cards': cards, 'createdAt': createdAt?.toIso8601String(), 'updatedAt': updatedAt?.toIso8601String(), + 'isDone': isDone, }; } } diff --git a/lib/presentation/pages/home/widgets/flashcard_tag.dart b/lib/presentation/pages/home/widgets/flashcard_tag.dart index 3290fb6..d26a3d3 100644 --- a/lib/presentation/pages/home/widgets/flashcard_tag.dart +++ b/lib/presentation/pages/home/widgets/flashcard_tag.dart @@ -154,6 +154,7 @@ class FlashcardTag extends StatelessWidget { cardCount: decks[index].cards?.length ?? 0, tags: decks[index].tags, imagePath: decks[index].img ?? 'assets/images/lacquerBlack.png', + isDone: decks[index].isDone ?? false, ); }, ), diff --git a/lib/presentation/pages/home/widgets/flashcard_topic.dart b/lib/presentation/pages/home/widgets/flashcard_topic.dart index 2acf2e8..71403fe 100644 --- a/lib/presentation/pages/home/widgets/flashcard_topic.dart +++ b/lib/presentation/pages/home/widgets/flashcard_topic.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:percent_indicator/circular_percent_indicator.dart'; import 'package:lacquer/presentation/pages/home/widgets/flashcard_options.dart'; class FlashcardTopic extends StatefulWidget { @@ -8,6 +7,7 @@ class FlashcardTopic extends StatefulWidget { final int cardCount; final List tags; final String imagePath; + final bool isDone; const FlashcardTopic({ super.key, @@ -16,6 +16,7 @@ class FlashcardTopic extends StatefulWidget { required this.cardCount, required this.tags, required this.imagePath, + required this.isDone, }); @override @@ -107,19 +108,19 @@ class FlashcardTopicState extends State { ), ), const SizedBox(width: 12), - CircularPercentIndicator( - radius: 20.0, - lineWidth: 4.0, - percent: 0.8, - center: const Text( - "80%", - style: TextStyle( - color: Colors.black, - fontSize: 12, - ), + Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: + widget.isDone ? Colors.green.shade100 : null, + ), + padding: const EdgeInsets.all(8), + child: Icon( + widget.isDone ? Icons.check_circle : null, + color: + widget.isDone ? Colors.green.shade700 : null, + size: 24, ), - backgroundColor: Colors.grey.shade300, - progressColor: Colors.green, ), ], ), From dae925e6dd95014d726ec60aff4d1e244f644506 Mon Sep 17 00:00:00 2001 From: Sir Date: Wed, 4 Jun 2025 23:50:12 +0700 Subject: [PATCH 19/28] feat: finish learning deck API --- .../flashcard/bloc/flashcard_bloc.dart | 29 ++++++ .../flashcard/bloc/flashcard_event.dart | 9 ++ .../flashcard/data/flashcard_api_client.dart | 25 +++++ .../flashcard/data/flashcard_repository.dart | 10 ++ .../flashcard/dtos/create_deck_dto.dart | 3 + .../flashcard/dtos/finish_deck_dto.dart | 95 +++++++++++++++++++ .../pages/home/learning_flashcard_page.dart | 2 + .../home/widgets/learning_card_list.dart | 15 +++ 8 files changed, 188 insertions(+) create mode 100644 lib/features/flashcard/dtos/finish_deck_dto.dart diff --git a/lib/features/flashcard/bloc/flashcard_bloc.dart b/lib/features/flashcard/bloc/flashcard_bloc.dart index e0f2edf..697483a 100644 --- a/lib/features/flashcard/bloc/flashcard_bloc.dart +++ b/lib/features/flashcard/bloc/flashcard_bloc.dart @@ -20,6 +20,7 @@ class FlashcardBloc extends Bloc { on(_onDeleteTagRequested); on(_onSearchDecksRequested); on(_onAddCardToDeckRequested); + on(_onFinishDeckRequested); } Future _onCreateDeckRequested( @@ -348,4 +349,32 @@ class FlashcardBloc extends Bloc { ); return GroupedDecksResponseDto(count: newCount, data: filteredItems); } + + Future _onFinishDeckRequested( + FinishDeckRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: FlashcardStatus.loading)); + + try { + await repository.finishDeck(event.deckId); + final groupedDecks = await repository.getDecks(); + final updatedDeck = await repository.getDeckById(event.deckId); + + emit( + state.copyWith( + status: FlashcardStatus.success, + selectedDeck: updatedDeck, + groupedDecks: groupedDecks, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: FlashcardStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } } diff --git a/lib/features/flashcard/bloc/flashcard_event.dart b/lib/features/flashcard/bloc/flashcard_event.dart index 65bb00e..dce799d 100644 --- a/lib/features/flashcard/bloc/flashcard_event.dart +++ b/lib/features/flashcard/bloc/flashcard_event.dart @@ -120,3 +120,12 @@ class AddCardToDeckRequested extends FlashcardEvent { @override List get props => [deckId, cardId]; } + +class FinishDeckRequested extends FlashcardEvent { + final String deckId; + + const FinishDeckRequested({required this.deckId}); + + @override + List get props => [deckId]; +} diff --git a/lib/features/flashcard/data/flashcard_api_client.dart b/lib/features/flashcard/data/flashcard_api_client.dart index e75e703..d2dfbc4 100644 --- a/lib/features/flashcard/data/flashcard_api_client.dart +++ b/lib/features/flashcard/data/flashcard_api_client.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.dart'; import 'package:http_parser/http_parser.dart'; +import 'package:lacquer/features/flashcard/dtos/finish_deck_dto.dart'; import 'package:lacquer/features/flashcard/dtos/update_deck_dto.dart'; import 'package:lacquer/features/flashcard/dtos/update_tag_dto.dart'; import 'dart:io'; @@ -207,6 +208,30 @@ class FlashcardApiClient { } } + Future finishDeck(String deckId) async { + try { + final token = await authLocalDataSource.getToken(); + final options = Options( + headers: {if (token != null) 'Authorization': 'Bearer $token'}, + ); + + final response = await dio.put('/deck/$deckId/finish', options: options); + + final responseData = response.data as Map; + if (responseData['success'] != true) { + throw Exception(responseData['message'] ?? 'Failed to finish deck'); + } + + return FinishDeckResponseDto.fromJson(responseData); + } on DioException catch (e) { + if (e.response != null) { + throw Exception(e.response!.data['message'] ?? 'Failed to finish deck'); + } else { + throw Exception(e.message); + } + } + } + Future deleteTag(String tagId) async { try { final token = await authLocalDataSource.getToken(); diff --git a/lib/features/flashcard/data/flashcard_repository.dart b/lib/features/flashcard/data/flashcard_repository.dart index 99de4be..88447b2 100644 --- a/lib/features/flashcard/data/flashcard_repository.dart +++ b/lib/features/flashcard/data/flashcard_repository.dart @@ -2,6 +2,7 @@ import 'package:dio/dio.dart'; import 'package:lacquer/features/flashcard/dtos/card_dto.dart'; import 'dart:io'; import 'package:lacquer/features/flashcard/dtos/create_tag_dto.dart'; +import 'package:lacquer/features/flashcard/dtos/finish_deck_dto.dart'; import 'package:lacquer/features/flashcard/dtos/grouped_decks_dto.dart'; import 'package:lacquer/features/flashcard/dtos/update_deck_dto.dart'; import 'package:lacquer/features/flashcard/dtos/update_tag_dto.dart'; @@ -125,4 +126,13 @@ class FlashcardRepository { } } } + + Future finishDeck(String deckId) async { + try { + final response = await apiClient.finishDeck(deckId); + return response; + } catch (e) { + throw Exception('Failed to finish deck: $e'); + } + } } diff --git a/lib/features/flashcard/dtos/create_deck_dto.dart b/lib/features/flashcard/dtos/create_deck_dto.dart index 8e7ebe1..eb25f1b 100644 --- a/lib/features/flashcard/dtos/create_deck_dto.dart +++ b/lib/features/flashcard/dtos/create_deck_dto.dart @@ -63,6 +63,7 @@ class CreateDeckResponseDto { final List? cards; final String? userId; final DateTime? createdAt; + final bool? isDone; CreateDeckResponseDto({ this.id, @@ -73,6 +74,7 @@ class CreateDeckResponseDto { this.cards, this.userId, this.createdAt, + this.isDone, }); factory CreateDeckResponseDto.fromJson(Map json) { @@ -112,6 +114,7 @@ class CreateDeckResponseDto { json['createdAt'] != null ? DateTime.tryParse(json['createdAt'] as String) : null, + isDone: json['isDone'] as bool? ?? false, ); } diff --git a/lib/features/flashcard/dtos/finish_deck_dto.dart b/lib/features/flashcard/dtos/finish_deck_dto.dart new file mode 100644 index 0000000..49e924b --- /dev/null +++ b/lib/features/flashcard/dtos/finish_deck_dto.dart @@ -0,0 +1,95 @@ +class FinishDeckResponseDto { + final bool success; + final String message; + final DeckDto data; + + FinishDeckResponseDto({ + required this.success, + required this.message, + required this.data, + }); + + factory FinishDeckResponseDto.fromJson(Map json) { + return FinishDeckResponseDto( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: DeckDto.fromJson(json['data']), + ); + } +} + +class DeckDto { + final String id; + final String title; + final List tags; + final String? description; + final String? img; + final List? + cards; // Changed from List> to List + final DateTime? createdAt; + final DateTime? updatedAt; + final bool? isDone; + final String? owner; // Added + final int? v; // Added for __v + + DeckDto({ + required this.id, + required this.title, + required this.tags, + this.description, + this.img, + this.cards, + this.createdAt, + this.updatedAt, + this.isDone, + this.owner, + this.v, + }); + + factory DeckDto.fromJson(Map json) { + List parsedTags = []; + final tagsData = json['tags']; + if (tagsData is List) { + for (var tag in tagsData) { + if (tag is String) { + parsedTags.add(tag); + } else if (tag is Map) { + parsedTags.add(tag['_id'] as String? ?? tag['id'] as String? ?? ''); + } + } + } + + return DeckDto( + id: json['_id'] as String? ?? '', + title: json['title'] as String? ?? '', + tags: parsedTags, + description: json['description'] as String?, + img: json['img'] as String?, + cards: + (json['cards'] as List?)?.map((e) => e.toString()).toList(), + createdAt: + json['createdAt'] != null ? DateTime.parse(json['createdAt']) : null, + updatedAt: + json['updatedAt'] != null ? DateTime.parse(json['updatedAt']) : null, + isDone: json['isDone'] as bool? ?? false, + owner: json['owner'] as String?, + v: json['__v'] as int?, + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'tags': tags, + 'description': description, + 'img': img, + 'cards': cards, + 'createdAt': createdAt?.toIso8601String(), + 'updatedAt': updatedAt?.toIso8601String(), + 'isDone': isDone, + 'owner': owner, + '__v': v, + }; + } +} diff --git a/lib/presentation/pages/home/learning_flashcard_page.dart b/lib/presentation/pages/home/learning_flashcard_page.dart index 5f27d17..7c7bb13 100644 --- a/lib/presentation/pages/home/learning_flashcard_page.dart +++ b/lib/presentation/pages/home/learning_flashcard_page.dart @@ -119,10 +119,12 @@ class _LearningFlashcardPageState extends State { ), Expanded( child: LearningCardList( + deckId: widget.deckId, cards: deck.cards ?? [], onScrollProgress: _updateProgress, speechRate: _speechRate, selectedAccent: _selectedAccent, + isDone: deck.isDone ?? false, ), ), const SizedBox(height: 20), diff --git a/lib/presentation/pages/home/widgets/learning_card_list.dart b/lib/presentation/pages/home/widgets/learning_card_list.dart index d0f3427..9a5c64c 100644 --- a/lib/presentation/pages/home/widgets/learning_card_list.dart +++ b/lib/presentation/pages/home/widgets/learning_card_list.dart @@ -1,19 +1,26 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lacquer/features/flashcard/bloc/flashcard_bloc.dart'; +import 'package:lacquer/features/flashcard/bloc/flashcard_event.dart'; import 'package:lacquer/features/flashcard/dtos/card_dto.dart'; import 'package:lacquer/presentation/pages/home/widgets/learning_card.dart'; class LearningCardList extends StatefulWidget { + final String deckId; final List cards; final Function(double)? onScrollProgress; final double speechRate; final String selectedAccent; + final bool isDone; const LearningCardList({ super.key, + required this.deckId, required this.cards, this.onScrollProgress, required this.speechRate, required this.selectedAccent, + required this.isDone, }); @override @@ -36,6 +43,14 @@ class _LearningCardListState extends State { final maxPages = widget.cards.length - 1; final progress = maxPages > 0 ? index / maxPages : 0.0; widget.onScrollProgress?.call(progress.clamp(0.0, 1.0)); + + if (index == widget.cards.length - 1 && + widget.cards.isNotEmpty && + !widget.isDone) { + context.read().add( + FinishDeckRequested(deckId: widget.deckId), + ); + } } } From 27c39bf751b522ed741a69a43dad670ef1b30746 Mon Sep 17 00:00:00 2001 From: Sir Date: Thu, 5 Jun 2025 01:01:37 +0700 Subject: [PATCH 20/28] feat: add complete card for finish deck learning --- .../flashcard/dtos/finish_deck_dto.dart | 7 +- .../home/widgets/learning_card_list.dart | 66 ++++++++++++++++--- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/lib/features/flashcard/dtos/finish_deck_dto.dart b/lib/features/flashcard/dtos/finish_deck_dto.dart index 49e924b..0bd9ac3 100644 --- a/lib/features/flashcard/dtos/finish_deck_dto.dart +++ b/lib/features/flashcard/dtos/finish_deck_dto.dart @@ -24,13 +24,12 @@ class DeckDto { final List tags; final String? description; final String? img; - final List? - cards; // Changed from List> to List + final List? cards; final DateTime? createdAt; final DateTime? updatedAt; final bool? isDone; - final String? owner; // Added - final int? v; // Added for __v + final String? owner; + final int? v; DeckDto({ required this.id, diff --git a/lib/presentation/pages/home/widgets/learning_card_list.dart b/lib/presentation/pages/home/widgets/learning_card_list.dart index 9a5c64c..a7c9850 100644 --- a/lib/presentation/pages/home/widgets/learning_card_list.dart +++ b/lib/presentation/pages/home/widgets/learning_card_list.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lacquer/config/router.dart'; import 'package:lacquer/features/flashcard/bloc/flashcard_bloc.dart'; import 'package:lacquer/features/flashcard/bloc/flashcard_event.dart'; import 'package:lacquer/features/flashcard/dtos/card_dto.dart'; @@ -40,12 +42,12 @@ class _LearningCardListState extends State { void _onPageChanged(int index) { if (index > _highestPageReached) { _highestPageReached = index; - final maxPages = widget.cards.length - 1; + final maxPages = widget.cards.length; final progress = maxPages > 0 ? index / maxPages : 0.0; widget.onScrollProgress?.call(progress.clamp(0.0, 1.0)); - if (index == widget.cards.length - 1 && - widget.cards.isNotEmpty && + if (widget.cards.isNotEmpty && + index == widget.cards.length && !widget.isDone) { context.read().add( FinishDeckRequested(deckId: widget.deckId), @@ -62,17 +64,63 @@ class _LearningCardListState extends State { @override Widget build(BuildContext context) { + final hasCards = widget.cards.isNotEmpty; + final totalPages = hasCards ? widget.cards.length + 1 : 0; + return PageView.builder( controller: _pageController, - itemCount: widget.cards.length, + itemCount: totalPages, onPageChanged: _onPageChanged, itemBuilder: (context, index) { - return LearningCard( - card: widget.cards[index], - speechRate: widget.speechRate, - selectedAccent: widget.selectedAccent, - ); + if (index < widget.cards.length) { + return LearningCard( + card: widget.cards[index], + speechRate: widget.speechRate, + selectedAccent: widget.selectedAccent, + ); + } else { + return _buildCompletionCard(context); + } }, ); } } + +Widget _buildCompletionCard(BuildContext context) { + 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 8fa1d099b99e4b0bb2e6e23dc5ac4c52b90555a8 Mon Sep 17 00:00:00 2001 From: Sir Date: Thu, 5 Jun 2025 01:43:46 +0700 Subject: [PATCH 21/28] fix: card list reload when reach last card --- .../pages/home/widgets/learning_card_list.dart | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/lib/presentation/pages/home/widgets/learning_card_list.dart b/lib/presentation/pages/home/widgets/learning_card_list.dart index a7c9850..1777cf8 100644 --- a/lib/presentation/pages/home/widgets/learning_card_list.dart +++ b/lib/presentation/pages/home/widgets/learning_card_list.dart @@ -45,14 +45,6 @@ class _LearningCardListState extends State { final maxPages = widget.cards.length; final progress = maxPages > 0 ? index / maxPages : 0.0; widget.onScrollProgress?.call(progress.clamp(0.0, 1.0)); - - if (widget.cards.isNotEmpty && - index == widget.cards.length && - !widget.isDone) { - context.read().add( - FinishDeckRequested(deckId: widget.deckId), - ); - } } } @@ -79,14 +71,14 @@ class _LearningCardListState extends State { selectedAccent: widget.selectedAccent, ); } else { - return _buildCompletionCard(context); + return _buildCompletionCard(context, widget.deckId, widget.isDone); } }, ); } } -Widget _buildCompletionCard(BuildContext context) { +Widget _buildCompletionCard(BuildContext context, String deckId, bool isDone) { return Center( child: Padding( padding: const EdgeInsets.all(24.0), @@ -109,6 +101,11 @@ Widget _buildCompletionCard(BuildContext context) { const SizedBox(height: 32), ElevatedButton.icon( onPressed: () { + if (isDone != true) { + context.read().add( + FinishDeckRequested(deckId: deckId), + ); + } context.go(RouteName.flashcards); }, icon: const Icon(Icons.check), From 30c7037028f76ae2a6a0e7bfb86b92f91a058abe Mon Sep 17 00:00:00 2001 From: Sir Date: Thu, 5 Jun 2025 11:26:26 +0700 Subject: [PATCH 22/28] feat: delete card from deck API --- .../flashcard/bloc/flashcard_bloc.dart | 29 +++++++++++++++++++ .../flashcard/bloc/flashcard_event.dart | 10 +++++++ .../flashcard/data/flashcard_api_client.dart | 12 ++++++++ .../flashcard/data/flashcard_repository.dart | 4 +++ .../pages/home/edit_card_list_page.dart | 2 +- .../pages/home/widgets/card_item.dart | 5 ---- .../pages/home/widgets/card_item_list.dart | 2 +- 7 files changed, 57 insertions(+), 7 deletions(-) diff --git a/lib/features/flashcard/bloc/flashcard_bloc.dart b/lib/features/flashcard/bloc/flashcard_bloc.dart index 697483a..45e1630 100644 --- a/lib/features/flashcard/bloc/flashcard_bloc.dart +++ b/lib/features/flashcard/bloc/flashcard_bloc.dart @@ -18,6 +18,7 @@ class FlashcardBloc extends Bloc { on(_onDeleteDeckRequested); on(_onUpdateDeckRequested); on(_onDeleteTagRequested); + on(_onDeleteCardRequested); on(_onSearchDecksRequested); on(_onAddCardToDeckRequested); on(_onFinishDeckRequested); @@ -261,6 +262,34 @@ class FlashcardBloc extends Bloc { } } + Future _onDeleteCardRequested( + DeleteCardRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: FlashcardStatus.loading)); + + final updatedDeck = await repository.getDeckById(event.deckId); + final groupedDecks = await repository.getDecks(); + + try { + await repository.deleteCard(event.deckId, event.cardId); + emit( + state.copyWith( + status: FlashcardStatus.success, + selectedDeck: updatedDeck, + groupedDecks: groupedDecks, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: FlashcardStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + void _onSearchDecksRequested( SearchDecksRequested event, Emitter emit, diff --git a/lib/features/flashcard/bloc/flashcard_event.dart b/lib/features/flashcard/bloc/flashcard_event.dart index dce799d..e003246 100644 --- a/lib/features/flashcard/bloc/flashcard_event.dart +++ b/lib/features/flashcard/bloc/flashcard_event.dart @@ -102,6 +102,16 @@ class DeleteTagRequested extends FlashcardEvent { List get props => [tagId]; } +class DeleteCardRequested extends FlashcardEvent { + final String deckId; + final String cardId; + + const DeleteCardRequested(this.deckId, this.cardId); + + @override + List get props => [deckId, cardId]; +} + class SearchDecksRequested extends FlashcardEvent { final String query; diff --git a/lib/features/flashcard/data/flashcard_api_client.dart b/lib/features/flashcard/data/flashcard_api_client.dart index d2dfbc4..b8a2213 100644 --- a/lib/features/flashcard/data/flashcard_api_client.dart +++ b/lib/features/flashcard/data/flashcard_api_client.dart @@ -244,6 +244,18 @@ class FlashcardApiClient { } } + Future deleteCard(String deckId, String cardId) async { + try { + final token = await authLocalDataSource.getToken(); + final options = Options( + headers: {if (token != null) 'Authorization': 'Bearer $token'}, + ); + await dio.delete('/deck/$deckId/cards/$cardId', options: options); + } on DioException catch (e) { + throw Exception(e.response?.data['message'] ?? e.message); + } + } + Future deleteDeck(String deckId) async { try { final token = await authLocalDataSource.getToken(); diff --git a/lib/features/flashcard/data/flashcard_repository.dart b/lib/features/flashcard/data/flashcard_repository.dart index 88447b2..59d13bc 100644 --- a/lib/features/flashcard/data/flashcard_repository.dart +++ b/lib/features/flashcard/data/flashcard_repository.dart @@ -110,6 +110,10 @@ class FlashcardRepository { await apiClient.deleteTag(tagId); } + Future deleteCard(String deckId, String cardId) async { + await apiClient.deleteCard(deckId, cardId); + } + Future addCardToDeck({ required String deckId, required String cardId, diff --git a/lib/presentation/pages/home/edit_card_list_page.dart b/lib/presentation/pages/home/edit_card_list_page.dart index 6647996..e5922d9 100644 --- a/lib/presentation/pages/home/edit_card_list_page.dart +++ b/lib/presentation/pages/home/edit_card_list_page.dart @@ -45,7 +45,7 @@ class _EditCardListPageState extends State { _buildAppBar(context, deck.title), Column( children: [ - const SizedBox(height: 80), + const SizedBox(height: 70), Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0, diff --git a/lib/presentation/pages/home/widgets/card_item.dart b/lib/presentation/pages/home/widgets/card_item.dart index 9c31605..798d324 100644 --- a/lib/presentation/pages/home/widgets/card_item.dart +++ b/lib/presentation/pages/home/widgets/card_item.dart @@ -17,11 +17,6 @@ class _CardItemState extends State super.initState(); } - @override - void dispose() { - super.dispose(); - } - @override Widget build(BuildContext context) { return Padding( diff --git a/lib/presentation/pages/home/widgets/card_item_list.dart b/lib/presentation/pages/home/widgets/card_item_list.dart index e8a8404..d6e259e 100644 --- a/lib/presentation/pages/home/widgets/card_item_list.dart +++ b/lib/presentation/pages/home/widgets/card_item_list.dart @@ -16,7 +16,7 @@ class CardItemList extends StatelessWidget { return ListView.separated( padding: const EdgeInsets.all(12.0), itemCount: cards.length, - separatorBuilder: (context, index) => const SizedBox(height: 8), + separatorBuilder: (context, index) => const SizedBox(height: 4), itemBuilder: (context, index) { return CardItem(card: cards[index]); }, From 609584b154c5e372735c75ae2d05809f08fb8d66 Mon Sep 17 00:00:00 2001 From: Sir Date: Thu, 5 Jun 2025 21:14:23 +0700 Subject: [PATCH 23/28] feat: multi selection mode to manage card, apply delete card --- .../flashcard/bloc/flashcard_bloc.dart | 8 +- .../pages/home/edit_card_list_page.dart | 151 +++++++++++++++--- .../pages/home/widgets/card_item.dart | 45 ++++-- .../pages/home/widgets/card_item_list.dart | 27 +++- 4 files changed, 183 insertions(+), 48 deletions(-) diff --git a/lib/features/flashcard/bloc/flashcard_bloc.dart b/lib/features/flashcard/bloc/flashcard_bloc.dart index 45e1630..20e522c 100644 --- a/lib/features/flashcard/bloc/flashcard_bloc.dart +++ b/lib/features/flashcard/bloc/flashcard_bloc.dart @@ -268,11 +268,12 @@ class FlashcardBloc extends Bloc { ) async { emit(state.copyWith(status: FlashcardStatus.loading)); - final updatedDeck = await repository.getDeckById(event.deckId); - final groupedDecks = await repository.getDecks(); - try { await repository.deleteCard(event.deckId, event.cardId); + + final updatedDeck = await repository.getDeckById(event.deckId); + final groupedDecks = await repository.getDecks(); + emit( state.copyWith( status: FlashcardStatus.success, @@ -326,7 +327,6 @@ class FlashcardBloc extends Bloc { ); final groupedDecks = await repository.getDecks(); - final updatedDeck = await repository.getDeckById(event.deckId); emit( diff --git a/lib/presentation/pages/home/edit_card_list_page.dart b/lib/presentation/pages/home/edit_card_list_page.dart index e5922d9..437e108 100644 --- a/lib/presentation/pages/home/edit_card_list_page.dart +++ b/lib/presentation/pages/home/edit_card_list_page.dart @@ -7,7 +7,8 @@ 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/card_item_list.dart'; +import 'package:lacquer/features/flashcard/dtos/card_dto.dart'; +import 'package:lacquer/presentation/pages/home/widgets/card_item.dart'; class EditCardListPage extends StatefulWidget { final String deckId; @@ -19,16 +20,73 @@ class EditCardListPage extends StatefulWidget { } class _EditCardListPageState extends State { + bool isMultiSelectMode = false; + final Set selectedCardIds = {}; + @override void initState() { super.initState(); context.read().add(LoadDeckByIdRequested(widget.deckId)); } + void _onCardTap(CardDto card) { + final cardId = card.id; + if (cardId == null) return; + if (!isMultiSelectMode) return; + setState(() { + selectedCardIds.contains(card.id) + ? selectedCardIds.remove(card.id) + : selectedCardIds.add(cardId); + }); + } + + void _onCardLongPress(CardDto card) { + final cardId = card.id; + if (cardId == null) return; + setState(() { + isMultiSelectMode = true; + selectedCardIds.add(cardId); + }); + } + + void _exitMultiSelect() { + setState(() { + isMultiSelectMode = false; + selectedCardIds.clear(); + }); + } + + void _selectAll(List cards) { + setState(() { + selectedCardIds.addAll(cards.map((c) => c.id).whereType()); + }); + } + + void _deleteSelected() { + final bloc = context.read(); + for (final cardId in selectedCardIds) { + bloc.add(DeleteCardRequested(widget.deckId, cardId)); + } + _exitMultiSelect(); + } + @override Widget build(BuildContext context) { return Scaffold( backgroundColor: CustomTheme.lightbeige, + appBar: PreferredSize( + preferredSize: const Size.fromHeight(90), + child: BlocBuilder( + builder: (context, state) { + final title = state.selectedDeck?.title ?? ''; + return _buildAppBar( + context, + title, + state.selectedDeck?.cards ?? [], + ); + }, + ), + ), body: BlocBuilder( builder: (context, state) { if (state.status == FlashcardStatus.loading) { @@ -40,24 +98,33 @@ class _EditCardListPageState extends State { } else if (state.status == FlashcardStatus.success && state.selectedDeck != null) { final deck = state.selectedDeck!; - return Stack( + return Column( children: [ - _buildAppBar(context, deck.title), - Column( - children: [ - const SizedBox(height: 70), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 8.0, - ), - ), - Expanded(child: CardItemList(cards: deck.cards ?? [])), - const SizedBox(height: 20), - if (deck.cards == null || deck.cards!.isEmpty) - const Text('No cards available in this deck'), - ], + Expanded( + child: ListView.separated( + padding: const EdgeInsets.all(12.0), + itemCount: deck.cards?.length ?? 0, + separatorBuilder: (_, __) => const SizedBox(height: 4), + itemBuilder: (context, index) { + final card = deck.cards![index]; + final isSelected = selectedCardIds.contains(card.id); + return GestureDetector( + onTap: () => _onCardTap(card), + onLongPress: () => _onCardLongPress(card), + child: CardItem( + card: card, + isSelected: isSelected, + isMultiSelectMode: isMultiSelectMode, + ), + ); + }, + ), ), + if (deck.cards == null || deck.cards!.isEmpty) + const Padding( + padding: EdgeInsets.all(16.0), + child: Text('No cards available in this deck'), + ), ], ); } @@ -67,7 +134,44 @@ class _EditCardListPageState extends State { ); } - Widget _buildAppBar(BuildContext context, String? title) { + Widget _buildAppBar(BuildContext context, String title, List cards) { + if (isMultiSelectMode) { + return AppBar( + backgroundColor: CustomTheme.mainColor1, + title: Text( + '${selectedCardIds.length} selected', + style: TextStyle(color: Colors.white), + ), + leading: IconButton( + icon: const Icon(Icons.close), + color: Colors.white, + onPressed: _exitMultiSelect, + ), + actions: [ + IconButton( + icon: const Icon(Icons.select_all), + color: CustomTheme.mainColor3, + onPressed: () => _selectAll(cards), + ), + IconButton( + icon: const Icon(Icons.copy), + color: CustomTheme.mainColor3, + onPressed: () {}, + ), + IconButton( + icon: const Icon(Icons.drive_file_move), + color: CustomTheme.mainColor3, + onPressed: () {}, + ), + IconButton( + icon: const Icon(Icons.delete), + color: CustomTheme.mainColor3, + onPressed: _deleteSelected, + ), + ], + ); + } + return ClipRRect( borderRadius: const BorderRadius.only( bottomLeft: Radius.circular(8), @@ -87,12 +191,10 @@ class _EditCardListPageState extends State { FontAwesomeIcons.arrowLeft, color: Colors.white, ), - onPressed: () { - context.go(RouteName.flashcards); - }, + onPressed: () => context.go(RouteName.flashcards), ), Text( - title ?? '', + title, style: const TextStyle( fontSize: 24, color: Colors.white, @@ -102,9 +204,8 @@ class _EditCardListPageState extends State { const Spacer(), IconButton( icon: const Icon(FontAwesomeIcons.plus, color: Colors.white), - onPressed: () { - context.go(RouteName.addNewWord(widget.deckId)); - }, + onPressed: + () => context.go(RouteName.addNewWord(widget.deckId)), ), IconButton( icon: const Icon( diff --git a/lib/presentation/pages/home/widgets/card_item.dart b/lib/presentation/pages/home/widgets/card_item.dart index 798d324..417f1dc 100644 --- a/lib/presentation/pages/home/widgets/card_item.dart +++ b/lib/presentation/pages/home/widgets/card_item.dart @@ -1,21 +1,17 @@ import 'package:flutter/material.dart'; import 'package:lacquer/features/flashcard/dtos/card_dto.dart'; -class CardItem extends StatefulWidget { +class CardItem extends StatelessWidget { final CardDto card; + final bool isSelected; + final bool isMultiSelectMode; - const CardItem({super.key, required this.card}); - - @override - State createState() => _CardItemState(); -} - -class _CardItemState extends State - with SingleTickerProviderStateMixin { - @override - void initState() { - super.initState(); - } + const CardItem({ + super.key, + required this.card, + this.isSelected = false, + this.isMultiSelectMode = false, + }); @override Widget build(BuildContext context) { @@ -23,7 +19,14 @@ class _CardItemState extends State padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4), child: Card( elevation: 2, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: + isSelected + ? BorderSide(color: Theme.of(context).primaryColor, width: 2) + : BorderSide.none, + ), + color: isSelected ? Colors.blue.shade50 : Colors.white, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12), child: IntrinsicHeight( @@ -46,7 +49,7 @@ class _CardItemState extends State crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - widget.card.word ?? '', + card.word ?? '', style: const TextStyle( fontSize: 18, fontWeight: FontWeight.bold, @@ -54,7 +57,7 @@ class _CardItemState extends State ), const SizedBox(height: 6), Text( - widget.card.meaning?.definition ?? '', + card.meaning?.definition ?? '', style: const TextStyle( fontSize: 16, color: Colors.black87, @@ -63,6 +66,16 @@ class _CardItemState extends State ], ), ), + if (isMultiSelectMode) + Icon( + isSelected + ? Icons.check_circle + : Icons.radio_button_unchecked, + color: + isSelected + ? Theme.of(context).primaryColor + : Colors.grey, + ), ], ), ), diff --git a/lib/presentation/pages/home/widgets/card_item_list.dart b/lib/presentation/pages/home/widgets/card_item_list.dart index d6e259e..e67d5be 100644 --- a/lib/presentation/pages/home/widgets/card_item_list.dart +++ b/lib/presentation/pages/home/widgets/card_item_list.dart @@ -4,8 +4,19 @@ import 'card_item.dart'; class CardItemList extends StatelessWidget { final List cards; + final Set selectedCardIds; + final bool isMultiSelectMode; + final void Function(CardDto card) onCardTap; + final void Function(CardDto card) onCardLongPress; - const CardItemList({super.key, required this.cards}); + const CardItemList({ + super.key, + required this.cards, + required this.selectedCardIds, + required this.isMultiSelectMode, + required this.onCardTap, + required this.onCardLongPress, + }); @override Widget build(BuildContext context) { @@ -16,9 +27,19 @@ class CardItemList extends StatelessWidget { return ListView.separated( padding: const EdgeInsets.all(12.0), itemCount: cards.length, - separatorBuilder: (context, index) => const SizedBox(height: 4), + separatorBuilder: (_, __) => const SizedBox(height: 4), itemBuilder: (context, index) { - return CardItem(card: cards[index]); + final card = cards[index]; + final isSelected = selectedCardIds.contains(card.id); + return GestureDetector( + onTap: () => onCardTap(card), + onLongPress: () => onCardLongPress(card), + child: CardItem( + card: card, + isSelected: isSelected, + isMultiSelectMode: isMultiSelectMode, + ), + ); }, ); } From ec2d612afd4f9f3c25c46c5dd9327bbeea43366b Mon Sep 17 00:00:00 2001 From: Sir Date: Thu, 5 Jun 2025 23:26:27 +0700 Subject: [PATCH 24/28] feat: copy and move card between deck --- .../flashcard/bloc/flashcard_bloc.dart | 93 ++++++++++++++++++ .../flashcard/bloc/flashcard_event.dart | 20 ++++ .../flashcard/bloc/flashcard_state.dart | 6 ++ .../flashcard/data/flashcard_api_client.dart | 23 +++++ .../flashcard/data/flashcard_repository.dart | 26 +++++ .../pages/home/edit_card_list_page.dart | 98 +++++++++++++++++-- 6 files changed, 256 insertions(+), 10 deletions(-) diff --git a/lib/features/flashcard/bloc/flashcard_bloc.dart b/lib/features/flashcard/bloc/flashcard_bloc.dart index 20e522c..9739780 100644 --- a/lib/features/flashcard/bloc/flashcard_bloc.dart +++ b/lib/features/flashcard/bloc/flashcard_bloc.dart @@ -11,6 +11,7 @@ class FlashcardBloc extends Bloc { FlashcardBloc({required this.repository}) : super(const FlashcardState()) { on(_onCreateDeckRequested); on(_onLoadDecksRequested); + on(_onLoadUserAllDecksRequested); on(_onLoadTagsRequested); on(_onCreateTagRequested); on(_onUpdateTagRequested); @@ -22,6 +23,8 @@ class FlashcardBloc extends Bloc { on(_onSearchDecksRequested); on(_onAddCardToDeckRequested); on(_onFinishDeckRequested); + on(_onCopyCardsRequested); + on(_onMoveCardsRequested); } Future _onCreateDeckRequested( @@ -83,6 +86,96 @@ class FlashcardBloc extends Bloc { } } + Future _onLoadUserAllDecksRequested( + LoadUserAllDecksRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: FlashcardStatus.loading)); + + try { + final decks = await repository.getUserAllDecks(); + emit( + state.copyWith( + status: FlashcardStatus.success, + decks: decks, + searchResult: true, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: FlashcardStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + Future _onCopyCardsRequested( + CopyCardsRequested event, + Emitter emit, + ) async { + try { + emit(state.copyWith(status: FlashcardStatus.loading)); + for (final cardId in event.cardIds) { + await repository.addCardToDeck( + deckId: event.targetDeckId, + cardId: cardId, + ); + } + final deck = await repository.getDeckById(event.sourceDeckId); + final groupedDecks = await repository.getDecks(); + emit( + state.copyWith( + status: FlashcardStatus.success, + selectedDeck: deck, + groupedDecks: groupedDecks, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: FlashcardStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + Future _onMoveCardsRequested( + MoveCardsRequested event, + Emitter emit, + ) async { + try { + emit(state.copyWith(status: FlashcardStatus.loading)); + for (final cardId in event.cardIds) { + await repository.addCardToDeck( + deckId: event.targetDeckId, + cardId: cardId, + ); + } + for (final cardId in event.cardIds) { + await repository.deleteCard(event.sourceDeckId, cardId); + } + final deck = await repository.getDeckById(event.sourceDeckId); + final groupedDecks = await repository.getDecks(); + emit( + state.copyWith( + status: FlashcardStatus.success, + selectedDeck: deck, + groupedDecks: groupedDecks, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: FlashcardStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + Future _onLoadTagsRequested( LoadTagsRequested event, Emitter emit, diff --git a/lib/features/flashcard/bloc/flashcard_event.dart b/lib/features/flashcard/bloc/flashcard_event.dart index e003246..8281497 100644 --- a/lib/features/flashcard/bloc/flashcard_event.dart +++ b/lib/features/flashcard/bloc/flashcard_event.dart @@ -33,6 +33,10 @@ class LoadDecksRequested extends FlashcardEvent { const LoadDecksRequested(); } +class LoadUserAllDecksRequested extends FlashcardEvent { + const LoadUserAllDecksRequested(); +} + class LoadTagsRequested extends FlashcardEvent { const LoadTagsRequested(); } @@ -112,6 +116,22 @@ class DeleteCardRequested extends FlashcardEvent { List get props => [deckId, cardId]; } +class CopyCardsRequested extends FlashcardEvent { + final String sourceDeckId; + final String targetDeckId; + final List cardIds; + + const CopyCardsRequested(this.sourceDeckId, this.targetDeckId, this.cardIds); +} + +class MoveCardsRequested extends FlashcardEvent { + final String sourceDeckId; + final String targetDeckId; + final List cardIds; + + const MoveCardsRequested(this.sourceDeckId, this.targetDeckId, this.cardIds); +} + class SearchDecksRequested extends FlashcardEvent { final String query; diff --git a/lib/features/flashcard/bloc/flashcard_state.dart b/lib/features/flashcard/bloc/flashcard_state.dart index bf8b67a..e253150 100644 --- a/lib/features/flashcard/bloc/flashcard_state.dart +++ b/lib/features/flashcard/bloc/flashcard_state.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; import 'package:lacquer/features/flashcard/dtos/create_tag_dto.dart'; +import 'package:lacquer/features/flashcard/dtos/get_deck_dto.dart'; import 'package:lacquer/features/flashcard/dtos/grouped_decks_dto.dart'; import '../dtos/create_deck_dto.dart'; @@ -12,6 +13,7 @@ class FlashcardState extends Equatable { final GroupedDecksResponseDto? groupedDecks; final CreateDeckResponseDto? selectedDeck; final List tags; + final List decks; final String? errorMessage; final String searchQuery; final bool searchResult; @@ -23,6 +25,7 @@ class FlashcardState extends Equatable { this.groupedDecks, this.selectedDeck, this.tags = const [], + this.decks = const [], this.errorMessage, this.searchQuery = '', this.searchResult = true, @@ -35,6 +38,7 @@ class FlashcardState extends Equatable { GroupedDecksResponseDto? groupedDecks, CreateDeckResponseDto? selectedDeck, List? tags, + List? decks, String? errorMessage, String? searchQuery, bool? searchResult, @@ -46,6 +50,7 @@ class FlashcardState extends Equatable { groupedDecks: groupedDecks ?? this.groupedDecks, selectedDeck: selectedDeck ?? this.selectedDeck, tags: tags ?? this.tags, + decks: decks ?? this.decks, errorMessage: errorMessage ?? this.errorMessage, searchQuery: searchQuery ?? this.searchQuery, searchResult: searchResult ?? this.searchResult, @@ -60,6 +65,7 @@ class FlashcardState extends Equatable { groupedDecks, selectedDeck, tags, + decks, errorMessage, searchQuery, searchResult, diff --git a/lib/features/flashcard/data/flashcard_api_client.dart b/lib/features/flashcard/data/flashcard_api_client.dart index b8a2213..6e987df 100644 --- a/lib/features/flashcard/data/flashcard_api_client.dart +++ b/lib/features/flashcard/data/flashcard_api_client.dart @@ -91,6 +91,29 @@ class FlashcardApiClient { } } + Future getUserAllDecks() async { + try { + final token = await authLocalDataSource.getToken(); + final options = Options( + headers: {if (token != null) 'Authorization': 'Bearer $token'}, + ); + final response = await dio.get('/deck', options: options); + + final responseData = response.data as Map; + if (responseData['success'] != true) { + throw Exception(responseData['message'] ?? 'Failed to load decks'); + } + + return responseData; + } on DioException catch (e) { + if (e.response != null) { + throw Exception(e.response!.data['message']); + } else { + throw Exception(e.message); + } + } + } + Future> getDeckById(String deckId) async { try { final token = await authLocalDataSource.getToken(); diff --git a/lib/features/flashcard/data/flashcard_repository.dart b/lib/features/flashcard/data/flashcard_repository.dart index 59d13bc..b6884fc 100644 --- a/lib/features/flashcard/data/flashcard_repository.dart +++ b/lib/features/flashcard/data/flashcard_repository.dart @@ -3,6 +3,7 @@ import 'package:lacquer/features/flashcard/dtos/card_dto.dart'; import 'dart:io'; import 'package:lacquer/features/flashcard/dtos/create_tag_dto.dart'; import 'package:lacquer/features/flashcard/dtos/finish_deck_dto.dart'; +import 'package:lacquer/features/flashcard/dtos/get_deck_dto.dart'; import 'package:lacquer/features/flashcard/dtos/grouped_decks_dto.dart'; import 'package:lacquer/features/flashcard/dtos/update_deck_dto.dart'; import 'package:lacquer/features/flashcard/dtos/update_tag_dto.dart'; @@ -52,6 +53,31 @@ class FlashcardRepository { } } + Future> getUserAllDecks() async { + try { + final response = await apiClient.getUserAllDecks(); + final responseData = response as Map; + if (responseData['success'] != true) { + throw Exception( + 'Failed to load decks: ${responseData['message'] ?? 'Unknown error'}', + ); + } + + final decksData = responseData['data']['data'] as List; + return decksData + .map( + (deckJson) => GetDeckDto.fromJson(deckJson as Map), + ) + .toList(); + } on DioException catch (e) { + if (e.response != null) { + throw Exception(e.response!.data['message']); + } else { + throw Exception(e.message); + } + } + } + Future> getTags() async { return apiClient.getTags(); } diff --git a/lib/presentation/pages/home/edit_card_list_page.dart b/lib/presentation/pages/home/edit_card_list_page.dart index 437e108..d39e91b 100644 --- a/lib/presentation/pages/home/edit_card_list_page.dart +++ b/lib/presentation/pages/home/edit_card_list_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/features/flashcard/dtos/card_dto.dart'; +import 'package:lacquer/features/flashcard/dtos/get_deck_dto.dart'; import 'package:lacquer/presentation/pages/home/widgets/card_item.dart'; class EditCardListPage extends StatefulWidget { @@ -70,6 +71,90 @@ class _EditCardListPageState extends State { _exitMultiSelect(); } + Future _showDeckSelectionDialog(bool isCopy) async { + showDialog( + context: context, + builder: (context) { + return FutureBuilder>( + future: context.read().repository.getUserAllDecks(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const AlertDialog( + content: SizedBox( + height: 100, + child: Center(child: CircularProgressIndicator()), + ), + ); + } else if (snapshot.hasError) { + return AlertDialog( + title: const Text('Error'), + content: Text('Failed to load decks: ${snapshot.error}'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ); + } else if (snapshot.hasData && snapshot.data != null) { + final decks = snapshot.data!; + return AlertDialog( + title: Text(isCopy ? 'Copy Cards To' : 'Move Cards To'), + content: SizedBox( + width: double.maxFinite, + child: ListView.builder( + shrinkWrap: true, + itemCount: decks.length, + itemBuilder: (context, index) { + final deck = decks[index]; + if (deck.id == widget.deckId) + return const SizedBox.shrink(); + return ListTile( + title: Text(deck.title), + onTap: () { + final bloc = context.read(); + if (isCopy) { + bloc.add( + CopyCardsRequested( + widget.deckId, + deck.id, + selectedCardIds.toList(), + ), + ); + } else { + bloc.add( + MoveCardsRequested( + widget.deckId, + deck.id, + selectedCardIds.toList(), + ), + ); + } + Navigator.of(context).pop(); + _exitMultiSelect(); + }, + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ], + ); + } + return const AlertDialog( + content: Text('No decks available'), + actions: [TextButton(onPressed: null, child: Text('OK'))], + ); + }, + ); + }, + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -140,7 +225,7 @@ class _EditCardListPageState extends State { backgroundColor: CustomTheme.mainColor1, title: Text( '${selectedCardIds.length} selected', - style: TextStyle(color: Colors.white), + style: const TextStyle(color: Colors.white), ), leading: IconButton( icon: const Icon(Icons.close), @@ -156,12 +241,12 @@ class _EditCardListPageState extends State { IconButton( icon: const Icon(Icons.copy), color: CustomTheme.mainColor3, - onPressed: () {}, + onPressed: () => _showDeckSelectionDialog(true), ), IconButton( icon: const Icon(Icons.drive_file_move), color: CustomTheme.mainColor3, - onPressed: () {}, + onPressed: () => _showDeckSelectionDialog(false), ), IconButton( icon: const Icon(Icons.delete), @@ -207,13 +292,6 @@ class _EditCardListPageState extends State { onPressed: () => context.go(RouteName.addNewWord(widget.deckId)), ), - IconButton( - icon: const Icon( - FontAwesomeIcons.ellipsisVertical, - color: Colors.white, - ), - onPressed: null, - ), const SizedBox(width: 10), ], ), From be141eef2bf120a4b334c19dabb7961c27dbc772 Mon Sep 17 00:00:00 2001 From: Sir Date: Thu, 5 Jun 2025 23:34:31 +0700 Subject: [PATCH 25/28] chore: improve move and copy UI --- .../pages/home/edit_card_list_page.dart | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/lib/presentation/pages/home/edit_card_list_page.dart b/lib/presentation/pages/home/edit_card_list_page.dart index d39e91b..e4339f5 100644 --- a/lib/presentation/pages/home/edit_card_list_page.dart +++ b/lib/presentation/pages/home/edit_card_list_page.dart @@ -97,19 +97,36 @@ class _EditCardListPageState extends State { ], ); } else if (snapshot.hasData && snapshot.data != null) { - final decks = snapshot.data!; + final decks = + snapshot.data! + .where((deck) => deck.id != widget.deckId) + .toList(); + + if (decks.isEmpty) { + return const AlertDialog( + content: Text('No other decks available.'), + actions: [TextButton(onPressed: null, child: Text('OK'))], + ); + } + return AlertDialog( - title: Text(isCopy ? 'Copy Cards To' : 'Move Cards To'), + title: Text( + isCopy ? 'Copy Cards To' : 'Move Cards To', + style: const TextStyle(fontWeight: FontWeight.bold), + ), content: SizedBox( width: double.maxFinite, - child: ListView.builder( + child: ListView.separated( shrinkWrap: true, itemCount: decks.length, + separatorBuilder: (_, __) => const Divider(), itemBuilder: (context, index) { final deck = decks[index]; - if (deck.id == widget.deckId) - return const SizedBox.shrink(); return ListTile( + leading: Icon( + isCopy ? Icons.copy : Icons.drive_file_move, + color: Theme.of(context).primaryColor, + ), title: Text(deck.title), onTap: () { final bloc = context.read(); @@ -145,8 +162,9 @@ class _EditCardListPageState extends State { ], ); } + return const AlertDialog( - content: Text('No decks available'), + content: Text('No decks available.'), actions: [TextButton(onPressed: null, child: Text('OK'))], ); }, From d249ea73ee26d546eff2fe88c4717372e322eb7b Mon Sep 17 00:00:00 2001 From: Sir Date: Fri, 6 Jun 2025 13:01:53 +0700 Subject: [PATCH 26/28] feat: add Reset button and change Options UI --- .../home/widgets/flashcard_confirm_reset.dart | 77 +++++++++++++++++++ .../pages/home/widgets/flashcard_options.dart | 26 ++++++- .../pages/home/widgets/flashcard_topic.dart | 1 + .../home/widgets/flashcard_topic_create.dart | 6 +- 4 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 lib/presentation/pages/home/widgets/flashcard_confirm_reset.dart diff --git a/lib/presentation/pages/home/widgets/flashcard_confirm_reset.dart b/lib/presentation/pages/home/widgets/flashcard_confirm_reset.dart new file mode 100644 index 0000000..ad004c9 --- /dev/null +++ b/lib/presentation/pages/home/widgets/flashcard_confirm_reset.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lacquer/features/flashcard/bloc/flashcard_bloc.dart'; +import 'package:lacquer/features/flashcard/bloc/flashcard_event.dart'; + +class FlashcardConfirmReset extends StatefulWidget { + final String id; + final String title; + final bool isDone; + const FlashcardConfirmReset({ + super.key, + required this.id, + required this.title, + required this.isDone, + }); + + @override + State createState() => _FlashcardConfirmResetState(); +} + +class _FlashcardConfirmResetState extends State { + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text( + 'Reset statistics', + style: TextStyle(fontWeight: FontWeight.bold), + ), + content: SizedBox( + width: 300, + child: RichText( + text: TextSpan( + style: TextStyle( + fontSize: 16, + color: DefaultTextStyle.of(context).style.color, + ), + children: [ + TextSpan( + text: 'Are you sure want to reset statistics of topic "', + ), + TextSpan( + text: widget.title, + style: TextStyle(fontWeight: FontWeight.bold), + ), + TextSpan(text: '"?'), + ], + ), + ), + ), + actions: [ + TextButton( + style: TextButton.styleFrom( + textStyle: Theme.of(context).textTheme.labelLarge, + ), + child: const Text('No'), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + TextButton( + style: TextButton.styleFrom( + textStyle: Theme.of(context).textTheme.labelLarge, + ), + child: const Text('Yes'), + onPressed: () { + if (widget.isDone == true) { + context.read().add( + FinishDeckRequested(deckId: widget.id), + ); + } + Navigator.of(context).pop(); + }, + ), + ], + ); + } +} diff --git a/lib/presentation/pages/home/widgets/flashcard_options.dart b/lib/presentation/pages/home/widgets/flashcard_options.dart index 09456d5..30d42a9 100644 --- a/lib/presentation/pages/home/widgets/flashcard_options.dart +++ b/lib/presentation/pages/home/widgets/flashcard_options.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:lacquer/config/router.dart'; import 'package:lacquer/config/theme.dart'; import 'package:lacquer/presentation/pages/home/widgets/flashcard_confirm_delete.dart'; +import 'package:lacquer/presentation/pages/home/widgets/flashcard_confirm_reset.dart'; import 'package:lacquer/presentation/pages/home/widgets/flashcard_topic_edit.dart'; class FlashcardOptionDialog extends StatefulWidget { @@ -11,12 +12,14 @@ class FlashcardOptionDialog extends StatefulWidget { final String title; final List tags; final String imagePath; + final bool isDone; const FlashcardOptionDialog({ super.key, required this.id, required this.title, required this.tags, required this.imagePath, + required this.isDone, }); @override @@ -35,7 +38,7 @@ class _FlashcardOptionDialogState extends State { { "icon": FontAwesomeIcons.play, "title": "Explore", - "subtitle": "10 new cards", + "subtitle": "", "action": () { context.go(RouteName.learn(widget.id)); }, @@ -43,7 +46,7 @@ class _FlashcardOptionDialogState extends State { { "icon": FontAwesomeIcons.rotateRight, "title": "Revise", - "subtitle": "Repeat 10 cards", + "subtitle": "", "action": () { print("Revise clicked"); }, @@ -145,6 +148,25 @@ class _FlashcardOptionDialogState extends State { ); }, ), + IconButton( + icon: Icon( + FontAwesomeIcons.rotateLeft, + size: 20, + color: Colors.black, + ), + onPressed: () { + Navigator.pop(context); + showDialog( + context: context, + builder: + (context) => FlashcardConfirmReset( + id: widget.id, + title: widget.title, + isDone: widget.isDone, + ), + ); + }, + ), IconButton( icon: Icon( FontAwesomeIcons.listUl, diff --git a/lib/presentation/pages/home/widgets/flashcard_topic.dart b/lib/presentation/pages/home/widgets/flashcard_topic.dart index 71403fe..be93915 100644 --- a/lib/presentation/pages/home/widgets/flashcard_topic.dart +++ b/lib/presentation/pages/home/widgets/flashcard_topic.dart @@ -39,6 +39,7 @@ class FlashcardTopicState extends State { title: widget.title, tags: widget.tags, imagePath: widget.imagePath, + isDone: widget.isDone, ), ); }, diff --git a/lib/presentation/pages/home/widgets/flashcard_topic_create.dart b/lib/presentation/pages/home/widgets/flashcard_topic_create.dart index 731ee7a..53f3e4d 100644 --- a/lib/presentation/pages/home/widgets/flashcard_topic_create.dart +++ b/lib/presentation/pages/home/widgets/flashcard_topic_create.dart @@ -59,6 +59,7 @@ class _FlashcardTopicCreateState extends State { @override Widget build(BuildContext context) { return AlertDialog( + insetPadding: const EdgeInsets.symmetric(horizontal: 24.0), backgroundColor: Colors.white, title: const Text( 'Create New Topic', @@ -69,7 +70,6 @@ class _FlashcardTopicCreateState extends State { textAlign: TextAlign.center, ), content: SizedBox( - width: 300, child: ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * 0.7, @@ -81,7 +81,7 @@ class _FlashcardTopicCreateState extends State { Padding( padding: const EdgeInsets.symmetric( horizontal: 8, - vertical: 8, + vertical: 4, ), child: TextFormField( controller: _titleController, @@ -101,7 +101,7 @@ class _FlashcardTopicCreateState extends State { ), ), SizedBox( - width: 180, + width: 200, child: TextButton( onPressed: () { showDialog( From e9d1d65c90dee3a74bb515cc384545bad6299405 Mon Sep 17 00:00:00 2001 From: Sir Date: Fri, 6 Jun 2025 13:39:16 +0700 Subject: [PATCH 27/28] fix: handle no card in deck case --- .../pages/home/edit_card_list_page.dart | 103 ++++++++++--- .../pages/home/learning_flashcard_page.dart | 141 +++++++++++++----- 2 files changed, 182 insertions(+), 62 deletions(-) diff --git a/lib/presentation/pages/home/edit_card_list_page.dart b/lib/presentation/pages/home/edit_card_list_page.dart index e4339f5..53128bc 100644 --- a/lib/presentation/pages/home/edit_card_list_page.dart +++ b/lib/presentation/pages/home/edit_card_list_page.dart @@ -201,32 +201,89 @@ class _EditCardListPageState extends State { } else if (state.status == FlashcardStatus.success && state.selectedDeck != null) { final deck = state.selectedDeck!; + final hasCards = deck.cards != null && deck.cards!.isNotEmpty; + return Column( children: [ - Expanded( - child: ListView.separated( - padding: const EdgeInsets.all(12.0), - itemCount: deck.cards?.length ?? 0, - separatorBuilder: (_, __) => const SizedBox(height: 4), - itemBuilder: (context, index) { - final card = deck.cards![index]; - final isSelected = selectedCardIds.contains(card.id); - return GestureDetector( - onTap: () => _onCardTap(card), - onLongPress: () => _onCardLongPress(card), - child: CardItem( - card: card, - isSelected: isSelected, - isMultiSelectMode: isMultiSelectMode, + if (hasCards) + Expanded( + child: ListView.separated( + padding: const EdgeInsets.all(12.0), + itemCount: deck.cards!.length, + separatorBuilder: (_, __) => const SizedBox(height: 4), + itemBuilder: (context, index) { + final card = deck.cards![index]; + final isSelected = selectedCardIds.contains(card.id); + return GestureDetector( + onTap: () => _onCardTap(card), + onLongPress: () => _onCardLongPress(card), + child: CardItem( + card: card, + isSelected: isSelected, + isMultiSelectMode: isMultiSelectMode, + ), + ); + }, + ), + ) + else + Expanded( + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.style_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 flashcard 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), + ), + style: ElevatedButton.styleFrom( + backgroundColor: CustomTheme.mainColor1, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + icon: const Icon(Icons.add, color: Colors.white), + label: const Text( + 'Add New Card', + style: TextStyle(fontSize: 16), + ), + ), + ], ), - ); - }, - ), - ), - if (deck.cards == null || deck.cards!.isEmpty) - const Padding( - padding: EdgeInsets.all(16.0), - child: Text('No cards available in this deck'), + ), + ), ), ], ); diff --git a/lib/presentation/pages/home/learning_flashcard_page.dart b/lib/presentation/pages/home/learning_flashcard_page.dart index 7c7bb13..3218159 100644 --- a/lib/presentation/pages/home/learning_flashcard_page.dart +++ b/lib/presentation/pages/home/learning_flashcard_page.dart @@ -84,52 +84,115 @@ class _LearningFlashcardPageState extends State { Column( children: [ const SizedBox(height: 100), - 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), + 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, ), - minHeight: 12.0, ), ), - ), - const SizedBox(width: 12), - Text( - '${(_progress * 100).toStringAsFixed(0)}%', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, + const SizedBox(width: 12), + Text( + '${(_progress * 100).toStringAsFixed(0)}%', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), ), - ), - ], + ], + ), ), - ), Expanded( - child: LearningCardList( - deckId: widget.deckId, - cards: deck.cards ?? [], - onScrollProgress: _updateProgress, - speechRate: _speechRate, - selectedAccent: _selectedAccent, - isDone: deck.isDone ?? false, - ), + 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, + ), + ), + ), + ), + ], + ), + ), + ) + : LearningCardList( + deckId: widget.deckId, + cards: deck.cards!, + onScrollProgress: _updateProgress, + speechRate: _speechRate, + selectedAccent: _selectedAccent, + isDone: deck.isDone ?? false, + ), ), - const SizedBox(height: 20), - if (deck.cards == null || deck.cards!.isEmpty) - const Text('No cards available in this deck'), ], ), ], From d87d92416426d10c187a968a6cf30394e0bb9258 Mon Sep 17 00:00:00 2001 From: Sir Date: Fri, 6 Jun 2025 14:16:03 +0700 Subject: [PATCH 28/28] fix: handle card not exists error --- lib/features/flashcard/bloc/flashcard_bloc.dart | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/lib/features/flashcard/bloc/flashcard_bloc.dart b/lib/features/flashcard/bloc/flashcard_bloc.dart index 9739780..c129717 100644 --- a/lib/features/flashcard/bloc/flashcard_bloc.dart +++ b/lib/features/flashcard/bloc/flashcard_bloc.dart @@ -430,12 +430,16 @@ class FlashcardBloc extends Bloc { ), ); } catch (e) { - emit( - state.copyWith( - status: FlashcardStatus.failure, - errorMessage: e.toString(), - ), - ); + if (e.toString().contains('already exists')) { + emit(state.copyWith(status: FlashcardStatus.success)); + } else { + emit( + state.copyWith( + status: FlashcardStatus.failure, + errorMessage: e.toString(), + ), + ); + } } }