diff --git a/frontend/lib/screens/home_screen.dart b/frontend/lib/screens/home_screen.dart index 1c5aa6f..fbfc7a3 100644 --- a/frontend/lib/screens/home_screen.dart +++ b/frontend/lib/screens/home_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'pages/feed_page.dart'; import 'pages/invitations_page.dart'; import 'pages/profile_page.dart'; +import 'pages/wordle_page.dart'; import '../services/invitations_service.dart'; class HomeScreen extends StatefulWidget { @@ -13,7 +14,7 @@ class _HomeScreenState extends State { int _currentIndex = 0; int _availableInvitationsCount = 0; - final List _pages = [FeedPage(), InvitationsPage(), ProfilePage()]; + final List _pages = [FeedPage(), InvitationsPage(), WordlePage(), ProfilePage()]; @override void initState() { @@ -113,6 +114,7 @@ class _HomeScreenState extends State { icon: _buildInvitationsBadge(), label: 'Invitations', ), + BottomNavigationBarItem(icon: Icon(Icons.games), label: 'Puzzles'), BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'), ], ), diff --git a/frontend/lib/screens/pages/wordle_page.dart b/frontend/lib/screens/pages/wordle_page.dart new file mode 100644 index 0000000..6df484e --- /dev/null +++ b/frontend/lib/screens/pages/wordle_page.dart @@ -0,0 +1,537 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/foundation.dart'; +import 'dart:io'; + +class WordlePage extends StatefulWidget { + @override + _WordlePageState createState() => _WordlePageState(); +} + +class _WordlePageState extends State with TickerProviderStateMixin { + static const String TARGET_WORD = "HELLO"; + static const int MAX_ATTEMPTS = 6; + static const int WORD_LENGTH = 5; + + List> grid = List.generate(MAX_ATTEMPTS, (_) => List.filled(WORD_LENGTH, '')); + List> gridStates = List.generate(MAX_ATTEMPTS, (_) => List.filled(WORD_LENGTH, LetterState.empty)); + Map keyboardStates = {}; + + int currentRow = 0; + int currentCol = 0; + bool gameWon = false; + bool gameLost = false; + + late List _flipControllers; + late List> _flipAnimations; + late FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _initializeAnimations(); + _initializeKeyboardStates(); + _focusNode = FocusNode(); + + // Request focus for keyboard input on web/desktop + if (kIsWeb || (!kIsWeb && !Platform.isIOS && !Platform.isAndroid)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + } + } + + void _initializeAnimations() { + _flipControllers = List.generate( + WORD_LENGTH, + (index) => AnimationController( + duration: Duration(milliseconds: 600), + vsync: this, + ), + ); + _flipAnimations = _flipControllers.map((controller) => + Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: controller, curve: Curves.easeInOut) + ) + ).toList(); + } + + void _initializeKeyboardStates() { + for (int i = 65; i <= 90; i++) { + keyboardStates[String.fromCharCode(i)] = LetterState.empty; + } + } + + @override + void dispose() { + for (var controller in _flipControllers) { + controller.dispose(); + } + _focusNode.dispose(); + super.dispose(); + } + + void _addLetter(String letter) { + if (currentCol < WORD_LENGTH && currentRow < MAX_ATTEMPTS && !gameWon && !gameLost) { + setState(() { + grid[currentRow][currentCol] = letter; + currentCol++; + }); + HapticFeedback.lightImpact(); + } + } + + void _deleteLetter() { + if (currentCol > 0 && !gameWon && !gameLost) { + setState(() { + currentCol--; + grid[currentRow][currentCol] = ''; + }); + HapticFeedback.lightImpact(); + } + } + + Future _submitWord() async { + if (currentCol == WORD_LENGTH && !gameWon && !gameLost) { + String guess = grid[currentRow].join(''); + + // Animate the flip + for (int i = 0; i < WORD_LENGTH; i++) { + await Future.delayed(Duration(milliseconds: 100)); + _flipControllers[i].forward(); + } + + await Future.delayed(Duration(milliseconds: 300)); + + List newStates = _evaluateGuess(guess); + + setState(() { + gridStates[currentRow] = newStates; + _updateKeyboardStates(guess, newStates); + }); + + // Reset animations + for (var controller in _flipControllers) { + controller.reset(); + } + + if (guess == TARGET_WORD) { + setState(() { + gameWon = true; + }); + HapticFeedback.heavyImpact(); + _showGameEndDialog(true); + } else if (currentRow == MAX_ATTEMPTS - 1) { + setState(() { + gameLost = true; + }); + HapticFeedback.heavyImpact(); + _showGameEndDialog(false); + } else { + setState(() { + currentRow++; + currentCol = 0; + }); + } + } + } + + List _evaluateGuess(String guess) { + List states = List.filled(WORD_LENGTH, LetterState.absent); + List targetLetters = TARGET_WORD.split(''); + List used = List.filled(WORD_LENGTH, false); + + // First pass: check for correct positions + for (int i = 0; i < WORD_LENGTH; i++) { + if (guess[i] == targetLetters[i]) { + states[i] = LetterState.correct; + used[i] = true; + } + } + + // Second pass: check for present letters + for (int i = 0; i < WORD_LENGTH; i++) { + if (states[i] != LetterState.correct) { + for (int j = 0; j < WORD_LENGTH; j++) { + if (!used[j] && guess[i] == targetLetters[j]) { + states[i] = LetterState.present; + used[j] = true; + break; + } + } + } + } + + return states; + } + + void _updateKeyboardStates(String guess, List states) { + for (int i = 0; i < guess.length; i++) { + String letter = guess[i]; + LetterState currentState = keyboardStates[letter] ?? LetterState.empty; + LetterState newState = states[i]; + + if (currentState == LetterState.correct) continue; + if (currentState == LetterState.present && newState == LetterState.absent) continue; + + keyboardStates[letter] = newState; + } + } + + void _showGameEndDialog(bool won) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: Text(won ? 'Congratulations!' : 'Game Over'), + content: Text(won ? 'You solved today\'s puzzle!' : 'The word was: $TARGET_WORD'), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _resetGame(); + }, + child: Text('Play Again', style: TextStyle(color: Color(0xFF6A4C93))), + ), + ], + ); + }, + ); + } + + void _resetGame() { + setState(() { + grid = List.generate(MAX_ATTEMPTS, (_) => List.filled(WORD_LENGTH, '')); + gridStates = List.generate(MAX_ATTEMPTS, (_) => List.filled(WORD_LENGTH, LetterState.empty)); + currentRow = 0; + currentCol = 0; + gameWon = false; + gameLost = false; + _initializeKeyboardStates(); + }); + } + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + child: GestureDetector( + onTap: () { + // Prevent mobile keyboard from showing up + FocusScope.of(context).unfocus(); + // Only request focus on web/desktop platforms + if (kIsWeb || (!kIsWeb && !Platform.isIOS && !Platform.isAndroid)) { + _focusNode.requestFocus(); + } + }, + child: KeyboardListener( + focusNode: _focusNode, + onKeyEvent: _handleKeyEvent, + child: Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + automaticallyImplyLeading: false, + centerTitle: true, + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'وصال', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w200, + fontFamily: 'Blaka', + color: Color(0xFF6A4C93), + ), + ), + SizedBox(width: 8), + Text( + 'Daily Challenge', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.grey[600], + ), + ), + ], + ), + bottom: PreferredSize( + preferredSize: Size.fromHeight(1), + child: Container(height: 1, color: Colors.grey[200]), + ), + ), + body: Column( + children: [ + Expanded( + child: Center( + child: Container( + constraints: BoxConstraints(maxWidth: 350), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Game Grid + Container( + padding: EdgeInsets.all(16), + child: Column( + children: List.generate(MAX_ATTEMPTS, (row) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(WORD_LENGTH, (col) { + return Container( + margin: EdgeInsets.all(2), + width: 62, + height: 62, + child: AnimatedBuilder( + animation: _flipAnimations[col], + builder: (context, child) { + // Check if this tile should be animating + bool isCurrentRowAndAnimating = row == currentRow && _flipAnimations[col].value > 0; + + // If not animating, show the final state + if (!isCurrentRowAndAnimating) { + return Container( + width: 62, + height: 62, + decoration: BoxDecoration( + border: Border.all( + color: _getBorderColor(row, col), + width: 2, + ), + color: _getTileColor(row, col), + ), + child: Center( + child: Text( + grid[row][col], + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: _getTextColor(row, col), + ), + ), + ), + ); + } + + // Animation is running - create flip effect + double progress = _flipAnimations[col].value; + double rotationX = progress * 3.14159; + + // Determine colors to show during animation + bool showFinalColors = progress > 0.5; + Color bgColor = showFinalColors ? _getTileColor(row, col) : Colors.white; + Color textColor = showFinalColors ? _getTextColor(row, col) : Colors.black; + + return Container( + width: 62, + height: 62, + decoration: BoxDecoration( + border: Border.all( + color: _getBorderColor(row, col), + width: 2, + ), + color: Colors.white, + ), + child: Stack( + children: [ + // Animated background that flips + Transform( + alignment: Alignment.center, + transform: Matrix4.identity() + ..setEntry(3, 2, 0.001) + ..rotateX(rotationX), + child: Container( + width: 62, + height: 62, + color: bgColor, + ), + ), + // Text that always stays upright and visible + Center( + child: Text( + grid[row][col], + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: textColor, + ), + ), + ), + ], + ), + ); + }, + ), + ); + }), + ), + ); + }), + ), + ), + ], + ), + ), + ), + ), + // Keyboard + Container( + padding: EdgeInsets.all(8), + child: _buildKeyboard(), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildKeyboard() { + List> keyboardLayout = [ + ['Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P'], + ['A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L'], + ['ENTER', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', '⌫'], + ]; + + return Column( + children: keyboardLayout.map((row) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: row.map((key) { + bool isSpecial = key == 'ENTER' || key == '⌫'; + double width = isSpecial ? 65 : 35; + + return Container( + margin: EdgeInsets.symmetric(horizontal: 1.5), + width: width, + height: 58, + child: Material( + color: _getKeyColor(key), + borderRadius: BorderRadius.circular(4), + child: InkWell( + borderRadius: BorderRadius.circular(4), + onTap: () => _handleKeyTap(key), + child: Container( + alignment: Alignment.center, + child: Text( + key, + style: TextStyle( + fontSize: isSpecial ? 12 : 14, + fontWeight: FontWeight.bold, + color: _getKeyTextColor(key), + ), + ), + ), + ), + ), + ); + }).toList(), + ), + ); + }).toList(), + ); + } + + void _handleKeyTap(String key) { + if (key == 'ENTER') { + _submitWord(); + } else if (key == '⌫') { + _deleteLetter(); + } else { + _addLetter(key); + } + } + + bool _handleKeyEvent(KeyEvent event) { + if (event is KeyDownEvent) { + if (event.logicalKey == LogicalKeyboardKey.enter) { + _submitWord(); + return true; + } else if (event.logicalKey == LogicalKeyboardKey.backspace) { + _deleteLetter(); + return true; + } else if (event.logicalKey.keyLabel.length == 1) { + String key = event.logicalKey.keyLabel.toUpperCase(); + if (key.codeUnitAt(0) >= 65 && key.codeUnitAt(0) <= 90) { + _addLetter(key); + return true; + } + } + } + return false; + } + + Color _getBorderColor(int row, int col) { + if (row >= MAX_ATTEMPTS || col >= WORD_LENGTH) return Colors.grey[300]!; + if (row < currentRow || (row == currentRow && gameWon)) { + return Colors.transparent; + } else if (row == currentRow && col < currentCol) { + return Colors.grey[600]!; + } else { + return Colors.grey[300]!; + } + } + + Color _getTileColor(int row, int col) { + if (row >= MAX_ATTEMPTS || col >= WORD_LENGTH) return Colors.white; + if (row >= currentRow && !gameWon) return Colors.white; + + switch (gridStates[row][col]) { + case LetterState.correct: + return Color(0xFF6AAE7C); + case LetterState.present: + return Color(0xFFC9B037); + case LetterState.absent: + return Color(0xFF787C7E); + default: + return Colors.white; + } + } + + Color _getTextColor(int row, int col) { + if (row >= MAX_ATTEMPTS || col >= WORD_LENGTH) return Colors.black; + if (row >= currentRow && !gameWon) return Colors.black; + return gridStates[row][col] == LetterState.empty ? Colors.black : Colors.white; + } + + Color _getKeyColor(String key) { + if (key == 'ENTER' || key == '⌫') { + return Colors.grey[300]!; + } + + LetterState state = keyboardStates[key] ?? LetterState.empty; + switch (state) { + case LetterState.correct: + return Color(0xFF6AAE7C); + case LetterState.present: + return Color(0xFFC9B037); + case LetterState.absent: + return Color(0xFF787C7E); + default: + return Colors.grey[200]!; + } + } + + Color _getKeyTextColor(String key) { + if (key == 'ENTER' || key == '⌫') { + return Colors.black; + } + + LetterState state = keyboardStates[key] ?? LetterState.empty; + return state == LetterState.empty ? Colors.black : Colors.white; + } +} + +enum LetterState { + empty, + absent, + present, + correct, +} \ No newline at end of file