diff --git a/backend/src/main/java/online/wesal/wesal/controller/PuzzleController.java b/backend/src/main/java/online/wesal/wesal/controller/PuzzleController.java new file mode 100644 index 0000000..9fdc997 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/controller/PuzzleController.java @@ -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> 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> 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>> getLeaderboard() { + try { + List attempts = puzzleService.getTodaysLeaderboard(); + + List 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())); + } + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/dto/DailyChallengeResponse.java b/backend/src/main/java/online/wesal/wesal/dto/DailyChallengeResponse.java new file mode 100644 index 0000000..6f74cc9 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/DailyChallengeResponse.java @@ -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; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/dto/LeaderboardEntry.java b/backend/src/main/java/online/wesal/wesal/dto/LeaderboardEntry.java new file mode 100644 index 0000000..3066e44 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/LeaderboardEntry.java @@ -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; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/dto/PuzzleAttemptRequest.java b/backend/src/main/java/online/wesal/wesal/dto/PuzzleAttemptRequest.java new file mode 100644 index 0000000..71b79b5 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/dto/PuzzleAttemptRequest.java @@ -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; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/entity/Puzzle.java b/backend/src/main/java/online/wesal/wesal/entity/Puzzle.java new file mode 100644 index 0000000..2765333 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/entity/Puzzle.java @@ -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; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/entity/PuzzleAttempt.java b/backend/src/main/java/online/wesal/wesal/entity/PuzzleAttempt.java new file mode 100644 index 0000000..e0358c1 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/entity/PuzzleAttempt.java @@ -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; + } +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/repository/PuzzleAttemptRepository.java b/backend/src/main/java/online/wesal/wesal/repository/PuzzleAttemptRepository.java new file mode 100644 index 0000000..3ed54e1 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/repository/PuzzleAttemptRepository.java @@ -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 { + Optional findByUserAndPuzzle(User user, Puzzle puzzle); + + @Query("SELECT pa FROM PuzzleAttempt pa WHERE pa.puzzle = :puzzle ORDER BY pa.submittedAt ASC") + List findByPuzzleOrderBySubmittedAtAsc(@Param("puzzle") Puzzle puzzle); +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/repository/PuzzleRepository.java b/backend/src/main/java/online/wesal/wesal/repository/PuzzleRepository.java new file mode 100644 index 0000000..cbb5f88 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/repository/PuzzleRepository.java @@ -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 { + Optional findByDate(LocalDate date); +} \ No newline at end of file diff --git a/backend/src/main/java/online/wesal/wesal/service/PuzzleService.java b/backend/src/main/java/online/wesal/wesal/service/PuzzleService.java new file mode 100644 index 0000000..4ab1e18 --- /dev/null +++ b/backend/src/main/java/online/wesal/wesal/service/PuzzleService.java @@ -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 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 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 attempt = puzzleAttemptRepository.findByUserAndPuzzle(currentUser, todaysPuzzle); + return attempt.isPresent(); + } + + public PuzzleAttempt submitAttempt(int attempts, boolean solved) { + User currentUser = userService.getCurrentUser(); + Puzzle todaysPuzzle = getTodaysPuzzle(); + + Optional 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 getTodaysLeaderboard() { + Puzzle todaysPuzzle = getTodaysPuzzle(); + return puzzleAttemptRepository.findByPuzzleOrderBySubmittedAtAsc(todaysPuzzle); + } +} \ No newline at end of file diff --git a/frontend/lib/screens/home_screen.dart b/frontend/lib/screens/home_screen.dart index a856e0c..52a225e 100644 --- a/frontend/lib/screens/home_screen.dart +++ b/frontend/lib/screens/home_screen.dart @@ -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 { final List _pages = [ FeedPage(), InvitationsPage(), - WordlePage(), + PuzzlesPage(), ProfilePage(), ]; diff --git a/frontend/lib/screens/pages/leaderboard_page.dart b/frontend/lib/screens/pages/leaderboard_page.dart new file mode 100644 index 0000000..6003b3c --- /dev/null +++ b/frontend/lib/screens/pages/leaderboard_page.dart @@ -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> 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>.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, + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/frontend/lib/screens/pages/puzzles_page.dart b/frontend/lib/screens/pages/puzzles_page.dart new file mode 100644 index 0000000..5db1cea --- /dev/null +++ b/frontend/lib/screens/pages/puzzles_page.dart @@ -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 { + 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> 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>.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 } diff --git a/frontend/lib/screens/pages/wordle_page.dart b/frontend/lib/screens/pages/wordle_page.dart index d03e75c..7c7899a 100644 --- a/frontend/lib/screens/pages/wordle_page.dart +++ b/frontend/lib/screens/pages/wordle_page.dart @@ -255,7 +255,10 @@ class _WordlePageState extends State 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,