Merge pull request #15 from sBubshait/feature/puzzles

Feature/puzzles
This commit is contained in:
Saleh Bubshait 2025-08-05 11:24:14 +03:00 committed by GitHub
commit f2713c4bd3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1243 additions and 3 deletions

View File

@ -0,0 +1,78 @@
package online.wesal.wesal.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import online.wesal.wesal.dto.ApiResponse;
import online.wesal.wesal.dto.DailyChallengeResponse;
import online.wesal.wesal.dto.LeaderboardEntry;
import online.wesal.wesal.dto.PuzzleAttemptRequest;
import online.wesal.wesal.entity.Puzzle;
import online.wesal.wesal.entity.PuzzleAttempt;
import online.wesal.wesal.service.PuzzleService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/puzzles")
@CrossOrigin(origins = "*")
@Tag(name = "Puzzles", description = "Wordle puzzle endpoints")
public class PuzzleController {
@Autowired
private PuzzleService puzzleService;
@GetMapping("/dailyChallenge")
@Operation(summary = "Get daily challenge", description = "Returns today's Wordle word and whether user has solved it")
public ResponseEntity<ApiResponse<DailyChallengeResponse>> getDailyChallenge() {
try {
Puzzle todaysPuzzle = puzzleService.getTodaysPuzzle();
boolean solved = puzzleService.hasUserSolvedToday();
DailyChallengeResponse response = new DailyChallengeResponse(todaysPuzzle.getWord(), solved);
return ResponseEntity.ok(ApiResponse.success(response));
} catch (RuntimeException e) {
return ResponseEntity.ok(ApiResponse.error(e.getMessage()));
}
}
@PostMapping("/attempt")
@Operation(summary = "Submit puzzle attempt", description = "Submit user's attempt for today's puzzle")
public ResponseEntity<ApiResponse<String>> submitAttempt(@Valid @RequestBody PuzzleAttemptRequest request) {
try {
puzzleService.submitAttempt(request.getAttempts(), request.getSolved());
String message = request.getSolved() ?
"Congratulations! You solved today's puzzle!" :
"Better luck next time!";
return ResponseEntity.ok(ApiResponse.success(message));
} catch (RuntimeException e) {
return ResponseEntity.ok(ApiResponse.error(e.getMessage()));
}
}
@GetMapping("/leaderboard")
@Operation(summary = "Get daily leaderboard", description = "Returns leaderboard for today's puzzle ordered by submission time")
public ResponseEntity<ApiResponse<List<LeaderboardEntry>>> getLeaderboard() {
try {
List<PuzzleAttempt> attempts = puzzleService.getTodaysLeaderboard();
List<LeaderboardEntry> leaderboard = attempts.stream()
.map(attempt -> new LeaderboardEntry(
attempt.getUser().getDisplayName(),
attempt.getUser().getUsername(),
attempt.getAttempts(),
attempt.isSolved(),
attempt.getSubmittedAt()
))
.collect(Collectors.toList());
return ResponseEntity.ok(ApiResponse.success(leaderboard));
} catch (RuntimeException e) {
return ResponseEntity.ok(ApiResponse.error(e.getMessage()));
}
}
}

View File

@ -0,0 +1,30 @@
package online.wesal.wesal.dto;
public class DailyChallengeResponse {
private String wordle;
private boolean solved;
public DailyChallengeResponse() {}
public DailyChallengeResponse(String wordle, boolean solved) {
this.wordle = wordle;
this.solved = solved;
}
public String getWordle() {
return wordle;
}
public void setWordle(String wordle) {
this.wordle = wordle;
}
public boolean isSolved() {
return solved;
}
public void setSolved(boolean solved) {
this.solved = solved;
}
}

View File

@ -0,0 +1,62 @@
package online.wesal.wesal.dto;
import java.time.LocalDateTime;
public class LeaderboardEntry {
private String displayName;
private String username;
private int attempts;
private boolean solved;
private LocalDateTime submittedAt;
public LeaderboardEntry() {}
public LeaderboardEntry(String displayName, String username, int attempts, boolean solved, LocalDateTime submittedAt) {
this.displayName = displayName;
this.username = username;
this.attempts = attempts;
this.solved = solved;
this.submittedAt = submittedAt;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public int getAttempts() {
return attempts;
}
public void setAttempts(int attempts) {
this.attempts = attempts;
}
public boolean isSolved() {
return solved;
}
public void setSolved(boolean solved) {
this.solved = solved;
}
public LocalDateTime getSubmittedAt() {
return submittedAt;
}
public void setSubmittedAt(LocalDateTime submittedAt) {
this.submittedAt = submittedAt;
}
}

View File

@ -0,0 +1,39 @@
package online.wesal.wesal.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
public class PuzzleAttemptRequest {
@NotNull
@Min(1)
@Max(6)
private Integer attempts;
@NotNull
private Boolean solved;
public PuzzleAttemptRequest() {}
public PuzzleAttemptRequest(Integer attempts, Boolean solved) {
this.attempts = attempts;
this.solved = solved;
}
public Integer getAttempts() {
return attempts;
}
public void setAttempts(Integer attempts) {
this.attempts = attempts;
}
public Boolean getSolved() {
return solved;
}
public void setSolved(Boolean solved) {
this.solved = solved;
}
}

View File

@ -0,0 +1,50 @@
package online.wesal.wesal.entity;
import jakarta.persistence.*;
import java.time.LocalDate;
@Entity
@Table(name = "puzzles")
public class Puzzle {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private LocalDate date;
@Column(nullable = false, length = 5)
private String word;
public Puzzle() {}
public Puzzle(LocalDate date, String word) {
this.date = date;
this.word = word;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public LocalDate getDate() {
return date;
}
public void setDate(LocalDate date) {
this.date = date;
}
public String getWord() {
return word;
}
public void setWord(String word) {
this.word = word;
}
}

View File

@ -0,0 +1,88 @@
package online.wesal.wesal.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "puzzle_attempts")
public class PuzzleAttempt {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "puzzle_id", nullable = false)
private Puzzle puzzle;
@Column(nullable = false)
private int attempts;
@Column(nullable = false)
private boolean solved;
@Column(nullable = false)
private LocalDateTime submittedAt;
public PuzzleAttempt() {}
public PuzzleAttempt(User user, Puzzle puzzle, int attempts, boolean solved) {
this.user = user;
this.puzzle = puzzle;
this.attempts = attempts;
this.solved = solved;
this.submittedAt = LocalDateTime.now();
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public Puzzle getPuzzle() {
return puzzle;
}
public void setPuzzle(Puzzle puzzle) {
this.puzzle = puzzle;
}
public int getAttempts() {
return attempts;
}
public void setAttempts(int attempts) {
this.attempts = attempts;
}
public boolean isSolved() {
return solved;
}
public void setSolved(boolean solved) {
this.solved = solved;
}
public LocalDateTime getSubmittedAt() {
return submittedAt;
}
public void setSubmittedAt(LocalDateTime submittedAt) {
this.submittedAt = submittedAt;
}
}

View File

@ -0,0 +1,20 @@
package online.wesal.wesal.repository;
import online.wesal.wesal.entity.Puzzle;
import online.wesal.wesal.entity.PuzzleAttempt;
import online.wesal.wesal.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface PuzzleAttemptRepository extends JpaRepository<PuzzleAttempt, Long> {
Optional<PuzzleAttempt> findByUserAndPuzzle(User user, Puzzle puzzle);
@Query("SELECT pa FROM PuzzleAttempt pa WHERE pa.puzzle = :puzzle ORDER BY pa.submittedAt ASC")
List<PuzzleAttempt> findByPuzzleOrderBySubmittedAtAsc(@Param("puzzle") Puzzle puzzle);
}

View File

@ -0,0 +1,13 @@
package online.wesal.wesal.repository;
import online.wesal.wesal.entity.Puzzle;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.util.Optional;
@Repository
public interface PuzzleRepository extends JpaRepository<Puzzle, Long> {
Optional<Puzzle> findByDate(LocalDate date);
}

View File

@ -0,0 +1,133 @@
package online.wesal.wesal.service;
import online.wesal.wesal.entity.Puzzle;
import online.wesal.wesal.entity.PuzzleAttempt;
import online.wesal.wesal.entity.User;
import online.wesal.wesal.repository.PuzzleAttemptRepository;
import online.wesal.wesal.repository.PuzzleRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.Random;
@Service
public class PuzzleService {
@Autowired
private PuzzleRepository puzzleRepository;
@Autowired
private PuzzleAttemptRepository puzzleAttemptRepository;
@Autowired
private UserService userService;
private static final List<String> WORD_LIST = Arrays.asList(
"APPLE", "BEACH", "CHAIN", "DANCE", "EAGLE", "FLAME", "GRAPE", "HOUSE", "IMAGE", "JUDGE",
"KNIFE", "LIGHT", "MUSIC", "NIGHT", "OCEAN", "PEACE", "QUEEN", "RIVER", "STAGE", "TIGER",
"UNITY", "VOICE", "WATER", "YOUTH", "ZEBRA", "ANGEL", "BREAD", "CHAIR", "DREAM", "EARTH",
"FIELD", "GLASS", "HEART", "INDEX", "JUICE", "KITE", "LEMON", "MAGIC", "NOVEL", "OPERA",
"PLANT", "QUEST", "RADIO", "SMILE", "TOWER", "ULTRA", "VILLA", "WORLD", "YOUTH", "ZEBRA",
"ABOUT", "ABOVE", "ABUSE", "ACTOR", "ACUTE", "ADMIT", "ADOPT", "ADULT", "AFTER", "AGAIN",
"AGENT", "AGREE", "AHEAD", "ALARM", "ALBUM", "ALERT", "ALIEN", "ALIGN", "ALIKE", "ALIVE",
"ALLOW", "ALONE", "ALONG", "ALTER", "AMONG", "ANGER", "ANGLE", "ANGRY", "APART", "APPLE",
"APPLY", "ARENA", "ARGUE", "ARISE", "ARRAY", "ASIDE", "ASSET", "AVOID", "AWAKE", "AWARD",
"AWARE", "BADLY", "BAKER", "BASES", "BASIC", "BEACH", "BEGAN", "BEGIN", "BEING", "BELOW",
"BENCH", "BILLY", "BIRTH", "BLACK", "BLAME", "BLANK", "BLIND", "BLOCK", "BLOOD", "BOARD",
"BOOST", "BOOTH", "BOUND", "BRAIN", "BRAND", "BRASS", "BRAVE", "BREAD", "BREAK", "BREED",
"BRIEF", "BRING", "BROAD", "BROKE", "BROWN", "BUILD", "BUILT", "BUYER", "CABLE", "CALIF",
"CARRY", "CATCH", "CAUSE", "CHAIN", "CHAIR", "CHAOS", "CHARM", "CHART", "CHASE", "CHEAP",
"CHECK", "CHEST", "CHIEF", "CHILD", "CHINA", "CHOSE", "CIVIL", "CLAIM", "CLASS", "CLEAN",
"CLEAR", "CLICK", "CLIMB", "CLOCK", "CLOSE", "CLOUD", "COACH", "COAST", "COULD", "COUNT",
"COURT", "COVER", "CRAFT", "CRASH", "CRAZY", "CREAM", "CRIME", "CROSS", "CROWD", "CROWN",
"CRUDE", "CURVE", "CYCLE", "DAILY", "DAMAGE", "DANCE", "DATED", "DEALT", "DEATH", "DEBUG",
"DELAY", "DEPTH", "DOING", "DOUBT", "DOZEN", "DRAFT", "DRAMA", "DRANK", "DRAWN", "DREAM",
"DRESS", "DRILL", "DRINK", "DRIVE", "DROVE", "DYING", "EAGER", "EARLY", "EARTH", "EIGHT",
"ELITE", "EMPTY", "ENEMY", "ENJOY", "ENTER", "ENTRY", "EQUAL", "ERROR", "EVENT", "EVERY",
"EXACT", "EXIST", "EXTRA", "FAITH", "FALSE", "FAULT", "FIBER", "FIELD", "FIFTH", "FIFTY",
"FIGHT", "FINAL", "FIRST", "FIXED", "FLASH", "FLEET", "FLOOR", "FLUID", "FOCUS", "FORCE",
"FORTH", "FORTY", "FORUM", "FOUND", "FRAME", "FRANK", "FRAUD", "FRESH", "FRONT", "FRUIT",
"FULLY", "FUNNY", "GIANT", "GIVEN", "GLASS", "GLOBE", "GOING", "GRACE", "GRADE", "GRAND",
"GRANT", "GRASS", "GRAVE", "GREAT", "GREEN", "GROSS", "GROUP", "GROWN", "GUARD", "GUESS",
"GUEST", "GUIDE", "HAPPY", "HARRY", "HEART", "HEAVY", "HENCE", "HENRY", "HORSE", "HOTEL",
"HOUSE", "HUMAN", "IDEAL", "IMAGE", "INDEX", "INNER", "INPUT", "ISSUE", "JAPAN", "JIMMY",
"JOINT", "JONES", "JUDGE", "KNOWN", "LABEL", "LARGE", "LASER", "LATER", "LAUGH", "LAYER",
"LEARN", "LEASE", "LEAST", "LEAVE", "LEGAL", "LEVEL", "LEWIS", "LIGHT", "LIMIT", "LINKS",
"LIVES", "LOCAL", "LOOSE", "LOWER", "LUCKY", "LUNCH", "LYING", "MAGIC", "MAJOR", "MAKER",
"MARCH", "MARIA", "MATCH", "MAYBE", "MAYOR", "MEANT", "MEDIA", "METAL", "MIGHT", "MINOR",
"MINUS", "MIXED", "MODEL", "MONEY", "MONTH", "MORAL", "MOTOR", "MOUNT", "MOUSE", "MOUTH",
"MOVED", "MOVIE", "MUSIC", "NEEDS", "NEVER", "NEWLY", "NIGHT", "NOISE", "NORTH", "NOTED",
"NOVEL", "NURSE", "OCCUR", "OCEAN", "OFFER", "OFTEN", "ORDER", "OTHER", "OUGHT", "PAINT",
"PANEL", "PAPER", "PARTY", "PEACE", "PETER", "PHASE", "PHONE", "PHOTO", "PIANO", "PICKED",
"PIECE", "PILOT", "PITCH", "PLACE", "PLAIN", "PLANE", "PLANT", "PLATE", "POINT", "POUND",
"POWER", "PRESS", "PRICE", "PRIDE", "PRIME", "PRINT", "PRIOR", "PRIZE", "PROOF", "PROUD",
"PROVE", "QUEEN", "QUICK", "QUIET", "QUITE", "RADIO", "RAISE", "RANGE", "RAPID", "RATIO",
"REACH", "READY", "REALM", "REBEL", "REFER", "RELAX", "RELAY", "RIDER", "RIDGE", "RIGHT",
"RIGID", "RIVAL", "RIVER", "ROBIN", "ROGER", "ROMAN", "ROUGH", "ROUND", "ROUTE", "ROYAL",
"RURAL", "SCALE", "SCENE", "SCOPE", "SCORE", "SENSE", "SERVE", "SEVEN", "SHALL", "SHAPE",
"SHARE", "SHARP", "SHEET", "SHELF", "SHELL", "SHIFT", "SHINE", "SHIRT", "SHOCK", "SHOOT",
"SHORT", "SHOWN", "SIGHT", "SILLY", "SINCE", "SIXTH", "SIXTY", "SIZED", "SKILL", "SLEEP",
"SLIDE", "SMALL", "SMART", "SMILE", "SMITH", "SMOKE", "SNAKE", "SNOW", "SOBER", "SORRY",
"SOUND", "SOUTH", "SPACE", "SPARE", "SPEAK", "SPEED", "SPEND", "SPENT", "SPLIT", "SPOKE",
"SPORT", "STAFF", "STAGE", "STAKE", "STAND", "START", "STATE", "STEAM", "STEEL", "STEEP",
"STEER", "STERN", "STICK", "STILL", "STOCK", "STONE", "STOOD", "STORE", "STORM", "STORY",
"STRIP", "STUCK", "STUDY", "STUFF", "STYLE", "SUGAR", "SUITE", "SUPER", "SWEET", "TABLE",
"TAKEN", "TASTE", "TAXES", "TEACH", "TEAM", "TEETH", "TERRY", "THANK", "THEFT", "THEIR",
"THEME", "THERE", "THESE", "THICK", "THING", "THINK", "THIRD", "THOSE", "THREE", "THREW",
"THROW", "THUMB", "TIGER", "TIGHT", "TIMES", "TIRED", "TITLE", "TODAY", "TOPIC", "TOTAL",
"TOUCH", "TOUGH", "TOWER", "TRACK", "TRADE", "TRAIN", "TREAT", "TREND", "TRIAL", "TRIBE",
"TRICK", "TRIED", "TRIES", "TRIP", "TRUCK", "TRULY", "TRUNK", "TRUST", "TRUTH", "TWICE",
"UNDER", "UNDUE", "UNION", "UNITY", "UNTIL", "UPPER", "UPSET", "URBAN", "USAGE", "USUAL",
"VALUE", "VIDEO", "VIRUS", "VISIT", "VITAL", "VOCAL", "VOICE", "WASTE", "WATCH", "WATER",
"WHEEL", "WHERE", "WHICH", "WHILE", "WHITE", "WHOLE", "WHOSE", "WOMAN", "WOMEN", "WORLD",
"WORRY", "WORSE", "WORST", "WORTH", "WOULD", "WRITE", "WRONG", "WROTE", "YOUNG", "YOUTH"
);
public Puzzle getTodaysPuzzle() {
LocalDate today = LocalDate.now();
Optional<Puzzle> existingPuzzle = puzzleRepository.findByDate(today);
if (existingPuzzle.isPresent()) {
return existingPuzzle.get();
}
String word = generateWordForDate(today);
Puzzle puzzle = new Puzzle(today, word);
return puzzleRepository.save(puzzle);
}
private String generateWordForDate(LocalDate date) {
Random random = new Random(date.toEpochDay());
return WORD_LIST.get(random.nextInt(WORD_LIST.size()));
}
public boolean hasUserSolvedToday() {
User currentUser = userService.getCurrentUser();
Puzzle todaysPuzzle = getTodaysPuzzle();
Optional<PuzzleAttempt> attempt = puzzleAttemptRepository.findByUserAndPuzzle(currentUser, todaysPuzzle);
return attempt.isPresent();
}
public PuzzleAttempt submitAttempt(int attempts, boolean solved) {
User currentUser = userService.getCurrentUser();
Puzzle todaysPuzzle = getTodaysPuzzle();
Optional<PuzzleAttempt> existingAttempt = puzzleAttemptRepository.findByUserAndPuzzle(currentUser, todaysPuzzle);
if (existingAttempt.isPresent()) {
throw new RuntimeException("You've already attempted the daily challenge");
}
PuzzleAttempt attempt = new PuzzleAttempt(currentUser, todaysPuzzle, attempts, solved);
return puzzleAttemptRepository.save(attempt);
}
public List<PuzzleAttempt> getTodaysLeaderboard() {
Puzzle todaysPuzzle = getTodaysPuzzle();
return puzzleAttemptRepository.findByPuzzleOrderBySubmittedAtAsc(todaysPuzzle);
}
}

View File

@ -2,7 +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 'pages/puzzles_page.dart';
import '../services/invitations_service.dart';
class HomeScreen extends StatefulWidget {
@ -17,7 +17,7 @@ class _HomeScreenState extends State<HomeScreen> {
final List<Widget> _pages = [
FeedPage(),
InvitationsPage(),
WordlePage(),
PuzzlesPage(),
ProfilePage(),
];

View File

@ -0,0 +1,280 @@
import 'package:flutter/material.dart';
class LeaderboardPage extends StatelessWidget {
LeaderboardPage({Key? key}) : super(key: key);
// Sample JSON data structure for leaderboard
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",
},
];
@override
Widget build(BuildContext context) {
// 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);
});
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: Container(),
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(
'Leaderboard',
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: [
// Leaderboard list
Expanded(
child: ListView.builder(
padding: EdgeInsets.all(16),
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;
// Format time display
final timeFormatted =
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}:${time.second.toString().padLeft(2, '0')}';
// Determine rank styling
Color rankColor = Colors.grey[600]!;
IconData? rankIcon;
if (rank == 1) {
rankColor = Color(0xFFFFD700); // Gold
rankIcon = Icons.emoji_events;
} else if (rank == 2) {
rankColor = Color(0xFFC0C0C0); // Silver
rankIcon = Icons.emoji_events;
} else if (rank == 3) {
rankColor = Color(0xFFCD7F32); // Bronze
rankIcon = Icons.emoji_events;
}
return Container(
margin: EdgeInsets.only(bottom: 12),
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: Offset(0, 2),
),
],
border: rank <= 3
? Border.all(
color: rankColor.withOpacity(0.3),
width: 2,
)
: Border.all(color: Colors.grey[200]!, width: 1),
),
child: Row(
children: [
// Rank indicator
Container(
width: 40,
child: Column(
children: [
if (rankIcon != null)
Icon(rankIcon, color: rankColor, size: 24)
else
Text(
'#$rank',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: rankColor,
),
),
],
),
),
SizedBox(width: 16),
// User avatar
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),
),
),
),
SizedBox(width: 16),
// User details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user['displayName'],
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
SizedBox(height: 4),
Row(
children: [
Icon(
Icons.access_time,
size: 16,
color: Colors.grey[600],
),
SizedBox(width: 4),
Text(
timeFormatted,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
SizedBox(width: 16),
Icon(
Icons.try_sms_star,
size: 16,
color: Colors.grey[600],
),
SizedBox(width: 4),
Text(
'$attempts attempts',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
],
),
),
],
),
);
},
),
),
// Return button
Container(
padding: EdgeInsets.all(24),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
Navigator.pop(context);
},
style: ElevatedButton.styleFrom(
backgroundColor: Color(0xFF6A4C93),
padding: EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
'Return',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,444 @@
import 'package:flutter/material.dart';
import 'wordle_page.dart';
class PuzzlesPage extends StatefulWidget {
const PuzzlesPage({Key? key}) : super(key: key);
@override
_PuzzlesPageState createState() => _PuzzlesPageState();
}
class _PuzzlesPageState extends State<PuzzlesPage> {
String _currentView = 'main'; // main, leaderboard
void _showLeaderboardView() {
setState(() {
_currentView = 'leaderboard';
});
}
void _showMainView() {
setState(() {
_currentView = 'main';
});
}
@override
Widget build(BuildContext context) {
if (_currentView == 'leaderboard') {
return _buildLeaderboardView();
}
return _buildMainView();
}
Widget _buildMainView() {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF32B0A5), Color(0xFF4600B9)],
),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// App Logo
Text(
'وصال',
style: TextStyle(
fontSize: 72,
fontWeight: FontWeight.w200,
fontFamily: 'Blaka',
color: Colors.white,
),
),
SizedBox(height: 40),
// Daily Challenge Title
Text(
'Daily Challenge',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
SizedBox(height: 16),
// Challenge timing info
Container(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'Daily Wordle Challenge Open Until 11 AM',
style: TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
SizedBox(height: 60),
// Big gamified PLAY button
GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => WordlePage()),
);
},
child: Container(
padding: EdgeInsets.symmetric(horizontal: 40, vertical: 20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.white, Colors.white.withOpacity(0.9)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 20,
offset: Offset(0, 8),
),
BoxShadow(
color: Colors.white.withOpacity(0.5),
blurRadius: 10,
offset: Offset(-5, -5),
),
],
border: Border.all(
color: Color(0xFF6A4C93).withOpacity(0.2),
width: 2,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.sports_esports,
size: 32,
color: Color(0xFF6A4C93),
),
SizedBox(width: 12),
Text(
'PLAY NOW',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Color(0xFF6A4C93),
letterSpacing: 1.5,
),
),
SizedBox(width: 8),
Icon(
Icons.arrow_forward_ios,
size: 20,
color: Color(0xFF6A4C93),
),
],
),
),
),
SizedBox(height: 40),
// Leaderboard button
GestureDetector(
onTap: _showLeaderboardView,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 32, vertical: 16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(30),
border: Border.all(color: Colors.white, width: 2),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.leaderboard, color: Colors.white, size: 24),
SizedBox(width: 8),
Text(
'Leaderboard',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
),
],
),
),
),
),
);
}
Widget _buildLeaderboardView() {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: Color(0xFF6A4C93)),
onPressed: _showMainView,
),
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(
'Leaderboard',
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: _LeaderboardContent(),
);
}
}
// 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",
},
];
@override
Widget build(BuildContext context) {
// 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);
});
return Column(
children: [
// Leaderboard list
Expanded(
child: ListView.builder(
padding: EdgeInsets.all(16),
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;
// Format time display
final timeFormatted =
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}:${time.second.toString().padLeft(2, '0')}';
// Determine rank styling
Color rankColor = Colors.grey[600]!;
IconData? rankIcon;
if (rank == 1) {
rankColor = Color(0xFFFFD700); // Gold
rankIcon = Icons.emoji_events;
} else if (rank == 2) {
rankColor = Color(0xFFC0C0C0); // Silver
rankIcon = Icons.emoji_events;
} else if (rank == 3) {
rankColor = Color(0xFFCD7F32); // Bronze
rankIcon = Icons.emoji_events;
}
return Container(
margin: EdgeInsets.only(bottom: 12),
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: Offset(0, 2),
),
],
border: rank <= 3
? Border.all(color: rankColor.withOpacity(0.3), width: 2)
: Border.all(color: Colors.grey[200]!, width: 1),
),
child: Row(
children: [
// Rank indicator
Container(
width: 40,
child: Column(
children: [
if (rankIcon != null)
Icon(rankIcon, color: rankColor, size: 24)
else
Text(
'#$rank',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: rankColor,
),
),
],
),
),
SizedBox(width: 16),
// User avatar
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),
),
),
),
SizedBox(width: 16),
// User details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user['displayName'],
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
SizedBox(height: 4),
Row(
children: [
Icon(
Icons.access_time,
size: 16,
color: Colors.grey[600],
),
SizedBox(width: 4),
Text(
timeFormatted,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
SizedBox(width: 16),
Icon(
Icons.try_sms_star,
size: 16,
color: Colors.grey[600],
),
SizedBox(width: 4),
Text(
'$attempts attempts',
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
],
),
),
],
),
);
},
),
),
],
);
}
}
enum LetterState { empty, absent, present, correct }

View File

@ -255,7 +255,10 @@ class _WordlePageState extends State<WordlePage> with TickerProviderStateMixin {
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
automaticallyImplyLeading: false,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: Color(0xFF6A4C93)),
onPressed: () => Navigator.pop(context),
),
centerTitle: true,
title: Row(
mainAxisSize: MainAxisSize.min,