From fa3548f5bf2193f919b4dbb93d46c43cdee36ad6 Mon Sep 17 00:00:00 2001 From: sBubshait Date: Wed, 6 Aug 2025 13:03:48 +0300 Subject: [PATCH] feat: connect puzzles with backend, save game status in device --- frontend/lib/constants/api_constants.dart | 5 + frontend/lib/models/puzzle_models.dart | 193 +++++++++ frontend/lib/screens/pages/puzzles_page.dart | 408 ++++++++++++++----- frontend/lib/screens/pages/wordle_page.dart | 195 ++++++++- frontend/lib/services/puzzle_service.dart | 139 +++++++ 5 files changed, 839 insertions(+), 101 deletions(-) create mode 100644 frontend/lib/models/puzzle_models.dart create mode 100644 frontend/lib/services/puzzle_service.dart diff --git a/frontend/lib/constants/api_constants.dart b/frontend/lib/constants/api_constants.dart index 93b8cf7..cdb6ebd 100644 --- a/frontend/lib/constants/api_constants.dart +++ b/frontend/lib/constants/api_constants.dart @@ -16,4 +16,9 @@ class ApiConstants { // Post endpoints static const String createPostEndpoint = '/posts/create'; + + // Puzzle endpoints + static const String dailyChallengeEndpoint = '/puzzles/dailyChallenge'; + static const String puzzleAttemptEndpoint = '/puzzles/attempt'; + static const String puzzleLeaderboardEndpoint = '/puzzles/leaderboard'; } diff --git a/frontend/lib/models/puzzle_models.dart b/frontend/lib/models/puzzle_models.dart new file mode 100644 index 0000000..966b88c --- /dev/null +++ b/frontend/lib/models/puzzle_models.dart @@ -0,0 +1,193 @@ +class DailyChallenge { + final String wordle; + final bool attempted; + final bool solved; + final int? attempts; + final DateTime? solveTime; + + DailyChallenge({ + required this.wordle, + required this.attempted, + required this.solved, + this.attempts, + this.solveTime, + }); + + factory DailyChallenge.fromJson(Map json) { + return DailyChallenge( + wordle: json['wordle'] ?? '', + attempted: json['attempted'] ?? false, + solved: json['solved'] ?? false, + attempts: json['attempts'], + solveTime: json['solveTime'] != null + ? DateTime.parse(json['solveTime']) + : null, + ); + } + + Map toJson() { + return { + 'wordle': wordle, + 'attempted': attempted, + 'solved': solved, + 'attempts': attempts, + 'solveTime': solveTime?.toIso8601String(), + }; + } +} + +class DailyChallengeResponse { + final bool status; + final String? message; + final DailyChallenge? data; + + DailyChallengeResponse({ + required this.status, + this.message, + this.data, + }); + + factory DailyChallengeResponse.fromJson(Map json) { + return DailyChallengeResponse( + status: json['status'] ?? false, + message: json['message'], + data: json['data'] != null + ? DailyChallenge.fromJson(json['data']) + : null, + ); + } +} + +class PuzzleAttempt { + final int attempts; + final bool solved; + + PuzzleAttempt({ + required this.attempts, + required this.solved, + }); + + Map toJson() { + return { + 'attempts': attempts, + 'solved': solved, + }; + } +} + +class GameState { + final String targetWord; + final List guesses; + final int currentAttempt; + final bool isGameComplete; + final bool isWon; + final DateTime? solveTime; + final String gameDate; + + GameState({ + required this.targetWord, + required this.guesses, + required this.currentAttempt, + required this.isGameComplete, + required this.isWon, + this.solveTime, + required this.gameDate, + }); + + factory GameState.fromJson(Map json) { + return GameState( + targetWord: json['targetWord'] ?? '', + guesses: List.from(json['guesses'] ?? []), + currentAttempt: json['currentAttempt'] ?? 0, + isGameComplete: json['isGameComplete'] ?? false, + isWon: json['isWon'] ?? false, + solveTime: json['solveTime'] != null + ? DateTime.parse(json['solveTime']) + : null, + gameDate: json['gameDate'] ?? '', + ); + } + + Map toJson() { + return { + 'targetWord': targetWord, + 'guesses': guesses, + 'currentAttempt': currentAttempt, + 'isGameComplete': isGameComplete, + 'isWon': isWon, + 'solveTime': solveTime?.toIso8601String(), + 'gameDate': gameDate, + }; + } + + GameState copyWith({ + String? targetWord, + List? guesses, + int? currentAttempt, + bool? isGameComplete, + bool? isWon, + DateTime? solveTime, + String? gameDate, + }) { + return GameState( + targetWord: targetWord ?? this.targetWord, + guesses: guesses ?? this.guesses, + currentAttempt: currentAttempt ?? this.currentAttempt, + isGameComplete: isGameComplete ?? this.isGameComplete, + isWon: isWon ?? this.isWon, + solveTime: solveTime ?? this.solveTime, + gameDate: gameDate ?? this.gameDate, + ); + } +} + +class LeaderboardEntry { + final String displayName; + final String username; + final String? avatar; + final int attempts; + final bool solved; + final DateTime submittedAt; + + LeaderboardEntry({ + required this.displayName, + required this.username, + this.avatar, + required this.attempts, + required this.solved, + required this.submittedAt, + }); + + factory LeaderboardEntry.fromJson(Map json) { + return LeaderboardEntry( + displayName: json['displayName'] ?? '', + username: json['username'] ?? '', + avatar: json['avatar'], + attempts: json['attempts'] ?? 0, + solved: json['solved'] ?? false, + submittedAt: DateTime.parse(json['submittedAt']), + ); + } +} + +class LeaderboardResponse { + final bool status; + final String? message; + final List? data; + + LeaderboardResponse({ + required this.status, + this.message, + this.data, + }); + + factory LeaderboardResponse.fromJson(Map json) { + return LeaderboardResponse( + status: json['status'] ?? false, + message: json['message'], + data: json['data'] != null + ? (json['data'] as List).map((e) => LeaderboardEntry.fromJson(e)).toList() + : null, + ); + } +} \ No newline at end of file diff --git a/frontend/lib/screens/pages/puzzles_page.dart b/frontend/lib/screens/pages/puzzles_page.dart index f7b430e..b832430 100644 --- a/frontend/lib/screens/pages/puzzles_page.dart +++ b/frontend/lib/screens/pages/puzzles_page.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; import 'wordle_page.dart'; +import '../../services/puzzle_service.dart'; +import '../../services/user_service.dart'; +import '../../models/puzzle_models.dart'; class PuzzlesPage extends StatefulWidget { const PuzzlesPage({Key? key}) : super(key: key); @@ -10,6 +13,9 @@ class PuzzlesPage extends StatefulWidget { class _PuzzlesPageState extends State { String _currentView = 'main'; // main, leaderboard + DailyChallenge? _dailyChallenge; + bool _isLoading = false; + String? _errorMessage; void _showLeaderboardView() { setState(() { @@ -23,6 +29,52 @@ class _PuzzlesPageState extends State { }); } + @override + void initState() { + super.initState(); + _loadDailyChallenge(); + } + + Future _loadDailyChallenge() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final challenge = await PuzzleService.getDailyChallenge(); + setState(() { + _dailyChallenge = challenge; + _isLoading = false; + }); + } catch (e) { + setState(() { + _errorMessage = 'Failed to load daily challenge'; + _isLoading = false; + }); + } + } + + void _startGame() async { + if (_dailyChallenge == null) { + _loadDailyChallenge(); + return; + } + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => WordlePage( + targetWord: _dailyChallenge!.wordle, + dailyChallenge: _dailyChallenge!, + ), + ), + ).then((_) { + // Refresh daily challenge when returning from game + _loadDailyChallenge(); + }); + } + @override Widget build(BuildContext context) { if (_currentView == 'leaderboard') { @@ -90,12 +142,7 @@ class _PuzzlesPageState extends State { // Big gamified PLAY button GestureDetector( - onTap: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => WordlePage()), - ); - }, + onTap: _isLoading ? null : _startGame, child: Container( padding: EdgeInsets.symmetric(horizontal: 40, vertical: 20), decoration: BoxDecoration( @@ -131,15 +178,28 @@ class _PuzzlesPageState extends State { color: Color(0xFF6A4C93), ), SizedBox(width: 12), - Text( - 'PLAY NOW', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Color(0xFF6A4C93), - letterSpacing: 1.5, - ), - ), + _isLoading + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Color(0xFF6A4C93), + ), + ), + ) + : Text( + _dailyChallenge?.attempted == true + ? 'VIEW RESULTS' + : 'PLAY NOW', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Color(0xFF6A4C93), + letterSpacing: 1.5, + ), + ), SizedBox(width: 8), Icon( Icons.arrow_forward_ios, @@ -150,6 +210,50 @@ class _PuzzlesPageState extends State { ), ), ), + + // Show error message if any + if (_errorMessage != null) ...[ + SizedBox(height: 20), + Container( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _errorMessage!, + style: TextStyle( + fontSize: 14, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + + // Show completion status if attempted + if (_dailyChallenge?.attempted == true) ...[ + SizedBox(height: 20), + Container( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12), + decoration: BoxDecoration( + color: (_dailyChallenge!.solved ? Colors.green : Colors.orange).withOpacity(0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _dailyChallenge!.solved + ? 'You solved this at ${PuzzleService.formatSolveTime(_dailyChallenge!.solveTime!)} in ${_dailyChallenge!.attempts} attempts!' + : 'You attempted this challenge but didn\'t solve it in ${_dailyChallenge!.attempts} attempts', + style: TextStyle( + fontSize: 14, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + ], + SizedBox(height: 40), // Leaderboard button @@ -232,67 +336,150 @@ class _PuzzlesPageState extends State { } // Wrapper class for Leaderboard content without the scaffold -class _LeaderboardContent extends StatelessWidget { - final List> leaderboardData = [ - { - "user": { - "displayName": "Ahmed Hassan", - "avatar": "https://via.placeholder.com/50", - }, - "attempts": 3, - "time": "2025-01-15T08:45:23.000Z", - }, - { - "user": { - "displayName": "Sarah Abdullah", - "avatar": "https://via.placeholder.com/50", - }, - "attempts": 4, - "time": "2025-01-15T09:12:45.000Z", - }, - { - "user": { - "displayName": "Omar Al-Rashid", - "avatar": "https://via.placeholder.com/50", - }, - "attempts": 2, - "time": "2025-01-15T08:23:12.000Z", - }, - { - "user": { - "displayName": "Fatima Al-Zahra", - "avatar": "https://via.placeholder.com/50", - }, - "attempts": 5, - "time": "2025-01-15T09:34:56.000Z", - }, - { - "user": { - "displayName": "Khalid Mohammed", - "avatar": "https://via.placeholder.com/50", - }, - "attempts": 3, - "time": "2025-01-15T08:56:34.000Z", - }, - { - "user": { - "displayName": "Layla Ibrahim", - "avatar": "https://via.placeholder.com/50", - }, - "attempts": 4, - "time": "2025-01-15T09:18:27.000Z", - }, - ]; +class _LeaderboardContent extends StatefulWidget { + @override + _LeaderboardContentState createState() => _LeaderboardContentState(); +} + +class _LeaderboardContentState extends State<_LeaderboardContent> { + List? _leaderboardData; + bool _isLoading = true; + String? _errorMessage; + String? _currentUsername; + + @override + void initState() { + super.initState(); + _loadLeaderboard(); + } + + Future _loadLeaderboard() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + // Get current user and leaderboard data in parallel + final results = await Future.wait([ + UserService.getCurrentUser(), + PuzzleService.getLeaderboard(), + ]); + + final userResponse = results[0] as Map; + final leaderboardResponse = results[1] as LeaderboardResponse; + + setState(() { + _isLoading = false; + if (userResponse['success'] == true && userResponse['data'] != null) { + _currentUsername = userResponse['data']['username']; + } + + if (leaderboardResponse.status) { + _leaderboardData = leaderboardResponse.data; + } else { + _errorMessage = leaderboardResponse.message ?? 'Failed to load leaderboard'; + } + }); + } catch (e) { + setState(() { + _isLoading = false; + _errorMessage = 'Error loading leaderboard'; + }); + } + } @override Widget build(BuildContext context) { + if (_isLoading) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Color(0xFF6A4C93)), + ), + SizedBox(height: 16), + Text( + 'Loading leaderboard...', + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + ), + ], + ), + ); + } + + if (_errorMessage != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: Colors.grey[400], + ), + SizedBox(height: 16), + Text( + _errorMessage!, + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + textAlign: TextAlign.center, + ), + SizedBox(height: 16), + ElevatedButton( + onPressed: _loadLeaderboard, + style: ElevatedButton.styleFrom( + backgroundColor: Color(0xFF6A4C93), + foregroundColor: Colors.white, + ), + child: Text('Try Again'), + ), + ], + ), + ); + } + + if (_leaderboardData == null || _leaderboardData!.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.emoji_events_outlined, + size: 64, + color: Colors.grey[400], + ), + SizedBox(height: 16), + Text( + 'No one solved the puzzle today.', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.grey[600], + ), + ), + SizedBox(height: 8), + Text( + 'Be the first one!', + style: TextStyle( + fontSize: 16, + color: Colors.grey[500], + ), + ), + ], + ), + ); + } + // Sort leaderboard by time (fastest first) - final sortedData = List>.from(leaderboardData); - sortedData.sort((a, b) { - final timeA = DateTime.parse(a['time']); - final timeB = DateTime.parse(b['time']); - return timeA.compareTo(timeB); - }); + final sortedData = List.from(_leaderboardData!); + sortedData.sort((a, b) => a.submittedAt.compareTo(b.submittedAt)); return Column( children: [ @@ -303,13 +490,11 @@ class _LeaderboardContent extends StatelessWidget { itemCount: sortedData.length, itemBuilder: (context, index) { final entry = sortedData[index]; - final user = entry['user']; - final attempts = entry['attempts']; - final timeString = entry['time']; - final time = DateTime.parse(timeString); final rank = index + 1; + final isCurrentUser = entry.username == _currentUsername; // Format time display + final time = entry.submittedAt; final timeFormatted = '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}:${time.second.toString().padLeft(2, '0')}'; @@ -331,7 +516,7 @@ class _LeaderboardContent extends StatelessWidget { margin: EdgeInsets.only(bottom: 12), padding: EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.white, + color: isCurrentUser ? Color(0xFF6A4C93).withOpacity(0.05) : Colors.white, borderRadius: BorderRadius.circular(12), boxShadow: [ BoxShadow( @@ -340,9 +525,11 @@ class _LeaderboardContent extends StatelessWidget { offset: Offset(0, 2), ), ], - border: rank <= 3 - ? Border.all(color: rankColor.withOpacity(0.3), width: 2) - : Border.all(color: Colors.grey[200]!, width: 1), + border: isCurrentUser + ? Border.all(color: Color(0xFF6A4C93), width: 2) + : rank <= 3 + ? Border.all(color: rankColor.withOpacity(0.3), width: 2) + : Border.all(color: Colors.grey[200]!, width: 1), ), child: Row( children: [ @@ -371,14 +558,21 @@ class _LeaderboardContent extends StatelessWidget { CircleAvatar( radius: 25, backgroundColor: Color(0xFF6A4C93).withOpacity(0.1), - child: Text( - user['displayName'][0].toUpperCase(), - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Color(0xFF6A4C93), - ), - ), + backgroundImage: entry.avatar != null && entry.avatar!.isNotEmpty + ? NetworkImage(entry.avatar!) + : null, + child: entry.avatar == null || entry.avatar!.isEmpty + ? Text( + entry.displayName.isNotEmpty + ? entry.displayName[0].toUpperCase() + : '?', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Color(0xFF6A4C93), + ), + ) + : null, ), SizedBox(width: 16), @@ -387,13 +581,35 @@ class _LeaderboardContent extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - user['displayName'], - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.black87, - ), + Row( + children: [ + Text( + entry.displayName, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + if (isCurrentUser) ...[ + SizedBox(width: 8), + Container( + padding: EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Color(0xFF6A4C93), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'YOU', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ], + ], ), SizedBox(height: 4), Row( @@ -419,7 +635,7 @@ class _LeaderboardContent extends StatelessWidget { ), SizedBox(width: 4), Text( - '$attempts attempts', + '${entry.attempts} attempts', style: TextStyle( fontSize: 14, color: Colors.grey[600], diff --git a/frontend/lib/screens/pages/wordle_page.dart b/frontend/lib/screens/pages/wordle_page.dart index 7c7899a..a134bef 100644 --- a/frontend/lib/screens/pages/wordle_page.dart +++ b/frontend/lib/screens/pages/wordle_page.dart @@ -2,16 +2,25 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter/foundation.dart'; import 'dart:io'; +import '../../services/puzzle_service.dart'; +import '../../models/puzzle_models.dart'; class WordlePage extends StatefulWidget { + final String? targetWord; + final DailyChallenge? dailyChallenge; + + const WordlePage({Key? key, this.targetWord, this.dailyChallenge}) + : super(key: key); + @override _WordlePageState createState() => _WordlePageState(); } class _WordlePageState extends State with TickerProviderStateMixin { - static const String TARGET_WORD = "HELLO"; + late String TARGET_WORD; static const int MAX_ATTEMPTS = 6; static const int WORD_LENGTH = 5; + DateTime? gameStartTime; List> grid = List.generate( MAX_ATTEMPTS, @@ -37,9 +46,12 @@ class _WordlePageState extends State with TickerProviderStateMixin { @override void initState() { super.initState(); + TARGET_WORD = widget.targetWord ?? "HELLO"; + gameStartTime = DateTime.now(); _initializeAnimations(); _initializeKeyboardStates(); _focusNode = FocusNode(); + _loadGameState(); // Request focus for keyboard input on web/desktop if (kIsWeb || (!kIsWeb && !Platform.isIOS && !Platform.isAndroid)) { @@ -49,6 +61,63 @@ class _WordlePageState extends State with TickerProviderStateMixin { } } + Future _loadGameState() async { + final gameState = await PuzzleService.loadGameState(); + if (gameState != null && gameState.targetWord == TARGET_WORD) { + setState(() { + // Restore game state + for (int i = 0; i < gameState.guesses.length; i++) { + if (i < MAX_ATTEMPTS && gameState.guesses[i].isNotEmpty) { + for (int j = 0; j < WORD_LENGTH; j++) { + if (j < gameState.guesses[i].length) { + grid[i][j] = gameState.guesses[i][j]; + } + } + // Evaluate the guess to set proper states + List states = _evaluateGuess(gameState.guesses[i]); + gridStates[i] = states; + _updateKeyboardStates(gameState.guesses[i], states); + } + } + currentRow = gameState.currentAttempt; + currentCol = 0; + gameWon = gameState.isWon; + gameLost = gameState.isGameComplete && !gameState.isWon; + if (gameState.solveTime != null) { + gameStartTime = gameState.solveTime; + } + }); + + // If game is already completed, show the completed state + if (gameState.isGameComplete) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _showCompletedGameUI(); + }); + } + } + } + + Future _saveGameState() async { + final guesses = []; + for (int i = 0; i <= currentRow && i < MAX_ATTEMPTS; i++) { + if (grid[i].any((cell) => cell.isNotEmpty)) { + guesses.add(grid[i].join('')); + } + } + + final gameState = GameState( + targetWord: TARGET_WORD, + guesses: guesses, + currentAttempt: currentRow, + isGameComplete: gameWon || gameLost, + isWon: gameWon, + solveTime: gameWon ? DateTime.now() : null, + gameDate: PuzzleService.getTodayDateString(), + ); + + await PuzzleService.saveGameState(gameState); + } + void _initializeAnimations() { _revealController = AnimationController( duration: Duration(milliseconds: 1500), @@ -92,6 +161,7 @@ class _WordlePageState extends State with TickerProviderStateMixin { grid[currentRow][currentCol] = letter; currentCol++; }); + _saveGameState(); HapticFeedback.lightImpact(); } } @@ -102,6 +172,7 @@ class _WordlePageState extends State with TickerProviderStateMixin { currentCol--; grid[currentRow][currentCol] = ''; }); + _saveGameState(); HapticFeedback.lightImpact(); } } @@ -131,19 +202,24 @@ class _WordlePageState extends State with TickerProviderStateMixin { setState(() { gameWon = true; }); + await _saveGameState(); + await _submitAttempt(currentRow + 1, true); HapticFeedback.heavyImpact(); - _showGameEndDialog(true); + _showCompletedGameUI(); } else if (currentRow == MAX_ATTEMPTS - 1) { setState(() { gameLost = true; }); + await _saveGameState(); + await _submitAttempt(MAX_ATTEMPTS, false); HapticFeedback.heavyImpact(); - _showGameEndDialog(false); + _showCompletedGameUI(); } else { setState(() { currentRow++; currentCol = 0; }); + await _saveGameState(); } } } @@ -191,6 +267,25 @@ class _WordlePageState extends State with TickerProviderStateMixin { } } + Future _submitAttempt(int attempts, bool solved) async { + try { + await PuzzleService.submitAttempt(attempts: attempts, solved: solved); + } catch (e) { + print('Failed to submit attempt: $e'); + } + } + + void _showCompletedGameUI() { + // Remove the old dialog and show a new completed game state + // This will be handled in the UI by showing a different keyboard/message area + } + + void _goToLeaderboard() { + Navigator.pop( + context, + ); // Go back to puzzles page which will show leaderboard option + } + void _showGameEndDialog(bool won) { showDialog( context: context, @@ -416,8 +511,13 @@ class _WordlePageState extends State with TickerProviderStateMixin { ), ), ), - // Keyboard - Container(padding: EdgeInsets.all(8), child: _buildKeyboard()), + // Keyboard or completion message + Container( + padding: EdgeInsets.all(8), + child: (gameWon || gameLost) + ? _buildCompletionMessage() + : _buildKeyboard(), + ), ], ), ), @@ -426,6 +526,91 @@ class _WordlePageState extends State with TickerProviderStateMixin { ); } + Widget _buildCompletionMessage() { + String message; + String buttonText = 'Go to Leaderboard'; + Color messageColor; + IconData messageIcon; + + if (gameWon) { + if (widget.dailyChallenge?.solved == true && + widget.dailyChallenge?.solveTime != null) { + // Already completed today + message = + 'You solved this at ${PuzzleService.formatSolveTime(widget.dailyChallenge!.solveTime!)} in ${widget.dailyChallenge!.attempts} attempts!'; + } else { + // Just completed now + message = 'You got it!\nSolved in ${currentRow + 1} attempts!'; + } + messageColor = Colors.green; + messageIcon = Icons.celebration; + } else { + if (widget.dailyChallenge?.attempted == true) { + // Already attempted today + message = + 'You attempted this challenge but didn\'t solve it in ${widget.dailyChallenge!.attempts} attempts.\nThe word was: $TARGET_WORD'; + } else { + // Just failed now + message = 'Better luck next time!\nThe word was: $TARGET_WORD'; + } + messageColor = Colors.orange; + messageIcon = Icons.sentiment_neutral; + } + + return Column( + children: [ + Container( + padding: EdgeInsets.all(24), + margin: EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: messageColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: messageColor.withOpacity(0.3), width: 2), + ), + child: Column( + children: [ + Icon(messageIcon, color: messageColor, size: 48), + SizedBox(height: 16), + Text( + message, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + SizedBox(height: 24), + ElevatedButton( + onPressed: _goToLeaderboard, + style: ElevatedButton.styleFrom( + backgroundColor: Color(0xFF6A4C93), + foregroundColor: Colors.white, + padding: EdgeInsets.symmetric(horizontal: 32, vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(25), + ), + elevation: 4, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.leaderboard, size: 20), + SizedBox(width: 8), + Text( + buttonText, + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + ], + ), + ), + ], + ); + } + Widget _buildKeyboard() { List> keyboardLayout = [ ['Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P'], diff --git a/frontend/lib/services/puzzle_service.dart b/frontend/lib/services/puzzle_service.dart new file mode 100644 index 0000000..1b8c77e --- /dev/null +++ b/frontend/lib/services/puzzle_service.dart @@ -0,0 +1,139 @@ +import 'dart:convert'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'http_service.dart'; +import '../constants/api_constants.dart'; +import '../models/puzzle_models.dart'; + +class PuzzleService { + static const FlutterSecureStorage _storage = FlutterSecureStorage(); + static const String _gameStateKey = 'wordle_game_state'; + + static Future getDailyChallenge() async { + try { + final response = await HttpService.get(ApiConstants.dailyChallengeEndpoint); + + if (response.statusCode == 200) { + final dailyChallengeResponse = DailyChallengeResponse.fromJson( + jsonDecode(response.body) + ); + return dailyChallengeResponse.data; + } else { + print('Failed to get daily challenge: ${response.statusCode}'); + return null; + } + } catch (e) { + print('Error getting daily challenge: $e'); + return null; + } + } + + static Future submitAttempt({ + required int attempts, + required bool solved, + }) async { + try { + final attempt = PuzzleAttempt(attempts: attempts, solved: solved); + final response = await HttpService.post( + ApiConstants.puzzleAttemptEndpoint, + attempt.toJson(), + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + final responseData = jsonDecode(response.body); + return responseData['status'] ?? false; + } else { + print('Failed to submit attempt: ${response.statusCode}'); + return false; + } + } catch (e) { + print('Error submitting attempt: $e'); + return false; + } + } + + static Future saveGameState(GameState gameState) async { + try { + await _storage.write( + key: _gameStateKey, + value: jsonEncode(gameState.toJson()), + ); + } catch (e) { + print('Error saving game state: $e'); + } + } + + static Future loadGameState() async { + try { + final gameStateString = await _storage.read(key: _gameStateKey); + if (gameStateString != null) { + final gameState = GameState.fromJson(jsonDecode(gameStateString)); + + // Check if it's the same day - if not, return null to start fresh + final today = DateTime.now().toIso8601String().substring(0, 10); + if (gameState.gameDate != today) { + await clearGameState(); + return null; + } + + return gameState; + } + return null; + } catch (e) { + print('Error loading game state: $e'); + return null; + } + } + + static Future clearGameState() async { + try { + await _storage.delete(key: _gameStateKey); + } catch (e) { + print('Error clearing game state: $e'); + } + } + + static String getTodayDateString() { + return DateTime.now().toIso8601String().substring(0, 10); + } + + static Future getLeaderboard() async { + try { + final response = await HttpService.get(ApiConstants.puzzleLeaderboardEndpoint); + + if (response.statusCode == 200) { + return LeaderboardResponse.fromJson(jsonDecode(response.body)); + } else { + print('Failed to get leaderboard: ${response.statusCode}'); + return LeaderboardResponse( + status: false, + message: 'Failed to load leaderboard', + data: null, + ); + } + } catch (e) { + print('Error getting leaderboard: $e'); + return LeaderboardResponse( + status: false, + message: 'Error loading leaderboard', + data: null, + ); + } + } + + static String formatSolveTime(DateTime solveTime) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final solveDate = DateTime(solveTime.year, solveTime.month, solveTime.day); + + if (solveDate == today) { + // Format as time today (e.g., "2:36 PM") + final hour = solveTime.hour > 12 ? solveTime.hour - 12 : solveTime.hour == 0 ? 12 : solveTime.hour; + final minute = solveTime.minute.toString().padLeft(2, '0'); + final period = solveTime.hour >= 12 ? 'PM' : 'AM'; + return '$hour:$minute $period today'; + } else { + // Format as full date if not today + return '${solveTime.day}/${solveTime.month}/${solveTime.year}'; + } + } +} \ No newline at end of file