From eab0597573a76c6bbf0a09389888fdde790d6c0e Mon Sep 17 00:00:00 2001 From: eld_master Date: Mon, 13 Jan 2025 15:04:33 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,=20=EC=95=A0=EB=93=9C=EB=AA=B9=20=EC=88=98=EC=A0=95,?= =?UTF-8?q?=20=EB=A9=94=EC=9D=B8=ED=8E=98=EC=9D=B4=EC=A7=80=EA=B9=8C?= =?UTF-8?q?=EC=A7=80=20=EC=A0=90=EA=B2=80=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle | 44 +- android/app/google-oauth-service.json | 1 + android/app/google-services.json | 34 +- android/app/src/main/AndroidManifest.xml | 12 +- android/build.gradle | 1 + assets/images/icons8-google-logo-144.png | Bin 0 -> 3954 bytes assets/images/icons8-google-logo-192.png | Bin 0 -> 5359 bytes assets/images/icons8-google-logo-36.png | Bin 0 -> 972 bytes assets/images/icons8-google-logo-48-2.png | Bin 0 -> 1305 bytes assets/images/icons8-google-logo-48.png | Bin 0 -> 1305 bytes assets/images/icons8-google-logo-72.png | Bin 0 -> 1934 bytes assets/images/icons8-google-logo-96.png | Bin 0 -> 2650 bytes ios/Runner/Info.plist | 2 +- lib/config/config.dart | 4 + lib/dialogs/room_setting_finish_dialog.dart | 149 ++++ lib/dialogs/settings_dialog.dart | 5 +- lib/dialogs/user_info_finish_dialog.dart | 127 +++ lib/main.dart | 12 +- lib/views/login/id_finding_page.dart | 55 +- lib/views/login/login_page.dart | 783 ++++++++++++++---- lib/views/login/pw_finding_page.dart | 51 ++ lib/views/login/signup_page.dart | 2 +- lib/views/room/finish_private_page.dart | 333 +++++++- lib/views/room/finish_team_page.dart | 408 ++++++++- lib/views/room/main_page.dart | 225 +++-- lib/views/room/playing_private_page.dart | 228 +++-- lib/views/room/playing_team_page.dart | 194 +++-- lib/views/room/room_search_list_page.dart | 90 +- lib/views/room/waiting_room_private_page.dart | 383 +++++---- lib/views/room/waiting_room_team_page.dart | 488 +++++------ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + my_release_key.jks | Bin 0 -> 2724 bytes pubspec.lock | 64 +- pubspec.yaml | 98 +-- 34 files changed, 2742 insertions(+), 1053 deletions(-) create mode 100644 android/app/google-oauth-service.json create mode 100644 assets/images/icons8-google-logo-144.png create mode 100644 assets/images/icons8-google-logo-192.png create mode 100644 assets/images/icons8-google-logo-36.png create mode 100644 assets/images/icons8-google-logo-48-2.png create mode 100644 assets/images/icons8-google-logo-48.png create mode 100644 assets/images/icons8-google-logo-72.png create mode 100644 assets/images/icons8-google-logo-96.png create mode 100644 lib/config/config.dart create mode 100644 lib/dialogs/room_setting_finish_dialog.dart create mode 100644 lib/dialogs/user_info_finish_dialog.dart create mode 100644 my_release_key.jks diff --git a/android/app/build.gradle b/android/app/build.gradle index e907d79..c9b2b8e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,44 +1,48 @@ plugins { id "com.android.application" - // START: FlutterFire Configuration + // (Firebase, Google Services 필요 시) id 'com.google.gms.google-services' - // END: FlutterFire Configuration id "kotlin-android" - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id "dev.flutter.flutter-gradle-plugin" } android { namespace = "com.allscore_app" - compileSdkVersion = 34 - ndkVersion = flutter.ndkVersion - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_17 - } + compileSdkVersion 34 defaultConfig { - applicationId = "com.allscore_app" + applicationId "com.allscore_app" minSdkVersion 23 targetSdkVersion 34 - versionCode = 1 - versionName = "1.0" + versionCode 1 + versionName "1.0" } + // ... + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.debug + signingConfig signingConfigs.debug } } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17 + } } flutter { source = "../.." } + +dependencies { + // 기존 의존성 ... + implementation 'com.google.android.gms:play-services-auth:20.6.0' +} + +// (Firebase Auth, Crashlytics 등을 사용한다면, 아래 구문이 필요할 수 있습니다.) +apply plugin: 'com.google.gms.google-services' diff --git a/android/app/google-oauth-service.json b/android/app/google-oauth-service.json new file mode 100644 index 0000000..551ebbb --- /dev/null +++ b/android/app/google-oauth-service.json @@ -0,0 +1 @@ +{"installed":{"client_id":"19981745655-3dadv7n64jqcada6mtc1ao25k1m90gp3.apps.googleusercontent.com","project_id":"allscore-447406","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs"}} \ No newline at end of file diff --git a/android/app/google-services.json b/android/app/google-services.json index e0156d4..8a0e07a 100644 --- a/android/app/google-services.json +++ b/android/app/google-services.json @@ -1,27 +1,45 @@ { "project_info": { - "project_number": "70449524223", - "firebase_url": "https://allscore-344c2-default-rtdb.asia-southeast1.firebasedatabase.app", - "project_id": "allscore-344c2", - "storage_bucket": "allscore-344c2.firebasestorage.app" + "project_number": "452355332155", + "firebase_url": "https://allscore-29edf-default-rtdb.asia-southeast1.firebasedatabase.app", + "project_id": "allscore-29edf", + "storage_bucket": "allscore-29edf.firebasestorage.app" }, "client": [ { "client_info": { - "mobilesdk_app_id": "1:70449524223:android:94ffb9ec98e508313e4bca", + "mobilesdk_app_id": "1:452355332155:android:152995468604d10d13e41e", "android_client_info": { "package_name": "com.allscore_app" } }, - "oauth_client": [], + "oauth_client": [ + { + "client_id": "452355332155-t29ceato8o62c9kq9drefe7b6hd1ka1d.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "com.allscore_app", + "certificate_hash": "83fe36945bd0f037a7b934f9737a4fa94c47872d" + } + }, + { + "client_id": "452355332155-jv26k1rs4tro38tc99mffid2e3gbra6j.apps.googleusercontent.com", + "client_type": 3 + } + ], "api_key": [ { - "current_key": "AIzaSyAJEItMxO-TemHGlveSKySG-eNaTD9XJI0" + "current_key": "AIzaSyB6hil7Nrk8wslHDfRNRRyw6rQktY16tTc" } ], "services": { "appinvite_service": { - "other_platform_oauth_client": [] + "other_platform_oauth_client": [ + { + "client_id": "452355332155-jv26k1rs4tro38tc99mffid2e3gbra6j.apps.googleusercontent.com", + "client_type": 3 + } + ] } } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0a3ecc1..12f0be3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,16 +2,24 @@ package="com.allscore_app"> + + + android:value="ca-app-pub-3940256099942544~3347511713" /> + + + + aC>Tmd%Jtw@rVC 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 Uqekw3`=4-ab$zuu6}zba1Bd5Bo&W#< literal 0 HcmV?d00001 diff --git a/assets/images/icons8-google-logo-36.png b/assets/images/icons8-google-logo-36.png new file mode 100644 index 0000000000000000000000000000000000000000..171fe0f3e943c26c19df199e9ab62b1b4005f2d3 GIT binary patch literal 972 zcmV;-12g=IP)V0j-=9UdLFPE z^Jx7R39S&~*wz(;Oq9fg7<19rLoE8tU`#ahrp{+g1*pP`AK67J#!^ge7-V`6S=`|y z;20ItDwhp`IJVi44c_?+S(}#9lUaneS<9)3<2VSKD2WU)m4Rfl0DWrU12buxnGhWg9ZsS< z++jvi={AcbO5kaZRe}nJ*>wA|FwRVMBR4F?sB6qAVEu)IJ>JeTU!m__76x|mH`%oj zhG`uFC+|_=Fq6JuGrnL*iM=lZb7JN?C69!LOfUFg_A4rEln-^dZFKBh-=4D#eBAVzi*nPfW7^UrF?zWRt z(frEekTx|tI7iNLxB_uxT$rm!v$b}|i#*M_PcTG+C+8>k3%Xrm4hRWHW=&J5SHJ8I zkx=gr#F24fx{M3O$`o_6D&a|+Dn3Q3i3#b@VnGjkFm|Jw`KU+hyQG7{-DzSellrKH~G6OQNKmLbFzy9ze#n<4U!vn4;~~{-*M-WKuTee9bjothfA5KmP{J&s}xG?JRk^+l%h0*>aAfRn4fPkeOB` z7F10nYO>^<8E+xZXX^;2qJxOtNh)T3THk{$^+ckil1Lg5D%s;_6=@Eh{3JXrI|=g? usg5E=>R7dk)qjs*Tz1~W1&24X9`gtO0N-Q`4{$XA0000)VL5 z87v`;4#FV3D6A^rKp62h7_x!92!n+Q+k(z$lR>-QH6XaFx_xpPWD<=ZOpI#_YmXN< z7oaaZ=Exv*gZ6GcT8#ZC|1VAN>F;;f=eeg>2nr^cV0;e1L?P1^XdYFJ5Y|le&bU%A zH!c&nEPG>tMHmY#`veOU3Uv0a3N_mb`2$obKNnz72o&i2RSw5d1;`Cc66!R^#{xFB z$OTSY9&%&4Dmuph;MIaRs66zxB|*RndPNaoEU+CxMrV$r;eLUi`VeV0O9TtPfv-{A8(hMz7$+E`6QIqIbHk(*N`{OYNbF`|1Iy5)XZRG94; zETKwcFe(=rrI*iD&b*OR_?cQX+#(aWn1WB0AYDu*pBY1+u^r`Kau=bvhNm;BMt))f zZ#|5d?$_~NWIH~+?prgE@il=5-|yNnKMc=LmGOajx5p*#7SKQ&p!F6937>WtVLNobGj&CRiSR*d4ZEEiQWdS$JmX$cz5{aUW38C zx|qei%cL6VhSqEBM!0H4frou?A$kI+lb(jwYwRR^#gl-;2)D5r?}dCL;Ht@0J%4!p zK!=ex=23u*N(z*~TczL3di_8<2~TUt#8mX5P;)Wf*%=v6J+TWK+&H9S`6X~Tnnhr)OUNk0yXUwDz5_p z06k?l^6o5w+l?^j9}t2R?SVyjx`%ZNXU9Oo4zA$qTPj|TEY`a`!ABm&ofwHRdz$Ak z!u2%#3>gh-aQQO0dn>NWEhH)O`SBn|Vj;Y85Uf5}7)MvyDjX!7gTpdKzL zR*&%EMTQ0JQ;gNkb2%M^xLL_Sz+I$HFC95+5My5v3F0e*@nM%q(Uu;=9$~v7vE~FW zze&Hx{TO9mD#&WaKv)5}qCml^#!kqW)Dw~srUJe+yX$?h{hgtI!KYN8{}u8z8co{9 zW4it0ev#W@weJb3|eTI{E)5FNGr07}9Bs0L^@PtEtB|NqJNXfy!Ya2^*QX~M<& zN?i75fi`(IORI3vSBTyOrfZc8^lRl;_nR6B3DIRp%%+clf(a%V$k+S>oe^=S0`doh P00000NkvXXu0mjfpVVA} literal 0 HcmV?d00001 diff --git a/assets/images/icons8-google-logo-48.png b/assets/images/icons8-google-logo-48.png new file mode 100644 index 0000000000000000000000000000000000000000..796b00a71ff3ff3d0a0d9202fecb752b8812d010 GIT binary patch literal 1305 zcmV+!1?KvRP))VL5 z87v`;4#FV3D6A^rKp62h7_x!92!n+Q+k(z$lR>-QH6XaFx_xpPWD<=ZOpI#_YmXN< z7oaaZ=Exv*gZ6GcT8#ZC|1VAN>F;;f=eeg>2nr^cV0;e1L?P1^XdYFJ5Y|le&bU%A zH!c&nEPG>tMHmY#`veOU3Uv0a3N_mb`2$obKNnz72o&i2RSw5d1;`Cc66!R^#{xFB z$OTSY9&%&4Dmuph;MIaRs66zxB|*RndPNaoEU+CxMrV$r;eLUi`VeV0O9TtPfv-{A8(hMz7$+E`6QIqIbHk(*N`{OYNbF`|1Iy5)XZRG94; zETKwcFe(=rrI*iD&b*OR_?cQX+#(aWn1WB0AYDu*pBY1+u^r`Kau=bvhNm;BMt))f zZ#|5d?$_~NWIH~+?prgE@il=5-|yNnKMc=LmGOajx5p*#7SKQ&p!F6937>WtVLNobGj&CRiSR*d4ZEEiQWdS$JmX$cz5{aUW38C zx|qei%cL6VhSqEBM!0H4frou?A$kI+lb(jwYwRR^#gl-;2)D5r?}dCL;Ht@0J%4!p zK!=ex=23u*N(z*~TczL3di_8<2~TUt#8mX5P;)Wf*%=v6J+TWK+&H9S`6X~Tnnhr)OUNk0yXUwDz5_p z06k?l^6o5w+l?^j9}t2R?SVyjx`%ZNXU9Oo4zA$qTPj|TEY`a`!ABm&ofwHRdz$Ak z!u2%#3>gh-aQQO0dn>NWEhH)O`SBn|Vj;Y85Uf5}7)MvyDjX!7gTpdKzL zR*&%EMTQ0JQ;gNkb2%M^xLL_Sz+I$HFC95+5My5v3F0e*@nM%q(Uu;=9$~v7vE~FW zze&Hx{TO9mD#&WaKv)5}qCml^#!kqW)Dw~srUJe+yX$?h{hgtI!KYN8{}u8z8co{9 zW4it0ev#W@weJb3|eTI{E)5FNGr07}9Bs0L^@PtEtB|NqJNXfy!Ya2^*QX~M<& zN?i75fi`(IORI3vSBTyOrfZc8^lRl;_nR6B3DIRp%%+clf(a%V$k+S>oe^=S0`doh P00000NkvXXu0mjfpVVA} literal 0 HcmV?d00001 diff --git a/assets/images/icons8-google-logo-72.png b/assets/images/icons8-google-logo-72.png new file mode 100644 index 0000000000000000000000000000000000000000..a53c956f3510198145be484a1757d9a5c3f8338f GIT binary patch literal 1934 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 z|_l#yh;`8BICFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Allscore App + 올스코어 CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier diff --git a/lib/config/config.dart b/lib/config/config.dart new file mode 100644 index 0000000..abea696 --- /dev/null +++ b/lib/config/config.dart @@ -0,0 +1,4 @@ +class Config { + static const String testAdUnitId = 'ca-app-pub-3940256099942544/6300978111'; + static const String realAdUnitId = 'ca-app-pub-3940256099942544/6300978111'; +} \ No newline at end of file diff --git a/lib/dialogs/room_setting_finish_dialog.dart b/lib/dialogs/room_setting_finish_dialog.dart new file mode 100644 index 0000000..bc61d85 --- /dev/null +++ b/lib/dialogs/room_setting_finish_dialog.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; + +/// 종료된 방에서 "방 정보 보기" 버튼을 누르면 표시되는 읽기전용 모달 (수정판) +class RoomSettingFinishDialog extends StatelessWidget { + final Map roomInfo; + + const RoomSettingFinishDialog({ + Key? key, + required this.roomInfo, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final media = MediaQuery.of(context); + final String roomTitle = (roomInfo['room_title'] ?? '') as String; + final String roomIntro = (roomInfo['room_intro'] ?? '') as String; + final String openYn = (roomInfo['open_yn'] ?? 'Y') as String; + final int maxPeople = (roomInfo['number_of_people'] ?? 0) as int; + final int runningTime = (roomInfo['running_time'] ?? 0) as int; + final String scoreOpen = (roomInfo['score_open_range'] ?? 'ALL') as String; + + final String openLabel = (openYn == 'Y') ? '공개' : '비공개'; + + return Dialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: media.size.height * 0.8, + minWidth: media.size.width * 0.8, + ), + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // (A) 상단 타이틀 + const Text( + '방 정보', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + const Divider(color: Colors.black54), + const SizedBox(height: 12), + + // (B) 방 제목 + _buildLabelValue(label: '방 제목', value: roomTitle.isNotEmpty ? roomTitle : '(없음)'), + const SizedBox(height: 14), + + // (C) 방 소개 + const Text( + '방 소개', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + const SizedBox(height: 6), + + // 가로로 꽉 차게(width: double.infinity), 세로 최대 100px → 스크롤 + Container( + width: double.infinity, + constraints: const BoxConstraints(maxHeight: 100), + decoration: BoxDecoration( + border: Border.all(color: Colors.black26), + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.all(8), + child: SingleChildScrollView( + child: Text( + roomIntro.isNotEmpty ? roomIntro : '소개글 없음', + style: const TextStyle(fontSize: 14, color: Colors.black), + softWrap: true, + ), + ), + ), + const SizedBox(height: 16), + + // (D) 공개 여부 + _buildLabelValue(label: '공개 여부', value: openLabel), + const SizedBox(height: 8), + + // (E) 최대 인원 + _buildLabelValue(label: '최대 인원', value: '$maxPeople 명'), + const SizedBox(height: 8), + + // (F) 운영 시간 + _buildLabelValue(label: '운영 시간', value: '$runningTime 분'), + const SizedBox(height: 8), + + // (G) 점수 공개 범위 + _buildLabelValue(label: '점수 공개 범위', value: scoreOpen), + const SizedBox(height: 24), + + // (H) 하단 닫기 버튼 (가운데 정렬) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () => Navigator.pop(context), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + ), + child: const Text('닫기'), + ), + ], + ), + ], + ), + ), + ), + ); + } + + /// label + value를 세로로 배치한 간단 위젯 + Widget _buildLabelValue({ + required String label, + required String value, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle(fontSize: 14, color: Colors.black), + ), + ], + ); + } +} diff --git a/lib/dialogs/settings_dialog.dart b/lib/dialogs/settings_dialog.dart index 831cf8f..b7d77fa 100644 --- a/lib/dialogs/settings_dialog.dart +++ b/lib/dialogs/settings_dialog.dart @@ -69,8 +69,11 @@ void showSettingsDialog(BuildContext context) { // 로그아웃 클릭 시 동작 SharedPreferences prefs = await SharedPreferences.getInstance(); await prefs.setString('auth_token', ''); // auth_token 초기화 - Navigator.of(context).pushReplacement( + await prefs.setBool('auto_login', false); // auto_login 초기화 + Navigator.pushAndRemoveUntil( + context, MaterialPageRoute(builder: (context) => const LoginPage()), // 로그인 페이지로 이동 + (route) => false, ); }, style: ButtonStyle( diff --git a/lib/dialogs/user_info_finish_dialog.dart b/lib/dialogs/user_info_finish_dialog.dart new file mode 100644 index 0000000..2b708c5 --- /dev/null +++ b/lib/dialogs/user_info_finish_dialog.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; + +class UserInfoFinishDialog extends StatelessWidget { + final Map userData; + + const UserInfoFinishDialog({Key? key, required this.userData}) : super(key: key); + + @override + Widget build(BuildContext context) { + // 예: { "nickname": "testuser4", "profile_img": "...", "introduce_myself": "..." } + final nickname = (userData['nickname'] ?? '').toString(); + final profileImg = (userData['profile_img'] ?? '').toString(); + final intro = (userData['introduce_myself'] ?? '').toString().trim(); + + return Dialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + // 좌우여백 좀 더 주고, 세로도 여유롭게 + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // (A) 상단 타이틀 + const Text( + '유저 정보', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + + // (B) 프로필 이미지 + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.black26, width: 1.5), + ), + child: ClipOval( + child: (profileImg.isNotEmpty) + ? Image.network( + 'https://eldsoft.com:8097/images$profileImg', + fit: BoxFit.cover, + errorBuilder: (ctx, err, st) => + const Center(child: Text('이미지\n오류', textAlign: TextAlign.center, style: TextStyle(fontSize: 12))), + ) + : const Center( + child: Text( + '이미지\n없음', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12), + ), + ), + ), + ), + const SizedBox(height: 16), + + // (C) 닉네임 + Text( + nickname.isNotEmpty ? nickname : '유저', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + + const SizedBox(height: 20), + + // (D) 소개 라벨 + const Align( + alignment: Alignment.centerLeft, + child: Text( + '소개', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ), + const SizedBox(height: 6), + + // (E) 소개 내용 (길이에 맞춰 자연스럽게 높이 확장) + Container( + width: double.infinity, + // 최소 60, 최대 200 정도로 제한 + constraints: const BoxConstraints( + minHeight: 60, + maxHeight: 200, + ), + decoration: BoxDecoration( + border: Border.all(color: Colors.black26), + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.all(8), + child: SingleChildScrollView( + child: Text( + intro.isNotEmpty ? intro : '소개글이 없습니다.', + style: const TextStyle(fontSize: 14), + ), + ), + ), + + const SizedBox(height: 24), + + // (F) 확인 버튼 + SizedBox( + width: 100, + child: ElevatedButton( + onPressed: () => Navigator.pop(context), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text('확인', style: TextStyle(fontSize: 14)), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 3eda742..c60ad03 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,13 +11,17 @@ import 'firebase_options.dart'; import 'views/login/login_page.dart'; import 'views/room/main_page.dart'; +// 모바일 광고 +import 'package:google_mobile_ads/google_mobile_ads.dart'; + void main() async { WidgetsFlutterBinding.ensureInitialized(); - // Firebase 초기화 - await Firebase.initializeApp( - options: DefaultFirebaseOptions.currentPlatform, // FirebaseOptions 사용 - ); + // 파이어베이스 초기화 + await Firebase.initializeApp(); + + // 모바일 광고 초기화 + MobileAds.instance.initialize(); runApp(const MyApp()); } diff --git a/lib/views/login/id_finding_page.dart b/lib/views/login/id_finding_page.dart index 72d6eda..92d861c 100644 --- a/lib/views/login/id_finding_page.dart +++ b/lib/views/login/id_finding_page.dart @@ -6,6 +6,12 @@ import 'login_page.dart'; import 'pw_finding_page.dart'; import 'signup_page.dart'; +// 모바일 광고 +import 'package:google_mobile_ads/google_mobile_ads.dart'; + +// 설정 +import '../../config/config.dart'; + class IdFindingPage extends StatefulWidget { const IdFindingPage({Key? key}) : super(key: key); @@ -21,7 +27,14 @@ class _IdFindingPageState extends State { String foundIdMessage = ''; String authId = ''; + /// (1) 광고 배너 관련 변수 + BannerAd? _bannerAd; + bool _isBannerReady = false; // 광고 로드 완료 여부 + String adUnitId = Config.testAdUnitId; + Future _findId(String nickname, String email) async { + + // 로딩 인디케이터 표시 showDialog( context: context, @@ -85,6 +98,36 @@ class _IdFindingPageState extends State { } } + @override + void initState() { + super.initState(); + _initBannerAd(); + } + + void _initBannerAd() { + _bannerAd = BannerAd( + // 실제/테스트 배너 광고 단위 ID + adUnitId: adUnitId, + size: AdSize.banner, + request: const AdRequest(), + listener: BannerAdListener( + onAdLoaded: (ad) { + setState(() => _isBannerReady = true); + }, + onAdFailedToLoad: (ad, error) { + ad.dispose(); + }, + ), + ); + _bannerAd?.load(); + } + + @override + void dispose() { + _bannerAd?.dispose(); + super.dispose(); + } + Future _findAllId() async { // ID 전체 찾기 요청 처리 print('ID 전체 찾기 요청 $authId'); // 요청 시 출력 @@ -185,7 +228,6 @@ class _IdFindingPageState extends State { ); } - @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.white, @@ -303,10 +345,21 @@ class _IdFindingPageState extends State { }, child: const Text('회원가입', style: TextStyle(color: Colors.black)), ), + ], ), ), ), + + // (3) 하단 광고 영역 + bottomNavigationBar: _isBannerReady && _bannerAd != null + ? Container( + color: Colors.white, + width: _bannerAd!.size.width.toDouble(), + height: _bannerAd!.size.height.toDouble(), + child: AdWidget(ad: _bannerAd!), + ) + : SizedBox.shrink(), // 로딩 전엔 빈 공간 or 원하는 위젯 ); } } \ No newline at end of file diff --git a/lib/views/login/login_page.dart b/lib/views/login/login_page.dart index c552136..e44ef13 100644 --- a/lib/views/login/login_page.dart +++ b/lib/views/login/login_page.dart @@ -1,50 +1,118 @@ import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; -import 'dart:convert'; -import 'dart:convert' show utf8; +// import 'package:http/http.dart' as http; ← 사용안함. Api.serverRequest() 사용 +import 'dart:convert' show utf8, jsonEncode; import 'package:crypto/crypto.dart'; import 'package:shared_preferences/shared_preferences.dart'; + +// 우리의 Api 모듈 +import '../../plugins/api.dart'; + +// 안내 모달창 +import '../../dialogs/response_dialog.dart'; + +// (기존) ID/PW 찾기, 회원가입 페이지 import 'id_finding_page.dart'; import 'pw_finding_page.dart'; import 'signup_page.dart'; + +// 구글 로그인 +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:firebase_auth/firebase_auth.dart'; + +// 광고 import 'package:google_mobile_ads/google_mobile_ads.dart'; + +// 메인 페이지 import '../room/main_page.dart'; +// 설정 +import '../../config/config.dart'; + +// 뒤로가기 +import 'package:fluttertoast/fluttertoast.dart'; // 뒤로가기 안내 문구에 Toast 등 사용 + class LoginPage extends StatefulWidget { const LoginPage({Key? key}) : super(key: key); @override - _LoginPageState createState() => _LoginPageState(); + State createState() => _LoginPageState(); } class _LoginPageState extends State { + // ───────────────────────────────────────── + // (A) ID/PW 관련 + // ───────────────────────────────────────── final TextEditingController idController = TextEditingController(); final TextEditingController passwordController = TextEditingController(); bool autoLogin = false; - String loginErrorMessage = ''; + String loginErrorMessage = ''; // 로그인 실패 시 안내 + // ───────────────────────────────────────── + // (B) 구글 로그인 객체 + // ───────────────────────────────────────── + final GoogleSignIn _googleSignIn = GoogleSignIn( + scopes: ['email'], + ); + + // ───────────────────────────────────────── + // (C) 광고 배너 + // ───────────────────────────────────────── BannerAd? _bannerAd; - Widget? _adWidget; + bool _isBannerReady = false; + String adUnitId = Config.testAdUnitId; + + // 뒤로가기 처리 + DateTime? _lastPressedTime; + + // 로딩 중 + bool _isLoading = false; + + // 예: 2초 이내로 뒤로가기를 한 번 더 누르면 종료 + static const _exitDuration = Duration(seconds: 2); + + Future _onWillPop() async { + final now = DateTime.now(); + if (_lastPressedTime == null || + now.difference(_lastPressedTime!) > _exitDuration) { + // 첫 번째 뒤로가기 누름 or 이전 누름이 오래 전 + _lastPressedTime = now; + + // 안내 문구 띄우기 (Toast 예시) + Fluttertoast.showToast( + msg: '한 번 더 누르면 앱이 종료됩니다.', + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM, + ); + return false; // 페이지 pop 하지 않음 + } + // 2초 이내에 뒤로가기 두 번째 누름 → 앱 종료 허용 + return true; // pop 허용 (Scaffold 밖으로 벗어남, 결과적으로 앱 종료) + } @override void initState() { super.initState(); + _initBannerAd(); + } + + void _initBannerAd() { _bannerAd = BannerAd( - adUnitId: "ca-app-pub-3151339278746301~1689299887", - request: const AdRequest(), + // 실제/테스트 배너 광고 단위 ID + adUnitId: adUnitId, size: AdSize.banner, + request: const AdRequest(), listener: BannerAdListener( onAdLoaded: (ad) { - setState(() { - _adWidget = AdWidget(ad: ad as AdWithView); - }); + setState(() => _isBannerReady = true); + print('로그인페이지 배너 광고 로드 완료'); }, onAdFailedToLoad: (ad, error) { - print('Ad failed to load: $error'); + print('로그인페이지 배너 광고 로드 실패: $error'); ad.dispose(); }, ), - )..load(); + ); + _bannerAd?.load(); } @override @@ -53,93 +121,287 @@ class _LoginPageState extends State { super.dispose(); } - Future _login() async { - String id = idController.text.trim(); - String password = passwordController.text.trim(); + // ───────────────────────────────────────── + // (D1) ID/PW 로그인 + // ───────────────────────────────────────── + Future _loginWithIdPw() async { + setState(() => _isLoading = true); + final id = idController.text.trim(); + final pw = passwordController.text.trim(); - // autoLogin 체크여부 - String autoLoginStatus = autoLogin ? 'Y' : 'N'; + // PW SHA-256 해싱 + final bytes = utf8.encode(pw); + final digest = sha256.convert(bytes); + final hashedPw = digest.toString(); - // PW를 sha256으로 해시 - var bytes = utf8.encode(password); - var digest = sha256.convert(bytes); + final requestBody = { + "user_id": id, + "user_pw": hashedPw, + }; try { - final response = await http.post( - Uri.parse('https://eldsoft.com:8097/user/login'), - headers: { - 'Content-Type': 'application/json', - 'auth_token': '', - }, - body: jsonEncode({ - 'user_id': id, - 'user_pw': digest.toString(), - }), - ).timeout(const Duration(seconds: 10)); + // (1) /user/login 서버 요청 + final response = await Api.serverRequest(uri: '/user/login', body: requestBody); - // 응답 바디 디코딩 - String responseBody = utf8.decode(response.bodyBytes); - - if (response.statusCode == 200) { - final Map jsonResponse = jsonDecode(responseBody); - print('jsonResponse: $jsonResponse'); - - if (jsonResponse['result'] == 'OK') { + if (response['result'] == 'OK') { + // 내부 응답 + final resp = response['response'] ?? {}; + if (resp['result'] == 'OK') { // 로그인 성공 - final authData = jsonResponse['auth'] ?? {}; - final token = authData['token'] ?? ''; - final userSeq = authData['user_seq'] ?? 0; // 새로 추가 + print('ID/PW 로그인 성공: $resp'); - SharedPreferences prefs = await SharedPreferences.getInstance(); - // 토큰 및 autoLogin 여부 저장 - await prefs.setString('auth_token', token); - await prefs.setBool('auto_login', autoLogin); - // (New) 내 user_seq 저장 - await prefs.setInt('my_user_seq', userSeq); + // (a) google_user_yn = N + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('oauth_type', 'idpw'); + await prefs.setBool('auto_login', true); + await prefs.setString('jwt_token', resp['auth']['token'].toString()); + await prefs.setString('user_seq', resp['auth']['user_seq'].toString()); - // 메인 페이지로 이동 + // 메인 페이지 이동 + if (!mounted) return; Navigator.pushReplacement( context, - MaterialPageRoute(builder: (context) => const MainPage()), + MaterialPageRoute(builder: (_) => const MainPage()), ); - } else if (jsonResponse['response_info']['msg_title'] == '로그인 실패') { - // 로그인 실패 메시지 - setState(() { - loginErrorMessage = '회원정보를 다시 확인해주세요.'; - }); } else { - // result != OK 이지만, 다른 이유 - _showDialog('로그인 실패', '서버에서 로그인에 실패했습니다.\n관리자에게 문의해주세요.'); + showResponseDialog(context, resp['response_info']['msg_title'], resp['response_info']['msg_content']); } } else { - _showDialog('오류', '로그인에 실패했습니다. 관리자에게 문의해주세요.'); + // 서버 통신 자체가 FAIL + showResponseDialog(context, '오류', '로그인 요청 실패'); } } catch (e) { - print('로그인 요청 중 오류: $e'); - _showDialog('오류', '로그인 요청이 실패했습니다. 관리자에게 문의해주세요.\n$e'); + showResponseDialog(context, '오류', '로그인 요청 중 예외 발생.\n$e'); + } finally { + setState(() => _isLoading = false); } } - void _showDialog(String title, String content) { - showDialog( + // ───────────────────────────────────────── + // (D2) 구글 로그인 + // ───────────────────────────────────────── + Future _googleLogin() async { + setState(() => _isLoading = true); + try { + // 1) 구글 계정 선택 + final GoogleSignInAccount? googleUser = await _googleSignIn.signIn(); + if (googleUser == null) { + // 사용자가 로그인 창에서 취소 누름 + return; + } + + // 2) 구글 인증 정보 가져오기 + final GoogleSignInAuthentication googleAuth = await googleUser.authentication; + + // 3) FirebaseAuth Credential 생성 + final AuthCredential credential = GoogleAuthProvider.credential( + accessToken: googleAuth.accessToken, + idToken: googleAuth.idToken, + ); + + // 4) FirebaseAuth로 로그인 + final UserCredential userCredential = + await FirebaseAuth.instance.signInWithCredential(credential); + + final User? user = userCredential.user; + if (user == null) { + print('구글 로그인 실패: Firebase User가 null'); + _showAlert('로그인 오류', 'Firebase User가 null입니다.'); + return; + } + + final idToken = await user.getIdToken(); + + // 서버에 구글 로그인 정보 전송 + final requestBody = { + 'id_token': idToken, + }; + + final response = await Api.serverRequest(uri: '/user/google/login', body: requestBody); + if (response['result'] == 'OK') { + final resp = response['response'] ?? {}; + if (resp['result'] == 'OK') { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('oauth_type', 'google'); + await prefs.setBool('auto_login', true); + await prefs.setString('jwt_token', resp['auth']['token'].toString()); + await prefs.setString('user_seq', resp['auth']['user_seq'].toString()); + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const MainPage()), + ); + } else { + showResponseDialog(context, resp['response_info']['msg_title'], resp['response_info']['msg_content']); + } + } else { + showResponseDialog(context, '오류', '구글 로그인 요청 실패'); + } + + // (선택) SharedPreferences에 google_user_yn = 'Y' 저장 등 + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('google_user_yn', 'Y'); + } catch (e) { + _showAlert('오류', '구글 로그인 중 오류가 발생했습니다.\n$e'); + } finally { + setState(() => _isLoading = false); + } + } + + // ───────────────────────────────────────── + // (D3) 구글 회원가입 + // ───────────────────────────────────────── + Future _googleSignUp() async { + final agreed = await _showTermsModal(); + if (agreed != true) { + return; + } + + try { + // (2) 구글 계정 선택 + final GoogleSignInAccount? googleUser = await _googleSignIn.signIn(); + if (googleUser == null) { + // 사용자가 회원가입 창에서 취소 누름 + return; + } + + // (3) 구글 인증 정보 가져오기 + final GoogleSignInAuthentication googleAuth = await googleUser.authentication; + + // (4) FirebaseAuth Credential 생성 + final AuthCredential credential = GoogleAuthProvider.credential( + accessToken: googleAuth.accessToken, + idToken: googleAuth.idToken, + ); + + // (5) FirebaseAuth로 로그인 (회원가입 시도) + // 사실 "회원가입"이라는 명칭을 썼지만, Firebase 쪽에선 같은 signInWithCredential()를 사용 + final UserCredential userCredential = + await FirebaseAuth.instance.signInWithCredential(credential); + + final User? user = userCredential.user; + if (user == null) { + showResponseDialog(context, '오류', '구글계정 인증에 실패했습니다.'); + return; + } + + // (6) idToken 추출 후, 서버에 회원가입 요청 + final idToken = await user.getIdToken(); + final requestBody = { + 'id_token': idToken, + }; + + // 서버 측 '/user/google/signup' API 호출 (예시) + final response = await Api.serverRequest(uri: '/user/google/signup', body: requestBody); + if (response['result'] == 'OK') { + final resp = response['response'] ?? {}; + if (resp['result'] == 'OK') { + // 회원가입 성공 안내 + showResponseDialog(context, '회원가입 완료', '구글 회원가입이 완료되었습니다.'); + } else { + // 서버 응답 자체가 OK가 아니면, 어떤 에러 메시지를 띄울지 처리 + final msgTitle = resp['response_info']?['msg_title'] ?? '오류'; + final msgContent = resp['response_info']?['msg_content'] ?? '회원가입에 실패했습니다.'; + showResponseDialog(context, msgTitle, msgContent); + } + } else { + // 서버 통신이 FAIL (응답 result != OK) + showResponseDialog(context, '오류', '구글 회원가입 요청 실패'); + } + + } catch (e) { + showResponseDialog(context, '오류', '구글 회원가입 중 오류가 발생했습니다.\n$e'); + } + } + + // ───────────────────────────────────────── + // (E) 약관 모달 + // ───────────────────────────────────────── + Future _showTermsModal() async { + return showDialog( context: context, - builder: (BuildContext context) { + barrierDismissible: false, + builder: (ctx) { return AlertDialog( backgroundColor: Colors.white, - title: Text(title, style: const TextStyle(color: Colors.black)), - content: Text(content, style: const TextStyle(color: Colors.black)), - actions: [ - Center( - child: TextButton( - style: TextButton.styleFrom( - backgroundColor: Colors.black, - foregroundColor: Colors.white, + title: const Text( + '개인정보 수집 및 이용 동의서', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text( + '''올스코어(이하 "회사"라 합니다)는 이용자의 개인정보를 중요시하며, 「개인정보 보호법」 등 관련 법령을 준수하고 있습니다. 회사는 개인정보 수집 및 이용에 관한 사항을 아래와 같이 안내드리오니, 내용을 충분히 숙지하신 후 동의하여 주시기 바랍니다. + +1. 수집하는 개인정보 항목 +필수 항목: 아이디(ID), 비밀번호(PW), 닉네임(실명 아님), 이메일 주소 +선택 항목: 소속, 자기소개 + +2. 개인정보의 수집 및 이용 목적 +회원 관리 +회원 식별 및 인증 +부정 이용 방지 및 비인가 사용 방지 +서비스 이용에 따른 문의 사항 처리 +서비스 제공 +게임 방 생성 및 참여 등 기본 서비스 제공 +통계 및 순위 제공 등 부가 서비스 제공 +고객 지원 및 공지사항 전달 +서비스 관련 중요한 공지사항 전달 +이용자 문의 및 불만 처리 + +3. 개인정보의 보유 및 이용 기간 +회원 탈퇴 시: 수집된 모든 개인정보는 회원 탈퇴 즉시 파기합니다. +관련 법령에 따른 보관: 전자상거래 등에서의 소비자 보호에 관한 법률 등 관계 법령의 규정에 따라 일정 기간 보관이 필요한 경우 해당 기간 동안 보관합니다. +계약 또는 청약 철회 등에 관한 기록: 5년 보관 +대금 결제 및 재화 등의 공급에 관한 기록: 5년 보관 +소비자의 불만 또는 분쟁 처리에 관한 기록: 3년 보관 + +4. 개인정보의 파기 절차 및 방법 +파기 절차 +회원 탈퇴 요청 또는 개인정보 수집 및 이용 목적이 달성된 후 지체 없이 해당 정보를 파기합니다. +파기 방법 +전자적 파일 형태: 복구 및 재생이 불가능한 방법으로 영구 삭제 +종이 문서 형태: 분쇄하거나 소각 + +5. 이용자의 권리 및 행사 방법 +이용자는 언제든지 자신의 개인정보에 대해 열람, 수정, 삭제, 처리 정지를 요구할 수 있습니다. +회원 탈퇴를 원하시는 경우, 서비스 내의 "회원 탈퇴" 기능을 이용하시거나 고객센터를 통해 요청하실 수 있습니다. + +6. 동의를 거부할 권리 및 거부 시 불이익 +이용자는 개인정보 수집 및 이용에 대한 동의를 거부할 권리가 있습니다. +그러나 필수 항목에 대한 동의를 거부하실 경우 서비스 이용이 제한될 수 있습니다. + +7. 개인정보 보호책임자 +연락처: eld_yeojh@naver.com + +8. 개인정보의 안전성 확보 조치 +회사는 개인정보의 안전한 처리를 위하여 기술적, 관리적 보호조치를 시행하고 있습니다. +개인정보의 암호화 +해킹 등에 대비한 대책 +접근 통제 장치의 설치 및 운영 +''', + style: TextStyle(fontSize: 14), ), - child: const Text('확인'), - onPressed: () { - Navigator.of(context).pop(); - }, + ], + ), + ), + actions: [ + TextButton( + style: TextButton.styleFrom( + backgroundColor: Colors.black, + foregroundColor: Colors.white, ), + onPressed: () => Navigator.pop(ctx, false), + child: Text('거부'), + ), + TextButton( + style: TextButton.styleFrom( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + ), + onPressed: () => Navigator.pop(ctx, true), + child: Text('동의'), ), ], ); @@ -147,124 +409,287 @@ class _LoginPageState extends State { ); } + // ───────────────────────────────────────── + // (F) 간단 Alert + // ───────────────────────────────────────── + void _showAlert(String title, String message) { + showDialog( + context: context, + builder: (_) => AlertDialog( + backgroundColor: Colors.white, + title: Text(title, style: const TextStyle(color: Colors.black)), + content: Text(message, style: const TextStyle(color: Colors.black)), + actions: [ + TextButton( + style: TextButton.styleFrom( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + ), + onPressed: () => Navigator.pop(context), + child: const Text('확인'), + ), + ], + ), + ); + } + + // ───────────────────────────────────────── + // (G) 화면 + // ───────────────────────────────────────── @override Widget build(BuildContext context) { - return Scaffold( + return WillPopScope( + onWillPop: _onWillPop, + child: Stack( + children: [ +Scaffold( backgroundColor: Colors.white, + + // 상단 AppBar appBar: AppBar( title: const Text('ALL SCORE', style: TextStyle(color: Colors.white)), backgroundColor: Colors.black, ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - '로그인', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Colors.black, - ), - ), - const SizedBox(height: 32), - TextField( - controller: idController, - decoration: InputDecoration( - labelText: 'ID', - labelStyle: const TextStyle(color: Colors.black), - border: const OutlineInputBorder(), - focusedBorder: const OutlineInputBorder( - borderSide: BorderSide(color: Colors.black, width: 2.0), + + // 전체 세로 레이아웃 + body: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // (1) 중앙 영역 → 로그인 UI + Expanded( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // ───────────────────────────────────────── + // 1. 올스코어 로그인 (ID/PW) + // ───────────────────────────────────────── + const Text( + '올스코어 로그인', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black), + ), + const SizedBox(height: 16), + + // (A) 아이디 입력 + SizedBox( + width: 300, + child: TextField( + controller: idController, + decoration: InputDecoration( + labelText: 'ID', + border: OutlineInputBorder( + borderSide: const BorderSide(color: Colors.black), + borderRadius: BorderRadius.circular(8), + ), + enabledBorder: OutlineInputBorder( + borderSide: const BorderSide(color: Colors.black), + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const SizedBox(height: 12), + + // (B) 비밀번호 입력 + SizedBox( + width: 300, + child: TextField( + controller: passwordController, + obscureText: true, + decoration: InputDecoration( + labelText: 'PW', + border: OutlineInputBorder( + borderSide: const BorderSide(color: Colors.black), + borderRadius: BorderRadius.circular(8), + ), + enabledBorder: OutlineInputBorder( + borderSide: const BorderSide(color: Colors.black), + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + + // (C) 로그인 에러 + if (loginErrorMessage.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + loginErrorMessage, + style: const TextStyle(color: Colors.red), + ), + ), + + const SizedBox(height: 8), + + // (D) 자동로그인 체크박스 + SizedBox( + width: 300, + child: Row( + children: [ + Checkbox( + value: autoLogin, + onChanged: (val) { + setState(() { + autoLogin = val ?? false; + }); + }, + ), + const Text('자동로그인', style: TextStyle(color: Colors.black)), + ], + ), + ), + + // (E) 로그인 버튼 + SizedBox( + width: 300, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + side: const BorderSide(color: Colors.black), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: _loginWithIdPw, + child: const Text('로그인'), + ), + ), + + // (F) ID/PW 찾기, 회원가입 + const SizedBox(height: 8), + TextButton( + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => const IdFindingPage())); + }, + child: const Text('ID 찾기', style: TextStyle(color: Colors.black)), + ), + TextButton( + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => const PwFindingPage())); + }, + child: const Text('PW 찾기', style: TextStyle(color: Colors.black)), + ), + TextButton( + onPressed: () { + Navigator.push(context, MaterialPageRoute(builder: (_) => const SignUpPage())); + }, + child: const Text('회원가입', style: TextStyle(color: Colors.black)), + ), + + const SizedBox(height: 24), + const Divider(height: 1, color: Colors.black), + const SizedBox(height: 24), + + // ───────────────────────────────────────── + // 2. 구글 로그인 / 회원가입 + // ───────────────────────────────────────── + const Text( + '구글 계정', + style: TextStyle(fontSize: 20, color: Colors.black, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + + // (a) 구글 로그인 + SizedBox( + width: 300, + child: ElevatedButton.icon( + icon: Container( + width: 24, + height: 24, + decoration: const BoxDecoration( + image: DecorationImage( + image: AssetImage('assets/images/icons8-google-logo-48.png'), + fit: BoxFit.contain, + ), + ), + ), + label: const Text( + 'Google 로그인', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + side: const BorderSide(color: Colors.black), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: _googleLogin, + ), + ), + const SizedBox(height: 12), + + // (b) 구글 회원가입 + SizedBox( + width: 300, + child: ElevatedButton.icon( + icon: Container( + width: 24, + height: 24, + decoration: const BoxDecoration( + image: DecorationImage( + image: AssetImage('assets/images/icons8-google-logo-48.png'), + fit: BoxFit.contain, + ), + ), + ), + label: const Text( + 'Google 회원가입', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + side: const BorderSide(color: Colors.black), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + onPressed: _googleSignUp, + ), + ), + ], ), ), ), - const SizedBox(height: 16), - TextField( - controller: passwordController, - obscureText: true, - decoration: InputDecoration( - labelText: 'PW', - labelStyle: const TextStyle(color: Colors.black), - border: const OutlineInputBorder(), - focusedBorder: const OutlineInputBorder( - borderSide: BorderSide(color: Colors.black, width: 2.0), - ), - ), - ), - if (loginErrorMessage.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Text( - loginErrorMessage, - style: const TextStyle(color: Colors.red), - ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Checkbox( - value: autoLogin, - onChanged: (bool? value) { - setState(() { - autoLogin = value ?? false; - }); - }, - ), - const Text('자동로그인', style: TextStyle(color: Colors.black)), - ], - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _login, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.black, - foregroundColor: Colors.white, - ), - child: const Text('로그인'), - ), - const SizedBox(height: 16), - TextButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const IdFindingPage()), - ); - }, - child: const Text('ID 찾기', style: TextStyle(color: Colors.black)), - ), - TextButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const PwFindingPage()), - ); - }, - child: const Text('PW 찾기', style: TextStyle(color: Colors.black)), - ), - TextButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const SignUpPage()), - ); - }, - child: const Text('회원가입', style: TextStyle(color: Colors.black)), - ), - const SizedBox(height: 16), - // 광고 영역 + ), + + // (2) 하단 광고 영역 + if (_isBannerReady && _bannerAd != null) Container( + width: _bannerAd!.size.width.toDouble(), + height: _bannerAd!.size.height.toDouble(), + alignment: Alignment.center, + child: AdWidget(ad: _bannerAd!), + ) + else + Container( + width: 300, height: 50, - color: Colors.grey[300], - child: const Center(child: Text('광고 영역', style: TextStyle(color: Colors.black))), + color: Colors.grey.shade400, + alignment: Alignment.center, + child: const Text( + '광고 로딩중', + style: TextStyle(color: Colors.black), + ), ), - ], + ], ), ), + + // (2) 로딩 중일 때 오버레이 표시 + if (_isLoading) + Container( + width: double.infinity, + height: double.infinity, + color: Colors.black54, // 반투명 배경 + alignment: Alignment.center, + child: const CircularProgressIndicator(color: Colors.white), + ), + ] + ) ); } } - - - diff --git a/lib/views/login/pw_finding_page.dart b/lib/views/login/pw_finding_page.dart index 1a0fa96..efdd017 100644 --- a/lib/views/login/pw_finding_page.dart +++ b/lib/views/login/pw_finding_page.dart @@ -6,6 +6,12 @@ import 'login_page.dart'; // 로그인 페이지 임포트 추가 import 'signup_page.dart'; // 회원가입 페이지 임포트 추가 import 'id_finding_page.dart'; // ID 찾기 페이지 임포트 추가 +// 모바일 광고 +import 'package:google_mobile_ads/google_mobile_ads.dart'; + +// 설정 +import '../../config/config.dart'; + class PwFindingPage extends StatefulWidget { const PwFindingPage({Key? key}) : super(key: key); @@ -19,6 +25,11 @@ class _PwFindingPageState extends State { String emailErrorMessage = ''; // 이메일 오류 메시지 String idErrorMessage = ''; // ID 오류 메시지 + /// (1) 광고 배너 관련 변수 + BannerAd? _bannerAd; + bool _isBannerReady = false; // 광고 로드 완료 여부 + String adUnitId = Config.testAdUnitId; + Future _findPassword(String id, String email) async { // PW 찾기 요청 처리 print('PW 찾기 요청: ID: $id, 이메일: $email'); // 요청 시 출력 @@ -113,6 +124,36 @@ class _PwFindingPageState extends State { ); } + @override + void initState() { + super.initState(); + _initBannerAd(); + } + + void _initBannerAd() { + _bannerAd = BannerAd( + // 실제/테스트 배너 광고 단위 ID + adUnitId: adUnitId, + size: AdSize.banner, + request: const AdRequest(), + listener: BannerAdListener( + onAdLoaded: (ad) { + setState(() => _isBannerReady = true); + }, + onAdFailedToLoad: (ad, error) { + ad.dispose(); + }, + ), + ); + _bannerAd?.load(); + } + + @override + void dispose() { + _bannerAd?.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -213,6 +254,16 @@ class _PwFindingPageState extends State { ], ), ), + + // (3) 하단 광고 영역 + bottomNavigationBar: _isBannerReady && _bannerAd != null + ? Container( + color: Colors.white, + width: _bannerAd!.size.width.toDouble(), + height: _bannerAd!.size.height.toDouble(), + child: AdWidget(ad: _bannerAd!), + ) + : SizedBox.shrink(), // 로딩 전엔 빈 공간 or 원하는 위젯 ); } } \ No newline at end of file diff --git a/lib/views/login/signup_page.dart b/lib/views/login/signup_page.dart index 3b360b3..913f403 100644 --- a/lib/views/login/signup_page.dart +++ b/lib/views/login/signup_page.dart @@ -21,7 +21,7 @@ class _SignUpPageState extends State { // 유효성 검사 bool _isUsernameValid(String username) => RegExp(r'^(?![0-9])[A-Za-z0-9]{6,20}$').hasMatch(username); - bool _isPasswordValidPattern(String password) => RegExp(r'^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,20}$').hasMatch(password); + bool _isPasswordValidPattern(String password) => RegExp(r"""^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d!@#$%^&*()_\-+=~`{}\[\]|\\:;\"'<>,.?/]{8,20}$""").hasMatch(password); bool _isEmailValid(String email) => RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(email); bool _isNicknameValid(String nickname) => RegExp(r'^[A-Za-z가-힣0-9]{2,20}$').hasMatch(nickname); diff --git a/lib/views/room/finish_private_page.dart b/lib/views/room/finish_private_page.dart index e3e8089..50a0dc0 100644 --- a/lib/views/room/finish_private_page.dart +++ b/lib/views/room/finish_private_page.dart @@ -1,48 +1,325 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'main_page.dart'; +import '../../plugins/api.dart'; +import '../../dialogs/response_dialog.dart'; +import '../../dialogs/room_setting_finish_dialog.dart'; +import '../../dialogs/user_info_finish_dialog.dart'; -class FinishPrivatePage extends StatelessWidget { +class FinishPrivatePage extends StatefulWidget { final int roomSeq; + final bool fromPlayingPage; // 만약 대기/진행중에서 넘어온 경우 => 뒤로가기 시 메인으로 const FinishPrivatePage({ Key? key, required this.roomSeq, + this.fromPlayingPage = false, }) : super(key: key); @override - Widget build(BuildContext context) { - // 간단한 종료 안내 화면 - return Scaffold( - backgroundColor: Colors.white, - appBar: AppBar( - title: const Text('게임 종료 (개인전)', style: TextStyle(color: Colors.white)), - backgroundColor: Colors.black, - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios, color: Colors.white), - onPressed: () { - Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const MainPage())); - }, - ), - ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + State createState() => _FinishPrivatePageState(); +} + +class _FinishPrivatePageState extends State { + bool _isLoading = true; + + Map _roomInfo = {}; // 서버에서 받은 room_info + // 전체 user_info Map + // userSeq → { user_seq, nickname, participant_type, score, ... } + Map _userMap = {}; + + // 리스트로 만든 (관리자 제외) 참가자 목록 (점수 내림차순) + List> _playerList = []; + // 별도 사회자(ADMIN) 목록 (개인전이라 1명이거나 없을 수 있음) + List> _adminList = []; + + String _roomTitle = ''; + DateTime? _startDt; + DateTime? _endDt; + int _masterUserSeq = 0; // 방장 user_seq (ADMIN과는 별개) + + @override + void initState() { + super.initState(); + _fetchFinishRoomInfo(); + } + + /// (A) 서버에서 종료된 방 정보를 가져옴 + Future _fetchFinishRoomInfo() async { + setState(() => _isLoading = true); + try { + final body = {"room_seq": "${widget.roomSeq}"}; + final response = await Api.serverRequest( + uri: '/room/score/get/finish/room/info', + body: body, + ); + if (response['result'] == 'OK') { + final resp = response['response'] ?? {}; + if (resp['result'] == 'OK') { + final data = resp['data'] ?? {}; + + final rInfo = data['room_info'] ?? {}; + final uInfo = data['user_info'] ?? {}; + + final rTitle = (rInfo['room_title'] ?? '') as String; + final mSeq = (rInfo['master_user_seq'] ?? 0) as int; + final sdt = rInfo['start_dt']; + final edt = rInfo['end_dt']; + + setState(() { + _roomInfo = rInfo; + _userMap = uInfo; + _roomTitle = rTitle.isNotEmpty ? rTitle : '종료된 방(개인전)'; + _masterUserSeq = mSeq; + + if (sdt != null && sdt is String && sdt.contains('T')) { + _startDt = DateTime.tryParse(sdt); + } + if (edt != null && edt is String && edt.contains('T')) { + _endDt = DateTime.tryParse(edt); + } + }); + + // userInfo -> List 변환 + final List> tempList = []; + uInfo.forEach((_, val) { + // val: { user_seq, participant_type, nickname, score, ... } + tempList.add(Map.from(val)); + }); + + // (1) 사회자(ADMIN) 분리 + final adminList = tempList.where((u) { + final pType = (u['participant_type'] ?? '').toString().toUpperCase(); + return pType == 'ADMIN'; + }).toList(); + + // (2) 플레이어 목록 (ADMIN 제외) & 점수 내림차순 + final playerList = tempList.where((u) { + final pType = (u['participant_type'] ?? '').toString().toUpperCase(); + return pType != 'ADMIN'; + }).toList(); + + // 점수 내림차순 정렬 + playerList.sort((a, b) { + final sa = (a['score'] ?? 0) as int; + final sb = (b['score'] ?? 0) as int; + return sb.compareTo(sa); // 내림차순 + }); + + setState(() { + _adminList = adminList; + _playerList = playerList; + _isLoading = false; + }); + } else { + final msgTitle = resp['response_info']?['msg_title'] ?? '오류'; + final msgContent = resp['response_info']?['msg_content'] ?? '데이터 조회 실패'; + showResponseDialog(context, msgTitle, msgContent); + } + } else { + showResponseDialog(context, '실패', '서버 통신 오류'); + } + } catch (e) { + showResponseDialog(context, '오류', '$e'); + } finally { + setState(() => _isLoading = false); + } + } + + /// (B) 뒤로가기 + Future _onWillPop() async { + if (widget.fromPlayingPage) { + // 진행중/대기중 등에서 넘어왔다면 => 메인으로 + Navigator.popUntil(context, (route) => route.isFirst); + } else { + // 검색 등에서 왔으면 => 한 단계 pop + Navigator.pop(context); + } + return false; + } + + /// (C) 방 정보보기 모달 (읽기 전용) + Future _openRoomSettingDialog() async { + if (_roomInfo.isEmpty) return; + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => RoomSettingFinishDialog(roomInfo: _roomInfo), + ); + } + + /// (D) 게임 진행 시간 표시 + Widget _buildGameTimeWidget() { + if (_startDt == null || _endDt == null) { + return const Text('게임 진행 시간: 00:00', style: TextStyle(fontSize: 14)); + } + final dur = _endDt!.difference(_startDt!); + final hh = dur.inHours.toString().padLeft(2, '0'); + final mm = dur.inMinutes.remainder(60).toString().padLeft(2, '0'); + return Text('게임 진행 시간: $hh:$mm', style: const TextStyle(fontSize: 14)); + } + + /// (E) 플레이어 목록 표시 (점수 내림차순) + /// - 1등/2등/3등 금/은/동 메달 + Widget _buildPlayerItem(Map user, int index) { + final score = (user['score'] ?? 0) as int; + final nickname = user['nickname'] ?? '유저'; + final profileImg = user['profile_img'] ?? ''; + final userSeq = user['user_seq'] ?? 0; + + Widget medal = const SizedBox(); + if (index == 0) { + medal = const Text('🥇 ', style: TextStyle(fontSize: 16)); + } else if (index == 1) { + medal = const Text('🥈 ', style: TextStyle(fontSize: 16)); + } else if (index == 2) { + medal = const Text('🥉 ', style: TextStyle(fontSize: 16)); + } + + return GestureDetector( + onTap: () => _onTapUser(user), + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + child: Row( children: [ - const Text('게임이 종료되었습니다.', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () { - // 메인 페이지로 이동 - Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const MainPage())); - }, - style: ElevatedButton.styleFrom(backgroundColor: Colors.black), - child: const Text('메인으로', style: TextStyle(color: Colors.white)), + medal, + // 프로필 + Container( + width: 36, height: 36, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.black54), + ), + child: ClipOval( + child: (profileImg.isNotEmpty) + ? Image.network( + 'https://eldsoft.com:8097/images$profileImg', + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Center(child: Text('ERR')), + ) + : const Center(child: Text('No\nImg', textAlign: TextAlign.center, style: TextStyle(fontSize: 10))), + ), ), + const SizedBox(width: 8), + Expanded( + child: Text(nickname, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), + ), + Text('$score 점', style: const TextStyle(fontSize: 14)), ], ), ), ); } + + /// 사회자 목록 (보통 1명 예상) + Widget _buildAdminItem(Map admin) { + final nickname = admin['nickname'] ?? '사회자'; + final profileImg = admin['profile_img'] ?? ''; + return GestureDetector( + onTap: () => _onTapUser(admin), + child: Row( + children: [ + Container( + width: 36, height: 36, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.deepPurple), + ), + child: ClipOval( + child: (profileImg.isNotEmpty) + ? Image.network( + 'https://eldsoft.com:8097/images$profileImg', + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Center(child: Text('ERR')), + ) + : const Center(child: Text('No\nImg', textAlign: TextAlign.center, style: TextStyle(fontSize: 10))), + ), + ), + Text(nickname, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.deepPurple)), + ], + ), + ); + } + + /// 사용자 클릭 -> 새 유저 정보 모달 + Future _onTapUser(Map userData) async { + // user_info_finish_dialog.dart (새 모달) + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => UserInfoFinishDialog(userData: userData), + ); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: _onWillPop, + child: Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.black, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.white), + onPressed: _onWillPop, + ), + title: Text(_roomTitle, style: const TextStyle(color: Colors.white)), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // (A) 상단: [방 정보 보기] 버튼 + 진행 시간 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // 방번호는 숨기라 했으므로 제거 + ElevatedButton( + onPressed: _openRoomSettingDialog, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + side: const BorderSide(color: Colors.black, width: 1), + ), + child: const Text('방 정보 보기', style: TextStyle(color: Colors.black)), + ), + _buildGameTimeWidget(), + ], + ), + const SizedBox(height: 16), + + // (B) 사회자 목록 + if (_adminList.isNotEmpty) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all(color: Colors.deepPurple), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Text('사회자: ', style: TextStyle(fontSize: 14, color: Colors.deepPurple)), + ..._adminList.map(_buildAdminItem), + ], + ), + ), + const SizedBox(height: 16), + ], + + // (C) 참가자 목록 + ListView.builder( + primary: false, + shrinkWrap: true, + itemCount: _playerList.length, + itemBuilder: (ctx, i) => _buildPlayerItem(_playerList[i], i), + ), + ], + ), + ), + ), + ); + } } diff --git a/lib/views/room/finish_team_page.dart b/lib/views/room/finish_team_page.dart index 243b37d..1d5d78c 100644 --- a/lib/views/room/finish_team_page.dart +++ b/lib/views/room/finish_team_page.dart @@ -1,47 +1,399 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'main_page.dart'; -class FinishTeamPage extends StatelessWidget { +import '../../plugins/api.dart'; +import '../../dialogs/response_dialog.dart'; +import '../../dialogs/room_setting_finish_dialog.dart'; +import '../../dialogs/user_info_finish_dialog.dart'; + +class FinishTeamPage extends StatefulWidget { final int roomSeq; + final bool fromPlayingPage; // 진행중에서 넘어왔는지 여부 const FinishTeamPage({ Key? key, required this.roomSeq, + this.fromPlayingPage = false, }) : super(key: key); @override - Widget build(BuildContext context) { - // 간단한 종료 안내 - return Scaffold( - backgroundColor: Colors.white, - appBar: AppBar( - title: const Text('게임 종료 (팀전)', style: TextStyle(color: Colors.white)), - backgroundColor: Colors.black, - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios, color: Colors.white), - onPressed: () { - Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const MainPage())); - }, - ), + State createState() => _FinishTeamPageState(); +} + +class _FinishTeamPageState extends State { + bool _isLoading = true; + + Map _roomInfo = {}; + Map _userMap = {}; + List> _userList = []; + + String _roomTitle = ''; + int _masterUserSeq = 0; + DateTime? _startDt; + DateTime? _endDt; + + // 팀별 [ { user }, { user } ... ] + Map>> _teamMap = {}; + // 팀별 점수 + Map _teamScoreMap = {}; + + // 별도 사회자 목록 + List> _adminList = []; + + @override + void initState() { + super.initState(); + _fetchFinishRoomInfo(); + } + + Future _fetchFinishRoomInfo() async { + setState(() => _isLoading = true); + try { + final body = {"room_seq": "${widget.roomSeq}"}; + final response = await Api.serverRequest( + uri: '/room/score/get/finish/room/info', + body: body, + ); + if (response['result'] == 'OK') { + final resp = response['response'] ?? {}; + if (resp['result'] == 'OK') { + final data = resp['data'] ?? {}; + final rInfo = data['room_info'] ?? {}; + final uInfo = data['user_info'] ?? {}; + + final rTitle = (rInfo['room_title'] ?? '') as String; + final mSeq = (rInfo['master_user_seq'] ?? 0) as int; + final sdt = rInfo['start_dt']; + final edt = rInfo['end_dt']; + + setState(() { + _roomInfo = rInfo; + _userMap = uInfo; + _roomTitle = rTitle.isNotEmpty ? rTitle : '종료된 팀전'; + _masterUserSeq = mSeq; + + if (sdt != null && sdt is String && sdt.contains('T')) { + _startDt = DateTime.tryParse(sdt); + } + if (edt != null && edt is String && edt.contains('T')) { + _endDt = DateTime.tryParse(edt); + } + }); + + // userList + final List> tempList = []; + uInfo.forEach((_, val) { + tempList.add(Map.from(val)); + }); + + // (1) 사회자(ADMIN) 분리 + final adminList = tempList.where((u) { + final pType = (u['participant_type'] ?? '').toString().toUpperCase(); + return pType == 'ADMIN'; + }).toList(); + + // (2) 일반 참가자 + final players = tempList.where((u) { + final pType = (u['participant_type'] ?? '').toString().toUpperCase(); + return pType != 'ADMIN'; + }).toList(); + + // (3) 팀명별 분류 + 점수 합 + final Map>> tMap = {}; + final Map tScoreMap = {}; + + for (var user in players) { + final tName = (user['team_name'] ?? 'WAIT').toString().toUpperCase(); + if (tName == 'WAIT') continue; // 팀 미배정 + tMap.putIfAbsent(tName, () => []); + tMap[tName]!.add(user); + } + + // 팀별 점수 + tMap.forEach((team, mems) { + int sumScore = 0; + // mems 내림차순 정렬 + mems.sort((a, b) { + final sa = (a['score'] ?? 0) as int; + final sb = (b['score'] ?? 0) as int; + return sb.compareTo(sa); + }); + for (var m in mems) { + sumScore += (m['score'] ?? 0) as int; + } + tScoreMap[team] = sumScore; + }); + + // (4) 팀들을 점수 순으로 정렬 + final sortedTeams = tScoreMap.keys.toList(); + sortedTeams.sort((a, b) => tScoreMap[b]!.compareTo(tScoreMap[a]!)); + + // 정렬된 결과를 새 맵에 담음 + final Map>> finalTeamMap = {}; + final Map finalScoreMap = {}; + + for (var t in sortedTeams) { + finalTeamMap[t] = tMap[t]!; + finalScoreMap[t] = tScoreMap[t]!; + } + + setState(() { + _adminList = adminList; + _userList = tempList; // 전체 필요하면 보관 + _teamMap = finalTeamMap; + _teamScoreMap = finalScoreMap; + _isLoading = false; + }); + } else { + final msgTitle = resp['response_info']?['msg_title'] ?? '오류'; + final msgContent = resp['response_info']?['msg_content'] ?? '데이터 조회 실패'; + showResponseDialog(context, msgTitle, msgContent); + } + } else { + showResponseDialog(context, '실패', '서버 통신 오류'); + } + } catch (e) { + showResponseDialog(context, '오류', '$e'); + } finally { + setState(() => _isLoading = false); + } + } + + Future _onWillPop() async { + if (widget.fromPlayingPage) { + Navigator.popUntil(context, (route) => route.isFirst); + } else { + Navigator.pop(context); + } + return false; + } + + Future _openRoomSettingDialog() async { + if (_roomInfo.isEmpty) return; + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => RoomSettingFinishDialog(roomInfo: _roomInfo), + ); + } + + Widget _buildGameTimeWidget() { + if (_startDt == null || _endDt == null) { + return const Text('게임 진행 시간: 00:00', style: TextStyle(fontSize: 14)); + } + final dur = _endDt!.difference(_startDt!); + final hh = dur.inHours.toString().padLeft(2, '0'); + final mm = dur.inMinutes.remainder(60).toString().padLeft(2, '0'); + return Text('게임 진행 시간: $hh:$mm', style: const TextStyle(fontSize: 14)); + } + + /// 팀 박스 (팀별 점수 표시 + 1등2등3등 금/은/동 배경) + Widget _buildTeamBox(String teamName, int index) { + final members = _teamMap[teamName] ?? []; + final tScore = _teamScoreMap[teamName] ?? 0; + + // 1등/2등/3등 팀 -> 배경색 + Color bgColor = Colors.white; + if (index == 0) { + bgColor = const Color(0xFFFFF9C4); // 약간 금색 계열 예: amber.shade100 + } else if (index == 1) { + bgColor = const Color(0xFFE0E0E0); // 은색(회색) 계열 + } else if (index == 2) { + bgColor = const Color(0xFFFFE0B2); // 동색(주황~갈색 계열) + } + + return Container( + margin: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: bgColor, + border: Border.all(color: Colors.black), + borderRadius: BorderRadius.circular(8), ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('게임이 종료되었습니다.', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), - const SizedBox(height: 16), - ElevatedButton( - onPressed: () { - // 메인페이지 - Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const MainPage())); - }, - style: ElevatedButton.styleFrom(backgroundColor: Colors.black), - child: const Text('메인으로', style: TextStyle(color: Colors.white)), + child: Column( + children: [ + // 상단 바 + Container( + color: Colors.black, + width: double.infinity, + padding: const EdgeInsets.all(8), + child: Center( + child: Text( + '$teamName 팀 (점수: $tScore)', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + ), ), + ), + // 팀 멤버 + Container( + padding: const EdgeInsets.all(8), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: members.map((m) => _buildTeamMember(m)).toList(), + ), + ), + ), + ], + ), + ); + } + + /// 팀 멤버 표시 + Widget _buildTeamMember(Map user) { + final score = (user['score'] ?? 0) as int; + final nickname = user['nickname'] ?? '유저'; + final profileImg = user['profile_img'] ?? ''; + + return GestureDetector( + onTap: () => _onTapUser(user), + child: Container( + width: 60, + margin: const EdgeInsets.only(right: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('$score', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 2), + Container( + width: 30, height: 30, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.black), + ), + child: ClipOval( + child: Image.network( + 'https://eldsoft.com:8097/images$profileImg', + fit: BoxFit.cover, + errorBuilder: (ctx, err, st) => const Center( + child: Text('ERR', style: TextStyle(fontSize: 8)), + ), + ), + ), + ), + const SizedBox(height: 2), + Text(nickname, style: const TextStyle(fontSize: 11), overflow: TextOverflow.ellipsis), ], ), ), ); } + + /// 팀전 사회자 목록 (ADMIN) + Widget _buildAdminList() { + final adminList = _adminList; + if (adminList.isEmpty) return const SizedBox(); + + return Container( + width: double.infinity, + margin: const EdgeInsets.only(top: 16), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.deepPurple), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + Container( + color: Colors.black, + width: double.infinity, + padding: const EdgeInsets.all(8), + child: const Center( + child: Text('사회자', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + ), + ), + ListView.builder( + shrinkWrap: true, + primary: false, + itemCount: adminList.length, + itemBuilder: (ctx, i) { + final user = adminList[i]; + return _buildAdminItem(user); + }, + ), + ], + ), + ); + } + + Widget _buildAdminItem(Map user) { + final nickname = user['nickname'] ?? '사회자'; + final profileImg = user['profile_img'] ?? ''; + + return ListTile( + onTap: () => _onTapUser(user), + leading: CircleAvatar( + backgroundColor: Colors.white, + backgroundImage: (profileImg.isNotEmpty) + ? NetworkImage('https://eldsoft.com:8097/images$profileImg') + : null, + child: (profileImg.isEmpty) + ? const Text('NoImg', style: TextStyle(fontSize: 10)) + : null, + ), + title: Text(nickname), + subtitle: const Text('사회자'), + ); + } + + Future _onTapUser(Map userData) async { + // 새 유저 정보 모달 + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => UserInfoFinishDialog(userData: userData), + ); + } + + @override + Widget build(BuildContext context) { + final teamNames = _teamMap.keys.toList(); + // 이미 점수순으로 정렬됨 + + return WillPopScope( + onWillPop: _onWillPop, + child: Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.black, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.white), + onPressed: _onWillPop, + ), + title: Text(_roomTitle, style: const TextStyle(color: Colors.white)), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // 방 정보 + 시간 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ElevatedButton( + onPressed: _openRoomSettingDialog, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + side: const BorderSide(color: Colors.black), + ), + child: const Text('방 정보 보기', style: TextStyle(color: Colors.black)), + ), + _buildGameTimeWidget(), + ], + ), + const SizedBox(height: 16), + + // 팀 박스들 + for (int i = 0; i < teamNames.length; i++) + _buildTeamBox(teamNames[i], i), + + // 사회자 목록 + _buildAdminList(), + ], + ), + ), + ), + ); + } } diff --git a/lib/views/room/main_page.dart b/lib/views/room/main_page.dart index d87c2bd..19864c2 100644 --- a/lib/views/room/main_page.dart +++ b/lib/views/room/main_page.dart @@ -1,12 +1,24 @@ import 'package:flutter/material.dart'; +import 'package:firebase_database/firebase_database.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:google_mobile_ads/google_mobile_ads.dart'; // ★ AdMob 패키지 +// 기타 import... import '../../dialogs/settings_dialog.dart'; import 'create_room_page.dart'; - -// 새로 추가할 페이지들 import 'room_search_home_page.dart'; +// 진행중 방 +import 'playing_private_page.dart'; +import 'playing_team_page.dart'; + +// 임시: 서버 API & 모달 +import '../../plugins/api.dart'; +import '../../dialogs/response_dialog.dart'; + +// 뒤로가기 +import 'package:fluttertoast/fluttertoast.dart'; // 뒤로가기 안내 문구에 Toast 등 사용 + class MainPage extends StatefulWidget { const MainPage({Key? key}) : super(key: key); @@ -15,25 +27,151 @@ class MainPage extends StatefulWidget { } class _MainPageState extends State { - bool _isBackButtonVisible = false; // 뒤로가기 버튼 상태 + /// (1) 광고 배너 관련 변수 + BannerAd? _bannerAd; + bool _isBannerReady = false; // 광고 로드 완료 여부 + + // 뒤로가기 처리 + DateTime? _lastPressedTime; + + // 예: 2초 이내로 뒤로가기를 한 번 더 누르면 종료 + static const _exitDuration = Duration(seconds: 2); @override void initState() { super.initState(); - _isBackButtonVisible = false; + + // (A) 메인페이지 들어올 때 전체 FRD 연결 해제 + FirebaseDatabase.instance.goOffline(); + + // (B) 강제 종료 여부 확인 후 재입장 시도 + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkForcedExitStatus(); + }); + + // (C) 배너 광고 초기화 + _initBannerAd(); + } + + /// 배너 광고 초기화 + void _initBannerAd() { + _bannerAd = BannerAd( + size: AdSize.banner, // 일반 배너 사이즈 + // adUnitId: 'ca-app-pub-3940256099942544/6300978111' (테스트용) + adUnitId: 'ca-app-pub-3940256099942544/6300978111', // 예시: 구글 테스트 배너ID + listener: BannerAdListener( + onAdLoaded: (Ad ad) { + setState(() => _isBannerReady = true); + debugPrint('배너 광고 로드 완료'); + }, + onAdFailedToLoad: (Ad ad, LoadAdError err) { + debugPrint('배너 광고 로드 실패: $err'); + ad.dispose(); + }, + ), + request: const AdRequest(), + ); + + // load() 호출로 광고 요청 + _bannerAd?.load(); + } + + @override + void dispose() { + _bannerAd?.dispose(); // ★ 광고 자원 해제 + super.dispose(); + } + + Future _onWillPop() async { + final now = DateTime.now(); + if (_lastPressedTime == null || + now.difference(_lastPressedTime!) > _exitDuration) { + // 첫 번째 뒤로가기 누름 or 이전 누름이 오래 전 + _lastPressedTime = now; + + // 안내 문구 띄우기 (Toast 예시) + Fluttertoast.showToast( + msg: '한 번 더 누르면 앱이 종료됩니다.', + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM, + ); + return false; // 페이지 pop 하지 않음 + } + // 2초 이내에 뒤로가기 두 번째 누름 → 앱 종료 허용 + return true; // pop 허용 (Scaffold 밖으로 벗어남, 결과적으로 앱 종료) + } + + /// (B) 서버에 "강제 종료 여부" 확인 → 방이 있으면 재입장 + Future _checkForcedExitStatus() async { + try { + final Map requestBody = {}; + final response = await Api.serverRequest( + uri: '/room/score/enter/running/room', + body: requestBody, + ); + + if (response['result'] == 'OK') { + final resp = response['response'] ?? {}; + if (resp['result'] == 'OK') { + final data = resp['data'] ?? {}; + final forceExitYn = (data['force_exit_yn'] ?? 'N').toString().toUpperCase(); + if (forceExitYn == 'Y') { + final int roomSeq = data['room_seq'] ?? 0; + final String roomType = (data['room_type_name'] ?? '').toString().toUpperCase(); + final String roomTitle = (data['room_title'] ?? '').toString(); + + // (1) 재입장 전 FRD 연결 복원 + FirebaseDatabase.instance.goOnline(); + + showResponseDialog(context, '게임 재입장', '강제 종료 된 게임에 재입장 합니다.'); + + // (2) 방 타입에 따라 pushReplacement + if (roomType == 'TEAM') { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => PlayingTeamPage( + roomSeq: roomSeq, + roomTitle: roomTitle.isNotEmpty ? roomTitle : '재입장 (팀전)', + ), + ), + ); + } else { + // 개인전 + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => PlayingPrivatePage( + roomSeq: roomSeq, + roomTitle: roomTitle.isNotEmpty ? roomTitle : '재입장 (개인전)', + ), + ), + ); + } + } + } else { + final msgTitle = resp['response_info']?['msg_title'] ?? '오류'; + final msgContent = resp['response_info']?['msg_content'] ?? '강제 종료 여부 확인 실패'; + showResponseDialog(context, msgTitle, msgContent); + } + } else { + showResponseDialog(context, '서버 오류', '강제 종료 여부 확인에 실패했습니다.'); + } + } catch (e) { + showResponseDialog(context, '서버 오류', '강제 종료 여부 확인에 실패했습니다.'); + } } @override Widget build(BuildContext context) { - return Scaffold( - // (A) 전체 배경 흰색 → 텍스트/버튼은 블랙 위주 + return WillPopScope( + onWillPop: _onWillPop, + child: Scaffold( backgroundColor: Colors.white, - - // (B) 상단 AppBar: 블랙 배경, 흰색 아이콘 appBar: AppBar( backgroundColor: Colors.black, elevation: 0, - automaticallyImplyLeading: false, // 뒤로가기 버튼 자동생성 비활성 + automaticallyImplyLeading: false, // 뒤로가기 버튼 X title: const Text( 'ALLSCORE', style: TextStyle(color: Colors.white), @@ -42,17 +180,16 @@ class _MainPageState extends State { IconButton( icon: const Icon(Icons.settings, color: Colors.white), onPressed: () { - showSettingsDialog(context); // 설정 모달 호출 + showSettingsDialog(context); }, ), ], ), - // (C) 본문: 위아래 공간 분배 body: Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - // 중간 영역(“방 만들기” / “참여하기”) + // 중앙 버튼 Expanded( child: Padding( padding: const EdgeInsets.all(16.0), @@ -60,7 +197,6 @@ class _MainPageState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - // (C1) 방 만들기 버튼 _buildBlackWhiteButton( label: '방만들기', onTap: () { @@ -71,10 +207,9 @@ class _MainPageState extends State { }, ), const SizedBox(width: 16), - // (C2) 참여하기 버튼 => RoomSearchHomePage로 이동 _buildBlackWhiteButton( label: '참여하기', - onTap: () async { + onTap: () { Navigator.push( context, MaterialPageRoute(builder: (_) => const RoomSearchHomePage()), @@ -87,42 +222,38 @@ class _MainPageState extends State { ), ), - // (D) 광고 영역 - Container( - color: Colors.white, - padding: const EdgeInsets.only(bottom: 24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - height: 50, - width: 300, - color: Colors.grey.shade400, - child: const Center( - child: Text( - '구글 광고', - style: TextStyle(color: Colors.black), - ), - ), - ), - ], + // 광고 영역 교체 + // (기존) Container(...) 대신 _bannerAd 위젯 사용 + if (_isBannerReady && _bannerAd != null) + Container( + width: _bannerAd!.size.width.toDouble(), + height: _bannerAd!.size.height.toDouble(), + alignment: Alignment.center, + child: AdWidget(ad: _bannerAd!), + ) + else + // 로딩중이거나 오류시 대체영역 + Container( + width: 300, + height: 50, + color: Colors.grey.shade400, + alignment: Alignment.center, + child: const Text( + '광고 로딩중', + style: TextStyle(color: Colors.black), + ), ), - ), - // (E) 임시 버튼: 방 생성 완료 이동 + // 디버그용 임시버튼 Center( child: OutlinedButton( onPressed: () { - // 예시로 팀전 대기방(15번 방) 이동 - // 실무에서는 제외하거나 debugging용 - // (아직 남겨두고 싶다면 유지) + // TODO }, style: OutlinedButton.styleFrom( backgroundColor: Colors.white, side: const BorderSide(color: Colors.black54, width: 1), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(40), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(40)), padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 60), ), child: const Text( @@ -131,13 +262,13 @@ class _MainPageState extends State { ), ), ), - const SizedBox(height: 16), - ], + const SizedBox(height: 16), + ], + ), ), ); } - /// 블랙 라인 + 흰 배경 스타일의 버튼 Widget _buildBlackWhiteButton({ required String label, required VoidCallback onTap, @@ -149,9 +280,7 @@ class _MainPageState extends State { foregroundColor: Colors.black, side: const BorderSide(color: Colors.black, width: 1), padding: const EdgeInsets.symmetric(vertical: 36, horizontal: 32), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), ), child: Text(label, style: const TextStyle(color: Colors.black)), ); diff --git a/lib/views/room/playing_private_page.dart b/lib/views/room/playing_private_page.dart index 7b467f8..3ed262b 100644 --- a/lib/views/room/playing_private_page.dart +++ b/lib/views/room/playing_private_page.dart @@ -3,14 +3,11 @@ import 'package:firebase_database/firebase_database.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'main_page.dart'; -import 'finish_private_page.dart'; // (★) 개인전 종료화면 +import 'finish_private_page.dart'; // 게임 종료 후 이동할 페이지 import '../../plugins/api.dart'; import '../../dialogs/response_dialog.dart'; - -// 점수 수정 모달 -import '../../dialogs/score_edit_dialog.dart'; -// 기존 사용자 정보 모달 (관리자/방장X) -import '../../dialogs/user_info_basic_dialog.dart'; +import '../../dialogs/score_edit_dialog.dart'; // 점수 수정 모달 +import '../../dialogs/user_info_basic_dialog.dart'; // 일반 유저 정보 모달 class PlayingPrivatePage extends StatefulWidget { final int roomSeq; @@ -27,7 +24,6 @@ class PlayingPrivatePage extends StatefulWidget { } class _PlayingPrivatePageState extends State { - // FRD late DatabaseReference _roomRef; Stream? _roomStream; @@ -35,21 +31,20 @@ class _PlayingPrivatePageState extends State { String roomTitle = ''; int myScore = 0; - - // (ADMIN 제외) 플레이어 목록 List> _scoreList = []; bool _isLoading = true; - - // 내 user_seq String mySeq = '0'; - // userListMap + // userListMap: { userSeq: true/false } Map _userListMap = {}; @override void initState() { super.initState(); + // (1) FRD 연결 복원 + FirebaseDatabase.instance.goOnline(); + roomTitle = widget.roomTitle; _initFirebase(); } @@ -79,18 +74,14 @@ class _PlayingPrivatePageState extends State { } final data = snapshot.value as Map? ?? {}; - final roomInfoData = data['roomInfo'] as Map? ?? {}; final userInfoData = data['userInfo'] as Map? ?? {}; final userListData = data['userList'] as Map?; // 방 상태 체크 final roomStatus = (roomInfoData['room_status'] ?? 'WAIT').toString().toUpperCase(); - - // 만약 FINISH라면 => 종료 페이지 이동 if (roomStatus == 'FINISH') { - // 모든 유저 -> 종료 페이지 - // (중복 이동 방지) + // 종료 페이지 if (mounted) { Navigator.pushReplacement( context, @@ -117,7 +108,7 @@ class _PlayingPrivatePageState extends State { }); } - // 전체 참가자 + // 전체 유저 목록 final List> rawList = []; userInfoData.forEach((uSeq, uData) { rawList.add({ @@ -133,10 +124,10 @@ class _PlayingPrivatePageState extends State { }); // 내 점수 - int tempMyScore = 0; - for (var u in rawList) { - if ((u['is_my_score'] ?? 'N') == 'Y') { - tempMyScore = u['score'] ?? 0; + int tmpMyScore = 0; + for (var user in rawList) { + if ((user['is_my_score'] ?? 'N') == 'Y') { + tmpMyScore = user['score'] ?? 0; } } @@ -149,8 +140,9 @@ class _PlayingPrivatePageState extends State { return scoreB.compareTo(scoreA); }); - myScore = tempMyScore; + myScore = tmpMyScore; _scoreList = playerList; + _isLoading = false; }); }, onError: (err) { @@ -161,11 +153,104 @@ class _PlayingPrivatePageState extends State { }); } - /// (A) WillPopScope + AppBar leading - Future _onBackPressed() async { - // 방장? => 게임 종료 API + /// 방장이면 Finish API + Future _requestFinish() async { + final reqBody = { + "room_seq": "${widget.roomSeq}", + "room_type": "PRIVATE", + }; + try { + await Api.serverRequest(uri: '/room/score/game/finish', body: reqBody); + } catch (e) { + // ignore + } + } + + /// 뒤로가기 + Future _onWillPop() async { if (roomMasterYn == 'Y') { - await _requestFinish(); + // 방장 -> 모달 + final confirm = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) { + return AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide(color: Colors.black, width: 1), + ), + title: const Text('나가기', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + content: const Text( + '방장이 나가면 게임이 종료됩니다.\n정말 나가시겠습니까?', + style: TextStyle(fontSize: 14), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + style: TextButton.styleFrom( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + ), + child: const Text('취소'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + ), + child: const Text('종료'), + ), + ], + ); + }, + ); + + if (confirm != true) return false; + + // Finish API + await _requestFinish(); + } else { + // 일반 유저 + final confirm = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) { + return AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide(color: Colors.black, width: 1), + ), + title: const Text('나가기', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + content: const Text( + '진행중인 방에서 나가면 재입장이 불가능합니다.\n정말 나가시겠습니까?', + style: TextStyle(fontSize: 14), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + style: TextButton.styleFrom( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + ), + child: const Text('취소'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + ), + child: const Text('나가기'), + ), + ], + ); + }, + ); + + if (confirm != true) return false; } // userList => false @@ -177,36 +262,17 @@ class _PlayingPrivatePageState extends State { return false; } - /// (B) 서버에 "게임 종료" 요청 - Future _requestFinish() async { - final reqBody = { - "room_seq": "${widget.roomSeq}", - "room_type": "PRIVATE", - }; - try { - final resp = await Api.serverRequest( - uri: '/room/score/game/finish', - body: reqBody, - ); - // OK / FAIL 등은 여기서 특별 처리 없이 넘어감 - // room_status = FINISH => FRD에서 반영 -> 모든 참여자 이동 - } catch (e) { - // 무시하거나 모달 표시 - print('게임 종료 API 에러: $e'); - } - } - - /// (C) 각 참가자 표시 + /// 참가자 카드 Widget _buildScoreItem(Map user) { final userSeq = user['user_seq'].toString(); - final score = user['score'] ?? 0; + final score = user['score'] ?? 0; final nickname = user['nickname'] ?? '유저'; - final bool isActive = _userListMap[userSeq] ?? true; + final bool isActive = _userListMap[userSeq] ?? true; final hasExited = !isActive; return GestureDetector( - onTap: () => _onUserTapped(user), + onTap: () => _onTapUser(user), child: Container( width: 60, margin: const EdgeInsets.all(4), @@ -214,49 +280,43 @@ class _PlayingPrivatePageState extends State { mainAxisSize: MainAxisSize.min, children: [ hasExited - ? Text('X', style: TextStyle(fontSize: 20, color: Colors.redAccent, fontWeight: FontWeight.bold)) - : Text('$score', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black)), + ? Text('X', style: const TextStyle(fontSize: 20, color: Colors.redAccent, fontWeight: FontWeight.bold)) + : Text('$score', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), const SizedBox(height: 2), Container( - width: 30, - height: 30, + width: 30, height: 30, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all(color: hasExited ? Colors.redAccent : Colors.black), ), child: hasExited - ? Center( + ? const Center( child: Text('X', style: TextStyle(fontSize: 14, color: Colors.redAccent, fontWeight: FontWeight.bold)), ) : ClipOval( child: Image.network( 'https://eldsoft.com:8097/images${user['profile_img']}', fit: BoxFit.cover, - errorBuilder: (ctx, err, st) => const Center( - child: Text('ERR', style: TextStyle(fontSize: 8, color: Colors.black)), - ), + errorBuilder: (_, __, ___) => const Center(child: Text('ERR', style: TextStyle(fontSize: 8))), ), ), ), const SizedBox(height: 2), - Text( - nickname, - style: TextStyle(fontSize: 11, color: hasExited ? Colors.redAccent : Colors.black), - overflow: TextOverflow.ellipsis, - ), + Text(nickname, + style: TextStyle(fontSize: 11, color: hasExited ? Colors.redAccent : Colors.black), + overflow: TextOverflow.ellipsis), ], ), ), ); } - Future _onUserTapped(Map userData) async { + Future _onTapUser(Map userData) async { final pType = (userData['participant_type'] ?? '').toString().toUpperCase(); if (pType == 'ADMIN') { - // 점수 수정 모달 + // 점수수정 await showDialog( context: context, - barrierDismissible: false, builder: (_) => ScoreEditDialog( roomSeq: widget.roomSeq, roomType: 'PRIVATE', @@ -264,10 +324,9 @@ class _PlayingPrivatePageState extends State { ), ); } else if (roomMasterYn == 'Y') { - // 방장(PLAYER)도 점수 수정 + // 방장(PLAYER)도 수정 가능 await showDialog( context: context, - barrierDismissible: false, builder: (_) => ScoreEditDialog( roomSeq: widget.roomSeq, roomType: 'PRIVATE', @@ -275,10 +334,9 @@ class _PlayingPrivatePageState extends State { ), ); } else { - // 일반 모달 + // 일반 유저 정보 await showDialog( context: context, - barrierDismissible: false, builder: (_) => UserInfoBasicDialog(userData: userData), ); } @@ -287,7 +345,7 @@ class _PlayingPrivatePageState extends State { @override Widget build(BuildContext context) { return WillPopScope( - onWillPop: _onBackPressed, + onWillPop: _onWillPop, child: Scaffold( backgroundColor: Colors.white, appBar: AppBar( @@ -295,7 +353,7 @@ class _PlayingPrivatePageState extends State { elevation: 0, leading: IconButton( icon: const Icon(Icons.arrow_back_ios, color: Colors.white), - onPressed: _onBackPressed, + onPressed: () => _onWillPop(), ), title: Text( roomTitle.isNotEmpty ? roomTitle : '진행중 (개인전)', @@ -304,10 +362,7 @@ class _PlayingPrivatePageState extends State { actions: [ if (roomMasterYn == 'Y') TextButton( - onPressed: () async { - // 방장 수동 종료버튼 - await _requestFinish(); - }, + onPressed: _requestFinish, child: const Text('게임종료', style: TextStyle(color: Colors.white)), ), ], @@ -319,31 +374,26 @@ class _PlayingPrivatePageState extends State { // 내 점수 Container( width: double.infinity, - color: Colors.white, padding: const EdgeInsets.symmetric(vertical: 16), child: Column( children: [ - const Text('내 점수', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.black)), + const Text('내 점수', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), const SizedBox(height: 4), - Text('$myScore', style: const TextStyle(fontSize: 36, fontWeight: FontWeight.bold, color: Colors.black)), + Text('$myScore', style: const TextStyle(fontSize: 36, fontWeight: FontWeight.bold)), ], ), ), const Divider(height: 1, color: Colors.black), - Expanded( - child: Container( + child: SingleChildScrollView( padding: const EdgeInsets.all(8), - child: SingleChildScrollView( - child: Wrap( - spacing: 8, - runSpacing: 8, - children: _scoreList.map(_buildScoreItem).toList(), - ), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: _scoreList.map(_buildScoreItem).toList(), ), ), ), - Container( height: 50, decoration: BoxDecoration( @@ -351,7 +401,7 @@ class _PlayingPrivatePageState extends State { border: Border.all(color: Colors.black, width: 1), ), child: const Center( - child: Text('구글 광고', style: TextStyle(color: Colors.black)), + child: Text('구글 광고'), ), ), ], diff --git a/lib/views/room/playing_team_page.dart b/lib/views/room/playing_team_page.dart index 99ddce5..0d211c4 100644 --- a/lib/views/room/playing_team_page.dart +++ b/lib/views/room/playing_team_page.dart @@ -3,7 +3,7 @@ import 'package:firebase_database/firebase_database.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'main_page.dart'; -import 'finish_team_page.dart'; // (★) 팀전 종료화면 +import 'finish_team_page.dart'; // 팀전 종료화면 import '../../plugins/api.dart'; import '../../dialogs/response_dialog.dart'; import '../../dialogs/score_edit_dialog.dart'; @@ -27,7 +27,7 @@ class _PlayingTeamPageState extends State { late DatabaseReference _roomRef; Stream? _roomStream; - String roomMasterYn = 'N'; + String roomMasterYn = 'N'; String roomTitle = ''; int myScore = 0; @@ -37,15 +37,17 @@ class _PlayingTeamPageState extends State { Map>> _teamMap = {}; bool _isLoading = true; - String mySeq = '0'; - // userListMap + // userListMap: { seq: true/false } Map _userListMap = {}; @override void initState() { super.initState(); + // (1) FRD 연결 복원 + FirebaseDatabase.instance.goOnline(); + roomTitle = widget.roomTitle; _initFirebase(); } @@ -77,16 +79,13 @@ class _PlayingTeamPageState extends State { } final data = snapshot.value as Map? ?? {}; - final roomInfoData = data['roomInfo'] as Map? ?? {}; final userInfoData = data['userInfo'] as Map? ?? {}; final userListData = data['userList'] as Map?; - // room_status final roomStatus = (roomInfoData['room_status'] ?? 'WAIT').toString().toUpperCase(); - - // FINISH -> 종료화면 if (roomStatus == 'FINISH') { + // 종료화면 if (mounted) { Navigator.pushReplacement( context, @@ -97,14 +96,12 @@ class _PlayingTeamPageState extends State { } setState(() { - // 방장 여부 final masterSeq = roomInfoData['master_user_seq']; roomMasterYn = (masterSeq != null && masterSeq.toString() == mySeq) ? 'Y' : 'N'; final newTitle = (roomInfoData['room_title'] ?? '') as String; if (newTitle.isNotEmpty) roomTitle = newTitle; - // userListMap _userListMap.clear(); if (userListData != null) { userListData.forEach((k, v) { @@ -124,37 +121,36 @@ class _PlayingTeamPageState extends State { }); }); - // 내 점수/팀점수 + // 내 점수 & 팀 점수 int tmpMyScore = 0; int tmpMyTeamScore = 0; String myTeam = 'WAIT'; for (var user in rawList) { final uSeq = user['user_seq'].toString(); - final sc = (user['score'] ?? 0) as int; - final tName = user['team_name'] ?? 'WAIT'; + final sc = (user['score'] ?? 0) as int; + final tName= (user['team_name'] ?? 'WAIT'); if (uSeq == mySeq) { tmpMyScore = sc; myTeam = tName; } } - // 내 팀 점수 for (var user in rawList) { final tName = user['team_name'] ?? 'WAIT'; - final sc = (user['score'] ?? 0) as int; - if (tName == myTeam && tName != 'WAIT') { + final sc = (user['score'] ?? 0) as int; + if (tName == myTeam && myTeam != 'WAIT') { tmpMyTeamScore += sc; } } - // 팀별 분류 (ADMIN/WAIT 제외) + // ADMIN, WAIT 제외 final Map>> tMap = {}; final Map tScoreMap = {}; for (var user in rawList) { final pType = user['participant_type']; - final tName = user['team_name'] ?? 'WAIT'; + final tName = (user['team_name'] ?? 'WAIT'); if (pType == 'ADMIN') continue; if (tName == 'WAIT') continue; @@ -162,7 +158,6 @@ class _PlayingTeamPageState extends State { tMap[tName]!.add(user); } - // 팀 점수 합 tMap.forEach((k, members) { int sumScore = 0; for (var m in members) { @@ -175,6 +170,7 @@ class _PlayingTeamPageState extends State { myTeamScore = tmpMyTeamScore; _teamMap = tMap; _teamScoreMap = tScoreMap; + _isLoading = false; }); }, onError: (err) { @@ -185,11 +181,102 @@ class _PlayingTeamPageState extends State { }); } - /// (A) 뒤로가기 -> 방장? => Finish API - Future _onBackPressed() async { - if (roomMasterYn == 'Y') { - await _requestFinish(); + /// 게임종료 + Future _requestFinish() async { + final body = { + "room_seq": "${widget.roomSeq}", + "room_type": "TEAM", + }; + try { + await Api.serverRequest(uri: '/room/score/game/finish', body: body); + } catch (e) { + // ignore } + } + + Future _onWillPop() async { + if (roomMasterYn == 'Y') { + // 방장 모달 + final confirm = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) { + return AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide(color: Colors.black, width: 1), + ), + title: const Text('나가기', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + content: const Text( + '방장이 나가면 게임이 종료됩니다.\n정말 나가시겠습니까?', + style: TextStyle(fontSize: 14), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + style: TextButton.styleFrom( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + ), + child: const Text('취소'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + ), + child: const Text('종료'), + ), + ], + ); + }, + ); + if (confirm != true) return false; + + await _requestFinish(); + } else { + // 일반 유저 + final confirm = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) { + return AlertDialog( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide(color: Colors.black, width: 1), + ), + title: const Text('나가기', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + content: const Text( + '진행중인 방에서 나가면 재입장이 불가능합니다.\n정말 나가시겠습니까?', + style: TextStyle(fontSize: 14), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + style: TextButton.styleFrom( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + ), + child: const Text('취소'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + ), + child: const Text('나가기'), + ), + ], + ); + }, + ); + if (confirm != true) return false; + } + // userList => false final userRef = _roomRef.child('userList').child(mySeq); await userRef.set(false); @@ -199,26 +286,10 @@ class _PlayingTeamPageState extends State { return false; } - Future _requestFinish() async { - final body = { - "room_seq": "${widget.roomSeq}", - "room_type": "TEAM", - }; - try { - final resp = await Api.serverRequest( - uri: '/room/score/game/finish', - body: body, - ); - // result ... - } catch (e) { - print('finish API error: $e'); - } - } - @override Widget build(BuildContext context) { return WillPopScope( - onWillPop: _onBackPressed, + onWillPop: _onWillPop, child: Scaffold( backgroundColor: Colors.white, appBar: AppBar( @@ -230,7 +301,7 @@ class _PlayingTeamPageState extends State { elevation: 0, leading: IconButton( icon: const Icon(Icons.arrow_back_ios, color: Colors.white), - onPressed: _onBackPressed, + onPressed: () => _onWillPop(), ), actions: [ if (roomMasterYn == 'Y') @@ -244,7 +315,7 @@ class _PlayingTeamPageState extends State { ? const Center(child: CircularProgressIndicator()) : Column( children: [ - // 내 점수 / 팀 점수 + // (A) 내 점수 / 팀 점수 Container( color: Colors.white, padding: const EdgeInsets.only(top: 16, bottom: 16), @@ -253,17 +324,17 @@ class _PlayingTeamPageState extends State { children: [ Column( children: [ - const Text('내 점수', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.black)), + const Text('내 점수', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), const SizedBox(height: 4), - Text('$myScore', style: const TextStyle(fontSize: 36, fontWeight: FontWeight.bold, color: Colors.black)), + Text('$myScore', style: const TextStyle(fontSize: 36, fontWeight: FontWeight.bold)), ], ), Container(width: 1, height: 60, color: Colors.black), Column( children: [ - const Text('우리 팀 점수', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.black)), + const Text('우리 팀 점수', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), const SizedBox(height: 4), - Text('$myTeamScore', style: const TextStyle(fontSize: 36, fontWeight: FontWeight.bold, color: Colors.black)), + Text('$myTeamScore', style: const TextStyle(fontSize: 36, fontWeight: FontWeight.bold)), ], ), ], @@ -271,6 +342,7 @@ class _PlayingTeamPageState extends State { ), const Divider(height: 1, color: Colors.black), + // (B) 팀별 표시 Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(8), @@ -280,6 +352,7 @@ class _PlayingTeamPageState extends State { ), ), ), + Container( height: 50, decoration: BoxDecoration( @@ -316,7 +389,8 @@ class _PlayingTeamPageState extends State { width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 8), child: Center( - child: Text('$teamName (팀점수 $teamScore)', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + child: Text('$teamName (팀점수 $teamScore)', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), ), ), Container( @@ -333,14 +407,14 @@ class _PlayingTeamPageState extends State { Widget _buildTeamMemberItem(Map userData) { final userSeq = userData['user_seq'].toString(); - final score = userData['score'] ?? 0; - final nickname = userData['nickname'] ?? '유저'; + final score = userData['score'] ?? 0; + final nickname= userData['nickname'] ?? '유저'; final bool isActive = _userListMap[userSeq] ?? true; final hasExited = !isActive; return GestureDetector( - onTap: () => _onUserTapped(userData), + onTap: () => _onTapUser(userData), child: Container( width: 60, margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), @@ -348,8 +422,8 @@ class _PlayingTeamPageState extends State { mainAxisSize: MainAxisSize.min, children: [ hasExited - ? Text('X', style: TextStyle(fontSize: 18, color: Colors.redAccent, fontWeight: FontWeight.bold)) - : Text('$score', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black)), + ? Text('X', style: const TextStyle(fontSize: 18, color: Colors.redAccent, fontWeight: FontWeight.bold)) + : Text('$score', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), const SizedBox(height: 2), Container( width: 30, @@ -359,12 +433,18 @@ class _PlayingTeamPageState extends State { border: Border.all(color: hasExited ? Colors.redAccent : Colors.black), ), child: hasExited - ? Center(child: Text('X', style: TextStyle(fontSize: 14, color: Colors.redAccent, fontWeight: FontWeight.bold))) + ? const Center( + child: Text( + 'X', + style: TextStyle(fontSize: 14, color: Colors.redAccent, fontWeight: FontWeight.bold), + ), + ) : ClipOval( child: Image.network( 'https://eldsoft.com:8097/images${userData['profile_img']}', fit: BoxFit.cover, - errorBuilder: (ctx, err, st) => const Center(child: Text('ERR', style: TextStyle(fontSize: 8, color: Colors.black))), + errorBuilder: (ctx, err, st) => + const Center(child: Text('ERR', style: TextStyle(fontSize: 8))), ), ), ), @@ -380,13 +460,11 @@ class _PlayingTeamPageState extends State { ); } - Future _onUserTapped(Map userData) async { + Future _onTapUser(Map userData) async { final pType = (userData['participant_type'] ?? '').toString().toUpperCase(); if (pType == 'ADMIN') { - // 점수 수정 await showDialog( context: context, - barrierDismissible: false, builder: (_) => ScoreEditDialog( roomSeq: widget.roomSeq, roomType: 'TEAM', @@ -396,7 +474,6 @@ class _PlayingTeamPageState extends State { } else if (roomMasterYn == 'Y') { await showDialog( context: context, - barrierDismissible: false, builder: (_) => ScoreEditDialog( roomSeq: widget.roomSeq, roomType: 'TEAM', @@ -406,7 +483,6 @@ class _PlayingTeamPageState extends State { } else { await showDialog( context: context, - barrierDismissible: false, builder: (_) => UserInfoBasicDialog(userData: userData), ); } diff --git a/lib/views/room/room_search_list_page.dart b/lib/views/room/room_search_list_page.dart index cf7c7bb..e72348a 100644 --- a/lib/views/room/room_search_list_page.dart +++ b/lib/views/room/room_search_list_page.dart @@ -1,14 +1,15 @@ import 'package:flutter/material.dart'; import 'dart:async'; -import '../../plugins/api.dart'; // 서버 요청용 (예: Api.serverRequest) -import '../../dialogs/response_dialog.dart'; // 모달창 띄우기 예시 -import '../../dialogs/room_detail_dialog.dart'; // 분리된 모달창 import +import '../../plugins/api.dart'; +import '../../dialogs/response_dialog.dart'; +import '../../dialogs/room_detail_dialog.dart'; + +// (새로 import) +// 종료된 방 페이지들 +import '../room/finish_private_page.dart'; +import '../room/finish_team_page.dart'; -/// 서버로부터 방 리스트를 검색/조회하는 페이지 -/// - roomStatus: "WAIT"/"RUNNING"/"FINISH" -/// - 1페이지당 10개씩 로드, 스크롤 최하단 도달 시 다음 페이지 자동 로드 -/// - 검색창을 통해 room_title 필터링 class RoomSearchListPage extends StatefulWidget { final String roomStatus; // WAIT / RUNNING / FINISH @@ -21,7 +22,6 @@ class RoomSearchListPage extends StatefulWidget { class _RoomSearchListPageState extends State { final TextEditingController _searchController = TextEditingController(); - // 방 목록 List> _roomList = []; bool _isLoading = false; @@ -47,7 +47,6 @@ class _RoomSearchListPageState extends State { super.dispose(); } - /// 스크롤이 최하단 근처 도달 시 다음 페이지 로드 void _onScroll() { if (!_scrollController.hasClients) return; final thresholdPixels = 200; @@ -59,9 +58,8 @@ class _RoomSearchListPageState extends State { } } - /// (1) 서버에서 방 리스트 가져오기 Future _fetchRoomList({required bool isRefresh}) async { - if (_isLoading) return; + if (_isLoading) return; if (!isRefresh && !_hasMore) return; setState(() => _isLoading = true); @@ -72,26 +70,19 @@ class _RoomSearchListPageState extends State { _roomList.clear(); } - // 서버 API 요구사항에 맞춰 WAIT/RUNNING/FINISH (대문자) 사용 final String searchType = widget.roomStatus.toUpperCase(); final String searchValue = _searchController.text.trim(); final String searchPage = _currentPage.toString(); final requestBody = { - "search_type": searchType, + "search_type": searchType, "search_value": searchValue, "search_page": searchPage, }; try { - final response = await Api.serverRequest( - uri: '/room/score/room/list', - body: requestBody, - ); + final response = await Api.serverRequest(uri: '/room/score/room/list', body: requestBody); - print('🔵 response: $response'); - - // (참고) 서버 구조: { result: OK, response: {...}, ... } if (response == null || response['result'] != 'OK') { showResponseDialog(context, '오류', '방 목록을 불러오지 못했습니다.'); } else { @@ -105,13 +96,14 @@ class _RoomSearchListPageState extends State { _hasMore = false; } else { for (var item in respData) { - print('🔵 item: $item'); final parsedItem = { 'room_seq': item['room_seq'] ?? 0, 'nickname': item['nickname'] ?? '사용자', + // WAIT/RUNNING/FINISH -> 한글 'room_status': _statusToKr(item['room_status'] ?? ''), + 'raw_room_status': (item['room_status'] ?? '').toString().toUpperCase(), 'open_yn': (item['open_yn'] == 'Y') ? '공개' : '비공개', - 'room_type': item['room_type_name'] ?? 'private', + 'room_type': (item['room_type_name'] ?? 'PRIVATE').toString().toLowerCase(), 'room_title': item['room_title'] ?? '(방제목 없음)', 'room_intro': item['room_intro'] ?? '', 'now_people': item['now_number_of_people']?.toString() ?? '0', @@ -137,7 +129,6 @@ class _RoomSearchListPageState extends State { } } - /// WAIT->'대기중', RUNNING->'진행중', FINISH->'종료' String _statusToKr(String status) { switch (status.toUpperCase()) { case 'WAIT': @@ -156,12 +147,42 @@ class _RoomSearchListPageState extends State { } void _onRoomItemTap(Map item) { - // 여기서 분리된 모달 호출 - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => RoomDetailDialog(roomData: item), - ); + // room_status(한글)이 아니라, raw_room_status(영문 WAIT/RUNNING/FINISH)를 보고 판단 + final rawStatus = (item['raw_room_status'] ?? '').toString().toUpperCase(); + if (rawStatus == 'FINISH') { + // 종료된 방이면 => FinishPrivatePage or FinishTeamPage + final roomType = (item['room_type'] ?? 'private').toString().toLowerCase(); + final roomSeq = (item['room_seq'] ?? 0) as int; + final roomTitle = (item['room_title'] ?? '(종료된 방)') as String; + + if (roomType == 'private') { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => FinishPrivatePage( + roomSeq: roomSeq, + fromPlayingPage: false, // ← 검색에서 왔으므로 false + ), + ), + ); + } else { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => FinishTeamPage( + roomSeq: roomSeq, + fromPlayingPage: false, + ), + ), + ); + } + } else { + // 아직 진행중 or 대기중인 방 => 기존 로직, 예: RoomDetailDialog + showDialog( + context: context, + builder: (_) => RoomDetailDialog(roomData: item), + ); + } } @override @@ -180,7 +201,7 @@ class _RoomSearchListPageState extends State { ), body: Column( children: [ - // (A) 검색창 + // 검색창 Padding( padding: const EdgeInsets.all(8.0), child: TextField( @@ -206,14 +227,12 @@ class _RoomSearchListPageState extends State { ), ), - // (B) 로딩 표시 or 리스트 Expanded( child: _isLoading && _roomList.isEmpty ? const Center(child: CircularProgressIndicator()) : _buildRoomListView(), ), - // (C) 하단 광고 Container( height: 60, color: Colors.white, @@ -234,16 +253,11 @@ class _RoomSearchListPageState extends State { } Widget _buildRoomListView() { - print('🔵 _roomList: $_roomList'); if (_roomList.isEmpty) { return const Center( - child: Text( - '검색 결과가 없습니다.', - style: TextStyle(color: Colors.black), - ), + child: Text('검색 결과가 없습니다.', style: TextStyle(color: Colors.black)), ); } - return ListView.builder( controller: _scrollController, padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), diff --git a/lib/views/room/waiting_room_private_page.dart b/lib/views/room/waiting_room_private_page.dart index 82acf6e..e92d214 100644 --- a/lib/views/room/waiting_room_private_page.dart +++ b/lib/views/room/waiting_room_private_page.dart @@ -4,9 +4,9 @@ import 'package:firebase_database/firebase_database.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'main_page.dart'; -import '../../plugins/api.dart'; // 서버 API -import '../../dialogs/response_dialog.dart'; -import '../../dialogs/yes_no_dialog.dart'; // 예/아니오 모달 +import '../../plugins/api.dart'; +import '../../dialogs/response_dialog.dart'; +import '../../dialogs/yes_no_dialog.dart'; import '../../dialogs/room_setting_dialog.dart'; import '../../dialogs/user_info_private_dialog.dart'; import 'playing_private_page.dart'; @@ -38,46 +38,57 @@ class _WaitingRoomPrivatePageState extends State { int numberOfPeople = 10; String scoreOpenRange = 'PRIVATE'; + // FRD + late DatabaseReference _roomRef; + Stream? _roomStream; + StreamSubscription? _roomStreamSubscription; + // 유저 목록 List> _userList = []; bool _isLoading = true; - // FRD - late DatabaseReference _roomRef; - Stream? _roomStream; - // 진행중 화면 이동 중복 방지 bool _movedToRunningPage = false; - // 강퇴 안내 중복 방지 bool _kickedOut = false; - // FRD 구독 해제 - StreamSubscription? _roomStreamSubscription; + // 내 user_seq + String mySeq = '0'; - // (예) 내 user_seq - String mySeq = '0'; // 원래 '6' 고정이었던 부분 제거 + // ───────────────────────────────────────── + // 1시간 카운트다운 + // ───────────────────────────────────────── + Timer? _countdownTimer; + Duration _remaining = const Duration(hours: 1); // 기본 1시간 + DateTime? _createDt; // FRD의 roomInfo.create_dt @override void initState() { super.initState(); - _loadMySeq(); + // FRD 연결 복원 + FirebaseDatabase.instance.goOnline(); + _initRoomRef(); } - /// (A) my_user_seq 로드 -> 리스너 - Future _loadMySeq() async { + Future _initRoomRef() async { final prefs = await SharedPreferences.getInstance(); mySeq = prefs.getInt('my_user_seq')?.toString() ?? '0'; final roomKey = 'korea-${widget.roomSeq}'; _roomRef = FirebaseDatabase.instance.ref('rooms/$roomKey'); + + // onDisconnect + connect_yn='Y' + final myUserRef = _roomRef.child('userInfo').child(mySeq); + myUserRef.onDisconnect().update({'connect_yn': 'N'}); + await myUserRef.update({'connect_yn': 'Y'}); + _listenRoomData(); } void _listenRoomData() { _roomStream = _roomRef.onValue; - _roomStream?.listen((event) { + _roomStreamSubscription = _roomStream?.listen((event) { final snapshot = event.snapshot; if (!snapshot.exists) { setState(() { @@ -94,12 +105,13 @@ class _WaitingRoomPrivatePageState extends State { final roomStatus = (roomInfoData['room_status'] ?? 'WAIT').toString().toUpperCase(); + // (A) roomInfo 갱신 setState(() { - roomTitle = (roomInfoData['room_title'] ?? '') as String; - roomIntro = (roomInfoData['room_intro'] ?? '') as String; - openYn = (roomInfoData['open_yn'] ?? 'Y') as String; - roomPw = (roomInfoData['room_pw'] ?? '') as String; - runningTime = _toInt(roomInfoData['running_time'], 1); + roomTitle = (roomInfoData['room_title'] ?? '') as String; + roomIntro = (roomInfoData['room_intro'] ?? '') as String; + openYn = (roomInfoData['open_yn'] ?? 'Y') as String; + roomPw = (roomInfoData['room_pw'] ?? '') as String; + runningTime = _toInt(roomInfoData['running_time'], 1); numberOfPeople = _toInt(roomInfoData['number_of_people'], 10); scoreOpenRange = (roomInfoData['score_open_range'] ?? 'PRIVATE') as String; @@ -110,7 +122,7 @@ class _WaitingRoomPrivatePageState extends State { roomMasterYn = 'Y'; } - // 유저 목록 + // userList final tempList = >[]; userInfoData.forEach((userSeq, userMap) { tempList.add({ @@ -119,16 +131,16 @@ class _WaitingRoomPrivatePageState extends State { 'nickname': userMap['nickname'] ?? '유저', 'score': userMap['score'] ?? 0, 'profile_img': userMap['profile_img'] ?? '', - 'department': userMap['department'] ?? '', 'introduce_myself': userMap['introduce_myself'] ?? '', 'ready_yn': (userMap['ready_yn'] ?? 'N').toString().toUpperCase(), + 'connect_yn': (userMap['connect_yn'] ?? 'Y').toString().toUpperCase(), }); }); _userList = tempList; _isLoading = false; }); - // 진행중 -> 화면 이동 + // (B) 진행중 => 이동 if (roomStatus == 'RUNNING' && !_movedToRunningPage) { _movedToRunningPage = true; Navigator.pushReplacement( @@ -143,20 +155,21 @@ class _WaitingRoomPrivatePageState extends State { return; } - // (2) 여기서 "내 user_seq가 목록에 있는지" 검사 + // (C) 1시간 카운트다운 위한 create_dt 파싱 + // 예: "2025-01-07T06:38:10.123456" + final createDtStr = (roomInfoData['create_dt'] ?? '') as String; + if (createDtStr.isNotEmpty && createDtStr.contains('T')) { + final dt = DateTime.tryParse(createDtStr); + if (dt != null) { + _createDt = dt; + _startCountdownTimer(); + } + } + + // (D) 내가 목록에서 사라졌는지 => 강퇴 판별 final amIStillHere = _userList.any((u) => u['user_seq'].toString() == mySeq); - - // (3) 만약 내가 목록에서 사라졌고, - // 아직 안내하지 않았으며(_kickedOut == false), - // 내가 방장도 아니고(roomMasterYn != 'Y'), - // 방이 진행중/종료가 아닌 상태면 => "강퇴"로 간주 if (!amIStillHere && !_kickedOut && roomMasterYn != 'Y') { - // ※ 방이 이미 RUNNING이면 그냥 게임중에 퇴장인지, - // 방이 DELETE 상태인지 등 필요 시 조건 보강 - - _kickedOut = true; // 중복 안내 막기 - - // (★) 강퇴 안내 + 메인으로 이동 + _kickedOut = true; Future.delayed(Duration.zero, () async { await showResponseDialog(context, '안내', '강퇴되었습니다.'); Navigator.pushReplacement( @@ -166,7 +179,6 @@ class _WaitingRoomPrivatePageState extends State { }); } }, onError: (error) { - print('FRD onError: $error'); setState(() { _isLoading = false; roomTitle = '오류 발생'; @@ -174,13 +186,64 @@ class _WaitingRoomPrivatePageState extends State { }); } + // 1시간 카운트다운 타이머 + void _startCountdownTimer() { + if (_countdownTimer != null && _countdownTimer!.isActive) { + return; // 이미 실행중이면 중복 실행 방지 + } + if (_createDt == null) return; + + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + // 목표시각: createDt + 1시간 + final endTime = _createDt!.add(const Duration(hours: 1)); + final now = DateTime.now(); + final diff = endTime.difference(now); + + if (diff.isNegative) { + // 이미 시간이 지남 -> 자동 종료 로직 + timer.cancel(); + _remaining = const Duration(seconds: 0); + _onAutoTimeout(); + } else { + setState(() { + _remaining = diff; + }); + } + }); + } + + // 1시간 만료 후 자동 종료 + void _onAutoTimeout() { + // 방장 => 방 삭제 (leave API) + // 일반 => 그냥 나가기 + if (roomMasterYn == 'Y') { + _requestLeaveRoom(); + } else { + _requestLeaveRoom(); + } + } + + Future _requestLeaveRoom() async { + try { + final reqBody = {"room_seq": "${widget.roomSeq}"}; + final response = await Api.serverRequest(uri: '/room/score/game/leave', body: reqBody); + // result ok -> 메인 + } catch (e) { + // + } + if (mounted) { + Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const MainPage())); + } + } + @override void dispose() { - _roomStreamSubscription?.cancel(); // ← 구독 해제 + _countdownTimer?.cancel(); + _roomStreamSubscription?.cancel(); super.dispose(); } - /// (B) 뒤로가기 -> 방 나가기 + /// 뒤로가기 → 방 나가기 Future _onLeaveRoom() async { if (roomMasterYn == 'Y') { // 방장 @@ -195,15 +258,9 @@ class _WaitingRoomPrivatePageState extends State { side: const BorderSide(color: Colors.black, width: 2), ), title: const Center( - child: Text( - '방 나가기', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black), - ), - ), - content: const Text( - '방장이 나가면 방은 삭제됩니다.\n정말 나가시겠습니까?', - style: TextStyle(fontSize: 14, color: Colors.black), + child: Text('방 나가기', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), ), + content: const Text('방장이 나가면 방은 삭제됩니다.\n정말 나가시겠습니까?', style: TextStyle(fontSize: 14)), actionsAlignment: MainAxisAlignment.spaceEvenly, actions: [ TextButton( @@ -211,17 +268,19 @@ class _WaitingRoomPrivatePageState extends State { style: TextButton.styleFrom( backgroundColor: Colors.black, foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), child: const Text('취소'), ), TextButton( - onPressed: () => Navigator.pop(context, true), + onPressed: () { + final myUserRef = _roomRef.child('userInfo').child(mySeq); + myUserRef.onDisconnect().cancel(); + Navigator.pop(context, true); + }, style: TextButton.styleFrom( backgroundColor: Colors.black, foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), child: const Text('확인'), @@ -233,75 +292,10 @@ class _WaitingRoomPrivatePageState extends State { if (confirm != true) return; // leave API - try { - final reqBody = {"room_seq": "${widget.roomSeq}"}; - final response = await Api.serverRequest(uri: '/room/score/game/leave', body: reqBody); - if (response['result'] == 'OK') { - final resp = response['response'] ?? {}; - if (resp['result'] == 'OK') { - Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const MainPage())); - } else { - final msg = resp['response_info']?['msg_content'] ?? '방 나가기 실패'; - final again = await showYesNoDialog( - context: context, - title: '오류', - message: '$msg\n그래도 나가시겠습니까?', - yesNo: true, - ); - if (again == true) { - Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const MainPage())); - } - } - } else { - final again = await showYesNoDialog( - context: context, - title: '오류', - message: '서버오류\n그래도 나가시겠습니까?', - yesNo: true, - ); - if (again == true) { - Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const MainPage())); - } - } - } catch (e) { - final again = await showYesNoDialog( - context: context, - title: '오류', - message: '$e\n그래도 나가시겠습니까?', - yesNo: true, - ); - if (again == true) { - Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const MainPage())); - } - } + await _requestLeaveRoom(); } else { // 일반 - try { - final reqBody = {"room_seq": "${widget.roomSeq}"}; - final response = await Api.serverRequest(uri: '/room/score/game/leave', body: reqBody); - if (response['result'] == 'OK') { - final resp = response['response'] ?? {}; - if (resp['result'] == 'OK') { - Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const MainPage())); - } else { - final msg = resp['response_info']?['msg_content'] ?? '나가기 실패'; - final again = await showYesNoDialog(context: context, title: '오류', message: msg, yesNo: true); - if (again == true) { - Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const MainPage())); - } - } - } else { - final again = await showYesNoDialog(context: context, title: '오류', message: '서버 통신 오류', yesNo: true); - if (again == true) { - Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const MainPage())); - } - } - } catch (e) { - final again = await showYesNoDialog(context: context, title: '오류', message: '$e', yesNo: true); - if (again == true) { - Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const MainPage())); - } - } + await _requestLeaveRoom(); } } @@ -314,17 +308,14 @@ class _WaitingRoomPrivatePageState extends State { return defaultVal; } - /// 상단 버튼 (방장=3개, 일반=2개) + /// 상단 버튼 Widget _buildTopButtons() { if (_isLoading) return const SizedBox(); - final me = _userList.firstWhere( - (u) => (u['user_seq'] ?? '0') == mySeq, - orElse: () => {}, - ); + final me = _userList.firstWhere((u) => (u['user_seq'] ?? '0') == mySeq, orElse: () => {}); final myReadyYn = (me['ready_yn'] ?? 'N').toString().toUpperCase(); final bool isReady = (myReadyYn == 'Y'); - final String readyLabel = isReady ? '준비완료' : '준비'; + final readyLabel = isReady ? '준비완료' : '준비'; final btnStyle = ElevatedButton.styleFrom( backgroundColor: Colors.white, @@ -335,7 +326,6 @@ class _WaitingRoomPrivatePageState extends State { if (roomMasterYn == 'Y') { // 방장 => 3개 return Row( - mainAxisAlignment: MainAxisAlignment.center, children: [ Expanded( child: Container( @@ -372,7 +362,6 @@ class _WaitingRoomPrivatePageState extends State { } else { // 일반 => 2개 return Row( - mainAxisAlignment: MainAxisAlignment.center, children: [ Expanded( child: Container( @@ -399,15 +388,11 @@ class _WaitingRoomPrivatePageState extends State { } } - /// READY 토글 Future _onToggleReady() async { try { - final me = _userList.firstWhere( - (u) => (u['user_seq'] ?? '0') == mySeq, - orElse: () => {}, - ); + final me = _userList.firstWhere((u) => (u['user_seq'] ?? '0') == mySeq, orElse: () => {}); final myReadyYn = (me['ready_yn'] ?? 'N').toString().toUpperCase(); - final bool isReady = (myReadyYn == 'Y'); + final isReady = (myReadyYn == 'Y'); final newYn = isReady ? 'N' : 'Y'; final userRef = _roomRef.child('userInfo').child(mySeq); @@ -417,7 +402,6 @@ class _WaitingRoomPrivatePageState extends State { } } - /// 방 설정 Future _onOpenRoomSetting() async { final roomInfo = { "room_seq": "${widget.roomSeq}", @@ -442,12 +426,8 @@ class _WaitingRoomPrivatePageState extends State { } } - /// 게임 시작 Future _onGameStart() async { - final notReady = _userList.any((u) { - final ry = (u['ready_yn'] ?? 'N').toString().toUpperCase(); - return (ry != 'Y'); - }); + final notReady = _userList.any((u) => (u['ready_yn'] ?? 'N').toString().toUpperCase() != 'Y'); if (notReady) { showResponseDialog(context, '안내', 'READY되지 않은 참가자가 있습니다(방장 포함).'); return; @@ -457,40 +437,48 @@ class _WaitingRoomPrivatePageState extends State { "room_seq": "${widget.roomSeq}", "room_type": "PRIVATE", }; - try { - final response = await Api.serverRequest( - uri: '/room/score/game/start', - body: requestBody, - ); + final response = await Api.serverRequest(uri: '/room/score/game/start', body: requestBody); if (response['result'] == 'OK') { final resp = response['response'] ?? {}; if (resp['result'] == 'OK') { print('게임 시작 요청 성공(개인전)'); } else { - final msgTitle = resp['response_info']?['msg_title'] ?? '오류'; - final msgContent = resp['response_info']?['msg_content'] ?? '게임 시작 실패'; - showResponseDialog(context, msgTitle, msgContent); + // ... } } else { - showResponseDialog(context, '실패', '서버 통신 오류'); + // ... } } catch (e) { - showResponseDialog(context, '오류', '$e'); + // ... } } + // (★) 카운트다운 표시용 + String _formatDuration(Duration d) { + final mm = d.inMinutes.remainder(60).toString().padLeft(2, '0'); + final ss = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$mm:$ss'; + } + @override Widget build(BuildContext context) { + // 남은시간 (기본: 60:00 ~ 0:00) + final countdownStr = _formatDuration(_remaining); + return Scaffold( backgroundColor: Colors.white, appBar: AppBar( backgroundColor: Colors.black, elevation: 0, - title: const Text('대기 방 (개인전)', style: TextStyle(color: Colors.white)), + // 방 제목 + 남은시간 표시 + title: Text( + (roomTitle.isNotEmpty ? roomTitle : '방 제목') + ' [$countdownStr]', + style: const TextStyle(color: Colors.white), + ), leading: IconButton( icon: const Icon(Icons.arrow_back_ios, color: Colors.white), - onPressed: _onLeaveRoom, + onPressed: _onLeaveRoom, ), ), bottomNavigationBar: Container( @@ -510,24 +498,16 @@ class _WaitingRoomPrivatePageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Center( - child: Text( - roomTitle.isNotEmpty ? roomTitle : '방 제목', - style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black), - textAlign: TextAlign.center, - ), - ), - const SizedBox(height: 8), - + // 상단 버튼들 _buildTopButtons(), const SizedBox(height: 20), - const Text('사회자', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black)), + const Text('사회자', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const SizedBox(height: 8), _buildAdminSection(), const SizedBox(height: 20), - const Text('참가자', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black)), + const Text('참가자', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const SizedBox(height: 8), _buildPlayerSection(), ], @@ -536,7 +516,6 @@ class _WaitingRoomPrivatePageState extends State { ); } - // 사회자 Widget _buildAdminSection() { final adminList = _userList.where((u) { final t = (u['participant_type'] ?? '').toString().toUpperCase(); @@ -544,7 +523,6 @@ class _WaitingRoomPrivatePageState extends State { }).toList(); return Container( - width: double.infinity, padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.white, @@ -552,7 +530,7 @@ class _WaitingRoomPrivatePageState extends State { borderRadius: BorderRadius.circular(8), ), child: adminList.isEmpty - ? const Text('사회자가 없습니다.', style: TextStyle(color: Colors.black)) + ? const Text('사회자가 없습니다.') : Wrap( spacing: 16, runSpacing: 8, @@ -561,7 +539,6 @@ class _WaitingRoomPrivatePageState extends State { ); } - // 일반 참가자 Widget _buildPlayerSection() { final playerList = _userList.where((u) { final t = (u['participant_type'] ?? '').toString().toUpperCase(); @@ -569,7 +546,6 @@ class _WaitingRoomPrivatePageState extends State { }).toList(); return Container( - width: double.infinity, padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.white, @@ -577,7 +553,7 @@ class _WaitingRoomPrivatePageState extends State { borderRadius: BorderRadius.circular(8), ), child: playerList.isEmpty - ? const Text('참가자가 없습니다.', style: TextStyle(color: Colors.black)) + ? const Text('참가자가 없습니다.') : SingleChildScrollView( child: Wrap( spacing: 16, @@ -589,13 +565,14 @@ class _WaitingRoomPrivatePageState extends State { ); } - // Seat Widget _buildSeat(Map userData) { final userName = userData['nickname'] ?? '유저'; final profileImg = userData['profile_img'] ?? ''; - final readyYn = userData['ready_yn'] ?? 'N'; - final isReady = (readyYn == 'Y'); - final isMaster = (roomMasterYn == 'Y'); + final readyYn = (userData['ready_yn'] ?? 'N').toString().toUpperCase(); + final connectYn = (userData['connect_yn'] ?? 'Y').toString().toUpperCase(); + final bool isReady = (readyYn == 'Y'); + final bool isDisconnected = (connectYn == 'N'); + final bool isMaster = (roomMasterYn == 'Y'); return GestureDetector( onTap: () async { @@ -616,7 +593,6 @@ class _WaitingRoomPrivatePageState extends State { child: Container( margin: const EdgeInsets.only(right: 8), child: Column( - mainAxisSize: MainAxisSize.min, children: [ Container( width: 50, @@ -624,40 +600,55 @@ class _WaitingRoomPrivatePageState extends State { decoration: BoxDecoration( color: Colors.white, border: Border.all( - color: isReady ? Colors.red : Colors.black, - width: isReady ? 2 : 1, + color: isDisconnected + ? Colors.orange + : (isReady ? Colors.red : Colors.black), + width: isDisconnected ? 2 : (isReady ? 2 : 1), ), borderRadius: BorderRadius.circular(20), - boxShadow: isReady - ? [ - BoxShadow( - color: Colors.redAccent.withOpacity(0.6), - blurRadius: 8, - spreadRadius: 2, - offset: const Offset(0, 0), - ) - ] - : [], + boxShadow: [ + if (isReady) + BoxShadow( + color: Colors.redAccent.withOpacity(0.6), + blurRadius: 8, + spreadRadius: 2, + ), + if (isDisconnected) + BoxShadow( + color: Colors.orangeAccent.withOpacity(0.6), + blurRadius: 8, + spreadRadius: 2, + ), + ], ), child: ClipRRect( borderRadius: BorderRadius.circular(20), - child: Image.network( - 'https://eldsoft.com:8097/images$profileImg', - fit: BoxFit.cover, - errorBuilder: (ctx, err, st) { - return const Center( - child: Text( - '이미지\n불가', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 10), + child: isDisconnected + ? const Center( + child: Text( + '!', + style: TextStyle( + fontSize: 20, + color: Colors.orange, + fontWeight: FontWeight.bold, + ), + ), + ) + : Image.network( + 'https://eldsoft.com:8097/images$profileImg', + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Center( + child: Text( + '이미지\n불가', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 10), + ), + ), ), - ); - }, - ), ), ), const SizedBox(height: 4), - Text(userName, style: const TextStyle(fontSize: 12, color: Colors.black)), + Text(userName, style: const TextStyle(fontSize: 12)), ], ), ), diff --git a/lib/views/room/waiting_room_team_page.dart b/lib/views/room/waiting_room_team_page.dart index 8a500a7..06a0ca5 100644 --- a/lib/views/room/waiting_room_team_page.dart +++ b/lib/views/room/waiting_room_team_page.dart @@ -4,9 +4,9 @@ import 'package:firebase_database/firebase_database.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'main_page.dart'; -import '../../plugins/api.dart'; // 서버 API -import '../../dialogs/response_dialog.dart'; // 응답 모달 -import '../../dialogs/yes_no_dialog.dart'; // 예/아니오 모달 +import '../../plugins/api.dart'; +import '../../dialogs/response_dialog.dart'; +import '../../dialogs/yes_no_dialog.dart'; import '../../dialogs/room_setting_dialog.dart'; import '../../dialogs/user_info_team_dialog.dart'; import '../../dialogs/team_name_edit_dialog.dart'; @@ -27,9 +27,7 @@ class WaitingRoomTeamPage extends StatefulWidget { } class _WaitingRoomTeamPageState extends State { - // ───────────────────────────────────────── // 방 설정 - // ───────────────────────────────────────── String roomMasterYn = 'N'; String roomTitle = ''; String roomIntro = ''; @@ -40,57 +38,57 @@ class _WaitingRoomTeamPageState extends State { String scoreOpenRange = 'PRIVATE'; int numberOfTeams = 1; - // 팀명 리스트 List _teamNameList = []; - - // 유저 목록 List> _userList = []; bool _isLoading = true; - // FRD late DatabaseReference _roomRef; Stream? _roomStream; - - // 진행중 화면 중복 이동 방지 - bool _movedToRunningPage = false; - - // 강퇴 안내 중복 방지 - bool _kickedOut = false; - - // FRD 구독 해제 StreamSubscription? _roomStreamSubscription; - // 로컬스토리지에서 가져올 user_seq - String mySeq = '0'; // 원래 '6'을 하드코딩 했던 부분을 제거 + bool _movedToRunningPage = false; + bool _kickedOut = false; + + String mySeq = '0'; + + // (★) 1시간 카운트다운 + Timer? _countdownTimer; + Duration _remaining = const Duration(hours: 1); + DateTime? _createDt; @override void initState() { super.initState(); - _loadMySeq(); + FirebaseDatabase.instance.goOnline(); + _initRoomRef(); } - /// (A) 내 user_seq를 로드하고 나서 방 레퍼런스 설정 + 리스너 등록 - Future _loadMySeq() async { + Future _initRoomRef() async { final prefs = await SharedPreferences.getInstance(); - // 예: 저장된 자료형에 따라 getString or getInt mySeq = prefs.getInt('my_user_seq')?.toString() ?? '0'; - // roomKey / FRD 설정 + final roomKey = 'korea-${widget.roomSeq}'; _roomRef = FirebaseDatabase.instance.ref('rooms/$roomKey'); - // 리스너 시작 + + // onDisconnect + connect_yn='Y' + final myUserRef = _roomRef.child('userInfo').child(mySeq); + myUserRef.onDisconnect().update({'connect_yn': 'N'}); + await myUserRef.update({'connect_yn': 'Y'}); + _listenRoomData(); } @override void dispose() { - _roomStreamSubscription?.cancel(); // ← 구독 해제 + _countdownTimer?.cancel(); + _roomStreamSubscription?.cancel(); super.dispose(); } void _listenRoomData() { _roomStream = _roomRef.onValue; - _roomStream?.listen((event) async { + _roomStreamSubscription = _roomStream?.listen((event) { final snapshot = event.snapshot; if (!snapshot.exists) { setState(() { @@ -105,20 +103,19 @@ class _WaitingRoomTeamPageState extends State { final roomInfoData = data['roomInfo'] as Map? ?? {}; final userInfoData = data['userInfo'] as Map? ?? {}; - // 현재 방 상태 final roomStatus = (roomInfoData['room_status'] ?? 'WAIT').toString().toUpperCase(); setState(() { - roomTitle = (roomInfoData['room_title'] ?? '') as String; - roomIntro = (roomInfoData['room_intro'] ?? '') as String; - openYn = (roomInfoData['open_yn'] ?? 'Y') as String; - roomPw = (roomInfoData['room_pw'] ?? '') as String; - runningTime = _toInt(roomInfoData['running_time'], 1); + roomTitle = (roomInfoData['room_title'] ?? '') as String; + roomIntro = (roomInfoData['room_intro'] ?? '') as String; + openYn = (roomInfoData['open_yn'] ?? 'Y') as String; + roomPw = (roomInfoData['room_pw'] ?? '') as String; + runningTime = _toInt(roomInfoData['running_time'], 1); numberOfPeople = _toInt(roomInfoData['number_of_people'], 10); - scoreOpenRange = (roomInfoData['score_open_range'] ?? 'PRIVATE') as String; - numberOfTeams = _toInt(roomInfoData['number_of_teams'], 1); + scoreOpenRange = (roomInfoData['score_open_range'] ?? 'PRIVATE') as String; + numberOfTeams = _toInt(roomInfoData['number_of_teams'], 1); - // 팀명 리스트 + // 팀명 final tStr = (roomInfoData['team_name_list'] ?? '') as String; if (tStr.isNotEmpty) { _teamNameList = tStr.split(',').map((e) => e.trim().toUpperCase()).toList(); @@ -126,14 +123,14 @@ class _WaitingRoomTeamPageState extends State { _teamNameList = List.generate(numberOfTeams, (i) => String.fromCharCode(65 + i)); } - // 방장 여부 + // 방장 roomMasterYn = 'N'; final masterSeq = roomInfoData['master_user_seq']; if (masterSeq != null && masterSeq.toString() == mySeq) { roomMasterYn = 'Y'; } - // 유저 목록 + // userList final tempList = >[]; userInfoData.forEach((userSeq, userMap) { tempList.add({ @@ -143,16 +140,16 @@ class _WaitingRoomTeamPageState extends State { 'team_name': userMap['team_name'] ?? '', 'score': userMap['score'] ?? 0, 'profile_img': userMap['profile_img'] ?? '', - 'department': userMap['department'] ?? '', 'introduce_myself': userMap['introduce_myself'] ?? '', 'ready_yn': (userMap['ready_yn'] ?? 'N').toString().toUpperCase(), + 'connect_yn': (userMap['connect_yn'] ?? 'Y').toString().toUpperCase(), }); }); _userList = tempList; _isLoading = false; }); - // 상태가 RUNNING이면 진행중 화면으로 + // 진행중 -> 이동 if (roomStatus == 'RUNNING' && !_movedToRunningPage) { _movedToRunningPage = true; Navigator.pushReplacement( @@ -167,20 +164,20 @@ class _WaitingRoomTeamPageState extends State { return; } - // (2) 여기서 "내 user_seq가 목록에 있는지" 검사 + // (C) create_dt 파싱 -> 1시간 카운트다운 + final createDtStr = (roomInfoData['create_dt'] ?? '') as String; + if (createDtStr.isNotEmpty && createDtStr.contains('T')) { + final dt = DateTime.tryParse(createDtStr); + if (dt != null) { + _createDt = dt; + _startCountdownTimer(); + } + } + + // 강퇴판별 final amIStillHere = _userList.any((u) => u['user_seq'].toString() == mySeq); - - // (3) 만약 내가 목록에서 사라졌고, - // 아직 안내하지 않았으며(_kickedOut == false), - // 내가 방장도 아니고(roomMasterYn != 'Y'), - // 방이 진행중/종료가 아닌 상태면 => "강퇴"로 간주 if (!amIStillHere && !_kickedOut && roomMasterYn != 'Y') { - // ※ 방이 이미 RUNNING이면 그냥 게임중에 퇴장인지, - // 방이 DELETE 상태인지 등 필요 시 조건 보강 - - _kickedOut = true; // 중복 안내 막기 - - // (★) 강퇴 안내 + 메인으로 이동 + _kickedOut = true; Future.delayed(Duration.zero, () async { await showResponseDialog(context, '안내', '강퇴되었습니다.'); Navigator.pushReplacement( @@ -190,7 +187,6 @@ class _WaitingRoomTeamPageState extends State { }); } }, onError: (error) { - print('FRD onError: $error'); setState(() { _isLoading = false; roomTitle = '오류 발생'; @@ -198,12 +194,51 @@ class _WaitingRoomTeamPageState extends State { }); } - // ───────────────────────────────────────── - // [추가] 뒤로가기 -> 방 나가기 - // ───────────────────────────────────────── + // 카운트다운 + void _startCountdownTimer() { + if (_countdownTimer != null && _countdownTimer!.isActive) { + return; + } + if (_createDt == null) return; + + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + final endTime = _createDt!.add(const Duration(hours: 1)); + final now = DateTime.now(); + final diff = endTime.difference(now); + + if (diff.isNegative) { + timer.cancel(); + _remaining = const Duration(seconds: 0); + _onAutoTimeout(); + } else { + setState(() { + _remaining = diff; + }); + } + }); + } + + void _onAutoTimeout() { + // 자동 종료 -> 방장=나가기(방삭제), 일반=나가기 + _requestLeaveRoom(); + } + + Future _requestLeaveRoom() async { + try { + final reqBody = {"room_seq": "${widget.roomSeq}"}; + final response = await Api.serverRequest(uri: '/room/score/game/leave', body: reqBody); + // ... + } catch (e) { + // ... + } + if (mounted) { + Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const MainPage())); + } + } + + // 뒤로가기 -> 방 나가기 Future _onLeaveRoom() async { if (roomMasterYn == 'Y') { - // 방장 -> 경고 모달 final confirm = await showDialog( context: context, barrierDismissible: false, @@ -215,15 +250,9 @@ class _WaitingRoomTeamPageState extends State { side: const BorderSide(color: Colors.black, width: 2), ), title: const Center( - child: Text( - '방 나가기', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black), - ), - ), - content: const Text( - '방장이 나가면 방은 삭제됩니다.\n정말 나가시겠습니까?', - style: TextStyle(fontSize: 14, color: Colors.black), + child: Text('방 나가기', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black)), ), + content: const Text('방장이 나가면 방은 삭제됩니다.\n정말 나가시겠습니까?', style: TextStyle(fontSize: 14)), actionsAlignment: MainAxisAlignment.spaceEvenly, actions: [ TextButton( @@ -231,17 +260,19 @@ class _WaitingRoomTeamPageState extends State { style: TextButton.styleFrom( backgroundColor: Colors.black, foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), child: const Text('취소'), ), TextButton( - onPressed: () => Navigator.pop(context, true), + onPressed: () { + final myUserRef = _roomRef.child('userInfo').child(mySeq); + myUserRef.onDisconnect().cancel(); + Navigator.pop(context, true); + }, style: TextButton.styleFrom( backgroundColor: Colors.black, foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), ), child: const Text('확인'), @@ -252,75 +283,9 @@ class _WaitingRoomTeamPageState extends State { ); if (confirm != true) return; - try { - final reqBody = {"room_seq": "${widget.roomSeq}"}; - final response = await Api.serverRequest(uri: '/room/score/game/leave', body: reqBody); - if (response['result'] == 'OK') { - final resp = response['response']; - if (resp != null && resp['result'] == 'OK') { - Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const MainPage())); - } else { - final msg = resp?['response_info']?['msg_content'] ?? '방 나가기 실패'; - final again = await showYesNoDialog( - context: context, - title: '오류', - message: '$msg\n그래도 나가시겠습니까?', - yesNo: true, - ); - if (again == true) { - Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const MainPage())); - } - } - } else { - final again = await showYesNoDialog( - context: context, - title: '오류', - message: '서버오류\n그래도 나가시겠습니까?', - yesNo: true, - ); - if (again == true) { - Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const MainPage())); - } - } - } catch (e) { - final again = await showYesNoDialog( - context: context, - title: '오류', - message: '$e\n그래도 나가시겠습니까?', - yesNo: true, - ); - if (again == true) { - Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const MainPage())); - } - } + await _requestLeaveRoom(); } else { - // 일반 유저 - try { - final reqBody = {"room_seq": "${widget.roomSeq}"}; - final response = await Api.serverRequest(uri: '/room/score/game/leave', body: reqBody); - if (response['result'] == 'OK') { - final resp = response['response'] ?? {}; - if (resp['result'] == 'OK') { - Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const MainPage())); - } else { - final msg = resp['response_info']?['msg_content'] ?? '나가기 실패'; - final again = await showYesNoDialog(context: context, title: '오류', message: msg, yesNo: true); - if (again == true) { - Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const MainPage())); - } - } - } else { - final again = await showYesNoDialog(context: context, title: '오류', message: '서버 통신 오류', yesNo: true); - if (again == true) { - Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const MainPage())); - } - } - } catch (e) { - final again = await showYesNoDialog(context: context, title: '오류', message: '$e', yesNo: true); - if (again == true) { - Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const MainPage())); - } - } + await _requestLeaveRoom(); } } @@ -333,22 +298,14 @@ class _WaitingRoomTeamPageState extends State { return defaultVal; } - // ───────────────────────────────────────── - // 상단 버튼들: 방장 = 3개, 일반 = 2개 - // READY 버튼에 "준비"/"준비완료" 표시 - // 게임 시작: 전체 READY=Y 필요 - // ───────────────────────────────────────── + // 상단 버튼 Widget _buildTopButtons() { if (_isLoading) return const SizedBox(); - // 내 READY 상태 - final me = _userList.firstWhere( - (u) => (u['user_seq'] ?? '0') == mySeq, - orElse: () => {}, - ); + final me = _userList.firstWhere((u) => (u['user_seq'] ?? '0') == mySeq, orElse: () => {}); final myReadyYn = (me['ready_yn'] ?? 'N').toString().toUpperCase(); final bool isReady = (myReadyYn == 'Y'); - final String readyLabel = isReady ? '준비완료' : '준비'; + final readyLabel = isReady ? '준비완료' : '준비'; final btnStyle = ElevatedButton.styleFrom( backgroundColor: Colors.white, @@ -357,9 +314,7 @@ class _WaitingRoomTeamPageState extends State { ); if (roomMasterYn == 'Y') { - // 방장 -> [방 설정], [준비/준비완료], [게임 시작] return Row( - mainAxisAlignment: MainAxisAlignment.center, children: [ Expanded( child: Container( @@ -394,9 +349,7 @@ class _WaitingRoomTeamPageState extends State { ], ); } else { - // 일반 -> [방 설정], [준비/준비완료] return Row( - mainAxisAlignment: MainAxisAlignment.center, children: [ Expanded( child: Container( @@ -423,16 +376,11 @@ class _WaitingRoomTeamPageState extends State { } } - /// READY 토글 Future _onToggleReady() async { try { - // 내 데이터 - final me = _userList.firstWhere( - (u) => (u['user_seq'] ?? '') == mySeq, - orElse: () => {}, - ); + final me = _userList.firstWhere((u) => (u['user_seq'] ?? '') == mySeq, orElse: () => {}); final myReadyYn = (me['ready_yn'] ?? 'N').toString().toUpperCase(); - final bool isReady = (myReadyYn == 'Y'); + final isReady = (myReadyYn == 'Y'); final newYn = isReady ? 'N' : 'Y'; final userRef = _roomRef.child('userInfo').child(mySeq); @@ -442,7 +390,6 @@ class _WaitingRoomTeamPageState extends State { } } - /// 방 설정 열기 Future _onOpenRoomSetting() async { final roomInfo = { "room_seq": "${widget.roomSeq}", @@ -465,16 +412,12 @@ class _WaitingRoomTeamPageState extends State { builder: (_) => RoomSettingModal(roomInfo: roomInfo), ); if (result == 'refresh') { - // do something + // ... } } - /// 게임 시작 (전체 READY=Y 필요) Future _onGameStart() async { - final notReady = _userList.any((u) { - final ry = (u['ready_yn'] ?? 'N').toString().toUpperCase(); - return (ry != 'Y'); - }); + final notReady = _userList.any((u) => (u['ready_yn'] ?? 'N').toString().toUpperCase() != 'Y'); if (notReady) { showResponseDialog(context, '안내', 'READY되지 않은 참가자가 있습니다(방장 포함).'); return; @@ -485,30 +428,85 @@ class _WaitingRoomTeamPageState extends State { "room_type": "TEAM", }; try { - final response = await Api.serverRequest( - uri: '/room/score/game/start', - body: requestBody, - ); + final response = await Api.serverRequest(uri: '/room/score/game/start', body: requestBody); if (response['result'] == 'OK') { final resp = response['response'] ?? {}; if (resp['result'] == 'OK') { print('게임 시작 요청 성공(팀전)'); } else { - final msgTitle = resp['response_info']?['msg_title'] ?? '오류'; - final msgContent = resp['response_info']?['msg_content'] ?? '게임 시작 실패'; - showResponseDialog(context, msgTitle, msgContent); + // ... } } else { - showResponseDialog(context, '실패', '서버 통신 오류'); + // ... } } catch (e) { - showResponseDialog(context, '오류', '$e'); + // ... } } - // ───────────────────────────────────────── - // 사회자 / 팀 섹션 / 대기중 / Seat - // ───────────────────────────────────────── + // (★) 카운트다운 표시용 + String _formatDuration(Duration d) { + final mm = d.inMinutes.remainder(60).toString().padLeft(2, '0'); + final ss = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$mm:$ss'; + } + + @override + Widget build(BuildContext context) { + final countdownStr = _formatDuration(_remaining); + + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.black, + elevation: 0, + // 방 제목 + 남은시간 + title: Text( + (roomTitle.isNotEmpty ? roomTitle : '방 제목') + ' [$countdownStr]', + style: const TextStyle(color: Colors.white), + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.white), + onPressed: _onLeaveRoom, + ), + ), + bottomNavigationBar: Container( + height: 50, + decoration: BoxDecoration( + color: Colors.grey.shade300, + border: Border.all(color: Colors.black, width: 1), + ), + child: const Center( + child: Text('구글 광고', style: TextStyle(color: Colors.black)), + ), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTopButtons(), + const SizedBox(height: 20), + + const Text('사회자', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black)), + const SizedBox(height: 8), + _buildAdminSection(), + const SizedBox(height: 20), + + const Text('팀별 참가자', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black)), + const SizedBox(height: 8), + _buildTeamSection(), + const SizedBox(height: 20), + + _buildWaitSection(), + ], + ), + ), + ); + } + Widget _buildAdminSection() { final adminList = _userList.where((u) { final pType = (u['participant_type'] ?? '').toString().toUpperCase(); @@ -516,7 +514,6 @@ class _WaitingRoomTeamPageState extends State { }).toList(); return Container( - width: double.infinity, padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.white, @@ -553,7 +550,6 @@ class _WaitingRoomTeamPageState extends State { if (teamMap.isEmpty) { return Container( - width: double.infinity, padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.white, @@ -660,13 +656,14 @@ class _WaitingRoomTeamPageState extends State { Widget _buildSeat(Map user) { final userName = user['nickname'] ?? '유저'; final profileImg = user['profile_img'] ?? ''; - final readyYn = user['ready_yn'] ?? 'N'; - final isReady = (readyYn == 'Y'); - final isMaster = (roomMasterYn == 'Y'); + final readyYn = (user['ready_yn'] ?? 'N').toString().toUpperCase(); + final connectYn = (user['connect_yn'] ?? 'Y').toString().toUpperCase(); + final bool isReady = (readyYn == 'Y'); + final bool isDisconnected = (connectYn == 'N'); + final bool isMaster = (roomMasterYn == 'Y'); return GestureDetector( onTap: () async { - // 유저 정보 모달 final result = await showDialog( context: context, barrierDismissible: false, @@ -674,7 +671,7 @@ class _WaitingRoomTeamPageState extends State { userData: user, isRoomMaster: isMaster, roomSeq: widget.roomSeq, - roomTypeName: widget.roomType.toUpperCase(), // "TEAM" + roomTypeName: widget.roomType.toUpperCase(), teamNameList: _teamNameList, ), ); @@ -686,7 +683,6 @@ class _WaitingRoomTeamPageState extends State { width: 60, margin: const EdgeInsets.only(right: 8), child: Column( - mainAxisSize: MainAxisSize.min, children: [ Container( width: 50, @@ -694,36 +690,51 @@ class _WaitingRoomTeamPageState extends State { decoration: BoxDecoration( color: Colors.white, border: Border.all( - color: isReady ? Colors.red : Colors.black, - width: isReady ? 2 : 1, + color: isDisconnected + ? Colors.orange + : (isReady ? Colors.red : Colors.black), + width: isDisconnected ? 2 : (isReady ? 2 : 1), ), borderRadius: BorderRadius.circular(20), - boxShadow: isReady - ? [ - BoxShadow( - color: Colors.redAccent.withOpacity(0.6), - blurRadius: 8, - spreadRadius: 2, - offset: const Offset(0, 0), - ) - ] - : [], + boxShadow: [ + if (isReady) + BoxShadow( + color: Colors.redAccent.withOpacity(0.6), + blurRadius: 8, + spreadRadius: 2, + ), + if (isDisconnected) + BoxShadow( + color: Colors.orangeAccent.withOpacity(0.6), + blurRadius: 8, + spreadRadius: 2, + ), + ], ), child: ClipRRect( borderRadius: BorderRadius.circular(20), - child: Image.network( - 'https://eldsoft.com:8097/images$profileImg', - fit: BoxFit.cover, - errorBuilder: (ctx, err, st) { - return const Center( - child: Text( - '이미지\n불가', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 10), + child: isDisconnected + ? const Center( + child: Text( + '!', + style: TextStyle( + fontSize: 20, + color: Colors.orange, + fontWeight: FontWeight.bold, + ), + ), + ) + : Image.network( + 'https://eldsoft.com:8097/images$profileImg', + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Center( + child: Text( + '이미지\n불가', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 10), + ), + ), ), - ); - }, - ), ), ), const SizedBox(height: 2), @@ -733,63 +744,4 @@ class _WaitingRoomTeamPageState extends State { ), ); } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.white, - appBar: AppBar( - backgroundColor: Colors.black, - elevation: 0, - title: const Text('대기 방 (팀전)', style: TextStyle(color: Colors.white)), - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios, color: Colors.white), - onPressed: _onLeaveRoom, // 나가기 - ), - ), - bottomNavigationBar: Container( - height: 50, - decoration: BoxDecoration( - color: Colors.grey.shade300, - border: Border.all(color: Colors.black, width: 1), - ), - child: const Center( - child: Text('구글 광고', style: TextStyle(color: Colors.black)), - ), - ), - body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : SingleChildScrollView( - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Text( - roomTitle.isNotEmpty ? roomTitle : '방 제목', - style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.black), - textAlign: TextAlign.center, - ), - ), - const SizedBox(height: 8), - - _buildTopButtons(), - const SizedBox(height: 20), - - const Text('사회자', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black)), - const SizedBox(height: 8), - _buildAdminSection(), - const SizedBox(height: 20), - - const Text('팀별 참가자', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.black)), - const SizedBox(height: 8), - _buildTeamSection(), - const SizedBox(height: 20), - - _buildWaitSection(), - ], - ), - ), - ); - } } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index f3cb340..3c6371d 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import file_selector_macos import firebase_auth import firebase_core import firebase_database +import google_sign_in_ios import shared_preferences_foundation import webview_flutter_wkwebview @@ -17,6 +18,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseDatabasePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseDatabasePlugin")) + FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) FLTWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "FLTWebViewFlutterPlugin")) } diff --git a/my_release_key.jks b/my_release_key.jks new file mode 100644 index 0000000000000000000000000000000000000000..be46fefbd94b3fc93d2a27b979a239165c72b181 GIT binary patch literal 2724 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