diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 15cada48..e3773d42 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -26,6 +26,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> ( () => ShopDataSourceImpl(locator())); locator.registerLazySingleton(() => TokenDataSourceImpl()); + locator.registerLazySingleton( + () => BettingDataSourceImpl(locator())); } void setupRepositoryLocator() { @@ -66,6 +72,8 @@ void setupRepositoryLocator() { () => MiniGameRepositoryImpl(locator())); locator.registerLazySingleton( () => ShopRepositoryImpl(locator())); + locator.registerLazySingleton( + () => BettingRepositoryImpl(locator())); } void setupApiLocator() { diff --git a/lib/presentation/community/screen/community_main_screen.dart b/lib/presentation/community/screen/community_main_screen.dart index ef5ee6e4..5ee72b19 100644 --- a/lib/presentation/community/screen/community_main_screen.dart +++ b/lib/presentation/community/screen/community_main_screen.dart @@ -13,6 +13,7 @@ import 'package:gogo_app/presentation/community/screen/community_detail_screen.d import 'package:gogo_app/presentation/community/widgets/community_filter_popup.dart'; import 'package:gogo_app/presentation/community/widgets/community_item.dart'; import 'package:go_router/go_router.dart'; +import 'package:gogo_app/presentation/loading/widgets/loading_indicator.dart'; import 'dart:math'; import '../../../design_system/component/tag/gogo_tag_component.dart'; import '../../../design_system/component/top_bar/gogo_top_bar.dart'; @@ -130,23 +131,25 @@ class _CommunityMainScreenContentState ), const SizedBox(width: 12), GestureDetector( - onTap: state is CommunityLoadedState ? () async { - final result = await filterDialog( - context, - gameType, - sortType, - state is CommunityLoadedState - ? state.gameTypes - : [], - ); - if (gameType == result['gameType'] && - sortType == result['sortType']) { - return; - } - gameType = result['gameType']; - sortType = result['sortType']; - _fetchCommunity(); - } : null, + onTap: state is CommunityLoadedState + ? () async { + final result = await filterDialog( + context, + gameType, + sortType, + state is CommunityLoadedState + ? state.gameTypes + : [], + ); + if (gameType == result['gameType'] && + sortType == result['sortType']) { + return; + } + gameType = result['gameType']; + sortType = result['sortType']; + _fetchCommunity(); + } + : null, child: GogoTagComponent( color: GogoColors.main500, text: '필터', @@ -217,11 +220,7 @@ class _CommunityMainScreenContentState builder: (context, state) { if (state is CommunityLoadingState) { return Expanded( - child: Center( - child: CircularProgressIndicator( - color: GogoColors.main500, - ), - ), + child: Center(child: LoadingIndicator()), ); } else if (state is CommunityLoadedState) { final totalPage = state.response.info.totalPage; diff --git a/lib/presentation/home/screen/home_screen.dart b/lib/presentation/home/screen/home_screen.dart index 3e9f5c76..a4901209 100644 --- a/lib/presentation/home/screen/home_screen.dart +++ b/lib/presentation/home/screen/home_screen.dart @@ -15,10 +15,15 @@ import 'package:gogo_app/presentation/navigation_view/widgets/drawer/gogo_drawer import 'package:gogo_app/presentation/ranking/widgets/ranking_list_item.dart'; import 'package:gogo_app/router.dart'; import 'package:intl/intl.dart'; +import '../../../data/models/betting/request/betting_match_request.dart'; +import '../../../data/models/common/match_dto.dart'; import '../../../design_system/theme/color.dart'; import '../../../design_system/theme/icon.dart'; +import '../../match_list/bloc/match_list_bloc.dart'; +import '../../match_list/bloc/match_list_event.dart'; import '../bloc/home_event.dart'; import '../bloc/home_state.dart'; +import '../widgets/match_batting_status_dialog.dart'; import '../widgets/match_card/match_card_component.dart'; import '../widgets/minigame/minigame_play_component.dart'; @@ -87,6 +92,8 @@ class HomeScreen extends StatelessWidget { PageRouter.matchList, queryParameters: { 'stageId': stageId.toString(), + 'point': state.points.point + .toString(), 'year': DateFormat('yyyy') .format(context .read() @@ -105,9 +112,10 @@ class HomeScreen extends StatelessWidget { horizontal: 16), scrollDirection: Axis.horizontal, child: Builder( - builder: (context) { - final matches = - context.read().matches; + builder: (localContext) { + final matches = localContext + .read() + .matches; if (matches.isEmpty) { return Center( child: Padding( @@ -161,7 +169,16 @@ class HomeScreen extends StatelessWidget { right: 8), child: MatchCard( matchDto: match, - onBattingClick: () {}, + onBattingClick: () { + showDialogMatchBatting( + context, + match, + stageId!, + context + .read() + .selectedDate, + state.points.point); + }, ), ); }, @@ -180,12 +197,16 @@ class HomeScreen extends StatelessWidget { GogoIcons.arcade(color: GogoColors.white), text: '미니게임', onTap: () => PageRouter.router.pushNamed( - PageRouter.miniGame, - queryParameters: { - 'stageId': stageId.toString(), - 'point': state.points.point.toString(), - 'betLimitResponse': state.betLimitResponse?.toJson().toString(), - })), + PageRouter.miniGame, + queryParameters: { + 'stageId': stageId.toString(), + 'point': state.points.point + .toString(), + 'betLimitResponse': state + .betLimitResponse + ?.toJson() + .toString(), + })), MinigamePlayComponent( activeGameResponse: state.activeGameResponse, @@ -370,4 +391,122 @@ class HomeScreen extends StatelessWidget { ], ), ); + + void showDialogMatchBatting( + BuildContext context, + MatchDto data, + int stageId, + DateTime date, + int point, + ) { + final matchListBloc = MatchListBloc( + stageId: stageId, + year: date.year, + month: date.month, + day: date.day, + ); + + showDialog( + context: context, + builder: (BuildContext dialogContext) { + final textController = TextEditingController(); + int aTeamPoint = data.ateam.bettingPoint; + int bTeamPoint = data.bteam.bettingPoint; + String? selectedTeam; + + return StatefulBuilder( + builder: (context, setState) { + return MatchBattingStatusDialog( + bettingController: textController, + startDate: data.startDate, + system: data.system, + gameType: data.category, + round: data.round, + selectedTeam: selectedTeam, + setSelectedTeam: (team) { + setState(() { + selectedTeam = team; + }); + }, + teamAPoint: aTeamPoint, + teamBPoint: bTeamPoint, + teamA: data.ateam.teamName, + teamB: data.bteam.teamName, + enableBetting: + !data.isEnd && data.startDate.isBefore(DateTime.now()), + closeDialog: () { + Navigator.pop(dialogContext); + }, + onBattingClick: () { + final inputText = textController.text.trim(); + final bettingPoint = int.tryParse(inputText); + + // === 유효성 검사 === + if (selectedTeam == null) { + _showError(dialogContext, '배팅할 팀을 선택해주세요.'); + return; + } + + if (bettingPoint == null) { + _showError(dialogContext, '숫자로 된 배팅 포인트를 입력해주세요.'); + return; + } + + if (bettingPoint < 1000) { + _showError(dialogContext, '배팅 포인트는 최소 1000 이상이어야 합니다.'); + return; + } + + if (bettingPoint > 5000) { + _showError(dialogContext, '배팅 포인트는 최대 5000 이하로 입력해주세요.'); + return; + } + + if (bettingPoint > point) { + _showError(dialogContext, '보유한 포인트보다 많이 입력할 수 없습니다.'); + return; + } + + final predictedTeamId = (selectedTeam == data.ateam.teamName) + ? data.ateam.teamId + : data.bteam.teamId; + + if (predictedTeamId == null) { + _showError(dialogContext, '선택된 팀의 ID를 찾을 수 없습니다.'); + return; + } + + final request = BettingMatchRequest( + predictedWinTeamId: predictedTeamId, + bettingPoint: bettingPoint, + ); + + matchListBloc.add(BettingMatch( + matchId: data.matchId, + request: request, + )); + + matchListBloc.add(LoadItems( + gameType: data.category, + sortOrder: SortOrder.ascending, + )); + + Navigator.pop(dialogContext); // 성공 후 모달 닫기 + }, + ); + }, + ); + }, + ); + } + + void _showError(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + ), + ); + } } diff --git a/lib/presentation/home/widgets/appbar/home_appbar.dart b/lib/presentation/home/widgets/appbar/home_appbar.dart index d2473c9a..07624cdb 100644 --- a/lib/presentation/home/widgets/appbar/home_appbar.dart +++ b/lib/presentation/home/widgets/appbar/home_appbar.dart @@ -23,7 +23,7 @@ class HomeAppbar extends StatefulWidget { } class _HomeAppbarState extends State { - final now = DateTime(2025,04,20); + final now = DateTime(2025,04,22); final ScrollController scrollController = ScrollController(); @override diff --git a/lib/presentation/home/widgets/match_batting_status_dialog.dart b/lib/presentation/home/widgets/match_batting_status_dialog.dart index 3d1ed68e..3b0af508 100644 --- a/lib/presentation/home/widgets/match_batting_status_dialog.dart +++ b/lib/presentation/home/widgets/match_batting_status_dialog.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:gogo_app/design_system/component/button/gogo_default_button.dart'; import 'package:gogo_app/design_system/component/text_field/gogo_text_field.dart'; @@ -14,7 +15,7 @@ import '../../../design_system/theme/color.dart'; import '../../../design_system/theme/icon.dart'; import '../../../design_system/theme/typography.dart'; -class MatchBattingStatusDialog extends StatelessWidget { +class MatchBattingStatusDialog extends StatefulWidget { final int teamAPoint; final int teamBPoint; final String teamA; @@ -24,8 +25,11 @@ class MatchBattingStatusDialog extends StatelessWidget { final GameType gameType; final MatchRound? round; final System system; + final String? selectedTeam; final VoidCallback closeDialog; - final void Function(String) onBattingClick; + final void Function() onBattingClick; + final void Function(String) setSelectedTeam; + final TextEditingController bettingController; const MatchBattingStatusDialog({ super.key, @@ -38,16 +42,36 @@ class MatchBattingStatusDialog extends StatelessWidget { required this.gameType, required this.round, required this.system, + required this.selectedTeam, required this.closeDialog, required this.onBattingClick, + required this.bettingController, + required this.setSelectedTeam, }); + @override + State createState() => + _MatchBattingStatusDialogState(); +} + +class _MatchBattingStatusDialogState extends State { + @override + void initState() { + // TODO: implement initState + super.initState(); + widget.bettingController.addListener(() => setState(() {})); + } + @override Widget build(BuildContext context) { - final int maxPoints = max(teamAPoint, teamBPoint); - final int totalPoints = teamAPoint + teamBPoint; - final int aTeamPercentage = ((teamAPoint / totalPoints) * 100).toInt(); - final int bTeamPercentage = ((teamBPoint / totalPoints) * 100).toInt(); + final int maxPoints = max(widget.teamAPoint, widget.teamBPoint); + final int totalPoints = widget.teamAPoint + widget.teamBPoint; + final int aTeamPercentage = totalPoints == 0 + ? 0 + : ((widget.teamAPoint / totalPoints) * 100).toInt(); + final int bTeamPercentage = totalPoints == 0 + ? 0 + : ((widget.teamBPoint / totalPoints) * 100).toInt(); return Dialog( child: SingleChildScrollView( @@ -55,7 +79,7 @@ class MatchBattingStatusDialog extends StatelessWidget { padding: EdgeInsets.symmetric(vertical: 24, horizontal: 15), width: double.infinity, decoration: BoxDecoration( - color: GogoColors.black, + color: GogoColors.gray700, borderRadius: BorderRadius.circular(12), ), child: Column( @@ -70,15 +94,17 @@ class MatchBattingStatusDialog extends StatelessWidget { mainAxisSize: MainAxisSize.max, children: [ Text( - '$teamA팀 VS $teamB팀', + '${widget.teamA}팀 VS ${widget.teamB}팀', style: GogoTypography.body1Extrabold.copyWith( color: GogoColors.white, ), + overflow: TextOverflow.ellipsis, + maxLines: 1, ), GogoIcons.x( width: 32, height: 32, - onTap: closeDialog, + onTap: widget.closeDialog, color: GogoColors.white, ) ], @@ -86,19 +112,18 @@ class MatchBattingStatusDialog extends StatelessWidget { Row( spacing: 20, children: [ - GogoTagComponent.small( - color: GogoColors.success, - text: formatDateTimeToHourMinute(startDate), - icon: GogoIcons.alarm( - color: GogoColors.success, - width: 12, - height: 12, - ), - ), - switch (system) { + switch (widget.system) { System.TOURNAMENT => GogoTagComponent.small( - color: GogoColors.white, - text: switch (round) { + isBorder: false, + color: switch (widget.round) { + MatchRound.ROUND_OF_32 => GogoColors.white, + MatchRound.ROUND_OF_16 => GogoColors.white, + MatchRound.QUARTER_FINALS => GogoColors.white, + MatchRound.SEMI_FINALS => GogoColors.white, + MatchRound.FINALS => GogoColors.main300, + null => GogoColors.white, + }, + text: switch (widget.round) { MatchRound.ROUND_OF_32 => "32", MatchRound.ROUND_OF_16 => "16", MatchRound.QUARTER_FINALS => "4", @@ -106,9 +131,20 @@ class MatchBattingStatusDialog extends StatelessWidget { MatchRound.FINALS => "결승전", null => "", }, - icon: round == MatchRound.FINALS + icon: widget.round == MatchRound.FINALS ? GogoIcons.flame( - color: GogoColors.white, + color: switch (widget.round) { + MatchRound.ROUND_OF_32 => + GogoColors.white, + MatchRound.ROUND_OF_16 => + GogoColors.white, + MatchRound.QUARTER_FINALS => + GogoColors.white, + MatchRound.SEMI_FINALS => + GogoColors.white, + MatchRound.FINALS => GogoColors.main300, + null => GogoColors.white, + }, width: 12, height: 12, ) @@ -138,10 +174,21 @@ class MatchBattingStatusDialog extends StatelessWidget { ), }, GogoTagComponent.small( + isBorder: false, + color: GogoColors.white, + text: formatDateTimeToHourMinute(widget.startDate), + icon: GogoIcons.alarm( + color: GogoColors.white, + width: 12, + height: 12, + ), + ), + GogoTagComponent.small( + isBorder: false, padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), color: GogoColors.main500, - text: switch (gameType) { + text: switch (widget.gameType) { GameType.SOCCER => "축구", GameType.BASKET_BALL => "농구", GameType.BASE_BALL => "야구", @@ -150,7 +197,7 @@ class MatchBattingStatusDialog extends StatelessWidget { GameType.LOL => "리그오브레전드", GameType.ETC => "기타", }, - icon: switch (gameType) { + icon: switch (widget.gameType) { GameType.SOCCER => GogoIcons.football( color: GogoColors.main500, width: 12, @@ -196,50 +243,48 @@ class MatchBattingStatusDialog extends StatelessWidget { alignment: Alignment.center, children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.end, children: [ - buildBattingGraph( - isSelected: true, - teamName: teamA, - maxBattingPoint: maxPoints, - currentTeamBattingPoint: teamAPoint, - currentBattingPercentage: aTeamPercentage, - enableBetting: enableBetting, - onClick: (team) => onBattingClick(team), - ), - SizedBox( - width: 62, + Expanded( + child: buildBattingGraph( + isSelected: widget.selectedTeam == widget.teamA, + teamName: widget.teamA, + maxBattingPoint: maxPoints, + currentTeamBattingPoint: widget.teamAPoint, + currentBattingPercentage: aTeamPercentage, + enableBetting: widget.enableBetting, + onClick: (team) => widget.setSelectedTeam(team), + ), ), - buildBattingGraph( - isSelected: false, - teamName: teamB, - maxBattingPoint: maxPoints, - currentTeamBattingPoint: teamBPoint, - currentBattingPercentage: bTeamPercentage, - enableBetting: enableBetting, - onClick: (team) => onBattingClick(team), + SizedBox(width: 80), // 가운데 공간 확보용 + Expanded( + child: buildBattingGraph( + isSelected: widget.selectedTeam == widget.teamB, + teamName: widget.teamB, + maxBattingPoint: maxPoints, + currentTeamBattingPoint: widget.teamBPoint, + currentBattingPercentage: bTeamPercentage, + enableBetting: widget.enableBetting, + onClick: (team) => widget.setSelectedTeam(team), + ), ), ], ), Column( - spacing: 12, - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.max, + mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisAlignment: MainAxisAlignment.center, - spacing: 4, children: [ GogoIcons.pointCircle(height: 20, width: 20), AnimatedInt( - currentInt: teamAPoint + teamBPoint, + currentInt: widget.teamAPoint + widget.teamBPoint, builder: (int value) => Text( "$value", style: GogoTypography.caption1Semibold.copyWith( color: GogoColors.gray300, ), - textAlign: TextAlign.center, ), ) ], @@ -249,7 +294,6 @@ class MatchBattingStatusDialog extends StatelessWidget { style: GogoTypography.body1Extrabold.copyWith( color: GogoColors.gray500, ), - textAlign: TextAlign.center, ), ], ), @@ -259,18 +303,29 @@ class MatchBattingStatusDialog extends StatelessWidget { spacing: 12, children: [ GogoTextField( - controller: TextEditingController(), + inputFormatter: [ + FilteringTextInputFormatter.digitsOnly, + ], + backgroundColor: GogoColors.gray600, + controller: widget.bettingController, hintText: "배팅할 금액을 입력해주세요", endIcon: GogoIcons.pointCircle( color: true ? GogoColors.white : GogoColors.gray400, ), ), GogoDefaultButton( - color: - enableBetting ? GogoColors.main600 : GogoColors.gray400, + color: widget.selectedTeam != null && + widget.enableBetting && + widget.bettingController.text.isEmpty == false + ? GogoColors.main600 + : GogoColors.gray400, text: "배팅", onTap: () { - onBattingClick("selectedTeam"); + if (widget.selectedTeam != null && + widget.enableBetting && + widget.bettingController.text.isEmpty == false) { + widget.onBattingClick(); + } }, ), ], @@ -312,6 +367,8 @@ class MatchBattingStatusDialog extends StatelessWidget { color: GogoColors.gray300, ), textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + maxLines: 1, ), ), Text( @@ -320,11 +377,14 @@ class MatchBattingStatusDialog extends StatelessWidget { color: GogoColors.white, ), textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + maxLines: 1, ), ], ), AnimatedContainer( - height: 30 + 131 * (currentBattingPercentage / 100), + height: + max(30, min(161, 30 + 131 * (currentBattingPercentage / 100))), decoration: ShapeDecoration( color: isSelected ? GogoColors.main600 : GogoColors.gray500, shape: RoundedRectangleBorder( @@ -342,7 +402,9 @@ class MatchBattingStatusDialog extends StatelessWidget { style: GogoTypography.body3Extrabold.copyWith( color: GogoColors.white, ), + overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, + maxLines: 1, ), ), ), diff --git a/lib/presentation/home/widgets/match_card/match_card_component.dart b/lib/presentation/home/widgets/match_card/match_card_component.dart index a3c4f5bf..2cfc6c2b 100644 --- a/lib/presentation/home/widgets/match_card/match_card_component.dart +++ b/lib/presentation/home/widgets/match_card/match_card_component.dart @@ -183,28 +183,36 @@ class MatchCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, spacing: 16, children: [ - Text( - "${matchDto.ateam.teamName}팀", - style: GogoTypography.body1Extrabold.copyWith( - color: matchDto.betting.isBetting && - matchDto.betting.predictedWinTeamId == - matchDto.ateam.teamId - ? GogoColors.main500 - : GogoColors.white), + Expanded( + child: Text( + "${matchDto.ateam.teamName}팀", + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: GogoTypography.body1Extrabold.copyWith( + color: matchDto.betting.isBetting && + matchDto.betting.predictedWinTeamId == + matchDto.ateam.teamId + ? GogoColors.main500 + : GogoColors.white), + ), ), Text( "VS", style: GogoTypography.body2Extrabold .copyWith(color: GogoColors.gray500), ), - Text( - "${matchDto.bteam.teamName}팀", - style: GogoTypography.body1Extrabold.copyWith( - color: matchDto.betting.isBetting && - matchDto.betting.predictedWinTeamId == - matchDto.bteam.teamId - ? GogoColors.main500 - : GogoColors.white, + Expanded( + child: Text( + "${matchDto.bteam.teamName}팀", + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: GogoTypography.body1Extrabold.copyWith( + color: matchDto.betting.isBetting && + matchDto.betting.predictedWinTeamId == + matchDto.bteam.teamId + ? GogoColors.main500 + : GogoColors.white, + ), ), ), ], diff --git a/lib/presentation/match_detail/widget/match_participant_widget.dart b/lib/presentation/match_detail/widget/match_participant_widget.dart index 0eb79d85..8c5d105d 100644 --- a/lib/presentation/match_detail/widget/match_participant_widget.dart +++ b/lib/presentation/match_detail/widget/match_participant_widget.dart @@ -27,7 +27,7 @@ class MatchParticipantWidget extends StatelessWidget { right: redOrBlue == TeamColor.red ? x : null, top: y, child: MatchParticipantItem( - redOrBlue: TeamColor.red, + redOrBlue: redOrBlue, name: name, ), ); diff --git a/lib/presentation/match_list/bloc/match_list_bloc.dart b/lib/presentation/match_list/bloc/match_list_bloc.dart index e69de29b..1ae7b33c 100644 --- a/lib/presentation/match_list/bloc/match_list_bloc.dart +++ b/lib/presentation/match_list/bloc/match_list_bloc.dart @@ -0,0 +1,71 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:get_it/get_it.dart'; +import 'package:gogo_app/data/models/stage/enum_type/game_type.dart'; +import '../../../data/repositories/betting/betting_repository.dart'; +import '../../../data/repositories/stage/stage_repository.dart'; +import 'match_list_event.dart'; +import 'match_list_state.dart'; +import '../../../data/models/common/match_dto.dart'; + +class MatchListBloc extends Bloc { + final StageRepository _stageRepository = GetIt.instance(); + final BettingRepository _bettingRepository = + GetIt.instance(); + + final int stageId; + final int year; + final int month; + final int day; + GameType? gameType; + + MatchListBloc({ + required this.stageId, + required this.year, + required this.month, + required this.day, + }) : super(InitMatchList()) { + on(_onLoadItems); + on(_onBettingMatch); + } + + Future _onLoadItems( + LoadItems event, Emitter emit) async { + emit(LoadingMatchList()); + + try { + final matchesResponse = await _stageRepository.searchMatch( + stageId, + year, + month, + day, + ); + List filtered = matchesResponse.matches; + + if (event.gameType != null) { + filtered = + filtered.where((e) => event.gameType == event.gameType).toList(); + } + if (event.sortOrder != null) { + filtered.sort((a, b) { + return event.sortOrder == SortOrder.ascending + ? a.startDate.compareTo(b.startDate) + : b.startDate.compareTo(a.startDate); + }); + } + + emit(LoadedMatchList(matchList: filtered, hasReachedMax: true)); + } catch (e) { + emit(InitMatchList()); + } + } + + Future _onBettingMatch( + BettingMatch event, Emitter emit) async { + try { + await _bettingRepository.bettingMatch(event.matchId, event.request); + emit(BettingSuccess()); + } catch (e) { + emit(BettingFailure(e.toString())); + } + } +} diff --git a/lib/presentation/match_list/bloc/match_list_event.dart b/lib/presentation/match_list/bloc/match_list_event.dart index aff1c4a0..6250ea28 100644 --- a/lib/presentation/match_list/bloc/match_list_event.dart +++ b/lib/presentation/match_list/bloc/match_list_event.dart @@ -1,11 +1,24 @@ +import 'package:gogo_app/data/models/stage/enum_type/game_type.dart'; + +import '../../../data/models/betting/request/betting_match_request.dart'; + abstract class MatchListEvent {} -class GetMatchList extends MatchListEvent { - final int page; - final int pageSize; +class LoadItems extends MatchListEvent { + final GameType? gameType; + final SortOrder? sortOrder; + + LoadItems({this.gameType, this.sortOrder}); +} + +enum SortOrder { ascending, descending } + +class BettingMatch extends MatchListEvent { + final int matchId; + final BettingMatchRequest request; - GetMatchList({ - required this.page, - required this.pageSize, + BettingMatch({ + required this.matchId, + required this.request, }); -} \ No newline at end of file +} diff --git a/lib/presentation/match_list/bloc/match_list_state.dart b/lib/presentation/match_list/bloc/match_list_state.dart index b501fa6a..6397a42c 100644 --- a/lib/presentation/match_list/bloc/match_list_state.dart +++ b/lib/presentation/match_list/bloc/match_list_state.dart @@ -14,4 +14,11 @@ class LoadedMatchList extends MatchListState { required this.matchList, required this.hasReachedMax, }); -} \ No newline at end of file +} + +class BettingSuccess extends MatchListState {} + +class BettingFailure extends MatchListState { + final String message; + BettingFailure(this.message); +} diff --git a/lib/presentation/match_list/screen/match_list_screen.dart b/lib/presentation/match_list/screen/match_list_screen.dart index f1fe0b6e..67188678 100644 --- a/lib/presentation/match_list/screen/match_list_screen.dart +++ b/lib/presentation/match_list/screen/match_list_screen.dart @@ -1,66 +1,136 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:gogo_app/data/models/common/match_dto.dart'; import 'package:gogo_app/design_system/component/tag/gogo_tag_component.dart'; import 'package:gogo_app/design_system/component/top_bar/gogo_top_bar.dart'; import 'package:gogo_app/design_system/theme/color.dart'; - -import '../../../data/models/stage/enum_type/game_type.dart'; -import '../../../data/models/stage/enum_type/match_round.dart'; -import '../../../data/models/stage/enum_type/system_type.dart'; -import '../../../data/models/stage/search_stage/search_betting_stage_response.dart'; -import '../../../design_system/theme/icon.dart'; +import 'package:gogo_app/design_system/theme/icon.dart'; +import 'package:gogo_app/presentation/loading/widgets/loading_indicator.dart'; +import '../../../data/models/betting/request/betting_match_request.dart'; import '../../home/widgets/match_batting_status_dialog.dart'; import '../../home/widgets/match_card/match_card_component.dart'; +import '../bloc/match_list_bloc.dart'; +import '../bloc/match_list_event.dart'; +import '../bloc/match_list_state.dart'; class MatchListScreen extends StatelessWidget { - const MatchListScreen({super.key}); + final int stageId; + final int point; + final int year; + final int month; + final int day; + + const MatchListScreen( + {super.key, + required this.stageId, + required this.point, + required this.year, + required this.month, + required this.day}); @override Widget build(BuildContext context) { - return Scaffold( - body: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - child: Column( - children: [ - GogoTopBar(title: "매치 목록", onBackTap: ()=> context.pop()), - SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - GogoTagComponent( - color: GogoColors.main500, - text: "필터", - icon: GogoIcons.filter( - color: GogoColors.main500, - width: 16, - height: 16, - ), - ) - ], - ), + return BlocProvider( + create: (_) => MatchListBloc( + stageId: stageId, + year: year, + month: month, + day: day, + )..add(LoadItems( + gameType: null, + sortOrder: SortOrder.descending, + )), + child: Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Column( + children: [ + GogoTopBar(title: "매치 목록", onBackTap: () => context.pop()), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + GogoTagComponent( + color: GogoColors.main500, + text: "필터", + icon: GogoIcons.filter( + color: GogoColors.main500, + width: 16, + height: 16, + ), + ), + ], + ), + ), + const SizedBox(height: 25), + Expanded( + child: BlocBuilder( + builder: (context, state) { + if (state is LoadingMatchList) { + return const Center(child: LoadingIndicator()); + } else if (state is LoadedMatchList) { + if (state.matchList.isEmpty) { + return Align( + alignment: Alignment.center, + child: Stack( + children: [ + Text( + "오늘\n매치가 없습니다", + style: TextStyle( + fontFamily: 'GmarketSans', + fontSize: 48, + foreground: Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1 + ..color = GogoColors.main600, + ), + textAlign: TextAlign.center, + ), + Transform.translate( + offset: Offset(5, -3), + child: Text( + "오늘\n매치가 없습니다", + style: TextStyle( + fontFamily: 'GmarketSans', + fontSize: 48, + color: GogoColors.main600, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ); + } + return ListView.separated( + itemCount: state.matchList.length, + separatorBuilder: (_, __) => + const SizedBox(height: 12), + itemBuilder: (context, index) { + final matchDto = state.matchList[index]; + return MatchCard( + matchDto: matchDto, + width: double.infinity, + onBattingClick: () { + showDialogMatchBatting( + context, matchDto, point); + }, + ); + }, + ); + } else { + return const SizedBox(); + } + }, + ), + ), + ], ), - SizedBox(height: 25), - // Expanded( - // child: ListView.separated( - // itemCount: list.length, - // separatorBuilder: (context, index) => - // SizedBox(height: 12), // 간격 추가 - // itemBuilder: (context, index) { - // final matchDto = list[index]; - // return MatchCard( - // matchDto: matchDto, - // width: double.infinity, - // onBattingClick: () { - // showDialogMatchBatting(context, matchDto); - // }, - // ); - // }, - // ), - // ) - ], + ), ), ), ); @@ -69,26 +139,122 @@ class MatchListScreen extends StatelessWidget { void showDialogMatchBatting( BuildContext context, MatchDto data, + int point, ) { + final matchListBloc = + MatchListBloc(stageId: stageId, year: year, month: month, day: day); + showDialog( context: context, - builder: (BuildContext context) { - return MatchBattingStatusDialog( - startDate: data.startDate, - system: data.system, - gameType: data.category, - round: data.round, - teamAPoint: data.ateam.bettingPoint, - teamBPoint: data.bteam.bettingPoint, - teamA: data.ateam.teamName, - teamB: data.bteam.teamName, - enableBetting: !data.isEnd && data.startDate.isBefore(DateTime.now()), - closeDialog: () { - Navigator.pop(context); + builder: (BuildContext dialogContext) { + final textController = TextEditingController(); + int aTeamPoint = data.ateam.bettingPoint; + int bTeamPoint = data.bteam.bettingPoint; + String? selectedTeam; + + return StatefulBuilder( + builder: (context, setState) { + return MatchBattingStatusDialog( + bettingController: textController, + startDate: data.startDate, + system: data.system, + gameType: data.category, + round: data.round, + selectedTeam: selectedTeam, + setSelectedTeam: (team) { + setState(() { + selectedTeam = team; + }); + }, + teamAPoint: aTeamPoint, + teamBPoint: bTeamPoint, + teamA: data.ateam.teamName, + teamB: data.bteam.teamName, + enableBetting: + !data.isEnd && data.startDate.isBefore(DateTime.now()), + closeDialog: () { + Navigator.pop(dialogContext); + }, + onBattingClick: () { + final inputText = textController.text.trim(); + final bettingPoint = int.tryParse(inputText); + + // === 유효성 검사 === + if (selectedTeam == null) { + _showError(dialogContext, '배팅할 팀을 선택해주세요.'); + return; + } + + if (bettingPoint == null) { + _showError(dialogContext, '배팅할 포인트를 입력해주세요.'); + return; + } + + if (bettingPoint < 1000) { + _showError(dialogContext, '배팅 포인트는 최소 1000 이상이어야 합니다.'); + return; + } + + if (bettingPoint > 5000) { + _showError(dialogContext, '배팅 포인트는 최대 5000 이하로 입력해주세요.'); + return; + } + + if (bettingPoint > point) { + _showError(dialogContext, '보유 포인트를 초과할 수 없습니다.'); + return; + } + + final predictedTeamId = (selectedTeam == data.ateam.teamName) + ? data.ateam.teamId + : data.bteam.teamId; + + if (predictedTeamId == null) { + _showError(dialogContext, '선택한 팀의 ID를 찾을 수 없습니다.'); + return; + } + + setState(() { + if (selectedTeam == data.ateam.teamName) { + aTeamPoint += bettingPoint; + } else { + bTeamPoint += bettingPoint; + } + }); + + final request = BettingMatchRequest( + predictedWinTeamId: predictedTeamId, + bettingPoint: bettingPoint, + ); + + matchListBloc.add( + BettingMatch( + matchId: data.matchId, + request: request, + ), + ); + + matchListBloc.add(LoadItems( + gameType: data.category, + sortOrder: SortOrder.ascending, + )); + + Navigator.pop(dialogContext); // 성공 후 다이얼로그 닫기 + }, + ); }, - onBattingClick: (String team) {}, ); }, ); } + + void _showError(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + ), + ); + } } diff --git a/lib/router.dart b/lib/router.dart index 0c617013..3d40cba2 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -216,7 +216,28 @@ class PageRouter { return CupertinoPage(child: CoinTossScreen(stageId: stageId)); }, ), - _customGoRoute(name: matchList, screen: MatchListScreen()), + GoRoute( + name: matchList, + path: matchList, + pageBuilder: (context, state) { + final stageId = + int.parse(state.uri.queryParameters['stageId']!); + final point = int.parse(state.uri.queryParameters['point']!); + final year = int.parse(state.uri.queryParameters['year']!); + final month = int.parse(state.uri.queryParameters['month']!); + final day = int.parse(state.uri.queryParameters['day']!); + + return CupertinoPage( + child: MatchListScreen( + stageId: stageId, + point: point, + year: year, + month: month, + day: day, + ), + ); + }, + ), GoRoute( name: matchTeamInfo, path: '$matchTeamInfo', diff --git a/pubspec.lock b/pubspec.lock index 4a9b05d4..c1fc0332 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -42,10 +42,10 @@ packages: dependency: transitive description: name: async - sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://pub.dev" source: hosted - version: "2.12.0" + version: "2.13.0" bloc: dependency: "direct main" description: @@ -258,10 +258,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.3.3" ffi: dependency: transitive description: @@ -753,10 +753,10 @@ packages: dependency: transitive description: name: leak_tracker - sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" url: "https://pub.dev" source: hosted - version: "10.0.8" + version: "10.0.9" leak_tracker_flutter_testing: dependency: transitive description: @@ -1198,10 +1198,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 url: "https://pub.dev" source: hosted - version: "14.3.1" + version: "15.0.0" watcher: dependency: transitive description: