feat: connect puzzles with backend, save game status in device
This commit is contained in:
parent
9b6c58269c
commit
fa3548f5bf
@ -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';
|
||||
}
|
||||
|
||||
193
frontend/lib/models/puzzle_models.dart
Normal file
193
frontend/lib/models/puzzle_models.dart
Normal file
@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> toJson() {
|
||||
return {
|
||||
'attempts': attempts,
|
||||
'solved': solved,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class GameState {
|
||||
final String targetWord;
|
||||
final List<String> 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<String, dynamic> json) {
|
||||
return GameState(
|
||||
targetWord: json['targetWord'] ?? '',
|
||||
guesses: List<String>.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<String, dynamic> toJson() {
|
||||
return {
|
||||
'targetWord': targetWord,
|
||||
'guesses': guesses,
|
||||
'currentAttempt': currentAttempt,
|
||||
'isGameComplete': isGameComplete,
|
||||
'isWon': isWon,
|
||||
'solveTime': solveTime?.toIso8601String(),
|
||||
'gameDate': gameDate,
|
||||
};
|
||||
}
|
||||
|
||||
GameState copyWith({
|
||||
String? targetWord,
|
||||
List<String>? 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<String, dynamic> 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<LeaderboardEntry>? data;
|
||||
|
||||
LeaderboardResponse({
|
||||
required this.status,
|
||||
this.message,
|
||||
this.data,
|
||||
});
|
||||
|
||||
factory LeaderboardResponse.fromJson(Map<String, dynamic> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<PuzzlesPage> {
|
||||
String _currentView = 'main'; // main, leaderboard
|
||||
DailyChallenge? _dailyChallenge;
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
|
||||
void _showLeaderboardView() {
|
||||
setState(() {
|
||||
@ -23,6 +29,52 @@ class _PuzzlesPageState extends State<PuzzlesPage> {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDailyChallenge();
|
||||
}
|
||||
|
||||
Future<void> _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<PuzzlesPage> {
|
||||
|
||||
// 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<PuzzlesPage> {
|
||||
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>(
|
||||
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<PuzzlesPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 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<PuzzlesPage> {
|
||||
}
|
||||
|
||||
// Wrapper class for Leaderboard content without the scaffold
|
||||
class _LeaderboardContent extends StatelessWidget {
|
||||
final List<Map<String, dynamic>> 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<LeaderboardEntry>? _leaderboardData;
|
||||
bool _isLoading = true;
|
||||
String? _errorMessage;
|
||||
String? _currentUsername;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadLeaderboard();
|
||||
}
|
||||
|
||||
Future<void> _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<String, dynamic>;
|
||||
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>(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<Map<String, dynamic>>.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<LeaderboardEntry>.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],
|
||||
|
||||
@ -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<WordlePage> 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<List<String>> grid = List.generate(
|
||||
MAX_ATTEMPTS,
|
||||
@ -37,9 +46,12 @@ class _WordlePageState extends State<WordlePage> 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<WordlePage> with TickerProviderStateMixin {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<LetterState> 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<void> _saveGameState() async {
|
||||
final guesses = <String>[];
|
||||
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<WordlePage> with TickerProviderStateMixin {
|
||||
grid[currentRow][currentCol] = letter;
|
||||
currentCol++;
|
||||
});
|
||||
_saveGameState();
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
}
|
||||
@ -102,6 +172,7 @@ class _WordlePageState extends State<WordlePage> with TickerProviderStateMixin {
|
||||
currentCol--;
|
||||
grid[currentRow][currentCol] = '';
|
||||
});
|
||||
_saveGameState();
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
}
|
||||
@ -131,19 +202,24 @@ class _WordlePageState extends State<WordlePage> 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<WordlePage> with TickerProviderStateMixin {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<WordlePage> 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<WordlePage> 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<List<String>> keyboardLayout = [
|
||||
['Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P'],
|
||||
|
||||
139
frontend/lib/services/puzzle_service.dart
Normal file
139
frontend/lib/services/puzzle_service.dart
Normal file
@ -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<DailyChallenge?> 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<bool> 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<void> saveGameState(GameState gameState) async {
|
||||
try {
|
||||
await _storage.write(
|
||||
key: _gameStateKey,
|
||||
value: jsonEncode(gameState.toJson()),
|
||||
);
|
||||
} catch (e) {
|
||||
print('Error saving game state: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<GameState?> 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<void> 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<LeaderboardResponse> 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}';
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user