diff --git a/lib/state/providers.dart b/lib/state/providers.dart index 707e35e..2245813 100644 --- a/lib/state/providers.dart +++ b/lib/state/providers.dart @@ -45,3 +45,10 @@ final seasonsProvider = FutureProvider>( final streakProvider = NotifierProvider( StreakNotifier.new, ); + +/// The visual theme of whatever season is in play; fallback outside seasons +/// (home, endless). Pure model — UI converts via ThemeColors. +final activeThemeProvider = Provider((ref) { + final flow = ref.watch(seasonFlowProvider); + return flow?.pack.theme ?? SeasonTheme.fallback; +}); diff --git a/lib/ui/theme/palette.dart b/lib/ui/theme/palette.dart index 45ea3f3..12620ea 100644 --- a/lib/ui/theme/palette.dart +++ b/lib/ui/theme/palette.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../../game/models/season.dart'; + /// Season-themeable color set. Season 1 default: vivid candy tones on a /// deep navy board. class GamePalette { @@ -24,3 +26,26 @@ class GamePalette { static const ghostLegal = Color(0x66FFFFFF); static const ghostIllegal = Color(0x55FF5252); } + +/// Resolved per-season colors for the UI layer. Built from a SeasonTheme; +/// falls back to the GamePalette constants. +class ThemeColors { + ThemeColors(SeasonTheme theme) + : gradient = [for (final c in theme.backgroundGradient) Color(c)], + accent = Color(theme.accentColor), + particleType = theme.particleType, + board = theme.boardTint != null + ? Color(theme.boardTint!) + : GamePalette.boardBackground, + tiles = theme.tilePalette != null + ? [for (final c in theme.tilePalette!) Color(c)] + : GamePalette.tileColors; + + final List gradient; + final Color accent; + final String particleType; + final Color board; + final List tiles; + + Color tile(int colorId) => tiles[colorId % tiles.length]; +} diff --git a/lib/ui/widgets/board_painter.dart b/lib/ui/widgets/board_painter.dart index e07e70f..68d80b7 100644 --- a/lib/ui/widgets/board_painter.dart +++ b/lib/ui/widgets/board_painter.dart @@ -6,6 +6,7 @@ import '../../game/models/piece.dart'; import '../theme/palette.dart'; import 'board_geometry.dart'; import 'piece_painter.dart'; +import 'tile_painter.dart'; /// Drag ghost preview: a piece hovering at a snapped anchor. class GhostSpec { @@ -55,27 +56,20 @@ class BoardPainter extends CustomPainter { for (var x = 0; x < GridState.size; x++) { final rect = geo.cellRect(x, y).deflate(inset); final cell = grid.cellAt(x, y); - final paint = Paint() - ..color = switch (cell.type) { - CellType.empty => GamePalette.emptyCell, - CellType.filled => GamePalette.tile(cell.colorId), - CellType.gem => GamePalette.emptyCell, - }; - canvas.drawRRect(RRect.fromRectAndRadius(rect, radius), paint); - - if (cell.type == CellType.gem) { - _paintGem(canvas, rect); - } else if (cell.type == CellType.filled) { - final highlight = Paint() - ..color = Colors.white.withValues(alpha: 0.15); - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromLTWH( - rect.left, rect.top, rect.width, rect.height * 0.32), - radius, - ), - highlight, - ); + switch (cell.type) { + case CellType.empty: + canvas.drawRRect( + RRect.fromRectAndRadius(rect, radius), + Paint()..color = GamePalette.emptyCell, + ); + case CellType.filled: + paintGlossyTile(canvas, rect, GamePalette.tile(cell.colorId)); + case CellType.gem: + canvas.drawRRect( + RRect.fromRectAndRadius(rect, radius), + Paint()..color = GamePalette.emptyCell, + ); + _paintGem(canvas, rect); } } } @@ -111,6 +105,10 @@ class BoardPainter extends CustomPainter { } void _paintGem(Canvas canvas, Rect rect) { + final glowPaint = Paint() + ..color = GamePalette.gem.withValues(alpha: 0.45) + ..maskFilter = MaskFilter.blur(BlurStyle.normal, rect.width * 0.25); + canvas.drawCircle(rect.center, rect.width * 0.32, glowPaint); final center = rect.center; final r = rect.width * 0.32; final path = Path() diff --git a/lib/ui/widgets/piece_painter.dart b/lib/ui/widgets/piece_painter.dart index d0f5793..59ca681 100644 --- a/lib/ui/widgets/piece_painter.dart +++ b/lib/ui/widgets/piece_painter.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../game/models/piece.dart'; import '../theme/palette.dart'; +import 'tile_painter.dart'; /// Draws a piece as rounded tiles at a given cell size; reused by the tray, /// the drag overlay, and ghost previews. @@ -12,8 +13,6 @@ void paintPiece( Offset origin = Offset.zero, Color? overrideColor, }) { - final paint = Paint() - ..color = overrideColor ?? GamePalette.tile(piece.colorId); final inset = cellSize * 0.05; final radius = Radius.circular(cellSize * 0.18); for (final (dx, dy) in piece.offsets) { @@ -23,17 +22,13 @@ void paintPiece( cellSize - inset * 2, cellSize - inset * 2, ); - canvas.drawRRect(RRect.fromRectAndRadius(rect, radius), paint); - if (overrideColor == null) { - // Subtle top highlight for depth. - final highlight = Paint()..color = Colors.white.withValues(alpha: 0.18); + if (overrideColor != null) { canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromLTWH(rect.left, rect.top, rect.width, rect.height * 0.32), - radius, - ), - highlight, + RRect.fromRectAndRadius(rect, radius), + Paint()..color = overrideColor, ); + } else { + paintGlossyTile(canvas, rect, GamePalette.tile(piece.colorId)); } } } diff --git a/lib/ui/widgets/tile_painter.dart b/lib/ui/widgets/tile_painter.dart new file mode 100644 index 0000000..9084fc2 --- /dev/null +++ b/lib/ui/widgets/tile_painter.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +Color lighten(Color c, double amount) => Color.lerp(c, Colors.white, amount)!; +Color darken(Color c, double amount) => Color.lerp(c, Colors.black, amount)!; + +/// Candy-gloss tile: diagonal gradient body, glass top highlight, optional +/// colored glow (used for gems and clear flashes). Shared by the board, +/// tray, and drag overlay so every tile in the game matches. +void paintGlossyTile( + Canvas canvas, + Rect rect, + Color color, { + double radiusFactor = 0.18, + double glow = 0, +}) { + final radius = Radius.circular(rect.width * radiusFactor); + final rrect = RRect.fromRectAndRadius(rect, radius); + + if (glow > 0) { + final glowPaint = Paint() + ..color = color.withValues(alpha: 0.55 * glow) + ..maskFilter = + MaskFilter.blur(BlurStyle.normal, rect.width * 0.28 * glow); + canvas.drawRRect(rrect.inflate(rect.width * 0.05), glowPaint); + } + + final body = Paint() + ..shader = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [lighten(color, 0.28), color, darken(color, 0.22)], + stops: const [0.0, 0.45, 1.0], + ).createShader(rect); + canvas.drawRRect(rrect, body); + + final highlight = Paint()..color = Colors.white.withValues(alpha: 0.30); + final hl = Rect.fromLTWH( + rect.left + rect.width * 0.10, + rect.top + rect.height * 0.07, + rect.width * 0.80, + rect.height * 0.30, + ); + canvas.drawRRect( + RRect.fromRectAndRadius(hl, Radius.circular(rect.width * 0.12)), + highlight, + ); +} diff --git a/test/ui/goldens/game_screen.png b/test/ui/goldens/game_screen.png index 83df2ec..f85011c 100644 Binary files a/test/ui/goldens/game_screen.png and b/test/ui/goldens/game_screen.png differ