diff --git a/lib/config/router.dart b/lib/config/router.dart index 00e0be6..0cd3d43 100644 --- a/lib/config/router.dart +++ b/lib/config/router.dart @@ -9,16 +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/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'; @@ -31,13 +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 addNewWord(String deckId) => '/add-new-word/$deckId'; static const publicRoutes = [login, forgotPassword, verify, register]; } @@ -99,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(), @@ -112,17 +117,10 @@ final router = GoRouter( builder: (context, state) => const FriendsPage(), ), noTransitionRoute( - path: RouteName.quiz, - 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( @@ -130,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/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/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 89c57d6..c129717 100644 --- a/lib/features/flashcard/bloc/flashcard_bloc.dart +++ b/lib/features/flashcard/bloc/flashcard_bloc.dart @@ -11,14 +11,20 @@ class FlashcardBloc extends Bloc { FlashcardBloc({required this.repository}) : super(const FlashcardState()) { on(_onCreateDeckRequested); on(_onLoadDecksRequested); + on(_onLoadUserAllDecksRequested); on(_onLoadTagsRequested); on(_onCreateTagRequested); on(_onUpdateTagRequested); - // on(_onLoadDeckByIdRequested); + on(_onLoadDeckByIdRequested); on(_onDeleteDeckRequested); on(_onUpdateDeckRequested); on(_onDeleteTagRequested); + on(_onDeleteCardRequested); on(_onSearchDecksRequested); + on(_onAddCardToDeckRequested); + on(_onFinishDeckRequested); + on(_onCopyCardsRequested); + on(_onMoveCardsRequested); } Future _onCreateDeckRequested( @@ -32,7 +38,7 @@ class FlashcardBloc extends Bloc { title: event.title, description: event.description, tags: event.tags, - cardIds: event.cardIds, + cards: event.cards, imageFile: event.imageFile, ); @@ -80,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, @@ -146,25 +242,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, @@ -260,6 +355,35 @@ class FlashcardBloc extends Bloc { } } + Future _onDeleteCardRequested( + DeleteCardRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: FlashcardStatus.loading)); + + 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, + selectedDeck: updatedDeck, + groupedDecks: groupedDecks, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: FlashcardStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + void _onSearchDecksRequested( SearchDecksRequested event, Emitter emit, @@ -283,6 +407,42 @@ 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 groupedDecks = await repository.getDecks(); + final updatedDeck = await repository.getDeckById(event.deckId); + + emit( + state.copyWith( + status: FlashcardStatus.success, + selectedDeck: updatedDeck, + groupedDecks: groupedDecks, + ), + ); + } catch (e) { + if (e.toString().contains('already exists')) { + emit(state.copyWith(status: FlashcardStatus.success)); + } else { + emit( + state.copyWith( + status: FlashcardStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + } + GroupedDecksResponseDto? filterDecksByName( GroupedDecksResponseDto groupedDecks, String query, @@ -315,4 +475,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 914e7b0..8281497 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,31 +14,29 @@ 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 { const LoadDecksRequested(); } +class LoadUserAllDecksRequested extends FlashcardEvent { + const LoadUserAllDecksRequested(); +} + class LoadTagsRequested extends FlashcardEvent { const LoadTagsRequested(); } @@ -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; @@ -106,6 +106,32 @@ 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 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; @@ -114,3 +140,22 @@ 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]; +} + +class FinishDeckRequested extends FlashcardEvent { + final String deckId; + + const FinishDeckRequested({required this.deckId}); + + @override + List get props => [deckId]; +} 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 08cbd6a..6e987df 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'; @@ -25,7 +26,7 @@ class FlashcardApiClient { 'title': deckDto.title, 'description': deckDto.description, 'tags': deckDto.tags, - 'cards': deckDto.cardIds, + 'cards': deckDto.cards, }); if (imageFile != null) { @@ -90,6 +91,49 @@ 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(); + + 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(); @@ -187,6 +231,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(); @@ -199,28 +267,17 @@ 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 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 { @@ -293,4 +350,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 bce04fd..b6884fc 100644 --- a/lib/features/flashcard/data/flashcard_repository.dart +++ b/lib/features/flashcard/data/flashcard_repository.dart @@ -1,6 +1,9 @@ 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/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'; @@ -17,14 +20,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); @@ -50,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(); } @@ -60,9 +88,15 @@ class FlashcardRepository { return apiClient.createTag(tagDto); } - // Future getDeckById(String deckId) async { - // return apiClient.getDeckById(deckId); - // } + 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'); + } + } Future deleteDeck(String deckId) async { return apiClient.deleteDeck(deckId); @@ -101,4 +135,34 @@ class FlashcardRepository { Future deleteTag(String tagId) async { await apiClient.deleteTag(tagId); } + + Future deleteCard(String deckId, String cardId) async { + await apiClient.deleteCard(deckId, cardId); + } + + 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); + } + } + } + + 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/card_dto.dart b/lib/features/flashcard/dtos/card_dto.dart new file mode 100644 index 0000000..305526c --- /dev/null +++ b/lib/features/flashcard/dtos/card_dto.dart @@ -0,0 +1,56 @@ +class CardDto { + final String? id; + final String? word; + final String? pronunciation; + final CardMeaningDto? meaning; + final String? description; + + CardDto({ + this.id, + this.word, + this.pronunciation, + this.meaning, + this.description, + }); + + factory CardDto.fromJson(Map json) { + return CardDto( + id: json['_id'] as String?, + word: json['word'] as String?, + pronunciation: json['pronunciation'] as String?, + meaning: + json['meaning'] != null + ? CardMeaningDto.fromJson(json['meaning'] as Map) + : null, + description: json['description'] as String?, + ); + } + + Map toJson() { + return { + '_id': id, + 'word': word, + '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/features/flashcard/dtos/create_deck_dto.dart b/lib/features/flashcard/dtos/create_deck_dto.dart index 0943519..eb25f1b 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,9 +60,10 @@ class CreateDeckResponseDto { final String? description; final String? img; final List? tags; - final List? cardIds; + final List? cards; final String? userId; final DateTime? createdAt; + final bool? isDone; CreateDeckResponseDto({ this.id, @@ -76,13 +71,13 @@ class CreateDeckResponseDto { this.description, this.img, this.tags, - this.cardIds, + this.cards, this.userId, this.createdAt, + this.isDone, }); 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 +86,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,12 +108,13 @@ 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 ? 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..0bd9ac3 --- /dev/null +++ b/lib/features/flashcard/dtos/finish_deck_dto.dart @@ -0,0 +1,94 @@ +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; + final DateTime? createdAt; + final DateTime? updatedAt; + final bool? isDone; + final String? owner; + final int? 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/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/add_new_word_page.dart b/lib/presentation/pages/home/add_new_word_page.dart new file mode 100644 index 0000000..962a100 --- /dev/null +++ b/lib/presentation/pages/home/add_new_word_page.dart @@ -0,0 +1,494 @@ +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), + 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, + ), + ), + ), + ), + ], + ); + }), + ], + ), + ), + ), + ); + } + + // 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; + setState(() { + suggestions.clear(); + searchResults.clear(); + }); + 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 new file mode 100644 index 0000000..53128bc --- /dev/null +++ b/lib/presentation/pages/home/edit_card_list_page.dart @@ -0,0 +1,377 @@ +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/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 { + final String deckId; + + const EditCardListPage({super.key, required this.deckId}); + + @override + State createState() => _EditCardListPageState(); +} + +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(); + } + + 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! + .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', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + content: SizedBox( + width: double.maxFinite, + child: ListView.separated( + shrinkWrap: true, + itemCount: decks.length, + separatorBuilder: (_, __) => const Divider(), + itemBuilder: (context, index) { + final deck = decks[index]; + 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(); + 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( + 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) { + 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!; + final hasCards = deck.cards != null && deck.cards!.isNotEmpty; + + return Column( + children: [ + 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), + ), + ), + ], + ), + ), + ), + ), + ], + ); + } + return const Center(child: Text('No deck data available')); + }, + ), + ); + } + + Widget _buildAppBar(BuildContext context, String title, List cards) { + if (isMultiSelectMode) { + return AppBar( + backgroundColor: CustomTheme.mainColor1, + title: Text( + '${selectedCardIds.length} selected', + style: const 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: () => _showDeckSelectionDialog(true), + ), + IconButton( + icon: const Icon(Icons.drive_file_move), + color: CustomTheme.mainColor3, + onPressed: () => _showDeckSelectionDialog(false), + ), + IconButton( + icon: const Icon(Icons.delete), + color: CustomTheme.mainColor3, + onPressed: _deleteSelected, + ), + ], + ); + } + + 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.plus, color: Colors.white), + onPressed: + () => context.go(RouteName.addNewWord(widget.deckId)), + ), + const SizedBox(width: 10), + ], + ), + ), + ), + ); + } +} 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..3218159 100644 --- a/lib/presentation/pages/home/learning_flashcard_page.dart +++ b/lib/presentation/pages/home/learning_flashcard_page.dart @@ -1,71 +1,258 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lacquer/config/router.dart'; import 'package:lacquer/config/theme.dart'; +import 'package:lacquer/features/flashcard/bloc/flashcard_bloc.dart'; +import 'package:lacquer/features/flashcard/bloc/flashcard_event.dart'; +import 'package:lacquer/features/flashcard/bloc/flashcard_state.dart'; +import 'package:lacquer/presentation/pages/home/widgets/learning_card_list.dart'; +import 'package:lacquer/presentation/pages/home/widgets/speech_adjustment.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 { + 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(title), - Column( - mainAxisAlignment: MainAxisAlignment.center, - // children: [HorizontalLearningCardList(flashcardItems: cards)], - ), - ], + body: BlocBuilder( + builder: (context, state) { + if (state.status == FlashcardStatus.loading) { + return const Center(child: CircularProgressIndicator()); + } else if (state.status == FlashcardStatus.failure) { + return Center( + child: Text('Error: ${state.errorMessage ?? 'Unknown error'}'), + ); + } else if (state.status == FlashcardStatus.success && + state.selectedDeck != null) { + final deck = state.selectedDeck!; + return Stack( + children: [ + _buildAppBar(context, deck.title), + Column( + children: [ + const SizedBox(height: 100), + if (deck.cards != null && deck.cards!.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 200, + child: ClipRRect( + borderRadius: BorderRadius.circular(4.0), + child: LinearProgressIndicator( + value: _progress, + backgroundColor: Colors.grey[300], + valueColor: AlwaysStoppedAnimation( + const Color.fromARGB(255, 104, 175, 106), + ), + minHeight: 12.0, + ), + ), + ), + const SizedBox(width: 12), + Text( + '${(_progress * 100).toStringAsFixed(0)}%', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ], + ), + ), + Expanded( + child: + (deck.cards == null || deck.cards!.isEmpty) + ? Center( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24.0, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.menu_book_outlined, + size: 80, + color: Colors.grey, + ), + const SizedBox(height: 16), + const Text( + 'No cards yet', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.black54, + ), + ), + const SizedBox(height: 8), + const Text( + 'Start by adding your first card to this deck.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: Colors.black45, + ), + ), + const SizedBox(height: 20), + ElevatedButton.icon( + onPressed: + () => context.go( + RouteName.addNewWord( + widget.deckId, + ), + ), + icon: const Icon( + Icons.add, + color: Colors.white, + ), + label: const Text('Add New Card'), + style: ElevatedButton.styleFrom( + backgroundColor: + CustomTheme.mainColor1, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 8, + ), + ), + ), + ), + ], + ), + ), + ) + : LearningCardList( + deckId: widget.deckId, + cards: deck.cards!, + onScrollProgress: _updateProgress, + speechRate: _speechRate, + selectedAccent: _selectedAccent, + isDone: deck.isDone ?? false, + ), + ), + ], + ), + ], + ); + } + return const Center(child: Text('No deck data available')); + }, ), ); } - 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, String? title) { + 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( + 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, ), ), - ), - ), - SizedBox(width: 15), - IconButton( - icon: Icon(FontAwesomeIcons.plus, color: Colors.white), - onPressed: null, + const SizedBox(width: 15), + const Spacer(), + IconButton( + icon: const Icon(FontAwesomeIcons.gear, color: Colors.white), + onPressed: _showTtsSettings, + ), + IconButton( + icon: const Icon( + FontAwesomeIcons.ellipsisVertical, + color: Colors.white, + ), + onPressed: null, + ), + const SizedBox(width: 10), + ], ), - 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..417f1dc --- /dev/null +++ b/lib/presentation/pages/home/widgets/card_item.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:lacquer/features/flashcard/dtos/card_dto.dart'; + +class CardItem extends StatelessWidget { + final CardDto card; + final bool isSelected; + final bool isMultiSelectMode; + + const CardItem({ + super.key, + required this.card, + this.isSelected = false, + this.isMultiSelectMode = false, + }); + + @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), + 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( + 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( + card.word ?? '', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 6), + Text( + card.meaning?.definition ?? '', + style: const TextStyle( + fontSize: 16, + color: Colors.black87, + ), + ), + ], + ), + ), + 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 new file mode 100644 index 0000000..e67d5be --- /dev/null +++ b/lib/presentation/pages/home/widgets/card_item_list.dart @@ -0,0 +1,46 @@ +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; + 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, + required this.selectedCardIds, + required this.isMultiSelectMode, + required this.onCardTap, + required this.onCardLongPress, + }); + + @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: (_, __) => const SizedBox(height: 4), + itemBuilder: (context, 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, + ), + ); + }, + ); + } +} 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 ba3e28c..30d42a9 100644 --- a/lib/presentation/pages/home/widgets/flashcard_options.dart +++ b/lib/presentation/pages/home/widgets/flashcard_options.dart @@ -1,7 +1,10 @@ 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_confirm_reset.dart'; import 'package:lacquer/presentation/pages/home/widgets/flashcard_topic_edit.dart'; class FlashcardOptionDialog extends StatefulWidget { @@ -9,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 @@ -23,41 +28,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": "", + "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": "", + "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) { @@ -137,6 +148,36 @@ 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, + size: 20, + color: Colors.black, + ), + onPressed: () { + Navigator.pop(context); + context.go(RouteName.edit(widget.id)); + }, + ), IconButton( icon: Icon( FontAwesomeIcons.trashCan, 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 3c26239..be93915 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 @@ -27,6 +28,7 @@ class FlashcardTopicState extends State { @override Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; return GestureDetector( onTap: () { showDialog( @@ -37,6 +39,7 @@ class FlashcardTopicState extends State { title: widget.title, tags: widget.tags, imagePath: widget.imagePath, + isDone: widget.isDone, ), ); }, @@ -49,7 +52,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, @@ -106,19 +109,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, ), ], ), diff --git a/lib/presentation/pages/home/widgets/flashcard_topic_create.dart b/lib/presentation/pages/home/widgets/flashcard_topic_create.dart index e5b2d65..53f3e4d 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, ), ); @@ -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( 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/pages/home/widgets/learning_card.dart b/lib/presentation/pages/home/widgets/learning_card.dart new file mode 100644 index 0000000..c3a754f --- /dev/null +++ b/lib/presentation/pages/home/widgets/learning_card.dart @@ -0,0 +1,287 @@ +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'; +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, + required this.speechRate, + required this.selectedAccent, + }); + + @override + State createState() => _LearningCardState(); +} + +class _LearningCardState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + bool _isFront = true; + late FlutterTts flutterTts; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 500), + 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(widget.selectedAccent); + await flutterTts.setSpeechRate(widget.speechRate); + await flutterTts.setPitch(1.0); + await flutterTts.speak(text); + } + + void _flipCard() { + if (_isFront) { + _controller.forward(); + } else { + _controller.reverse(); + } + _isFront = !_isFront; + } + + @override + void dispose() { + _controller.dispose(); + flutterTts.stop(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.only(top: 0), + 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: CustomTheme.flashcardColor, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: const Color.fromRGBO(0, 0, 0, 0.2), + blurRadius: 10, + offset: Offset(0, 6), + ), + ], + ), + 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 - 180, + decoration: BoxDecoration( + color: CustomTheme.flashcardColor, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: const Color.fromRGBO(0, 0, 0, 0.2), + blurRadius: 10, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + children: [ + 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: Row( + children: [ + IconButton( + onPressed: () { + if ((card.word ?? '').isNotEmpty) { + _speak(card.word!); + } + }, + 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/pages/home/widgets/learning_card_list.dart b/lib/presentation/pages/home/widgets/learning_card_list.dart new file mode 100644 index 0000000..1777cf8 --- /dev/null +++ b/lib/presentation/pages/home/widgets/learning_card_list.dart @@ -0,0 +1,123 @@ +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'; +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 + 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; + final progress = maxPages > 0 ? index / maxPages : 0.0; + widget.onScrollProgress?.call(progress.clamp(0.0, 1.0)); + } + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final hasCards = widget.cards.isNotEmpty; + final totalPages = hasCards ? widget.cards.length + 1 : 0; + + return PageView.builder( + controller: _pageController, + itemCount: totalPages, + onPageChanged: _onPageChanged, + itemBuilder: (context, index) { + if (index < widget.cards.length) { + return LearningCard( + card: widget.cards[index], + speechRate: widget.speechRate, + selectedAccent: widget.selectedAccent, + ); + } else { + return _buildCompletionCard(context, widget.deckId, widget.isDone); + } + }, + ); + } +} + +Widget _buildCompletionCard(BuildContext context, String deckId, bool isDone) { + 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: () { + if (isDone != true) { + context.read().add( + FinishDeckRequested(deckId: deckId), + ); + } + 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), + ), + ), + ], + ), + ), + ); +} 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..ac118f4 --- /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, 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.1, + max: 1.0, + divisions: 9, + 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"), + ), + ], + ), + ), + ); + } +} 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 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/", - ), -]; 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; +} 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