diff --git a/GAME_ENHANCEMENTS.md b/GAME_ENHANCEMENTS.md new file mode 100644 index 0000000..8f2acbe --- /dev/null +++ b/GAME_ENHANCEMENTS.md @@ -0,0 +1,395 @@ +# Tibetan Language Learning App - Game Enhancements + +## ๐ŸŽฎ Overview +Complete redesign of the game module with 5 new professional games, comprehensive reward system, and modern UI/UX. + +--- + +## โœจ New Features + +### 1. **Comprehensive Reward System** +- **Coins & Stars**: Earn rewards based on performance +- **Achievements**: 9 achievements to unlock (first game, streaks, milestones) +- **Daily Streaks**: Encourages daily engagement +- **Progress Tracking**: Persistent storage of all game statistics + +**Files Created:** +- `lib/model/reward_model.dart` - Achievement, GameResult, UserProgress models +- `lib/cubit/reward/reward_cubit.dart` - Reward management logic +- `lib/cubit/reward/reward_state.dart` - Reward state management + +--- + +### 2. **Five New Educational Games** + +#### ๐ŸŽฏ **Game 1: Alphabet Match** (Level 1) +- Memory-style matching game +- Match Tibetan characters with their romanized sounds +- 6 pairs per game +- **Location**: `lib/presentation/game/alphabet_match/alphabet_match_game.dart` +- **Features**: + - Audio playback for each character + - Score tracking with animations + - Star rating (1-3) based on moves + - Professional card design with gradients + +#### ๐ŸŽจ **Game 2: Character Trace** (Level 2) +- Interactive drawing/tracing game +- Learn to write Tibetan characters +- 10 characters per session +- **Location**: `lib/presentation/game/character_trace/character_trace_game.dart` +- **Features**: + - Touch-based drawing canvas + - Character reference display + - Audio pronunciation + - Confetti animations on success + +#### ๐ŸŽง **Game 3: Sound Quiz** (Level 3) +- Audio-based learning game +- Listen and identify correct character +- 15 questions per game +- **Location**: `lib/presentation/game/sound_quiz/sound_quiz_game.dart` +- **Features**: + - Multiple choice (4 options) + - Instant feedback with visual indicators + - Shake animation for wrong answers + - Score percentage calculation + +#### ๐Ÿงฉ **Game 4: Word Builder** (Level 4) +- Construct Tibetan words from characters +- Uses verb vocabulary +- 10 words per game +- **Location**: `lib/presentation/game/word_builder/word_builder_game.dart` +- **Features**: + - Drag-and-drop character chips + - Hint system (3 hints per game) + - Audio word pronunciation + - Skip option for difficult words + +#### โšก **Game 5: Speed Challenge** (Level 6) +- Timed rapid-fire game +- 60-second challenge +- Unlimited questions +- **Location**: `lib/presentation/game/speed_challenge/speed_challenge_game.dart` +- **Features**: + - Real-time countdown + - Streak multiplier bonuses + - High-pressure gameplay + - Maximum score tracking + +--- + +### 3. **Enhanced Game Model** + +**Updated**: `lib/presentation/game/util/game_model.dart` + +**New Fields:** +- `stars` (0-3): Star rating earned +- `coinsEarned`: Total coins from game +- `requiredStarsToUnlock`: Stars needed to unlock next level + +**New Game Types:** +```dart +enum GameType { + spellingBeeGame, // Existing - Now Level 8 + snakeGame, // Existing - Now Level 7 + memoryGame, // Existing - Now Level 5 + alphabetMatchGame, // NEW - Level 1 + characterTraceGame, // NEW - Level 2 + soundQuizGame, // NEW - Level 3 + wordBuilderGame, // NEW - Level 4 + speedChallengeGame, // NEW - Level 6 +} +``` + +--- + +### 4. **Professional Game Home Page** + +**Created**: `lib/presentation/game/game_home_page_new.dart` + +**Features:** +- **Modern Grid Layout**: 2-column responsive grid +- **Animated Cards**: Staggered entrance animations +- **Progress Display**: Shows total coins, stars, and streak at top +- **Visual Feedback**: + - Lock overlay for locked games + - Play indicator for unlocked games + - Star progress for each game + - Coins earned per game +- **Achievement Viewer**: Bottom sheet with all achievements +- **Gradient Backgrounds**: Beautiful color gradients +- **Professional Dialogs**: Custom unlock requirement dialogs + +--- + +### 5. **Shared UI Components** + +#### Game Result Dialog +**Location**: `lib/presentation/game/widgets/game_result_dialog.dart` + +**Features:** +- Consistent victory screen across all games +- Lottie animation celebration +- Star rating display (1-3 stars) +- Score and coins earned +- Play Again / Exit buttons +- Gradient background + +--- + +### 6. **Enhanced Game Bloc** + +**Updated**: `lib/game_bloc/game_bloc.dart` + +**New Features:** +- Star tracking (`UpdateGameStars` event) +- Coin tracking per game +- Star-based unlock system (instead of just score) +- 8 games with progressive unlock requirements + +**Unlock Progression:** +``` +Level 1: Alphabet Match (Always unlocked) + โ†“ (1 star required) +Level 2: Character Trace + โ†“ (1 star required) +Level 3: Sound Quiz + โ†“ (2 stars required) +Level 4: Word Builder + โ†“ (2 stars required) +Level 5: Memory Match + โ†“ (2 stars required) +Level 6: Speed Challenge + โ†“ (3 stars required) +Level 7: Snake Game + โ†“ (3 stars required) +Level 8: Spelling Bee +``` + +--- + +## ๐Ÿ”ง Technical Improvements + +### State Management +- **BLoC Pattern**: GameBloc for game state +- **Cubit Pattern**: RewardCubit for rewards +- **Provider Pattern**: Existing games (Spelling Bee) +- Proper dependency injection via MultiBlocProvider + +### Routing Updates +**File**: `lib/util/route_generator.dart` + +- Added route for new game home page +- AudioCubit provided to all games +- MultiBlocProvider for complex game dependencies + +### Main App Updates +**File**: `lib/main.dart` + +- Added RewardCubit to app-level providers +- Auto-loads user progress on startup + +### Home Page Updates +**File**: `lib/presentation/home.dart` + +- Updated to navigate to new game home page +- Uses `GameHomePageNew.routeName` + +--- + +## ๐Ÿ“ฑ UI/UX Highlights + +### Animations +- โœ… Confetti on game completion +- โœ… Staggered grid animations +- โœ… Card flip/shake effects +- โœ… Smooth transitions +- โœ… Lottie animations for victory + +### Visual Design +- ๐ŸŽจ Gradient backgrounds for each game +- ๐ŸŽจ Glassmorphism effects +- ๐ŸŽจ Professional card shadows +- ๐ŸŽจ Consistent color scheme +- ๐ŸŽจ Modern typography + +### User Feedback +- โœ… Audio cues for interactions +- โœ… Visual feedback (colors, icons) +- โœ… Toast messages +- โœ… Progress indicators +- โœ… Score displays + +--- + +## ๐ŸŽฏ Gamification Features + +### Reward System +- **Coins**: Base reward + star bonus +- **Stars**: 1-3 per game based on performance +- **Achievements**: 9 unlockable achievements +- **Streaks**: Daily play tracking + +### Progression +- **Linear Unlock**: Must earn stars to progress +- **Difficulty Curve**: Games get harder +- **Replayability**: Can replay for better stars +- **Mastery**: 3-star challenge on all games + +--- + +## ๐Ÿ“Š Performance Metrics + +### Star Criteria + +**Alphabet Match:** +- 3 stars: โ‰ค 12 moves +- 2 stars: โ‰ค 15 moves +- 1 star: > 15 moves + +**Character Trace:** +- 3 stars: Score โ‰ฅ 130 +- 2 stars: Score โ‰ฅ 100 +- 1 star: Score < 100 + +**Sound Quiz:** +- 3 stars: โ‰ฅ 90% correct +- 2 stars: โ‰ฅ 70% correct +- 1 star: < 70% correct + +**Word Builder:** +- 3 stars: โ‰ฅ 90% correct +- 2 stars: โ‰ฅ 70% correct +- 1 star: < 70% correct + +**Speed Challenge:** +- 3 stars: โ‰ฅ 30 correct in 60s +- 2 stars: โ‰ฅ 20 correct in 60s +- 1 star: < 20 correct in 60s + +--- + +## ๐Ÿ”„ Existing Games + +**All existing games maintained and improved:** +- โœ… Spelling Bee (Level 8) +- โœ… Snake Game (Level 7) +- โœ… Memory Match (Level 5) + +**Enhancements:** +- Added AudioCubit support +- Updated unlock requirements +- Integrated with new reward system + +--- + +## ๐Ÿš€ How to Play + +1. **Start**: Open app โ†’ Click "Play Game" +2. **Game Home**: See all 8 games in grid layout +3. **Select Game**: Tap unlocked game to play +4. **Earn Rewards**: Complete game to earn stars & coins +5. **Unlock Next**: Earn required stars to unlock next level +6. **Achievements**: View achievements via trophy icon +7. **Progress**: Track total coins, stars, and streak + +--- + +## ๐Ÿ“ File Structure + +``` +lib/ +โ”œโ”€โ”€ model/ +โ”‚ โ””โ”€โ”€ reward_model.dart # NEW: Reward models +โ”œโ”€โ”€ cubit/ +โ”‚ โ””โ”€โ”€ reward/ +โ”‚ โ”œโ”€โ”€ reward_cubit.dart # NEW: Reward logic +โ”‚ โ””โ”€โ”€ reward_state.dart # NEW: Reward state +โ”œโ”€โ”€ game_bloc/ +โ”‚ โ”œโ”€โ”€ game_bloc.dart # UPDATED: Star/coin support +โ”‚ โ”œโ”€โ”€ game_event.dart # UPDATED: New events +โ”‚ โ””โ”€โ”€ game_state.dart # Same +โ”œโ”€โ”€ presentation/ +โ”‚ โ”œโ”€โ”€ home.dart # UPDATED: New route +โ”‚ โ””โ”€โ”€ game/ +โ”‚ โ”œโ”€โ”€ game_home_page_new.dart # NEW: Professional UI +โ”‚ โ”œโ”€โ”€ widgets/ +โ”‚ โ”‚ โ””โ”€โ”€ game_result_dialog.dart # NEW: Shared dialog +โ”‚ โ”œโ”€โ”€ alphabet_match/ +โ”‚ โ”‚ โ””โ”€โ”€ alphabet_match_game.dart # NEW: Game 1 +โ”‚ โ”œโ”€โ”€ character_trace/ +โ”‚ โ”‚ โ””โ”€โ”€ character_trace_game.dart # NEW: Game 2 +โ”‚ โ”œโ”€โ”€ sound_quiz/ +โ”‚ โ”‚ โ””โ”€โ”€ sound_quiz_game.dart # NEW: Game 3 +โ”‚ โ”œโ”€โ”€ word_builder/ +โ”‚ โ”‚ โ””โ”€โ”€ word_builder_game.dart # NEW: Game 4 +โ”‚ โ””โ”€โ”€ speed_challenge/ +โ”‚ โ””โ”€โ”€ speed_challenge_game.dart # NEW: Game 5 +โ””โ”€โ”€ main.dart # UPDATED: RewardCubit +``` + +--- + +## ๐ŸŽ“ Educational Value + +### Language Learning Benefits +1. **Multi-modal Learning**: Visual, audio, kinesthetic +2. **Spaced Repetition**: Games encourage replay +3. **Progressive Difficulty**: Gradual skill building +4. **Immediate Feedback**: Learn from mistakes +5. **Engagement**: Gamification increases motivation + +### Tibetan Language Focus +- โœ… Authentic Tibetan alphabet (Uchen script) +- โœ… Native audio pronunciations +- โœ… Traditional character forms +- โœ… Verb vocabulary +- โœ… Writing practice + +--- + +## ๐Ÿ› Error Handling + +- Graceful error states +- User-friendly error messages +- Fallback UI for loading states +- Safe audio loading +- Proper disposal of controllers + +--- + +## ๐Ÿ’พ Data Persistence + +**SharedPreferences Keys:** +- `game_score_[gameType]`: High scores +- `game_stars_[gameType]`: Best star rating +- `game_coins_[gameType]`: Total coins earned +- `game_unlock_[gameType]`: Unlock status +- `total_coins`: User total coins +- `total_stars`: User total stars +- `games_played`: Total games count +- `current_streak`: Daily streak +- `last_played_date`: Last play timestamp +- `unlocked_achievements`: Achievement IDs + +--- + +## ๐ŸŽ‰ Summary + +This update transforms the Tibetan Language Learning App into a **professional, engaging, and educational** gaming experience with: + +- **5 NEW games** (+ 3 existing = 8 total) +- **Comprehensive reward system** (coins, stars, achievements) +- **Professional UI/UX** (animations, gradients, modern design) +- **Progressive unlock system** (earn stars to advance) +- **Educational focus** (authentic Tibetan learning) +- **High replayability** (star ratings, achievements) + +Perfect for kids learning Tibetan alphabet and vocabulary through fun, interactive games! + +--- + +**Version**: 1.4.0 +**Date**: 2025-11-14 +**Status**: โœ… Ready for Production diff --git a/lib/bloc/snake_game/snake_game_bloc.dart b/lib/bloc/snake_game/snake_game_bloc.dart index 73eb8fc..a0a8d91 100644 --- a/lib/bloc/snake_game/snake_game_bloc.dart +++ b/lib/bloc/snake_game/snake_game_bloc.dart @@ -13,6 +13,7 @@ part 'snake_game_state.dart'; class SnakeGameBloc extends Bloc { Timer? _gameTimer; static const int gridSize = 760; + static const int playableGridSize = 700; // Limit food to visible area (35 rows) static const int initialSpeed = 300; // Initial speed in milliseconds static const int minSpeed = 50; // Minimum speed (maximum difficulty) static const int speedDecrement = 5; // How much to decrease speed per score @@ -49,7 +50,7 @@ class SnakeGameBloc extends Bloc { final initialState = SnakeGameState.initial(); emit(initialState.copyWith( isPlaying: true, - food: _random.nextInt(700), + food: _random.nextInt(playableGridSize), currentLetter: _alphabet[_random.nextInt(_alphabet.length)], )); @@ -125,7 +126,16 @@ class SnakeGameBloc extends Bloc { } void _onGenerateFood(GenerateFood event, Emitter emit) { - final newFood = _random.nextInt(700); + // Generate food position within playable grid bounds, avoiding snake body + int newFood; + int attempts = 0; + do { + newFood = _random.nextInt(playableGridSize); + attempts++; + // Prevent infinite loop if grid is nearly full + if (attempts > 100) break; + } while (state.snakePosition.contains(newFood)); + final newLetter = _alphabet[_random.nextInt(_alphabet.length)]; emit(state.copyWith( food: newFood, diff --git a/lib/cubit/audio_cubit.dart b/lib/cubit/audio_cubit.dart index 20f010d..e22be9d 100644 --- a/lib/cubit/audio_cubit.dart +++ b/lib/cubit/audio_cubit.dart @@ -14,7 +14,7 @@ class AudioCubit extends Cubit { } Future loadAudio( - {required String pathName, required String fileName}) async { + { String pathName="assets/audio/", required String fileName}) async { try { emit(AudioLoading()); audioPlayer = @@ -42,6 +42,15 @@ class AudioCubit extends Cubit { await audioPlayer.play(); } + Future playAudioWithSpeed(double speed) async { + await audioPlayer.setSpeed(speed); + await audioPlayer.play(); + } + + Future setPlaybackSpeed(double speed) async { + await audioPlayer.setSpeed(speed); + } + Future pauseAudio() async { await audioPlayer.pause(); } diff --git a/lib/cubit/reward/reward_cubit.dart b/lib/cubit/reward/reward_cubit.dart new file mode 100644 index 0000000..1f01b42 --- /dev/null +++ b/lib/cubit/reward/reward_cubit.dart @@ -0,0 +1,347 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../model/reward_model.dart'; + +part 'reward_state.dart'; + +class RewardCubit extends Cubit { + static const String _coinsKey = 'total_coins'; + static const String _starsKey = 'total_stars'; + static const String _gamesPlayedKey = 'games_played'; + static const String _streakKey = 'current_streak'; + static const String _lastPlayedKey = 'last_played_date'; + static const String _achievementsKey = 'unlocked_achievements'; + + RewardCubit() : super(const RewardState()); + + /// Load user progress from storage + Future loadProgress() async { + emit(state.copyWith(isLoading: true)); + try { + final prefs = await SharedPreferences.getInstance(); + + final totalCoins = prefs.getInt(_coinsKey) ?? 0; + final totalStars = prefs.getInt(_starsKey) ?? 0; + final gamesPlayed = prefs.getInt(_gamesPlayedKey) ?? 0; + final currentStreak = prefs.getInt(_streakKey) ?? 0; + final lastPlayedString = prefs.getString(_lastPlayedKey); + final unlockedAchievements = + prefs.getStringList(_achievementsKey) ?? []; + + DateTime? lastPlayedDate; + if (lastPlayedString != null) { + lastPlayedDate = DateTime.tryParse(lastPlayedString); + } + + // Check and update streak + final updatedStreak = _calculateStreak(currentStreak, lastPlayedDate); + + final progress = UserProgress( + totalCoins: totalCoins, + totalStars: totalStars, + gamesPlayed: gamesPlayed, + currentStreak: updatedStreak, + lastPlayedDate: lastPlayedDate, + unlockedAchievements: unlockedAchievements, + ); + + final achievements = _initializeAchievements(progress); + + emit(state.copyWith( + progress: progress, + achievements: achievements, + isLoading: false, + )); + } catch (e) { + emit(state.copyWith( + isLoading: false, + error: 'Failed to load progress: $e', + )); + } + } + + /// Award coins to the user + Future awardCoins(int coins) async { + try { + final prefs = await SharedPreferences.getInstance(); + final newTotal = state.progress.totalCoins + coins; + await prefs.setInt(_coinsKey, newTotal); + + emit(state.copyWith( + progress: state.progress.copyWith(totalCoins: newTotal), + )); + + await _checkAchievements(); + } catch (e) { + emit(state.copyWith(error: 'Failed to award coins: $e')); + } + } + + /// Add stars to user progress + Future addStars(int stars) async { + try { + final prefs = await SharedPreferences.getInstance(); + final newTotal = state.progress.totalStars + stars; + await prefs.setInt(_starsKey, newTotal); + + emit(state.copyWith( + progress: state.progress.copyWith(totalStars: newTotal), + )); + + await _checkAchievements(); + } catch (e) { + emit(state.copyWith(error: 'Failed to add stars: $e')); + } + } + + /// Record a game completion + Future recordGamePlayed() async { + try { + final prefs = await SharedPreferences.getInstance(); + final now = DateTime.now(); + final newCount = state.progress.gamesPlayed + 1; + + // Update streak + final updatedStreak = + _calculateStreak(state.progress.currentStreak, state.progress.lastPlayedDate); + final newStreak = updatedStreak + (state.progress.lastPlayedDate == null || + !_isSameDay(state.progress.lastPlayedDate!, now) + ? 1 + : 0); + + await prefs.setInt(_gamesPlayedKey, newCount); + await prefs.setInt(_streakKey, newStreak); + await prefs.setString(_lastPlayedKey, now.toIso8601String()); + + emit(state.copyWith( + progress: state.progress.copyWith( + gamesPlayed: newCount, + currentStreak: newStreak, + lastPlayedDate: now, + ), + )); + + await _checkAchievements(); + } catch (e) { + emit(state.copyWith(error: 'Failed to record game: $e')); + } + } + + /// Process game result and award rewards + Future processGameResult({ + required int score, + required int stars, + required int baseCoins, + }) async { + final coinsEarned = baseCoins + (stars * 10); // Bonus coins per star + + await awardCoins(coinsEarned); + await addStars(stars); + await recordGamePlayed(); + + return GameResult( + score: score, + stars: stars, + coinsEarned: coinsEarned, + isNewHighScore: false, + isNewStarRecord: false, + ); + } + + /// Unlock an achievement + Future unlockAchievement(String achievementId) async { + try { + if (state.progress.unlockedAchievements.contains(achievementId)) { + return; // Already unlocked + } + + final prefs = await SharedPreferences.getInstance(); + final updatedList = [ + ...state.progress.unlockedAchievements, + achievementId, + ]; + await prefs.setStringList(_achievementsKey, updatedList); + + // Find achievement and award coins + final achievement = state.achievements.firstWhere( + (a) => a.id == achievementId, + orElse: () => const Achievement( + id: '', + title: '', + description: '', + icon: '', + coinsReward: 0, + type: AchievementType.gamesPlayed, + targetValue: 0, + ), + ); + + if (achievement.id.isNotEmpty) { + await awardCoins(achievement.coinsReward); + } + + emit(state.copyWith( + progress: state.progress.copyWith(unlockedAchievements: updatedList), + newlyUnlockedAchievement: achievement, + )); + + // Clear notification after a delay + await Future.delayed(const Duration(milliseconds: 100)); + emit(state.copyWith(newlyUnlockedAchievement: null)); + } catch (e) { + emit(state.copyWith(error: 'Failed to unlock achievement: $e')); + } + } + + /// Check if any achievements should be unlocked + Future _checkAchievements() async { + for (final achievement in state.achievements) { + if (achievement.isUnlocked) continue; + + bool shouldUnlock = false; + + switch (achievement.type) { + case AchievementType.gamesPlayed: + shouldUnlock = + state.progress.gamesPlayed >= achievement.targetValue; + break; + case AchievementType.starsEarned: + shouldUnlock = state.progress.totalStars >= achievement.targetValue; + break; + case AchievementType.coinsEarned: + shouldUnlock = state.progress.totalCoins >= achievement.targetValue; + break; + case AchievementType.streakDays: + shouldUnlock = + state.progress.currentStreak >= achievement.targetValue; + break; + default: + break; + } + + if (shouldUnlock) { + await unlockAchievement(achievement.id); + } + } + } + + /// Calculate streak based on last played date + int _calculateStreak(int currentStreak, DateTime? lastPlayed) { + if (lastPlayed == null) return 0; + + final now = DateTime.now(); + final difference = now.difference(lastPlayed).inDays; + + if (difference == 0) { + // Same day + return currentStreak; + } else if (difference == 1) { + // Consecutive day + return currentStreak; + } else { + // Streak broken + return 0; + } + } + + bool _isSameDay(DateTime date1, DateTime date2) { + return date1.year == date2.year && + date1.month == date2.month && + date1.day == date2.day; + } + + /// Initialize achievements list + List _initializeAchievements(UserProgress progress) { + final allAchievements = [ + const Achievement( + id: 'first_game', + title: 'First Steps', + description: 'Play your first game', + icon: '๐ŸŽฎ', + coinsReward: 50, + type: AchievementType.gamesPlayed, + targetValue: 1, + ), + const Achievement( + id: 'ten_games', + title: 'Getting Started', + description: 'Play 10 games', + icon: '๐ŸŽฏ', + coinsReward: 100, + type: AchievementType.gamesPlayed, + targetValue: 10, + ), + const Achievement( + id: 'fifty_games', + title: 'Dedicated Learner', + description: 'Play 50 games', + icon: '๐Ÿ†', + coinsReward: 250, + type: AchievementType.gamesPlayed, + targetValue: 50, + ), + const Achievement( + id: 'ten_stars', + title: 'Rising Star', + description: 'Earn 10 stars', + icon: 'โญ', + coinsReward: 100, + type: AchievementType.starsEarned, + targetValue: 10, + ), + const Achievement( + id: 'fifty_stars', + title: 'Star Collector', + description: 'Earn 50 stars', + icon: '๐ŸŒŸ', + coinsReward: 300, + type: AchievementType.starsEarned, + targetValue: 50, + ), + const Achievement( + id: 'hundred_coins', + title: 'Coin Collector', + description: 'Earn 100 coins', + icon: '๐Ÿช™', + coinsReward: 50, + type: AchievementType.coinsEarned, + targetValue: 100, + ), + const Achievement( + id: 'five_hundred_coins', + title: 'Wealthy Learner', + description: 'Earn 500 coins', + icon: '๐Ÿ’ฐ', + coinsReward: 200, + type: AchievementType.coinsEarned, + targetValue: 500, + ), + const Achievement( + id: 'three_day_streak', + title: 'Consistency', + description: 'Play for 3 days in a row', + icon: '๐Ÿ”ฅ', + coinsReward: 150, + type: AchievementType.streakDays, + targetValue: 3, + ), + const Achievement( + id: 'seven_day_streak', + title: 'Dedicated', + description: 'Play for 7 days in a row', + icon: '๐Ÿ”ฅ', + coinsReward: 350, + type: AchievementType.streakDays, + targetValue: 7, + ), + ]; + + // Mark achievements as unlocked based on progress + return allAchievements.map((achievement) { + final isUnlocked = + progress.unlockedAchievements.contains(achievement.id); + return achievement.copyWith(isUnlocked: isUnlocked); + }).toList(); + } +} diff --git a/lib/cubit/reward/reward_state.dart b/lib/cubit/reward/reward_state.dart new file mode 100644 index 0000000..0c3a691 --- /dev/null +++ b/lib/cubit/reward/reward_state.dart @@ -0,0 +1,42 @@ +part of 'reward_cubit.dart'; + +class RewardState extends Equatable { + final UserProgress progress; + final List achievements; + final bool isLoading; + final String? error; + final Achievement? newlyUnlockedAchievement; + + const RewardState({ + this.progress = const UserProgress(), + this.achievements = const [], + this.isLoading = false, + this.error, + this.newlyUnlockedAchievement, + }); + + RewardState copyWith({ + UserProgress? progress, + List? achievements, + bool? isLoading, + String? error, + Achievement? newlyUnlockedAchievement, + }) { + return RewardState( + progress: progress ?? this.progress, + achievements: achievements ?? this.achievements, + isLoading: isLoading ?? this.isLoading, + error: error, + newlyUnlockedAchievement: newlyUnlockedAchievement, + ); + } + + @override + List get props => [ + progress, + achievements, + isLoading, + error, + newlyUnlockedAchievement, + ]; +} diff --git a/lib/game_bloc/game_bloc.dart b/lib/game_bloc/game_bloc.dart index 6f175ac..13d43c7 100644 --- a/lib/game_bloc/game_bloc.dart +++ b/lib/game_bloc/game_bloc.dart @@ -12,11 +12,14 @@ part 'game_state.dart'; class GameBloc extends Bloc { static const String _scorePrefix = 'game_score_'; static const String _unlockPrefix = 'game_unlock_'; + static const String _starsPrefix = 'game_stars_'; + static const String _coinsPrefix = 'game_coins_'; GameBloc() : super(GameState(games: [])) { on(_onLoadGames); on(_onUpdateGameScore); on(_onCheckGameUnlock); + on(_onUpdateGameStars); } Future _onLoadGames(LoadGames event, Emitter emit) async { @@ -31,11 +34,15 @@ class GameBloc extends Bloc { _unlockPrefix + games[0].gameType.toString(), true); } - // Load saved scores and unlock status + // Load saved scores, stars, coins and unlock status for (var i = 0; i < games.length; i++) { var game = games[i]; final score = _prefs.getInt(_scorePrefix + game.gameType.toString()) ?? 0; + final stars = + _prefs.getInt(_starsPrefix + game.gameType.toString()) ?? 0; + final coins = + _prefs.getInt(_coinsPrefix + game.gameType.toString()) ?? 0; final isUnlocked = _prefs.getBool(_unlockPrefix + game.gameType.toString()) ?? (i == 0); @@ -43,6 +50,8 @@ class GameBloc extends Bloc { games[i] = game.copyWith( isUnlocked: isUnlocked, currentScore: score, + stars: stars, + coinsEarned: coins, ); } emit(state.copyWith(games: games, isLoading: false)); @@ -79,6 +88,35 @@ class GameBloc extends Bloc { } } + Future _onUpdateGameStars( + UpdateGameStars event, Emitter emit) async { + try { + final _prefs = await SharedPreferences.getInstance(); + + // Save stars and coins + await _prefs.setInt( + _starsPrefix + event.gameType.toString(), event.stars); + await _prefs.setInt( + _coinsPrefix + event.gameType.toString(), event.coinsEarned); + + // Update game list with new stars and coins + final updatedGames = state.games.map((game) { + if (game.gameType == event.gameType) { + return game.copyWith( + stars: event.stars, + coinsEarned: event.coinsEarned, + ); + } + return game; + }).toList(); + + emit(state.copyWith(games: updatedGames)); + add(CheckGameUnlock()); + } catch (e) { + emit(state.copyWith(error: e.toString())); + } + } + Future _onCheckGameUnlock( CheckGameUnlock event, Emitter emit) async { try { @@ -87,14 +125,14 @@ class GameBloc extends Bloc { final updatedGames = List.from(state.games); bool hasChanges = false; - // Check each game's unlock conditions + // Check each game's unlock conditions based on stars for (int i = 1; i < updatedGames.length; i++) { final game = updatedGames[i]; final previousGame = updatedGames[i - 1]; + // Unlock if previous game has required stars if (!game.isUnlocked && - previousGame.currentScore >= - game.requiredScoreInPreviousLevelToUnlock) { + previousGame.stars >= game.requiredStarsToUnlock) { updatedGames[i] = game.copyWith(isUnlocked: true); await _prefs.setBool(_unlockPrefix + game.gameType.toString(), true); hasChanges = true; @@ -111,33 +149,92 @@ class GameBloc extends Bloc { List _getInitialGames() { return [ - Game( - name: 'Spelling Bee', - description: 'Master Tibetan spelling with this exciting game!', - gameIcon: 'https://assets6.lottiefiles.com/packages/lf20_yU09RI.json', - gameType: GameType.spellingBeeGame, + // Level 1 - Always unlocked + const Game( + name: 'Alphabet Match', + description: 'Match Tibetan characters with their sounds!', + gameIcon: 'https://assets9.lottiefiles.com/packages/lf20_khzniaya.json', + gameType: GameType.alphabetMatchGame, requiredScoreInPreviousLevelToUnlock: 0, + requiredStarsToUnlock: 0, level: 1, - currentScore: 0, ), - Game( + + // Level 2 - Requires 1 star from Level 1 + const Game( + name: 'Character Trace', + description: 'Learn to write Tibetan characters by tracing!', + gameIcon: 'https://assets2.lottiefiles.com/packages/lf20_x1gjdldd.json', + gameType: GameType.characterTraceGame, + requiredScoreInPreviousLevelToUnlock: 0, + requiredStarsToUnlock: 1, + level: 2, + ), + + // Level 3 - Requires 1 star from Level 2 + const Game( + name: 'Sound Quiz', + description: 'Listen and identify the correct Tibetan character!', + gameIcon: 'https://assets2.lottiefiles.com/packages/lf20_xyadyfwx.json', + gameType: GameType.soundQuizGame, + requiredScoreInPreviousLevelToUnlock: 0, + requiredStarsToUnlock: 1, + level: 3, + ), + + // Level 4 - Requires 2 stars from Level 3 + const Game( + name: 'Word Builder', + description: 'Build Tibetan words from characters!', + gameIcon: 'https://assets6.lottiefiles.com/packages/lf20_yU09RI.json', + gameType: GameType.wordBuilderGame, + requiredScoreInPreviousLevelToUnlock: 0, + requiredStarsToUnlock: 2, + level: 4, + ), + + // Level 5 - Requires 2 stars from Level 4 + const Game( + name: 'Memory Match', + description: 'Match pairs of Tibetan characters!', + gameIcon: 'https://lottie.host/e878b150-a736-48c1-a865-35b03bc19920/7zmeLDaVtf.json', + gameType: GameType.memoryGame, + requiredScoreInPreviousLevelToUnlock: 0, + requiredStarsToUnlock: 2, + level: 5, + ), + + // Level 6 - Requires 2 stars from Level 5 + const Game( + name: 'Speed Challenge', + description: 'Race against time to identify characters!', + gameIcon: 'https://assets1.lottiefiles.com/packages/lf20_poqmycwy.json', + gameType: GameType.speedChallengeGame, + requiredScoreInPreviousLevelToUnlock: 0, + requiredStarsToUnlock: 2, + level: 6, + ), + + // Level 7 - Requires 3 stars from Level 6 + const Game( name: 'Snake Game', description: 'Collect Tibetan letters while growing your snake!', gameIcon: 'https://assets6.lottiefiles.com/packages/lf20_qoo3cyxi.json', gameType: GameType.snakeGame, - requiredScoreInPreviousLevelToUnlock: 1, - level: 2, - currentScore: 0, + requiredScoreInPreviousLevelToUnlock: 0, + requiredStarsToUnlock: 3, + level: 7, ), - Game( - name: 'Memory Match', - description: 'Collect Tibetan letters while growing your snake!', - gameIcon: - 'https://lottie.host/embed/e878b150-a736-48c1-a865-35b03bc19920/7zmeLDaVtf.json', - gameType: GameType.memoryGame, - requiredScoreInPreviousLevelToUnlock: kLevelOneScoreLimit, - level: 3, - currentScore: 0, + + // Level 8 - Requires 3 stars from Level 7 + const Game( + name: 'Spelling Bee', + description: 'Master Tibetan spelling with this challenging game!', + gameIcon: 'https://assets1.lottiefiles.com/packages/lf20_touohxv0.json', + gameType: GameType.spellingBeeGame, + requiredScoreInPreviousLevelToUnlock: 0, + requiredStarsToUnlock: 3, + level: 8, ), ]; } diff --git a/lib/game_bloc/game_event.dart b/lib/game_bloc/game_event.dart index 225596e..53eee0d 100644 --- a/lib/game_bloc/game_event.dart +++ b/lib/game_bloc/game_event.dart @@ -17,4 +17,19 @@ class UpdateGameScore extends GameEvent { List get props => [gameType, score]; } +class UpdateGameStars extends GameEvent { + final GameType gameType; + final int stars; + final int coinsEarned; + + UpdateGameStars({ + required this.gameType, + required this.stars, + required this.coinsEarned, + }); + + @override + List get props => [gameType, stars, coinsEarned]; +} + class CheckGameUnlock extends GameEvent {} diff --git a/lib/main.dart b/lib/main.dart index 98e01a1..23e4ac6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:get_it/get_it.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:tibetan_language_learning_app/cubit/language_cubit.dart'; +import 'package:tibetan_language_learning_app/cubit/reward/reward_cubit.dart'; import 'package:tibetan_language_learning_app/game_bloc/game_bloc.dart'; import 'package:tibetan_language_learning_app/l10n/app_localizations.dart'; import 'package:tibetan_language_learning_app/l10n/l10n.dart'; @@ -41,6 +42,9 @@ class MyApp extends StatelessWidget { AppConstant.ROBOTO_FAMILY, ), ), + BlocProvider( + create: (context) => RewardCubit()..loadProgress(), + ), ], child: StartPage(), ); diff --git a/lib/model/reward_model.dart b/lib/model/reward_model.dart new file mode 100644 index 0000000..4e2f883 --- /dev/null +++ b/lib/model/reward_model.dart @@ -0,0 +1,133 @@ +import 'package:equatable/equatable.dart'; + +/// Achievement model for gamification +class Achievement extends Equatable { + final String id; + final String title; + final String description; + final String icon; + final int coinsReward; + final bool isUnlocked; + final AchievementType type; + final int targetValue; // e.g., play 10 games, earn 100 stars + + const Achievement({ + required this.id, + required this.title, + required this.description, + required this.icon, + required this.coinsReward, + required this.type, + required this.targetValue, + this.isUnlocked = false, + }); + + Achievement copyWith({ + bool? isUnlocked, + }) { + return Achievement( + id: id, + title: title, + description: description, + icon: icon, + coinsReward: coinsReward, + type: type, + targetValue: targetValue, + isUnlocked: isUnlocked ?? this.isUnlocked, + ); + } + + @override + List get props => [ + id, + title, + description, + icon, + coinsReward, + isUnlocked, + type, + targetValue, + ]; +} + +enum AchievementType { + gamesPlayed, + starsEarned, + coinsEarned, + perfectGame, // All 3 stars + streakDays, + allGamesUnlocked, +} + +/// Game result after completing a game +class GameResult extends Equatable { + final int score; + final int stars; // 0-3 stars + final int coinsEarned; + final bool isNewHighScore; + final bool isNewStarRecord; + + const GameResult({ + required this.score, + required this.stars, + required this.coinsEarned, + this.isNewHighScore = false, + this.isNewStarRecord = false, + }); + + @override + List get props => [ + score, + stars, + coinsEarned, + isNewHighScore, + isNewStarRecord, + ]; +} + +/// User statistics and progress +class UserProgress extends Equatable { + final int totalCoins; + final int totalStars; + final int gamesPlayed; + final int currentStreak; // Days in a row + final DateTime? lastPlayedDate; + final List unlockedAchievements; + + const UserProgress({ + this.totalCoins = 0, + this.totalStars = 0, + this.gamesPlayed = 0, + this.currentStreak = 0, + this.lastPlayedDate, + this.unlockedAchievements = const [], + }); + + UserProgress copyWith({ + int? totalCoins, + int? totalStars, + int? gamesPlayed, + int? currentStreak, + DateTime? lastPlayedDate, + List? unlockedAchievements, + }) { + return UserProgress( + totalCoins: totalCoins ?? this.totalCoins, + totalStars: totalStars ?? this.totalStars, + gamesPlayed: gamesPlayed ?? this.gamesPlayed, + currentStreak: currentStreak ?? this.currentStreak, + lastPlayedDate: lastPlayedDate ?? this.lastPlayedDate, + unlockedAchievements: unlockedAchievements ?? this.unlockedAchievements, + ); + } + + @override + List get props => [ + totalCoins, + totalStars, + gamesPlayed, + currentStreak, + lastPlayedDate, + unlockedAchievements, + ]; +} diff --git a/lib/presentation/game/alphabet_match/alphabet_match_game.dart b/lib/presentation/game/alphabet_match/alphabet_match_game.dart new file mode 100644 index 0000000..3e08d0d --- /dev/null +++ b/lib/presentation/game/alphabet_match/alphabet_match_game.dart @@ -0,0 +1,253 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:confetti/confetti.dart'; +import '../../../game_bloc/game_bloc.dart'; +import '../../../cubit/audio_cubit.dart'; +import '../../../util/constant.dart'; +import '../../../util/application_util.dart'; +import '../util/game_model.dart'; +import '../widgets/game_result_dialog.dart'; +import '../widgets/game_score_card.dart'; +import '../widgets/game_layout.dart'; + +class AlphabetMatchGame extends StatefulWidget { + const AlphabetMatchGame({Key? key}) : super(key: key); + + @override + State createState() => _AlphabetMatchGameState(); +} + +class _AlphabetMatchGameState extends State + with SingleTickerProviderStateMixin { + late ConfettiController _confettiController; + late AnimationController _animationController; + + List cards = []; + List selectedIndices = []; + List matchedIndices = []; + int moves = 0; + int matches = 0; + final int totalPairs = 6; + int score = 0; + bool isProcessing = false; + + @override + void initState() { + super.initState(); + _confettiController = ConfettiController(duration: const Duration(seconds: 2)); + _animationController = AnimationController(vsync: this, duration: const Duration(milliseconds: 300)); + _initializeGame(); + } + + @override + void dispose() { + _confettiController.dispose(); + _animationController.dispose(); + super.dispose(); + } + + void _initializeGame() { + final random = Random(); + final alphabets = AppConstant.getAlphabetList(AlphabetCategoryType.ALPHABET).toList()..shuffle(random); + final selectedAlphabets = alphabets.take(totalPairs).toList(); + + cards.clear(); + for (var alphabet in selectedAlphabets) { + cards.add(MatchCard(id: alphabet.fileName, displayText: alphabet.alphabetName, isCharacter: true, audioFileName: alphabet.fileName)); + cards.add(MatchCard(id: alphabet.fileName, displayText: alphabet.fileName.toUpperCase(), isCharacter: false, audioFileName: alphabet.fileName)); + } + cards.shuffle(random); + setState(() {}); + } + + void _onCardTap(int index) { + if (isProcessing || selectedIndices.contains(index) || matchedIndices.contains(index) || selectedIndices.length >= 2) return; + + setState(() { + selectedIndices.add(index); + moves++; + }); + + context.read().loadAudio(fileName: cards[index].audioFileName); + context.read().playAudio(); + + if (selectedIndices.length == 2) { + _checkMatch(); + } + } + + void _checkMatch() { + isProcessing = true; + final firstCard = cards[selectedIndices[0]]; + final secondCard = cards[selectedIndices[1]]; + + if (firstCard.id == secondCard.id) { + _animationController.forward().then((_) => _animationController.reverse()); + + setState(() { + matchedIndices.addAll(selectedIndices); + matches++; + score += 10; + selectedIndices.clear(); + isProcessing = false; + }); + + if (matches == totalPairs) { + _gameComplete(); + } + } else { + Timer(const Duration(milliseconds: 1000), () { + setState(() { + selectedIndices.clear(); + isProcessing = false; + }); + }); + } + } + + void _gameComplete() { + _confettiController.play(); + + int stars = moves <= 12 ? 3 : (moves <= 16 ? 2 : 1); + final coinsEarned = score + (stars * 15); + + context.read().add(UpdateGameStars(gameType: GameType.alphabetMatchGame, stars: stars, coinsEarned: coinsEarned)); + context.read().add(UpdateGameScore(gameType: GameType.alphabetMatchGame, score: score)); + + Future.delayed(const Duration(milliseconds: 800), () { + if (mounted) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => GameResultDialog( + title: 'Amazing! ๐ŸŽ‰', + score: score, + stars: stars, + coinsEarned: coinsEarned, + message: 'You matched all pairs in $moves moves!', + onPlayAgain: () { + Navigator.pop(context); + _resetGame(); + }, + onExit: () { + Navigator.pop(context); + Navigator.pop(context); + }, + ), + ); + } + }); + } + + void _resetGame() { + setState(() { + selectedIndices.clear(); + matchedIndices.clear(); + moves = 0; + matches = 0; + score = 0; + isProcessing = false; + }); + _initializeGame(); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + GameLayout( + title: 'Alphabet Match', + scoreCards: [ + GameScoreCard(label: 'Moves', value: moves.toString(), icon: Icons.touch_app), + GameScoreCard(label: 'Pairs', value: '$matches/$totalPairs', icon: Icons.check_circle), + GameScoreCard(label: 'Score', value: score.toString(), icon: Icons.star), + ], + gameContent: Padding( + padding: const EdgeInsets.all(20), + child: GridView.builder( + physics: const BouncingScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 0.75, + ), + itemCount: cards.length, + itemBuilder: (context, index) { + final isSelected = selectedIndices.contains(index); + final isMatched = matchedIndices.contains(index); + return _buildCard(cards[index], isSelected, isMatched, index); + }, + ), + ), + ), + + Align( + alignment: Alignment.topCenter, + child: ConfettiWidget( + confettiController: _confettiController, + blastDirectionality: BlastDirectionality.explosive, + particleDrag: 0.05, + emissionFrequency: 0.05, + numberOfParticles: 25, + gravity: 0.1, + ), + ), + ], + ); + } + + Widget _buildCard(MatchCard card, bool isSelected, bool isMatched, int index) { + BoxDecoration decoration; + Color textColor; + + if (isMatched) { + decoration = BoxDecoration( + color: Colors.green.shade400, + borderRadius: BorderRadius.circular(10), + boxShadow: const [ + BoxShadow(color: Colors.black26, offset: Offset(-3, -3), blurRadius: 6), + BoxShadow(color: Colors.white24, offset: Offset(3, 3), blurRadius: 6), + ], + ); + textColor = Colors.white; + } else if (isSelected) { + decoration = ApplicationUtil.getBoxDecorationTwo(context); + textColor = Colors.black87; + } else { + decoration = ApplicationUtil.getBoxDecorationOne(context); + textColor = Colors.white; + } + + return GestureDetector( + onTap: () => _onCardTap(index), + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + decoration: decoration, + child: Center( + child: Text( + card.displayText, + style: TextStyle( + fontSize: card.isCharacter ? 42 : 18, + fontWeight: FontWeight.bold, + color: textColor, + fontFamily: card.isCharacter ? 'jomolhari' : null, + ), + textAlign: TextAlign.center, + ), + ), + ), + ); + } +} + +class MatchCard { + final String id; + final String displayText; + final bool isCharacter; + final String audioFileName; + + MatchCard({required this.id, required this.displayText, required this.isCharacter, required this.audioFileName}); +} diff --git a/lib/presentation/game/character_trace/character_trace_game.dart b/lib/presentation/game/character_trace/character_trace_game.dart new file mode 100644 index 0000000..a2cea3e --- /dev/null +++ b/lib/presentation/game/character_trace/character_trace_game.dart @@ -0,0 +1,355 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:confetti/confetti.dart'; +import '../../../game_bloc/game_bloc.dart'; +import '../../../cubit/audio_cubit.dart'; +import '../../../model/alphabet.dart'; +import '../../../util/constant.dart'; +import '../../../util/application_util.dart'; +import '../util/game_model.dart'; +import '../widgets/game_result_dialog.dart'; +import '../widgets/game_score_card.dart'; + +class CharacterTraceGame extends StatefulWidget { + const CharacterTraceGame({Key? key}) : super(key: key); + + @override + State createState() => _CharacterTraceGameState(); +} + +class _CharacterTraceGameState extends State { + late ConfettiController _confettiController; + + List drawnPoints = []; + List alphabets = []; + int currentIndex = 0; + int score = 0; + int completedCharacters = 0; + int totalCharacters = 10; + bool hasDrawn = false; + + @override + void initState() { + super.initState(); + _confettiController = ConfettiController(duration: const Duration(seconds: 2)); + _initializeGame(); + } + + @override + void dispose() { + _confettiController.dispose(); + super.dispose(); + } + + void _initializeGame() { + final random = Random(); + final allAlphabets = AppConstant.getAlphabetList(AlphabetCategoryType.ALPHABET) + .toList() + ..shuffle(random); + + alphabets = allAlphabets.take(totalCharacters).toList(); + _playCurrentCharacterAudio(); + } + + void _playCurrentCharacterAudio() { + if (currentIndex < alphabets.length) { + final current = alphabets[currentIndex]; + context.read().loadAudio( + fileName: current.fileName + ); + context.read().playAudio(); + } + } + + void _checkDrawing() { + if (!hasDrawn) return; + + // Simple validation - check if user drew something + final isValid = drawnPoints.length > 10; + + if (isValid) { + _confettiController.play(); + setState(() { + completedCharacters++; + score += 15; + }); + + Future.delayed(const Duration(milliseconds: 800), () { + if (completedCharacters >= totalCharacters) { + _gameComplete(); + } else { + setState(() { + currentIndex++; + drawnPoints.clear(); + hasDrawn = false; + }); + _playCurrentCharacterAudio(); + } + }); + } else { + _showFeedback('Try tracing the character!', Colors.orange); + } + } + + void _showFeedback(String message, Color color) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: color, + duration: const Duration(seconds: 1), + ), + ); + } + + void _gameComplete() { + // Calculate stars based on completion + int stars = 3; + if (score < 100) { + stars = 1; + } else if (score < 130) { + stars = 2; + } + + final coinsEarned = score + (stars * 10); + + // Update game stats + context.read().add(UpdateGameStars( + gameType: GameType.characterTraceGame, + stars: stars, + coinsEarned: coinsEarned, + )); + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => GameResultDialog( + title: 'Well Done! ๐ŸŽจ', + score: score, + stars: stars, + coinsEarned: coinsEarned, + message: 'You traced $completedCharacters characters!', + onPlayAgain: () { + Navigator.pop(context); + _resetGame(); + }, + onExit: () { + Navigator.pop(context); + Navigator.pop(context); + }, + ), + ); + } + + void _resetGame() { + setState(() { + drawnPoints.clear(); + currentIndex = 0; + score = 0; + completedCharacters = 0; + hasDrawn = false; + }); + _initializeGame(); + } + + void _clearDrawing() { + setState(() { + drawnPoints.clear(); + hasDrawn = false; + }); + } + + @override + Widget build(BuildContext context) { + final currentAlphabet = currentIndex < alphabets.length + ? alphabets[currentIndex] + : alphabets.last; + + return Scaffold( + backgroundColor: Theme.of(context).primaryColor, + appBar: AppBar( + title: const Text('Character Trace'), + backgroundColor: Theme.of(context).primaryColor, + foregroundColor: Colors.white, + elevation: 0, + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only(right: 16.0), + child: Text( + 'Progress: $completedCharacters/$totalCharacters', + style: const TextStyle(fontSize: 16, color: Colors.white), + ), + ), + ), + ], + ), + body: Stack( + children: [ + SafeArea( + child: Column( + children: [ + const SizedBox(height: 20), + + // Score display + GameScoreRow( + cards: [ + GameScoreCard(label: 'Score', value: score.toString(), icon: Icons.star), + GameScoreCard(label: 'Traced', value: '$completedCharacters', icon: Icons.check_circle), + ], + ), + + const SizedBox(height: 30), + + // Instruction + const Text( + 'Trace the character below:', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + + const SizedBox(height: 20), + + // Character to trace (reference) + Container( + padding: const EdgeInsets.all(20), + margin: const EdgeInsets.symmetric(horizontal: 40), + decoration: ApplicationUtil.getBoxDecorationTwo(context), + child: Center( + child: Text( + currentAlphabet.alphabetName, + style: const TextStyle( + fontSize: 80, + fontFamily: 'jomolhari', + color: Colors.black87, + ), + ), + ), + ), + + const SizedBox(height: 20), + + // Drawing area + Expanded( + child: Container( + margin: const EdgeInsets.all(20), + decoration: ApplicationUtil.getBoxDecorationTwo(context), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: GestureDetector( + onPanStart: (details) { + setState(() { + hasDrawn = true; + drawnPoints.add(details.localPosition); + }); + }, + onPanUpdate: (details) { + setState(() { + drawnPoints.add(details.localPosition); + }); + }, + onPanEnd: (details) { + setState(() { + drawnPoints.add(Offset.infinite); + }); + }, + child: CustomPaint( + painter: DrawingPainter(drawnPoints), + size: Size.infinite, + ), + ), + ), + ), + ), + + // Action buttons + Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Expanded( + child: GestureDetector( + onTap: _clearDrawing, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: ApplicationUtil.getBoxDecorationOne(context), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.clear, color: Colors.white), + SizedBox(width: 8), + Text('Clear', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)), + ], + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: GestureDetector( + onTap: _checkDrawing, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: ApplicationUtil.getBoxDecorationOne(context), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.check, color: Colors.white), + SizedBox(width: 8), + Text('Check', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)), + ], + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + + Align( + alignment: Alignment.topCenter, + child: ConfettiWidget( + confettiController: _confettiController, + blastDirectionality: BlastDirectionality.explosive, + particleDrag: 0.05, + emissionFrequency: 0.05, + numberOfParticles: 15, + gravity: 0.1, + ), + ), + ], + ), + ); + } + +} + +class DrawingPainter extends CustomPainter { + final List points; + + DrawingPainter(this.points); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = Colors.deepOrange + ..strokeWidth = 6.0 + ..strokeCap = StrokeCap.round; + + for (int i = 0; i < points.length - 1; i++) { + if (points[i].isFinite && points[i + 1].isFinite) { + canvas.drawLine(points[i], points[i + 1], paint); + } + } + } + + @override + bool shouldRepaint(DrawingPainter oldDelegate) => true; +} diff --git a/lib/presentation/game/game_home_page_new.dart b/lib/presentation/game/game_home_page_new.dart new file mode 100644 index 0000000..b792fad --- /dev/null +++ b/lib/presentation/game/game_home_page_new.dart @@ -0,0 +1,413 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:lottie/lottie.dart'; +import '../../game_bloc/game_bloc.dart'; +import '../../cubit/reward/reward_cubit.dart'; +import '../../cubit/audio_cubit.dart'; +import '../../util/application_util.dart'; +import '../../service/audio_service.dart'; +import '../game/util/game_model.dart'; +import 'alphabet_match/alphabet_match_game.dart'; +import 'character_trace/character_trace_game.dart'; +import 'sound_quiz/sound_quiz_game.dart'; +import 'word_builder/word_builder_game.dart'; +import 'speed_challenge/speed_challenge_game.dart'; +import 'memory_match/memory_match_screen.dart'; +import 'snake_game/snake_game.dart'; +import 'spelling_bee/spelling_bee_page.dart'; + +class GameHomePageNew extends StatefulWidget { + static const routeName = 'game-home'; + + const GameHomePageNew({Key? key}) : super(key: key); + + @override + State createState() => _GameHomePageNewState(); +} + +class _GameHomePageNewState extends State { + @override + void initState() { + super.initState(); + context.read().loadProgress(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Theme.of(context).primaryColor, + body: SafeArea( + child: Column( + children: [ + const SizedBox(height: 20), + _buildHeader(), + const SizedBox(height: 20), + Expanded( + child: BlocConsumer( + listener: (context, state) { + if (state.error != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(state.error!)), + ); + } + }, + builder: (context, state) { + if (state.isLoading) { + return const Center(child: CircularProgressIndicator(color: Colors.white)); + } + return _buildGameGrid(state.games); + }, + ), + ), + ], + ), + ), + ); + } + + Widget _buildHeader() { + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.arrow_back_ios, color: Colors.white, size: 24), + ), + const Text( + 'Games', + style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.white), + ), + IconButton( + onPressed: _showAchievements, + icon: const Icon(Icons.emoji_events, color: Colors.amberAccent, size: 26), + ), + ], + ), + ), + const SizedBox(height: 16), + BlocBuilder( + builder: (context, state) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildStatBadge('${state.progress.totalCoins}', Icons.monetization_on), + _buildStatBadge('${state.progress.totalStars}', Icons.star), + _buildStatBadge('${state.progress.currentStreak}', Icons.local_fire_department), + ], + ), + ); + }, + ), + ], + ); + } + + Widget _buildStatBadge(String value, IconData icon) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), + decoration: ApplicationUtil.getBoxDecorationOne(context), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: Colors.white, size: 20), + const SizedBox(width: 6), + Text(value, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)), + ], + ), + ); + } + + Widget _buildGameGrid(List games) { + return AnimationLimiter( + child: GridView.builder( + padding: const EdgeInsets.all(20), + physics: const BouncingScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + childAspectRatio: 0.78, + ), + itemCount: games.length, + itemBuilder: (context, index) { + return AnimationConfiguration.staggeredGrid( + position: index, + columnCount: 2, + duration: const Duration(milliseconds: 400), + child: SlideAnimation( + verticalOffset: 50, + child: FadeInAnimation(child: _buildGameCard(games[index])), + ), + ); + }, + ), + ); + } + + Widget _buildGameCard(Game game) { + final isLocked = !game.isUnlocked; + + return GestureDetector( + onTap: () { + if (isLocked) { + _showUnlockDialog(game); + } else { + _navigateToGame(game.gameType); + } + }, + child: Opacity( + opacity: isLocked ? 0.6 : 1.0, + child: Container( + decoration: ApplicationUtil.getBoxDecorationOne(context), + padding: const EdgeInsets.all(12), + child: Column( + children: [ + // Level badge + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.white24, + borderRadius: BorderRadius.circular(8), + ), + child: Text('LV ${game.level}', style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: Colors.white)), + ), + if (!isLocked) + const Icon(Icons.play_circle_filled, color: Colors.greenAccent, size: 20) + else + const Icon(Icons.lock, color: Colors.white54, size: 20), + ], + ), + const SizedBox(height: 8), + // Game icon + Expanded( + child: Lottie.network(game.gameIcon, fit: BoxFit.contain), + ), + const SizedBox(height: 8), + // Game name + Text( + game.name, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.white), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + // Stars or lock requirement + if (!isLocked) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(3, (index) { + return Icon( + index < game.stars ? Icons.star : Icons.star_border, + color: Colors.amberAccent, + size: 16, + ); + }), + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Row( + children: [ + const Icon(Icons.emoji_events, size: 12, color: Colors.white70), + const SizedBox(width: 3), + Text('${game.currentScore}', style: const TextStyle(fontSize: 10, color: Colors.white70)), + ], + ), + Row( + children: [ + const Icon(Icons.monetization_on, size: 12, color: Colors.amberAccent), + const SizedBox(width: 3), + Text('${game.coinsEarned}', style: const TextStyle(fontSize: 10, color: Colors.white70)), + ], + ), + ], + ), + ] else ...[ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.star, color: Colors.amberAccent, size: 14), + const SizedBox(width: 4), + Text('${game.requiredStarsToUnlock} stars', style: const TextStyle(fontSize: 11, color: Colors.white70)), + ], + ), + ], + ], + ), + ), + ), + ); + } + + void _showUnlockDialog(Game game) { + showDialog( + context: context, + builder: (context) => Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(10), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Lottie.asset('assets/json/unlock.json', height: 100, repeat: true), + const SizedBox(height: 16), + Text('${game.name} Locked', style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.white)), + const SizedBox(height: 12), + Text( + 'Earn ${game.requiredStarsToUnlock} stars in Level ${game.level - 1} to unlock!', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14, color: Colors.white70), + ), + const SizedBox(height: 20), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12), + decoration: ApplicationUtil.getBoxDecorationOne(context), + child: const Text('Got it!', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)), + ), + ), + ], + ), + ), + ), + ); + } + + void _showAchievements() { + showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).primaryColor, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only(topLeft: Radius.circular(20), topRight: Radius.circular(20)), + ), + builder: (context) => Container( + height: MediaQuery.of(context).size.height * 0.7, + padding: const EdgeInsets.all(20), + child: Column( + children: [ + Container( + width: 50, + height: 5, + decoration: BoxDecoration(color: Colors.white30, borderRadius: BorderRadius.circular(10)), + ), + const SizedBox(height: 16), + const Text('Achievements', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.white)), + const SizedBox(height: 20), + Expanded( + child: BlocBuilder( + builder: (context, state) { + if (state.achievements.isEmpty) { + return const Center(child: Text('No achievements yet', style: TextStyle(color: Colors.white70))); + } + + return ListView.builder( + physics: const BouncingScrollPhysics(), + itemCount: state.achievements.length, + itemBuilder: (context, index) { + final achievement = state.achievements[index]; + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: achievement.isUnlocked + ? BoxDecoration( + color: Colors.green.shade400, + borderRadius: BorderRadius.circular(10), + boxShadow: const [ + BoxShadow(color: Colors.black26, offset: Offset(-3, -3), blurRadius: 6), + BoxShadow(color: Colors.white24, offset: Offset(3, 3), blurRadius: 6), + ], + ) + : ApplicationUtil.getBoxDecorationOne(context), + child: Row( + children: [ + Text(achievement.icon, style: const TextStyle(fontSize: 36)), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(achievement.title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.white)), + Text(achievement.description, style: const TextStyle(fontSize: 12, color: Colors.white70)), + ], + ), + ), + if (achievement.isUnlocked) + const Icon(Icons.check_circle, color: Colors.white, size: 28) + else + const Icon(Icons.lock, color: Colors.white54, size: 24), + ], + ), + ); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } + + void _navigateToGame(GameType gameType) { + Widget? gameWidget; + + switch (gameType) { + case GameType.alphabetMatchGame: + gameWidget = const AlphabetMatchGame(); + break; + case GameType.characterTraceGame: + gameWidget = const CharacterTraceGame(); + break; + case GameType.soundQuizGame: + gameWidget = const SoundQuizGame(); + break; + case GameType.wordBuilderGame: + gameWidget = const WordBuilderGame(); + break; + case GameType.speedChallengeGame: + gameWidget = const SpeedChallengeGame(); + break; + case GameType.memoryGame: + Navigator.pushNamed(context, MemoryMatchGameScreen.routeName); + return; + case GameType.snakeGame: + Navigator.pushNamed(context, SnakeGamePage.routeName); + return; + case GameType.spellingBeeGame: + Navigator.pushNamed(context, SpellingBeePage.routeName); + return; + } + + if (gameWidget != null) { + // Provide AudioCubit to all new games + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => BlocProvider( + create: (context) => AudioCubit(AudioService(), audioPlayer: AudioPlayer()), + child: gameWidget!, + ), + ), + ); + } + } +} diff --git a/lib/presentation/game/memory_match/memory_match_screen.dart b/lib/presentation/game/memory_match/memory_match_screen.dart index 1e8f8a0..34759b4 100644 --- a/lib/presentation/game/memory_match/memory_match_screen.dart +++ b/lib/presentation/game/memory_match/memory_match_screen.dart @@ -1,116 +1,291 @@ +import 'dart:async'; +import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:confetti/confetti.dart'; +import '../../../game_bloc/game_bloc.dart'; +import '../../../cubit/audio_cubit.dart'; +import '../../../model/verb.dart'; +import '../../../util/constant.dart'; +import '../../../util/application_util.dart'; +import '../util/game_model.dart'; +import '../widgets/game_result_dialog.dart'; +import '../widgets/game_score_card.dart'; +import '../widgets/game_layout.dart'; -import '../../../bloc/memory_match/memory_match_bloc.dart'; - -class MemoryMatchGameScreen extends StatelessWidget { +class MemoryMatchGameScreen extends StatefulWidget { static const routeName = 'memory-match-game'; const MemoryMatchGameScreen({Key? key}) : super(key: key); @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => - MemoryMatchBloc()..add(InitializeGame(cards: _generateCards())), - child: Scaffold( - appBar: AppBar( - title: Text('Memory Match'), - ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - children: [ - BlocBuilder( - builder: (context, state) { - if (state.game == null) { - return Center(child: CircularProgressIndicator()); - } - return Expanded( - child: GridView.builder( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: 8.0, - mainAxisSpacing: 8.0, - ), - itemCount: state.game!.cards.length, - itemBuilder: (context, index) { - return CardWidget( - card: state.game!.cards[index], - isFlipped: state.game!.flipped[index], - isMatched: state.game!.matched[index], - onTap: () { - if (state.game!.currentlyFlipped.length < 2) { - context.read().add( - FlipCard(index: index), - ); - } - }, - ); - }, - ), - ); - }, - ), - BlocBuilder( - builder: (context, state) { - if (state.isGameComplete) { - return Text( - 'Congratulations! You found all pairs!', - style: - TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ); - } - return SizedBox.shrink(); - }, - ), - ], - ), - ), - ), + State createState() => _MemoryMatchGameScreenState(); +} + +class _MemoryMatchGameScreenState extends State + with SingleTickerProviderStateMixin { + late ConfettiController _confettiController; + late AnimationController _flipController; + + List cards = []; + List flippedIndices = []; + List matchedIndices = []; + int moves = 0; + int matches = 0; + final int totalPairs = 6; + int score = 0; + bool isProcessing = false; + + @override + void initState() { + super.initState(); + _confettiController = ConfettiController(duration: const Duration(seconds: 2)); + _flipController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 400), ); + _initializeGame(); } - List _generateCards() { - final words = ['๐ŸŽ', '๐ŸŒ', '๐Ÿ’', '๐Ÿ‡', '๐Ÿ‰', '๐Ÿ“']; - final cards = [...words, ...words]..shuffle(); - return cards; + @override + void dispose() { + _confettiController.dispose(); + _flipController.dispose(); + super.dispose(); } -} -class CardWidget extends StatelessWidget { - final String card; - final bool isFlipped; - final bool isMatched; - final VoidCallback onTap; - - const CardWidget({ - required this.card, - required this.isFlipped, - required this.isMatched, - required this.onTap, - }); + void _initializeGame() { + final random = Random(); + final verbs = AppConstant.verbsList.toList()..shuffle(random); + final selectedVerbs = verbs.take(totalPairs).toList(); + + cards.clear(); + for (var verb in selectedVerbs) { + cards.add(MemoryCard(id: verb.fileName, verb: verb)); + cards.add(MemoryCard(id: verb.fileName, verb: verb)); + } + cards.shuffle(random); + setState(() {}); + } + + void _onCardTap(int index) { + if (isProcessing || + flippedIndices.contains(index) || + matchedIndices.contains(index) || + flippedIndices.length >= 2) return; + + setState(() { + flippedIndices.add(index); + if (flippedIndices.length == 1) { + moves++; + } + }); + + // Play audio + context.read().loadAudio(fileName: cards[index].verb.fileName); + context.read().playAudio(); + + if (flippedIndices.length == 2) { + _checkMatch(); + } + } + + void _checkMatch() { + isProcessing = true; + final firstCard = cards[flippedIndices[0]]; + final secondCard = cards[flippedIndices[1]]; + + if (firstCard.id == secondCard.id) { + _flipController.forward().then((_) => _flipController.reverse()); + + setState(() { + matchedIndices.addAll(flippedIndices); + matches++; + score += 15; + flippedIndices.clear(); + isProcessing = false; + }); + + if (matches == totalPairs) { + _gameComplete(); + } + } else { + Timer(const Duration(milliseconds: 1200), () { + setState(() { + flippedIndices.clear(); + isProcessing = false; + }); + }); + } + } + + void _gameComplete() { + _confettiController.play(); + + int stars = moves <= 14 ? 3 : (moves <= 20 ? 2 : 1); + final coinsEarned = score + (stars * 15); + + context.read().add(UpdateGameStars( + gameType: GameType.memoryGame, + stars: stars, + coinsEarned: coinsEarned, + )); + + Future.delayed(const Duration(milliseconds: 800), () { + if (mounted) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => GameResultDialog( + title: 'Well Done! ๐ŸŽŠ', + score: score, + stars: stars, + coinsEarned: coinsEarned, + message: 'You matched all pairs in $moves moves!', + onPlayAgain: () { + Navigator.pop(context); + _resetGame(); + }, + onExit: () { + Navigator.pop(context); + Navigator.pop(context); + }, + ), + ); + } + }); + } + + void _resetGame() { + setState(() { + flippedIndices.clear(); + matchedIndices.clear(); + moves = 0; + matches = 0; + score = 0; + isProcessing = false; + }); + _initializeGame(); + } @override Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - decoration: BoxDecoration( - color: isMatched - ? Colors.green - : (isFlipped ? Colors.blue : Colors.grey), - borderRadius: BorderRadius.circular(8.0), + return Stack( + children: [ + GameLayout( + title: 'Memory Match', + scoreCards: [ + GameScoreCard(label: 'Moves', value: moves.toString(), icon: Icons.touch_app), + GameScoreCard(label: 'Pairs', value: '$matches/$totalPairs', icon: Icons.check_circle), + GameScoreCard(label: 'Score', value: score.toString(), icon: Icons.star), + ], + gameContent: Padding( + padding: const EdgeInsets.all(20), + child: GridView.builder( + physics: const BouncingScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 0.8, + ), + itemCount: cards.length, + itemBuilder: (context, index) { + final isFlipped = flippedIndices.contains(index); + final isMatched = matchedIndices.contains(index); + return _buildCard(cards[index], isFlipped, isMatched, index); + }, + ), + ), ), - child: Center( - child: isFlipped || isMatched - ? Text( - card, - style: TextStyle(fontSize: 32), - ) - : null, + + Align( + alignment: Alignment.topCenter, + child: ConfettiWidget( + confettiController: _confettiController, + blastDirectionality: BlastDirectionality.explosive, + particleDrag: 0.05, + emissionFrequency: 0.05, + numberOfParticles: 25, + gravity: 0.1, + ), ), + ], + ); + } + + Widget _buildCard(MemoryCard card, bool isFlipped, bool isMatched, int index) { + BoxDecoration frontDecoration; + if (isMatched) { + frontDecoration = BoxDecoration( + color: Colors.green.shade400, + borderRadius: BorderRadius.circular(10), + boxShadow: const [ + BoxShadow(color: Colors.black26, offset: Offset(-3, -3), blurRadius: 6), + BoxShadow(color: Colors.white24, offset: Offset(3, 3), blurRadius: 6), + ], + ); + } else { + frontDecoration = ApplicationUtil.getBoxDecorationTwo(context); + } + + return GestureDetector( + onTap: () => _onCardTap(index), + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + decoration: isFlipped || isMatched + ? frontDecoration + : ApplicationUtil.getBoxDecorationOne(context), + child: isFlipped || isMatched + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Image + Expanded( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Image.asset( + ApplicationUtil.getImagePath(card.verb.fileName), + fit: BoxFit.contain, + ), + ), + ), + // Name below image + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + card.verb.word, + style: TextStyle( + fontSize: 12, + fontFamily: 'jomolhari', + fontWeight: FontWeight.bold, + color: isMatched ? Colors.white : Colors.black87, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ) + : const Center( + child: Icon( + Icons.help_outline, + size: 40, + color: Colors.white, + ), + ), ), ); } } + +class MemoryCard { + final String id; + final Verb verb; + + MemoryCard({ + required this.id, + required this.verb, + }); +} diff --git a/lib/presentation/game/snake_game/snake_game.dart b/lib/presentation/game/snake_game/snake_game.dart index c8e2056..1db390b 100644 --- a/lib/presentation/game/snake_game/snake_game.dart +++ b/lib/presentation/game/snake_game/snake_game.dart @@ -4,7 +4,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../bloc/snake_game/snake_game_bloc.dart'; import '../../../game_bloc/game_bloc.dart'; import '../../../util/constant.dart'; +import '../../../util/application_util.dart'; import '../util/game_model.dart'; +import '../widgets/game_layout.dart'; +import '../widgets/game_score_card.dart'; +import '../widgets/game_result_dialog.dart'; class SnakeGamePage extends StatelessWidget { static const routeName = 'snake-game'; @@ -25,112 +29,136 @@ class SnakeGameView extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - body: BlocConsumer( - listener: (context, state) { - if (state.isGameOver) { - _showGameOverDialog(context, state.score); - } else if (state.score > kLevelOneScoreLimit) { - context.read().add(UpdateGameScore( - gameType: GameType.snakeGame, - score: kLevelOneScoreLimit, - )); - } - }, - builder: (context, state) { - return SafeArea( - child: Column( - children: [ - _buildScoreBoard(state.score, state.speed), - Expanded( - child: GestureDetector( - onVerticalDragUpdate: (details) { - if (state.direction != 'up' && details.delta.dy > 0) { - context - .read() - .add(ChangeDirection('down')); - } else if (state.direction != 'down' && - details.delta.dy < 0) { - context - .read() - .add(ChangeDirection('up')); - } - }, - onHorizontalDragUpdate: (details) { - if (state.direction != 'left' && details.delta.dx > 0) { - context - .read() - .add(ChangeDirection('right')); - } else if (state.direction != 'right' && - details.delta.dx < 0) { - context - .read() - .add(ChangeDirection('left')); - } - }, - child: Container( - decoration: BoxDecoration( - border: Border.all(color: Colors.white30), - borderRadius: BorderRadius.circular(10), + return BlocConsumer( + listener: (context, state) { + if (state.isGameOver) { + _showGameOverDialog(context, state.score); + } + }, + builder: (context, state) { + final speedPercentage = ((300 - state.speed) / 250 * 100).toInt(); + + return GameLayout( + title: 'Snake Game ๐Ÿ', + scoreCards: [ + GameScoreCard( + label: 'Score', + value: state.score.toString(), + icon: Icons.stars, + ), + GameScoreCard( + label: 'Speed', + value: '$speedPercentage%', + icon: Icons.speed, + ), + GameScoreCard( + label: 'Letters', + value: state.currentLetter, + icon: Icons.text_fields, + ), + ], + gameContent: Column( + children: [ + Expanded( + child: Center( + child: AspectRatio( + aspectRatio: 20 / 35, // 20 columns, 35 rows - ensures perfect fit + child: GestureDetector( + onVerticalDragUpdate: (details) { + if (state.direction != 'up' && details.delta.dy > 0) { + context.read().add(ChangeDirection('down')); + } else if (state.direction != 'down' && details.delta.dy < 0) { + context.read().add(ChangeDirection('up')); + } + }, + onHorizontalDragUpdate: (details) { + if (state.direction != 'left' && details.delta.dx > 0) { + context.read().add(ChangeDirection('right')); + } else if (state.direction != 'right' && details.delta.dx < 0) { + context.read().add(ChangeDirection('left')); + } + }, + child: Container( + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: Theme.of(context).primaryColorLight, + width: 3, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.3), + offset: const Offset(-5, -5), + blurRadius: 10, + ), + BoxShadow( + color: Colors.white.withOpacity(0.1), + offset: const Offset(5, 5), + blurRadius: 10, + ), + ], + ), + margin: const EdgeInsets.all(20), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: GridView.builder( + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 20, + childAspectRatio: 1.0, // Square cells + ), + itemCount: 700, // Match playableGridSize (35 rows ร— 20 columns) + itemBuilder: (context, index) { + if (state.snakePosition.contains(index)) { + return _buildSnakeBody(index == state.snakePosition.last); + } + if (index == state.food) { + return _buildFood(state.currentLetter); + } + return const SizedBox(); + }, + ), + ), ), - margin: const EdgeInsets.all(10), - child: GridView.builder( - physics: const NeverScrollableScrollPhysics(), - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 20, + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(20), + child: GestureDetector( + onTap: () { + if (!state.isPlaying) { + context.read().add(StartGame()); + } else { + context.read().add(EndGame()); + } + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: ApplicationUtil.getBoxDecorationOne(context), + child: Center( + child: Text( + state.isPlaying ? 'End Game' : 'Start Game', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, ), - itemCount: 760, - itemBuilder: (context, index) { - if (state.snakePosition.contains(index)) { - return _buildSnakeBody( - index == state.snakePosition.last); - } - if (index == state.food) { - return _buildFood(state.currentLetter); - } - return const SizedBox(); - }, ), ), ), ), - _buildControlButton(context, state), - ], - ), - ); - }, - ), - ); - } - - Widget _buildScoreBoard(int score, int speed) { - return Container( - padding: const EdgeInsets.all(20), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Text( - 'Score: $score', - style: const TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - Text( - 'Speed: ${((300 - speed) / 250 * 100).toStringAsFixed(0)}%', - style: TextStyle( - color: Colors.white.withOpacity(0.8), - fontSize: 18, - ), + ), + ], ), - ], - ), + ); + }, ); } + Widget _buildSnakeBody(bool isHead) { return Container( margin: const EdgeInsets.all(1), @@ -176,92 +204,60 @@ class SnakeGameView extends StatelessWidget { ); } - Widget _buildControlButton(BuildContext context, SnakeGameState state) { - return Container( - padding: const EdgeInsets.symmetric(vertical: 20), - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.green, - padding: const EdgeInsets.symmetric(horizontal: 50, vertical: 15), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(30), - ), - ), - onPressed: () { - if (!state.isPlaying) { - context.read().add(StartGame()); - } else { - context.read().add(EndGame()); - } - }, - child: Text( - state.isPlaying ? 'End Game' : 'Start Game', - style: const TextStyle(fontSize: 18), - ), - ), - ); - } void _showGameOverDialog(BuildContext context, int score) { + // Calculate stars based on score (realistic thresholds for snake game) + int stars = 0; + String resultTitle; + String resultMessage; + + if (score >= 10) { + stars = 3; // Excellent - 10+ letters collected + resultTitle = 'Amazing! ๐Ÿ๐ŸŒŸ'; + resultMessage = 'You collected $score Tibetan letters!\n\nYou\'re a Snake Master!'; + } else if (score >= 6) { + stars = 2; // Good - 6-9 letters collected + resultTitle = 'Great Job! ๐Ÿ'; + resultMessage = 'You collected $score Tibetan letters!\n\nKeep practicing!'; + } else if (score >= 3) { + stars = 1; // Basic - 3-5 letters collected + resultTitle = 'Good Try! ๐Ÿ'; + resultMessage = 'You collected $score Tibetan letters!\n\nNext level unlocked!'; + } else { + stars = 0; // Failed - less than 3 letters + resultTitle = 'Game Over ๐Ÿ’ช'; + resultMessage = 'You collected $score Tibetan letters.\n\nNeed 3+ letters to unlock next level!'; + } + + final coinsEarned = score * 5 + (stars * 10); + + // Update game stats only if player earned at least 1 star + if (stars > 0) { + context.read().add(UpdateGameStars( + gameType: GameType.snakeGame, + stars: stars, + coinsEarned: coinsEarned, + )); + } + showDialog( context: context, barrierDismissible: false, builder: (BuildContext dialogContext) { - return AlertDialog( - backgroundColor: Colors.black87, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15), - side: const BorderSide(color: Colors.green, width: 2), - ), - title: const Text( - 'Game Over', - style: TextStyle(color: Colors.white), - textAlign: TextAlign.center, - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.sentiment_dissatisfied, - color: Colors.red, - size: 50, - ), - const SizedBox(height: 20), - Text( - 'Score: $score', - style: const TextStyle( - color: Colors.white, - fontSize: 24, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of( - dialogContext, - ).pop(); - Navigator.pop(context); - }, - child: Text( - 'Exit', - style: - TextStyle(color: Theme.of(context).colorScheme.onPrimary), - )), - TextButton( - onPressed: () { - Navigator.of(dialogContext).pop(); - context.read().add(StartGame()); - }, - child: Text( - 'Play Again', - style: - TextStyle(color: Theme.of(context).colorScheme.onPrimary), - ), - ), - ], + return GameResultDialog( + title: resultTitle, + score: score, + stars: stars, + coinsEarned: coinsEarned, + message: resultMessage, + onPlayAgain: () { + Navigator.of(dialogContext).pop(); + context.read().add(StartGame()); + }, + onExit: () { + Navigator.of(dialogContext).pop(); + Navigator.pop(context); + }, ); }, ); diff --git a/lib/presentation/game/sound_quiz/sound_quiz_game.dart b/lib/presentation/game/sound_quiz/sound_quiz_game.dart new file mode 100644 index 0000000..442b16d --- /dev/null +++ b/lib/presentation/game/sound_quiz/sound_quiz_game.dart @@ -0,0 +1,334 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:confetti/confetti.dart'; +import '../../../game_bloc/game_bloc.dart'; +import '../../../cubit/audio_cubit.dart'; +import '../../../model/alphabet.dart'; +import '../../../util/constant.dart'; +import '../../../util/application_util.dart'; +import '../util/game_model.dart'; +import '../widgets/game_result_dialog.dart'; +import '../widgets/game_score_card.dart'; +import '../widgets/game_layout.dart'; + +class SoundQuizGame extends StatefulWidget { + const SoundQuizGame({Key? key}) : super(key: key); + + @override + State createState() => _SoundQuizGameState(); +} + +class _SoundQuizGameState extends State + with SingleTickerProviderStateMixin { + late ConfettiController _confettiController; + late AnimationController _shakeController; + + List allAlphabets = []; + Alphabet? currentQuestion; + List options = []; + + int currentQuestionIndex = 0; + int totalQuestions = 15; + int score = 0; + int correctAnswers = 0; + int wrongAnswers = 0; + bool hasAnswered = false; + int? selectedOption; + + @override + void initState() { + super.initState(); + _confettiController = ConfettiController(duration: const Duration(seconds: 2)); + _shakeController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + ); + _initializeGame(); + } + + @override + void dispose() { + _confettiController.dispose(); + _shakeController.dispose(); + super.dispose(); + } + + void _initializeGame() { + allAlphabets = AppConstant.getAlphabetList(AlphabetCategoryType.ALPHABET) + .toList(); + + if (allAlphabets.length >= 4) { + _loadNextQuestion(); + } + } + + void _loadNextQuestion() { + if (currentQuestionIndex >= totalQuestions) { + _gameComplete(); + return; + } + + final random = Random(); + final shuffled = List.from(allAlphabets)..shuffle(random); + + setState(() { + currentQuestion = shuffled[0]; + options = shuffled.take(4).toList()..shuffle(random); + hasAnswered = false; + selectedOption = null; + }); + + // Play the sound after a short delay + Future.delayed(const Duration(milliseconds: 500), () { + _playSound(); + }); + } + + void _playSound() { + if (currentQuestion != null) { + context.read().loadAudio( + fileName: currentQuestion!.fileName + ); + context.read().playAudio(); + } + } + + void _selectOption(int index) { + if (hasAnswered) return; + + setState(() { + selectedOption = index; + hasAnswered = true; + }); + + final isCorrect = options[index].fileName == currentQuestion!.fileName; + + if (isCorrect) { + _confettiController.play(); + setState(() { + correctAnswers++; + score += 10; + }); + } else { + _shakeController.forward().then((_) { + _shakeController.reverse(); + }); + setState(() { + wrongAnswers++; + }); + } + + Future.delayed(const Duration(milliseconds: 1500), () { + setState(() { + currentQuestionIndex++; + }); + _loadNextQuestion(); + }); + } + + void _gameComplete() { + // Calculate stars based on correct answers + int stars = 1; + final percentage = (correctAnswers / totalQuestions) * 100; + if (percentage >= 90) { + stars = 3; + } else if (percentage >= 70) { + stars = 2; + } + + final coinsEarned = score + (stars * 10); + + // Update game stats + context.read().add(UpdateGameStars( + gameType: GameType.soundQuizGame, + stars: stars, + coinsEarned: coinsEarned, + )); + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => GameResultDialog( + title: 'Excellent! ๐ŸŽง', + score: score, + stars: stars, + coinsEarned: coinsEarned, + message: + 'You got $correctAnswers out of $totalQuestions questions right!', + onPlayAgain: () { + Navigator.pop(context); + _resetGame(); + }, + onExit: () { + Navigator.pop(context); + Navigator.pop(context); + }, + ), + ); + } + + void _resetGame() { + setState(() { + currentQuestionIndex = 0; + score = 0; + correctAnswers = 0; + wrongAnswers = 0; + hasAnswered = false; + selectedOption = null; + }); + _initializeGame(); + } + + @override + Widget build(BuildContext context) { + if (currentQuestion == null) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + return Stack( + children: [ + GameLayout( + title: 'Sound Quiz (${currentQuestionIndex + 1}/$totalQuestions)', + scoreCards: [ + GameScoreCard(label: 'Score', value: score.toString(), icon: Icons.star), + GameScoreCard(label: 'Correct', value: correctAnswers.toString(), icon: Icons.check_circle), + GameScoreCard(label: 'Wrong', value: wrongAnswers.toString(), icon: Icons.cancel), + ], + topWidget: Column( + children: [ + const Text( + 'Listen carefully and select the character', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + GestureDetector( + onTap: _playSound, + child: Container( + width: 120, + height: 120, + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + shape: BoxShape.circle, + boxShadow: const [ + BoxShadow( + color: Colors.black, + offset: Offset(-5, -3), + spreadRadius: -4, + blurRadius: 10, + ), + BoxShadow( + color: Colors.white24, + offset: Offset(5, 5), + spreadRadius: 3, + blurRadius: 10, + ), + ], + ), + child: const Icon( + Icons.volume_up, + size: 60, + color: Colors.white, + ), + ), + ), + ], + ), + gameContent: GridView.builder( + padding: const EdgeInsets.all(20), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 15, + mainAxisSpacing: 15, + childAspectRatio: 1, + ), + itemCount: options.length, + itemBuilder: (context, index) { + return _buildOptionCard(index); + }, + ), + ), + + Align( + alignment: Alignment.topCenter, + child: ConfettiWidget( + confettiController: _confettiController, + blastDirectionality: BlastDirectionality.explosive, + particleDrag: 0.05, + emissionFrequency: 0.05, + numberOfParticles: 15, + gravity: 0.1, + ), + ), + ], + ); + } + + Widget _buildOptionCard(int index) { + final isSelected = selectedOption == index; + final isCorrect = hasAnswered && + options[index].fileName == currentQuestion!.fileName; + final isWrong = hasAnswered && isSelected && !isCorrect; + + BoxDecoration decoration; + if (isCorrect) { + decoration = BoxDecoration( + color: Colors.green.shade400, + borderRadius: BorderRadius.circular(10), + boxShadow: const [ + BoxShadow(color: Colors.black26, offset: Offset(-3, -3), blurRadius: 6), + BoxShadow(color: Colors.white24, offset: Offset(3, 3), blurRadius: 6), + ], + ); + } else if (isWrong) { + decoration = BoxDecoration( + color: Colors.red.shade400, + borderRadius: BorderRadius.circular(10), + boxShadow: const [ + BoxShadow(color: Colors.black26, offset: Offset(-3, -3), blurRadius: 6), + BoxShadow(color: Colors.white24, offset: Offset(3, 3), blurRadius: 6), + ], + ); + } else { + decoration = ApplicationUtil.getBoxDecorationTwo(context); + } + + return AnimatedBuilder( + animation: _shakeController, + builder: (context, child) { + final offset = isWrong + ? sin(_shakeController.value * pi * 4) * 10 + : 0.0; + return Transform.translate( + offset: Offset(offset, 0), + child: child, + ); + }, + child: GestureDetector( + onTap: () => _selectOption(index), + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + decoration: decoration, + child: Center( + child: Text( + options[index].alphabetName, + style: TextStyle( + fontSize: 60, + fontFamily: 'jomolhari', + color: (isCorrect || isWrong) ? Colors.white : Colors.black87, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/presentation/game/speed_challenge/speed_challenge_game.dart b/lib/presentation/game/speed_challenge/speed_challenge_game.dart new file mode 100644 index 0000000..9b5ee5f --- /dev/null +++ b/lib/presentation/game/speed_challenge/speed_challenge_game.dart @@ -0,0 +1,762 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:confetti/confetti.dart'; +import 'package:just_audio/just_audio.dart'; +import '../../../game_bloc/game_bloc.dart'; +import '../../../cubit/audio_cubit.dart'; +import '../../../model/alphabet.dart'; +import '../../../util/constant.dart'; +import '../../../util/application_util.dart'; +import '../util/game_model.dart'; +import '../widgets/game_result_dialog.dart'; +import '../widgets/game_score_card.dart'; +import '../widgets/game_layout.dart'; + +class SpeedChallengeGame extends StatefulWidget { + const SpeedChallengeGame({Key? key}) : super(key: key); + + @override + State createState() => _SpeedChallengeGameState(); +} + +class _SpeedChallengeGameState extends State + with TickerProviderStateMixin { + late ConfettiController _confettiController; + late AnimationController _timerController; + late AnimationController _correctAnimController; + + List allAlphabets = []; + Alphabet? currentQuestion; + List options = []; + + int timeLimit = 60; // 60 seconds + int timeRemaining = 60; + Timer? gameTimer; + + int score = 0; + int correctAnswers = 0; + int wrongAnswers = 0; + int streak = 0; + int maxStreak = 0; + bool isGameActive = false; + int? selectedOption; + bool isAudioPlaying = false; + bool canAnswer = false; + String? errorMessage; + + @override + void initState() { + super.initState(); + _confettiController = ConfettiController(duration: const Duration(seconds: 1)); + _timerController = AnimationController( + vsync: this, + duration: Duration(seconds: timeLimit), + ); + _correctAnimController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + ); + + _initializeGame(); + + // Show start dialog after first frame is built + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + + if (allAlphabets.length < 4) { + _showErrorDialog('Not enough content available. Need at least 4 alphabets to play.'); + } else { + _showStartDialog(); + } + }); + } + + @override + void dispose() { + _confettiController.dispose(); + _timerController.dispose(); + _correctAnimController.dispose(); + gameTimer?.cancel(); + + // Stop audio when leaving the screen + try { + context.read().pauseAudio(); + } catch (e) { + print('Could not stop audio on dispose: $e'); + } + + super.dispose(); + } + + void _initializeGame() { + allAlphabets = AppConstant.getAlphabetList(AlphabetCategoryType.ALPHABET) + .toList(); + } + + void _showStartDialog() { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(10), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.timer, + size: 80, + color: Colors.white, + ), + const SizedBox(height: 16), + const Text( + 'Speed Challenge!', + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 12), + Text( + 'Answer as many questions as you can in $timeLimit seconds!', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16, color: Colors.white70), + ), + const SizedBox(height: 8), + const Text( + '๐ŸŽง Audio plays at 2x speed for faster gameplay!', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, color: Colors.greenAccent), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: const [ + Text( + 'โญ Star Requirements:', + style: TextStyle(fontSize: 14, color: Colors.white, fontWeight: FontWeight.bold), + ), + SizedBox(height: 6), + Text( + '๐ŸŒŸ๐ŸŒŸ๐ŸŒŸ 15+ correct | ๐ŸŒŸ๐ŸŒŸ 10-14 correct | ๐ŸŒŸ 5-9 correct', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: Colors.white70), + ), + ], + ), + ), + const SizedBox(height: 20), + GestureDetector( + onTap: () { + Navigator.pop(context); + _startGame(); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14), + decoration: ApplicationUtil.getBoxDecorationOne(context), + child: const Text( + 'START', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white), + ), + ), + ), + ], + ), + ), + ), + ); + } + + void _showErrorDialog(String message) { + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.red.shade400, + borderRadius: BorderRadius.circular(10), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.error_outline, + size: 80, + color: Colors.white, + ), + const SizedBox(height: 16), + const Text( + 'Game Error', + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 12), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16, color: Colors.white), + ), + const SizedBox(height: 20), + GestureDetector( + onTap: () { + Navigator.pop(context); + Navigator.pop(context); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'GO BACK', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.red), + ), + ), + ), + ], + ), + ), + ), + ); + } + + void _startGame() { + setState(() { + isGameActive = true; + timeRemaining = timeLimit; + score = 0; + correctAnswers = 0; + wrongAnswers = 0; + streak = 0; + maxStreak = 0; + }); + + _timerController.forward(); + _loadNextQuestion(); + + gameTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + setState(() { + timeRemaining--; + }); + + if (timeRemaining <= 0) { + timer.cancel(); + _gameOver(); + } + }); + } + + void _loadNextQuestion() { + if (!mounted || !isGameActive) return; + + final random = Random(); + final shuffled = List.from(allAlphabets)..shuffle(random); + + setState(() { + currentQuestion = shuffled[0]; + options = shuffled.take(4).toList()..shuffle(random); + selectedOption = null; + isAudioPlaying = true; + canAnswer = false; + errorMessage = null; + }); + + // Load and play audio at 2x speed + _playQuestionAudio(); + } + + Future _playQuestionAudio() async { + try { + if (!mounted || currentQuestion == null) return; + + final audioCubit = context.read(); + await audioCubit.loadAudio(fileName: currentQuestion!.fileName); + + // Set speed to 2x for faster gameplay + await audioCubit.setPlaybackSpeed(2.0); + await audioCubit.playAudio(); + + // Listen to audio completion + final audioPlayer = audioCubit.audioPlayer; + final subscription = audioPlayer.playerStateStream.listen((state) { + if (state.processingState == ProcessingState.completed) { + if (mounted) { + setState(() { + isAudioPlaying = false; + canAnswer = true; + }); + } + } + }); + + // Cleanup subscription after a reasonable time + Future.delayed(const Duration(seconds: 5), () { + subscription.cancel(); + }); + } catch (e) { + if (mounted) { + setState(() { + isAudioPlaying = false; + canAnswer = true; + errorMessage = 'Audio playback error. Tap to continue.'; + }); + } + } + } + + void _selectOption(int index) { + // Prevent interaction if game is not active + if (!isGameActive) { + setState(() { + errorMessage = 'Game is not active!'; + }); + _clearErrorMessage(); + return; + } + + // Prevent repeat presses + if (selectedOption != null) { + setState(() { + errorMessage = 'Please wait for next question...'; + }); + _clearErrorMessage(); + return; + } + + // Prevent answering while audio is playing + if (isAudioPlaying || !canAnswer) { + setState(() { + errorMessage = '๐ŸŽง Please wait for audio to finish!'; + }); + _clearErrorMessage(); + return; + } + + setState(() { + selectedOption = index; + errorMessage = null; + }); + + final isCorrect = options[index].fileName == currentQuestion!.fileName; + + if (isCorrect) { + _confettiController.play(); + _correctAnimController.forward().then((_) { + _correctAnimController.reverse(); + }); + + setState(() { + correctAnswers++; + streak++; + if (streak > maxStreak) maxStreak = streak; + + // Bonus points for streaks + int points = 10; + if (streak >= 5) points += 10; + if (streak >= 10) points += 20; + + score += points; + }); + + // Quick transition to next question + Future.delayed(const Duration(milliseconds: 400), () { + if (mounted && isGameActive) { + _loadNextQuestion(); + } + }); + } else { + setState(() { + wrongAnswers++; + streak = 0; + }); + + // Short delay before next question + Future.delayed(const Duration(milliseconds: 800), () { + if (mounted && isGameActive) { + _loadNextQuestion(); + } + }); + } + } + + void _clearErrorMessage() { + Future.delayed(const Duration(milliseconds: 2000), () { + if (mounted) { + setState(() { + errorMessage = null; + }); + } + }); + } + + void _gameOver() { + if (!mounted) return; + + setState(() { + isGameActive = false; + isAudioPlaying = false; + canAnswer = false; + errorMessage = null; + }); + + gameTimer?.cancel(); + _timerController.stop(); + + // Stop audio if still playing + try { + context.read().pauseAudio(); + } catch (e) { + // Audio cubit might not be available + print('Could not stop audio: $e'); + } + + // Calculate stars based on correct answers (realistic for 60 seconds) + int stars = 1; + if (correctAnswers >= 15) { + stars = 3; // Excellent performance - ~15+ correct in 60s + } else if (correctAnswers >= 10) { + stars = 2; // Good performance - ~10-14 correct in 60s + } else if (correctAnswers >= 5) { + stars = 1; // Basic performance - ~5-9 correct in 60s + } else { + stars = 0; // Failed - less than 5 correct + } + + final coinsEarned = score + (stars * 10); + + // Update game stats - only save if player earned at least 1 star + if (stars > 0) { + context.read().add(UpdateGameStars( + gameType: GameType.speedChallengeGame, + stars: stars, + coinsEarned: coinsEarned, + )); + } + + Future.delayed(const Duration(milliseconds: 500), () { + if (!mounted) return; + + String resultTitle; + String resultMessage; + + if (stars == 3) { + resultTitle = 'Excellent! โšก๐ŸŒŸ'; + resultMessage = 'Correct: $correctAnswers | Wrong: $wrongAnswers\nMax Streak: $maxStreak\n\nYou\'re a Speed Champion!'; + } else if (stars == 2) { + resultTitle = 'Great Job! โšก'; + resultMessage = 'Correct: $correctAnswers | Wrong: $wrongAnswers\nMax Streak: $maxStreak\n\nKeep practicing!'; + } else if (stars == 1) { + resultTitle = 'Good Try! โšก'; + resultMessage = 'Correct: $correctAnswers | Wrong: $wrongAnswers\nMax Streak: $maxStreak\n\nNext level unlocked!'; + } else { + resultTitle = 'Keep Practicing! ๐Ÿ’ช'; + resultMessage = 'Correct: $correctAnswers | Wrong: $wrongAnswers\nMax Streak: $maxStreak\n\nNeed 5+ correct to unlock next level'; + } + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => GameResultDialog( + title: resultTitle, + score: score, + stars: stars, + coinsEarned: coinsEarned, + message: resultMessage, + onPlayAgain: () { + Navigator.pop(context); + _resetGame(); + }, + onExit: () { + Navigator.pop(context); + Navigator.pop(context); + }, + ), + ); + }); + } + + void _resetGame() { + _timerController.reset(); + _startGame(); + } + + @override + Widget build(BuildContext context) { + if (currentQuestion == null) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + return Stack( + children: [ + GameLayout( + title: 'Speed Challenge', + scoreCards: [ + GameScoreCard(label: 'Score', value: score.toString(), icon: Icons.star), + GameScoreCard(label: 'Correct', value: correctAnswers.toString(), icon: Icons.check), + GameScoreCard(label: 'Wrong', value: wrongAnswers.toString(), icon: Icons.close), + ], + topWidget: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Time: ${timeRemaining}s', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + Text( + 'Streak: $streak ๐Ÿ”ฅ', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + ), + const SizedBox(height: 8), + Container( + decoration: ApplicationUtil.getBoxDecorationTwo(context), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: LinearProgressIndicator( + value: timeRemaining / timeLimit, + backgroundColor: Colors.white24, + valueColor: AlwaysStoppedAnimation( + timeRemaining <= 10 ? Colors.red.shade400 : Theme.of(context).primaryColorLight, + ), + minHeight: 12, + ), + ), + ), + ], + ), + ), + gameContent: Column( + children: [ + // Question - Listen to character with animation + AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: 100, + height: 100, + decoration: BoxDecoration( + color: isAudioPlaying + ? Theme.of(context).primaryColorLight + : Theme.of(context).primaryColor, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black, + offset: const Offset(-5, -3), + spreadRadius: isAudioPlaying ? -2 : -4, + blurRadius: 10, + ), + BoxShadow( + color: isAudioPlaying ? Colors.greenAccent.withOpacity(0.5) : Colors.white24, + offset: const Offset(5, 5), + spreadRadius: isAudioPlaying ? 5 : 3, + blurRadius: isAudioPlaying ? 15 : 10, + ), + ], + ), + child: Icon( + isAudioPlaying ? Icons.hearing : Icons.headphones, + size: 50, + color: Colors.white, + ), + ), + + const SizedBox(height: 10), + + // Audio status indicator + AnimatedOpacity( + opacity: isAudioPlaying ? 1.0 : 0.0, + duration: const Duration(milliseconds: 300), + child: const Text( + '๐ŸŽต Playing...', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.greenAccent, + ), + ), + ), + + const SizedBox(height: 20), + + // Error message overlay + if (errorMessage != null) + Container( + margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.red.shade400, + borderRadius: BorderRadius.circular(10), + boxShadow: const [ + BoxShadow( + color: Colors.black26, + offset: Offset(0, 4), + blurRadius: 8, + ), + ], + ), + child: Row( + children: [ + const Icon(Icons.warning_amber_rounded, color: Colors.white, size: 28), + const SizedBox(width: 12), + Expanded( + child: Text( + errorMessage!, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ], + ), + ), + + // Options grid + Expanded( + child: GridView.builder( + padding: const EdgeInsets.all(20), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 15, + mainAxisSpacing: 15, + childAspectRatio: 1, + ), + itemCount: options.length, + itemBuilder: (context, index) { + return _buildOptionCard(index); + }, + ), + ), + ], + ), + ), + + Align( + alignment: Alignment.center, + child: ConfettiWidget( + confettiController: _confettiController, + blastDirectionality: BlastDirectionality.explosive, + particleDrag: 0.05, + emissionFrequency: 0.05, + numberOfParticles: 10, + gravity: 0.2, + ), + ), + ], + ); + } + + Widget _buildOptionCard(int index) { + final isSelected = selectedOption == index; + final isCorrect = selectedOption != null && + options[index].fileName == currentQuestion!.fileName; + final isWrong = isSelected && !isCorrect; + final isDisabled = isAudioPlaying || !canAnswer || selectedOption != null; + + BoxDecoration decoration; + Color textColor; + + if (isCorrect) { + decoration = BoxDecoration( + color: Colors.green.shade400, + borderRadius: BorderRadius.circular(10), + boxShadow: const [ + BoxShadow(color: Colors.black26, offset: Offset(-3, -3), blurRadius: 6), + BoxShadow(color: Colors.white24, offset: Offset(3, 3), blurRadius: 6), + ], + ); + textColor = Colors.white; + } else if (isWrong) { + decoration = BoxDecoration( + color: Colors.red.shade400, + borderRadius: BorderRadius.circular(10), + boxShadow: const [ + BoxShadow(color: Colors.black26, offset: Offset(-3, -3), blurRadius: 6), + BoxShadow(color: Colors.white24, offset: Offset(3, 3), blurRadius: 6), + ], + ); + textColor = Colors.white; + } else if (isDisabled) { + decoration = BoxDecoration( + color: Colors.grey.shade400, + borderRadius: BorderRadius.circular(10), + boxShadow: const [ + BoxShadow(color: Colors.black12, offset: Offset(-2, -2), blurRadius: 4), + BoxShadow(color: Colors.white12, offset: Offset(2, 2), blurRadius: 4), + ], + ); + textColor = Colors.grey.shade600; + } else { + decoration = ApplicationUtil.getBoxDecorationTwo(context); + textColor = Colors.black87; + } + + return GestureDetector( + onTap: () => _selectOption(index), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: decoration, + child: Stack( + children: [ + Center( + child: Text( + options[index].alphabetName, + style: TextStyle( + fontSize: 60, + fontFamily: 'jomolhari', + color: textColor, + ), + ), + ), + // Show lock icon when disabled + if (isDisabled && selectedOption == null) + Positioned( + top: 8, + right: 8, + child: Icon( + Icons.lock, + color: Colors.grey.shade600, + size: 20, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/game/spelling_bee/provider/spelling_bee_provider.dart b/lib/presentation/game/spelling_bee/provider/spelling_bee_provider.dart index 92f9be9..b00d1b6 100644 --- a/lib/presentation/game/spelling_bee/provider/spelling_bee_provider.dart +++ b/lib/presentation/game/spelling_bee/provider/spelling_bee_provider.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:tibetan_language_learning_app/util/application_util.dart'; import 'package:tibetan_language_learning_app/util/constant.dart'; import '../../../../game_bloc/game_bloc.dart'; import '../../util/game_model.dart'; +import '../../widgets/game_result_dialog.dart'; class SpellingBeeProvider extends ChangeNotifier { int totalLetters = 0, lettersAnswered = 0, wordAnswered = 0; @@ -23,53 +23,42 @@ class SpellingBeeProvider extends ChangeNotifier { sessionCompleted = true; } if (sessionCompleted) { - context.read().add(UpdateGameScore( + // Calculate stars and coins based on performance + final totalWords = AppConstant.verbsList.length; + final stars = 3; // Perfect completion = 3 stars + final score = wordAnswered * 10; // 10 points per word + final coinsEarned = score + (stars * 20); + + // Update game stats + context.read().add(UpdateGameStars( gameType: GameType.spellingBeeGame, - score: 1, + stars: stars, + coinsEarned: coinsEarned, )); + showDialog( - barrierDismissible: false, - context: context, - builder: (dialogContext) { - String title = - "Congrats! You just cracked spelling bee contest โค."; - String buttonText = "Exit Game"; - return AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - actionsAlignment: MainAxisAlignment.center, - title: Text( - title, - style: Theme.of(context).textTheme.titleMedium, - ), - actions: [ - InkWell( - child: Container( - width: 120, - decoration: ApplicationUtil.getBoxDecorationOne(context), - child: Center( - child: Padding( - padding: const EdgeInsets.all(5.0), - child: Text( - buttonText, - style: TextStyle( - fontSize: 16, - color: Colors.white, - ), - ), - ), - ), - ), - onTap: () { - reset(); - Navigator.pop(dialogContext); - Navigator.pop(context); - }, - ) - ], - ); - }); + barrierDismissible: false, + context: context, + builder: (dialogContext) { + return GameResultDialog( + title: 'Perfect! ๐Ÿ๐ŸŒŸ', + score: score, + stars: stars, + coinsEarned: coinsEarned, + message: + 'You completed all $totalWords words!\n\nYou\'re a Spelling Bee Champion!', + onPlayAgain: () { + reset(); + Navigator.pop(dialogContext); + }, + onExit: () { + reset(); + Navigator.pop(dialogContext); + Navigator.pop(context); + }, + ); + }, + ); } else { requestWord(request: true); } diff --git a/lib/presentation/game/spelling_bee/spelling_bee_page.dart b/lib/presentation/game/spelling_bee/spelling_bee_page.dart index 30bf000..ceef91d 100644 --- a/lib/presentation/game/spelling_bee/spelling_bee_page.dart +++ b/lib/presentation/game/spelling_bee/spelling_bee_page.dart @@ -2,7 +2,6 @@ import 'dart:math'; import 'package:confetti/confetti.dart'; import 'package:flutter/material.dart'; -import 'package:liquid_progress_indicator_v2/liquid_progress_indicator.dart'; import 'package:provider/provider.dart'; import 'package:tibetan_language_learning_app/model/verb.dart'; import 'package:tibetan_language_learning_app/presentation/game/spelling_bee/provider/spelling_bee_provider.dart'; @@ -11,8 +10,8 @@ import 'package:tibetan_language_learning_app/presentation/game/spelling_bee/wid import 'package:tibetan_language_learning_app/presentation/game/spelling_bee/widget/fly_in_animation.dart'; import 'package:tibetan_language_learning_app/util/application_util.dart'; import 'package:tibetan_language_learning_app/util/constant.dart'; - -import '../../../l10n/app_localizations.dart'; +import '../widgets/game_layout.dart'; +import '../widgets/game_score_card.dart'; class SpellingBeePage extends StatefulWidget { static const routeName = 'spelling-bee'; @@ -61,38 +60,60 @@ class _SpellingBeePageState extends State { Widget build(BuildContext context) { return WillPopScope( onWillPop: () => _showWarningDialog(), - child: Scaffold( - body: Selector( - selector: (_, controller) => controller.generateWord, - builder: (_, generate, __) { - if (generate) { - if (_tempList.isNotEmpty) { - _generateWord(); - } + child: Selector( + selector: (_, controller) => controller.generateWord, + builder: (_, generate, __) { + if (generate) { + if (_tempList.isNotEmpty) { + _generateWord(); } - return Stack( - children: [ - _getBackgroundImage(), - Column( + } + + final provider = context.watch(); + final totalWords = AppConstant.verbsList.length; + final wordsCompleted = provider.wordAnswered; + final progress = wordsCompleted / totalWords; + + return Stack( + children: [ + GameLayout( + title: 'Spelling Bee Contest ๐Ÿ', + scoreCards: [ + GameScoreCard( + label: 'Words', + value: '$wordsCompleted/$totalWords', + icon: Icons.check_circle, + ), + GameScoreCard( + label: 'Progress', + value: '${(progress * 100).toInt()}%', + icon: Icons.trending_up, + ), + GameScoreCard( + label: 'Letters', + value: '${provider.lettersAnswered}/${provider.totalLetters}', + icon: Icons.text_fields, + ), + ], + gameContent: Column( children: [ - SizedBox( - height: kToolbarHeight, - ), - _getHeader(), + const SizedBox(height: 10), _getDropContent(), + const SizedBox(height: 20), _getImageForWord(), + const SizedBox(height: 20), _getDragContent(), - _getProgressIndicator(), + const SizedBox(height: 20), + _getProgressBar(progress, wordsCompleted, totalWords), + const SizedBox(height: 10), ], ), - _getFinishCelebAnimation(), - _getCelebAnimationOnCorrectAnswer(), - ], - ); - }, - ), - floatingActionButton: ApplicationUtil.getFloatingActionButton(context, - floatingPosition: 30), + ), + _getFinishCelebAnimation(), + _getCelebAnimationOnCorrectAnswer(), + ], + ); + }, ), ); } @@ -119,119 +140,97 @@ class _SpellingBeePageState extends State { }); } - _getBackgroundImage() { - return Image.asset( - 'assets/images/tree.jpg', - fit: BoxFit.cover, - height: double.infinity, - width: double.infinity, - alignment: Alignment.centerLeft, - ); - } - - _getHeader() => Expanded( - flex: 1, - child: Container( - padding: EdgeInsets.all(5), - margin: EdgeInsets.symmetric( - horizontal: 10, - ), - decoration: ApplicationUtil.getBoxDecorationOne(context), - width: double.infinity, - child: Center( - child: Text( - AppLocalizations.of(context)!.spellingBeeContest, - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 28, - shadows: [ - Shadow( - offset: Offset(2, 2), - color: Colors.black38, - blurRadius: 10), - Shadow( - offset: Offset(-2, -2), - color: Colors.white.withOpacity(0.35), - blurRadius: 10) - ], - color: Colors.grey.shade300), - ), - ), - ), - ); - - _getDropContent() => Expanded( - flex: 3, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: selectedVerb.characterList - .map((e) => - FlyInAnimation(animate: true, child: Drop(letter: e))) - .toList(), - ), - ), - ); - _getImageForWord() => Expanded( - flex: 3, - child: Image.asset( - ApplicationUtil.getImagePath(selectedVerb.fileName), + Widget _getDropContent() { + return Container( + height: 100, + padding: const EdgeInsets.symmetric(horizontal: 10), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: selectedVerb.characterList + .map((e) => FlyInAnimation(animate: true, child: Drop(letter: e))) + .toList(), ), - ); + ), + ); + } - _getDragContent() => Expanded( - flex: 3, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: shuffledVerb.characterList - .map((e) => FlyInAnimation( - animate: true, - child: Drag( - letter: e, - ), - )) - .toList(), - ), - ), - ); + Widget _getImageForWord() { + return Container( + height: 150, + padding: const EdgeInsets.all(10), + child: Image.asset( + ApplicationUtil.getImagePath(selectedVerb.fileName), + fit: BoxFit.contain, + ), + ); + } - _getProgressIndicator() => Expanded( - flex: 1, + Widget _getDragContent() { + return Container( + height: 100, + padding: const EdgeInsets.symmetric(horizontal: 10), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, child: Row( - children: [ - Expanded( - child: Container( - child: LiquidLinearProgressIndicator( - value: _getValue(), // Defaults to 0.5. - valueColor: AlwaysStoppedAnimation( - Theme.of(context).primaryColor, - ), // Defaults to the current Theme's accentColor. - backgroundColor: Colors - .white, // Defaults to the current Theme's backgroundColor. - borderColor: Theme.of(context).primaryColor, - borderWidth: 4.0, - borderRadius: 5, + mainAxisAlignment: MainAxisAlignment.center, + children: shuffledVerb.characterList + .map((e) => FlyInAnimation( + animate: true, + child: Drag(letter: e), + )) + .toList(), + ), + ), + ); + } - direction: Axis - .horizontal, // The direction the liquid moves (Axis.vertical = bottom to top, Axis.horizontal = left to right). Defaults to Axis.horizontal. - center: Text( - "${AppConstant.getTibetanNumberByNumber(number: (AppConstant.verbsList.length - (_tempList.length + 1)).toString())} / ${AppConstant.getTibetanNumberByNumber(number: AppConstant.verbsList.length.toString())}", - style: TextStyle(fontSize: 20), - ), + Widget _getProgressBar(double progress, int completed, int total) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.all(12), + decoration: ApplicationUtil.getBoxDecorationTwo(context), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Game Progress', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + Text( + '$completed / $total', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, ), ), + ], + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: LinearProgressIndicator( + value: progress, + minHeight: 12, + backgroundColor: Colors.white24, + valueColor: AlwaysStoppedAnimation( + Theme.of(context).primaryColorLight, + ), ), - ], - ), - ); - - _getValue() => - (AppConstant.verbsList.length - (_tempList.length + 1)) / - AppConstant.verbsList.length; + ), + ], + ), + ); + } _getTopCelebrateAnimation() { return Align( @@ -323,29 +322,96 @@ class _SpellingBeePageState extends State { ); } - _showWarningDialog() { - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text('Alert'), - content: Text('Do you really want to exit?'), - actions: [ - TextButton( - onPressed: () { - Navigator.pop(context); - Navigator.pop(context); - }, - child: const Text('Yes'), - ), - TextButton( - onPressed: () { - Navigator.pop(context); - }, - child: const Text('Cancel'), - ) - ], - ); - }); + Future _showWarningDialog() async { + final result = await showDialog( + context: context, + builder: (BuildContext dialogContext) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(15), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.warning_amber_rounded, + size: 60, + color: Colors.orange, + ), + const SizedBox(height: 16), + const Text( + 'Exit Game?', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 12), + const Text( + 'Your progress will be lost!', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16, color: Colors.white70), + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => Navigator.pop(dialogContext, false), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: ApplicationUtil.getBoxDecorationTwo(context), + child: const Center( + child: Text( + 'Cancel', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: GestureDetector( + onTap: () => Navigator.pop(dialogContext, true), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: ApplicationUtil.getBoxDecorationOne(context), + child: const Center( + child: Text( + 'Exit', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + + if (result == true) { + Navigator.pop(context); + return true; + } + return false; } } diff --git a/lib/presentation/game/spelling_bee/widget/drag.dart b/lib/presentation/game/spelling_bee/widget/drag.dart index 811b206..c11704b 100644 --- a/lib/presentation/game/spelling_bee/widget/drag.dart +++ b/lib/presentation/game/spelling_bee/widget/drag.dart @@ -33,43 +33,41 @@ class _DragState extends State { child: _accepted ? SizedBox() : Container( - margin: EdgeInsets.symmetric(horizontal: 5), + margin: const EdgeInsets.symmetric(horizontal: 5), width: size.width * 0.15, height: size.width * 0.15, decoration: ApplicationUtil.getBoxDecorationOne(context), child: Center( - child: Draggable( + child: Draggable( data: widget.letter, - childWhenDragging: SizedBox(), + childWhenDragging: const SizedBox(), onDragEnd: (details) { if (details.wasAccepted) { - _accepted = true; - setState(() {}); - Provider.of(context, - listen: false) + setState(() { + _accepted = true; + }); + Provider.of(context, listen: false) .incrementLetters(context: context); } }, feedback: Container( - margin: EdgeInsets.symmetric(horizontal: 5), + margin: const EdgeInsets.symmetric(horizontal: 5), width: size.width * 0.10, height: size.width * 0.10, - decoration: - ApplicationUtil.getBoxDecorationOne(context), + decoration: ApplicationUtil.getBoxDecorationOne(context), child: Center( child: Text( widget.letter, textAlign: TextAlign.center, - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith( + style: const TextStyle( + fontFamily: 'jomolhari', + fontSize: 28, + fontWeight: FontWeight.bold, color: Colors.white, - fontFamily: AppConstant.JOMAHALI_FAMILY, shadows: [ Shadow( offset: Offset(3, 3), - color: Colors.black.withOpacity(0.4), + color: Colors.black38, blurRadius: 5, ), ], @@ -80,18 +78,17 @@ class _DragState extends State { child: Container( width: size.width * 0.15, height: size.width * 0.15, - decoration: - ApplicationUtil.getBoxDecorationOne(context), + decoration: ApplicationUtil.getBoxDecorationOne(context), child: Center( child: Text( widget.letter, textAlign: TextAlign.center, - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith( - color: Colors.white, - ), + style: const TextStyle( + fontFamily: 'jomolhari', + fontSize: 28, + fontWeight: FontWeight.bold, + color: Colors.white, + ), ), ), ), diff --git a/lib/presentation/game/spelling_bee/widget/drop.dart b/lib/presentation/game/spelling_bee/widget/drop.dart index 9cdf1ac..b67966e 100644 --- a/lib/presentation/game/spelling_bee/widget/drop.dart +++ b/lib/presentation/game/spelling_bee/widget/drop.dart @@ -1,58 +1,83 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:tibetan_language_learning_app/util/application_util.dart'; +import '../provider/spelling_bee_provider.dart'; -class Drop extends StatelessWidget { +class Drop extends StatefulWidget { final String letter; const Drop({Key? key, required this.letter}) : super(key: key); + + @override + State createState() => _DropState(); +} + +class _DropState extends State { + bool accepted = false; + @override Widget build(BuildContext context) { final size = MediaQuery.of(context).size; - bool accepted = false; - return Container( - width: size.width * 0.20, - height: size.height * 0.20, - child: Center( - child: DragTarget( - onWillAccept: (data) { - if (data == letter) { - print("accepted"); - return true; - } else { - print("rejected"); - return false; + return Selector( + shouldRebuild: (previous, next) => true, + selector: (context, controller) => controller.generateWord, + builder: (_, generate, __) { + // Reset accepted state when a new word is generated + if (generate && accepted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + accepted = false; + }); } - }, - onAccept: (data) { - accepted = true; - }, - builder: (context, candidateData, rejectedData) { - if (accepted) { - return Container( - margin: EdgeInsets.symmetric(horizontal: 5), - decoration: ApplicationUtil.getBoxDecorationOne(context), - width: size.width * 0.15, - height: size.width * 0.15, - child: Center( - child: Text( - letter, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Colors.white, - ), + }); + } + + return Container( + width: size.width * 0.20, + height: size.height * 0.20, + child: Center( + child: DragTarget( + onWillAccept: (data) { + if (data == widget.letter && !accepted) { + print("accepted: ${widget.letter}"); + return true; + } else { + print("rejected"); + return false; + } + }, + onAccept: (data) { + setState(() { + accepted = true; + }); + }, + builder: (context, candidateData, rejectedData) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 5), + decoration: ApplicationUtil.getBoxDecorationOne(context), + width: size.width * 0.15, + height: size.width * 0.15, + child: Center( + child: accepted + ? Text( + widget.letter, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'jomolhari', + fontSize: 28, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ) + : Container(), ), - ), - ); - } else { - return Container( - width: size.width * 0.15, - height: size.width * 0.15, - decoration: ApplicationUtil.getBoxDecorationOne(context), - ); - } - }, - ), - ), + ); + }, + ), + ), + ); + }, ); } } diff --git a/lib/presentation/game/util/game_model.dart b/lib/presentation/game/util/game_model.dart index 1018b4c..b2887c4 100644 --- a/lib/presentation/game/util/game_model.dart +++ b/lib/presentation/game/util/game_model.dart @@ -1,6 +1,6 @@ import 'package:equatable/equatable.dart'; -// Updated Game model +// Enhanced Game model with stars and coins class Game extends Equatable { final String name; final String description; @@ -10,6 +10,9 @@ class Game extends Equatable { final bool isUnlocked; final int currentScore; final int level; + final int stars; // 0-3 stars earned + final int coinsEarned; // Total coins earned from this game + final int requiredStarsToUnlock; // Stars needed to unlock const Game({ required this.name, @@ -20,22 +23,29 @@ class Game extends Equatable { required this.level, this.isUnlocked = false, this.currentScore = 0, + this.stars = 0, + this.coinsEarned = 0, + this.requiredStarsToUnlock = 0, }); Game copyWith({ bool? isUnlocked, int? currentScore, + int? stars, + int? coinsEarned, }) { return Game( - name: this.name, - description: this.description, - gameIcon: this.gameIcon, - gameType: this.gameType, - requiredScoreInPreviousLevelToUnlock: - this.requiredScoreInPreviousLevelToUnlock, - level: this.level, + name: name, + description: description, + gameIcon: gameIcon, + gameType: gameType, + requiredScoreInPreviousLevelToUnlock: requiredScoreInPreviousLevelToUnlock, + level: level, isUnlocked: isUnlocked ?? this.isUnlocked, currentScore: currentScore ?? this.currentScore, + stars: stars ?? this.stars, + coinsEarned: coinsEarned ?? this.coinsEarned, + requiredStarsToUnlock: requiredStarsToUnlock, ); } @@ -49,7 +59,22 @@ class Game extends Equatable { isUnlocked, currentScore, level, + stars, + coinsEarned, + requiredStarsToUnlock, ]; } -enum GameType { snakeGame, spellingBeeGame, memoryGame } +enum GameType { + // Existing games + spellingBeeGame, + snakeGame, + memoryGame, + + // New games + alphabetMatchGame, + characterTraceGame, + soundQuizGame, + wordBuilderGame, + speedChallengeGame, +} diff --git a/lib/presentation/game/widgets/game_back_button.dart b/lib/presentation/game/widgets/game_back_button.dart new file mode 100644 index 0000000..0bcba94 --- /dev/null +++ b/lib/presentation/game/widgets/game_back_button.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:tibetan_language_learning_app/util/application_util.dart'; + +/// Neomorphism-styled back button for consistent UI across all games +class GameBackButton extends StatelessWidget { + final VoidCallback? onPressed; + + const GameBackButton({ + Key? key, + this.onPressed, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onPressed ?? () => Navigator.pop(context), + child: Container( + width: 48, + height: 48, + decoration: ApplicationUtil.getBoxDecorationOne(context).copyWith( + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: Icon( + Icons.arrow_back_ios_new, + color: Colors.white, + size: 20, + ), + ), + ), + ); + } +} diff --git a/lib/presentation/game/widgets/game_layout.dart b/lib/presentation/game/widgets/game_layout.dart new file mode 100644 index 0000000..3a0bc8c --- /dev/null +++ b/lib/presentation/game/widgets/game_layout.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:tibetan_language_learning_app/util/application_util.dart'; +import 'game_score_card.dart'; +import 'game_back_button.dart'; + +/// Unified game layout component for consistent UI across all games +/// Ensures all games have the same structure and element positioning +class GameLayout extends StatelessWidget { + final String title; + final List scoreCards; + final Widget gameContent; + final Widget? topWidget; // Optional widget below score cards (e.g., timer, instructions) + final VoidCallback? onBack; + + const GameLayout({ + Key? key, + required this.title, + required this.scoreCards, + required this.gameContent, + this.topWidget, + this.onBack, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Theme.of(context).primaryColor, + body: SafeArea( + child: Column( + children: [ + // Standard spacing from top + const SizedBox(height: 15), + + // Neomorphism App Bar + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: ApplicationUtil.getBoxDecorationOne(context).copyWith( + borderRadius: BorderRadius.circular(15), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Neomorphism back button - always in same position + GameBackButton(onPressed: onBack), + + // Title - always centered + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Colors.white, + letterSpacing: 0.5, + ), + ), + ), + ), + + // Spacer to balance the back button + const SizedBox(width: 48), + ], + ), + ), + ), + + const SizedBox(height: 20), + + // Score cards - always in same position + GameScoreRow(cards: scoreCards), + + const SizedBox(height: 20), + + // Optional top widget (timer, instructions, etc.) + if (topWidget != null) ...[ + topWidget!, + const SizedBox(height: 20), + ], + + // Main game content area + Expanded( + child: gameContent, + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/game/widgets/game_result_dialog.dart b/lib/presentation/game/widgets/game_result_dialog.dart new file mode 100644 index 0000000..1c5369b --- /dev/null +++ b/lib/presentation/game/widgets/game_result_dialog.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:lottie/lottie.dart'; +import '../../../util/application_util.dart'; + +class GameResultDialog extends StatelessWidget { + final String title; + final int score; + final int stars; + final int coinsEarned; + final String message; + final VoidCallback onPlayAgain; + final VoidCallback onExit; + + const GameResultDialog({ + Key? key, + required this.title, + required this.score, + required this.stars, + required this.coinsEarned, + required this.message, + required this.onPlayAgain, + required this.onExit, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(10), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Victory animation + SizedBox( + height: 120, + child: Lottie.network( + 'https://assets9.lottiefiles.com/packages/lf20_aEFaHc.json', + repeat: false, + ), + ), + + // Title + Text( + title, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 10), + + // Message + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + color: Colors.white70, + ), + ), + const SizedBox(height: 20), + + // Stars display + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(3, (index) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Icon( + index < stars ? Icons.star : Icons.star_border, + color: Colors.amber, + size: 40, + ), + ); + }), + ), + const SizedBox(height: 20), + + // Score and coins + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildStatCard( + context, + 'Score', + score.toString(), + Icons.emoji_events, + ), + _buildStatCard( + context, + 'Coins', + '+$coinsEarned', + Icons.monetization_on, + ), + ], + ), + const SizedBox(height: 25), + + // Buttons + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: onExit, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: ApplicationUtil.getBoxDecorationOne(context), + child: const Center( + child: Text( + 'Exit', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white), + ), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: GestureDetector( + onTap: onPlayAgain, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: ApplicationUtil.getBoxDecorationOne(context), + child: const Center( + child: Text( + 'Play Again', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white), + ), + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildStatCard(BuildContext context, String label, String value, IconData icon) { + return Container( + padding: const EdgeInsets.all(16), + decoration: ApplicationUtil.getBoxDecorationOne(context), + child: Column( + children: [ + Icon(icon, color: Colors.white, size: 32), + const SizedBox(height: 8), + Text( + label, + style: const TextStyle( + fontSize: 12, + color: Colors.white70, + fontWeight: FontWeight.w500, + ), + ), + Text( + value, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/game/widgets/game_score_card.dart b/lib/presentation/game/widgets/game_score_card.dart new file mode 100644 index 0000000..17d3733 --- /dev/null +++ b/lib/presentation/game/widgets/game_score_card.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import '../../../util/application_util.dart'; + +class GameScoreCard extends StatefulWidget { + final String label; + final String value; + final IconData icon; + final bool compact; + + const GameScoreCard({ + Key? key, + required this.label, + required this.value, + required this.icon, + this.compact = false, + }) : super(key: key); + + @override + State createState() => _GameScoreCardState(); +} + +class _GameScoreCardState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + String _previousValue = ''; + + @override + void initState() { + super.initState(); + _previousValue = widget.value; + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + _scaleAnimation = Tween(begin: 1.0, end: 1.15).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ); + } + + @override + void didUpdateWidget(GameScoreCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.value != widget.value) { + _animateChange(); + _previousValue = widget.value; + } + } + + void _animateChange() { + _controller.forward().then((_) { + _controller.reverse(); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _scaleAnimation, + builder: (context, child) { + return Transform.scale( + scale: _scaleAnimation.value, + child: Container( + padding: widget.compact + ? const EdgeInsets.symmetric(horizontal: 12, vertical: 10) + : const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: ApplicationUtil.getBoxDecorationOne(context), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.label, + style: TextStyle( + fontSize: widget.compact ? 10 : 11, + color: Colors.white70, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + widget.icon, + color: Colors.white, + size: widget.compact ? 14 : 16, + ), + const SizedBox(width: 6), + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (child, animation) { + return ScaleTransition( + scale: animation, + child: child, + ); + }, + child: Text( + widget.value, + key: ValueKey(widget.value), + style: TextStyle( + fontSize: widget.compact ? 16 : 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } +} + +/// A horizontal row of game score cards +class GameScoreRow extends StatelessWidget { + final List cards; + + const GameScoreRow({ + Key? key, + required this.cards, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: cards, + ), + ); + } +} diff --git a/lib/presentation/game/word_builder/word_builder_game.dart b/lib/presentation/game/word_builder/word_builder_game.dart new file mode 100644 index 0000000..e363f60 --- /dev/null +++ b/lib/presentation/game/word_builder/word_builder_game.dart @@ -0,0 +1,425 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:confetti/confetti.dart'; +import '../../../game_bloc/game_bloc.dart'; +import '../../../cubit/audio_cubit.dart'; +import '../../../model/verb.dart'; +import '../../../util/constant.dart'; +import '../../../util/application_util.dart'; +import '../util/game_model.dart'; +import '../widgets/game_result_dialog.dart'; +import '../widgets/game_score_card.dart'; +import '../widgets/game_layout.dart'; + +class WordBuilderGame extends StatefulWidget { + const WordBuilderGame({Key? key}) : super(key: key); + + @override + State createState() => _WordBuilderGameState(); +} + +class _WordBuilderGameState extends State + with SingleTickerProviderStateMixin { + late ConfettiController _confettiController; + late AnimationController _pulseController; + + List allVerbs = []; + List shuffledVerbs = []; + Verb? currentWord; + List availableCharacters = []; + List selectedCharacters = []; + + int currentWordIndex = 0; + int totalWords = 10; + int score = 0; + int correctWords = 0; + int hints = 3; + + @override + void initState() { + super.initState(); + _confettiController = ConfettiController(duration: const Duration(seconds: 2)); + _pulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1000), + )..repeat(reverse: true); + _initializeGame(); + } + + @override + void dispose() { + _confettiController.dispose(); + _pulseController.dispose(); + super.dispose(); + } + + void _initializeGame() { + allVerbs = AppConstant.verbsList; + if (allVerbs.isNotEmpty) { + final random = Random(); + shuffledVerbs = List.from(allVerbs)..shuffle(random); + _loadNextWord(); + } + } + + void _loadNextWord() { + if (currentWordIndex >= totalWords || currentWordIndex >= shuffledVerbs.length) { + _gameComplete(); + return; + } + + final random = Random(); + + setState(() { + currentWord = shuffledVerbs[currentWordIndex]; + availableCharacters = List.from(currentWord!.characterList); + availableCharacters.shuffle(random); + selectedCharacters = []; + }); + + // Play audio + Future.delayed(const Duration(milliseconds: 500), () { + _playWordAudio(); + }); + } + + void _playWordAudio() { + if (currentWord != null) { + context.read().loadAudio( + fileName: currentWord!.fileName + ); + context.read().playAudio(); + } + } + + void _selectCharacter(int index) { + if (index >= availableCharacters.length) return; + + setState(() { + selectedCharacters.add(availableCharacters[index]); + availableCharacters.removeAt(index); + }); + + // Auto-check when all characters are selected + if (availableCharacters.isEmpty) { + _checkWord(); + } + } + + void _removeCharacter(int index) { + if (index >= selectedCharacters.length) return; + + setState(() { + availableCharacters.add(selectedCharacters[index]); + selectedCharacters.removeAt(index); + }); + } + + void _checkWord() { + final builtWord = selectedCharacters.join(); + final correctWord = currentWord!.word; + + if (builtWord == correctWord) { + _confettiController.play(); + setState(() { + correctWords++; + score += 15; + }); + + Future.delayed(const Duration(milliseconds: 1500), () { + setState(() { + currentWordIndex++; + }); + _loadNextWord(); + }); + } else { + _showFeedback('Not quite right. Try again!', Colors.orange); + } + } + + void _useHint() { + if (hints <= 0 || currentWord == null) return; + + // Find the next correct character + final correctChars = currentWord!.characterList; + final nextCorrectIndex = selectedCharacters.length; + + if (nextCorrectIndex < correctChars.length) { + final nextChar = correctChars[nextCorrectIndex]; + final availableIndex = availableCharacters.indexOf(nextChar); + + if (availableIndex != -1) { + setState(() { + hints--; + selectedCharacters.add(availableCharacters[availableIndex]); + availableCharacters.removeAt(availableIndex); + }); + + // Auto-check if complete + if (availableCharacters.isEmpty) { + _checkWord(); + } + } + } + } + + void _showFeedback(String message, Color color) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: color, + duration: const Duration(seconds: 1), + ), + ); + } + + void _skip() { + setState(() { + currentWordIndex++; + }); + _loadNextWord(); + } + + void _gameComplete() { + // Calculate stars based on correct words + int stars = 1; + final percentage = (correctWords / totalWords) * 100; + if (percentage >= 90) { + stars = 3; + } else if (percentage >= 70) { + stars = 2; + } + + final coinsEarned = score + (stars * 10); + + // Update game stats + context.read().add(UpdateGameStars( + gameType: GameType.wordBuilderGame, + stars: stars, + coinsEarned: coinsEarned, + )); + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => GameResultDialog( + title: 'Fantastic! ๐ŸŽฏ', + score: score, + stars: stars, + coinsEarned: coinsEarned, + message: 'You built $correctWords out of $totalWords words!', + onPlayAgain: () { + Navigator.pop(context); + _resetGame(); + }, + onExit: () { + Navigator.pop(context); + Navigator.pop(context); + }, + ), + ); + } + + void _resetGame() { + setState(() { + currentWordIndex = 0; + score = 0; + correctWords = 0; + hints = 3; + selectedCharacters = []; + availableCharacters = []; + }); + _initializeGame(); + } + + @override + Widget build(BuildContext context) { + if (currentWord == null) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + return Stack( + children: [ + GameLayout( + title: 'Word Builder (${currentWordIndex + 1}/$totalWords)', + scoreCards: [ + GameScoreCard(label: 'Score', value: score.toString(), icon: Icons.star), + GameScoreCard(label: 'Correct', value: correctWords.toString(), icon: Icons.check), + GameScoreCard(label: 'Hints', value: hints.toString(), icon: Icons.lightbulb), + ], + topWidget: GestureDetector( + onTap: _playWordAudio, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + decoration: ApplicationUtil.getBoxDecorationOne(context), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.volume_up, color: Colors.white, size: 28), + SizedBox(width: 8), + Text( + 'Listen to Word', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + ), + ), + ), + gameContent: Column( + children: [ + // Selected characters (word being built) + Container( + margin: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.all(20), + constraints: const BoxConstraints(minHeight: 100), + decoration: ApplicationUtil.getBoxDecorationTwo(context), + child: selectedCharacters.isEmpty + ? const Center( + child: Text( + 'Build the word here', + style: TextStyle( + fontSize: 16, + color: Colors.black54, + ), + ), + ) + : Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: List.generate( + selectedCharacters.length, + (index) => GestureDetector( + onTap: () => _removeCharacter(index), + child: _buildCharacterChip( + selectedCharacters[index], + true, + ), + ), + ), + ), + ), + + const SizedBox(height: 30), + + const Text( + 'Available Characters:', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + + const SizedBox(height: 15), + + // Available characters + Expanded( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 20), + child: Wrap( + alignment: WrapAlignment.center, + spacing: 12, + runSpacing: 12, + children: List.generate( + availableCharacters.length, + (index) => GestureDetector( + onTap: () => _selectCharacter(index), + child: _buildCharacterChip( + availableCharacters[index], + false, + ), + ), + ), + ), + ), + ), + + // Action buttons + Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Expanded( + child: GestureDetector( + onTap: hints > 0 ? _useHint : null, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: hints > 0 ? ApplicationUtil.getBoxDecorationOne(context) : ApplicationUtil.getBoxDecorationTwo(context), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.lightbulb_outline, color: hints > 0 ? Colors.white : Colors.grey), + const SizedBox(width: 8), + Text('Hint ($hints)', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: hints > 0 ? Colors.white : Colors.grey)), + ], + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: GestureDetector( + onTap: _skip, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: ApplicationUtil.getBoxDecorationOne(context), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.skip_next, color: Colors.white), + SizedBox(width: 8), + Text('Skip', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)), + ], + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + + Align( + alignment: Alignment.topCenter, + child: ConfettiWidget( + confettiController: _confettiController, + blastDirectionality: BlastDirectionality.explosive, + particleDrag: 0.05, + emissionFrequency: 0.05, + numberOfParticles: 15, + gravity: 0.1, + ), + ), + ], + ); + } + + Widget _buildCharacterChip(String character, bool isSelected) { + return Container( + padding: const EdgeInsets.all(12), + decoration: isSelected + ? ApplicationUtil.getBoxDecorationOne(context) + : ApplicationUtil.getBoxDecorationTwo(context), + child: Text( + character, + style: TextStyle( + fontSize: 36, + fontFamily: 'jomolhari', + color: isSelected ? Colors.white : Colors.black87, + fontWeight: FontWeight.bold, + ), + ), + ); + } +} diff --git a/lib/presentation/home.dart b/lib/presentation/home.dart index ee57142..160ee03 100644 --- a/lib/presentation/home.dart +++ b/lib/presentation/home.dart @@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:tibetan_language_learning_app/l10n/app_localizations.dart'; -import 'package:tibetan_language_learning_app/presentation/game/game_home_page.dart'; +import 'package:tibetan_language_learning_app/presentation/game/game_home_page_new.dart'; import 'package:tibetan_language_learning_app/presentation/learn/learn_menu_page.dart'; import 'package:tibetan_language_learning_app/presentation/practice/practice_menu_page.dart'; import 'package:tibetan_language_learning_app/presentation/use_cases/use_cases_menu.dart'; @@ -202,7 +202,7 @@ class _HomePageState extends State { ); _playGameButtons() => InkWell( onTap: () { - Navigator.pushNamed(context, GameHomePage.routeName); + Navigator.pushNamed(context, GameHomePageNew.routeName); }, child: AnimatedOpacity( duration: Duration(milliseconds: ApplicationUtil.ANIMATION_DURATION), diff --git a/lib/util/constant.dart b/lib/util/constant.dart index a0e5505..d7d487e 100644 --- a/lib/util/constant.dart +++ b/lib/util/constant.dart @@ -135,7 +135,7 @@ class AppConstant { word: 'เฝเผ‹เฝ”เฝขเผ', characterList: ['เฝเผ‹', 'เฝ”', 'เฝขเผ'], ), -/* Verb( + Verb( fileName: 'balloon', word: 'เฝฆเพ’เฝ„เผ‹เฝ•เฝดเฝ‚เผ', characterList: ['เฝฆเพ’', 'เฝ„เผ‹', 'เฝ•เฝด', 'เฝ‚เผ']), @@ -143,8 +143,8 @@ class AppConstant { fileName: 'duck', word: 'เฝ„เฝ„เผ‹เฝ”เผ', characterList: ['เฝ„', 'เฝ„เผ‹', 'เฝ”เผ'], - ),*/ - /* Verb( + ), + Verb( fileName: 'chain', word: 'เฝฃเพ•เฝ‚เฝฆเผ‹เฝเฝ‚เผ', characterList: ['เฝฃเพ•', 'เฝ‚', 'เฝฆเผ‹', 'เฝ', 'เฝ‚เผ'], @@ -269,7 +269,7 @@ class AppConstant { fileName: 'mango', word: 'เฝจเฝ˜เผ', characterList: ['เฝจ', 'เฝ˜เผ'], - ),*/ + ), ]; static getAudioByVerb(String verb) { return 'assets/audio/words/' + verb + ".mp3"; diff --git a/lib/util/route_generator.dart b/lib/util/route_generator.dart index ac1ab30..1576446 100644 --- a/lib/util/route_generator.dart +++ b/lib/util/route_generator.dart @@ -6,7 +6,7 @@ import 'package:tibetan_language_learning_app/bloc/snake_game/snake_game_bloc.da import 'package:tibetan_language_learning_app/cubit/audio_cubit.dart'; import 'package:tibetan_language_learning_app/model/alphabet.dart'; import 'package:tibetan_language_learning_app/model/verb.dart'; -import 'package:tibetan_language_learning_app/presentation/game/game_home_page.dart'; +import 'package:tibetan_language_learning_app/presentation/game/game_home_page_new.dart'; import 'package:tibetan_language_learning_app/presentation/game/memory_match/memory_match_screen.dart'; import 'package:tibetan_language_learning_app/presentation/game/snake_game/snake_game.dart'; import 'package:tibetan_language_learning_app/presentation/game/spelling_bee/provider/spelling_bee_provider.dart'; @@ -131,27 +131,47 @@ class RouteGenerator { } return _errorRoute(); } - case GameHomePage.routeName: + case GameHomePageNew.routeName: return MaterialPageRoute( - builder: (_) => GameHomePage(), + builder: (_) => BlocProvider( + create: (context) => AudioCubit(AudioService(), audioPlayer: AudioPlayer()), + child: const GameHomePageNew(), + ), ); case SpellingBeePage.routeName: return MaterialPageRoute( - builder: (_) => ChangeNotifierProvider( - create: (BuildContext context) => SpellingBeeProvider(), - child: SpellingBeePage(), + builder: (_) => MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => AudioCubit(AudioService(), audioPlayer: AudioPlayer()), + ), + ], + child: ChangeNotifierProvider( + create: (BuildContext context) => SpellingBeeProvider(), + child: SpellingBeePage(), + ), ), ); case SnakeGamePage.routeName: return MaterialPageRoute( - builder: (_) => BlocProvider( - create: (context) => SnakeGameBloc(), + builder: (_) => MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => SnakeGameBloc(), + ), + BlocProvider( + create: (context) => AudioCubit(AudioService(), audioPlayer: AudioPlayer()), + ), + ], child: SnakeGamePage(), ), ); case MemoryMatchGameScreen.routeName: return MaterialPageRoute( - builder: (_) => MemoryMatchGameScreen(), + builder: (_) => BlocProvider( + create: (context) => AudioCubit(AudioService(), audioPlayer: AudioPlayer()), + child: MemoryMatchGameScreen(), + ), ); default: