From 0c00859b906233e4f7acff837bc7b5f46ff95898 Mon Sep 17 00:00:00 2001 From: Jimin Kim Date: Mon, 27 Apr 2026 11:22:21 -0400 Subject: [PATCH 1/7] added components --- game/assets/wheel/Pointer.svg | 5 + game/assets/wheel/close.svg | 3 + game/assets/wheel/equipnow.svg | 4 + game/assets/wheel/orangepiece.svg | 4 + game/assets/wheel/orangestopped.svg | 19 ++++ game/assets/wheel/redpiece.svg | 4 + game/assets/wheel/redstopped.svg | 19 ++++ game/assets/wheel/spinbutton.svg | 25 +++++ game/assets/wheel/spinthewheel.svg | 163 ++++++++++++++++++++++++++++ game/assets/wheel/startajourney.svg | 4 + game/assets/wheel/wait.svg | 4 + game/assets/wheel/wheel.png | Bin 0 -> 10685 bytes 12 files changed, 254 insertions(+) create mode 100644 game/assets/wheel/Pointer.svg create mode 100644 game/assets/wheel/close.svg create mode 100644 game/assets/wheel/equipnow.svg create mode 100644 game/assets/wheel/orangepiece.svg create mode 100644 game/assets/wheel/orangestopped.svg create mode 100644 game/assets/wheel/redpiece.svg create mode 100644 game/assets/wheel/redstopped.svg create mode 100644 game/assets/wheel/spinbutton.svg create mode 100644 game/assets/wheel/spinthewheel.svg create mode 100644 game/assets/wheel/startajourney.svg create mode 100644 game/assets/wheel/wait.svg create mode 100644 game/assets/wheel/wheel.png diff --git a/game/assets/wheel/Pointer.svg b/game/assets/wheel/Pointer.svg new file mode 100644 index 00000000..fccf2053 --- /dev/null +++ b/game/assets/wheel/Pointer.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/game/assets/wheel/close.svg b/game/assets/wheel/close.svg new file mode 100644 index 00000000..c759d4ba --- /dev/null +++ b/game/assets/wheel/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/game/assets/wheel/equipnow.svg b/game/assets/wheel/equipnow.svg new file mode 100644 index 00000000..a3043368 --- /dev/null +++ b/game/assets/wheel/equipnow.svg @@ -0,0 +1,4 @@ + + + + diff --git a/game/assets/wheel/orangepiece.svg b/game/assets/wheel/orangepiece.svg new file mode 100644 index 00000000..431c2c7c --- /dev/null +++ b/game/assets/wheel/orangepiece.svg @@ -0,0 +1,4 @@ + + + + diff --git a/game/assets/wheel/orangestopped.svg b/game/assets/wheel/orangestopped.svg new file mode 100644 index 00000000..c174a2b3 --- /dev/null +++ b/game/assets/wheel/orangestopped.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/wheel/redpiece.svg b/game/assets/wheel/redpiece.svg new file mode 100644 index 00000000..2c4c7198 --- /dev/null +++ b/game/assets/wheel/redpiece.svg @@ -0,0 +1,4 @@ + + + + diff --git a/game/assets/wheel/redstopped.svg b/game/assets/wheel/redstopped.svg new file mode 100644 index 00000000..472ccbef --- /dev/null +++ b/game/assets/wheel/redstopped.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/wheel/spinbutton.svg b/game/assets/wheel/spinbutton.svg new file mode 100644 index 00000000..bd264e12 --- /dev/null +++ b/game/assets/wheel/spinbutton.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/wheel/spinthewheel.svg b/game/assets/wheel/spinthewheel.svg new file mode 100644 index 00000000..6845bcd0 --- /dev/null +++ b/game/assets/wheel/spinthewheel.svg @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/game/assets/wheel/startajourney.svg b/game/assets/wheel/startajourney.svg new file mode 100644 index 00000000..1e3a88f3 --- /dev/null +++ b/game/assets/wheel/startajourney.svg @@ -0,0 +1,4 @@ + + + + diff --git a/game/assets/wheel/wait.svg b/game/assets/wheel/wait.svg new file mode 100644 index 00000000..457d4ba5 --- /dev/null +++ b/game/assets/wheel/wait.svg @@ -0,0 +1,4 @@ + + + + diff --git a/game/assets/wheel/wheel.png b/game/assets/wheel/wheel.png new file mode 100644 index 0000000000000000000000000000000000000000..fb64432cec2d63937ae309ad292cbe7bbf55fb71 GIT binary patch literal 10685 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 literal 0 HcmV?d00001 From da2f76156ca5d9f27b3171fddbd58d13ff274f8f Mon Sep 17 00:00:00 2001 From: Jimin Kim Date: Tue, 28 Apr 2026 00:06:02 -0400 Subject: [PATCH 2/7] spin wheel functionality --- admin/src/all.dto.ts | 20 + admin/src/components/ServerApi.tsx | 33 ++ game/assets/wheel/ferris-wheel.svg | 11 + game/lib/api/game_client_api.dart | 30 + game/lib/api/game_client_dto.dart | 119 ++++ game/lib/api/game_server_api.dart | 9 + game/lib/navigation_page/bottom_navbar.dart | 69 ++- game/lib/profile/profile_page.dart | 16 + game/lib/profile/spin_the_wheel_page.dart | 559 ++++++++++++++++++ game/pubspec.yaml | 1 + .../migration.sql | 8 + server/prisma/schema.prisma | 2 + server/src/avatar/avatar.dto.ts | 20 + server/src/avatar/avatar.gateway.spec.ts | 6 + server/src/avatar/avatar.gateway.ts | 51 ++ server/src/avatar/avatar.service.ts | 133 ++++- .../src/challenge/challenge.service.spec.ts | 5 + server/src/client/client.service.ts | 6 + 18 files changed, 1084 insertions(+), 14 deletions(-) create mode 100644 game/assets/wheel/ferris-wheel.svg create mode 100644 game/lib/profile/spin_the_wheel_page.dart create mode 100644 server/prisma/migrations/20260427150616_add_spin_wheel_fields/migration.sql diff --git a/admin/src/all.dto.ts b/admin/src/all.dto.ts index cd928c14..14574d04 100644 --- a/admin/src/all.dto.ts +++ b/admin/src/all.dto.ts @@ -350,6 +350,26 @@ export interface UpdateBearItemDataDto { export interface RequestAllBearItemsDto {} +export interface RequestSpinAvailabilityDto {} + +export interface RequestSpinWheelItemsDto {} + +export interface SpinWheelDto {} + +export interface SpinAvailabilityDto { + canSpin: boolean; + remainingCooldownSeconds: number; +} + +export interface SpinWheelItemsDto { + items: BearItemDto[]; +} + +export interface SpinResultDto { + wonItem: BearItemDto; + cooldownSeconds: number; +} + export interface CampusEventDto { id: string; title: string; diff --git a/admin/src/components/ServerApi.tsx b/admin/src/components/ServerApi.tsx index cb57926f..cce351aa 100644 --- a/admin/src/components/ServerApi.tsx +++ b/admin/src/components/ServerApi.tsx @@ -55,6 +55,22 @@ export class ServerApi { return this.send('equipBearItem', data) as Promise; } + requestSpinAvailability(data: dto.RequestSpinAvailabilityDto) { + return this.send('requestSpinAvailability', data) as Promise< + boolean | undefined + >; + } + + requestSpinWheelItems(data: dto.RequestSpinWheelItemsDto) { + return this.send('requestSpinWheelItems', data) as Promise< + number | undefined + >; + } + + spinWheel(data: dto.SpinWheelDto) { + return this.send('spinWheel', data) as Promise; + } + requestAllBearItems(data: dto.RequestAllBearItemsDto) { return this.send('requestAllBearItems', data) as Promise< number | undefined @@ -432,6 +448,23 @@ export class ServerApi { this.socket.on('updatePurchaseResult', data => callback(data)); } + onUpdateSpinAvailabilityData( + callback: (data: dto.SpinAvailabilityDto) => void, + ) { + this.socket.removeAllListeners('updateSpinAvailabilityData'); + this.socket.on('updateSpinAvailabilityData', data => callback(data)); + } + + onUpdateSpinWheelItemsData(callback: (data: dto.SpinWheelItemsDto) => void) { + this.socket.removeAllListeners('updateSpinWheelItemsData'); + this.socket.on('updateSpinWheelItemsData', data => callback(data)); + } + + onUpdateSpinResultData(callback: (data: dto.SpinResultDto) => void) { + this.socket.removeAllListeners('updateSpinResultData'); + this.socket.on('updateSpinResultData', data => callback(data)); + } + onTimerStarted(callback: (data: dto.TimerStartedDto) => void) { this.socket.removeAllListeners('timerStarted'); this.socket.on('timerStarted', data => callback(data)); diff --git a/game/assets/wheel/ferris-wheel.svg b/game/assets/wheel/ferris-wheel.svg new file mode 100644 index 00000000..c574b3fd --- /dev/null +++ b/game/assets/wheel/ferris-wheel.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/game/lib/api/game_client_api.dart b/game/lib/api/game_client_api.dart index 890905c1..0425d50d 100644 --- a/game/lib/api/game_client_api.dart +++ b/game/lib/api/game_client_api.dart @@ -93,6 +93,21 @@ class GameClientApi { Stream get updatePurchaseResultStream => _updatePurchaseResultController.stream; + final _updateSpinAvailabilityDataController = + StreamController.broadcast(sync: true); + Stream get updateSpinAvailabilityDataStream => + _updateSpinAvailabilityDataController.stream; + + final _updateSpinWheelItemsDataController = + StreamController.broadcast(sync: true); + Stream get updateSpinWheelItemsDataStream => + _updateSpinWheelItemsDataController.stream; + + final _updateSpinResultDataController = + StreamController.broadcast(sync: true); + Stream get updateSpinResultDataStream => + _updateSpinResultDataController.stream; + final _timerStartedController = StreamController.broadcast(sync: true); Stream get timerStartedStream => @@ -258,6 +273,21 @@ class GameClientApi { (data) => _updatePurchaseResultController .add(UpdatePurchaseResultDto.fromJson(data))); + sock.on( + "updateSpinAvailabilityData", + (data) => _updateSpinAvailabilityDataController + .add(SpinAvailabilityDto.fromJson(data))); + + sock.on( + "updateSpinWheelItemsData", + (data) => _updateSpinWheelItemsDataController + .add(SpinWheelItemsDto.fromJson(data))); + + sock.on( + "updateSpinResultData", + (data) => + _updateSpinResultDataController.add(SpinResultDto.fromJson(data))); + sock.on("timerStarted", (data) => _timerStartedController.add(TimerStartedDto.fromJson(data))); diff --git a/game/lib/api/game_client_dto.dart b/game/lib/api/game_client_dto.dart index 123d9f56..bc8b2aad 100644 --- a/game/lib/api/game_client_dto.dart +++ b/game/lib/api/game_client_dto.dart @@ -1063,6 +1063,125 @@ class RequestAllBearItemsDto { RequestAllBearItemsDto(); } +class RequestSpinAvailabilityDto { + Map toJson() { + Map fields = {}; + return fields; + } + + RequestSpinAvailabilityDto.fromJson(Map fields) {} + + void partialUpdate(RequestSpinAvailabilityDto other) {} + + RequestSpinAvailabilityDto(); +} + +class RequestSpinWheelItemsDto { + Map toJson() { + Map fields = {}; + return fields; + } + + RequestSpinWheelItemsDto.fromJson(Map fields) {} + + void partialUpdate(RequestSpinWheelItemsDto other) {} + + RequestSpinWheelItemsDto(); +} + +class SpinWheelDto { + Map toJson() { + Map fields = {}; + return fields; + } + + SpinWheelDto.fromJson(Map fields) {} + + void partialUpdate(SpinWheelDto other) {} + + SpinWheelDto(); +} + +class SpinAvailabilityDto { + Map toJson() { + Map fields = {}; + fields['canSpin'] = canSpin; + fields['remainingCooldownSeconds'] = remainingCooldownSeconds; + return fields; + } + + SpinAvailabilityDto.fromJson(Map fields) { + canSpin = fields["canSpin"]; + remainingCooldownSeconds = fields["remainingCooldownSeconds"]; + } + + void partialUpdate(SpinAvailabilityDto other) { + canSpin = other.canSpin; + remainingCooldownSeconds = other.remainingCooldownSeconds; + } + + SpinAvailabilityDto({ + required this.canSpin, + required this.remainingCooldownSeconds, + }); + + late bool canSpin; + late int remainingCooldownSeconds; +} + +class SpinWheelItemsDto { + Map toJson() { + Map fields = {}; + fields['items'] = items! + .map>((dynamic val) => val!.toJson()) + .toList(); + return fields; + } + + SpinWheelItemsDto.fromJson(Map fields) { + items = fields["items"] + .map((dynamic val) => BearItemDto.fromJson(val)) + .toList(); + } + + void partialUpdate(SpinWheelItemsDto other) { + items = other.items; + } + + SpinWheelItemsDto({ + required this.items, + }); + + late List items; +} + +class SpinResultDto { + Map toJson() { + Map fields = {}; + fields['wonItem'] = wonItem!.toJson(); + fields['cooldownSeconds'] = cooldownSeconds; + return fields; + } + + SpinResultDto.fromJson(Map fields) { + wonItem = BearItemDto.fromJson(fields['wonItem']); + cooldownSeconds = fields["cooldownSeconds"]; + } + + void partialUpdate(SpinResultDto other) { + wonItem = other.wonItem; + cooldownSeconds = other.cooldownSeconds; + } + + SpinResultDto({ + required this.wonItem, + required this.cooldownSeconds, + }); + + late BearItemDto wonItem; + late int cooldownSeconds; +} + class CampusEventDto { Map toJson() { Map fields = {}; diff --git a/game/lib/api/game_server_api.dart b/game/lib/api/game_server_api.dart index 56ece362..97cbeea9 100644 --- a/game/lib/api/game_server_api.dart +++ b/game/lib/api/game_server_api.dart @@ -87,6 +87,15 @@ class GameServerApi { Future equipBearItem(EquipBearItemDto dto) async => await _invokeWithRefresh("equipBearItem", dto.toJson()); + Future requestSpinAvailability(RequestSpinAvailabilityDto dto) async => + await _invokeWithRefresh("requestSpinAvailability", dto.toJson()); + + Future requestSpinWheelItems(RequestSpinWheelItemsDto dto) async => + await _invokeWithRefresh("requestSpinWheelItems", dto.toJson()); + + Future spinWheel(SpinWheelDto dto) async => + await _invokeWithRefresh("spinWheel", dto.toJson()); + Future requestAllBearItems(RequestAllBearItemsDto dto) async => await _invokeWithRefresh("requestAllBearItems", dto.toJson()); diff --git a/game/lib/navigation_page/bottom_navbar.dart b/game/lib/navigation_page/bottom_navbar.dart index 51c74ac0..b4a35bd1 100644 --- a/game/lib/navigation_page/bottom_navbar.dart +++ b/game/lib/navigation_page/bottom_navbar.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:game/api/game_api.dart'; +import 'package:game/api/game_client_dto.dart'; import 'package:game/global_leaderboard/global_leaderboard_widget.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:game/profile/profile_page.dart'; +import 'package:game/profile/spin_the_wheel_page.dart'; import 'package:game/splash_page/splash_page.dart'; import 'package:provider/provider.dart'; import 'package:game/model/onboarding_model.dart'; @@ -13,6 +15,7 @@ import 'package:game/model/challenge_model.dart'; import 'package:game/widgets/bear_mascot_message.dart'; import 'package:game/utils/utility_functions.dart'; import 'package:showcaseview/showcaseview.dart'; +import 'dart:async'; import 'search_filter_home.dart'; /** @@ -57,14 +60,38 @@ class BottomNavBar extends StatefulWidget { class _BottomNavBarState extends State { int _selectedIndex = 0; + bool _canDailySpinNow = false; + /// While true, keep the middle tab on the wheel even if the server has already + /// set canSpin to false (so the tab does not switch mid-animation). + bool _spinTabLockedUntilResult = false; bool _hasTriggeredStep11 = false; // Prevent multiple showcase triggers bool _hasTriggeredStep12 = false; // Prevent multiple showcase triggers OverlayEntry? _bearOverlayEntry; + StreamSubscription? _spinAvailabilitySub; static const TextStyle optionStyle = TextStyle( fontSize: 30, fontWeight: FontWeight.bold, ); - late final List _widgetOptions; + List _buildWidgetOptions(bool showDailySpinTab) => [ + SearchFilterBar(initialHomeTab: widget.initialHomeTab), + showDailySpinTab + ? SpinTheWheelPage( + onSpinSessionStarted: () { + if (!mounted) return; + setState(() { + _spinTabLockedUntilResult = true; + }); + }, + onSpinResultClosed: () { + if (!mounted) return; + setState(() { + _spinTabLockedUntilResult = false; + }); + }, + ) + : GlobalLeaderboardWidget(), + ProfilePage(), + ]; void _onItemTapped(int index) { setState(() { @@ -75,15 +102,23 @@ class _BottomNavBarState extends State { @override void initState() { super.initState(); - _widgetOptions = [ - SearchFilterBar(initialHomeTab: widget.initialHomeTab), - GlobalLeaderboardWidget(), - ProfilePage(), - ]; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final client = context.read(); + _spinAvailabilitySub ??= + client.clientApi.updateSpinAvailabilityDataStream.listen((data) { + if (!mounted) return; + setState(() { + _canDailySpinNow = data.canSpin; + }); + }); + client.serverApi?.requestSpinAvailability(RequestSpinAvailabilityDto()); + }); } @override void dispose() { + _spinAvailabilitySub?.cancel(); _removeBearOverlay(); super.dispose(); } @@ -279,6 +314,8 @@ class _BottomNavBarState extends State { final challengeModel = Provider.of(context); final screenWidth = MediaQuery.of(context).size.width; final screenHeight = MediaQuery.of(context).size.height; + final showDailySpinTab = (_canDailySpinNow || _spinTabLockedUntilResult) && + onboarding.step13FinalComplete; // Step 11: Auto-select Profile tab and start showcase (trigger once) if (onboarding.step10HintButtonComplete && @@ -317,6 +354,7 @@ class _BottomNavBarState extends State { // Step 12: Auto-select Leaderboard tab and start showcase (trigger once) if (onboarding.step11ProfileTabComplete && !onboarding.step12LeaderboardTabComplete && + !showDailySpinTab && !_hasTriggeredStep12) { _hasTriggeredStep12 = true; // Prevent re-triggering on rebuild WidgetsBinding.instance.addPostFrameCallback((_) { @@ -383,7 +421,8 @@ class _BottomNavBarState extends State { }); } // Returning the main content page above the navbar [Home, Leaderboard, Profile] - return _widgetOptions.elementAt(_selectedIndex); + return _buildWidgetOptions(showDailySpinTab) + .elementAt(_selectedIndex); }, ), ), @@ -400,7 +439,21 @@ class _BottomNavBarState extends State { ), label: 'Home', ), - _buildLeaderboardTab(onboarding, screenWidth, screenHeight), + showDailySpinTab + ? BottomNavigationBarItem( + icon: SvgPicture.asset( + "assets/wheel/ferris-wheel.svg", + colorFilter: + ColorFilter.mode(Colors.grey, BlendMode.srcIn), + ), + activeIcon: SvgPicture.asset( + "assets/wheel/ferris-wheel.svg", + colorFilter: + ColorFilter.mode(Colors.black, BlendMode.srcIn), + ), + label: 'Daily Spin', + ) + : _buildLeaderboardTab(onboarding, screenWidth, screenHeight), _buildProfileTab(onboarding, screenWidth, screenHeight), ], currentIndex: _selectedIndex, diff --git a/game/lib/profile/profile_page.dart b/game/lib/profile/profile_page.dart index 35a25bdb..f21abda2 100644 --- a/game/lib/profile/profile_page.dart +++ b/game/lib/profile/profile_page.dart @@ -16,6 +16,7 @@ import 'package:game/achievements/achievement_cell.dart'; import 'package:game/profile/completed_cell.dart'; import 'package:game/profile/build_a_bear/bear_preview.dart'; import 'package:game/profile/build_a_bear/build_a_bear_page.dart'; +import 'package:game/profile/spin_the_wheel_page.dart'; import 'package:game/profile/settings_page.dart'; import 'package:game/utils/utility_functions.dart'; import 'package:intl/intl.dart' hide TextDirection; @@ -313,6 +314,21 @@ class _ProfilePageState extends State { ); }, ), + IconButton( + icon: const Icon( + Icons.casino_rounded, + color: Colors.black, + ), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + const SpinTheWheelPage(), + ), + ); + }, + ), IconButton( icon: Icon(Icons.settings, size: iconSize, color: Colors.black), diff --git a/game/lib/profile/spin_the_wheel_page.dart b/game/lib/profile/spin_the_wheel_page.dart new file mode 100644 index 00000000..07b720dc --- /dev/null +++ b/game/lib/profile/spin_the_wheel_page.dart @@ -0,0 +1,559 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:game/api/game_api.dart'; +import 'package:game/api/game_client_dto.dart'; +import 'package:provider/provider.dart'; + +class SpinTheWheelPage extends StatefulWidget { + const SpinTheWheelPage({ + super.key, + this.onSpinSessionStarted, + this.onSpinResultClosed, + }); + + /// Fired as soon as the user starts a spin, before the server marks them on cooldown. + final VoidCallback? onSpinSessionStarted; + + /// After the result dialog closes (Equip Now or X), or if the spin errors. + final VoidCallback? onSpinResultClosed; + + @override + State createState() => _SpinTheWheelPageState(); +} + +class _SpinTheWheelPageState extends State + with SingleTickerProviderStateMixin { + List _wheelItems = []; + bool _canSpin = false; + int _remainingCooldownSeconds = 0; + bool _isLoading = true; + bool _isSpinning = false; + + late final AnimationController _spinController; + late Animation _spinAnimation; + double _currentRotation = 0; + + StreamSubscription? _availabilitySub; + StreamSubscription? _wheelItemsSub; + StreamSubscription? _spinResultSub; + StreamSubscription? _errorSub; + Timer? _countdownTimer; + + final _spinResultCompleter = StreamController.broadcast(); + + @override + void initState() { + super.initState(); + _spinController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 4200), + ); + _spinAnimation = AlwaysStoppedAnimation(0); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _initStreamsAndLoad(); + }); + } + + @override + void dispose() { + _availabilitySub?.cancel(); + _wheelItemsSub?.cancel(); + _spinResultSub?.cancel(); + _errorSub?.cancel(); + _countdownTimer?.cancel(); + _spinResultCompleter.close(); + _spinController.dispose(); + super.dispose(); + } + + void _initStreamsAndLoad() { + final apiClient = context.read(); + final server = apiClient.serverApi; + if (server == null) return; + + _availabilitySub ??= + apiClient.clientApi.updateSpinAvailabilityDataStream.listen((data) { + setState(() { + _canSpin = data.canSpin; + _remainingCooldownSeconds = data.remainingCooldownSeconds; + }); + _startOrStopCountdown(); + }); + + _wheelItemsSub ??= + apiClient.clientApi.updateSpinWheelItemsDataStream.listen((data) { + setState(() { + _wheelItems = data.items.take(8).toList(); + _isLoading = false; + }); + }); + + _spinResultSub ??= apiClient.clientApi.updateSpinResultDataStream.listen(( + data, + ) { + _spinResultCompleter.add(data); + }); + + _errorSub ??= apiClient.clientApi.updateErrorDataStream.listen((data) { + if (!mounted || data.message.isEmpty) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(data.message)), + ); + }); + + server.requestSpinAvailability(RequestSpinAvailabilityDto()); + server.requestSpinWheelItems(RequestSpinWheelItemsDto()); + } + + void _startOrStopCountdown() { + _countdownTimer?.cancel(); + if (_canSpin || _remainingCooldownSeconds <= 0) return; + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) { + timer.cancel(); + return; + } + if (_remainingCooldownSeconds <= 0) { + timer.cancel(); + setState(() { + _canSpin = true; + }); + return; + } + setState(() { + _remainingCooldownSeconds -= 1; + }); + }); + } + + String _formatDuration(int seconds) { + final duration = Duration(seconds: seconds); + final hours = duration.inHours.toString().padLeft(2, '0'); + final minutes = (duration.inMinutes % 60).toString().padLeft(2, '0'); + final secs = (duration.inSeconds % 60).toString().padLeft(2, '0'); + return '$hours:$minutes:$secs'; + } + + Future _spin() async { + if (_isSpinning || !_canSpin || _wheelItems.isEmpty) return; + + final apiClient = context.read(); + final server = apiClient.serverApi; + if (server == null) return; + + setState(() { + _isSpinning = true; + }); + widget.onSpinSessionStarted?.call(); + + // Immediate visual feedback on tap so the wheel starts moving right away. + await _animateByRadians( + radians: 2 * math.pi * 1.5, + durationMs: 700, + curve: Curves.easeIn, + ); + + server.spinWheel(SpinWheelDto()); + + SpinResultDto result; + try { + result = await _spinResultCompleter.stream.first.timeout( + const Duration(seconds: 8), + ); + } catch (_) { + if (!mounted) return; + setState(() { + _isSpinning = false; + }); + widget.onSpinResultClosed?.call(); + context + .read() + .serverApi + ?.requestSpinAvailability(RequestSpinAvailabilityDto()); + return; + } + + final wonIndex = + _wheelItems.indexWhere((item) => item.id == result.wonItem.id); + if (wonIndex < 0) { + setState(() { + _wheelItems = [result.wonItem, ..._wheelItems].take(8).toList(); + }); + } + final targetIndex = + _wheelItems.indexWhere((item) => item.id == result.wonItem.id); + await _animateToIndex(targetIndex < 0 ? 0 : targetIndex); + + if (!mounted) return; + setState(() { + _isSpinning = false; + _canSpin = false; + _remainingCooldownSeconds = result.cooldownSeconds; + }); + _startOrStopCountdown(); + _showWinDialog(result.wonItem); + } + + Future _animateByRadians({ + required double radians, + required int durationMs, + required Curve curve, + }) async { + final finalRotation = _currentRotation + radians; + _spinController.duration = Duration(milliseconds: durationMs); + _spinAnimation = Tween( + begin: _currentRotation, + end: finalRotation, + ).animate(CurvedAnimation(parent: _spinController, curve: curve)); + + setState(() {}); + await _spinController.forward(from: 0); + _currentRotation = finalRotation; + } + + Future _animateToIndex(int index) async { + final segmentCount = _wheelItems.isEmpty ? 8 : _wheelItems.length; + final segmentAngle = (2 * math.pi) / segmentCount; + final targetOffset = (2 * math.pi - (index * segmentAngle)) % (2 * math.pi); + final currentOffset = _currentRotation % (2 * math.pi); + final delta = + (targetOffset - currentOffset + (2 * math.pi)) % (2 * math.pi); + final finalRotation = _currentRotation + (6 * 2 * math.pi) + delta; + + _spinController.duration = const Duration(milliseconds: 4200); + _spinAnimation = Tween( + begin: _currentRotation, + end: finalRotation, + ).animate( + CurvedAnimation(parent: _spinController, curve: Curves.easeOutCubic), + ); + + setState(() {}); + await _spinController.forward(from: 0); + _currentRotation = finalRotation; + } + + void _showWinDialog(BearItemDto wonItem) { + void onDismissed() { + if (!mounted) return; + widget.onSpinResultClosed?.call(); + context + .read() + .serverApi + ?.requestSpinAvailability(RequestSpinAvailabilityDto()); + } + + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return Dialog( + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Stack( + clipBehavior: Clip.none, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 28, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'You Won', + style: + TextStyle(fontSize: 24, fontWeight: FontWeight.w700), + ), + Text( + wonItem.name.toUpperCase(), + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w900, + color: Color(0xFFED5656), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: 90, + height: 90, + child: Image.asset('assets/${wonItem.assetKey}.png', + fit: BoxFit.contain), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + height: 52, + child: GestureDetector( + onTap: () { + context.read().serverApi?.equipBearItem( + EquipBearItemDto( + slot: wonItem.slot, itemId: wonItem.id), + ); + Navigator.pop(dialogContext); + }, + child: SvgPicture.asset( + 'assets/wheel/equipnow.svg', + fit: BoxFit.contain, + ), + ), + ), + ], + ), + ), + Positioned( + right: 4, + top: 4, + child: IconButton( + onPressed: () => Navigator.pop(dialogContext), + icon: SvgPicture.asset( + 'assets/wheel/close.svg', + width: 24, + height: 24, + ), + ), + ), + ], + ), + ); + }, + ).then((_) => onDismissed()); + } + + @override + Widget build(BuildContext context) { + final wheelItems = _wheelItems; + + return Scaffold( + backgroundColor: const Color(0xFFFFF5EA), + appBar: AppBar( + backgroundColor: const Color(0xFFED5656), + foregroundColor: Colors.white, + title: const Text('Spin the Wheel'), + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Column( + children: [ + if (_isLoading) + const Expanded( + child: Center(child: CircularProgressIndicator())) + else if (wheelItems.isEmpty) + Expanded( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'No wardrobe items available yet.', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () { + context + .read() + .serverApi + ?.requestSpinWheelItems( + RequestSpinWheelItemsDto(), + ); + }, + child: const Text('Refresh Items'), + ), + ], + ), + ), + ) + else + Expanded( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset( + 'assets/wheel/spinthewheel.svg', + height: 120, + ), + const SizedBox(height: 6), + Padding( + padding: const EdgeInsets.only(top: 56, bottom: 0), + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + AnimatedBuilder( + animation: _spinAnimation, + builder: (context, child) { + final a = _spinAnimation.value; + return Transform.rotate( + angle: a, + child: _WheelWidget( + items: wheelItems, + rotation: a, + ), + ); + }, + ), + Positioned( + top: -58, + child: SvgPicture.asset('assets/wheel/Pointer.svg', + height: 64), + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 6), + if (_canSpin) + SizedBox( + width: double.infinity, + child: GestureDetector( + onTap: _isSpinning ? null : _spin, + child: Opacity( + opacity: _isSpinning ? 0.6 : 1, + child: SvgPicture.asset( + 'assets/wheel/spinbutton.svg', + height: 102, + ), + ), + ), + ) + else + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: const Color(0xFF8F8F8F), + borderRadius: BorderRadius.circular(24), + ), + child: Text( + 'Wait ${_formatDuration(_remainingCooldownSeconds)}', + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: 20), + ], + ), + ), + ), + ); + } +} + +int _segmentIndexAtPointer(int segmentCount, double wheelRotation) { + if (segmentCount <= 0) return 0; + final sa = (2 * math.pi) / segmentCount; + // Pointer at 12 o'clock: bisector of segment j in local is -pi/2 + j*sa; after turn R, j*sa ≡ -R (mod 2pi). + var t = -wheelRotation; + t %= 2 * math.pi; + if (t < 0) t += 2 * math.pi; + return (t / sa + 1e-9).floor() % segmentCount; +} + +class _WheelWidget extends StatelessWidget { + const _WheelWidget({required this.items, required this.rotation}); + + final List items; + final double rotation; + + @override + Widget build(BuildContext context) { + const size = 300.0; + final n = items.length; + final selected = _segmentIndexAtPointer(n, rotation); + return SizedBox( + width: size, + height: size, + child: Stack( + alignment: Alignment.center, + children: [ + CustomPaint( + size: const Size(size, size), + painter: _WheelPainter( + segmentCount: n, + selectedIndex: selected, + ), + ), + ...List.generate(items.length, (index) { + final angle = + (-math.pi / 2) + (2 * math.pi * index / items.length); + final radius = size * 0.31; + return Transform.translate( + offset: + Offset(radius * math.cos(angle), radius * math.sin(angle)), + child: SizedBox( + width: 42, + height: 42, + child: Image.asset('assets/${items[index].assetKey}.png', + fit: BoxFit.contain), + ), + ); + }), + ], + ), + ); + } +} + +class _WheelPainter extends CustomPainter { + _WheelPainter({required this.segmentCount, required this.selectedIndex}); + + final int segmentCount; + final int selectedIndex; + + @override + void paint(Canvas canvas, Size size) { + final center = size.center(Offset.zero); + final radius = size.width / 2; + final segmentAngle = (2 * math.pi) / segmentCount; + final fillColors = [ + const Color(0xFFFFAA5B), + const Color(0xFFED5656), + ]; + for (int i = 0; i < segmentCount; i++) { + final path = Path()..moveTo(center.dx, center.dy); + final start = (-math.pi / 2 - segmentAngle / 2) + (i * segmentAngle); + path.arcTo(Rect.fromCircle(center: center, radius: radius), start, + segmentAngle, false); + path.close(); + + final paint = Paint() + ..style = PaintingStyle.fill + ..color = fillColors[i % fillColors.length]; + canvas.drawPath(path, paint); + + if (i == selectedIndex) { + final stroke = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2.5 + ..color = const Color(0xFFFF0300); + canvas.drawPath(path, stroke); + } + } + + final border = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 6 + ..color = const Color(0xFFED5656); + canvas.drawCircle(center, radius, border); + canvas.drawCircle(center, 10, Paint()..color = const Color(0xFFED5656)); + } + + @override + bool shouldRepaint(covariant _WheelPainter oldDelegate) => + oldDelegate.segmentCount != segmentCount || + oldDelegate.selectedIndex != selectedIndex; +} diff --git a/game/pubspec.yaml b/game/pubspec.yaml index d3e24ef5..48800322 100644 --- a/game/pubspec.yaml +++ b/game/pubspec.yaml @@ -107,6 +107,7 @@ flutter: - assets/buildabear/eyes/ - assets/buildabear/mouth/ - assets/buildabear/accessories/ + - assets/wheel/ - assets/videos/ - assets/audios/ - assets/lottie_animations/ diff --git a/server/prisma/migrations/20260427150616_add_spin_wheel_fields/migration.sql b/server/prisma/migrations/20260427150616_add_spin_wheel_fields/migration.sql new file mode 100644 index 00000000..ad9ea3b8 --- /dev/null +++ b/server/prisma/migrations/20260427150616_add_spin_wheel_fields/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "User" +ADD COLUMN "lastSpinAt" TIMESTAMP(3), +ADD COLUMN "spinJourneyUnlockAt" TIMESTAMP(3); +-- AlterTable +ALTER TABLE "User" +ADD COLUMN IF NOT EXISTS "lastSpinAt" TIMESTAMP(3), +ADD COLUMN IF NOT EXISTS "spinJourneyUnlockAt" TIMESTAMP(3); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index f3e67088..249b1338 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -116,6 +116,8 @@ model User { eventAttendances EventAttendance[] eventRSVPs EventRSVP[] feedbacks Feedback[] + lastSpinAt DateTime? + spinJourneyUnlockAt DateTime? } model Group { diff --git a/server/src/avatar/avatar.dto.ts b/server/src/avatar/avatar.dto.ts index d26f9621..2b391c0b 100644 --- a/server/src/avatar/avatar.dto.ts +++ b/server/src/avatar/avatar.dto.ts @@ -97,3 +97,23 @@ export interface UpdateBearItemDataDto { } export interface RequestAllBearItemsDto {} + +export interface RequestSpinAvailabilityDto {} + +export interface RequestSpinWheelItemsDto {} + +export interface SpinWheelDto {} + +export interface SpinAvailabilityDto { + canSpin: boolean; + remainingCooldownSeconds: number; +} + +export interface SpinWheelItemsDto { + items: BearItemDto[]; +} + +export interface SpinResultDto { + wonItem: BearItemDto; + cooldownSeconds: number; +} diff --git a/server/src/avatar/avatar.gateway.spec.ts b/server/src/avatar/avatar.gateway.spec.ts index 47c10d4b..298e6e99 100644 --- a/server/src/avatar/avatar.gateway.spec.ts +++ b/server/src/avatar/avatar.gateway.spec.ts @@ -39,6 +39,9 @@ const mockAvatarService = { updateBearItem: jest.fn(), deleteBearItem: jest.fn(), emitUpdateBearItemData: jest.fn(), + getAffectedUserIds: jest.fn(), + getEquippedEntries: jest.fn(), + reEquipDefaults: jest.fn(), getInventory: jest.fn(), getLoadout: jest.fn(), purchaseItem: jest.fn(), @@ -56,6 +59,9 @@ describe('AvatarGateway', () => { beforeEach(async () => { jest.clearAllMocks(); + mockAvatarService.getAffectedUserIds.mockResolvedValue([]); + mockAvatarService.getEquippedEntries.mockResolvedValue([]); + mockAvatarService.reEquipDefaults.mockResolvedValue(undefined); const module: TestingModule = await Test.createTestingModule({ providers: [ diff --git a/server/src/avatar/avatar.gateway.ts b/server/src/avatar/avatar.gateway.ts index 7f70f511..71968a51 100644 --- a/server/src/avatar/avatar.gateway.ts +++ b/server/src/avatar/avatar.gateway.ts @@ -13,8 +13,11 @@ import { PurchaseBearItemDto, RequestAllBearItemsDto, RequestBearItemsDto, + RequestSpinAvailabilityDto, + RequestSpinWheelItemsDto, RequestUserBearLoadoutDto, RequestUserInventoryDto, + SpinWheelDto, UpdateBearItemDataDto, } from './avatar.dto'; import { AvatarService } from './avatar.service'; @@ -105,6 +108,54 @@ export class AvatarGateway { return true; } + @SubscribeMessage('requestSpinAvailability') + async requestSpinAvailability( + @CallingUser() user: User, + @MessageBody() _data: RequestSpinAvailabilityDto, + ) { + const availability = await this.avatarService.requestSpinAvailability(user.id); + await this.clientService.sendProtected( + 'updateSpinAvailabilityData', + user, + availability, + ); + return availability.canSpin; + } + + @SubscribeMessage('requestSpinWheelItems') + async requestSpinWheelItems( + @CallingUser() user: User, + @MessageBody() _data: RequestSpinWheelItemsDto, + ) { + const items = await this.avatarService.requestSpinWheelItems(); + await this.clientService.sendProtected('updateSpinWheelItemsData', user, { + items, + }); + return items.length; + } + + @SubscribeMessage('spinWheel') + async spinWheel(@CallingUser() user: User, @MessageBody() _data: SpinWheelDto) { + const result = await this.avatarService.spinWheel(user.id); + if (!result) { + await this.clientService.emitErrorData( + user, + 'You cannot spin yet or no items are available.', + ); + return false; + } + + await this.clientService.sendProtected('updateSpinResultData', user, result); + await this.clientService.sendProtected('updateSpinAvailabilityData', user, { + canSpin: false, + remainingCooldownSeconds: result.cooldownSeconds, + }); + + const inv = await this.avatarService.getInventory(user.id); + await this.clientService.sendProtected('updateUserInventoryData', user, inv); + return true; + } + // ── Admin endpoints ── @SubscribeMessage('requestAllBearItems') diff --git a/server/src/avatar/avatar.service.ts b/server/src/avatar/avatar.service.ts index 50d45664..9d8e85e4 100644 --- a/server/src/avatar/avatar.service.ts +++ b/server/src/avatar/avatar.service.ts @@ -1,9 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { - BearSlot as PrismaBearSlot, - BearItem, - UserBearEquipped, -} from '@prisma/client'; +import { BearItem } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { ClientService } from '../client/client.service'; import { @@ -18,8 +14,13 @@ import { UserInventoryDto, } from './avatar.dto'; +type PrismaBearSlot = BearItem['slot']; + @Injectable() export class AvatarService { + private static readonly SPIN_COOLDOWN_HOURS = 24; + private static readonly WHEEL_ITEM_COUNT = 8; + constructor( private readonly prisma: PrismaService, private readonly clientService: ClientService, @@ -50,11 +51,131 @@ export class AvatarService { private async getUserBalance(userId: string): Promise { const user = await this.prisma.user.findUnique({ where: { id: userId }, - select: { coins: true }, }); return user?.coins ?? 0; } + private toSpinAvailabilityDto( + canSpin: boolean, + remainingCooldownSeconds: number, + ): { canSpin: boolean; remainingCooldownSeconds: number } { + return { + canSpin, + remainingCooldownSeconds: Math.max(0, Math.floor(remainingCooldownSeconds)), + }; + } + + private shuffle(items: T[]): T[] { + const cloned = [...items]; + for (let i = cloned.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [cloned[i], cloned[j]] = [cloned[j], cloned[i]]; + } + return cloned; + } + + private getCooldownSeconds(): number { + return AvatarService.SPIN_COOLDOWN_HOURS * 60 * 60; + } + + async requestSpinAvailability(userId: string): Promise<{ + canSpin: boolean; + remainingCooldownSeconds: number; + }> { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user?.lastSpinAt) { + return this.toSpinAvailabilityDto(true, 0); + } + + const nowMs = Date.now(); + const lastSpinMs = user.lastSpinAt.getTime(); + const elapsedSeconds = Math.floor((nowMs - lastSpinMs) / 1000); + const remainingCooldownSeconds = this.getCooldownSeconds() - elapsedSeconds; + if (remainingCooldownSeconds <= 0) { + return this.toSpinAvailabilityDto(true, 0); + } + + return this.toSpinAvailabilityDto(false, remainingCooldownSeconds); + } + + async requestSpinWheelItems(): Promise { + let items = await this.prisma.bearItem.findMany({ + where: { isDefault: false }, + orderBy: [{ slot: 'asc' }, { zIndex: 'asc' }, { name: 'asc' }], + }); + // Fallback for environments where only default items are seeded. + if (items.length === 0) { + items = await this.prisma.bearItem.findMany({ + orderBy: [{ slot: 'asc' }, { zIndex: 'asc' }, { name: 'asc' }], + }); + } + if (items.length === 0) { + return []; + } + + const shuffled = this.shuffle(items); + const selected: BearItem[] = []; + for (let i = 0; i < AvatarService.WHEEL_ITEM_COUNT; i++) { + selected.push(shuffled[i % shuffled.length]); + } + + return selected.map(item => this.toBearItemDto(item)); + } + + async spinWheel(userId: string): Promise<{ + wonItem: BearItemDto; + cooldownSeconds: number; + } | null> { + const availability = await this.requestSpinAvailability(userId); + if (!availability.canSpin) { + return null; + } + + const result = await this.prisma.$transaction(async tx => { + const availableItems = await tx.bearItem.findMany({ + where: { + isDefault: false, + inventories: { + none: { userId }, + }, + }, + }); + + if (availableItems.length === 0) { + return null; + } + + const wonItem = + availableItems[Math.floor(Math.random() * availableItems.length)]; + + await tx.userBearInventory.create({ + data: { + userId, + bearItemId: wonItem.id, + }, + }); + + await tx.user.update({ + where: { id: userId }, + data: { lastSpinAt: new Date() }, + }); + + return wonItem; + }); + + if (!result) { + return null; + } + + return { + wonItem: this.toBearItemDto(result), + cooldownSeconds: this.getCooldownSeconds(), + }; + } + async getInventory(userId: string): Promise { const [inv, balance] = await Promise.all([ this.prisma.userBearInventory.findMany({ diff --git a/server/src/challenge/challenge.service.spec.ts b/server/src/challenge/challenge.service.spec.ts index cfd86e14..f527cf07 100644 --- a/server/src/challenge/challenge.service.spec.ts +++ b/server/src/challenge/challenge.service.spec.ts @@ -6,6 +6,7 @@ import { EventService } from '../event/event.service'; import { ClientService } from '../client/client.service'; import { AchievementService } from '../achievement/achievement.service'; import { CaslAbilityFactory } from '../casl/casl-ability.factory'; +import { UserService } from '../user/user.service'; describe('ChallengeService', () => { let service: ChallengeService; @@ -38,6 +39,10 @@ describe('ChallengeService', () => { useValue: null, provide: CaslAbilityFactory, }, + { + useValue: null, + provide: UserService, + }, ], }).compile(); diff --git a/server/src/client/client.service.ts b/server/src/client/client.service.ts index e8bdc625..3e681068 100644 --- a/server/src/client/client.service.ts +++ b/server/src/client/client.service.ts @@ -26,6 +26,9 @@ import { UpdateAchievementDataDto, } from '../achievement/achievement.dto'; import { + SpinAvailabilityDto, + SpinResultDto, + SpinWheelItemsDto, UpdateBearItemDataDto, UpdateBearItemsDataDto, UpdatePurchaseResultDto, @@ -72,6 +75,9 @@ export type ClientApiDef = { updateUserInventoryData: UpdateUserInventoryDataDto; updateUserBearLoadoutData: UpdateUserBearLoadoutDataDto; updatePurchaseResult: UpdatePurchaseResultDto; + updateSpinAvailabilityData: SpinAvailabilityDto; + updateSpinWheelItemsData: SpinWheelItemsDto; + updateSpinResultData: SpinResultDto; timerStarted: TimerStartedDto; timerExtended: TimerExtendedDto; timerCompleted: TimerCompletedDto; From 66e29d3e9d8a5558df67d57581f86e704f46e579 Mon Sep 17 00:00:00 2001 From: Jimin Kim Date: Tue, 28 Apr 2026 00:06:27 -0400 Subject: [PATCH 3/7] style --- server/src/avatar/avatar.dto.ts | 12 ++++++------ server/src/avatar/avatar.gateway.ts | 2 +- server/src/avatar/avatar.service.ts | 2 +- server/src/client/client.service.ts | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/server/src/avatar/avatar.dto.ts b/server/src/avatar/avatar.dto.ts index 2b391c0b..2449092c 100644 --- a/server/src/avatar/avatar.dto.ts +++ b/server/src/avatar/avatar.dto.ts @@ -53,9 +53,9 @@ export interface RequestBearItemsDto { } // Empty DTOS for documentation and frontend api generation -export interface RequestUserInventoryDto {} +export interface RequestUserInventoryDto { } -export interface RequestUserBearLoadoutDto {} +export interface RequestUserBearLoadoutDto { } export interface UpdateBearItemsDataDto { items: BearItemDto[]; @@ -96,13 +96,13 @@ export interface UpdateBearItemDataDto { deleted: boolean; } -export interface RequestAllBearItemsDto {} +export interface RequestAllBearItemsDto { } -export interface RequestSpinAvailabilityDto {} +export interface RequestSpinAvailabilityDto { } -export interface RequestSpinWheelItemsDto {} +export interface RequestSpinWheelItemsDto { } -export interface SpinWheelDto {} +export interface SpinWheelDto { } export interface SpinAvailabilityDto { canSpin: boolean; diff --git a/server/src/avatar/avatar.gateway.ts b/server/src/avatar/avatar.gateway.ts index 71968a51..549830fb 100644 --- a/server/src/avatar/avatar.gateway.ts +++ b/server/src/avatar/avatar.gateway.ts @@ -29,7 +29,7 @@ export class AvatarGateway { constructor( private clientService: ClientService, private avatarService: AvatarService, - ) {} + ) { } @SubscribeMessage('requestBearItems') async requestBearItems( diff --git a/server/src/avatar/avatar.service.ts b/server/src/avatar/avatar.service.ts index 9d8e85e4..fd16fb0c 100644 --- a/server/src/avatar/avatar.service.ts +++ b/server/src/avatar/avatar.service.ts @@ -24,7 +24,7 @@ export class AvatarService { constructor( private readonly prisma: PrismaService, private readonly clientService: ClientService, - ) {} + ) { } /** Map Prisma enum to DTO enum */ private toDtoSlot(slot: PrismaBearSlot): BearSlotDto { diff --git a/server/src/client/client.service.ts b/server/src/client/client.service.ts index 3e681068..19a39b84 100644 --- a/server/src/client/client.service.ts +++ b/server/src/client/client.service.ts @@ -100,7 +100,7 @@ export class ClientService { private gateway: ClientGateway, private abilityFactory: CaslAbilityFactory, private prisma: PrismaService, - ) {} + ) { } public subscribe(user: User, resourceId: string) { if (process.env.TESTING_E2E === 'true') { From 45ef506215666583645465871d7e57764746be2f Mon Sep 17 00:00:00 2001 From: Jimin Kim Date: Mon, 4 May 2026 12:49:21 -0400 Subject: [PATCH 4/7] limit access to spin the wheel from profile page --- game/lib/profile/profile_page.dart | 18 +----------------- server/src/challenge/challenge.service.ts | 2 +- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/game/lib/profile/profile_page.dart b/game/lib/profile/profile_page.dart index f21abda2..37c5b209 100644 --- a/game/lib/profile/profile_page.dart +++ b/game/lib/profile/profile_page.dart @@ -16,7 +16,6 @@ import 'package:game/achievements/achievement_cell.dart'; import 'package:game/profile/completed_cell.dart'; import 'package:game/profile/build_a_bear/bear_preview.dart'; import 'package:game/profile/build_a_bear/build_a_bear_page.dart'; -import 'package:game/profile/spin_the_wheel_page.dart'; import 'package:game/profile/settings_page.dart'; import 'package:game/utils/utility_functions.dart'; import 'package:intl/intl.dart' hide TextDirection; @@ -286,7 +285,7 @@ class _ProfilePageState extends State { ), ), ), - // Clothes hanger and settings icons positioned on the right + // Header action icons on the right Positioned( right: screenWidth * 0.05, top: MediaQuery.of(context) @@ -314,21 +313,6 @@ class _ProfilePageState extends State { ); }, ), - IconButton( - icon: const Icon( - Icons.casino_rounded, - color: Colors.black, - ), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - const SpinTheWheelPage(), - ), - ); - }, - ), IconButton( icon: Icon(Icons.settings, size: iconSize, color: Colors.black), diff --git a/server/src/challenge/challenge.service.ts b/server/src/challenge/challenge.service.ts index 3fdbbb85..99af1ed7 100644 --- a/server/src/challenge/challenge.service.ts +++ b/server/src/challenge/challenge.service.ts @@ -38,7 +38,7 @@ export class ChallengeService { private clientService: ClientService, private abilityFactory: CaslAbilityFactory, private userService: UserService, - ) {} + ) { } /** * Calculate hint-adjusted points using the "Half-after-3" system From 978472c67e31895792dd093679fc4f160beff527 Mon Sep 17 00:00:00 2001 From: Jimin Kim Date: Mon, 4 May 2026 16:11:18 -0400 Subject: [PATCH 5/7] style --- server/src/avatar/avatar.dto.ts | 12 ++++++------ server/src/avatar/avatar.gateway.ts | 23 ++++++++++++++++++----- server/src/avatar/avatar.service.ts | 7 +++++-- server/src/challenge/challenge.service.ts | 2 +- server/src/client/client.service.ts | 2 +- 5 files changed, 31 insertions(+), 15 deletions(-) diff --git a/server/src/avatar/avatar.dto.ts b/server/src/avatar/avatar.dto.ts index 2449092c..2b391c0b 100644 --- a/server/src/avatar/avatar.dto.ts +++ b/server/src/avatar/avatar.dto.ts @@ -53,9 +53,9 @@ export interface RequestBearItemsDto { } // Empty DTOS for documentation and frontend api generation -export interface RequestUserInventoryDto { } +export interface RequestUserInventoryDto {} -export interface RequestUserBearLoadoutDto { } +export interface RequestUserBearLoadoutDto {} export interface UpdateBearItemsDataDto { items: BearItemDto[]; @@ -96,13 +96,13 @@ export interface UpdateBearItemDataDto { deleted: boolean; } -export interface RequestAllBearItemsDto { } +export interface RequestAllBearItemsDto {} -export interface RequestSpinAvailabilityDto { } +export interface RequestSpinAvailabilityDto {} -export interface RequestSpinWheelItemsDto { } +export interface RequestSpinWheelItemsDto {} -export interface SpinWheelDto { } +export interface SpinWheelDto {} export interface SpinAvailabilityDto { canSpin: boolean; diff --git a/server/src/avatar/avatar.gateway.ts b/server/src/avatar/avatar.gateway.ts index 549830fb..356dd31d 100644 --- a/server/src/avatar/avatar.gateway.ts +++ b/server/src/avatar/avatar.gateway.ts @@ -29,7 +29,7 @@ export class AvatarGateway { constructor( private clientService: ClientService, private avatarService: AvatarService, - ) { } + ) {} @SubscribeMessage('requestBearItems') async requestBearItems( @@ -113,7 +113,9 @@ export class AvatarGateway { @CallingUser() user: User, @MessageBody() _data: RequestSpinAvailabilityDto, ) { - const availability = await this.avatarService.requestSpinAvailability(user.id); + const availability = await this.avatarService.requestSpinAvailability( + user.id, + ); await this.clientService.sendProtected( 'updateSpinAvailabilityData', user, @@ -135,7 +137,10 @@ export class AvatarGateway { } @SubscribeMessage('spinWheel') - async spinWheel(@CallingUser() user: User, @MessageBody() _data: SpinWheelDto) { + async spinWheel( + @CallingUser() user: User, + @MessageBody() _data: SpinWheelDto, + ) { const result = await this.avatarService.spinWheel(user.id); if (!result) { await this.clientService.emitErrorData( @@ -145,14 +150,22 @@ export class AvatarGateway { return false; } - await this.clientService.sendProtected('updateSpinResultData', user, result); + await this.clientService.sendProtected( + 'updateSpinResultData', + user, + result, + ); await this.clientService.sendProtected('updateSpinAvailabilityData', user, { canSpin: false, remainingCooldownSeconds: result.cooldownSeconds, }); const inv = await this.avatarService.getInventory(user.id); - await this.clientService.sendProtected('updateUserInventoryData', user, inv); + await this.clientService.sendProtected( + 'updateUserInventoryData', + user, + inv, + ); return true; } diff --git a/server/src/avatar/avatar.service.ts b/server/src/avatar/avatar.service.ts index fd16fb0c..36f4b7d4 100644 --- a/server/src/avatar/avatar.service.ts +++ b/server/src/avatar/avatar.service.ts @@ -24,7 +24,7 @@ export class AvatarService { constructor( private readonly prisma: PrismaService, private readonly clientService: ClientService, - ) { } + ) {} /** Map Prisma enum to DTO enum */ private toDtoSlot(slot: PrismaBearSlot): BearSlotDto { @@ -61,7 +61,10 @@ export class AvatarService { ): { canSpin: boolean; remainingCooldownSeconds: number } { return { canSpin, - remainingCooldownSeconds: Math.max(0, Math.floor(remainingCooldownSeconds)), + remainingCooldownSeconds: Math.max( + 0, + Math.floor(remainingCooldownSeconds), + ), }; } diff --git a/server/src/challenge/challenge.service.ts b/server/src/challenge/challenge.service.ts index 99af1ed7..3fdbbb85 100644 --- a/server/src/challenge/challenge.service.ts +++ b/server/src/challenge/challenge.service.ts @@ -38,7 +38,7 @@ export class ChallengeService { private clientService: ClientService, private abilityFactory: CaslAbilityFactory, private userService: UserService, - ) { } + ) {} /** * Calculate hint-adjusted points using the "Half-after-3" system diff --git a/server/src/client/client.service.ts b/server/src/client/client.service.ts index 19a39b84..3e681068 100644 --- a/server/src/client/client.service.ts +++ b/server/src/client/client.service.ts @@ -100,7 +100,7 @@ export class ClientService { private gateway: ClientGateway, private abilityFactory: CaslAbilityFactory, private prisma: PrismaService, - ) { } + ) {} public subscribe(user: User, resourceId: string) { if (process.env.TESTING_E2E === 'true') { From 78eef75d858cc18f7fd543cb105bffb650f24e8b Mon Sep 17 00:00:00 2001 From: Jimin Kim Date: Tue, 5 May 2026 12:56:21 -0400 Subject: [PATCH 6/7] fixed formatting issues --- game/lib/navigation_page/bottom_navbar.dart | 1 + game/lib/profile/spin_the_wheel_page.dart | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/game/lib/navigation_page/bottom_navbar.dart b/game/lib/navigation_page/bottom_navbar.dart index b4a35bd1..4582a518 100644 --- a/game/lib/navigation_page/bottom_navbar.dart +++ b/game/lib/navigation_page/bottom_navbar.dart @@ -61,6 +61,7 @@ class BottomNavBar extends StatefulWidget { class _BottomNavBarState extends State { int _selectedIndex = 0; bool _canDailySpinNow = false; + /// While true, keep the middle tab on the wheel even if the server has already /// set canSpin to false (so the tab does not switch mid-animation). bool _spinTabLockedUntilResult = false; diff --git a/game/lib/profile/spin_the_wheel_page.dart b/game/lib/profile/spin_the_wheel_page.dart index 07b720dc..9d1b2870 100644 --- a/game/lib/profile/spin_the_wheel_page.dart +++ b/game/lib/profile/spin_the_wheel_page.dart @@ -402,7 +402,8 @@ class _SpinTheWheelPageState extends State ), Positioned( top: -58, - child: SvgPicture.asset('assets/wheel/Pointer.svg', + child: SvgPicture.asset( + 'assets/wheel/Pointer.svg', height: 64), ), ], @@ -488,8 +489,7 @@ class _WheelWidget extends StatelessWidget { ), ), ...List.generate(items.length, (index) { - final angle = - (-math.pi / 2) + (2 * math.pi * index / items.length); + final angle = (-math.pi / 2) + (2 * math.pi * index / items.length); final radius = size * 0.31; return Transform.translate( offset: From a40acc481edec4cfb1f33ffec70b329e23934a60 Mon Sep 17 00:00:00 2001 From: Jimin Kim Date: Tue, 5 May 2026 23:55:46 -0400 Subject: [PATCH 7/7] remove duplicates + journey unlocking --- .../20260427150616_add_spin_wheel_fields/migration.sql | 7 +------ server/prisma/schema.prisma | 1 - 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/server/prisma/migrations/20260427150616_add_spin_wheel_fields/migration.sql b/server/prisma/migrations/20260427150616_add_spin_wheel_fields/migration.sql index ad9ea3b8..c449352d 100644 --- a/server/prisma/migrations/20260427150616_add_spin_wheel_fields/migration.sql +++ b/server/prisma/migrations/20260427150616_add_spin_wheel_fields/migration.sql @@ -1,8 +1,3 @@ -- AlterTable ALTER TABLE "User" -ADD COLUMN "lastSpinAt" TIMESTAMP(3), -ADD COLUMN "spinJourneyUnlockAt" TIMESTAMP(3); --- AlterTable -ALTER TABLE "User" -ADD COLUMN IF NOT EXISTS "lastSpinAt" TIMESTAMP(3), -ADD COLUMN IF NOT EXISTS "spinJourneyUnlockAt" TIMESTAMP(3); +ADD COLUMN "lastSpinAt" TIMESTAMP(3); \ No newline at end of file diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 249b1338..6994ae18 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -117,7 +117,6 @@ model User { eventRSVPs EventRSVP[] feedbacks Feedback[] lastSpinAt DateTime? - spinJourneyUnlockAt DateTime? } model Group {