diff --git a/.env b/.env new file mode 100644 index 0000000..c4063e0 --- /dev/null +++ b/.env @@ -0,0 +1,6 @@ +API_BASE_URL=https://semialuminous-countryfied-pamala.ngrok-free.dev +# 카카오 앱 키 +KAKAO_NATIVE_APP_KEY=577b0a9bb06eda95fc517ebd07a18c93 +#GOOGLE_CLIENT_ID=YOUR_GOOGLE_CLIENT_ID +# 구글 클라이언트 ID (나중에 추가) +# \ No newline at end of file diff --git a/.env,example b/.env,example new file mode 100644 index 0000000..5df15e9 --- /dev/null +++ b/.env,example @@ -0,0 +1,3 @@ +# 이 파일을 복사해서 .env 파일을 만들고, +# 본인의 ngrok URL을 채워넣으세요. +API_BASE_URL=https:// \ No newline at end of file diff --git a/.gitignore b/.gitignore index 79c113f..d6998e8 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release +.env.gradle/ +local.properties diff --git a/.gradle/8.11.1/checksums/checksums.lock b/.gradle/8.11.1/checksums/checksums.lock new file mode 100644 index 0000000..f0c37ca Binary files /dev/null and b/.gradle/8.11.1/checksums/checksums.lock differ diff --git a/.gradle/8.11.1/checksums/md5-checksums.bin b/.gradle/8.11.1/checksums/md5-checksums.bin new file mode 100644 index 0000000..c6af5c5 Binary files /dev/null and b/.gradle/8.11.1/checksums/md5-checksums.bin differ diff --git a/.gradle/8.11.1/checksums/sha1-checksums.bin b/.gradle/8.11.1/checksums/sha1-checksums.bin new file mode 100644 index 0000000..ecfec9b Binary files /dev/null and b/.gradle/8.11.1/checksums/sha1-checksums.bin differ diff --git a/.gradle/8.11.1/executionHistory/executionHistory.lock b/.gradle/8.11.1/executionHistory/executionHistory.lock new file mode 100644 index 0000000..6bfb5df Binary files /dev/null and b/.gradle/8.11.1/executionHistory/executionHistory.lock differ diff --git a/.gradle/8.11.1/fileChanges/last-build.bin b/.gradle/8.11.1/fileChanges/last-build.bin new file mode 100644 index 0000000..f76dd23 Binary files /dev/null and b/.gradle/8.11.1/fileChanges/last-build.bin differ diff --git a/.gradle/8.11.1/fileHashes/fileHashes.bin b/.gradle/8.11.1/fileHashes/fileHashes.bin new file mode 100644 index 0000000..a5c20e2 Binary files /dev/null and b/.gradle/8.11.1/fileHashes/fileHashes.bin differ diff --git a/.gradle/8.11.1/fileHashes/fileHashes.lock b/.gradle/8.11.1/fileHashes/fileHashes.lock new file mode 100644 index 0000000..2ba246b Binary files /dev/null and b/.gradle/8.11.1/fileHashes/fileHashes.lock differ diff --git a/.gradle/8.11.1/fileHashes/resourceHashesCache.bin b/.gradle/8.11.1/fileHashes/resourceHashesCache.bin new file mode 100644 index 0000000..0ed72e1 Binary files /dev/null and b/.gradle/8.11.1/fileHashes/resourceHashesCache.bin differ diff --git a/.gradle/8.11.1/gc.properties b/.gradle/8.11.1/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/.gradle/buildOutputCleanup/buildOutputCleanup.lock new file mode 100644 index 0000000..440d621 Binary files /dev/null and b/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/.gradle/buildOutputCleanup/cache.properties b/.gradle/buildOutputCleanup/cache.properties new file mode 100644 index 0000000..a3e81a1 --- /dev/null +++ b/.gradle/buildOutputCleanup/cache.properties @@ -0,0 +1,2 @@ +#Tue Oct 14 01:37:09 KST 2025 +gradle.version=8.11.1 diff --git a/.gradle/config.properties b/.gradle/config.properties new file mode 100644 index 0000000..64de5cb --- /dev/null +++ b/.gradle/config.properties @@ -0,0 +1,2 @@ +#Sun Nov 09 17:16:01 KST 2025 +java.home=C\:\\Program Files\\Android\\Android Studio\\jbr diff --git a/.gradle/file-system.probe b/.gradle/file-system.probe new file mode 100644 index 0000000..26f3ede Binary files /dev/null and b/.gradle/file-system.probe differ diff --git a/.gradle/noVersion/buildLogic.lock b/.gradle/noVersion/buildLogic.lock new file mode 100644 index 0000000..8c0312f Binary files /dev/null and b/.gradle/noVersion/buildLogic.lock differ diff --git a/.gradle/vcs-1/gc.properties b/.gradle/vcs-1/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/.metadata b/.metadata index d024a4b..84f56b1 100644 --- a/.metadata +++ b/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "fcf2c11572af6f390246c056bc905eca609533a0" + revision: "d693b4b9dbac2acd4477aea4555ca6dcbea44ba2" channel: "stable" project_type: app @@ -13,14 +13,26 @@ project_type: app migration: platforms: - platform: root - create_revision: fcf2c11572af6f390246c056bc905eca609533a0 - base_revision: fcf2c11572af6f390246c056bc905eca609533a0 + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 - platform: android - create_revision: fcf2c11572af6f390246c056bc905eca609533a0 - base_revision: fcf2c11572af6f390246c056bc905eca609533a0 + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 - platform: ios - create_revision: fcf2c11572af6f390246c056bc905eca609533a0 - base_revision: fcf2c11572af6f390246c056bc905eca609533a0 + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + - platform: linux + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + - platform: macos + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + - platform: web + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + - platform: windows + create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 + base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2 # User provided section diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a2fb51b..9be0d78 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -8,7 +8,7 @@ plugins { android { namespace = "com.example.guardpayfront" compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion + ndkVersion = "27.0.12077973" compileOptions { sourceCompatibility = JavaVersion.VERSION_11 @@ -41,4 +41,4 @@ android { flutter { source = "../.." -} +} \ No newline at end of file diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 399f698..91333a3 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -1,7 +1,3 @@ - - + \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a3c597d..9d9e2f2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,45 +1,73 @@ + + + + + + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> + + + + - + + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" /> + + + + + + + + + + + + + + + + + + + + + - + - + - + \ No newline at end of file diff --git a/android/app/src/main/java/com/example/guardpayfront/MainActivity.java b/android/app/src/main/java/com/example/guardpayfront/MainActivity.java deleted file mode 100644 index f001b5d..0000000 --- a/android/app/src/main/java/com/example/guardpayfront/MainActivity.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.example.guardpayfront; - -import io.flutter.embedding.android.FlutterActivity; - -public class MainActivity extends FlutterActivity { -} diff --git a/android/app/src/main/kotlin/com/example/guardpayfront/MainActivity.kt b/android/app/src/main/kotlin/com/example/guardpayfront/MainActivity.kt new file mode 100644 index 0000000..95b4421 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/guardpayfront/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.guardpayfront + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..018ee3c --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,11 @@ + + + + + + + + + dapi.kakao.com + + \ No newline at end of file diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 89176ef..58af68c 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -18,4 +18,4 @@ subprojects { tasks.register("clean") { delete(rootProject.layout.buildDirectory) -} +} \ No newline at end of file diff --git a/android/gradle.properties b/android/gradle.properties index f018a61..6996107 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,5 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true +flutter.experimental.enable-impeller=false + diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index ac3b479..6240499 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,8 @@ +# ? ?? ??: android/gradle/wrapper/gradle-wrapper.properties +# ? ?? ?? ?? ??? ?? ???? ?? + distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index ab39a10..0a3effc 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -22,4 +22,4 @@ plugins { id("org.jetbrains.kotlin.android") version "2.1.0" apply false } -include(":app") +include(":app") \ No newline at end of file diff --git a/assets/images/AI_icon.png b/assets/images/AI_icon.png new file mode 100644 index 0000000..53f3dfe Binary files /dev/null and b/assets/images/AI_icon.png differ diff --git a/assets/images/TestProfile.jpg b/assets/images/TestProfile.jpg new file mode 100644 index 0000000..3a58870 Binary files /dev/null and b/assets/images/TestProfile.jpg differ diff --git a/assets/images/alert_icon.png b/assets/images/alert_icon.png new file mode 100644 index 0000000..d3bd3b6 Binary files /dev/null and b/assets/images/alert_icon.png differ diff --git a/assets/images/character_icon.png b/assets/images/character_icon.png new file mode 100644 index 0000000..d989a01 Binary files /dev/null and b/assets/images/character_icon.png differ diff --git a/assets/images/checkBox_icon.png b/assets/images/checkBox_icon.png new file mode 100644 index 0000000..36e731b Binary files /dev/null and b/assets/images/checkBox_icon.png differ diff --git a/assets/images/check_icon.png b/assets/images/check_icon.png new file mode 100644 index 0000000..b546257 Binary files /dev/null and b/assets/images/check_icon.png differ diff --git a/assets/images/google_logo.png b/assets/images/google_logo.png new file mode 100644 index 0000000..2e35d33 Binary files /dev/null and b/assets/images/google_logo.png differ diff --git a/assets/images/hana_logo.png b/assets/images/hana_logo.png new file mode 100644 index 0000000..2ee3438 Binary files /dev/null and b/assets/images/hana_logo.png differ diff --git a/assets/images/home_icon.png b/assets/images/home_icon.png new file mode 100644 index 0000000..a00991c Binary files /dev/null and b/assets/images/home_icon.png differ diff --git a/assets/images/ibk_logo.png b/assets/images/ibk_logo.png new file mode 100644 index 0000000..53408a6 Binary files /dev/null and b/assets/images/ibk_logo.png differ diff --git a/assets/images/kakao_logo.png b/assets/images/kakao_logo.png new file mode 100644 index 0000000..c77b5b6 Binary files /dev/null and b/assets/images/kakao_logo.png differ diff --git a/assets/images/kakaobank_logo.jpg b/assets/images/kakaobank_logo.jpg new file mode 100644 index 0000000..4c62e8d Binary files /dev/null and b/assets/images/kakaobank_logo.jpg differ diff --git a/assets/images/kb_logo.png b/assets/images/kb_logo.png new file mode 100644 index 0000000..853d21b Binary files /dev/null and b/assets/images/kb_logo.png differ diff --git a/assets/images/logo.png b/assets/images/logo.png new file mode 100644 index 0000000..69eae60 Binary files /dev/null and b/assets/images/logo.png differ diff --git a/assets/images/main_icon.png b/assets/images/main_icon.png new file mode 100644 index 0000000..d584f97 Binary files /dev/null and b/assets/images/main_icon.png differ diff --git a/assets/images/main_logo-removebg-preview.png b/assets/images/main_logo-removebg-preview.png new file mode 100644 index 0000000..1b35488 Binary files /dev/null and b/assets/images/main_logo-removebg-preview.png differ diff --git a/assets/images/main_logo.png b/assets/images/main_logo.png new file mode 100644 index 0000000..a8e203f Binary files /dev/null and b/assets/images/main_logo.png differ diff --git a/assets/images/map_icon.png b/assets/images/map_icon.png new file mode 100644 index 0000000..3478eb4 Binary files /dev/null and b/assets/images/map_icon.png differ diff --git a/assets/images/money_icon.png b/assets/images/money_icon.png new file mode 100644 index 0000000..d2ab6a2 Binary files /dev/null and b/assets/images/money_icon.png differ diff --git a/assets/images/nh_logo.png b/assets/images/nh_logo.png new file mode 100644 index 0000000..9633e8a Binary files /dev/null and b/assets/images/nh_logo.png differ diff --git a/assets/images/quiz_icon.png b/assets/images/quiz_icon.png new file mode 100644 index 0000000..be0f08d Binary files /dev/null and b/assets/images/quiz_icon.png differ diff --git a/assets/images/quiz_icon2.png b/assets/images/quiz_icon2.png new file mode 100644 index 0000000..4fd3acc Binary files /dev/null and b/assets/images/quiz_icon2.png differ diff --git a/assets/images/settings_icon.png b/assets/images/settings_icon.png new file mode 100644 index 0000000..bfbf326 Binary files /dev/null and b/assets/images/settings_icon.png differ diff --git a/assets/images/shinhan_logo.png b/assets/images/shinhan_logo.png new file mode 100644 index 0000000..020c3c8 Binary files /dev/null and b/assets/images/shinhan_logo.png differ diff --git a/assets/images/shop_icon.png b/assets/images/shop_icon.png new file mode 100644 index 0000000..e00c194 Binary files /dev/null and b/assets/images/shop_icon.png differ diff --git a/assets/images/toss_logo.png b/assets/images/toss_logo.png new file mode 100644 index 0000000..c610561 Binary files /dev/null and b/assets/images/toss_logo.png differ diff --git a/assets/images/video_icon.png b/assets/images/video_icon.png new file mode 100644 index 0000000..923a5b3 Binary files /dev/null and b/assets/images/video_icon.png differ diff --git a/assets/images/woori_logo.png b/assets/images/woori_logo.png new file mode 100644 index 0000000..a652921 Binary files /dev/null and b/assets/images/woori_logo.png differ diff --git a/assets/images/youtube_icon.png b/assets/images/youtube_icon.png new file mode 100644 index 0000000..5abdb7b Binary files /dev/null and b/assets/images/youtube_icon.png differ diff --git a/backup/config/theme.dart b/backup/config/theme.dart new file mode 100644 index 0000000..0150b45 --- /dev/null +++ b/backup/config/theme.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +// 앱 전체에서 사용할 기본 색상 정의 +const Color primaryColor = Color(0xFF6AA84F); // GuardPay Green +const Color lightGrayBorder = Color(0xFFD0D0D0); // 연한 회색 테두리 +const Color lightBackground = Color(0xFFF9F5EC); // 베이지 배경색 + +// 앱의 전체 테마를 정의하는 함수 +ThemeData appTheme() { + return ThemeData( + // 앱의 전반적인 색상 톤을 설정합니다. + primaryColor: primaryColor, + // 배경색을 베이지색으로 설정합니다. + scaffoldBackgroundColor: lightBackground, + + // 입력창(TextField)의 기본 디자인을 설정합니다. + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: Colors.white, + // 힌트 텍스트 색상 설정 + hintStyle: TextStyle(color: Color(0xFFBDBDBD)), + + // 기본 테두리 스타일: 둥근 모서리(8.0)와 연한 회색 테두리 + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + borderSide: BorderSide(color: lightGrayBorder), + ), + // 활성화된 상태의 테두리 + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + borderSide: BorderSide(color: lightGrayBorder), + ), + // 포커스 상태의 테두리 (주요 색상으로 강조) + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + borderSide: BorderSide(color: primaryColor, width: 2.0), + ), + // 비활성화 상태의 테두리 (인증 완료 시 사용) + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + borderSide: BorderSide(color: lightGrayBorder, width: 1.0), + ), + // 입력 필드 내부 패딩 + contentPadding: EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0), + ), + + // Checkbox의 색상 설정 + checkboxTheme: CheckboxThemeData( + fillColor: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.selected)) { + return primaryColor; + } + return Colors.white; + }), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + ) + ); +} diff --git a/backup/core/services/jwt_util.dart b/backup/core/services/jwt_util.dart new file mode 100644 index 0000000..e69de29 diff --git a/backup/core/services/token_storage.dart b/backup/core/services/token_storage.dart new file mode 100644 index 0000000..e69de29 diff --git a/backup/features/auth/screens/home_screen.dart b/backup/features/auth/screens/home_screen.dart new file mode 100644 index 0000000..63cfbf6 --- /dev/null +++ b/backup/features/auth/screens/home_screen.dart @@ -0,0 +1,293 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter/cupertino.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + final storage = const FlutterSecureStorage(); + String? accessToken; + int _selectedIndex = 0; + + @override + void initState() { + super.initState(); + _loadToken(); + } + + Future _loadToken() async { + final token = await storage.read(key: 'accessToken'); + setState(() { + accessToken = token; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF7F7F7), + body: SafeArea( + child: Column( + children: [ + // 🔹 상단 검색창 + 알림/설정 아이콘 (이미지 버전) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + // 왼쪽 메뉴 아이콘 + const Icon(Icons.menu, color: Colors.black87, size: 26), + const SizedBox(width: 10), + + // 검색창 아이콘 + 입력창 + const Icon(Icons.search, color: Colors.black54), + const SizedBox(width: 8), + Expanded( + child: TextField( + decoration: InputDecoration( + hintText: '검색어를 입력해주세요.', + hintStyle: const TextStyle(color: Colors.grey), + border: InputBorder.none, + ), + ), + ), + + // 🔔 알림 아이콘 (이미지) + Image.asset( + 'assets/images/alram_icon.png', + width: 26, + height: 26, + fit: BoxFit.contain, + ), + const SizedBox(width: 14), + + // ⚙️ 설정 아이콘 (이미지) + Image.asset( + 'assets/images/setting_icon.png', + width: 26, + height: 26, + fit: BoxFit.contain, + ), + ], + ), + ), + + + // 🔹 상단 노란 배너 + Container( + width: 358, + height: 85, + margin: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFFFFF8C6), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Image.asset( + 'assets/images/character_icon.png', + width: 60, + height: 60, + fit: BoxFit.contain, + ), + const SizedBox(width: 25), + const Expanded( + child: Text( + '안전 송금, 퀴즈로 배우자!\n퀴즈 풀고 포인트를 모아요!', + style: TextStyle( + color: Colors.black87, + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.4, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 16), + + // 🔹 아래 카드 3개 + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + _buildCard( + title: '역량 진단 시작하기', + subtitle: '나의 금융 이해도를 측정해보세요!', + imagePath: 'assets/images/check_icon.png', + onTap: () { + Navigator.pushNamed(context, '/abilityTest'); + }, + ), + _buildCard( + title: '금융 퀴즈 도전', + subtitle: '맞히면 포인트가 쌓여요!', + imagePath: 'assets/images/quiz_icon.png', + onTap: () { + Navigator.pushNamed(context, '/quiz'); + }, + ), + _buildCard( + title: '보이스피싱 예방 영상', + subtitle: '보이스피싱 수법과 대처법을\n영상으로 확인하세요!', + imagePath: 'assets/images/video_icon.png', + onTap: () { + Navigator.pushNamed(context, '/video'); + }, + ), + ], + ), + ), + ), + + // 🔹 하단 네비게이션바 (이미지 라벨 포함된 아이콘만) + Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border( + top: BorderSide(color: Colors.grey.shade300, width: 1), + ), + ), + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _BottomImageOnlyIcon( + imagePath: 'assets/images/home_icon.png', + isActive: _selectedIndex == 0, + onTap: () => setState(() => _selectedIndex = 0), + ), + _BottomImageOnlyIcon( + imagePath: 'assets/images/AI_icon.png', + isActive: _selectedIndex == 1, + onTap: () => setState(() => _selectedIndex = 1), + ), + _BottomImageOnlyIcon( + imagePath: 'assets/images/shop_icon.png', + isActive: _selectedIndex == 2, + onTap: () => setState(() => _selectedIndex = 2), + ), + _BottomImageOnlyIcon( + imagePath: 'assets/images/money_icon.png', + isActive: _selectedIndex == 3, + onTap: () => setState(() => _selectedIndex = 3), + ), + _BottomImageOnlyIcon( + imagePath: 'assets/images/map_icon.png', + isActive: _selectedIndex == 4, + onTap: () => setState(() => _selectedIndex = 4), + ), + ], + ), + ), + ], + ), + ), + ); + } + + // 🔹 카드 위젯 (오른쪽 하단 이미지) + Widget _buildCard({ + required String title, + required String subtitle, + required String imagePath, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 358, + height: 137, + margin: const EdgeInsets.symmetric(vertical: 20), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.15), + spreadRadius: 1, + blurRadius: 5, + offset: const Offset(0, 2), + ), + ], + ), + child: Stack( + children: [ + Positioned( + left: 0, + top: 20, + child: SizedBox( + width: 250, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + const SizedBox(height: 8), + Text( + subtitle, + style: const TextStyle( + color: Colors.black54, + fontSize: 15, + ), + ), + ], + ), + ), + ), + Positioned( + right: 8, + bottom: 8, + child: Image.asset( + imagePath, + width: 60, + height: 60, + fit: BoxFit.contain, + ), + ), + ], + ), + ), + ); + } +} + +// 🔹 하단 네비게이션 이미지 아이콘 (텍스트 라벨 제거) +class _BottomImageOnlyIcon extends StatelessWidget { + final String imagePath; + final bool isActive; + final VoidCallback onTap; + + const _BottomImageOnlyIcon({ + required this.imagePath, + required this.isActive, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Image.asset( + imagePath, + width: 45, + height: 45, + color: isActive ? null : Colors.black54, // 비활성 시 약간 어둡게 처리 + ), + ); + } +} diff --git a/backup/features/auth/screens/login_screen.dart b/backup/features/auth/screens/login_screen.dart new file mode 100644 index 0000000..d4ea078 --- /dev/null +++ b/backup/features/auth/screens/login_screen.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _storage = const FlutterSecureStorage(); + bool _isLoading = false; + bool _obscureText = true; + + Future _handleLogin() async { + if (_emailController.text.isEmpty || _passwordController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('이메일과 비밀번호를 모두 입력해주세요.')), + ); + return; + } + + const apiUrl = 'http://10.0.2.2:8080/api/auth/login'; + final loginData = { + 'email': _emailController.text, + 'password': _passwordController.text, + }; + + setState(() => _isLoading = true); + try { + final response = await http.post( + Uri.parse(apiUrl), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(loginData), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + + // ✅ 토큰 저장 + await _storage.write(key: 'accessToken', value: data['accessToken']); + await _storage.write(key: 'refreshToken', value: data['refreshToken']); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('로그인 성공!')), + ); + + // ✅ 홈 화면으로 이동 (로그인 페이지는 제거) + Navigator.pushReplacementNamed(context, '/home'); + } else { + final error = jsonDecode(response.body); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error['message'] ?? '로그인 실패')), + ); + } + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('서버와 통신할 수 없습니다.')), + ); + } finally { + setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF9F5EC), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 60), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 110), + const Text( + 'GuardPay', + textAlign: TextAlign.center, + style: TextStyle( + color: Color(0xFF6AA84F), + fontSize: 55, + fontWeight: FontWeight.bold, + ), + ), + + const SizedBox(height: 80), + TextField( + controller: _emailController, + decoration: const InputDecoration( + hintText: '이메일', + border: OutlineInputBorder(), + filled: true, + fillColor: Colors.white, + contentPadding: + EdgeInsets.symmetric(vertical: 13.0, horizontal: 15.0), + ), + ), + + const SizedBox(height: 13), + TextField( + controller: _passwordController, + obscureText: _obscureText, + decoration: InputDecoration( + hintText: '비밀번호', + border: const OutlineInputBorder(), + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric( + vertical: 13.0, horizontal: 15.0), + suffixIcon: IconButton( + icon: Icon( + _obscureText ? Icons.visibility_off : Icons.visibility, + ), + onPressed: () { + setState(() { + _obscureText = !_obscureText; + }); + }, + ), + ), + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: _isLoading ? null : _handleLogin, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6AA84F), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 13), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), + ), + child: _isLoading + ? const CircularProgressIndicator(color: Colors.white) + : const Text('로그인'), + ), + const SizedBox(height: 13), + ElevatedButton( + onPressed: () { + Navigator.pushNamed(context, '/signup'); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6AA84F), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 13), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), + ), + child: const Text('회원가입'), + ), + + const SizedBox(height: 7), + TextButton( + onPressed: () { + Navigator.pushNamed(context, '/reset'); + }, + style: TextButton.styleFrom( + foregroundColor: Colors.black, + textStyle: const TextStyle(fontSize: 14), + ), + child: const Text('이메일/비밀번호 찾기 >'), + ), + const SizedBox(height: 18), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + GestureDetector( + onTap: () { + // TODO: 카카오 로그인 로직 + }, + child: Image.asset( + 'assets/images/kakao_logo.png', + width: 70, + height: 70, + ), + ), + const SizedBox(width: 22), + GestureDetector( + onTap: () { + // TODO: 구글 로그인 로직 + }, + child: Image.asset( + 'assets/images/google_logo.png', + width: 70, + height: 70, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/backup/features/auth/screens/reset_password_screen.dart b/backup/features/auth/screens/reset_password_screen.dart new file mode 100644 index 0000000..52a4b83 --- /dev/null +++ b/backup/features/auth/screens/reset_password_screen.dart @@ -0,0 +1,267 @@ +import 'package:flutter/material.dart'; + +// 1. 필요한 서비스 및 위젯을 임포트합니다. +import '../services/auth_service.dart'; // AuthService 사용 +import '../widgets/auth_input_field.dart'; + +class ResetPasswordScreen extends StatefulWidget { + const ResetPasswordScreen({super.key}); + + @override + State createState() => _ResetPasswordScreenState(); +} + +class _ResetPasswordScreenState extends State { + // 2. 서비스 인스턴스 및 상태 변수를 SignupScreen과 유사하게 구성합니다. + final AuthService _authService = AuthService(); + final _formKey = GlobalKey(); + + final _emailController = TextEditingController(); + final _codeController = TextEditingController(); + final _passwordController = TextEditingController(); + final _passwordConfirmController = TextEditingController(); + + bool _isCodeRequested = false; // 인증 코드가 요청되었는가? + bool _isCodeVerified = false; // 인증 코드가 확인되었는가? + bool _isLoading = false; // 로딩 상태 + + @override + void dispose() { + _emailController.dispose(); + _codeController.dispose(); + _passwordController.dispose(); + _passwordConfirmController.dispose(); + super.dispose(); + } + + // 3. 인증 코드 요청 핸들러 + Future _handleCodeRequest() async { + // 간단한 이메일 유효성 검사 + if (_emailController.text.isEmpty || !_emailController.text.contains('@')) { + _showSnackBar('유효한 이메일을 입력해주세요.'); + return; + } + + setState(() => _isLoading = true); + try { + // AuthService를 통해 코드 요청 (수정된 부분) + await _authService.requestPasswordResetCode(_emailController.text); + setState(() => _isCodeRequested = true); + _showSnackBar('인증 코드가 이메일로 전송되었습니다.'); + } catch (e) { + _showSnackBar(e.toString().replaceFirst('Exception: ', '')); + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + // 4. 인증 코드 확인 핸들러 + Future _handleCodeVerify() async { + if (_codeController.text.isEmpty) { + _showSnackBar('인증 코드를 입력해주세요.'); + return; + } + + setState(() => _isLoading = true); + try { + // AuthService를 통해 코드 확인 (수정된 부분) + await _authService.verifyPasswordResetCode( + _emailController.text, _codeController.text); + setState(() => _isCodeVerified = true); + _showSnackBar('인증이 완료되었습니다. 새 비밀번호를 입력하세요.'); + } catch (e) { + _showSnackBar(e.toString().replaceFirst('Exception: ', '')); + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + // 5. 최종 제출 핸들러 (비밀번호 변경) + Future _handleSubmit() async { + if (!(_formKey.currentState?.validate() ?? false)) { + return; // 폼 유효성 검사 실패 시 종료 + } + if (_passwordController.text != _passwordConfirmController.text) { + _showSnackBar('비밀번호가 일치하지 않습니다.'); + return; + } + if (!_isCodeVerified) { + _showSnackBar('이메일 인증을 먼저 완료해주세요.'); + return; + } + + setState(() => _isLoading = true); + try { + // AuthService를 통해 비밀번호 재설정 + final message = await _authService.resetPassword( + email: _emailController.text, + code: _codeController.text, // 백엔드 API에 따라 코드가 필요할 수 있음 + newPassword: _passwordController.text, + ); + _showSnackBar(message); + + await Future.delayed(const Duration(seconds: 1)); + if (mounted) { + Navigator.pushReplacementNamed(context, '/login'); + } + } catch (e) { + _showSnackBar(e.toString().replaceFirst('Exception: ', '')); + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + // 6. 일관된 SnackBar 표시를 위한 유틸리티 함수 + void _showSnackBar(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + + @override + Widget build(BuildContext context) { + final buttonStyle = ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6AA84F), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 13), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ); + final disabledButtonStyle = buttonStyle.copyWith( + backgroundColor: WidgetStateProperty.all(Colors.grey.shade400), + ); + + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black), + onPressed: () => Navigator.of(context).pop(), + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 20), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + '새 비밀번호 만들기', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 65), + + // --- 이메일 입력 섹션 --- + const Text('가입한 이메일 주소를 입력해주세요.', style: TextStyle(fontSize: 15)), + const SizedBox(height: 10), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + hintText: '이메일', + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF6AA84F), width: 2.0), + ), + ), + readOnly: _isCodeRequested, // 코드 요청 후에는 수정 불가 + ), + const SizedBox(height: 10), + ElevatedButton( + onPressed: _isLoading || _isCodeRequested ? null : _handleCodeRequest, + style: _isLoading || _isCodeRequested ? disabledButtonStyle : buttonStyle, + child: const Text('인증코드 받기'), + ), + + // --- 인증코드 입력 섹션 (코드 요청 후에만 보임) --- + if (_isCodeRequested) ...[ + const SizedBox(height: 30), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _codeController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + hintText: '인증코드', + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF6AA84F), width: 2.0), + ), + ), + readOnly: _isCodeVerified, // 인증 완료 후에는 수정 불가 + ), + ), + const SizedBox(width: 10), + ElevatedButton( + onPressed: _isLoading || _isCodeVerified ? null : _handleCodeVerify, + style: _isLoading || _isCodeVerified ? disabledButtonStyle : buttonStyle, + child: const Text('확인'), + ), + ], + ), + ], + + // --- 새 비밀번호 입력 섹션 (인증 완료 후에만 보임) --- + if (_isCodeVerified) ...[ + const SizedBox(height: 50), + const Text('비밀번호 재설정', style: TextStyle(fontSize: 15)), + const SizedBox(height: 10), + AuthInputField( + controller: _passwordController, + hintText: '새 비밀번호', + isPassword: true, + validator: (value) { + if (value == null || value.isEmpty || value.length < 8) { + return '비밀번호는 8자 이상이어야 합니다.'; + } + return null; + }, + ), + const SizedBox(height: 10), + AuthInputField( + controller: _passwordConfirmController, + hintText: '새 비밀번호 재입력', + isPassword: true, + validator: (value) { + if (value != _passwordController.text) { + return '비밀번호가 일치하지 않습니다.'; + } + return null; + }, + ), + const SizedBox(height: 35), + ElevatedButton( + onPressed: _isLoading ? null : _handleSubmit, + style: buttonStyle, + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) + : const Text('확인'), + ), + ], + ], + ), + ), + ), + ); + } +} + diff --git a/backup/features/auth/screens/signup_screen.dart b/backup/features/auth/screens/signup_screen.dart new file mode 100644 index 0000000..484be0c --- /dev/null +++ b/backup/features/auth/screens/signup_screen.dart @@ -0,0 +1,318 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:kakao_flutter_sdk_user/kakao_flutter_sdk_user.dart'; + +// 분리된 서비스와 위젯을 임포트합니다. +import '../services/auth_service.dart'; +import '../widgets/email_auth_section.dart'; +import '../widgets/auth_input_field.dart'; + +// 회원가입 화면 +class SignupScreen extends StatefulWidget { + const SignupScreen({super.key}); + + @override + State createState() => _SignupScreenState(); +} + +class _SignupScreenState extends State { + // 1. 서비스 인스턴스 및 상태 관리 + final AuthService _authService = AuthService(); + final _formKey = GlobalKey(); // 폼 유효성 검사를 위한 키 + + final _emailController = TextEditingController(); + final _authCodeController = TextEditingController(); + final _passwordController = TextEditingController(); + final _passwordConfirmController = TextEditingController(); + final _nicknameController = TextEditingController(); + + bool _termsAgreed = false; + bool _isCodeRequested = false; // 인증 코드가 요청되었는가? + bool _isCodeVerified = false; // 인증 코드가 확인되었는가? + bool _isLoading = false; // 로딩 상태 + + // 2. 인증 코드 요청 핸들러 + Future _handleCodeRequest() async { + setState(() { _isLoading = true; }); + try { + await _authService.requestAuthCode(_emailController.text); + setState(() { + _isCodeRequested = true; + _authCodeController.clear(); // 새 요청 시 코드 초기화 + }); + _showSnackBar('인증 코드가 이메일로 전송되었습니다.'); + } catch (e) { + _showSnackBar(e.toString().replaceFirst('Exception: ', '')); + } finally { + if(mounted) { + setState(() { _isLoading = false; }); + } + } + } + + // 3. 인증 코드 확인 핸들러 + Future _handleCodeVerify() async { + setState(() { _isLoading = true; }); + try { + await _authService.verifyAuthCode(_emailController.text, _authCodeController.text); + setState(() { _isCodeVerified = true; }); + _showSnackBar('이메일 인증이 성공적으로 완료되었습니다.'); + } catch (e) { + _showSnackBar(e.toString().replaceFirst('Exception: ', '')); + } finally { + if(mounted) { + setState(() { _isLoading = false; }); + } + } + } + + // 4. '가입하기' 버튼 함수 (최종 제출) + Future _handleSubmit() async { + if (!_formKey.currentState!.validate()) { + return; // 폼 유효성 검사 실패 시 종료 + } + + if (_passwordController.text != _passwordConfirmController.text) { + _showSnackBar('비밀번호가 일치하지 않습니다.'); + return; + } + if (!_isCodeVerified) { + _showSnackBar('이메일 인증을 완료해주세요.'); + return; + } + if (!_termsAgreed) { + _showSnackBar('약관에 동의해주세요.'); + return; + } + + setState(() { _isLoading = true; }); + try { + final message = await _authService.signup( + email: _emailController.text, + password: _passwordController.text, + nickname: _nicknameController.text, + ); + _showSnackBar(message); + + // ✅ [병합] 회원가입 성공 시 로그인 화면으로 이동하는 로직을 활성화합니다. + await Future.delayed(const Duration(seconds: 1)); // 알림을 보여줄 시간 + if (mounted) { + Navigator.pushReplacementNamed(context, '/login'); + } + + } catch (e) { + _showSnackBar(e.toString().replaceFirst('Exception: ', '')); + } finally { + if(mounted) { + setState(() { _isLoading = false; }); + } + } + } + + // 5. 카카오 로그인/가입 처리 핸들러 + Future _handleKakaoSignup() async { + if (_isLoading) return; + setState(() { _isLoading = true; }); + + try { + final result = await _authService.signupWithKakao(); + final isNewUser = result['isNewUser'] ?? false; + + if (isNewUser) { + // TODO: 신규 사용자일 경우, 약관 동의나 추가 정보 입력 화면으로 이동 + _showSnackBar('카카오 계정으로 가입을 진행합니다. 추가 정보 입력 화면으로 이동합니다.'); + // 예: Navigator.push(context, MaterialPageRoute(builder: (_) => TermsScreen(userInfo: result))); + } else { + // TODO: 기존 사용자일 경우, JWT 저장 후 메인 화면으로 이동 + _showSnackBar('카카오 계정으로 로그인되었습니다. 메인 화면으로 이동합니다.'); + // 예: final token = result['accessToken']; await saveToken(token); + // Navigator.pushAndRemoveUntil(context, MaterialPageRoute(builder: (_) => MainScreen()), (route) => false); + } + } catch (e) { + _showSnackBar(e.toString().replaceFirst('Exception: ', '')); + } finally { + if(mounted) { + setState(() { _isLoading = false; }); + } + } + } + + // 간결한 SnackBar 표시 유틸리티 + void _showSnackBar(String message) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + } + + // 리소스 해제 + @override + void dispose() { + _emailController.dispose(); + _authCodeController.dispose(); + _passwordController.dispose(); + _passwordConfirmController.dispose(); + _nicknameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.black), + onPressed: () => Navigator.of(context).pop(), + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // GuardPay 로고 + const Text( + 'GuardPay', + textAlign: TextAlign.center, + style: TextStyle( + color: Color(0xFF6AA84F), + fontSize: 55, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 30), + + // 1. 이메일 인증 섹션 + EmailAuthSection( + emailController: _emailController, + codeController: _authCodeController, + isCodeRequested: _isCodeRequested, + isCodeVerified: _isCodeVerified, + onCodeRequest: _handleCodeRequest, + onCodeVerify: _handleCodeVerify, + ), + const SizedBox(height: 15), + + // 2. 비밀번호 입력 + const Text('비밀번호 *', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 5), + AuthInputField( + controller: _passwordController, + hintText: '비밀번호', + isPassword: true, + validator: (value) { + if (value == null || value.isEmpty || value.length < 8) { + return '비밀번호는 8자 이상이어야 합니다.'; + } + return null; + }, + ), + const SizedBox(height: 10), + AuthInputField( + controller: _passwordConfirmController, + hintText: '비밀번호 재입력', + isPassword: true, + validator: (value) { + if (value != _passwordController.text) { + return '비밀번호가 일치하지 않습니다.'; + } + return null; + }, + ), + const SizedBox(height: 15), + + // 3. 닉네임 입력 + const Text('닉네임 *', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 5), + AuthInputField( + controller: _nicknameController, + hintText: '닉네임', + validator: (value) { + if (value == null || value.isEmpty) { + return '닉네임을 입력해주세요.'; + } + return null; + }, + ), + const SizedBox(height: 25), + + // 4. 약관 동의 + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFD0D0D0)), + ), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 10), + child: Row( + children: [ + Checkbox( + value: _termsAgreed, + onChanged: (value) => setState(() { _termsAgreed = value ?? false; }), + ), + const Text('약관 전체 동의'), + ], + ), + ), + const SizedBox(height: 20), + + // 5. 가입하기 버튼 + ElevatedButton( + onPressed: _isLoading ? null : _handleSubmit, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6AA84F), + padding: const EdgeInsets.symmetric(vertical: 15), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) + : const Text('가입하기', style: TextStyle(color: Colors.white)), + ), + const SizedBox(height: 20), + + // 'OR' 구분선 + Row( + children: [ + Expanded(child: Divider(color: Colors.grey[400])), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 10), + child: Text('OR', style: TextStyle(color: Colors.grey)), + ), + Expanded(child: Divider(color: Colors.grey[400])), + ], + ), + const SizedBox(height: 20), + + // 카카오로 시작하기 버튼 + ElevatedButton( + onPressed: _isLoading ? null : _handleKakaoSignup, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFFEE500), // 카카오 노란색 + padding: const EdgeInsets.symmetric(vertical: 15), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text( + '카카오로 시작하기', + style: TextStyle( + color: Colors.black87, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/backup/features/auth/services/auth_service.dart b/backup/features/auth/services/auth_service.dart new file mode 100644 index 0000000..c0fae18 --- /dev/null +++ b/backup/features/auth/services/auth_service.dart @@ -0,0 +1,253 @@ +import 'dart:convert'; // jsonEncode, jsonDecode를 사용하기 위해 필요 +import 'package:http/http.dart' as http; // HTTP 통신을 위해 필요 +import 'package:flutter/services.dart'; // [추가] PlatformException을 사용하기 위해 필요 +import 'package:kakao_flutter_sdk_user/kakao_flutter_sdk_user.dart'; // [추가] 카카오 SDK +import 'dart:developer'; // 👈 1. dart:developer 라이브러리를 import 합니다. +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; // ✅ 이 줄을 추가하세요. + + + + +class AuthService { + // 실제 서버 환경에서는 여기에 Dio 인스턴스 등이 주입될 수 있습니다. + final String _apiUrl = 'http://10.0.2.2:8080/api/users'; // 기본 API 경로 설정 + static const String _tempAuthCode = '123456'; // 임시 이메일 인증 코드 + //final String _baseUrl = 'https://nonsusceptible-hyman-periproctal.ngrok-free.dev'; + final String _baseUrl = dotenv.env['API_BASE_URL'] ?? 'http://10.0.2.2:8080'; + + final _secureStorage = const FlutterSecureStorage(); // ✅ 토큰 저장을 위해 추가 + + + Future signInWithGoogle() async { + try { + // 1. 스프링 부트의 구글 로그인 시작 URL + // 💡 포트 번호(8080)는 본인 서버에 맞게 확인하세요. + final url = Uri.parse('$_baseUrl/oauth2/authorization/google'); + + // 2. 웹뷰를 열고, 스프링 부트가 리디렉션할 때까지 대기합니다. + // 'guardpay'는 AndroidManifest.xml에 설정한 scheme 값입니다. + final result = await FlutterWebAuth2.authenticate( + url: url.toString(), + callbackUrlScheme: "guardpay", + ); + + // 3. 돌아온 URL에서 토큰을 추출합니다. + final Uri callbackUri = Uri.parse(result); + final accessToken = callbackUri.queryParameters['accessToken']; + final refreshToken = callbackUri.queryParameters['refreshToken']; + + if (accessToken != null && refreshToken != null) { + // 4. 토큰을 안전하게 기기에 저장합니다. + await _secureStorage.write(key: 'accessToken', value: accessToken); + await _secureStorage.write(key: 'refreshToken', value: refreshToken); + + log('✅ 구글 로그인 성공! Access Token: $accessToken'); + + } else { + throw Exception('로그인에 성공했지만 토큰을 받아오지 못했습니다.'); + } + + } on PlatformException catch (e) { + // 사용자가 웹뷰를 그냥 닫았을 경우를 처리합니다. + if (e.code == 'CANCELED' || e.code == 'USER_CANCELLED') { + log('ℹ️ 구글 로그인이 사용자에 의해 취소되었습니다.'); + // 에러를 던지지 않고 조용히 종료할 수도 있습니다. + return; + } + throw Exception('로그인 중 오류가 발생했습니다: ${e.message}'); + } catch (e) { + log('🚨 구글 로그인 중 알 수 없는 에러 발생: $e'); + throw Exception('로그인에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } + } + + // 1. 이메일 인증 코드를 요청하는 함수 + Future requestAuthCode(String email) async { + final emailRegex = RegExp(r"^[^\s@]+@[^\s@]+\.[^\s@]+$"); + if (email.isEmpty || !emailRegex.hasMatch(email)) { + // 클라이언트 측 유효성 검사 (실제 환경에서는 예외를 throw하거나 Error 객체를 반환) + throw Exception('올바른 이메일 주소를 입력해주세요.'); + } + + // TODO: 실제 서버 API 엔드포인트에 맞게 URL 수정 필요 (예: '/request-code') + // 현재는 성공했다고 가정하고 true 반환 + print('이메일 인증 코드 요청: $email'); + await Future.delayed(const Duration(milliseconds: 500)); // API 지연 시뮬레이션 + return true; + } + + // 2. 인증 코드를 확인하는 함수 + Future verifyAuthCode(String email, String code) async { + // 정의된 임시 인증 코드(_tempAuthCode)와 사용자가 입력한 코드를 비교합니다. + if (code != _tempAuthCode) { // TODO: 실제 인증 로직으로 대체 필요 + throw Exception('인증 코드가 일치하지 않습니다.'); + } + print('인증 코드 확인 완료: $email'); + await Future.delayed(const Duration(milliseconds: 500)); + return true; + } + + // 3. 회원가입을 최종적으로 처리하는 함수 + Future signup({ + required String email, + required String password, + required String nickname, + }) async { + // ✅ 백엔드 회원가입 API 경로 + const apiUrl = 'http://10.0.2.2:8080/api/auth/signup'; + + // ✅ 서버에 보낼 JSON 데이터 + final signupData = { + 'email': email, + 'password': password, + 'nickname': nickname, + }; + + try { + final response = await http.post( + Uri.parse(apiUrl), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(signupData), + ); + + final responseBody = jsonDecode(utf8.decode(response.bodyBytes)); // 한글 깨짐 방지 + + // ✅ 성공 (200 또는 201) + if (response.statusCode == 200 || response.statusCode == 201) { + print('가입 성공 응답: ${response.body}'); + return responseBody['message'] ?? '회원가입이 성공적으로 완료되었습니다.'; + } + // ❌ 실패 (400, 409 등) + else { + print('가입 실패 응답: ${response.body}'); + throw Exception(responseBody['message'] ?? '회원가입에 실패했습니다.'); + } + + } catch (error) { + print('가입 요청 실패: $error'); + throw Exception('서버와 통신할 수 없습니다.'); + } + } + + + /// 비밀번호 재설정을 위한 이메일 인증 코드를 요청합니다. + Future requestPasswordResetCode(String email) async { + // TODO: 실제 서버의 '비밀번호 재설정용' 코드 요청 API와 연동해야 합니다. + print('[AuthService] 비밀번호 재설정 코드 요청: $email'); + await Future.delayed(const Duration(milliseconds: 500)); + } + + /// 비밀번호 재설정을 위한 인증 코드를 확인합니다. + Future verifyPasswordResetCode(String email, String code) async { + // TODO: 실제 서버의 '비밀번호 재설정용' 코드 확인 API와 연동해야 합니다. + if (code != _tempAuthCode) { + throw Exception('인증 코드가 일치하지 않습니다.'); + } + print('[AuthService] 비밀번호 재설정 코드 확인 완료: $email'); + await Future.delayed(const Duration(milliseconds: 500)); + } + + /// 새 비밀번호로 재설정하는 최종 요청을 보냅니다. + Future resetPassword({ + required String email, + required String code, + required String newPassword, + }) async { + // --- 백엔드 API 없이 프론트엔드 시연을 위한 임시 코드 --- + print('[AuthService] 비밀번호 변경 요청 시뮬레이션 시작 (서버 호출 안함)'); + + // 마치 서버가 성공적으로 응답한 것처럼 1초간 기다립니다. + await Future.delayed(const Duration(seconds: 1)); + + // 서버 대신 성공 메시지를 직접 반환합니다. + return '비밀번호가 성공적으로 변경되었습니다.'; + + } + + // [수정] 카카오 회원가입/로그인 처리 함수 + Future> signupWithKakao() async { + // 1. [추가] 기존 로그인 정보가 있다면 먼저 로그아웃 처리 + try { + if (await AuthApi.instance.hasToken()) { + await UserApi.instance.logout(); + print('기존 토큰 발견. 로그아웃 처리 완료.'); + } + } catch (error) { + print('로그아웃 처리 중 에러 발생 (무시): $error'); + } + // 2. 카카오 SDK로 액세스 토큰 받기 (기존 로직과 동일) + String? kakaoAccessToken; + if (await isKakaoTalkInstalled()) { + try { + await UserApi.instance.loginWithKakaoTalk(); + kakaoAccessToken = (await TokenManagerProvider.instance.manager.getToken())?.accessToken; + } catch (error) { + if (error is PlatformException && error.code == 'CANCELED') { + throw Exception('카카오톡 로그인이 취소되었습니다.'); + } + try { + await UserApi.instance.loginWithKakaoAccount(); + kakaoAccessToken = (await TokenManagerProvider.instance.manager.getToken())?.accessToken; + } catch (accountError) { + throw Exception('카카오 계정 로그인에 실패했습니다.'); + } + } + } else { + try { + await UserApi.instance.loginWithKakaoAccount(); + kakaoAccessToken = (await TokenManagerProvider.instance.manager.getToken())?.accessToken; + } catch (accountError) { + throw Exception('카카오 계정 로그인에 실패했습니다.'); + } + } + + if (kakaoAccessToken == null) { + throw Exception('카카오 액세스 토큰을 가져오는데 실패했습니다.'); + } + + log('🚀 Sending request to backend...'); + log('URL: $_baseUrl/api/auth/kakao'); + log('Kakao Access Token: $kakaoAccessToken'); + // 3. 백엔드 서버로 액세스 토큰 전송 (기존 로직과 동일) + final response = await http.post( + Uri.parse('$_baseUrl/api/auth/kakao'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'accessToken': kakaoAccessToken}), + ); + + + // ✅ [로그 추가] 백엔드로부터 받은 응답의 상태 코드와 본문을 출력 + log('✅ Received response from backend!'); + log('Status Code: ${response.statusCode}'); + log('Response Body: ${response.body}'); + // [수정] 성공(200)과 실패 케이스를 나누어 처리 + if (response.statusCode == 200) { + // 성공 시, 정상적으로 응답 본문 반환 + return jsonDecode(response.body); + } else { + // 실패 시, 서버가 보낸 구체적인 에러 메시지를 담아 Exception 발생 + try { + final errorBody = jsonDecode(response.body); + throw Exception('서버 통신 실패: ${errorBody['message'] ?? '알 수 없는 오류'}'); + } catch (e) { + // 응답 본문이 JSON 형태가 아닐 경우를 대비한 예외 처리 + throw Exception('서버와 통신 중 오류가 발생했습니다. (상태 코드: ${response.statusCode})'); + } + } + } + + // [추가] 카카오 로그아웃 함수 + Future kakaoLogout() async { + try { + await UserApi.instance.logout(); + print('로그아웃 성공, SDK에서 토큰 삭제'); + } catch (error) { + print('로그아웃 실패, SDK에서 토큰 삭제 실패 $error'); + throw Exception('로그아웃에 실패했습니다.'); + } + } + + +} \ No newline at end of file diff --git a/backup/features/auth/widgets/auth_input_field.dart b/backup/features/auth/widgets/auth_input_field.dart new file mode 100644 index 0000000..f9097ac --- /dev/null +++ b/backup/features/auth/widgets/auth_input_field.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +// 앱의 모든 인증 화면에서 재사용할 수 있는 범용 입력 필드 위젯입니다. +class AuthInputField extends StatefulWidget { + final TextEditingController controller; + final String hintText; + final bool isPassword; // 비밀번호 가시성 토글이 필요한지 여부 + final String? Function(String?)? validator; + final TextInputType keyboardType; + final bool readOnly; + final bool enabled; + + const AuthInputField({ + super.key, + required this.controller, + required this.hintText, + this.isPassword = false, + this.validator, + this.keyboardType = TextInputType.text, + this.readOnly = false, + this.enabled = true, + }); + + @override + State createState() => _AuthInputFieldState(); +} + +class _AuthInputFieldState extends State { + // 비밀번호 필드일 경우 가리기 상태를 관리합니다. + late bool _obscureText; + + @override + void initState() { + super.initState(); + _obscureText = widget.isPassword; + } + + void _toggleVisibility() { + setState(() { + _obscureText = !_obscureText; + }); + } + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: widget.controller, + // isPassword 속성에 따라 가리기 여부 및 가시성 토글 버튼 제공 + obscureText: _obscureText, + validator: widget.validator, + keyboardType: widget.keyboardType, + readOnly: widget.readOnly, + enabled: widget.enabled, + decoration: InputDecoration( + hintText: widget.hintText, + // 비밀번호 필드일 경우에만 가시성 토글 버튼 제공 + suffixIcon: widget.isPassword + ? IconButton( + icon: Icon( + _obscureText ? Icons.visibility_off : Icons.visibility, + color: Colors.grey, + ), + onPressed: _toggleVisibility, + ) + : null, + ), + ); + } +} diff --git a/backup/features/auth/widgets/email_auth_section.dart b/backup/features/auth/widgets/email_auth_section.dart new file mode 100644 index 0000000..4e47626 --- /dev/null +++ b/backup/features/auth/widgets/email_auth_section.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; + +// AuthInputField를 임포트하여 재사용합니다. +import 'auth_input_field.dart'; + +// 이메일 입력, 인증 코드 요청, 인증 코드 입력, 확인 버튼을 묶어 관리하는 복합 위젯 +class EmailAuthSection extends StatelessWidget { + final TextEditingController emailController; + final TextEditingController codeController; + final bool isCodeRequested; + final bool isCodeVerified; + final VoidCallback onCodeRequest; + final VoidCallback onCodeVerify; + + const EmailAuthSection({ + super.key, + required this.emailController, + required this.codeController, + required this.isCodeRequested, + required this.isCodeVerified, + required this.onCodeRequest, + required this.onCodeVerify, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 1. 이메일 입력 필드 + const Text('이메일 *', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 5), + Row( + children: [ + Expanded( + child: AuthInputField( // AuthInputField 재사용 + controller: emailController, + hintText: '이메일', + keyboardType: TextInputType.emailAddress, + readOnly: isCodeRequested, // 코드가 요청되면 이메일 수정 불가 + enabled: !isCodeVerified, // 인증 완료되면 비활성화 + validator: (value) { + final emailRegex = RegExp(r"^[^\s@]+@[^\s@]+\.[^\s@]+$"); + if (value == null || value.isEmpty || !emailRegex.hasMatch(value)) { + return '올바른 이메일 주소를 입력해주세요.'; + } + return null; + }, + ), + ), + const SizedBox(width: 8), + // 2. 인증 코드 요청 버튼 + SizedBox( + height: 56, // 텍스트 필드와 높이 맞추기 + child: ElevatedButton( + onPressed: isCodeRequested ? null : onCodeRequest, // 요청 후 비활성화 + style: ElevatedButton.styleFrom( + backgroundColor: isCodeRequested + ? Colors.grey + : const Color(0xFF6AA84F).withOpacity(0.8), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text( + '인증코드 받기', + style: TextStyle(fontSize: 14), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + + // 3. 인증 코드가 요청된 경우에만 코드 입력 필드 표시 + if (isCodeRequested) ...[ + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: AuthInputField( // AuthInputField 재사용 + controller: codeController, + hintText: '인증코드', + keyboardType: TextInputType.number, + readOnly: isCodeVerified, // 인증 완료되면 수정 불가 + ), + ), + const SizedBox(width: 8), + // 4. 확인 버튼 + SizedBox( + height: 56, // 텍스트 필드와 높이 맞추기 + child: ElevatedButton( + onPressed: isCodeVerified ? null : onCodeVerify, // 확인 후 비활성화 + style: ElevatedButton.styleFrom( + backgroundColor: isCodeVerified + ? Colors.grey + : const Color(0xFF6AA84F), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 20), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: Text( + isCodeVerified ? '완료' : '확인', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + ], + ], + ); + } +} diff --git a/backup/main.dart b/backup/main.dart new file mode 100644 index 0000000..a1f6210 --- /dev/null +++ b/backup/main.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:kakao_flutter_sdk_user/kakao_flutter_sdk_user.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'config/theme.dart'; +import 'features/auth/screens/login_screen.dart'; +import 'features/auth/screens/signup_screen.dart'; +import 'features/auth/screens/reset_password_screen.dart'; +import 'features/auth/screens/home_screen.dart'; + + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + KakaoSdk.init(nativeAppKey: '6cdfe8239c6cf5fdbaf793f4fc9581e3'); + + // ✅ 토큰 체크 후 적절한 첫 화면 결정 + final storage = FlutterSecureStorage(); + final accessToken = await storage.read(key: 'accessToken'); + + runApp(MyApp(initialRoute: accessToken != null ? '/home' : '/login')); +} + +class MyApp extends StatelessWidget { + final String initialRoute; + const MyApp({super.key, required this.initialRoute}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'GuardPay App', + theme: appTheme(), + initialRoute: initialRoute, // ✅ 토큰 존재 여부에 따라 첫 화면 변경 + routes: { + '/login': (context) => const LoginScreen(), + '/signup': (context) => const SignupScreen(), + '/reset': (context) => const ResetPasswordScreen(), + '/home': (context) => const HomeScreen(), // ✅ 홈 라우트 추가 + }, + ); + } +} diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e2847c8 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..f5feea6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/guardpayFlutter b/guardpayFlutter new file mode 160000 index 0000000..7c618d0 --- /dev/null +++ b/guardpayFlutter @@ -0,0 +1 @@ +Subproject commit 7c618d0369ae843bd6f1c0a621b9b9e7a9c94165 diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..620e46e --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/lib/config/app_router.dart b/lib/config/app_router.dart new file mode 100644 index 0000000..18e341a --- /dev/null +++ b/lib/config/app_router.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:guardpayfront/features/auth/screens/login_screen.dart'; +import 'package:guardpayfront/features/auth/screens/signup_screen.dart'; +import 'package:guardpayfront/features/auth/screens/reset_password_screen.dart'; +import 'package:guardpayfront/features/auth/screens/home_screen.dart'; +import 'package:guardpayfront/features/video/screens/video_category_screen.dart'; +import 'package:guardpayfront/features/video/screens/video_list_screen.dart'; +import 'package:guardpayfront/features/video/screens/video_player_screen.dart'; + +Route generateRoute(RouteSettings settings) { + switch (settings.name) { + case '/login': + return MaterialPageRoute(builder: (_) => const LoginScreen()); + + case '/signup': + return MaterialPageRoute(builder: (_) => const SignupScreen()); + + case '/reset': + return MaterialPageRoute(builder: (_) => const ResetPasswordScreen()); + + case '/home': + return MaterialPageRoute(builder: (_) => const HomeScreen()); + + case '/video': + return MaterialPageRoute(builder: (_) => VideoCategoryScreen()); + + case '/videoList': + final args = settings.arguments as Map; + return MaterialPageRoute( + builder: (_) => VideoListScreen( + categoryId: args['categoryId'], // ✅ 추가 + categoryName: args['categoryName'], // ✅ 추가 + ), + ); + + case '/videoPlayer': + final args = settings.arguments as Map; + return MaterialPageRoute( + builder: (_) => VideoPlayerScreen( + videoId: args['videoId'], // ✅ 추가 + title: args['title'], // ✅ 추가 + ), + ); + + default: + return MaterialPageRoute( + builder: (_) => const Scaffold( + body: Center(child: Text('페이지를 찾을 수 없습니다.')), + ), + ); + } +} diff --git a/lib/config/theme.dart b/lib/config/theme.dart new file mode 100644 index 0000000..0150b45 --- /dev/null +++ b/lib/config/theme.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; + +// 앱 전체에서 사용할 기본 색상 정의 +const Color primaryColor = Color(0xFF6AA84F); // GuardPay Green +const Color lightGrayBorder = Color(0xFFD0D0D0); // 연한 회색 테두리 +const Color lightBackground = Color(0xFFF9F5EC); // 베이지 배경색 + +// 앱의 전체 테마를 정의하는 함수 +ThemeData appTheme() { + return ThemeData( + // 앱의 전반적인 색상 톤을 설정합니다. + primaryColor: primaryColor, + // 배경색을 베이지색으로 설정합니다. + scaffoldBackgroundColor: lightBackground, + + // 입력창(TextField)의 기본 디자인을 설정합니다. + inputDecorationTheme: const InputDecorationTheme( + filled: true, + fillColor: Colors.white, + // 힌트 텍스트 색상 설정 + hintStyle: TextStyle(color: Color(0xFFBDBDBD)), + + // 기본 테두리 스타일: 둥근 모서리(8.0)와 연한 회색 테두리 + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + borderSide: BorderSide(color: lightGrayBorder), + ), + // 활성화된 상태의 테두리 + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + borderSide: BorderSide(color: lightGrayBorder), + ), + // 포커스 상태의 테두리 (주요 색상으로 강조) + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + borderSide: BorderSide(color: primaryColor, width: 2.0), + ), + // 비활성화 상태의 테두리 (인증 완료 시 사용) + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8.0)), + borderSide: BorderSide(color: lightGrayBorder, width: 1.0), + ), + // 입력 필드 내부 패딩 + contentPadding: EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0), + ), + + // Checkbox의 색상 설정 + checkboxTheme: CheckboxThemeData( + fillColor: MaterialStateProperty.resolveWith((states) { + if (states.contains(MaterialState.selected)) { + return primaryColor; + } + return Colors.white; + }), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + ) + ); +} diff --git a/lib/core/services/jwt_util.dart b/lib/core/services/jwt_util.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/core/services/storage.dart b/lib/core/services/storage.dart new file mode 100644 index 0000000..e1978d7 --- /dev/null +++ b/lib/core/services/storage.dart @@ -0,0 +1,13 @@ +// storage.dart (새 파일) +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class AppStorage { + static const storage = FlutterSecureStorage( + aOptions: AndroidOptions( + encryptedSharedPreferences: true, // ✅ 항상 동일 옵션 + ), + iOptions: IOSOptions( + accessibility: KeychainAccessibility.unlocked, + ), + ); +} diff --git a/lib/core/services/token_storage.dart b/lib/core/services/token_storage.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/features/assessment/models/quiz_model.dart b/lib/features/assessment/models/quiz_model.dart new file mode 100644 index 0000000..ba83ed8 --- /dev/null +++ b/lib/features/assessment/models/quiz_model.dart @@ -0,0 +1,74 @@ +class Quiz { + final int id; + final String question; + final Map options; + final String answer; // 정답 키 (예: 'B') + final int categoryId; + final String level; // 난이도 (예: 'EASY', 'MEDIUM', 'HARD') + final int point; + final String? userSelectedAnswer; + + Quiz({ + required this.id, + required this.question, + required this.options, + required this.answer, + required this.categoryId, + required this.level, + required this.point, + this.userSelectedAnswer, + }); + + Quiz copyWith({ + String? userSelectedAnswer, + }) { + return Quiz( + id: this.id, + question: this.question, + options: this.options, + answer: this.answer, + categoryId: this.categoryId, + level: this.level, + point: this.point, + userSelectedAnswer: userSelectedAnswer ?? this.userSelectedAnswer, + ); + } + + + +// [quiz_model.dart] 파일 내의 Quiz 클래스 수정 + factory Quiz.fromJson(Map json) { + // 1. API 응답의 options는 List (옵션 객체들의 배열)입니다. + final List rawOptions = json['options'] ?? []; + final Map parsedOptions = {}; + + // 2. List를 순회하며 Map 형태로 변환 + // 옵션 ID(int)를 키(String)로, 텍스트(String)를 값으로 사용합니다. + for (var option in rawOptions) { + if (option is Map) { + // 키로 사용할 옵션 ID를 문자열로 변환합니다. (예: 4001 -> "4001") + final String key = (option['optionId'] as int?)?.toString() ?? 'unknown'; + final String text = option['text'] as String? ?? 'No Text'; + + // 키가 'unknown'이 아니면서 텍스트가 있을 경우에만 추가합니다. + if (key != 'unknown') { + parsedOptions[key] = text; + } + } + } + + return Quiz( + id: json['questionId'] as int? ?? 0, + question: json['questionText'] as String? ?? 'No Question', + options: parsedOptions, // 👈 수정된 Map 사용 + // ⬇ 새 API 명세에 없는 필드들은 기본값으로 채웁니다. + answer: '', + categoryId: 0, + level: 'UNKNOWN', + point: 0, + + ); + } + + +} \ No newline at end of file diff --git a/lib/features/assessment/screens/assessment_screen.dart b/lib/features/assessment/screens/assessment_screen.dart new file mode 100644 index 0000000..616dfc8 --- /dev/null +++ b/lib/features/assessment/screens/assessment_screen.dart @@ -0,0 +1,362 @@ +import 'package:flutter/material.dart'; +import 'package:guardpayfront/features/assessment/models/quiz_model.dart'; +import 'package:guardpayfront/features/assessment/services/assessment_service.dart'; +import 'package:guardpayfront/features/assessment/widgets/result_dialog.dart'; +import 'package:guardpayfront/core/services/storage.dart'; + +// 1. 16진수 색상 코드를 편리하게 사용하기 위한 확장 함수 (클래스 밖으로 이동) +extension ColorExtension on String { + Color toColor() { + var hexColor = replaceAll('#', ''); + if (hexColor.length == 6) { + hexColor = 'FF$hexColor'; + } + + if (hexColor.length == 8) { + return Color(int.parse('0x$hexColor')); + } + return Colors.black; + } +} + +class AssessmentScreen extends StatefulWidget { + static const String routeName = '/assessment-screen'; + + const AssessmentScreen({super.key}); + + @override + State createState() => _AssessmentScreenState(); +} + +class _AssessmentScreenState extends State { + final AssessmentService _service = AssessmentService(); + Future>? _quizzesFuture; + late List _quizzes; + int _currentIndex = 0; + String? _selectedAnswerKey; + + String? _accessToken; + final storage = AppStorage.storage; + + @override + void initState() { + super.initState(); + _loadQuizData(); + } + + // 5. ✅ 새 메서드: 토큰을 읽고 퀴즈 로딩을 시작 + Future _loadQuizData() async { + // 5a. 저장소에서 토큰 읽기 + final token = await storage.read(key: 'accessToken'); + + if (token == null) { + print("🚨 퀴즈 로딩 실패: 저장된 토큰이 없습니다. 로그인 화면으로 이동합니다."); + // 토큰이 없으면 로그인 화면으로 강제 이동 (optional) + if (mounted) { + Navigator.pushReplacementNamed(context, '/login'); + } + return; + } + + // 5b. 토큰과 퀴즈 Future를 설정하고 UI 업데이트 + setState(() { + _accessToken = token; + _quizzesFuture = _service.fetchAssessmentQuizzes(_accessToken!); + }); + } + + void _nextQuestion() { + if (_selectedAnswerKey == null) return; // 답변 선택 안 했으면 이동 불가 + // 1. ✅ 수정: 선택된 인덱스(0, 1, 2, 3)에 1을 더하여 Option ID를 '추정'합니다. + final currentQuiz = _quizzes[_currentIndex]; + + _quizzes[_currentIndex] = currentQuiz.copyWith( + // ✅ 수정: userSelectedAnswer에 선택된 키(예: "1", "2")를 저장 + userSelectedAnswer: _selectedAnswerKey, + ); + + setState(() { + if (_currentIndex < _quizzes.length - 1) { + _currentIndex++; + _selectedAnswerKey = null; + } else { + _submitAssessment(); + } + }); + } + + void _handleAnswerSelection(String key) { + setState(() { + _selectedAnswerKey = key; + }); + } + + void _showResultDialog(BuildContext context, String level) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return ResultDialog( + level: level, + onConfirm: () { + Navigator.of(context).pop(); + Navigator.pushNamed(context, '/home'); + }, + ); + }, + ); + } + +// ======================================================= +// 5. 최종 제출 및 결과 조회 로직 (추가) +// ======================================================= + + Future _submitAssessment() async { +// 1. 제출할 데이터 구조 생성 + final List> submissionData = _quizzes.map((quiz) { + return { + 'quizId': quiz.id, +// userSelectedAnswer가 'A', 'B', 'C', 'D' 형태로 저장되어 있어야 함 + 'userAnswer': quiz.userSelectedAnswer, + }; + }).toList(); + + try { +// 2. AssessmentService를 통해 9번 개별 제출 및 최종 레벨 조회 +// 이 함수는 9번의 API 호출을 수행합니다. + final finalLevel = await _service.submitAssessmentResults( + submissionData, + _accessToken!, + ); + +// 3. 성공 시 결과 팝업 표시 + _showResultDialog(context, finalLevel); + } catch (e) { +// 4. 에러 발생 시 처리 + print('역량 진단 제출 중 오류 발생: $e'); +// 사용자에게 에러 메시지 표시 (예: Snackbar) + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('제출 실패: ${e.toString()}')), + ); + } + } + } + +// ======================================================= + +// 2. 퀴즈 내용 빌드 메서드 (상태 접근 가능) + +// ======================================================= + + Widget _buildQuizContent() { + final currentQuiz = _quizzes[_currentIndex]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ +// 퀴즈 번호 + Padding( + padding: const EdgeInsets.only(top: 7.0, left: 13.0), + child: Text( + 'Quiz ${_currentIndex + 1}.', + style: const TextStyle(fontSize: 27, fontWeight: FontWeight.bold), + ), + ), +// 퀴즈 질문 + Padding( + padding: const EdgeInsets.only(top: 28.0), + child: Text( + currentQuiz.question, + textAlign: TextAlign.justify, + style: const TextStyle( + fontSize: 18, + color: Colors.black87, + height: 1.5, + ), + ), + ), + const SizedBox(height: 30), + +// 옵션 버튼 목록 + + Expanded( + child: ListView( + padding: EdgeInsets.zero, + children: currentQuiz.options.entries.map((entry) { + String key = entry.key; // 예: "1", "2" + String optionText = entry.value; // 예: "수취 은행명" + bool isSelected = _selectedAnswerKey == key; // ✅ 수정 + return GestureDetector( + onTap: () => _handleAnswerSelection(key), + child: Container( + margin: const EdgeInsets.only(bottom: 25.0), + padding: const EdgeInsets.symmetric(vertical: 17.5, horizontal: 22), + decoration: BoxDecoration( + color: isSelected ? Colors.green.shade50 : Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isSelected ? Colors.green : Colors.grey.shade300, + width: 1.5, + ), + boxShadow: isSelected + ? [ + BoxShadow( + color: Colors.green.withOpacity(0.1), + spreadRadius: 1, + blurRadius: 3, + offset: const Offset(0, 2), + ), + ] : [], + ), + child: Text( + '$key. $optionText', + style: TextStyle( + fontSize: 17, + color: isSelected ? Colors.green.shade800 : Colors.black87, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ); + }).toList(), + ), + ), + ], + ); + } + +// ======================================================= + +// 3. 하단 버튼 빌드 메서드 (상태 접근 가능) + +// ======================================================= + + Widget _buildBottomButton() { + + return Positioned( + left: 33.0, + right: 33.0, + bottom: 40.0, + child: ElevatedButton( +// 답변 선택 시에만 버튼 활성화 + onPressed: _selectedAnswerKey != null ? _nextQuestion : null, + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 53), + backgroundColor: _selectedAnswerKey != null ? Colors.green.shade600 : Colors.green.shade200, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + + child: Text( +// -1 빼면 마지막 제출하기 한 후 이니까 거기서 마지막 점수확인 으로 바꾸기 + _currentIndex == _quizzes.length - 1 ? '제출하기' : '다음 문제로', +//if quizzes.empty == 점수확인하기 + style: const TextStyle(fontSize: 18, color: Colors.white, fontWeight: FontWeight.bold), + ), + ), + ); + } + + + +// ======================================================= + +// 4. 메인 빌드 메서드 + +// ======================================================= + + @override + Widget build(BuildContext context) { + return Scaffold( + body: FutureBuilder>( + future: _quizzesFuture, + builder: (context, snapshot) { + if (_quizzesFuture == null || snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } else if (snapshot.hasError) { + return Center(child: Text('오류 발생: ${snapshot.error}')); + } else if (snapshot.hasData) { + _quizzes = snapshot.data!; + if (_quizzes.isEmpty) { + return const Center(child: Text('퀴즈가 없습니다.')); + } + + return Container( + color: '#F9F5EC'.toColor(), + child: SafeArea( + child: Stack( + children: [ + // 1. 뒤로 가기 버튼 + Positioned( + top: 26.0, + left: 20.0, + child: GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: const Icon(Icons.arrow_back_ios, color: Colors.black), + ), + ), + + // 2. 퀴즈 헤더 + Positioned( + top: 80.0, + left: 0, + right: 0, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 74, vertical: 20), + decoration: BoxDecoration( + color: Colors.lightGreen.shade200, + borderRadius: BorderRadius.circular(15), + ), + child: const Text( + '금융 역량 진단 퀴즈', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ), + ), + ), + + + +// 3. 메인 퀴즈 박스 (Positioned로 위치/크기 지정) + + Positioned( + left: 33.0, + right: 33.0, + top: 180.0, + bottom: 140.0, + child: Container( + padding: const EdgeInsets.all(24.0), // 패딩 조정 + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 5), + ), + ], + ), + // 퀴즈 내용 메서드 호출 + child: _buildQuizContent(), + ), + ), +// 4. 하단 버튼 메서드 호출 + _buildBottomButton(), + ], + ), + ), + ); + } else { + return const Center(child: Text('퀴즈를 불러오는 중 문제가 발생했습니다.')); + } + }, + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/assessment/services/assessment_service.dart b/lib/features/assessment/services/assessment_service.dart new file mode 100644 index 0000000..1f58352 --- /dev/null +++ b/lib/features/assessment/services/assessment_service.dart @@ -0,0 +1,152 @@ +import 'package:http/http.dart' as http; +import 'dart:convert'; +import 'package:guardpayfront/features/assessment/models/quiz_model.dart'; + +/** + * 진단(Assessment) 관련 백엔드 API 호출을 담당하는 서비스 클래스 + * - 퀴즈 정보 로드 및 사용자 답변 제출 기능을 포함합니다. + */ +class AssessmentService { + // 백엔드 API의 기본 URL + final String baseUrl = 'http://10.0.2.2:8080/api/v1'; + + // 1. ✅ 수정: 모든 퀴즈를 한 번에 로드하는 함수 + Future> fetchAssessmentQuizzes(String accessToken) async { + final url = Uri.parse('$baseUrl/diagnoses/questions'); + + try { + print('📱 [Flutter] 요청 시작: $url'); + print('📱 [Flutter] 토큰 길이: ${accessToken.length}'); + + final response = await http.get( + url, + headers: {'Authorization': 'Bearer $accessToken'}, + ); + + // ✅ 응답 상세 로깅 + print('📱 [Flutter] 응답 코드: ${response.statusCode}'); + print('📱 [Flutter] 응답 본문 (처음 200자): ${response.body.substring(0, response.body.length > 200 ? 200 : response.body.length)}'); + + if (response.statusCode == 200) { + try { + final jsonResponse = json.decode(utf8.decode(response.bodyBytes)); + print('📱 [Flutter] JSON 파싱 성공'); + + final Map data = jsonResponse['data'] ?? {}; + final List parts = data['parts'] ?? []; + + print('📱 [Flutter] parts 개수: ${parts.length}'); + + List allQuizzes = []; + + for (var part in parts) { + final List questions = part['questions'] ?? []; + print('📱 [Flutter] part의 questions 개수: ${questions.length}'); + + for (var questionJson in questions) { + allQuizzes.add(Quiz.fromJson(questionJson)); + } + } + + print('📱 [Flutter] 총 퀴즈 개수: ${allQuizzes.length}'); + return allQuizzes; + + } catch (parseError) { + // ✅ JSON 파싱 에러를 명확히 구분 + print('❌ [Flutter] JSON 파싱 에러: $parseError'); + print('❌ [Flutter] 응답 본문: ${response.body}'); + throw Exception('데이터 파싱 오류: $parseError'); + } + + } else if (response.statusCode == 401) { + print('❌ [Flutter] 401 에러 - 응답: ${response.body}'); + throw Exception('인증 오류 발생 (401). 유효하지 않은 토큰입니다. 재로그인이 필요합니다.'); + } else { + print('❌ [Flutter] ${response.statusCode} 에러 - 응답: ${response.body}'); + throw Exception('퀴즈 로딩 실패: ${response.statusCode} - ${response.body}'); + } + + } on http.ClientException catch (e) { + // ✅ 네트워크 에러 명확히 구분 + print('❌ [Flutter] 네트워크 에러: $e'); + throw Exception('네트워크 연결 오류: $e'); + } catch (e) { + // ✅ 기타 예외를 그대로 다시 던지기 (재포장하지 않음) + print('❌ [Flutter] 예상치 못한 에러: $e'); + print('❌ [Flutter] 예상치 못한 에러: $e'); + + rethrow; // 원본 예외를 그대로 던짐 + } + } + + // 2. ✅ 수정: 모든 퀴즈 답변을 한 번에 제출하고, 결과 ID를 받아, 최종 레벨을 조회하는 함수 + Future submitAssessmentResults(List> submissionData, String accessToken) async { + + // 2-1. API가 요구하는 'answers' 리스트 생성 + List> answers = submissionData.map((data) { + return { + 'quizID': data['quizId'] as int, + // API는 'selectedAnswer'를 숫자로 받으므로 String을 int로 변환 + 'selectedAnswer': int.parse(data['userAnswer'] as String), + }; + }).toList(); + + // 2-2. (POST /diagnoses/submit) 답변 일괄 제출 + final int historyId = await _submitAllAnswers(answers, accessToken); + + // 2-3. (GET /diagnoses/history/{id}) 제출 결과(레벨) 조회 + return _fetchFinalResult(historyId, accessToken); + } + + // 2-2. (Helper) 답변 일괄 제출 + Future _submitAllAnswers(List> answers, String accessToken) async { + final url = Uri.parse('$baseUrl/diagnoses/submit'); + + final response = await http.post( + url, + headers: { + 'Authorization': 'Bearer $accessToken', + 'Content-Type': 'application/json' + }, + body: json.encode({ + 'answers': answers, // API 명세에 맞는 요청 본문 + }), + ); + + if (response.statusCode == 201 || response.statusCode == 200) { // 201 Created + final jsonResponse = json.decode(utf8.decode(response.bodyBytes)); + // 응답에서 historyId 추출 + final int historyId = jsonResponse['data']['historyId'] as int? ?? 0; + if (historyId == 0) { + throw Exception('응답에서 historyId를 찾을 수 없습니다.'); + } + return historyId; + } + else { + throw Exception('퀴즈 제출 실패: ${response.statusCode}'); + } + } + + // 2-3. (Helper) 최종 진단 결과(레벨) 조회 + Future _fetchFinalResult(int historyId, String accessToken) async { + final url = Uri.parse('$baseUrl/diagnoses/history/$historyId'); + + final response = await http.get( + url, + headers: {'Authorization': 'Bearer $accessToken'}, + ); + + if (response.statusCode == 200 || response.statusCode == 201) { + final jsonResponse = json.decode(utf8.decode(response.bodyBytes)); + print('✅ 최종 레벨 서버 응답: $jsonResponse'); // 로그 확인 + + // ✅ 수정: 'finalGrade' 키에서 레벨 문자열 추출 + final String finalGrade = jsonResponse['data']['finalGrade'] as String? ?? 'UNKNOWN'; + print('✅ 파싱된 최종 레벨: $finalGrade'); + + return finalGrade; + } else { + throw Exception('최종 레벨 조회 실패: ${response.statusCode}'); + } + } +} \ No newline at end of file diff --git a/lib/features/assessment/widgets/result_dialog.dart b/lib/features/assessment/widgets/result_dialog.dart new file mode 100644 index 0000000..cbc0e7d --- /dev/null +++ b/lib/features/assessment/widgets/result_dialog.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; + +// 레벨 정보 구조체 (변경 없음) +class LevelData { + final String title; + final List stars; // [첫번째 별 채움 여부, 두번째 별 채움 여부, 세번째 별 채움 여부] + final Color titleColor; + + LevelData({ + required this.title, + required this.stars, + required this.titleColor, + }); +} + +class ResultDialog extends StatelessWidget { + final String level; // High, Middle, Low 중 하나 + final VoidCallback onConfirm; + + const ResultDialog({ + super.key, + required this.level, + required this.onConfirm, + }); + + // 레벨에 따른 데이터 반환 (변경 없음) + LevelData _getLevelData() { + switch (level) { + case '안전 송금 마스터': + return LevelData( + title: '안전 송금 마스터', + stars: [true, true, true], + titleColor: Colors.black, + ); + case '금융 방패단': + return LevelData( + title: '금융 방패단', + stars: [true, false, true], + titleColor: Colors.black, + ); + case '초보 금융가': + return LevelData( + title: '초보 금융가', + stars: [false, false, true], + titleColor: Colors.black, + ); + default: + return LevelData( + title: '주의 필요', + stars: [false, false, false], + titleColor: Colors.grey, + ); + } + } + + // 별 아이콘 빌드 메서드 (변경 없음) + Widget _buildStar(bool isFilled, double size) { + return Icon( + isFilled ? Icons.star : Icons.star_border, + color: isFilled ? Colors.amber : Colors.grey.shade400, + size: size, + ); + } + + @override + Widget build(BuildContext context) { + final data = _getLevelData(); + + return AlertDialog( + // AlertDialog 자체의 모양과 그림자(Elevation)를 조정하여 겹침 문제 완화 + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), // 둥근 모양을 더 크게 + elevation: 0, + contentPadding: EdgeInsets.zero, + + content: Container( // ✅ content 속성의 값 시작 + width: MediaQuery.of(context).size.width * 0.73, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + ), + child: SizedBox( + height: 220.0, // ✅ 팝업 내용의 고정된 높이 (이 값을 변경해야 팝업 높이 고정) + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 1. 별 아이콘 영역 + const SizedBox(height: 20), // ✅ 상단 여백 (이 값을 조절하여 별 위치 조정) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 첫 번째 별 + _buildStar(data.stars[0], 55), + + const SizedBox(width: 8), + + // 두 번째 별 (가운데) + _buildStar(data.stars[1], 65), + + const SizedBox(width: 8), + + // 세 번째 별 + _buildStar(data.stars[2], 45), + ], + ), + + const SizedBox(height: 15), // ✅ 별과 텍스트 사이 여백 (이 값을 조절하여 위치 조정) + + // 2. 레벨 텍스트 + Text( + data.title, + textAlign: TextAlign.center, // 중앙 정렬 추가 + style: TextStyle( + fontSize: 27, + fontWeight: FontWeight.bold, + color: data.titleColor, + ), + ), + + const Spacer(), // ✅ 남은 공간을 밀어내어 버튼을 하단에 고정 + + // 4. 메인으로 버튼 영역 (GestureDetector로 감싸고, 내부 Container 제거) + GestureDetector( + onTap: onConfirm, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 13), + decoration: BoxDecoration( + color: Colors.grey.shade100, // 버튼 영역 배경색 + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(20), + bottomRight: Radius.circular(20), + ), + ), + alignment: Alignment.center, + child: const Text( + '메인으로', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + ), + ), + ], + ), + ), + ), // ✅ content 속성의 값 종료 + ); // ✅ AlertDialog 위젯 종료 + } +} \ No newline at end of file diff --git a/lib/features/auth/screens/chat_screen.dart b/lib/features/auth/screens/chat_screen.dart new file mode 100644 index 0000000..6225c52 --- /dev/null +++ b/lib/features/auth/screens/chat_screen.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import '../services/api_service.dart'; +import 'package:guardpayfront/core/services/storage.dart'; +import 'package:guardpayfront/features/auth/widgets/bottom_nav.dart'; // ✅ 통일된 하단바 + +class ChatScreen extends StatefulWidget { + final ApiService api; + const ChatScreen({super.key, required this.api}); + + @override + State createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + final TextEditingController _textController = TextEditingController(); + final List> _messages = []; + bool _isLoading = false; + + @override + void dispose() { + _textController.dispose(); + super.dispose(); + } + + void _handleSubmitted(String text) async { + if (text.isEmpty || _isLoading) return; + + _textController.clear(); + setState(() { + _messages.insert(0, {'sender': 'user', 'text': text}); + _isLoading = true; + }); + + try { + final aiResponse = await widget.api.sendChatMessage(text); + setState(() { + _messages.insert(0, {'sender': 'ai', 'text': aiResponse}); + }); + } catch (e) { + setState(() { + _messages.insert(0, { + 'sender': 'ai', + 'text': '죄송합니다. 오류가 발생했습니다: $e' + }); + }); + } finally { + setState(() => _isLoading = false); + } + } + + Widget _buildTextComposer() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + color: Colors.white, + child: Row( + children: [ + Expanded( + child: TextField( + controller: _textController, + onSubmitted: _handleSubmitted, + decoration: InputDecoration( + hintText: 'GuardAI에게 물어보세요', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: const Color(0xFFF3F3F3), + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + ), + ), + ), + const SizedBox(width: 8), + CircleAvatar( + backgroundColor: const Color(0xFF7FB77E), + child: IconButton( + icon: const Icon(Icons.arrow_upward, color: Colors.white), + onPressed: _isLoading + ? null + : () => _handleSubmitted(_textController.text), + ), + ), + ], + ), + ); + } + + Widget _buildChatMessage(Map message) { + final bool isUser = message['sender'] == 'user'; + return Container( + margin: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: + isUser ? MainAxisAlignment.end : MainAxisAlignment.start, + children: [ + if (!isUser) + const CircleAvatar( + backgroundColor: Color(0xFF7FB77E), + child: Icon(Icons.auto_awesome, color: Colors.white, size: 18), + ), + if (!isUser) const SizedBox(width: 8), + Flexible( + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isUser + ? const Color(0xFFF8F8F8) + : const Color(0xFFFFFBF5), + borderRadius: BorderRadius.circular(16), + ), + child: Text( + message['text']!, + style: const TextStyle(fontSize: 15, height: 1.4), + ), + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF9F5EC), + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 40, bottom: 16), + child: Column( + children: const [ + Text( + 'GuardAI', + style: TextStyle( + fontSize: 50, + fontWeight: FontWeight.bold, + color: Color(0xFF7FB77E), + shadows: [ + Shadow( + color: Colors.black26, + offset: Offset(1, 1), + blurRadius: 2, + ), + ], + ), + ), + ], + ), + ), + if (_isLoading) + const LinearProgressIndicator(color: Color(0xFF7FB77E)), + Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(8), + reverse: true, + itemBuilder: (_, i) => _buildChatMessage(_messages[i]), + itemCount: _messages.length, + ), + ), + _buildTextComposer(), + ], + ), + ), + bottomNavigationBar: const BottomNav(selectedIndex: 1), // ✅ 통일된 하단바 + ); + } +} diff --git a/lib/features/auth/screens/grade_screen.dart b/lib/features/auth/screens/grade_screen.dart new file mode 100644 index 0000000..8ecefb4 --- /dev/null +++ b/lib/features/auth/screens/grade_screen.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:guardpayfront/features/auth/services/api_service.dart'; + +class GradeScreen extends StatefulWidget { + const GradeScreen({super.key}); + + @override + State createState() => _GradeScreenState(); +} + +class _GradeScreenState extends State { + final ApiService _api = ApiService(); + String? _grade; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _fetchGrade(); + } + + // API를 통해 등급 가져오기 + Future _fetchGrade() async { + try { + final result = await _api.getMyGrade(); + + if (mounted) { + setState(() { + _grade = result; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _grade = null; + _isLoading = false; + }); + } + print('등급 조회 중 오류 발생: $e'); + } + } + + // 등급 텍스트를 보기 좋게 변환 (예: "주의_필요" -> "주의 필요") + String _formatGrade(String? grade) { + if (grade == null) return "정보 없음"; + return grade.replaceAll('_', ' '); + } + + // 등급에 따른 아이콘 색상 (옵션) + Color _getGradeColor(String? grade) { + if (grade == null) return Colors.grey; + if (grade.contains("안전")) return Colors.green; + if (grade.contains("방패")) return Colors.yellow; + if (grade.contains("초보")) return Colors.red; + return Colors.blueAccent; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF9F5EC), // 기존 앱 배경색 유지 + appBar: AppBar( + backgroundColor: const Color(0xFFF9F5EC), + elevation: 0, + centerTitle: true, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.black87), + onPressed: () => Navigator.pop(context), + ), + title: const Text( + "내 등급 정보", + style: TextStyle(color: Colors.black87, fontWeight: FontWeight.bold), + ), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 🔹 등급 카드 디자인 + Container( + width: 300, + padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.2), + spreadRadius: 2, + blurRadius: 10, + offset: const Offset(0, 5), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.verified_user_rounded, // 방패 모양 아이콘 + size: 80, + color: _getGradeColor(_grade), + ), + const SizedBox(height: 20), + const Text( + "현재 나의 금융 등급", + style: TextStyle( + fontSize: 16, + color: Colors.grey, + ), + ), + const SizedBox(height: 10), + Text( + _formatGrade(_grade), + style: const TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + const SizedBox(height: 30), + + // 🔹 새로고침 버튼 (선택 사항) + TextButton.icon( + onPressed: () { + setState(() => _isLoading = true); + _fetchGrade(); + }, + icon: const Icon(Icons.refresh, color: Colors.black54), + label: const Text( + "다시 불러오기", + style: TextStyle(color: Colors.black54), + ), + ) + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/auth/screens/home_screen.dart b/lib/features/auth/screens/home_screen.dart new file mode 100644 index 0000000..0e0ea79 --- /dev/null +++ b/lib/features/auth/screens/home_screen.dart @@ -0,0 +1,334 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:guardpayfront/features/auth/screens/grade_screen.dart'; +import 'chat_screen.dart'; +import 'package:guardpayfront/core/services/storage.dart'; +import 'package:guardpayfront/features/auth/services/api_service.dart'; +import 'package:guardpayfront/features/auth/widgets/bottom_nav.dart'; +import 'package:guardpayfront/features/auth/screens/mypage_screen.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + final storage = AppStorage.storage; + final ApiService _api = ApiService(); + int _selectedIndex = 0; + + final List> _searchOptions = [ + {'title': '역량 진단', 'route': '/assessment'}, + {'title': '금융 퀴즈', 'route': '/quizCategory'}, + {'title': '보이스피싱 예방', 'route': '/video'}, + {'title': '마이페이지', 'route': '/mypage'}, + {'title': '내 등급 조회', 'route': '/grade'}, + ]; + String? accessToken; + + @override + void initState() { + super.initState(); + _loadToken(); + } + + Future _loadToken() async { + final token = await storage.read(key: 'accessToken'); + + if (token != null) { + setState(() => accessToken = token); + } else { + print("🚨 HomeScreen: 저장된 토큰이 없습니다. 로그인 화면으로 이동합니다."); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + Navigator.pushReplacementNamed(context, '/login'); + } + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF9F5EC), + body: SafeArea( + child: Column( + children: [ + const SizedBox(height: 10), + + // 🔹 상단 검색창 + 아이콘들 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const GradeScreen()), + ); + }, + child: const Icon(Icons.menu, color: Colors.black87, size: 26), + ), + + const SizedBox(width: 10), + Expanded( + child: Autocomplete>( + displayStringForOption: (option) => '', + // 1️⃣ 검색 로직 + optionsBuilder: (TextEditingValue textEditingValue) { + if (textEditingValue.text == '') { + return const Iterable>.empty(); + } + return _searchOptions.where((option) { + return option['title']!.contains(textEditingValue.text); + }); + }, + + onSelected: (Map selection) { + FocusManager.instance.primaryFocus?.unfocus(); + print('선택된 메뉴: ${selection['title']}'); + Navigator.pushNamed(context, selection['route']!); + }, + + fieldViewBuilder: (context, textEditingController, focusNode, onFieldSubmitted) { + return TextField( + controller: textEditingController, + focusNode: focusNode, + decoration: InputDecoration( + hintText: '검색어를 입력해주세요.', + hintStyle: const TextStyle(color: Colors.grey), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.0), + borderSide: BorderSide.none, + ), + prefixIcon: const Icon(Icons.search, color: Colors.black54), + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: 10), + ), + ); + }, + + optionsViewBuilder: (context, onSelected, options) { + return Align( + alignment: Alignment.topLeft, + child: Material( + elevation: 4.0, + color: Colors.transparent, + child: Container( + width: MediaQuery.of(context).size.width - 90, + constraints: const BoxConstraints( + maxHeight: 200, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + ), + child: ListView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + itemCount: options.length, + itemBuilder: (BuildContext context, int index) { + final option = options.elementAt(index); + return ListTile( + title: Text(option['title']!), + onTap: () { + onSelected(option); + }, + ); + }, + ), + ), + ), + ); + }, + ), + ), + const SizedBox(width: 15), + + const SizedBox(width: 12), + GestureDetector( + onTap: () { + Navigator.pushNamed(context, '/mypage'); + }, + child: Image.asset('assets/images/settings_icon.png', + width: 26, height: 26), + ), + ], + ), + ), + + // 🔹 상단 배너 - 로고 흰 박스 제거 + Container( + width: double.infinity, + height: 190, + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFFFFF8DC), + Color(0xFFFFF0B3), + ], + ), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // 로고 (흰 박스 제거) + Image.asset( + 'assets/images/main_logo-removebg-preview.png', + width: 130, + height: 130, + fit: BoxFit.contain, + ), + + const SizedBox(width: 20), + + // 텍스트 + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '안전 송금,\n퀴즈로 배우자!', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w800, + color: Color(0xFF2D2D2D), + height: 1.2, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: const Color(0xFFFFB74D), + borderRadius: BorderRadius.circular(12), + ), + child: const Text( + '퀴즈 풀고 포인트 모으기', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + + const SizedBox(height: 11), + + // 🔹 카드 3개 + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + _buildCard( + title: '역량 진단 시작하기', + imagePath: 'assets/images/checkBox_icon.png', + onTap: () { + Navigator.pushNamed(context, '/assessment'); + }, + ), + _buildCard( + title: '금융 퀴즈 도전', + imagePath: 'assets/images/quiz_icon2.png', + onTap: () { + Navigator.pushNamed(context, '/quizCategory'); + }, + ), + _buildCard( + title: '보이스피싱 예방 영상', + imagePath: 'assets/images/youtube_icon.png', + onTap: () { + Navigator.pushNamed(context, '/video'); + }, + ), + ], + ), + ), + ), + ], + ), + ), + bottomNavigationBar: const BottomNav(selectedIndex: 0), + ); + } + + Widget _buildCard({ + required String title, + required String imagePath, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 347, + height: 140, + margin: const EdgeInsets.symmetric(vertical: 15), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(37), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.3), + spreadRadius: 1, + blurRadius: 5, + offset: const Offset(0, 2), + ), + ], + ), + child: Stack( + children: [ + Positioned( + left: 22, + top: 27, + child: Image.asset(imagePath, width: 50, height: 50), + ), + Positioned( + left: 35, + top: 35, + child: SizedBox( + width: 350 - (16 * 2), + child: Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 23.5, + ), + ), + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/auth/screens/login_screen.dart b/lib/features/auth/screens/login_screen.dart new file mode 100644 index 0000000..708829b --- /dev/null +++ b/lib/features/auth/screens/login_screen.dart @@ -0,0 +1,290 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'dart:convert'; // jsonDecode를 사용하기 위해 필요 +import 'package:http/http.dart' as http; // http 사용을 위해 필요 +import 'package:guardpayfront/core/services/storage.dart'; +import 'package:guardpayfront/features/auth/services/social_login_service.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + // 1. 서비스 인스턴스 초기화 + final _emailController = TextEditingController(); + + final _passwordController = TextEditingController(); + //final _storage = const FlutterSecureStorage(); + final _storage = AppStorage.storage; // ✅ 교체 + final _socialLoginService = SocialLoginService(); // 추가 + + // 카카오 로그인 처리 + Future _handleKakaoLogin() async { + setState(() => _isLoading = true); + try { + final result = await _socialLoginService.loginWithKakao(); + + _showSnackBar(result['message']); + + if (result['success'] && mounted) { + Navigator.pushReplacementNamed(context, '/home'); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + // 구글 로그인 처리 + Future _handleGoogleLogin() async { + setState(() => _isLoading = true); + try { + final result = await _socialLoginService.loginWithGoogle(); + + _showSnackBar(result['message']); + + if (result['success'] && mounted) { + Navigator.pushReplacementNamed(context, '/home'); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + + bool _isLoading = false; + bool _obscureText = true; + + // 2. 일관된 SnackBar 표시를 위한 유틸리티 함수 + void _showSnackBar(String message) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + } + + // 3. 이메일/비밀번호 로그인 처리 + Future _handleLogin() async { + // 입력 유효성 검사 + if (_emailController.text.isEmpty || _passwordController.text.isEmpty) { + _showSnackBar('이메일과 비밀번호를 모두 입력해주세요.'); + return; + } + + const apiUrl = 'http://10.0.2.2:8080/api/auth/login'; + final loginData = { + 'email': _emailController.text, + 'password': _passwordController.text, + }; + + setState(() => _isLoading = true); + try { + final response = await http.post( + Uri.parse(apiUrl), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(loginData), + ); + + if (response.statusCode == 200) { + // ⬇️ [수정] response.body 대신 response.bodyBytes를 UTF-8로 디코딩합니다. + final responseBody = utf8.decode(response.bodyBytes); + + // ⬇️ [디버그] Flutter가 받은 응답 원본 확인 + print('✅ [Login Success] 서버 응답 본문 (UTF-8 디코딩): $responseBody'); + + final data = jsonDecode(responseBody); // 디코딩된 문자열을 JSON 객체로 파싱 + + final accessToken = data['accessToken']; + final refreshToken = data['refreshToken']; + + // ⬇️ [디버그] 파싱된 토큰 값 확인 + print('Access Token from data: $accessToken'); + print('Refresh Token from data: $refreshToken'); + + + if (accessToken != null && refreshToken != null) { + // 토큰 저장 + await _storage.write(key: 'tokenType', value: 'Bearer'); + await _storage.write(key: 'accessToken', value: accessToken); + await _storage.write(key: 'refreshToken', value: refreshToken); + +// 저장 확인 (중요) + final checkA = await _storage.read(key: 'accessToken'); + final checkR = await _storage.read(key: 'refreshToken'); + final checkT = await _storage.read(key: 'tokenType'); + + print('>>> saved? access=${checkA != null}, refresh=${checkR != null}'); + _showSnackBar('로그인 성공!'); + if (!mounted) return; + // 홈 화면으로 이동 (로그인 페이지는 제거) + Navigator.pushReplacementNamed(context, '/home'); + + } else { + // 🚨 서버가 토큰을 반환했지만, 필드가 누락된 경우 + _showSnackBar('로그인은 성공했지만, 토큰 필드(accessToken/refreshToken)가 누락되었습니다. 서버의 응답 구조를 확인해주세요.'); + } + } else { + // 오류 처리 + // ⬇️ 오류 응답도 UTF-8 디코딩을 시도합니다. + final errorBody = jsonDecode(utf8.decode(response.bodyBytes)); + _showSnackBar(errorBody['message'] ?? '로그인 실패: 서버 오류'); + } + } catch (e) { + print('🚨 로그인 요청 실패: $e'); // 실제 예외 로깅 + _showSnackBar('서버와 통신할 수 없습니다.'); + } finally { + setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF9F5EC), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 60), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 5), + Center( + child: Column( + children: [ + Image.asset( + 'assets/images/logo.png', + width: 350, + height: 350, + fit: BoxFit.contain, + ), + const SizedBox(height: 10), + ], + ), + ), + const SizedBox(height: 10), + TextField( + controller: _emailController, + decoration: const InputDecoration( + hintText: '이메일', + border: OutlineInputBorder(), + filled: true, + fillColor: Colors.white, + contentPadding: + EdgeInsets.symmetric(vertical: 13.0, horizontal: 15.0), + ), + ), + const SizedBox(height: 13), + TextField( + controller: _passwordController, + obscureText: _obscureText, + decoration: InputDecoration( + hintText: '비밀번호', + border: const OutlineInputBorder(), + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric( + vertical: 13.0, horizontal: 15.0), + suffixIcon: IconButton( + icon: Icon( + _obscureText ? Icons.visibility_off : Icons.visibility, + ), + onPressed: () { + setState(() { + _obscureText = !_obscureText; + }); + }, + ), + ), + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: _isLoading ? null : _handleLogin, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6AA84F), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 13), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), + ), + child: _isLoading + ? const CircularProgressIndicator(color: Colors.white) + : const Text('로그인'), + ), + const SizedBox(height: 13), + ElevatedButton( + onPressed: () { + Navigator.pushNamed(context, '/signup'); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6AA84F), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 13), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), + ), + child: const Text('회원가입'), + ), + const SizedBox(height: 7), + TextButton( + onPressed: () { + Navigator.pushNamed(context, '/reset'); + }, + style: TextButton.styleFrom( + foregroundColor: Colors.black, + textStyle: const TextStyle(fontSize: 14), + ), + child: const Text('임시 비밀번호 발급받기 >'), + ), + const SizedBox(height: 18), + // ✅ 수정: 소셜 로그인 버튼 연결 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + GestureDetector( + onTap: _isLoading ? null : _handleKakaoLogin, // ✅ 카카오 로그인 연결 + child: Opacity( + opacity: _isLoading ? 0.5 : 1.0, // 로딩 중일 때 비활성화 표시 + child: Image.asset( + 'assets/images/kakao_logo.png', + width: 70, + height: 70, + ), + ), + ), + const SizedBox(width: 22), + GestureDetector( + onTap: _isLoading ? null : _handleGoogleLogin, // ✅ 구글 로그인 연결 + child: Opacity( + opacity: _isLoading ? 0.5 : 1.0, // 로딩 중일 때 비활성화 표시 + child: Image.asset( + 'assets/images/google_logo.png', + width: 70, + height: 70, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + diff --git a/lib/features/auth/screens/mypage_screen.dart b/lib/features/auth/screens/mypage_screen.dart new file mode 100644 index 0000000..6bee7f7 --- /dev/null +++ b/lib/features/auth/screens/mypage_screen.dart @@ -0,0 +1,961 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:guardpayfront/core/services/storage.dart'; +import 'package:guardpayfront/features/auth/services/api_service.dart'; +import 'package:guardpayfront/features/auth/widgets/bottom_nav.dart'; +import 'package:guardpayfront/features/shop/screens/my_coupon_screen.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:path_provider/path_provider.dart'; +import 'dart:io'; + +class MypageScreen extends StatefulWidget { + const MypageScreen({super.key}); + + @override + State createState() => _MypageScreenState(); +} + +class _MypageScreenState extends State { + final storage = AppStorage.storage; + final ApiService _api = ApiService(); + final _formKey = GlobalKey(); + String? _accessToken; + + final TextEditingController _nicknameController = TextEditingController(); + final TextEditingController _currentPasswordController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _passwordConfirmController = TextEditingController(); + + String? profileImageUrl; + File? _selectedImage; + int _points = 0; + String _grade = '주의 필요'; + bool isLoading = true; + bool isEditing = false; + bool isUpdating = false; + + @override + void initState() { + super.initState(); + _loadUserInfo(); + } + + Map _getGradeInfo(String grade) { + final normalizedGrade = grade.replaceAll('_', '').replaceAll(' ', ''); + const Color commonTextColor = Color(0xFF424242); + + switch (normalizedGrade) { + case '안전송금마스터': + return { + 'text': '안전 송금 마스터', + 'textColor': commonTextColor, + 'bgColor': const Color(0xFFE8F5E9), + }; + + case '금융방패단': + return { + 'text': '금융 방패단', + 'textColor': commonTextColor, + 'bgColor': const Color(0xFFFFFDE7), + }; + + case '초보금융가': + return { + 'text': '초보 금융가', + 'textColor': commonTextColor, + 'bgColor': const Color(0xFFFFEBEE), + }; + + default: + return { + 'text': '주의 필요', + 'textColor': commonTextColor, + 'bgColor': const Color(0xFFE3F2FD), + }; + } + } + + Future _loadUserInfo() async { + try { + final token = await storage.read(key: 'accessToken'); + + if (token == null) { + Navigator.pushReplacementNamed(context, '/login'); + return; + } + + _accessToken = token; + + final response = await _api.get( + '/api/members/profile', + headers: {'Authorization': 'Bearer $token'}, + ); + + print("🔍 프로필 응답: $response"); + + if (response != null) { + setState(() { + _nicknameController.text = response['nickname'] ?? 'OOO'; + _points = response['points'] ?? 0; + _grade = response['grade'] ?? '주의 필요'; + + final rawImageUrl = response['profileImageUrl']; + if (rawImageUrl != null) { + profileImageUrl = Platform.isAndroid + ? rawImageUrl.replaceAll("localhost", "10.0.2.2") + : rawImageUrl; + } else { + profileImageUrl = null; + } + + isLoading = false; + }); + } else { + setState(() { + _nicknameController.text = "OOO"; + _points = 0; + isLoading = false; + }); + } + } catch (e) { + print("❌ 사용자 정보 로드 실패: $e"); + setState(() { + _nicknameController.text = "OOO"; + _points = 0; + isLoading = false; + }); + } + } + + Future _pickImage() async { + final ImagePicker picker = ImagePicker(); + + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.photo_library, color: Colors.blue), + ), + title: const Text('갤러리에서 선택'), + onTap: () async { + Navigator.pop(context); + final XFile? image = await picker.pickImage(source: ImageSource.gallery); + if (image != null) { + setState(() { + _selectedImage = File(image.path); + }); + } + }, + ), + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.green.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.camera_alt, color: Colors.green), + ), + title: const Text('카메라로 촬영'), + onTap: () async { + Navigator.pop(context); + final XFile? image = await picker.pickImage(source: ImageSource.camera); + if (image != null) { + setState(() { + _selectedImage = File(image.path); + }); + } + }, + ), + ListTile( + leading: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.orange.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.image, color: Colors.orange), + ), + title: const Text('테스트 이미지 사용'), + onTap: () async { + Navigator.pop(context); + try { + final ByteData data = await rootBundle.load('assets/images/TestProfile.jpg'); + final buffer = data.buffer; + final tempDir = await getTemporaryDirectory(); + final tempFile = File('${tempDir.path}/test_profile.jpg'); + await tempFile.writeAsBytes( + buffer.asUint8List(data.offsetInBytes, data.lengthInBytes), + ); + + setState(() { + _selectedImage = tempFile; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('테스트 이미지가 선택되었습니다.'), + backgroundColor: Colors.blue, + ), + ); + } catch (e) { + print('❌ 테스트 이미지 로드 실패: $e'); + } + }, + ), + ], + ), + ), + ), + ); + } + + Future _updateProfile() async { + if (!_formKey.currentState!.validate()) return; + + if (_passwordController.text.isNotEmpty && + _passwordController.text != _passwordConfirmController.text) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('비밀번호가 일치하지 않습니다.')), + ); + return; + } + + setState(() => isUpdating = true); + + try { + final token = await storage.read(key: 'accessToken'); + + if (token == null) { + Navigator.pushReplacementNamed(context, '/login'); + return; + } + + final Map updateData = { + 'nickname': _nicknameController.text, + }; + + if (_passwordController.text.isNotEmpty) { + updateData['currentPassword'] = _currentPasswordController.text; + updateData['password'] = _passwordController.text; + } + + final response = await _api.put( + '/api/members/profile', + data: updateData, + headers: {'Authorization': 'Bearer $token'}, + ); + + if (response != null && _selectedImage != null) { + await _api.uploadImage( + '/api/members/profile/image', + _selectedImage!, + headers: {'Authorization': 'Bearer $token'}, + ); + } + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('프로필이 성공적으로 수정되었습니다.'), + backgroundColor: Colors.green, + ), + ); + setState(() { + isEditing = false; + _passwordController.clear(); + _passwordConfirmController.clear(); + _currentPasswordController.clear(); + }); + _loadUserInfo(); + } + } catch (e) { + print("❌ 프로필 수정 실패: $e"); + } finally { + setState(() => isUpdating = false); + } + } + + // 로그아웃 + Future _logout() async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('로그아웃'), + content: const Text('로그아웃 하시겠습니까?'), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('취소', style: TextStyle(color: Colors.grey)), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('로그아웃', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + + if (confirm == true) { + try { + // 저장된 토큰 삭제 + await storage.delete(key: 'accessToken'); + await storage.delete(key: 'refreshToken'); + + if (mounted) { + // 로그인 화면으로 이동 (뒤로가기 불가) + Navigator.pushNamedAndRemoveUntil( + context, + '/login', + (route) => false, + ); + } + } catch (e) { + print('❌ 로그아웃 실패: $e'); + } + } + } + + @override + void dispose() { + _nicknameController.dispose(); + _currentPasswordController.dispose(); + _passwordController.dispose(); + _passwordConfirmController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final gradeInfo = _getGradeInfo(_grade); + return Scaffold( + backgroundColor: Colors.grey[50], + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.black87, size: 20), + onPressed: () => Navigator.pop(context), + ), + title: const Text( + '마이페이지', + style: TextStyle( + color: Colors.black87, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + centerTitle: true, + ), + body: isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + child: Form( + key: _formKey, + child: Column( + children: [ + // 프로필 헤더 영역 + Container( + width: double.infinity, + color: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 32), + child: Column( + children: [ + // 프로필 이미지 + Stack( + children: [ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: Colors.grey[200]!, + width: 2, + ), + image: _selectedImage != null + ? DecorationImage( + image: FileImage(_selectedImage!), + fit: BoxFit.cover, + ) + : profileImageUrl != null + ? DecorationImage( + image: NetworkImage( + profileImageUrl!, + headers: { + 'Authorization': 'Bearer $_accessToken' + }, + ), + fit: BoxFit.cover, + ) + : null, + color: Colors.grey[100], + ), + child: _selectedImage == null && profileImageUrl == null + ? Icon(Icons.person, size: 50, color: Colors.grey[400]) + : null, + ), + if (isEditing) + Positioned( + bottom: 0, + right: 0, + child: GestureDetector( + onTap: _pickImage, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + child: const Icon( + Icons.camera_alt, + size: 16, + color: Colors.white, + ), + ), + ), + ), + ], + ), + + const SizedBox(height: 16), + + // 닉네임 + Text( + _nicknameController.text, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + + const SizedBox(height: 8), + + // 등급 배지 + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: gradeInfo['bgColor'], + borderRadius: BorderRadius.circular(20), + ), + child: Row( // Row 추가 + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.verified_user_rounded, + size: 16, // 배지에 맞게 크기 조정 + color: gradeInfo['textColor'], + ), + const SizedBox(width: 6), + Text( + gradeInfo['text'], // 등급별 텍스트 + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: gradeInfo['textColor'], // 등급별 글자색 + ), + ), + ], + ), + ), + ], + ), + ), + + const SizedBox(height: 12), + + // 포인트 카드 + Container( + margin: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Colors.green[400]!, Colors.green[600]!], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.green.withOpacity(0.3), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '내 포인트', + style: TextStyle( + fontSize: 14, + color: Colors.white70, + fontWeight: FontWeight.w500, + ), + ), + SizedBox(height: 4), + Text( + 'My Points', + style: TextStyle( + fontSize: 11, + color: Colors.white60, + ), + ), + ], + ), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '$_points', + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(width: 4), + const Padding( + padding: EdgeInsets.only(bottom: 4), + child: Text( + 'P', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ], + ), + ], + ), + ), + + const SizedBox(height: 24), + + // 계정 정보 섹션 + Container( + margin: const EdgeInsets.symmetric(horizontal: 20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(20, 20, 20, 16), + child: Text( + '계정 정보', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + ), + + Divider(height: 1, color: Colors.grey[200]), + + // 닉네임 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: isEditing + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '닉네임', + style: TextStyle( + fontSize: 13, + color: Colors.grey, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _nicknameController, + decoration: InputDecoration( + hintText: '닉네임을 입력하세요', + hintStyle: TextStyle(color: Colors.grey[400]), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey[300]!), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey[300]!), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Colors.green), + ), + filled: true, + fillColor: Colors.grey[50], + ), + ), + ], + ) + : Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '닉네임', + style: TextStyle( + fontSize: 15, + color: Colors.grey, + ), + ), + Text( + _nicknameController.text, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + ], + ), + ), + + Divider(height: 1, color: Colors.grey[200]), + + // 비밀번호 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: isEditing + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '비밀번호 변경', + style: TextStyle( + fontSize: 13, + color: Colors.grey, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _currentPasswordController, + obscureText: true, + decoration: InputDecoration( + hintText: '현재 비밀번호', + hintStyle: TextStyle(color: Colors.grey[400]), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey[300]!), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey[300]!), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Colors.green), + ), + filled: true, + fillColor: Colors.grey[50], + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _passwordController, + obscureText: true, + decoration: InputDecoration( + hintText: '새 비밀번호', + hintStyle: TextStyle(color: Colors.grey[400]), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey[300]!), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey[300]!), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Colors.green), + ), + filled: true, + fillColor: Colors.grey[50], + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _passwordConfirmController, + obscureText: true, + decoration: InputDecoration( + hintText: '새 비밀번호 확인', + hintStyle: TextStyle(color: Colors.grey[400]), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey[300]!), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey[300]!), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Colors.green), + ), + filled: true, + fillColor: Colors.grey[50], + ), + ), + ], + ) + : const Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '비밀번호', + style: TextStyle( + fontSize: 15, + color: Colors.grey, + ), + ), + Text( + '••••••••', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + ], + ), + ), + ], + ), + ), + + const SizedBox(height: 16), + + // 메뉴 섹션 + Container( + margin: const EdgeInsets.symmetric(horizontal: 20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Divider(height: 1, color: Colors.grey[200]), + _buildMenuItem( + icon: Icons.card_giftcard, + title: '내 쿠폰함', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const MyCouponScreen(), + ), + ); + }, + ), + Divider(height: 1, color: Colors.grey[200]), + _buildMenuItem( + icon: Icons.logout, + title: '로그아웃', + onTap: _logout, + isLogout: true, + ), + ], + ), + ), + + const SizedBox(height: 32), + + // 버튼 영역 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: isEditing + ? Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () { + setState(() { + isEditing = false; + _passwordController.clear(); + _passwordConfirmController.clear(); + _currentPasswordController.clear(); + }); + _loadUserInfo(); + }, + style: OutlinedButton.styleFrom( + side: BorderSide(color: Colors.grey[300]!), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: const Text( + '취소', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: isUpdating ? null : _updateProfile, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: isUpdating + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text( + '저장', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ) + : ElevatedButton( + onPressed: () { + setState(() { + isEditing = true; + }); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + minimumSize: const Size.fromHeight(52), + ), + child: const Text( + '프로필 수정', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + + const SizedBox(height: 40), + ], + ), + ), + ), + bottomNavigationBar: const BottomNav(selectedIndex: 4), + ); + } + + Widget _buildMenuItem({ + required IconData icon, + required String title, + required VoidCallback onTap, + bool isLogout = false, + }) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isLogout ? Colors.red.withOpacity(0.1) : Colors.grey[100], + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 20, color: isLogout ? Colors.red : Colors.green), + ), + const SizedBox(width: 16), + Expanded( + child: Text( + title, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: isLogout ? Colors.red : Colors.black87, + ), + ), + ), + if (!isLogout) + Icon(Icons.chevron_right, color: Colors.grey[400], size: 20), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/auth/screens/reset_password_screen.dart b/lib/features/auth/screens/reset_password_screen.dart new file mode 100644 index 0000000..24e6257 --- /dev/null +++ b/lib/features/auth/screens/reset_password_screen.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import '../services/auth_service.dart'; // AuthService 사용 + +class ResetPasswordScreen extends StatefulWidget { + const ResetPasswordScreen({super.key}); + + @override + State createState() => _ResetPasswordScreenState(); +} + +class _ResetPasswordScreenState extends State { + final AuthService _authService = AuthService(); + final _formKey = GlobalKey(); + + // 이메일 입력 컨트롤러 + final _emailController = TextEditingController(); + + // 상태 관리 필드 + bool _isCodeRequested = false; // 버튼 비활성화에 사용 (발급 완료 시) + bool _isLoading = false; // 로딩 상태 + + @override + void dispose() { + _emailController.dispose(); + super.dispose(); + } + + // 임시 비밀번호 발급 요청 & 성공 시 로그인 화면으로 돌아가기 + Future _handleCodeRequest() async { + // 1. 이메일 유효성 검사 + if (_emailController.text.isEmpty || !_emailController.text.contains('@')) { + _showSnackBar('유효한 이메일을 입력해주세요.'); + return; + } + + setState(() => _isLoading = true); + + try { + // 2. 서버에 임시 비밀번호 발급 요청 + await _authService.requestPasswordResetCode(_emailController.text); + + // 3. 요청 성공 시 알림 표시 및 화면 이동 + _showSnackBar('임시 비밀번호 전송 완료. 로그인 화면으로 돌아갑니다.'); + if (mounted) { + Navigator.of(context).pop(); + } + } catch (e) { + // 4. 요청 실패 시 에러 메시지 표시 + _showSnackBar(e.toString().replaceFirst('Exception: ', '')); + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + // 공통 SnackBar 표시 유틸리티 + void _showSnackBar(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + + @override + Widget build(BuildContext context) { + // 버튼 스타일 정의 + final buttonStyle = ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6AA84F), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 13), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ); + final disabledButtonStyle = buttonStyle.copyWith( + backgroundColor: WidgetStateProperty.all(Colors.grey.shade400), + ); + + return Scaffold( + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black), + onPressed: () => Navigator.of(context).pop(), + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 26, vertical: 20), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + '임시 비밀번호 발급', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 65), + + // 이메일 입력 섹션 + const Text('가입한 이메일 주소를 입력해주세요.', style: TextStyle(fontSize: 15)), + const SizedBox(height: 10), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + hintText: '이메일', + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF6AA84F), width: 2.0), + ), + ), + readOnly: _isCodeRequested, // 발급 요청 후에는 수정 불가 + ), + const SizedBox(height: 10), + + // 발급 버튼 + ElevatedButton( + onPressed: _isLoading || _isCodeRequested ? null : _handleCodeRequest, + style: _isLoading || _isCodeRequested ? disabledButtonStyle : buttonStyle, + child: const Text('발급'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/auth/screens/signup_screen.dart b/lib/features/auth/screens/signup_screen.dart new file mode 100644 index 0000000..78051e3 --- /dev/null +++ b/lib/features/auth/screens/signup_screen.dart @@ -0,0 +1,533 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:kakao_flutter_sdk_user/kakao_flutter_sdk_user.dart'; + +// 분리된 서비스와 위젯을 임포트합니다. +import '../services/auth_service.dart'; +import '../services/api_service.dart'; +import '../widgets/auth_input_field.dart'; + +// 회원가입 화면 +class SignupScreen extends StatefulWidget { + const SignupScreen({super.key}); + + @override + State createState() => _SignupScreenState(); +} + +class _SignupScreenState extends State { + // 1. 서비스 인스턴스 및 상태 관리 + final AuthService _authService = AuthService(); + final ApiService _apiService = ApiService(); + final _formKey = GlobalKey(); + + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _passwordConfirmController = TextEditingController(); + final _nicknameController = TextEditingController(); + + bool _termsAgreed = false; + bool _serviceTermsAgreed = false; + bool _privacyPolicyAgreed = false; + bool _marketingAgreed = false; + + bool _isEmailChecked = false; // 이메일 중복 확인 여부 + bool _isLoading = false; + + // ========== 약관 전문 ========== + final String termsOfServiceText = """ +[서비스 이용약관] + +1. 본 서비스는 회원에게 다양한 기능을 제공합니다. +2. 회원은 서비스 이용 시 관련 법령을 준수해야 합니다. +3. 회사는 안전하고 안정적인 서비스 제공을 위해 노력합니다. +4. 기타 자세한 내용은 본 약관에 따릅니다. +"""; + + final String privacyPolicyText = """ +[개인정보 수집 및 이용 안내] + +1. 수집 항목: 이메일, 비밀번호, 닉네임 +2. 이용 목적: 회원가입, 본인확인, 서비스 운영 및 고객 상담 +3. 보관기간: 회원 탈퇴 시까지 +"""; + + final String marketingPolicyText = """ +[마케팅 정보 수신 동의] + +1. 이벤트, 혜택, 광고 정보를 제공할 수 있습니다. +2. 수신 여부는 언제든지 설정에서 변경할 수 있습니다. +"""; + + @override + void initState() { + super.initState(); + _emailController.addListener(_onEmailChanged); + } + + // 이메일이 변경되면 중복 확인 상태 초기화 + void _onEmailChanged() { + if (_isEmailChecked) { + setState(() => _isEmailChecked = false); + } + } + + // 약관 팝업 + void _showPolicyDialog(String title, String content) { + showDialog( + context: context, + builder: (_) { + return AlertDialog( + title: Text(title), + content: SingleChildScrollView(child: Text(content)), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text("닫기"), + ), + ], + ); + }, + ); + } + + // 약관 전체 동의 토글 + void _toggleAllTerms(bool? value) { + setState(() { + _termsAgreed = value ?? false; + _serviceTermsAgreed = value ?? false; + _privacyPolicyAgreed = value ?? false; + _marketingAgreed = value ?? false; + }); + } + + // 개별 약관 체크 시 전체 동의 상태 업데이트 + void _updateAllTermsState() { + setState(() { + _termsAgreed = _serviceTermsAgreed && _privacyPolicyAgreed; + }); + } + + // 2. 이메일 중복 확인 핸들러 + Future _handleEmailCheck() async { + final email = _emailController.text.trim(); + if (email.isEmpty) { + _showSnackBar('이메일을 입력해주세요.'); + return; + } + + final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); + if (!emailRegex.hasMatch(email)) { + _showSnackBar('올바른 이메일 형식을 입력해주세요.'); + return; + } + + setState(() => _isLoading = true); + try { + final isAvailable = await _apiService.checkEmailDuplicate(email); + if (isAvailable) { + setState(() => _isEmailChecked = true); + _showSnackBar('사용 가능한 이메일입니다.'); + } + } catch (e) { + setState(() => _isEmailChecked = false); + _showSnackBar(e.toString().replaceFirst('Exception: ', '')); + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + // 3. '가입하기' 버튼 함수 (최종 제출) + Future _handleSubmit() async { + if (!_formKey.currentState!.validate()) { + return; + } + + if (_passwordController.text != _passwordConfirmController.text) { + _showSnackBar('비밀번호가 일치하지 않습니다.'); + return; + } + + if (!_isEmailChecked) { + _showSnackBar('이메일 중복 확인을 완료해주세요.'); + return; + } + + if (!_serviceTermsAgreed || !_privacyPolicyAgreed) { + _showSnackBar('필수 약관에 동의해주세요.'); + return; + } + + setState(() => _isLoading = true); + try { + final message = await _authService.signup( + email: _emailController.text, + password: _passwordController.text, + nickname: _nicknameController.text, + ); + _showSnackBar(message); + + await Future.delayed(const Duration(seconds: 1)); + if (mounted) { + Navigator.pushReplacementNamed(context, '/login'); + } + } catch (e) { + _showSnackBar(e.toString().replaceFirst('Exception: ', '')); + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + // 4. 카카오 로그인/가입 처리 핸들러 + Future _handleKakaoSignup() async { + if (_isLoading) return; + setState(() => _isLoading = true); + + try { + final result = await _authService.signupWithKakao(); + final isNewUser = result['isNewUser'] ?? false; + + if (isNewUser) { + _showSnackBar('카카오 계정으로 가입을 진행합니다. 추가 정보 입력 화면으로 이동합니다.'); + } else { + _showSnackBar('카카오 계정으로 로그인되었습니다. 메인 화면으로 이동합니다.'); + } + } catch (e) { + _showSnackBar(e.toString().replaceFirst('Exception: ', '')); + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + // 간결한 SnackBar 표시 유틸리티 + void _showSnackBar(String message) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + } + + // 리소스 해제 + @override + void dispose() { + _emailController.removeListener(_onEmailChanged); + _emailController.dispose(); + _passwordController.dispose(); + _passwordConfirmController.dispose(); + _nicknameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.black), + onPressed: () => Navigator.of(context).pop(), + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // GuardPay 로고 + const Text( + 'GuardPay', + textAlign: TextAlign.center, + style: TextStyle( + color: Color(0xFF6AA84F), + fontSize: 55, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 30), + + // 1. 이메일 입력 및 중복 확인 + const Text('이메일 *', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 5), + Row( + children: [ + Expanded( + child: AuthInputField( + controller: _emailController, + hintText: '이메일', + keyboardType: TextInputType.emailAddress, + validator: (value) { + if (value == null || value.isEmpty) { + return '이메일을 입력해주세요.'; + } + final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); + if (!emailRegex.hasMatch(value)) { + return '올바른 이메일 형식을 입력해주세요.'; + } + return null; + }, + ), + ), + const SizedBox(width: 10), + ElevatedButton( + onPressed: _isLoading ? null : _handleEmailCheck, + style: ElevatedButton.styleFrom( + backgroundColor: _isEmailChecked + ? Colors.grey + : const Color(0xFF6AA84F), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: _isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + )) + : Text( + _isEmailChecked ? '확인완료' : '중복확인', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + if (_isEmailChecked) + Padding( + padding: const EdgeInsets.only(top: 5), + child: Text( + '✓ 사용 가능한 이메일입니다', + style: TextStyle( + color: Colors.green[700], + fontSize: 12, + ), + ), + ), + const SizedBox(height: 15), + + // 2. 비밀번호 입력 + const Text('비밀번호 *', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 5), + AuthInputField( + controller: _passwordController, + hintText: '비밀번호', + isPassword: true, + validator: (value) { + if (value == null || value.isEmpty || value.length < 8) { + return '비밀번호는 8자 이상이어야 합니다.'; + } + return null; + }, + ), + const SizedBox(height: 10), + AuthInputField( + controller: _passwordConfirmController, + hintText: '비밀번호 재입력', + isPassword: true, + validator: (value) { + if (value != _passwordController.text) { + return '비밀번호가 일치하지 않습니다.'; + } + return null; + }, + ), + const SizedBox(height: 15), + + // 3. 닉네임 입력 + const Text('닉네임 *', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 5), + AuthInputField( + controller: _nicknameController, + hintText: '닉네임', + validator: (value) { + if (value == null || value.isEmpty) { + return '닉네임을 입력해주세요.'; + } + return null; + }, + ), + const SizedBox(height: 25), + + // 4. 약관 동의 (상세) + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFD0D0D0)), + ), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 전체 동의 + Row( + children: [ + Checkbox( + value: _termsAgreed, + onChanged: _toggleAllTerms, + ), + const Text( + '약관 전체 동의', + style: TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + const Divider(), + + // 필수 약관 1 - 서비스 이용약관 + Row( + children: [ + Checkbox( + value: _serviceTermsAgreed, + onChanged: (v) { + setState(() => _serviceTermsAgreed = v ?? false); + _updateAllTermsState(); + }, + ), + const Expanded( + child: Text( + '(필수) 서비스 이용약관 동의', + style: TextStyle(fontSize: 14), + ), + ), + TextButton( + onPressed: () => _showPolicyDialog( + "서비스 이용약관", + termsOfServiceText, + ), + child: const Text('보기'), + ), + ], + ), + + // 필수 약관 2 - 개인정보 수집 + Row( + children: [ + Checkbox( + value: _privacyPolicyAgreed, + onChanged: (v) { + setState(() => _privacyPolicyAgreed = v ?? false); + _updateAllTermsState(); + }, + ), + const Expanded( + child: Text( + '(필수) 개인정보 수집 및 이용 동의', + style: TextStyle(fontSize: 14), + ), + ), + TextButton( + onPressed: () => _showPolicyDialog( + "개인정보 수집 및 이용", + privacyPolicyText, + ), + child: const Text('보기'), + ), + ], + ), + + // 선택 약관 - 마케팅 + Row( + children: [ + Checkbox( + value: _marketingAgreed, + onChanged: (v) { + setState(() => _marketingAgreed = v ?? false); + }, + ), + const Expanded( + child: Text( + '(선택) 마케팅 정보 수신 동의', + style: TextStyle(fontSize: 14), + ), + ), + TextButton( + onPressed: () => _showPolicyDialog( + "마케팅 정보 수신", + marketingPolicyText, + ), + child: const Text('보기'), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 20), + + // 5. 가입하기 버튼 + ElevatedButton( + onPressed: _isLoading ? null : _handleSubmit, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6AA84F), + padding: const EdgeInsets.symmetric(vertical: 15), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + )) + : const Text( + '가입하기', + style: TextStyle(color: Colors.white), + ), + ), + const SizedBox(height: 20), + + // 'OR' 구분선 + Row( + children: [ + Expanded(child: Divider(color: Colors.grey[400])), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 10), + child: Text('OR', style: TextStyle(color: Colors.grey)), + ), + Expanded(child: Divider(color: Colors.grey[400])), + ], + ), + const SizedBox(height: 20), + + // 카카오로 시작하기 버튼 + ElevatedButton( + onPressed: _isLoading ? null : _handleKakaoSignup, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFFEE500), + padding: const EdgeInsets.symmetric(vertical: 15), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text( + '카카오로 시작하기', + style: TextStyle( + color: Colors.black87, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/auth/services/api_service.dart b/lib/features/auth/services/api_service.dart new file mode 100644 index 0000000..4171047 --- /dev/null +++ b/lib/features/auth/services/api_service.dart @@ -0,0 +1,424 @@ +// ApiService.dart +import 'dart:convert'; +import 'dart:io'; // ✅ File 타입 사용을 위해 추가 +import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart' as http_parser; // ✅ MediaType 사용을 위해 추가 +import 'package:guardpayfront/core/services/storage.dart'; +import 'auth_service.dart'; +import 'dart:developer'; // 👈 print 대신 log를 사용하기 위해 추가 (권장) + +class ApiService { + final storage = AppStorage.storage; + final AuthService _authService = AuthService(); + // ❗️ 안드로이드 에뮬레이터 기준. 실제 기기 테스트 시 PC의 IP로 변경 필요 + final String _baseUrl = "http://10.0.2.2:8080"; + + String? _jwtType(String jwt) { + try { + final parts = jwt.split('.'); + if (parts.length != 3) return null; + String norm(String s) => + s.replaceAll('-', '+').replaceAll('_', '/').padRight((s.length + 3) ~/ 4 * 4, '='); + final payload = utf8.decode(base64.decode(norm(parts[1]))); + final obj = jsonDecode(payload) as Map; + return (obj['type'] as String?)?.toLowerCase(); // "access" | "refresh" + } catch (_) { + return null; + } + } + + String _mask(String? t) => + (t == null || t.length <= 12) ? '***' : '${t.substring(0, 6)}...${t.substring(t.length - 4)}'; + + Future _executeApiCall(String token, String message) async { + final uri = Uri.parse('$_baseUrl/api/chat/financial-advice'); + final body = jsonEncode({'prompt': message}); + + log('>> [CHAT:req] POST $uri'); + log('>> [CHAT:req] Authorization: Bearer ${_mask(token)} (type=${_jwtType(token)})'); + log('>> [CHAT:req] Body: $body'); + + final res = await http.post( + uri, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': 'Bearer $token', + }, + body: body, + ); + + // ✅ UTF-8 디코딩을 여기서 한 번만 하도록 통일 + final responseBody = utf8.decode(res.bodyBytes); + log('>> [CHAT:res] status=${res.statusCode}'); + log('>> [CHAT:res] headers=${res.headers}'); + log('>> [CHAT:res] body=$responseBody'); + + return res; // response 객체 자체를 반환 (bodyBytes를 포함) + } + + // ✅ 공통 HTTP 요청 메서드 - GET + Future?> get( + String endpoint, { + Map? headers, + }) async { + try { + final uri = Uri.parse('$_baseUrl$endpoint'); + log('>> [GET] $uri'); + + final response = await http.get( + uri, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + ...?headers, + }, + ); + + final responseBody = utf8.decode(response.bodyBytes); + log('>> [GET:res] status=${response.statusCode}'); + log('>> [GET:res] body=$responseBody'); + + if (response.statusCode == 200) { + return jsonDecode(responseBody) as Map; + } else { + log('>> [GET:error] ${response.statusCode}'); + return null; + } + } catch (e) { + log('>> [GET:exception] $e'); + return null; + } + } + + // ✅ 공통 HTTP 요청 메서드 - PUT + Future?> put( + String endpoint, { + Map? data, + Map? headers, + }) async { + try { + final uri = Uri.parse('$_baseUrl$endpoint'); + final body = data != null ? jsonEncode(data) : null; + + log('>> [PUT] $uri'); + log('>> [PUT:body] $body'); + + final response = await http.put( + uri, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + ...?headers, + }, + body: body, + ); + + final responseBody = utf8.decode(response.bodyBytes); + log('>> [PUT:res] status=${response.statusCode}'); + log('>> [PUT:res] body=$responseBody'); + + if (response.statusCode == 200) { + return jsonDecode(responseBody) as Map; + } else { + log('>> [PUT:error] ${response.statusCode}'); + return null; + } + } catch (e) { + log('>> [PUT:exception] $e'); + return null; + } + } + + // ✅ 공통 HTTP 요청 메서드 - PATCH + Future?> patch( + String endpoint, { + Map? data, + Map? headers, + }) async { + try { + final uri = Uri.parse('$_baseUrl$endpoint'); + final body = data != null ? jsonEncode(data) : null; + + log('>> [PATCH] $uri'); + log('>> [PATCH:body] $body'); + + final response = await http.patch( + uri, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + ...?headers, + }, + body: body, + ); + + final responseBody = utf8.decode(response.bodyBytes); + log('>> [PATCH:res] status=${response.statusCode}'); + log('>> [PATCH:res] body=$responseBody'); + + if (response.statusCode == 200) { + return jsonDecode(responseBody) as Map; + } else { + log('>> [PATCH:error] ${response.statusCode}'); + return null; + } + } catch (e) { + log('>> [PATCH:exception] $e'); + return null; + } + } + + // ✅ Multipart 이미지 업로드 메서드 (MIME 타입 명시) + Future?> uploadImage( + String endpoint, + File imageFile, { + Map? headers, + }) async { + try { + final uri = Uri.parse('$_baseUrl$endpoint'); + log('>> [MULTIPART] $uri'); + + var request = http.MultipartRequest('PUT', uri); + + // 헤더 추가 + if (headers != null) { + request.headers.addAll(headers); + } + + // ✅ 파일 확장자에 따라 MIME 타입 결정 + String mimeType = 'image/jpeg'; // 기본값 + String fileName = imageFile.path.split('/').last.toLowerCase(); + + if (fileName.endsWith('.png')) { + mimeType = 'image/png'; + } else if (fileName.endsWith('.jpg') || fileName.endsWith('.jpeg')) { + mimeType = 'image/jpeg'; + } else if (fileName.endsWith('.gif')) { + mimeType = 'image/gif'; + } else if (fileName.endsWith('.webp')) { + mimeType = 'image/webp'; + } + + log('>> [MULTIPART:mimeType] $mimeType'); + + // ✅ 이미지 파일 추가 (MIME 타입 명시) + var multipartFile = http.MultipartFile.fromBytes( + 'profileImage', + await imageFile.readAsBytes(), + filename: 'profile.${fileName.split('.').last}', + contentType: http_parser.MediaType.parse(mimeType), + ); + request.files.add(multipartFile); + + log('>> [MULTIPART:file] ${imageFile.path}'); + log('>> [MULTIPART:contentType] ${multipartFile.contentType}'); + + // 요청 전송 + var streamedResponse = await request.send(); + var response = await http.Response.fromStream(streamedResponse); + + final responseBody = utf8.decode(response.bodyBytes); + log('>> [MULTIPART:res] status=${response.statusCode}'); + log('>> [MULTIPART:res] body=$responseBody'); + + if (response.statusCode == 200) { + return jsonDecode(responseBody) as Map; + } else { + log('>> [MULTIPART:error] ${response.statusCode}'); + return null; + } + } catch (e) { + log('>> [MULTIPART:exception] $e'); + return null; + } + } + + // ✅ DELETE 메서드 + Future delete( + String endpoint, { + Map? headers, + }) async { + try { + final uri = Uri.parse('$_baseUrl$endpoint'); + log('>> [DELETE] $uri'); + + final response = await http.delete( + uri, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + ...?headers, + }, + ); + + log('>> [DELETE:res] status=${response.statusCode}'); + + return response.statusCode == 204 || response.statusCode == 200; + } catch (e) { + log('>> [DELETE:exception] $e'); + return false; + } + } + Future getMyGrade() async { + final token = await storage.read(key: 'accessToken'); + if (token == null) return null; + + // 백엔드 경로 확인 필수! (예: /members/me/grade 인지 /api/members/me/grade 인지) + final result = await get( + '/api/members/me/grade', + headers: { + 'Authorization': 'Bearer $token', + }, + ); + + if (result != null && result.containsKey('grade')) { + return result['grade'] as String; + } + return null; + } + + + Future sendChatMessage(String message) async { + log('>>> [API] enter sendChatMessage: "$message"'); + + String? access = await storage.read(key: 'accessToken'); + String? refresh = await storage.read(key: 'refreshToken'); + + log('>>> [API] loaded tokens: access=${_mask(access)} type=${access==null?null:_jwtType(access)} ' + '/ refresh=${_mask(refresh)} type=${refresh==null?null:_jwtType(refresh)}'); + + // access가 없고 refresh가 있으면 먼저 갱신 시도 + if (access == null && refresh != null) { + log('>>> [API] access is null, try refresh with refresh=${_mask(refresh)}'); + final ok = await _authService.checkAndRefreshTokens(refresh); + log('>>> [API] refresh result: $ok'); + if (ok) { + access = await storage.read(key: 'accessToken'); + log('>>> [API] new access after refresh: ${_mask(access)} type=${access==null?null:_jwtType(access)}'); + } + } + + if (access == null) { + log('>>> [API] early return: access still null -> "로그인이 필요합니다."'); + return "로그인이 필요합니다."; + } + + final aType = _jwtType(access); + if (aType != null && aType != 'access') { + log('>>> [API] guard: access.type != access (type=$aType)'); + if (refresh == null) { + log('>>> [API] no refresh -> deleteAll & return'); + await storage.deleteAll(); + return "세션이 만료되었습니다. 다시 로그인해주세요."; + } + final ok = await _authService.checkAndRefreshTokens(refresh); + log('>>> [API] refresh-by-guard result: $ok'); + if (!ok) { + await storage.deleteAll(); + return "세션이 만료되었습니다. 다시 로그인해주세요."; + } + access = await storage.read(key: 'accessToken'); + log('>>> [API] access after guard-refresh: ${_mask(access)} type=${access==null?null:_jwtType(access)}'); + if (access == null || _jwtType(access) != 'access') { + await storage.deleteAll(); + return "세션이 만료되었습니다. 다시 로그인해주세요."; + } + } + + log('>>> [API] call _executeApiCall with access=${_mask(access)}'); + http.Response response = await _executeApiCall(access, message); + + if (response.statusCode == 401) { + log('>>> [API] got 401, try refresh & retry'); + final currentRefresh = await storage.read(key: 'refreshToken'); + if (currentRefresh != null) { + final refreshSuccess = await _authService.checkAndRefreshTokens(currentRefresh); + log('>>> [API] refresh-on-401 result: $refreshSuccess'); + if (refreshSuccess) { + final newAccess = await storage.read(key: 'accessToken'); + log('>>> [API] newAccess after 401-refresh: ${_mask(newAccess)} type=${newAccess==null?null:_jwtType(newAccess)}'); + if (newAccess != null && _jwtType(newAccess) == 'access') { + response = await _executeApiCall(newAccess, message); + } else { + await storage.deleteAll(); + return "세션이 만료되었습니다. 다시 로그인해주세요."; + } + } else { + await storage.deleteAll(); + return "세션이 만료되었습니다. 다시 로그인해주세요."; + } + } else { + await storage.delete(key: 'accessToken'); + return "세션이 만료되었습니다. 다시 로그인해주세요."; + } + } + + // ✅ 응답 본문을 미리 디코딩 + final String responseBody = utf8.decode(response.bodyBytes); + log('>>> [API] final status: ${response.statusCode}'); + + if (response.statusCode == 200) { + try { + // ✅ [수정] response.bodyBytes 대신 미리 디코딩한 responseBody 사용 + final map = jsonDecode(responseBody) as Map; + + // ✅ [수정] 서버가 반환하는 JSON 키인 'text'를 사용합니다. + final text = map['text'] as String?; + + if (text != null) { + log('>>> [API] parsed text ok (${text.length} chars)'); + return text; + } else { + log('>>> [API] parse error: "text" key is null or missing'); + log('>>> [API] raw response: $responseBody'); + return 'AI 응답 파싱 실패: "text" 키를 찾을 수 없습니다.'; + } + + } catch (e) { + log('>>> [API] parse error: $e'); + log('>>> [API] raw response: $responseBody'); + return "AI 응답 파싱 실패: $e"; + } + } else { + log('>>> [API] non-200: ${response.statusCode} / $responseBody'); + return "오류가 발생했습니다: ${response.statusCode} / $responseBody"; + } + } + + // ✅ [추가] 이메일 중복 확인 (GET 요청) + Future checkEmailDuplicate(String email) async { + try { + // 백엔드 경로: /api/members/check-email?email=user@test.com + final uri = Uri.parse('$_baseUrl/api/auth/check-email?email=$email'); + log('>> [CheckEmail] GET $uri'); + + final response = await http.get( + uri, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + ); + + // 응답 디코딩 (한글 깨짐 방지) + final responseBody = utf8.decode(response.bodyBytes); + final jsonResponse = jsonDecode(responseBody); + log('>> [CheckEmail:res] status=${response.statusCode}, msg=${jsonResponse['message']}'); + + if (response.statusCode == 200) { + return true; // 사용 가능 + } else if (response.statusCode == 409) { + // 백엔드에서 409(CONFLICT)를 보냈으므로 중복된 이메일임 + throw Exception(jsonResponse['message'] ?? "이미 사용 중인 이메일입니다."); + } else { + throw Exception("중복 확인 실패: ${response.statusCode}"); + } + } catch (e) { + log('>> [CheckEmail:exception] $e'); + rethrow; // UI에서 에러 메시지를 띄우기 위해 예외 던짐 + } + } + + +} \ No newline at end of file diff --git a/lib/features/auth/services/auth_service.dart b/lib/features/auth/services/auth_service.dart new file mode 100644 index 0000000..64997d7 --- /dev/null +++ b/lib/features/auth/services/auth_service.dart @@ -0,0 +1,311 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:flutter/services.dart'; +import 'package:kakao_flutter_sdk_user/kakao_flutter_sdk_user.dart'; // 카카오 SDK +import 'dart:developer'; +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; +import 'package:guardpayfront/core/services/storage.dart'; // ⬅️ 이걸 써야 함 +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +class AuthService { + // ⬇️ [수정됨] 1. 소셜 로그인용 (ngrok) + // ❗️ .env 파일에 API_BASE_URL=https://your-ngrok-url.ngrok-free.dev + final String _baseUrl = dotenv.env['API_BASE_URL'] ?? 'http://DEFAULT_NGROK_URL'; + + // ⬇️ [수정됨] 2. 폼 회원가입/비번찾기용 (고정 IP) + final String _localBaseUrl = 'http://10.0.2.2:8080'; + + final _secureStorage = AppStorage.storage; + static const String _tempAuthCode = '123456'; // 임시 이메일 인증 코드 + + /// === 소셜 로그인 (Google) === + Future signInWithGoogle() async { + try { + // 1. 스프링 부트의 구글 로그인 시작 URL 설정 (ngrok 사용) + final url = Uri.parse('$_baseUrl/oauth2/authorization/google'); + + // 2. 웹뷰를 열고, 리디렉션 대기 + final result = await FlutterWebAuth2.authenticate( + url: url.toString(), + callbackUrlScheme: "guardpay", + ); + + // 3. 돌아온 URL에서 토큰을 추출 + final Uri callbackUri = Uri.parse(result); + final accessToken = callbackUri.queryParameters['accessToken']; + final refreshToken = callbackUri.queryParameters['refreshToken']; + + if (accessToken != null && refreshToken != null) { + // 4. 토큰을 안전하게 저장 + await _secureStorage.write(key: 'accessToken', value: accessToken); + await _secureStorage.write(key: 'refreshToken', value: refreshToken); + + log('✅ [Google Auth] 구글 로그인 성공! 토큰 저장 완료.'); + } else { + throw Exception('로그인에는 성공했지만 토큰을 받아오지 못했습니다.'); + } + } on PlatformException catch (e) { + if (e.code == 'CANCELED' || e.code == 'USER_CANCELLED') { + log('ℹ️ [Google Auth] 사용자에 의해 로그인이 취소되었습니다.'); + return; + } + throw Exception('로그인 중 오류가 발생했습니다: ${e.message}'); + } catch (e) { + log('🚨 [Google Auth] 알 수 없는 에러 발생: $e'); + throw Exception('로그인에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } + } + + + /// === 일반 회원가입 & 인증 === + // 이메일 인증 코드 요청 함수 + Future requestAuthCode(String email) async { + final emailRegex = RegExp(r"^[^\s@]+@[^\s@]+\.[^\s@]+$"); + if (email.isEmpty || !emailRegex.hasMatch(email)) { + throw Exception('올바른 이메일 주소를 입력해주세요.'); + } + + // TODO: 실제 서버 API 엔드포인트에 맞게 URL 수정 필요 (예: '/request-code') + // ❗️[참고] 이 API도 실제로는 고정 IP(_localBaseUrl)를 써야 합니다. + log('[Email Auth] 인증 코드 요청: $email (시뮬레이션)'); + await Future.delayed(const Duration(milliseconds: 500)); + return true; + } + + // 인증 코드 확인 함수 + Future verifyAuthCode(String email, String code) async { + // TODO: 실제 인증 로직으로 대체 필요 + // ❗️[참고] 이 API도 실제로는 고정 IP(_localBaseUrl)를 써야 합니다. + if (code != _tempAuthCode) { + throw Exception('인증 코드가 일치하지 않습니다.'); + } + log('[Email Auth] 인증 코드 확인 완료: $email'); + await Future.delayed(const Duration(milliseconds: 500)); + return true; + } + + // 회원가입 최종 처리 함수 + Future signup({ + required String email, + required String password, + required String nickname, + }) async { + + // ⬇️ [수정됨] 폼 회원가입은 ngrok이 아닌 고정 IP(_localBaseUrl)를 사용합니다. + final apiUrl = '$_localBaseUrl/api/auth/signup'; + + // 서버에 보낼 JSON 데이터 + final signupData = { + 'email': email, + 'password': password, + 'nickname': nickname, + }; + + try { + final response = await http.post( + Uri.parse(apiUrl), // ⬅️ _localBaseUrl이 적용된 apiUrl 사용 + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(signupData), + ); + + final responseBody = jsonDecode(utf8.decode(response.bodyBytes)); + + if (response.statusCode == 200 || response.statusCode == 201) { + log('[Signup] 가입 성공 응답: ${response.body}'); + return responseBody['message'] ?? '회원가입이 성공적으로 완료되었습니다.'; + } else { + log('🚨 [Signup] 가입 실패 응답: ${response.body}'); + throw Exception(responseBody['message'] ?? '회원가입에 실패했습니다.'); + } + } catch (error) { + print('🚨 가입 요청 실패: $error'); + throw Exception('서버와 통신할 수 없습니다.'); + } + } + + + /// === 임시 비밀번호 발급 === + Future requestPasswordResetCode(String email) async { + log('[Password Reset] 임시 비밀번호 발급 요청: $email'); + + try { + final response = await http.post( + // ⬇️ [수정됨] 비밀번호 찾기도 고정 IP(_localBaseUrl)를 사용합니다. + Uri.parse('$_localBaseUrl/api/auth/password-reset-request'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'email': email}), + ); + + if (response.statusCode == 200) { + log('✅ [Password Reset] 임시 비밀번호 발급 요청 성공. 이메일 발송됨.'); + return true; + } else { + final errorBody = jsonDecode(response.body); + throw Exception(errorBody['message'] ?? '인증 코드 요청 실패'); + } + } catch (e) { + log('🚨 [Password Reset] requestPasswordResetCode 에러: $e'); + throw Exception('서버 통신 중 오류 발생: ${e.toString()}'); + } + } + + + /// === 소셜 로그인 (Kakao) === + // 회원가입 & 로그인 처리 함수 + Future> signupWithKakao() async { + // 1. 기존 로그인 정보가 있다면 먼저 로그아웃 처리 (클린 시작) + try { + if (await AuthApi.instance.hasToken()) { + await UserApi.instance.logout(); + log('[Kakao Auth] 기존 토큰 발견. 로그아웃 처리 완료.'); + } + } catch (error) { + log('⚠️ [Kakao Auth] 로그아웃 처리 중 에러 발생 (무시): $error'); + } + + // 2. 카카오 SDK로 액세스 토큰 받기 (생략) + String? kakaoAccessToken; + if (await isKakaoTalkInstalled()) { + try { + await UserApi.instance.loginWithKakaoTalk(); + kakaoAccessToken = (await TokenManagerProvider.instance.manager.getToken())?.accessToken; + } catch (error) { + if (error is PlatformException && error.code == 'CANCELED') { + throw Exception('카카오톡 로그인이 취소되었습니다.'); + } + try { + await UserApi.instance.loginWithKakaoAccount(); + kakaoAccessToken = (await TokenManagerProvider.instance.manager.getToken())?.accessToken; + } catch (accountError) { + throw Exception('카카오 계정 로그인에 실패했습니다.'); + } + } + } else { + try { + await UserApi.instance.loginWithKakaoAccount(); + kakaoAccessToken = (await TokenManagerProvider.instance.manager.getToken())?.accessToken; + } catch (accountError) { + throw Exception('카카오 계정 로그인에 실패했습니다.'); + } + } + + if (kakaoAccessToken == null) { + throw Exception('카카오 액세스 토큰을 가져오는데 실패했습니다.'); + } + + log('🚀 [Kakao Auth] 백엔드 서버로 토큰 전송 시작.'); + + // 3. 백엔드 서버로 액세스 토큰 전송 (ngrok 사용) + final response = await http.post( + Uri.parse('$_baseUrl/api/auth/kakao'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'accessToken': kakaoAccessToken}), + ); + + // 4. 응답 처리 (생략) + log('✅ [Kakao Auth] 서버 응답 Status Code: ${response.statusCode}'); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + try { + final errorBody = jsonDecode(response.body); + throw Exception('서버 통신 실패: ${errorBody['message'] ?? '알 수 없는 오류'}'); + } catch (e) { + throw Exception('서버와 통신 중 오류가 발생했습니다. (상태 코드: ${response.statusCode})'); + } + } + } + + + String _mask(String? t) => + (t == null || t.length <= 12) ? '***' : '${t.substring(0, 6)}...${t.substring(t.length - 4)}'; + + /// === 토큰 갱신 (Refresh Token) === + // JWT 갱신 API를 호출하고 성공 시 새 토큰을 저장하는 함수 + Future checkAndRefreshTokens(String refreshToken) async { + final uri = Uri.parse('$_localBaseUrl/api/auth/reissue'); + print('>>> [AUTH] enter checkAndRefreshTokens (uri: $uri)'); + + String? _jwtType(String jwt) { + try { + final p = jwt.split('.'); + if (p.length != 3) return null; + String norm(String s) => s.replaceAll('-', '+').replaceAll('_', '/') + .padRight((s.length + 3) ~/ 4 * 4, '='); + final payload = utf8.decode(base64.decode(norm(p[1]))); + return (jsonDecode(payload)['type'] as String?)?.toLowerCase(); + } catch (_) { return null; } + } + + Future _apply(http.Response res) async { + final bodyStr = utf8.decode(res.bodyBytes); + Map data = {}; + try { data = jsonDecode(bodyStr); } catch (_) {} + + String? newAccess = (data['accessToken'] ?? data['access_token']) as String?; + String? newRefresh = (data['refreshToken'] ?? data['refresh_token']) as String?; + // 헤더(Bearer)로 access를 줄 수도 있음 + final hb = res.headers['authorization']; + final headerAccess = hb?.replaceFirst(RegExp(r'Bearer\s+', caseSensitive:false), ''); + newAccess ??= headerAccess; + + // 타입 안전장치 + if (newAccess != null && _jwtType(newAccess) != 'access') newAccess = null; + if (newRefresh != null && _jwtType(newRefresh) != 'refresh') newRefresh = null; + + if (newAccess == null) return false; + await _secureStorage.write(key: 'accessToken', value: newAccess); + if (newRefresh != null) { + await _secureStorage.write(key: 'refreshToken', value: newRefresh); + } + return true; + } + + try { + print('>>> [AUTH] Attempt 1 (Header): Bearer ${_mask(refreshToken)}'); + + // 1) 헤더(Bearer refresh) 방식 + var res = await http.post(uri, headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $refreshToken', + }); + print('>>> [AUTH] Attempt 1 (Header) Result: status=${res.statusCode}, body=${utf8.decode(res.bodyBytes)}'); + + if (res.statusCode == 200 && await _apply(res)) return true; + print('>>> [AUTH] Attempt 2 (Body): JSON'); + + // 2) 바디(JSON) 방식 폴백 + res = await http.post(uri, + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({'refreshToken': refreshToken}), + ); + print('>>> [AUTH] Attempt 2 (Body) Result: status=${res.statusCode}, body=${utf8.decode(res.bodyBytes)}'); + + if (res.statusCode == 200 && await _apply(res)) return true; + print('>>> [AUTH] Both attempts failed. Returning false.'); + + return false; + } catch (e) { + print('>>> [AUTH] Exception caught: $e. Returning false.'); + + return false; + } + } + + + + // 로그아웃 함수 + Future kakaoLogout() async { + try { + await UserApi.instance.logout(); + log('[Kakao Auth] 로그아웃 성공, SDK에서 토큰 삭제'); + } catch (error) { + log('🚨 [Kakao Auth] 로그아웃 실패: $error'); + throw Exception('로그아웃에 실패했습니다.'); + } + } +} + + + diff --git a/lib/features/auth/services/social_login_service.dart b/lib/features/auth/services/social_login_service.dart new file mode 100644 index 0000000..164fc2f --- /dev/null +++ b/lib/features/auth/services/social_login_service.dart @@ -0,0 +1,250 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; +import 'dart:convert'; +import 'package:guardpayfront/core/services/storage.dart'; +import 'package:kakao_flutter_sdk_user/kakao_flutter_sdk_user.dart'; +import 'package:google_sign_in/google_sign_in.dart'; + +class SocialLoginService { + static final SocialLoginService _instance = SocialLoginService._internal(); + factory SocialLoginService() => _instance; + SocialLoginService._internal(); + + final _storage = AppStorage.storage; + static const String baseUrl = 'http://10.0.2.2:8080/api/auth'; + + final GoogleSignIn _googleSignIn = GoogleSignIn(); + + /// ✅ 카카오 로그인 완성 + Future> loginWithKakao() async { + try { + print('🔵 [Kakao Login] 카카오 로그인 시작'); + + // 1️⃣ 카카오에서 소셜 토큰 받기 + OAuthToken token; + + if (await isKakaoTalkInstalled()) { + try { + token = await UserApi.instance.loginWithKakaoTalk(); + print('✅ [Kakao Login] 카카오톡으로 로그인 성공'); + } catch (error) { + print('⚠️ [Kakao Login] 카카오톡 로그인 실패, 웹으로 시도: $error'); + token = await UserApi.instance.loginWithKakaoAccount(); + print('✅ [Kakao Login] 카카오 계정으로 로그인 성공'); + } + } else { + token = await UserApi.instance.loginWithKakaoAccount(); + print('✅ [Kakao Login] 카카오 계정으로 로그인 성공 (카카오톡 미설치)'); + } + + final kakaoAccessToken = token.accessToken; + print('✅ [Kakao Login] 카카오 액세스 토큰 획득: ${kakaoAccessToken.substring(0, 20)}...'); + + // 2️⃣ 카카오 사용자 정보 가져오기 (선택사항 - 디버깅용) + try { + User user = await UserApi.instance.me(); + print('✅ [Kakao Login] 사용자 정보:'); + print(' - ID: ${user.id}'); + print(' - 닉네임: ${user.kakaoAccount?.profile?.nickname}'); + print(' - 이메일: ${user.kakaoAccount?.email}'); + } catch (e) { + print('⚠️ [Kakao Login] 사용자 정보 가져오기 실패: $e'); + } + + // 3️⃣ 카카오 토큰을 우리 백엔드로 전송 + print('🔵 [Kakao Login] 백엔드로 토큰 전송 시작...'); + final response = await http.post( + Uri.parse('$baseUrl/kakao/login'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'accessToken': kakaoAccessToken, + }), + ); + + print('📩 [Kakao Login] 백엔드 응답 상태: ${response.statusCode}'); + + // 4️⃣ 우리 백엔드 응답 처리 + return await _handleSocialLoginResponse(response, 'Kakao'); + + } catch (e) { + print('🚨 [Kakao Login] 카카오 로그인 실패: $e'); + return { + 'success': false, + 'message': '카카오 로그인에 실패했습니다: ${e.toString()}', + }; + } + } + + /// ✅ 구글 로그인 완성 + Future> loginWithGoogle() async { + try { + print('🔵 [Google Login] 구글 로그인 시작'); + + // 1️⃣ 구글에서 소셜 토큰 받기 + final GoogleSignInAccount? googleUser = await _googleSignIn.signIn(); + + if (googleUser == null) { + print('⚠️ [Google Login] 사용자가 로그인을 취소함'); + return { + 'success': false, + 'message': '구글 로그인이 취소되었습니다.', + }; + } + + print('✅ [Google Login] 구글 계정 선택 완료: ${googleUser.email}'); + + final GoogleSignInAuthentication googleAuth = + await googleUser.authentication; + + final googleIdToken = googleAuth.idToken; + + if (googleIdToken == null) { + print('🚨 [Google Login] ID 토큰을 가져올 수 없음'); + return { + 'success': false, + 'message': '구글 ID 토큰을 가져올 수 없습니다.', + }; + } + + print('✅ [Google Login] 구글 ID 토큰 획득: ${googleIdToken.substring(0, 20)}...'); + + // 2️⃣ 구글 토큰을 우리 백엔드로 전송 + print('🔵 [Google Login] 백엔드로 토큰 전송 시작...'); + final response = await http.post( + Uri.parse('$baseUrl/google/login'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'idToken': googleIdToken, + }), + ); + + print('📩 [Google Login] 백엔드 응답 상태: ${response.statusCode}'); + + // 3️⃣ 우리 백엔드 응답 처리 + return await _handleSocialLoginResponse(response, 'Google'); + + } catch (e) { + print('🚨 [Google Login] 구글 로그인 실패: $e'); + return { + 'success': false, + 'message': '구글 로그인에 실패했습니다: ${e.toString()}', + }; + } + } + + /// ✅ 소셜 로그인 응답 처리 (공통 로직) + Future> _handleSocialLoginResponse( + http.Response response, String provider) async { + try { + print('🔵 [$provider Login] 응답 처리 시작'); + + if (response.statusCode == 200) { + final responseBody = utf8.decode(response.bodyBytes); + print('✅ [$provider Login] 로그인 성공 - 서버 응답:'); + print(' $responseBody'); + + final data = jsonDecode(responseBody); + final accessToken = data['accessToken']; + final refreshToken = data['refreshToken']; + final isNewUser = data['isNewUser'] ?? false; // 신규 회원 여부 + + if (accessToken != null && refreshToken != null) { + // 우리 서비스 토큰 저장 + await _storage.write(key: 'tokenType', value: 'Bearer'); + await _storage.write(key: 'accessToken', value: accessToken); + await _storage.write(key: 'refreshToken', value: refreshToken); + + // 저장 확인 + final checkA = await _storage.read(key: 'accessToken'); + final checkR = await _storage.read(key: 'refreshToken'); + print('✅ [$provider Login] 토큰 저장 완료:'); + print(' - Access Token 저장: ${checkA != null}'); + print(' - Refresh Token 저장: ${checkR != null}'); + + // 신규 회원 여부에 따른 메시지 + String message = isNewUser + ? '회원가입이 완료되었습니다! 환영합니다 🎉' + : '로그인 성공!'; + + return { + 'success': true, + 'message': message, + 'isNewUser': isNewUser, + }; + } else { + print('🚨 [$provider Login] 토큰 필드 누락'); + return { + 'success': false, + 'message': '토큰 정보가 누락되었습니다.', + }; + } + } else if (response.statusCode == 401) { + // 인증 실패 + print('🚨 [$provider Login] 인증 실패 (401)'); + final errorBody = jsonDecode(utf8.decode(response.bodyBytes)); + return { + 'success': false, + 'message': errorBody['message'] ?? '소셜 로그인 인증에 실패했습니다.', + }; + } else if (response.statusCode == 404) { + // 엔드포인트 없음 + print('🚨 [$provider Login] 엔드포인트 없음 (404)'); + return { + 'success': false, + 'message': '서버에서 소셜 로그인 기능을 찾을 수 없습니다. 백엔드 설정을 확인해주세요.', + }; + } else { + // 기타 오류 + print('🚨 [$provider Login] 기타 오류 (${response.statusCode})'); + try { + final errorBody = jsonDecode(utf8.decode(response.bodyBytes)); + print(' 에러 내용: ${errorBody['message']}'); + return { + 'success': false, + 'message': errorBody['message'] ?? '로그인 실패', + }; + } catch (e) { + return { + 'success': false, + 'message': '서버 오류가 발생했습니다. (${response.statusCode})', + }; + } + } + } catch (e) { + print('🚨 [$provider Login] 응답 처리 중 예외 발생: $e'); + return { + 'success': false, + 'message': '서버 응답 처리 중 오류가 발생했습니다.', + }; + } + } + + /// ✅ 로그아웃 + Future logout() async { + print('🔵 [Logout] 로그아웃 시작'); + + // 우리 서비스 토큰 삭제 + await _storage.delete(key: 'tokenType'); + await _storage.delete(key: 'accessToken'); + await _storage.delete(key: 'refreshToken'); + print('✅ [Logout] 서비스 토큰 삭제 완료'); + + // 카카오 로그아웃 + try { + await UserApi.instance.logout(); + print('✅ [Logout] 카카오 로그아웃 완료'); + } catch (e) { + print('⚠️ [Logout] 카카오 로그아웃 실패 (로그인 안 되어있었을 수 있음): $e'); + } + + // 구글 로그아웃 + try { + await _googleSignIn.signOut(); + print('✅ [Logout] 구글 로그아웃 완료'); + } catch (e) { + print('⚠️ [Logout] 구글 로그아웃 실패 (로그인 안 되어있었을 수 있음): $e'); + } + + print('✅ [Logout] 전체 로그아웃 완료'); + } +} diff --git a/lib/features/auth/widgets/auth_input_field.dart b/lib/features/auth/widgets/auth_input_field.dart new file mode 100644 index 0000000..f9097ac --- /dev/null +++ b/lib/features/auth/widgets/auth_input_field.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +// 앱의 모든 인증 화면에서 재사용할 수 있는 범용 입력 필드 위젯입니다. +class AuthInputField extends StatefulWidget { + final TextEditingController controller; + final String hintText; + final bool isPassword; // 비밀번호 가시성 토글이 필요한지 여부 + final String? Function(String?)? validator; + final TextInputType keyboardType; + final bool readOnly; + final bool enabled; + + const AuthInputField({ + super.key, + required this.controller, + required this.hintText, + this.isPassword = false, + this.validator, + this.keyboardType = TextInputType.text, + this.readOnly = false, + this.enabled = true, + }); + + @override + State createState() => _AuthInputFieldState(); +} + +class _AuthInputFieldState extends State { + // 비밀번호 필드일 경우 가리기 상태를 관리합니다. + late bool _obscureText; + + @override + void initState() { + super.initState(); + _obscureText = widget.isPassword; + } + + void _toggleVisibility() { + setState(() { + _obscureText = !_obscureText; + }); + } + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: widget.controller, + // isPassword 속성에 따라 가리기 여부 및 가시성 토글 버튼 제공 + obscureText: _obscureText, + validator: widget.validator, + keyboardType: widget.keyboardType, + readOnly: widget.readOnly, + enabled: widget.enabled, + decoration: InputDecoration( + hintText: widget.hintText, + // 비밀번호 필드일 경우에만 가시성 토글 버튼 제공 + suffixIcon: widget.isPassword + ? IconButton( + icon: Icon( + _obscureText ? Icons.visibility_off : Icons.visibility, + color: Colors.grey, + ), + onPressed: _toggleVisibility, + ) + : null, + ), + ); + } +} diff --git a/lib/features/auth/widgets/bottom_nav.dart b/lib/features/auth/widgets/bottom_nav.dart new file mode 100644 index 0000000..98bcc62 --- /dev/null +++ b/lib/features/auth/widgets/bottom_nav.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:guardpayfront/features/auth/screens/chat_screen.dart'; +import 'package:guardpayfront/features/auth/screens/home_screen.dart'; +import 'package:guardpayfront/features/auth/services/api_service.dart'; +import 'package:guardpayfront/features/map/screens/map_screen.dart'; + +import '../../bank/screens/account_selection_screen.dart'; +import '../../shop/screens/shop_screen.dart'; + + +class BottomNav extends StatelessWidget { + final int selectedIndex; + const BottomNav({super.key, required this.selectedIndex}); + + @override + Widget build(BuildContext context) { + final ApiService api = ApiService(); + + void _navigateTo(int index) { + if (index == selectedIndex) return; // 같은 탭이면 아무 것도 안 함 + switch (index) { + case 0: // 홈 + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const HomeScreen()), + (route) => false, + ); + break; + case 1: // AI + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => ChatScreen(api: api)), + (route) => false, + ); + break; + case 2: + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const ShopScreen()), + (route) => false, + ); + break; + case 3: + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const AccountSelectionScreen()), + (route) => false, + ); + break; + case 4: + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute(builder: (_) => const MapScreen()), // MapScreen으로 이동 + (route) => false, + ); + break; + } + } + + return Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border( + top: BorderSide(color: Colors.grey.shade300, width: 1), + ), + ), + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _BottomIcon( + imagePath: 'assets/images/home_icon.png', + isActive: selectedIndex == 0, + onTap: () => _navigateTo(0), + ), + _BottomIcon( + imagePath: 'assets/images/AI_icon.png', + isActive: selectedIndex == 1, + onTap: () => _navigateTo(1), + ), + _BottomIcon( + imagePath: 'assets/images/shop_icon.png', + isActive: selectedIndex == 2, + onTap: () => _navigateTo(2), + ), + _BottomIcon( + imagePath: 'assets/images/money_icon.png', + isActive: selectedIndex == 3, + onTap: () => _navigateTo(3), + ), + _BottomIcon( + imagePath: 'assets/images/map_icon.png', + isActive: selectedIndex == 4, + onTap: () => _navigateTo(4), + ), + ], + ), + ); + } +} + +class _BottomIcon extends StatelessWidget { + final String imagePath; + final bool isActive; + final VoidCallback onTap; + + const _BottomIcon({ + required this.imagePath, + required this.isActive, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Image.asset( + imagePath, + width: 45, + height: 45, + color: isActive ? null : Colors.black54, + ), + ); + } +} diff --git a/lib/features/auth/widgets/email_auth_section.dart b/lib/features/auth/widgets/email_auth_section.dart new file mode 100644 index 0000000..4e47626 --- /dev/null +++ b/lib/features/auth/widgets/email_auth_section.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; + +// AuthInputField를 임포트하여 재사용합니다. +import 'auth_input_field.dart'; + +// 이메일 입력, 인증 코드 요청, 인증 코드 입력, 확인 버튼을 묶어 관리하는 복합 위젯 +class EmailAuthSection extends StatelessWidget { + final TextEditingController emailController; + final TextEditingController codeController; + final bool isCodeRequested; + final bool isCodeVerified; + final VoidCallback onCodeRequest; + final VoidCallback onCodeVerify; + + const EmailAuthSection({ + super.key, + required this.emailController, + required this.codeController, + required this.isCodeRequested, + required this.isCodeVerified, + required this.onCodeRequest, + required this.onCodeVerify, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 1. 이메일 입력 필드 + const Text('이메일 *', style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 5), + Row( + children: [ + Expanded( + child: AuthInputField( // AuthInputField 재사용 + controller: emailController, + hintText: '이메일', + keyboardType: TextInputType.emailAddress, + readOnly: isCodeRequested, // 코드가 요청되면 이메일 수정 불가 + enabled: !isCodeVerified, // 인증 완료되면 비활성화 + validator: (value) { + final emailRegex = RegExp(r"^[^\s@]+@[^\s@]+\.[^\s@]+$"); + if (value == null || value.isEmpty || !emailRegex.hasMatch(value)) { + return '올바른 이메일 주소를 입력해주세요.'; + } + return null; + }, + ), + ), + const SizedBox(width: 8), + // 2. 인증 코드 요청 버튼 + SizedBox( + height: 56, // 텍스트 필드와 높이 맞추기 + child: ElevatedButton( + onPressed: isCodeRequested ? null : onCodeRequest, // 요청 후 비활성화 + style: ElevatedButton.styleFrom( + backgroundColor: isCodeRequested + ? Colors.grey + : const Color(0xFF6AA84F).withOpacity(0.8), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text( + '인증코드 받기', + style: TextStyle(fontSize: 14), + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + + // 3. 인증 코드가 요청된 경우에만 코드 입력 필드 표시 + if (isCodeRequested) ...[ + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: AuthInputField( // AuthInputField 재사용 + controller: codeController, + hintText: '인증코드', + keyboardType: TextInputType.number, + readOnly: isCodeVerified, // 인증 완료되면 수정 불가 + ), + ), + const SizedBox(width: 8), + // 4. 확인 버튼 + SizedBox( + height: 56, // 텍스트 필드와 높이 맞추기 + child: ElevatedButton( + onPressed: isCodeVerified ? null : onCodeVerify, // 확인 후 비활성화 + style: ElevatedButton.styleFrom( + backgroundColor: isCodeVerified + ? Colors.grey + : const Color(0xFF6AA84F), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 20), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: Text( + isCodeVerified ? '완료' : '확인', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + ], + ], + ); + } +} diff --git a/lib/features/auth/widgets/material.dart b/lib/features/auth/widgets/material.dart new file mode 100644 index 0000000..df34eb9 --- /dev/null +++ b/lib/features/auth/widgets/material.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +class BottomNav extends StatelessWidget { + final int selectedIndex; + + const BottomNav({ + super.key, + required this.selectedIndex, + }); + + @override + Widget build(BuildContext context) { + return BottomNavigationBar( + currentIndex: selectedIndex, + type: BottomNavigationBarType.fixed, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.home), + label: '홈', + ), + BottomNavigationBarItem( + icon: Icon(Icons.quiz), + label: '퀴즈', + ), + BottomNavigationBarItem( + icon: Icon(Icons.video_library), + label: '영상', + ), + BottomNavigationBarItem( + icon: Icon(Icons.assessment), + label: '평가', + ), + BottomNavigationBarItem( + icon: Icon(Icons.map), + label: '지도', + ), + ], + onTap: (index) { + if (index == selectedIndex) return; + + switch (index) { + case 0: + Navigator.pushReplacementNamed(context, '/home'); + break; + case 1: + Navigator.pushReplacementNamed(context, '/quizCategory'); + break; + case 2: + Navigator.pushReplacementNamed(context, '/video'); + break; + case 3: + Navigator.pushReplacementNamed(context, '/assessment'); + break; + case 4: + // 현재 지도 화면 + break; + } + }, + ); + } +} \ No newline at end of file diff --git a/lib/features/bank/models/transfer_models.dart b/lib/features/bank/models/transfer_models.dart new file mode 100644 index 0000000..0246a01 --- /dev/null +++ b/lib/features/bank/models/transfer_models.dart @@ -0,0 +1,71 @@ +// 1. 내 계좌 정보 모델 +class MyAccount { + final String myId; + final String accountName; + final int balance; + final String currency; + + MyAccount({ + required this.myId, + required this.accountName, + required this.balance, + required this.currency, + }); + + factory MyAccount.fromJson(Map json) { + return MyAccount( + myId: (json['id'] ?? json['memberId'] ?? json['username'] ?? 'unknown').toString(), + accountName: json['nickname'] ?? json['name'] ?? json['accountName'] ?? 'GuardPay 머니', + balance: json['point'] ?? json['points'] ?? json['balance'] ?? 0, + currency: json['currency'] ?? 'KRW', + ); + } +} + +// 2. 송금 대상(수취인) 모델 (기존 유지) +class Beneficiary { + final int beneficiaryId; + final String bankName; + final String accountNumber; + final String accountHolderName; + final String? nickname; + + Beneficiary({ + required this.beneficiaryId, + required this.bankName, + required this.accountNumber, + required this.accountHolderName, + this.nickname, + }); + + factory Beneficiary.fromJson(Map json) { + return Beneficiary( + beneficiaryId: json['id'] ?? 0, + bankName: json['bankName'] ?? '', + accountNumber: json['accountNumber'] ?? '', + accountHolderName: json['accountHolderName'] ?? '', + nickname: json['nickname'], + ); + } +} + +// 3. 송금 결과 모델 (기존 유지) +class TransferResult { + final bool success; + final String message; + final int? rewardPoints; + + TransferResult({ + required this.success, + required this.message, + this.rewardPoints, + }); + + factory TransferResult.fromJson(Map json) { + return TransferResult( + success: true, + message: json['message'] ?? '송금 완료', + rewardPoints: json['rewardPoints'], + ); + } +} \ No newline at end of file diff --git a/lib/features/bank/screens/account_selection_screen.dart b/lib/features/bank/screens/account_selection_screen.dart new file mode 100644 index 0000000..aab22a6 --- /dev/null +++ b/lib/features/bank/screens/account_selection_screen.dart @@ -0,0 +1,440 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:guardpayfront/core/services/storage.dart'; +import '../../auth/widgets/bottom_nav.dart'; +import '../models/transfer_models.dart'; +import '../services/transfer_service.dart'; +import 'mock_transfer_screen.dart'; + +class AccountSelectionScreen extends StatefulWidget { + const AccountSelectionScreen({super.key}); + + @override + State createState() => _AccountSelectionScreenState(); +} + +class _AccountSelectionScreenState extends State { + final TransferService _service = TransferService(); + final _storage = AppStorage.storage; + + String? _accessToken; + bool _isLoadingToken = true; + + // 내 계좌 정보 + MyAccount? _myAccount; + bool _isLoadingAccount = true; + + // 수취인 목록 + List _beneficiaries = []; + bool _isLoadingBeneficiaries = true; + + // 송금 완료된 ID 집합 + final Set _completedBeneficiaryIds = {}; + + @override + void initState() { + super.initState(); + _initializeScreen(); + } + + Future _initializeScreen() async { + final token = await _storage.read(key: 'accessToken'); + + if (token == null) { + if (mounted) setState(() => _isLoadingToken = false); + return; + } + + if (mounted) { + setState(() { + _accessToken = token; + _isLoadingToken = false; + }); + } + + await _fetchMyAccount(); + if (_myAccount == null) return; + + final String currentUserId = _myAccount!.myId; + final String? savedUserId = await _storage.read(key: 'last_user_id'); + + final String cacheKey = 'cached_beneficiaries_$currentUserId'; + final String dateKey = 'last_update_date_$currentUserId'; + final String completedKey = 'completed_ids_$currentUserId'; + + if (savedUserId != null && savedUserId != currentUserId) { + print("사용자가 변경되었습니다. ($savedUserId -> $currentUserId) 데이터를 초기화합니다."); + _completedBeneficiaryIds.clear(); + } + + // 현재 사용자 ID를 저장소에 업데이트 (다음번 비교를 위해) + await _storage.write(key: 'last_user_id', value: currentUserId); + + // 날짜 확인: 오늘 날짜 vs 저장된 날짜 + final String todayStr = DateTime.now().toString().split(' ')[0]; + final String? lastDate = await _storage.read(key: dateKey); + + // 날짜가 다르면(새로운 하루) 캐시 삭제 + if (lastDate != todayStr) { + await _storage.delete(key: cacheKey); + await _storage.delete(key: completedKey); + _completedBeneficiaryIds.clear(); + print("날짜가 변경되어 목록을 갱신합니다."); + } + + // 완료된 ID 목록 로드 + final String? savedIds = await _storage.read(key: completedKey); + if (savedIds != null && savedIds.isNotEmpty) { + final List ids = savedIds + .split(',') + .where((e) => e.isNotEmpty) + .map((e) => int.parse(e)) + .toList(); + _completedBeneficiaryIds.addAll(ids); + } + + // 저장된 '계좌 목록' 불러오기 + final String? cachedListJson = await _storage.read(key: cacheKey); + List cachedList = []; + if (cachedListJson != null) { + try { + final List decoded = jsonDecode(cachedListJson); + cachedList = decoded.map((e) => Beneficiary.fromJson(e)).toList(); + } catch (e) { + print("캐시 파싱 에러: $e"); + } + } + + if (mounted) { + setState(() { + if (cachedList.isNotEmpty) { + _beneficiaries = cachedList; + _isLoadingBeneficiaries = false; + } + }); + + // 캐시가 없으면(사용자 변경됨 or 날짜 변경됨 or 최초 실행) 서버 요청 + if (cachedList.isEmpty) { + _fetchAndSaveBeneficiaries(currentUserId); + } + } + } + + Future _fetchMyAccount() async { + if (_accessToken == null) return; + try { + final account = await _service.getMyAccount(_accessToken!); + if (mounted) { + setState(() { + _myAccount = account; + _isLoadingAccount = false; + }); + } + } catch (e) { + print("내 계좌 조회 실패: $e"); + if (mounted) setState(() => _isLoadingAccount = false); + } + } + + Future _fetchAndSaveBeneficiaries(String myId) async { + if (_accessToken == null) return; + try { + setState(() => _isLoadingBeneficiaries = true); + + // 1. 서버에서 목록 가져오기 (서버가 이미 랜덤으로 섞어서 줌) + final list = await _service.getBeneficiaries(_accessToken!); + + if (mounted) { + setState(() { + _beneficiaries = list; + _isLoadingBeneficiaries = false; + }); + + final String cacheKey = 'cached_beneficiaries_$myId'; + final String dateKey = 'last_update_date_$myId'; + + // 2. 목록 저장 (캐싱) + final jsonString = jsonEncode(list.map((b) => { + 'id': b.beneficiaryId, + 'bankName': b.bankName, + 'accountNumber': b.accountNumber, + 'accountHolderName': b.accountHolderName, + 'nickname': b.nickname, + }).toList()); + await _storage.write(key: cacheKey, value: jsonString); + + // [추가된 로직 2] 오늘 날짜 저장 (내일 비교하기 위해) + final String todayStr = DateTime.now().toString().split(' ')[0]; + await _storage.write(key: dateKey, value: todayStr); + } + } catch (e) { + print("목록 불러오기 실패: $e"); + if (mounted) setState(() => _isLoadingBeneficiaries = false); + } + } + + Future _saveCompletedId(int id) async { + if (_myAccount == null) return; + + final String myId = _myAccount!.myId; + final String completedKey = 'completed_ids_$myId'; + + setState(() { + _completedBeneficiaryIds.add(id); + }); + + await _storage.write( + key: completedKey, + value: _completedBeneficiaryIds.join(','), + ); + } + + @override + Widget build(BuildContext context) { + if (_isLoadingToken) { + return const Scaffold( + backgroundColor: Color(0xFFF9F5E8), + body: Center(child: CircularProgressIndicator()), + ); + } + + if (_accessToken == null) { + return const Scaffold( + backgroundColor: Color(0xFFF9F5E8), + body: Center(child: Text("로그인 정보가 없습니다. 다시 로그인해주세요.")), + ); + } + + return Scaffold( + backgroundColor: const Color(0xFFF9F5E8), + bottomNavigationBar: const BottomNav(selectedIndex: 3), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 30), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 15), + const Padding( + padding: EdgeInsets.only(left: 20), + child: Text( + "GuardPay", + style: TextStyle(fontSize: 40, fontWeight: FontWeight.bold, color: Color(0xFF49A65E)), + ), + ), + const SizedBox(height: 25), + + _isLoadingAccount + ? const Center(child: CircularProgressIndicator()) + : _myAccount != null + ? _myAccountCard(_myAccount!) + : const Text("계좌 정보를 불러올 수 없습니다."), + + const SizedBox(height: 35), + const Padding( + padding: EdgeInsets.only(left: 20), + child: Text( + "송금할 계좌를 선택해주세요", + style: TextStyle(fontSize: 20, fontWeight: FontWeight.w500, color: Colors.black54), + ), + ), + const SizedBox(height: 15), + + _isLoadingBeneficiaries + ? const Center(child: CircularProgressIndicator()) + : _beneficiaries.isEmpty + ? const Text("표시할 계좌가 없습니다.") + : ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _beneficiaries.length, + itemBuilder: (context, index) { + return _accountItem(context, _beneficiaries[index]); + }, + ), + ], + ), + ), + ), + ); + } + + // [수정됨] 디자인 적용된 카드 + Widget _myAccountCard(MyAccount account) { + // 3자리 콤마 포맷터 + String formatCurrency(int amount) { + return amount.toString().replaceAllMapped( + RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]},'); + } + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 25, vertical: 25), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(25), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "GuardPay 안심 포인트", + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.black54, + ), + ), + const SizedBox(height: 15), + Row( + children: [ + Container( + width: 45, + height: 45, + decoration: BoxDecoration( + color: const Color(0xFFFFF59D), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.stars_rounded, + color: Color(0xFFFBC02D), + size: 30, + ), + ), + const SizedBox(width: 15), + Text( + "${formatCurrency(account.balance)}원", + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _accountItem(BuildContext context, Beneficiary beneficiary) { + bool isCompleted = _completedBeneficiaryIds.contains(beneficiary.beneficiaryId); + + return Container( + height: 90, + margin: const EdgeInsets.only(bottom: 30), + padding: const EdgeInsets.symmetric(horizontal: 20), + decoration: BoxDecoration( + color: isCompleted ? Colors.grey[200] : Colors.white, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + children: [ + Opacity( + opacity: isCompleted ? 0.5 : 1.0, + child: _buildBankLogo(beneficiary.bankName), + ), + const SizedBox(width: 15), + + Expanded( + child: Opacity( + opacity: isCompleted ? 0.5 : 1.0, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + beneficiary.accountHolderName.isNotEmpty + ? "${beneficiary.accountHolderName} (${beneficiary.bankName})" + : beneficiary.bankName, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 6), + Text( + beneficiary.accountNumber, + style: const TextStyle(fontSize: 18, color: Colors.black87), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + + InkWell( + onTap: isCompleted + ? null + : () async { + if (_accessToken != null) { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MockTransferScreen( + beneficiary: beneficiary, + accessToken: _accessToken!, + + // [핵심 수정] 여기에 myBalance를 꼭 넣어줘야 합니다! + // 아직 로딩 안됐으면(null이면) 0원을 넘겨서 에러 방지 + myBalance: _myAccount?.balance ?? 0, + ), + ), + ); + + if (result != null && result is int) { + await _saveCompletedId(result); + _fetchMyAccount(); + setState(() {}); + } + } + }, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 8), + decoration: BoxDecoration( + color: isCompleted ? Colors.grey : const Color(0xFF49A65E), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + isCompleted ? "완료" : "선택", + style: const TextStyle(color: Colors.white), + ), + ), + ), + ], + ), + ); + } +} + +Widget _buildBankLogo(String bankName) { + String imagePath = 'assets/images/default_bank.png'; + + if (bankName.contains('토스')) { + imagePath = 'assets/images/toss_logo.png'; + } else if (bankName.contains('카카오')) { + imagePath = 'assets/images/kakaobank_logo.jpg'; + } else if (bankName.contains('신한')) { + imagePath = 'assets/images/shinhan_logo.png'; + } else if (bankName.contains('국민')) { + imagePath = 'assets/images/kb_logo.png'; + } else if (bankName.contains('우리')) { + imagePath = 'assets/images/woori_logo.png'; + } else if (bankName.contains('하나')) { + imagePath = 'assets/images/hana_logo.png'; + } else if (bankName.contains('농협')) { + imagePath = 'assets/images/nh_logo.png'; + } else if (bankName.contains('기업')) { + imagePath = 'assets/images/ibk_logo.png'; + } + + return CircleAvatar( + radius: 26, + backgroundColor: Colors.transparent, + backgroundImage: AssetImage(imagePath), + onBackgroundImageError: (exception, stackTrace) { + print("이미지 로드 실패: $imagePath"); + }, + ); +} \ No newline at end of file diff --git a/lib/features/bank/screens/mock_transfer_screen.dart b/lib/features/bank/screens/mock_transfer_screen.dart new file mode 100644 index 0000000..8964699 --- /dev/null +++ b/lib/features/bank/screens/mock_transfer_screen.dart @@ -0,0 +1,364 @@ +import 'package:flutter/material.dart'; +import '../models/transfer_models.dart'; +import '../services/transfer_service.dart'; +import 'transfer_success_screen.dart'; + +class MockTransferScreen extends StatefulWidget { + final Beneficiary beneficiary; + final String accessToken; + final int myBalance; // 내 잔액 정보 + + const MockTransferScreen({ + super.key, + required this.beneficiary, + required this.accessToken, + required this.myBalance, + }); + + @override + State createState() => _MockTransferScreenState(); +} + +class _MockTransferScreenState extends State { + // 입력 컨트롤러 + final TextEditingController _accountNumberController = TextEditingController(); + final TextEditingController _amountController = TextEditingController(); + + final TransferService _service = TransferService(); + + // 은행 선택 드롭다운 + String? _selectedBank; + final List _bankList = [ + '국민은행', '신한은행', '하나은행', '우리은행', 'NH농협은행', + 'IBK기업은행', '카카오뱅크', '토스뱅크' + ]; + + bool isTransferReady = false; + bool isLoading = false; + + @override + void initState() { + super.initState(); + _accountNumberController.addListener(_checkValidity); + _amountController.addListener(_checkValidity); + } + + @override + void dispose() { + _accountNumberController.dispose(); + _amountController.dispose(); + super.dispose(); + } + + // 버튼 활성화 여부 체크 + void _checkValidity() { + setState(() { + isTransferReady = _accountNumberController.text.isNotEmpty && + _selectedBank != null && + _amountController.text.isNotEmpty; + }); + } + + Future _performTransfer() async { + if (!isTransferReady) return; + + // 키보드 내리기 + FocusScope.of(context).unfocus(); + + // 1. 입력값 가져오기 + String inputNumber = _accountNumberController.text.replaceAll('-', '').replaceAll(' ', ''); + String targetNumber = widget.beneficiary.accountNumber.replaceAll('-', '').replaceAll(' ', ''); + String selectedBankName = (_selectedBank ?? '').trim(); + String targetBankName = widget.beneficiary.bankName.trim(); + + // 금액 숫자로 변환 + int transferAmount = int.tryParse(_amountController.text) ?? 0; + + // 2. 계좌 정보 일치 여부 확인 + bool isAccountMatch = (inputNumber == targetNumber); + bool isBankMatch = (selectedBankName == targetBankName); + + if (!isAccountMatch || !isBankMatch) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("계좌번호와 은행 정보가 일치하지 않습니다."), + backgroundColor: Colors.redAccent, + duration: Duration(seconds: 2), + ), + ); + return; + } + + // 3. [추가된 로직] 잔액 초과 확인 + if (transferAmount > widget.myBalance) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("잔액을 초과했습니다. (현재 잔액: ${widget.myBalance}원)"), + backgroundColor: Colors.red, // 빨간색 경고 + duration: const Duration(seconds: 2), + ), + ); + return; // 여기서 함수를 끝내서 송금을 막음! + } + + // 4. 모든 검사 통과 -> 송금 진행 + setState(() => isLoading = true); + + try { + await _service.postTransfer( + accessToken: widget.accessToken, + toBeneficiaryId: widget.beneficiary.beneficiaryId, + amount: transferAmount, + ); + + if (!mounted) return; + + // 성공 화면으로 이동 + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => TransferSuccessScreen( + completedBeneficiaryId: widget.beneficiary.beneficiaryId, + ), + ), + ); + + // 성공 후 복귀 시 처리 + if (result != null) { + if (!mounted) return; + Navigator.pop(context, result); + } + + } catch (e) { + if (!mounted) return; + String errorMsg = e.toString().replaceAll("Exception: ", ""); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("송금 실패: $errorMsg")), + ); + } finally { + if (mounted) setState(() => isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF9F5E8), + appBar: AppBar( + backgroundColor: const Color(0xFFF9F5E8), + elevation: 0, + leading: Padding( + padding: const EdgeInsets.only(left: 8.0, top: 18.0), + child: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.black), + onPressed: () => Navigator.of(context).pop(), + ), + ), + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 10), + const Padding( + padding: EdgeInsets.only(left: 20), + child: Text( + "선택한 계좌로 송금하세요!", + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500, color: Colors.black54), + ), + ), + const SizedBox(height: 15), + + _selectedAccountCard(), + + const SizedBox(height: 40), + + const Text( + "어떤 계좌로 돈을 보낼까요?", + style: TextStyle(fontSize: 27, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 10), + + // 계좌번호 입력 + TextField( + controller: _accountNumberController, + keyboardType: TextInputType.number, + style: const TextStyle(fontSize: 18, color: Colors.black), + decoration: const InputDecoration( + labelText: "계좌번호 입력", + labelStyle: TextStyle(fontSize: 22, fontWeight: FontWeight.w500, color: Colors.grey), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Colors.grey), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Color(0xFF49A65E), width: 2), + ), + ), + ), + const SizedBox(height: 20), + + // 은행 선택 + DropdownButtonFormField( + value: _selectedBank, + hint: const Text("은행 선택", style: TextStyle(fontSize: 22, fontWeight: FontWeight.w500, color: Colors.grey)), + icon: const Icon(Icons.keyboard_arrow_down, color: Colors.grey), + style: const TextStyle(fontSize: 18, color: Colors.black), + decoration: const InputDecoration( + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Colors.grey), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Color(0xFF49A65E), width: 2), + ), + ), + items: _bankList.map((String bank) { + return DropdownMenuItem( + value: bank, + child: Text(bank), + ); + }).toList(), + onChanged: (String? newValue) { + setState(() { + _selectedBank = newValue; + _checkValidity(); + }); + }, + ), + + const SizedBox(height: 50), + + const Text( + "얼마를 보낼까요?", + style: TextStyle(fontSize: 27, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 20), + + // 금액 입력 + TextField( + controller: _amountController, + keyboardType: TextInputType.number, + style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w500), + decoration: const InputDecoration( + hintText: "금액", + hintStyle: TextStyle(fontSize: 22, fontWeight: FontWeight.w500, color: Colors.grey), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Colors.grey), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Color(0xFF49A65E), width: 2), + ), + ), + ), + + const SizedBox(height: 100), + + // 송금 버튼 + GestureDetector( + onTap: (isTransferReady && !isLoading) ? _performTransfer : null, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: isTransferReady ? const Color(0xFF49A65E) : Colors.grey[300], + borderRadius: BorderRadius.circular(10), + ), + alignment: Alignment.center, + child: isLoading + ? const SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2), + ) + : const Text( + "송금하기", + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + + const SizedBox(height: 30), + ], + ), + ), + ), + ); + } + + // 상단 계좌 정보 카드 (UI 유지) + Widget _selectedAccountCard() { + return Container( + height: 100, + padding: const EdgeInsets.symmetric(horizontal: 20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + children: [ + _buildBankLogo(widget.beneficiary.bankName), + const SizedBox(width: 15), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.beneficiary.accountHolderName.isNotEmpty + ? "${widget.beneficiary.accountHolderName} (${widget.beneficiary.bankName})" + : widget.beneficiary.bankName, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 6), + Text( + widget.beneficiary.accountNumber, + style: const TextStyle(fontSize: 18, color: Colors.black87), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildBankLogo(String bankName) { + String imagePath = 'assets/images/default_bank.png'; + + if (bankName.contains('토스')) { + imagePath = 'assets/images/toss_logo.png'; + } else if (bankName.contains('카카오')) { + imagePath = 'assets/images/kakaobank_logo.jpg'; + } else if (bankName.contains('신한')) { + imagePath = 'assets/images/shinhan_logo.png'; + } else if (bankName.contains('국민')) { + imagePath = 'assets/images/kb_logo.png'; + } else if (bankName.contains('우리')) { + imagePath = 'assets/images/woori_logo.png'; + } else if (bankName.contains('하나')) { + imagePath = 'assets/images/hana_logo.png'; + } else if (bankName.contains('농협')) { + imagePath = 'assets/images/nh_logo.png'; + } else if (bankName.contains('기업')) { + imagePath = 'assets/images/ibk_logo.png'; + } + + return CircleAvatar( + radius: 26, + backgroundColor: Colors.transparent, + backgroundImage: AssetImage(imagePath), + onBackgroundImageError: (exception, stackTrace) { + print("이미지 로드 실패: $imagePath"); + }, + ); + } +} \ No newline at end of file diff --git a/lib/features/bank/screens/transfer_success_screen.dart b/lib/features/bank/screens/transfer_success_screen.dart new file mode 100644 index 0000000..2c12678 --- /dev/null +++ b/lib/features/bank/screens/transfer_success_screen.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; + +class TransferSuccessScreen extends StatelessWidget { + // [추가] 완료된 계좌 ID를 받기 위한 변수 (화면에는 안 보임, 뒤로가기용) + final int? completedBeneficiaryId; + + const TransferSuccessScreen({ + super.key, + this.completedBeneficiaryId, // 생성자 추가 + }); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF9F5E8), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 30), + child: Column( + children: [ + const Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.check_circle, + color: Color(0xFF49A65E), + size: 100, + ), + SizedBox(height: 30), + Text( + "송금이 완료되었습니다!", + style: TextStyle( + fontSize: 25, + fontWeight: FontWeight.w500, + color: Colors.black, + ), + ), + SizedBox(height: 5), + Text( + "100p가 지급됩니다.", + style: TextStyle( + fontSize: 25, + fontWeight: FontWeight.w500, + color: Colors.black, + ), + ), + ], + ), + ), + + // [수정] 버튼 영역 (Expanded 밖으로 빼서 하단에 고정) + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + // [핵심 수정] 버튼을 누르면 '완료된 ID'를 가지고 이전 화면으로 돌아감 + Navigator.pop(context, completedBeneficiaryId); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF49A65E), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: const Text( + "확인", + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ), + const SizedBox(height: 20), // 버튼 아래 약간의 여백 + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/bank/services/transfer_service.dart b/lib/features/bank/services/transfer_service.dart new file mode 100644 index 0000000..2699c40 --- /dev/null +++ b/lib/features/bank/services/transfer_service.dart @@ -0,0 +1,114 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import '../models/transfer_models.dart'; + +class TransferService { + // 💡 에뮬레이터용 주소: http://10.0.2.2:8080/api/v1 + final String _baseUrl = "http://10.0.2.2:8080/api"; + + // [응답 처리기] + dynamic _handleResponse(http.Response response, String endpointName) { + String decodedBody = utf8.decode(response.bodyBytes); + print("📥 [$endpointName] Status: ${response.statusCode}"); + print("📥 [$endpointName] Body: $decodedBody"); + + final dynamic body; + try { + body = json.decode(decodedBody); + } catch (e) { + throw Exception('JSON 파싱 실패: $decodedBody'); + } + + if (response.statusCode == 200) { + if (body is Map && body.containsKey('data')) { + return body['data']; + } + return body; + } else { + throw Exception('서버 에러 (${response.statusCode}): $decodedBody'); + } + } + + Map _authHeaders(String accessToken) { + String cleanToken = accessToken.startsWith('Bearer ') + ? accessToken.substring(7) + : accessToken; + return { + 'Content-Type': 'application/json; charset=UTF-8', + 'Authorization': 'Bearer $cleanToken', + }; + } + + // 1. 내 계좌 정보 조회 + Future getMyAccount(String accessToken) async { + try { + print("🚀 [내 계좌 조회] 요청 시작..."); + final url = Uri.parse('$_baseUrl/members/profile'); + + final response = await http.get( + url, + headers: _authHeaders(accessToken), + ); + + final data = _handleResponse(response, "내 계좌 조회"); + print("✅ [내 계좌 조회] 데이터 파싱 전: $data"); + + // [수정] 데이터가 Map이 아니라 숫자(int)로 왔을 때 처리 + if (data is int) { + print("⚠️ 데이터가 숫자로 왔습니다. 객체로 변환합니다."); + return MyAccount( + myId: 'unknown', + balance: data, + accountName: 'GuardPay 포인트', + currency: 'KRW', + ); + } + + // 정상적인 Map 형태일 때 파싱 + final account = MyAccount.fromJson(data); + print("✅ [내 계좌 조회] 파싱 성공! 잔액: ${account.balance}"); + return account; + + } catch (e) { + print('❌ [치명적 에러] 내 계좌 조회 실패: $e'); + throw Exception("데이터 불러오기 실패: $e"); + } + } + + // 2. 송금 대상 목록 조회 + Future> getBeneficiaries(String accessToken) async { + try { + final url = Uri.parse('$_baseUrl/v1/beneficiaries/random'); + final response = await http.get(url, headers: _authHeaders(accessToken)); + final dynamic data = _handleResponse(response, "송금 대상 조회"); + + List list = data is List ? data : []; + return list.map((json) => Beneficiary.fromJson(json)).toList(); + } catch (e) { + print('❌ 송금 대상 조회 실패: $e'); + rethrow; + } + } + + // 3. 송금하기 + Future postTransfer({ + required String accessToken, + required int toBeneficiaryId, + required int amount, + }) async { + try { + final url = Uri.parse('$_baseUrl/v1/beneficiaries/$toBeneficiaryId/transfer'); + final response = await http.post( + url, + headers: _authHeaders(accessToken), + body: json.encode({'amount': amount}), + ); + + final data = _handleResponse(response, "송금 요청"); + return TransferResult.fromJson(data); + } catch (e) { + print('❌ 송금 실패: $e'); + rethrow; + } + } +} \ No newline at end of file diff --git a/lib/features/map/models/bank_model.dart b/lib/features/map/models/bank_model.dart new file mode 100644 index 0000000..020476a --- /dev/null +++ b/lib/features/map/models/bank_model.dart @@ -0,0 +1,63 @@ +class BankModel { + final int id; + final String name; // 은행명 + final String branchName; // 지점명 + final String fullName; // 전체명 + final String address; // 지번 주소 + final String? roadAddress; // 도로명 주소 + final double lat; + final double lon; + final String? phoneNumber; + final String? businessHours; + final double? distance; // 거리(m) + final String? distanceText; // 거리 텍스트 + + BankModel({ + required this.id, + required this.name, + required this.branchName, + required this.fullName, + required this.address, + this.roadAddress, + required this.lat, + required this.lon, + this.phoneNumber, + this.businessHours, + this.distance, + this.distanceText, + }); + + factory BankModel.fromJson(Map json) { + return BankModel( + id: json['id'], + name: json['name'], + branchName: json['branchName'] ?? '', + fullName: json['fullName'] ?? '${json['name']} ${json['branchName'] ?? ''}', + address: json['address'], + roadAddress: json['roadAddress'], + lat: (json['lat'] as num).toDouble(), + lon: (json['lon'] as num).toDouble(), + phoneNumber: json['phoneNumber'], + businessHours: json['businessHours'], + distance: json['distance'] != null ? (json['distance'] as num).toDouble() : null, + distanceText: json['distanceText'], + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'branchName': branchName, + 'fullName': fullName, + 'address': address, + 'roadAddress': roadAddress, + 'lat': lat, + 'lon': lon, + 'phoneNumber': phoneNumber, + 'businessHours': businessHours, + 'distance': distance, + 'distanceText': distanceText, + }; + } +} \ No newline at end of file diff --git a/lib/features/map/models/location_model.dart b/lib/features/map/models/location_model.dart new file mode 100644 index 0000000..209c205 --- /dev/null +++ b/lib/features/map/models/location_model.dart @@ -0,0 +1,31 @@ +class LocationModel { + final String address; + final String? roadAddress; + final double latitude; + final double longitude; + final String? placeName; + final String? addressType; + + LocationModel({ + required this.address, + this.roadAddress, + required this.latitude, + required this.longitude, + this.placeName, + this.addressType, + }); + + factory LocationModel.fromJson(Map json) { + return LocationModel( + address: json['address'], + roadAddress: json['roadAddress'], + latitude: (json['latitude'] as num).toDouble(), + longitude: (json['longitude'] as num).toDouble(), + placeName: json['placeName'], + addressType: json['addressType'], + ); + } + + // 표시할 주소 (도로명 우선) + String get displayAddress => roadAddress ?? address; +} \ No newline at end of file diff --git a/lib/features/map/screens/map_screen.dart b/lib/features/map/screens/map_screen.dart new file mode 100644 index 0000000..7ee54ea --- /dev/null +++ b/lib/features/map/screens/map_screen.dart @@ -0,0 +1,248 @@ +import 'package:flutter/material.dart'; +import 'package:webview_flutter/webview_flutter.dart'; +import 'package:guardpayfront/features/auth/widgets/bottom_nav.dart'; +import 'package:guardpayfront/features/map/services/map_service.dart'; +import 'package:guardpayfront/features/map/models/bank_model.dart'; +import 'package:guardpayfront/features/map/widgets/IntegratedSearchBar.dart'; + +class MapScreen extends StatefulWidget { + const MapScreen({super.key}); + + @override + State createState() => _MapScreenState(); +} + +class _MapScreenState extends State { + final MapService _mapService = MapService(); + late WebViewController _webViewController; + + String _selectedBank = '신한은행'; + double? _myLat; + double? _myLon; + String? _myAddress; + + bool _isMapLoaded = false; + bool _isSearching = false; + int _foundBanksCount = 0; + + static const String _webViewUrl = 'http://10.0.2.2:8080/kakao-map.html'; + + @override + void initState() { + super.initState(); + _initializeWebView(); + } + + // ============================ + // 🚀 WebView 초기 설정 + // ============================ + void _initializeWebView() { + _webViewController = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setBackgroundColor(Colors.white) + + // 🔥 핵심: kakaomap:// 앱 스킴 차단! + ..setNavigationDelegate( + NavigationDelegate( + onNavigationRequest: (NavigationRequest request) { + final url = request.url; + + // 앱 스킴 차단 + if (url.startsWith("kakaomap://")) { + print("🚫 카카오 앱 스킴 차단: $url"); + return NavigationDecision.prevent; + } + return NavigationDecision.navigate; + }, + ), + )..setNavigationDelegate( + NavigationDelegate( + onPageFinished: (_) async { + await Future.delayed(const Duration(milliseconds: 300)); + setState(() => _isMapLoaded = true); + }, + ), + ) + ..loadRequest(Uri.parse(_webViewUrl)); + } + + // ============================ + // 🔎 통합 검색 완료 처리 + // ============================ + Future _handleIntegratedSearch(String bank, double lat, double lon, + String address) async { + if (!_isMapLoaded) { + _showSnackBar('지도 로딩 중입니다.'); + return; + } + + setState(() { + _selectedBank = bank; + _myLat = lat; + _myLon = lon; + _myAddress = address; + _foundBanksCount = 0; + }); + + // 지도 이동 + await _moveMapToLocation(lat, lon); + + // 백엔드 검색 (기능 유지) + await _searchBanks(); + + // 카카오 키워드 검색 (선택 은행) + await _webViewController.runJavaScript( + "searchBankByKeyword('${bank}');", + ); + + _showSnackBar('📍 $address ($bank) 검색 완료', isSuccess: true); + } + + // ============================ + // 지도 이동 + // ============================ + Future _moveMapToLocation(double lat, double lon) async { + await _webViewController.runJavaScript(''' + if (typeof map !== 'undefined') { + var pos = new kakao.maps.LatLng($lat, $lon); + map.setCenter(pos); + } + '''); + } + + // ============================ + // 백엔드 은행 검색 (기존 기능 유지) + // ============================ + Future _searchBanks() async { + if (_myLat == null || _myLon == null) return; + + _setSearchingState(true); + try { + final banks = await _mapService.searchBanks( + _selectedBank, + _myLat!, + _myLon!, + ); + _handleBankSearchResult(banks); + } catch (e) { + print('❌ 은행 검색 실패: $e'); + } finally { + _setSearchingState(false); + } + } + + void _handleBankSearchResult(List banks) { + setState(() => _foundBanksCount = banks.length); + + _webViewController.runJavaScript(""" + if (typeof clearBankMarkers === 'function') clearBankMarkers(); + """); + + for (var bank in banks) { + final js = """ + if (typeof addBankMarker === 'function') { + addBankMarker(${bank.lat}, ${bank.lon}, + "${bank.fullName}", + "${bank.roadAddress ?? bank.address}"); + } + """; + _webViewController.runJavaScript(js); + } + } + + void _setSearchingState(bool isSearching) { + setState(() => _isSearching = isSearching); + } + + // SnackBar + void _showSnackBar(String msg, {bool isSuccess = false}) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(msg), + backgroundColor: isSuccess ? Colors.green : Colors.black87, + ), + ); + } + + // ============================ + // UI + // ============================ + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async { + // WebView 뒤로갈 페이지가 있으면 WebView에서 뒤로가기 실행 + if (await _webViewController.canGoBack()) { + _webViewController.goBack(); + return false; // 앱 자체의 뒤로가기는 막음 + } + return true; // 더 이상 뒤로갈 페이지 없으면 앱 뒤로가기 + }, + + child: Scaffold( + body: Stack( + children: [ + WebViewWidget(controller: _webViewController), + + if (!_isMapLoaded) + const Center(child: CircularProgressIndicator()), + + if (_isSearching) + Container( + color: Colors.black12, + child: const Center(child: CircularProgressIndicator()), + ), + + Positioned( + top: 50, + left: 16, + right: 16, + child: IntegratedSearchBar( + initialBank: _selectedBank, + onSearchCompleted: _handleIntegratedSearch, + ), + ), + + if (_myAddress != null) + Positioned( + bottom: 20, + left: 16, + right: 16, + child: Card( + elevation: 4, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row(children: [ + const Icon(Icons.location_on, color: Colors.blue), + const SizedBox(width: 8), + Expanded(child: Text(_myAddress!)), + ]), + if (_foundBanksCount > 0) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Row(children: [ + const Icon( + Icons.account_balance, color: Colors.green), + const SizedBox(width: 8), + Text( + "${_selectedBank} $_foundBanksCount개 발견", + style: const TextStyle( + fontWeight: FontWeight.bold), + ), + ]), + ), + ], + ), + ), + ), + ), + ], + ), + bottomNavigationBar: const BottomNav(selectedIndex: 4), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/map/services/map_service.dart b/lib/features/map/services/map_service.dart new file mode 100644 index 0000000..5259c85 --- /dev/null +++ b/lib/features/map/services/map_service.dart @@ -0,0 +1,128 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:guardpayfront/features/map/models/bank_model.dart'; +import 'package:guardpayfront/features/map/models/location_model.dart'; +import 'package:guardpayfront/core/services/storage.dart'; + +class MapService { + final storage = AppStorage.storage; + + // ✅ 백엔드 URL 설정 + static const String baseUrl = 'http://10.0.2.2:8080'; // 에뮬레이터용 + // static const String baseUrl = 'http://192.168.x.x:8080'; // 실제 기기용 + + /// 주소 검색 (카카오 API를 통한 검색) + Future> searchAddressList(String query) async { + try { + print('📍 주소 검색 요청: $query'); + + final response = await http.get( + Uri.parse('$baseUrl/api/location/search?query=${Uri.encodeComponent(query)}'), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + }, + ).timeout(const Duration(seconds: 10)); + + print('📡 Response status: ${response.statusCode}'); + + if (response.statusCode == 200) { + final List data = json.decode(utf8.decode(response.bodyBytes)); + print('✅ 검색 결과: ${data.length}개'); + + return data + .map((json) => LocationModel.fromJson(json)) + .toList(); + } else { + print('❌ 주소 검색 실패: ${response.statusCode}'); + print('Response body: ${utf8.decode(response.bodyBytes)}'); + return []; + } + } catch (e) { + print('❌ 주소 검색 에러: $e'); + return []; + } + } + + /// 5km 반경 내 은행 검색 + Future> searchBanks( + String bankName, + double lat, + double lng, + ) async { + try { + final accessToken = await storage.read(key: 'accessToken'); + + if (accessToken == null) { + throw Exception('로그인이 필요합니다.'); + } + + print('🏦 은행 검색 요청: $bankName at ($lat, $lng)'); + + final uri = Uri.parse('$baseUrl/api/banks/nearby').replace( + queryParameters: { + 'bankName': bankName, + 'latitude': lat.toString(), + 'longitude': lng.toString(), + 'radius': '5000', // 5km + }, + ); + + print('📡 Request URL: $uri'); + + final response = await http.get( + uri, + headers: { + 'Authorization': 'Bearer $accessToken', + 'Content-Type': 'application/json; charset=UTF-8', + }, + ).timeout(const Duration(seconds: 15)); + + print('📡 Response status: ${response.statusCode}'); + + if (response.statusCode == 200) { + final List data = json.decode(utf8.decode(response.bodyBytes)); + print('✅ 은행 검색 결과: ${data.length}개'); + + return data + .map((json) => BankModel.fromJson(json)) + .toList(); + } else if (response.statusCode == 401) { + throw Exception('인증이 만료되었습니다. 다시 로그인해주세요.'); + } else { + print('❌ 은행 검색 실패: ${response.statusCode}'); + print('Response body: ${utf8.decode(response.bodyBytes)}'); + throw Exception('은행 검색 실패: ${response.statusCode}'); + } + } catch (e) { + print(' 로딩중: $e'); + rethrow; + } + } + + /// 키워드 검색 (선택적) + Future> searchKeyword(String keyword) async { + try { + print('🔍 키워드 검색: $keyword'); + + final response = await http.get( + Uri.parse('$baseUrl/api/location/search/keyword?keyword=${Uri.encodeComponent(keyword)}'), + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + }, + ).timeout(const Duration(seconds: 10)); + + if (response.statusCode == 200) { + final List data = json.decode(utf8.decode(response.bodyBytes)); + return data + .map((json) => LocationModel.fromJson(json)) + .toList(); + } + + return []; + } catch (e) { + print('❌ 키워드 검색 에러: $e'); + return []; + } + } +} \ No newline at end of file diff --git a/lib/features/map/widgets/IntegratedSearchBar.dart b/lib/features/map/widgets/IntegratedSearchBar.dart new file mode 100644 index 0000000..5cec1e5 --- /dev/null +++ b/lib/features/map/widgets/IntegratedSearchBar.dart @@ -0,0 +1,217 @@ +import 'package:flutter/material.dart'; +import 'package:guardpayfront/features/map/services/map_service.dart'; +import 'package:guardpayfront/features/map/models/location_model.dart'; + +class IntegratedSearchBar extends StatefulWidget { + final String initialBank; + final Function(String bank, double lat, double lon, String address) onSearchCompleted; + + const IntegratedSearchBar({ + super.key, + required this.initialBank, + required this.onSearchCompleted, + }); + + @override + State createState() => _IntegratedSearchBarState(); +} + +class _IntegratedSearchBarState extends State { + final TextEditingController _searchController = TextEditingController(); + final MapService _mapService = MapService(); + final FocusNode _focusNode = FocusNode(); + + late String _selectedBank; + List _searchResults = []; + bool _isSearching = false; + bool _showResults = false; + + @override + void initState() { + super.initState(); + _selectedBank = widget.initialBank; + } + + @override + void dispose() { + _searchController.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + /// 주소 검색 API 호출 + Future _searchAddress(String query) async { + if (query.trim().isEmpty) { + setState(() { + _searchResults = []; + _showResults = false; + }); + return; + } + + setState(() { + _isSearching = true; + _showResults = true; + }); + + try { + // 입력된 지역명 + 선택된 은행명으로 검색 (예: "강남 신한은행") + final finalQuery = "$query $_selectedBank"; + print("🔎 통합 검색 요청: $finalQuery"); + + final results = await _mapService.searchAddressList(finalQuery); + + setState(() { + _searchResults = results; + _isSearching = false; + }); + } catch (e) { + print('❌ 검색 에러: $e'); + setState(() { + _searchResults = []; + _isSearching = false; + }); + } + } + + /// 검색 결과 선택 시 처리 + void _selectLocation(LocationModel location) { + _searchController.text = location.placeName ?? location.address; + + setState(() { + _showResults = false; + _searchResults = []; + }); + _focusNode.unfocus(); + + // ✅ 부모에게 [은행, 위도, 경도, 주소] 모두 전달 + widget.onSearchCompleted( + _selectedBank, + location.latitude, + location.longitude, + location.displayAddress, + ); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // 🔹 통합 검색바 (Card 하나에 Row로 배치) + Card( + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Row( + children: [ + // 1. 은행 선택 드롭다운 (왼쪽) + DropdownButtonHideUnderline( + child: DropdownButton( + value: _selectedBank, + icon: const Icon(Icons.arrow_drop_down, color: Colors.green), + style: const TextStyle(color: Colors.black, fontWeight: FontWeight.bold), + onChanged: (String? newValue) { + if (newValue != null) { + setState(() { + _selectedBank = newValue; + }); + // 은행을 바꾸면, 현재 입력된 주소로 즉시 재검색 가능 + if (_searchController.text.isNotEmpty) { + _searchAddress(_searchController.text); + } + } + }, + items: [ + '신한은행', '국민은행', '우리은행', '하나은행', 'NH농협은행', + '기업은행', '부산은행', '대구은행', '경남은행', '광주은행', + '전북은행', '제주은행', '카카오뱅크', '케이뱅크', '토스뱅크' + ].map>((String value) { + return DropdownMenuItem( + value: value, + child: Text(value), + ); + }).toList(), + ), + ), + + // 구분선 + Container( + width: 1, + height: 24, + color: Colors.grey[300], + margin: const EdgeInsets.symmetric(horizontal: 8), + ), + + // 2. 주소 입력 필드 (오른쪽 확장) + Expanded( + child: TextField( + controller: _searchController, + focusNode: _focusNode, + decoration: const InputDecoration( + hintText: '지역 검색 (예: 강남구)', + border: InputBorder.none, + isDense: true, + ), + onChanged: (value) { + if (value.length >= 2) _searchAddress(value); + }, + onSubmitted: (value) { + if (_searchResults.isNotEmpty) { + _selectLocation(_searchResults[0]); + } + }, + ), + ), + + // 3. 검색 아이콘 또는 로딩바 + if (_isSearching) + const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) + else + IconButton( + icon: const Icon(Icons.search, color: Colors.blue), + onPressed: () { + if (_searchController.text.isNotEmpty) { + _searchAddress(_searchController.text); + } + }, + ), + ], + ), + ), + ), + + // 🔹 자동완성 결과 목록 (검색바 바로 아래 표시) + if (_showResults && _searchResults.isNotEmpty) + Container( + margin: const EdgeInsets.only(top: 4), + constraints: const BoxConstraints(maxHeight: 300), + child: Card( + elevation: 4, + child: ListView.builder( + shrinkWrap: true, + padding: EdgeInsets.zero, + itemCount: _searchResults.length, + itemBuilder: (context, index) { + final location = _searchResults[index]; + return ListTile( + dense: true, + leading: const Icon(Icons.place, size: 18, color: Colors.grey), + title: Text( + location.placeName ?? location.address, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + subtitle: Text( + location.address, + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + onTap: () => _selectLocation(location), + ); + }, + ), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/features/map/widgets/LocationSearchBar.dart b/lib/features/map/widgets/LocationSearchBar.dart new file mode 100644 index 0000000..f183242 --- /dev/null +++ b/lib/features/map/widgets/LocationSearchBar.dart @@ -0,0 +1,199 @@ +// import 'package:flutter/material.dart'; +// import 'package:guardpayfront/features/map/services/map_service.dart'; +// import 'package:guardpayfront/features/map/models/location_model.dart'; +// +// class LocationSearchBar extends StatefulWidget { +// final Function(double lat, double lon, String address) onLocationSelected; +// final String? selectedBank; // ✅ 부모(MapScreen)로부터 은행 이름을 받습니다. +// +// const LocationSearchBar({ +// super.key, +// required this.onLocationSelected, +// this.selectedBank, +// }); +// +// @override +// State createState() => _LocationSearchBarState(); +// } +// +// class _LocationSearchBarState extends State { +// final TextEditingController _searchController = TextEditingController(); +// final MapService _mapService = MapService(); +// final FocusNode _focusNode = FocusNode(); +// +// List _searchResults = []; +// bool _isSearching = false; +// bool _showResults = false; +// +// // ✅ 은행이 바뀌면 검색 로직을 다시 수행할 수 있도록 감지 +// @override +// void didUpdateWidget(LocationSearchBar oldWidget) { +// super.didUpdateWidget(oldWidget); +// if (oldWidget.selectedBank != widget.selectedBank) { +// // 은행이 바뀌었고, 검색창에 텍스트가 있다면 즉시 재검색 수행 +// if (_searchController.text.isNotEmpty) { +// _searchAddress(_searchController.text); +// } +// } +// } +// +// @override +// void dispose() { +// _searchController.dispose(); +// _focusNode.dispose(); +// super.dispose(); +// } +// +// /// 주소 검색 +// Future _searchAddress(String query) async { +// if (query.trim().isEmpty) { +// setState(() { +// _searchResults = []; +// _showResults = false; +// }); +// return; +// } +// +// setState(() { +// _isSearching = true; +// _showResults = true; +// }); +// +// try { +// // ✅ [요구사항 1 해결] 검색어 조합 로직 +// // 입력값: "강남" + 선택된 은행: "국민은행" -> 최종검색어: "강남 국민은행" +// // 이렇게 보내면 백엔드가 카카오 API에 "강남 국민은행" + "BK9(은행코드)"로 요청합니다. +// String finalQuery = query; +// if (widget.selectedBank != null && widget.selectedBank!.isNotEmpty) { +// finalQuery = "$query ${widget.selectedBank}"; +// } +// +// print("🔎 검색 요청(조합됨): $finalQuery"); +// +// final results = await _mapService.searchAddressList(finalQuery); +// +// setState(() { +// _searchResults = results; +// _isSearching = false; +// }); +// } catch (e) { +// print('❌ 주소 검색 에러: $e'); +// setState(() { +// _searchResults = []; +// _isSearching = false; +// }); +// } +// } +// +// /// 주소 선택 +// void _selectAddress(LocationModel location) { +// // 선택 시 텍스트창은 깔끔하게 장소명으로 표시 +// _searchController.text = location.placeName ?? location.address; +// +// setState(() { +// _showResults = false; +// _searchResults = []; +// }); +// +// _focusNode.unfocus(); +// +// // ✅ [요구사항 2 해결] 부모(MapScreen)에게 좌표를 전달하여 지도 이동 트리거 +// widget.onLocationSelected( +// location.latitude, +// location.longitude, +// location.displayAddress, +// ); +// } +// +// @override +// Widget build(BuildContext context) { +// return Column( +// children: [ +// // 검색 입력 필드 +// Card( +// elevation: 4, +// child: Padding( +// padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), +// child: Row( +// children: [ +// const Icon(Icons.search, color: Colors.grey), +// const SizedBox(width: 8), +// Expanded( +// child: TextField( +// controller: _searchController, +// focusNode: _focusNode, +// decoration: InputDecoration( +// // 힌트 텍스트를 동적으로 변경하여 현재 어떤 은행을 검색하는지 알려줌 +// hintText: widget.selectedBank != null +// ? '${widget.selectedBank} 위치 검색 (예: 강남)' +// : '지역 검색 (예: 서울 강남구)', +// border: InputBorder.none, +// ), +// onChanged: (value) { +// if (value.length >= 2) { +// _searchAddress(value); +// } else { +// setState(() { +// _showResults = false; +// _searchResults = []; +// }); +// } +// }, +// onSubmitted: (value) { +// if (_searchResults.isNotEmpty) { +// _selectAddress(_searchResults[0]); +// } +// }, +// ), +// ), +// if (_isSearching) +// const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) +// else if (_searchController.text.isNotEmpty) +// IconButton( +// icon: const Icon(Icons.clear, size: 20), +// onPressed: () { +// _searchController.clear(); +// setState(() { +// _showResults = false; +// _searchResults = []; +// }); +// }, +// ), +// ], +// ), +// ), +// ), +// +// // 검색 결과 드롭다운 (기존 로직 유지) +// if (_showResults && _searchResults.isNotEmpty) +// Container( +// margin: const EdgeInsets.only(top: 4), +// constraints: const BoxConstraints(maxHeight: 300), +// child: Card( +// elevation: 4, +// child: ListView.builder( +// shrinkWrap: true, +// itemCount: _searchResults.length, +// itemBuilder: (context, index) { +// final location = _searchResults[index]; +// return ListTile( +// dense: true, +// leading: const Icon(Icons.location_on, color: Colors.blue, size: 20), +// title: Text( +// location.placeName ?? location.address, +// style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), +// ), +// subtitle: Text( +// location.address, +// style: TextStyle(fontSize: 12, color: Colors.grey[600]), +// ), +// onTap: () => _selectAddress(location), +// ); +// }, +// ), +// ), +// ), +// ], +// ); +// } +// } \ No newline at end of file diff --git a/lib/features/map/widgets/bank_search_bar.dart b/lib/features/map/widgets/bank_search_bar.dart new file mode 100644 index 0000000..5a7c2f0 --- /dev/null +++ b/lib/features/map/widgets/bank_search_bar.dart @@ -0,0 +1,63 @@ +// import 'package:flutter/material.dart'; +// +// class BankSearchBar extends StatelessWidget { +// final String initialBank; +// final Function(String) onBankSelected; +// +// const BankSearchBar({ +// super.key, +// required this.initialBank, +// required this.onBankSelected, +// }); +// +// @override +// Widget build(BuildContext context) { +// return Card( +// elevation: 4, +// child: Padding( +// padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), +// child: Row( +// children: [ +// const Icon(Icons.account_balance, color: Colors.green), +// const SizedBox(width: 8), +// Expanded( +// child: DropdownButton( +// value: initialBank, +// isExpanded: true, +// underline: const SizedBox(), +// items: [ +// '신한은행', +// '국민은행', +// '우리은행', +// '하나은행', +// 'NH농협은행', +// '기업은행', +// '부산은행', +// '대구은행', +// '경남은행', +// '광주은행', +// '전북은행', +// '제주은행', +// '카카오뱅크', +// '케이뱅크', +// '토스뱅크', +// ].map((String bank) { +// return DropdownMenuItem( +// value: bank, +// child: Text(bank), +// ); +// }).toList(), +// onChanged: (String? newBank) { +// if (newBank != null) { +// onBankSelected(newBank); +// } +// }, +// ), +// ), +// const Icon(Icons.arrow_drop_down, color: Colors.grey), +// ], +// ), +// ), +// ); +// } +// } \ No newline at end of file diff --git a/lib/features/map/widgets/location_search_bar.dart b/lib/features/map/widgets/location_search_bar.dart new file mode 100644 index 0000000..f183242 --- /dev/null +++ b/lib/features/map/widgets/location_search_bar.dart @@ -0,0 +1,199 @@ +// import 'package:flutter/material.dart'; +// import 'package:guardpayfront/features/map/services/map_service.dart'; +// import 'package:guardpayfront/features/map/models/location_model.dart'; +// +// class LocationSearchBar extends StatefulWidget { +// final Function(double lat, double lon, String address) onLocationSelected; +// final String? selectedBank; // ✅ 부모(MapScreen)로부터 은행 이름을 받습니다. +// +// const LocationSearchBar({ +// super.key, +// required this.onLocationSelected, +// this.selectedBank, +// }); +// +// @override +// State createState() => _LocationSearchBarState(); +// } +// +// class _LocationSearchBarState extends State { +// final TextEditingController _searchController = TextEditingController(); +// final MapService _mapService = MapService(); +// final FocusNode _focusNode = FocusNode(); +// +// List _searchResults = []; +// bool _isSearching = false; +// bool _showResults = false; +// +// // ✅ 은행이 바뀌면 검색 로직을 다시 수행할 수 있도록 감지 +// @override +// void didUpdateWidget(LocationSearchBar oldWidget) { +// super.didUpdateWidget(oldWidget); +// if (oldWidget.selectedBank != widget.selectedBank) { +// // 은행이 바뀌었고, 검색창에 텍스트가 있다면 즉시 재검색 수행 +// if (_searchController.text.isNotEmpty) { +// _searchAddress(_searchController.text); +// } +// } +// } +// +// @override +// void dispose() { +// _searchController.dispose(); +// _focusNode.dispose(); +// super.dispose(); +// } +// +// /// 주소 검색 +// Future _searchAddress(String query) async { +// if (query.trim().isEmpty) { +// setState(() { +// _searchResults = []; +// _showResults = false; +// }); +// return; +// } +// +// setState(() { +// _isSearching = true; +// _showResults = true; +// }); +// +// try { +// // ✅ [요구사항 1 해결] 검색어 조합 로직 +// // 입력값: "강남" + 선택된 은행: "국민은행" -> 최종검색어: "강남 국민은행" +// // 이렇게 보내면 백엔드가 카카오 API에 "강남 국민은행" + "BK9(은행코드)"로 요청합니다. +// String finalQuery = query; +// if (widget.selectedBank != null && widget.selectedBank!.isNotEmpty) { +// finalQuery = "$query ${widget.selectedBank}"; +// } +// +// print("🔎 검색 요청(조합됨): $finalQuery"); +// +// final results = await _mapService.searchAddressList(finalQuery); +// +// setState(() { +// _searchResults = results; +// _isSearching = false; +// }); +// } catch (e) { +// print('❌ 주소 검색 에러: $e'); +// setState(() { +// _searchResults = []; +// _isSearching = false; +// }); +// } +// } +// +// /// 주소 선택 +// void _selectAddress(LocationModel location) { +// // 선택 시 텍스트창은 깔끔하게 장소명으로 표시 +// _searchController.text = location.placeName ?? location.address; +// +// setState(() { +// _showResults = false; +// _searchResults = []; +// }); +// +// _focusNode.unfocus(); +// +// // ✅ [요구사항 2 해결] 부모(MapScreen)에게 좌표를 전달하여 지도 이동 트리거 +// widget.onLocationSelected( +// location.latitude, +// location.longitude, +// location.displayAddress, +// ); +// } +// +// @override +// Widget build(BuildContext context) { +// return Column( +// children: [ +// // 검색 입력 필드 +// Card( +// elevation: 4, +// child: Padding( +// padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), +// child: Row( +// children: [ +// const Icon(Icons.search, color: Colors.grey), +// const SizedBox(width: 8), +// Expanded( +// child: TextField( +// controller: _searchController, +// focusNode: _focusNode, +// decoration: InputDecoration( +// // 힌트 텍스트를 동적으로 변경하여 현재 어떤 은행을 검색하는지 알려줌 +// hintText: widget.selectedBank != null +// ? '${widget.selectedBank} 위치 검색 (예: 강남)' +// : '지역 검색 (예: 서울 강남구)', +// border: InputBorder.none, +// ), +// onChanged: (value) { +// if (value.length >= 2) { +// _searchAddress(value); +// } else { +// setState(() { +// _showResults = false; +// _searchResults = []; +// }); +// } +// }, +// onSubmitted: (value) { +// if (_searchResults.isNotEmpty) { +// _selectAddress(_searchResults[0]); +// } +// }, +// ), +// ), +// if (_isSearching) +// const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) +// else if (_searchController.text.isNotEmpty) +// IconButton( +// icon: const Icon(Icons.clear, size: 20), +// onPressed: () { +// _searchController.clear(); +// setState(() { +// _showResults = false; +// _searchResults = []; +// }); +// }, +// ), +// ], +// ), +// ), +// ), +// +// // 검색 결과 드롭다운 (기존 로직 유지) +// if (_showResults && _searchResults.isNotEmpty) +// Container( +// margin: const EdgeInsets.only(top: 4), +// constraints: const BoxConstraints(maxHeight: 300), +// child: Card( +// elevation: 4, +// child: ListView.builder( +// shrinkWrap: true, +// itemCount: _searchResults.length, +// itemBuilder: (context, index) { +// final location = _searchResults[index]; +// return ListTile( +// dense: true, +// leading: const Icon(Icons.location_on, color: Colors.blue, size: 20), +// title: Text( +// location.placeName ?? location.address, +// style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), +// ), +// subtitle: Text( +// location.address, +// style: TextStyle(fontSize: 12, color: Colors.grey[600]), +// ), +// onTap: () => _selectAddress(location), +// ); +// }, +// ), +// ), +// ), +// ], +// ); +// } +// } \ No newline at end of file diff --git a/lib/features/quiz/models/quiz_models.dart b/lib/features/quiz/models/quiz_models.dart new file mode 100644 index 0000000..501e925 --- /dev/null +++ b/lib/features/quiz/models/quiz_models.dart @@ -0,0 +1,116 @@ +// 1. 카테고리 목록 (GET /categories) +class QuizCategory { + final int categoryId; + final String name; + + QuizCategory({required this.categoryId, required this.name}); + + factory QuizCategory.fromJson(Map json) { + return QuizCategory( + categoryId: json['categoryId'], + name: json['name'], + ); + } +} + +// 2. 퀴즈 목록 (GET /{categoryId}/list) +class QuizSummary { + final int quizId; + final String question; + final int level; + final int point; + + QuizSummary({ + required this.quizId, + required this.question, + required this.level, + required this.point, + }); + + factory QuizSummary.fromJson(Map json) { + return QuizSummary( + quizId: json['quizId'], + question: json['question'], + level: json['level'], + point: json['point'], + ); + } +} + +// 3. 퀴즈 상세 (GET /{quizId}) +class QuizDetail { + final int quizId; + final String question; + final List options; + final int point; + + QuizDetail({ + required this.quizId, + required this.question, + required this.options, + required this.point, + }); + + factory QuizDetail.fromJson(Map json) { + List optionsList = json['options']; + List parsedOptions = optionsList.map((opt) => QuizOption.fromJson(opt)).toList(); + + return QuizDetail( + quizId: json['quizId'], + question: json['question'], + options: parsedOptions, + point: json['point'], + ); + } +} + +// 3a. 퀴즈 옵션 (QuizDetail에 포함됨) +class QuizOption { + final int optionId; + final String optionText; + + QuizOption({required this.optionId, required this.optionText}); + + factory QuizOption.fromJson(Map json) { + return QuizOption( + optionId: json['optionId'], + optionText: json['optionText'], + ); + } +} + +// 4. 정답 제출 결과 (POST /{quizId}/submit) +class SubmitResult { + final bool isCorrect; + final int gainExp; + + SubmitResult({required this.isCorrect, required this.gainExp}); + + factory SubmitResult.fromJson(Map json) { + return SubmitResult( + isCorrect: json['isCorrect'], + gainExp: json['gainExp'], + ); + } +} + +// 5. 진행률 (GET /progress) +class QuizProgress { + final int categoryId; + final String categoryName; + final double progress; + + QuizProgress({ + required this.categoryId, + required this.categoryName, + required this.progress, + }); + + factory QuizProgress.fromJson(Map json) { + return QuizProgress( + categoryId: json['categoryId'], + categoryName: json['categoryName'], + progress: (json['progress'] as num).toDouble(), + ); + } +} \ No newline at end of file diff --git a/lib/features/quiz/screens/quiz_category_screen.dart b/lib/features/quiz/screens/quiz_category_screen.dart new file mode 100644 index 0000000..52ecb95 --- /dev/null +++ b/lib/features/quiz/screens/quiz_category_screen.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; +import 'package:guardpayfront/core/services/storage.dart'; + +import '../models/quiz_models.dart'; +import '../services/quiz_service.dart'; + +class QuizCategoryScreen extends StatefulWidget { + const QuizCategoryScreen({super.key}); + + @override + State createState() => _QuizCategoryScreenState(); +} + +class _QuizCategoryScreenState extends State { + final QuizService _quizService = QuizService(); + final _storage = AppStorage.storage; + + Future>? _categoriesFuture; + Future>? _progressFuture; + + String? _accessToken; + + @override + void initState() { + super.initState(); + // ❗️ 화면 시작 시 토큰을 읽고 데이터 로딩 시작 + _loadData(); + } + + Future _loadData() async { + final token = await _storage.read(key: 'accessToken'); + print("토큰 읽기 완료. 토큰: $token"); + + if (token == null) { + print("🚨 오류: 토큰이 null입니다."); + return; + } + + setState(() { + _accessToken = token; + _categoriesFuture = _quizService.getCategories(); + _progressFuture = _quizService.getProgress(_accessToken!); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF9F5EC), + appBar: AppBar( + backgroundColor: const Color(0xFFFDF8EE), + elevation: 0, + leading: Padding( + padding: const EdgeInsets.only(left: 8.0, top: 18.0), + child: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.black), + onPressed: () => Navigator.of(context).pop(), + ), + ), + ), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + //const SizedBox(height: 5), + const Text( + "학습 분야를 선택하세요", + style: TextStyle( + fontSize: 27, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 30), + + Expanded( + child: FutureBuilder>( + future: Future.wait([ + _categoriesFuture ?? Future.value([]), + _progressFuture ?? Future.value([]) + ]), + builder: (context, snapshot) { + if (_categoriesFuture == null || _progressFuture == null) { + // _loadData가 아직 토큰을 못 가져온 경우 + return const Center(); + } + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center(child: Text("오류: ${snapshot.error}")); + } + if (!snapshot.hasData || + snapshot.data == null || + snapshot.data!.isEmpty) { + return const Center(child: Text("데이터가 없습니다.")); + } + + final categories = snapshot.data![0] as List; + final progresses = snapshot.data![1] as List; + print("✅ [CategoryScreen] API가 반환한 progresses: $progresses"); + + if (categories.isEmpty) { + return const Center(child: Text("퀴즈 카테고리가 없습니다.")); + } + + final progressMap = { + for (var p in progresses) p.categoryId: p.progress + }; + + // 카테고리 목록을 동적으로 빌드 + return ListView( + children: categories.map((category) { + // API에서 받은 progress 값 (예: 75.0)을 사용 + double currentProgress = + progressMap[category.categoryId] ?? 0.0; + + return buildCategoryCard( + context, + category.name, // 서버에서 받은 이름 + currentProgress, // 서버에서 받은 진행률 (예: 75.0) + category.categoryId, // 서버에서 받은 ID + ); + }).toList(), + ); + }, + ), + ), + ], + ), + ), + ); + } + + Widget buildCategoryCard( + BuildContext context, String title, double progress, int categoryId) { + + // ✅ 4. 수정: API에서 받은 0-100 범위의 progress 값을 0.0-1.0 범위로 변환 + // (progress / 100.0)을 사용 + // 혹시 progress가 1.0을 초과하는 경우를 대비해 1.0으로 제한 + final double progressRatio = (progress / 100.0).clamp(0.0, 1.0); + const double barWidth = 300.0; // 프로그레스 바의 최대 너비 + + return InkWell( + // 탭 기능 추가 + onTap: () { + Navigator.pushNamed( + context, + '/quiz', + arguments: { + 'categoryId': categoryId, + 'categoryName': title, + 'progress': progress, + }, + ); + }, + borderRadius: BorderRadius.circular(22), + child: Container( + width: 347, + height: 140, + margin: const EdgeInsets.symmetric(vertical: 15), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(37), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.3), + blurRadius: 5, + offset: const Offset(0, 3), + ) + ], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 25, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 22), + Stack( + alignment: Alignment.centerLeft, + clipBehavior: Clip.none, + children: [ + // progress bar background + Container( + height: 10, + width: barWidth, // 최대 너비 고정 + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(10), + ), + ), + // progress bar foreground + Container( + height: 10, + // 4. 수정: progressRatio 사용 + width: barWidth * progressRatio, + decoration: BoxDecoration( + color: const Color(0xFF3ED597), + borderRadius: BorderRadius.circular(10), + ), + ), + // check icon + Positioned( + // 4. 수정: progressRatio 사용 + left: (barWidth * progressRatio) - 14, // (아이콘 너비의 절반) + child: Container( + width: 26, + height: 26, + decoration: const BoxDecoration( + color: Color(0xFFCCFFE8), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check, + color: Color(0xFF3ED597), + size: 18, + ), + ), + ), + ], + ) + ], + ), + ), + ); + } +} diff --git a/lib/features/quiz/screens/quiz_screen.dart b/lib/features/quiz/screens/quiz_screen.dart new file mode 100644 index 0000000..159a799 --- /dev/null +++ b/lib/features/quiz/screens/quiz_screen.dart @@ -0,0 +1,549 @@ +import 'package:flutter/material.dart'; + +import '../../../core/services/storage.dart'; +import '../models/quiz_models.dart'; +import '../services/quiz_service.dart'; +import '../widgets/quiz_dialog.dart'; + +class QuizScreen extends StatefulWidget { + const QuizScreen({super.key}); + + @override + State createState() => _QuizScreenState(); +} + +class _QuizScreenState extends State { + // --- Services & Storage --- + final QuizService _quizService = QuizService(); + final _storage = AppStorage.storage; + String? _accessToken; + + // --- Navigation Arguments --- + late int _categoryId; + String _categoryName = "퀴즈"; // 기본값 + double _progress = 0.0; // 기본값 + + // --- State Variables --- + bool _isLoadingList = true; // 퀴즈 '목록' 로딩 중 + bool _isLoadingDetail = false; // 퀴즈 '상세' 로딩 중 + bool _isSubmitting = false; // 정답 '제출' 중 + bool _isAnswered = false; // 현재 퀴즈를 푼 상태인지 + + List _quizSummaries = []; // 퀴즈 목록 (플레이리스트) + int _currentQuizIndex = 0; // 현재 퀴즈 인덱스 + QuizDetail? _currentQuizDetail; // 현재 표시할 퀴즈의 상세 정보 + SubmitResult? _submitResult; // 정답 제출 결과 + int? _selectedOptionId; // 사용자가 선택한 선택지 ID + int _totalPointsEarned = 0; // 총 점수 + bool _isQuizCompleted = false; + + String get _progressKey => 'quiz_progress_cat_${_categoryId}'; + String get _pointsKey => 'quiz_points_cat_${_categoryId}'; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // 네비게이션 인자 받기 (최초 1회) + final args = + ModalRoute.of(context)!.settings.arguments as Map; + _categoryId = args['categoryId']; + _categoryName = args['categoryName']; + _progress = args['progress']; + + if (_accessToken == null) { + _loadData(); + } + } + + // 화면을 나갈 때 현재 퀴즈 진행 상황을 로컬 저장소에 저장 + @override + void dispose() { + if (!_isLoadingList && _quizSummaries.isNotEmpty && !_isQuizCompleted) { + // 퀴즈가 완료되지 않은 상태(목록 길이보다 인덱스가 작을 때)에만 저장 + if (_currentQuizIndex < _quizSummaries.length) { + _storage.write(key: _progressKey, value: _currentQuizIndex.toString()); + _storage.write(key: _pointsKey, value: _totalPointsEarned.toString()); + print("💾 퀴즈 진행 상황 저장됨: Index $_currentQuizIndex, Points $_totalPointsEarned"); + } + } + super.dispose(); + } + + // 데이터 로딩 (토큰 -> 퀴즈 목록 -> 퀴즈 1 상세) + Future _loadData() async { + // 1. 토큰 읽기 + final token = await _storage.read(key: 'accessToken'); + if (token == null) { + print("🚨 QuizScreen: 토큰이 없습니다. 이전 화면으로 돌아갑니다."); + if (mounted) Navigator.pop(context); + return; + } + _accessToken = token; + + // 2. 퀴즈 목록 불러오기 + try { + setState(() { + _isLoadingList = true; + }); + _quizSummaries = await _quizService.getQuizzesForCategory(_categoryId); + setState(() { + _isLoadingList = false; + }); + + // 3. 퀴즈 목록 성공 시 + if (_quizSummaries.isNotEmpty) { + // 저장된 진행 상황 로드 + final savedIndexStr = await _storage.read(key: _progressKey); + final savedPointsStr = await _storage.read(key: _pointsKey); + + int startIndex = 0; + int startPoints = 0; + + // 저장된 인덱스 복원, 유효성 검사 + if (savedIndexStr != null && int.tryParse(savedIndexStr) != null) { + int loadedIndex = int.parse(savedIndexStr); + if (loadedIndex < _quizSummaries.length) { + startIndex = loadedIndex; + } + } + + // 저장된 점수 복원 + if (savedPointsStr != null && int.tryParse(savedPointsStr) != null) { + startPoints = int.parse(savedPointsStr); + } + + // 상태 업데이트 및 퀴즈 로드 시작 + setState(() { + _currentQuizIndex = startIndex; + _totalPointsEarned = startPoints; + }); + + _loadQuizDetail(startIndex); + } else { + print("🤔 이 카테고리에 퀴즈가 없습니다."); + } + } catch (e) { + print("🚨 퀴즈 목록 로딩 실패: $e"); + setState(() { + _isLoadingList = false; + }); + } + } + + // 특정 인덱스의 퀴즈 상세 정보 불러오기 + Future _loadQuizDetail(int index) async { + if (index < 0 || index >= _quizSummaries.length) return; + + setState(() { + _currentQuizIndex = index; + _isLoadingDetail = true; + _isAnswered = false; + _selectedOptionId = null; + _submitResult = null; + }); + + try { + final quizId = _quizSummaries[index].quizId; + _currentQuizDetail = await _quizService.getQuizDetail(quizId); + } catch (e) { + print("🚨 퀴즈 상세 로딩 실패: $e"); + } finally { + if (mounted) { + setState(() { + _isLoadingDetail = false; + }); + } + } + } + + // 정답 제출 + Future _submitAnswer() async { + if (_selectedOptionId == null || _accessToken == null || _isAnswered) return; + + setState(() { + _isSubmitting = true; + }); + + try { + _submitResult = await _quizService.submitAnswer( + _currentQuizDetail!.quizId, + _selectedOptionId!, + _accessToken!, + ); + setState(() { + _isAnswered = true; + + // 정답일 경우에만 총점에 현재 퀴즈 점수 누적 + if (_submitResult!.isCorrect) { + _totalPointsEarned += _currentQuizDetail!.point; + } + }); + } catch (e) { + print("🚨 정답 제출 실패: $e"); + } finally { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + } + } + } + + // 다음 문제로 이동하거나 완료 시 팝업창 띄우기 + void _nextQuiz() { + if (_currentQuizIndex + 1 < _quizSummaries.length) { + // 다음 퀴즈 로드 + _loadQuizDetail(_currentQuizIndex + 1); + } else { + _isQuizCompleted = true; + + // 모든 퀴즈 완료 + showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + // ❗️ 새로 만든 QuizCompletionDialog 사용 + return QuizDialog( + title: "학습 완료!", // 기존 제목 + content: "총 ${_totalPointsEarned}p를 획득했습니다.", // 기존 내용 + onConfirm: () async { + print("DEBUG_KEY: Deleting progress key: $_progressKey"); + + await _storage.delete(key: _progressKey); + await _storage.delete(key: _pointsKey); + print("🗑️ 퀴즈 완료: 로컬 저장된 진행 상황이 리셋되었습니다."); + + final checkIndex = await _storage.read(key: _progressKey); + print("DEBUG_CHECK: Index after delete: $checkIndex"); + + Navigator.of(context).pop(); // 다이얼로그 닫기 + Navigator.of(context).pop(); // 퀴즈 스크린 닫기 + }, + ); + }, + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFFDF8EE), + appBar: AppBar( + backgroundColor: const Color(0xFFFDF8EE), + elevation: 0, + leading: IconButton( + padding: const EdgeInsets.only(left: 8.0, top: 18.0), + icon: const Icon(Icons.arrow_back_ios, color: Colors.black), + onPressed: () => Navigator.of(context).pop(), + ), + ), + body: _buildBody(), + ); + } + + // --- Body 빌더 --- + Widget _buildBody() { + if (_isLoadingList) { + return const Center(); + } + if (_quizSummaries.isEmpty) { + return const Center(child: Text("이 카테고리에 퀴즈가 없습니다.")); + } + if (_isLoadingDetail || _currentQuizDetail == null) { + return const Center(child: CircularProgressIndicator()); + } + + // 퀴즈 UI 빌드 + final quiz = _currentQuizDetail!; + final double progressRatio = (_progress / 100.0).clamp(0.0, 1.0); + + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 35), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 학습 진행률 (동적) + const SizedBox(height: 25), + Center( // ⬅️ Center 위젯으로 감싸서 수평 중앙 정렬 + child: Row( + mainAxisAlignment: MainAxisAlignment.center, // ⬅️ Row 내부 요소도 중앙 정렬 (필요하다면) + children: [ + Text( + "$_categoryName 학습 진행률", + style: const TextStyle( + fontSize: 22, // ⬅️ 22로 변경된 폰트 크기 사용 + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 6), + Text( + "${_progress.toStringAsFixed(0)}%", + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ) + ], + ), + ), + const SizedBox(height: 15), + + // Progress Bar (동적) - 수정된 로직 + LayoutBuilder( + builder: (context, constraints) { + return Container( + // Stack을 사용해 회색 바와 초록색 바를 겹칩니다. + child: Stack( + children: [ + // 1. 회색 배경 바 (항상 전체 너비) + Container( + width: constraints.maxWidth, + height: 12, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(12), + ), + ), + // 2. 초록색 진행률 바 (너비가 0%일 수 있음) + Container( + width: constraints.maxWidth * progressRatio, + height: 12, + decoration: BoxDecoration( + color: const Color(0xFF3ED597), + borderRadius: BorderRadius.circular(12), + ), + ), + ], + ), + ); + }, + ), + + const SizedBox(height: 50), + + // Quiz 카드 (동적) + Container( + padding: + const EdgeInsets.symmetric(vertical: 30, horizontal: 20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 6, + offset: const Offset(0, 5), + ) + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 문제 번호 + 점수 (동적) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(top: 7.0, left: 13.0), + child: Text( + 'Quiz ${_currentQuizIndex + 1}.', + style: const TextStyle( + fontSize: 27, + fontWeight: FontWeight.bold) + , + ), + ), + + Container( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 10), + decoration: BoxDecoration( + color: Colors.green.shade300, + borderRadius: BorderRadius.circular(15), + ), + child: Text( + "${quiz.point}p", // ⬅️ 동적 + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), + ), + ], + ), + + const SizedBox(height: 20), + + Padding( + padding: const EdgeInsets.only(top: 28.0), + child: Text( + quiz.question, + textAlign: TextAlign.justify, + style: const TextStyle( + fontSize: 18, + color: Colors.black87, + height: 1.5, + ), + ), + ), + const SizedBox(height: 30), + + // 선택지 리스트 (동적) + Column( + // ❗️ quiz.options를 사용해 동적으로 생성 + children: quiz.options.asMap().entries.map((entry) { + int index = entry.key; + QuizOption option = entry.value; + return answerButton( + option: option, + index: index, + selectedOptionId: _selectedOptionId, + isAnswered: _isAnswered, + submitResult: _submitResult, + onTap: () { + // 정답을 제출하지 않은 상태에서만 선택 가능 + if (!_isAnswered) { + setState(() { + _selectedOptionId = option.optionId; + }); + } + }, + ); + }).toList(), + ), + + const SizedBox(height: 18), + + // '정답 제출' 또는 '다음 문제로' 버튼 (동적) + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: _getButtonColor(), // ⬅️ 동적 + elevation: 0, + padding: const EdgeInsets.symmetric( + vertical: 14, horizontal: 0), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + // ❗️ 상태에 따라 다른 함수 호출 + onPressed: _isSubmitting + ? null // 제출 중 비활성화 + : (_isAnswered ? _nextQuiz : _submitAnswer), + child: _isSubmitting + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Text( + _isAnswered ? "다음 문제로" : "정답 제출", // ⬅️ 동적 + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + // ❗️ 선택해야 버튼 활성화 + color: _selectedOptionId == null && !_isAnswered + ? Colors.white + : Colors.white, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 40), + ], + ), + ), + ); + } + + // --- Helper Widgets & Methods --- + + // 버튼 색상 결정 + Color _getButtonColor() { + if (!_isAnswered) { + // 정답 제출 전 + return _selectedOptionId != null + ? Colors.green // 선택함 (초록) + : Colors.grey.shade300; // 선택 안 함 (회색) + } else { + // 정답 제출 후 + return _submitResult!.isCorrect + ? Colors.green // 정답 (초록) + : Colors.red.shade400; // 오답 (빨강) + } + } + + // 선택지 버튼 위젯 (상태에 따라 UI 변경) + Widget answerButton({ + required QuizOption option, // ❗️ QuizOption 객체를 받음 + required int? selectedOptionId, + required bool isAnswered, + required SubmitResult? submitResult, + required VoidCallback onTap, + required int index, + }) { + bool isSelected = selectedOptionId == option.optionId; + + // 테두리 색상 결정 + Color borderColor = Colors.grey.shade300; + Color? iconColor; + + if (isAnswered && isSelected) { + // 정답 제출 후, 내가 선택한 옵션 + if (submitResult!.isCorrect) { + borderColor = Colors.green; // 정답 (초록) + iconColor = Colors.green; + } else { + borderColor = Colors.red.shade400; // 오답 (빨강) + iconColor = Colors.red.shade400; + } + } else if (!isAnswered && isSelected) { + // 정답 제출 전, 내가 선택한 옵션 + borderColor = Colors.green; + } + + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(bottom: 14), + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: borderColor, + width: isSelected ? 2 : 1, + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + "${index + 1}. ${option.optionText}", // ⬅️ 동적 + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: isSelected ? Colors.black : Colors.grey.shade700, + ), + ), + ), + if (isAnswered && isSelected) // 정답/오답 아이콘 + Icon( + submitResult!.isCorrect ? Icons.check_circle : Icons.cancel, + color: iconColor, + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/quiz/services/quiz_service.dart b/lib/features/quiz/services/quiz_service.dart new file mode 100644 index 0000000..8b9bd65 --- /dev/null +++ b/lib/features/quiz/services/quiz_service.dart @@ -0,0 +1,111 @@ +import 'package:http/http.dart' as http; +import 'dart:convert'; + +import '../models/quiz_models.dart'; + +class QuizService { + final String _baseUrl = "http://10.0.2.2:8080/api/quiz"; + + // HTTP 응답 공통 처리 + dynamic _handleResponse(http.Response response) { + if (response.statusCode == 200) { + final body = json.decode(utf8.decode(response.bodyBytes)); + + if (body['status'] == 200) { + return body['data']; // 성공 시 'data' 객체 반환 + } else { + throw Exception('API Error (${body['status']}): ${body['message']}'); + } + } else { + throw Exception('HTTP Error: ${response.statusCode}'); + } + } + + // 인증 헤더 생성 + Map _authHeaders(String accessToken) { + return { + 'Content-Type': 'application/json; charset=UTF-8', + 'Authorization': 'Bearer $accessToken', // ❗️ JWT 토큰 전송 + }; + } + + // 1. 카테고리 목록 조회 (GET /api/quiz/categories) + Future> getCategories() async { + try { + final response = await http.get(Uri.parse('$_baseUrl/categories')); + final data = _handleResponse(response); + + // 'categories' 키 아래의 동적 리스트를 QuizCategory 객체 리스트로 매핑 + List categoryList = data['categories']; + return categoryList.map((json) => QuizCategory.fromJson(json)).toList(); + } catch (e) { + print('getCategories 오류: $e'); + rethrow; + } + } + + // 2. 카테고리별 퀴즈 목록 조회 (GET /api/quiz/{categoryId}/list) + Future> getQuizzesForCategory(int categoryId) async { + try { + final response = await http.get(Uri.parse('$_baseUrl/$categoryId/list')); + final data = _handleResponse(response); + + // 'quizzes' 키 아래의 동적 리스트를 QuizSummary 객체 리스트로 매핑 + List quizList = data['quizzes']; + return quizList.map((json) => QuizSummary.fromJson(json)).toList(); + } catch (e) { + print('getQuizzesForCategory 오류: $e'); + rethrow; + } + } + + // 3. 퀴즈 상세 조회 (GET /api/quiz/{quizId}) + Future getQuizDetail(int quizId) async { + try { + final response = await http.get(Uri.parse('$_baseUrl/$quizId')); + final data = _handleResponse(response); + + return QuizDetail.fromJson(data); + + } catch (e) { + print('getQuizDetail 오류: $e'); + rethrow; + } + } + + // 4. 퀴즈 정답 제출 (POST /api/quiz/{quizId}/submit) + Future submitAnswer(int quizId, int selectedOptionId, String accessToken) async { + try { + final response = await http.post( + Uri.parse('$_baseUrl/$quizId/submit'), + headers: _authHeaders(accessToken), + body: json.encode({ + 'selectedOptionId': selectedOptionId, + }), + ); + + final data = _handleResponse(response); + return SubmitResult.fromJson(data); + } catch (e) { + print('submitAnswer 오류: $e'); + rethrow; + } + } + + // 5. 퀴즈 진행률 조회 (GET /api/quiz/progress) + Future> getProgress(String accessToken) async { + try { + final response = await http.get( + Uri.parse('$_baseUrl/progress'), + headers: _authHeaders(accessToken), + ); + + final data = _handleResponse(response); + List progressList = data['progress']; + return progressList.map((json) => QuizProgress.fromJson(json)).toList(); + } catch (e) { + print('getProgress 오류: $e'); + rethrow; + } + } +} \ No newline at end of file diff --git a/lib/features/quiz/widgets/quiz_dialog.dart b/lib/features/quiz/widgets/quiz_dialog.dart new file mode 100644 index 0000000..b0cb807 --- /dev/null +++ b/lib/features/quiz/widgets/quiz_dialog.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; + +class QuizDialog extends StatelessWidget { + final VoidCallback onConfirm; + final String title; + final String content; + + const QuizDialog({ + super.key, + required this.onConfirm, + required this.title, + required this.content, + }); + + @override + Widget build(BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + elevation: 0, + contentPadding: EdgeInsets.zero, + + content: Container( + width: MediaQuery.of(context).size.width * 0.73, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + ), + + child: SizedBox( + height: 220.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 50), + + // 1. 다이얼로그 제목 + Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 27, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + const SizedBox(height: 10), + + // 2. 내용 텍스트 + Text( + content, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 22, + color: Colors.grey.shade800, + ), + ), + + const Spacer(), + + // 3. 메인으로 버튼 + GestureDetector( + onTap: onConfirm, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 13), + decoration: BoxDecoration( + color: Colors.grey.shade100, // 버튼 영역 배경색 + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(20), + bottomRight: Radius.circular(20), + ), + ), + alignment: Alignment.center, + child: const Text( + '확인', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/shop/models/shop_models.dart b/lib/features/shop/models/shop_models.dart new file mode 100644 index 0000000..83321d4 --- /dev/null +++ b/lib/features/shop/models/shop_models.dart @@ -0,0 +1,124 @@ +class ProductModel { + final int id; + final String brand; + final String name; + final String category; + final int pricePoint; + final String thumbnail; + + ProductModel({ + required this.id, + required this.brand, + required this.name, + required this.category, + required this.pricePoint, + required this.thumbnail, + }); + + factory ProductModel.fromJson(Map json) { + return ProductModel( + id: (json['id'] as num).toInt(), + brand: json['brand'] as String, + name: json['name'] as String, + category: json['category'] as String, + pricePoint: (json['pricePoint'] as num).toInt(), + thumbnail: json['thumbnail'] as String, + ); + } +} + +// 1. ProfileModel (상세 모델) +class ProfileModel { + final int points; + final String? profileImageUrl; + final String? grade; + final String? status; + final String? provider; + + ProfileModel({ + required this.points, + this.profileImageUrl, + this.grade, + this.status, + this.provider, + }); + + factory ProfileModel.fromJson(Map json) { + return ProfileModel( + points: (json['points'] as num).toInt(), + profileImageUrl: json['profileImageUrl'] as String?, + grade: json['grade'] as String?, + status: json['status'] as String?, + provider: json['provider'] as String?, + ); + } +} + +// 2. ExchangeResponseModel (상품 교환 응답) +class ExchangeResponseModel { + final int productId; + final String brand; + final String name; + final String thumbnail; + final String couponCode; + final String validUntil; + + ExchangeResponseModel({ + required this.productId, + required this.brand, + required this.name, + required this.thumbnail, + required this.couponCode, + required this.validUntil, + }); + + factory ExchangeResponseModel.fromJson(Map json) { + return ExchangeResponseModel( + productId: (json['productId'] as num).toInt(), + brand: json['brand'] as String, + name: json['name'] as String, + thumbnail: json['thumbnail'] as String, + couponCode: json['couponCode'] as String, + validUntil: json['validUntil'] as String, + ); + } +} + +// 3. ExchangeHistoryItemModel (교환 내역 조회 항목) +class ExchangeHistoryItemModel { + final int exchangeId; + final String productName; + final String brandName; + final int pointsUsed; + final String status; + final String exchangedAt; + final String? couponCode; + final String? validUntil; + final String? thumbnail; + + ExchangeHistoryItemModel({ + required this.exchangeId, + required this.productName, + required this.brandName, + required this.pointsUsed, + required this.status, + required this.exchangedAt, + this.couponCode, + this.validUntil, + this.thumbnail, + }); + + factory ExchangeHistoryItemModel.fromJson(Map json) { + return ExchangeHistoryItemModel( + exchangeId: (json['exchangeId'] as num).toInt(), + productName: json['productName'] as String? ?? '상품명 정보 없음', + brandName: json['brandName'] as String? ?? '브랜드 정보 없음', + pointsUsed: (json['pointsUsed'] as num).toInt(), + status: json['status'] as String, + exchangedAt: json['exchangedAt'] as String, + couponCode: json['couponCode'] as String?, + validUntil: json['validUntil'] as String?, + thumbnail: json['thumbnail'] as String?, + ); + } +} \ No newline at end of file diff --git a/lib/features/shop/screens/coupon_screen.dart b/lib/features/shop/screens/coupon_screen.dart new file mode 100644 index 0000000..494cc5b --- /dev/null +++ b/lib/features/shop/screens/coupon_screen.dart @@ -0,0 +1,197 @@ +import 'package:barcode_widget/barcode_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../models/shop_models.dart'; + +class CouponScreen extends StatelessWidget { + final ExchangeHistoryItemModel coupon; + static const String _BASE_URL = 'http://10.0.2.2:8080'; + + CouponScreen({super.key, required this.coupon}); + + // YYYY년 MM월 DD일 형식으로 날짜를 포맷팅하는 유틸리티 함수 + String _formatDate(String? isoDateString) { + if (isoDateString == null) return "정보 없음"; + try { + // YYYY-MM-DDTHH:mm:ss.SSSZ 형식일 경우 T 이전까지만 파싱 + final datePart = isoDateString.split('T')[0]; + final parts = datePart.split('-'); + if (parts.length == 3) { + return '${parts[0]}년 ${parts[1]}월 ${parts[2]}일'; + } + return isoDateString; + } catch (e) { + return "날짜 오류"; + } + } + + String _generateMockOrderNumber(int exchangeId) { + return (100000000 + exchangeId * 13 % 900000000).toString().substring(0, 9); + } + + // 이미지 URL 조합 함수 추가 + String _buildImageUrl(String? thumbnail) { + if (thumbnail == null) { + return 'https://placehold.co/150x150/eeeeee/333333?text=NO+IMAGE'; + } + final relativePath = thumbnail.replaceFirst('.', ''); + final fullUrl = _BASE_URL + relativePath; + + return Uri.encodeFull(fullUrl); + } + + + @override + Widget build(BuildContext context) { + // 쿠폰 정보 추출 + + // 상품명을 분리하지 않고, 서버에서 받은 정확한 brandName을 사용합니다. + final brandDisplay = coupon.brandName.isNotEmpty + ? coupon.brandName + : coupon.productName.split(' - ')[0]; // brandName이 비어있다면, 기존 로직으로 분리 시도 + + final productName = coupon.productName; // 상품명은 그대로 사용 + + // couponCode가 null이면 임의의 바코드 값을 사용 + final barcodeValue = coupon.couponCode ?? '614141999996'; + + // validUntil이 null이면 임시 유효 기간을 사용 + final validUntilDate = _formatDate(coupon.validUntil ?? '2025-12-31T00:00:00Z'); + + // 이미지 URL 생성 + final imageUrl = _buildImageUrl(coupon.thumbnail); + + + return Scaffold( + backgroundColor: const Color(0xfff7f2e9), // 배경색 + appBar: AppBar( + backgroundColor: const Color(0xfff7f2e9), + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.black), + onPressed: () => Navigator.pop(context), + ), + ), + body: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 50), + width: 360, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 5), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 1. 상품 이미지 + Container( + height: 150, + width: 150, + margin: const EdgeInsets.only(bottom: 20), + child: Image.network( + imageUrl, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return Center( + child: Text( + brandDisplay, + style: const TextStyle(fontSize: 30, color: Colors.grey) + ) + ); + }, + ), + ), + const SizedBox(height: 20), + + // 2. 브랜드 및 상품명 + Text( + brandDisplay, + style: const TextStyle(fontSize: 17, color: Colors.grey), + ), + Text( + productName, + style: const TextStyle(fontSize: 25, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 45), + + // 3. 바코드 + BarcodeWidget( + barcode: Barcode.code128(), + data: barcodeValue, + width: double.infinity, + height: 80, + style: const TextStyle(letterSpacing: 2), + ), + const SizedBox(height: 35), + + // 4. 상세 정보 표 + _buildInfoRow('교환처', brandDisplay), + _buildInfoRow('유효 기간', validUntilDate), + _buildInfoRow('주문 번호', _generateMockOrderNumber(coupon.exchangeId)), + + const SizedBox(height: 30), + + // 5. 사용 버튼 + ElevatedButton( + onPressed: () { + // 쿠폰 코드 복사 기능 + Clipboard.setData(ClipboardData(text: barcodeValue)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('쿠폰 코드가 클립보드에 복사되었습니다!')), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(50), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + child: const Text('쿠폰 코드 복사', style: TextStyle(fontSize: 18)), + ), + ], + ), + ), + ), + ), + ); + } + + // 상세 정보 표 Row 위젯 + Widget _buildInfoRow(String title, String value) { + return Column( + children: [ + Divider(color: Colors.grey[200], height: 1), + Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: Row( + children: [ + SizedBox( + width: 80, + child: Text( + title, + style: const TextStyle(fontSize: 15, color: Colors.grey), + ), + ), + Expanded( + child: Text( + value, + textAlign: TextAlign.right, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + ), + ], + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/features/shop/screens/my_coupon_screen.dart b/lib/features/shop/screens/my_coupon_screen.dart new file mode 100644 index 0000000..ddcf058 --- /dev/null +++ b/lib/features/shop/screens/my_coupon_screen.dart @@ -0,0 +1,334 @@ +import 'package:flutter/material.dart'; +import 'package:guardpayfront/core/services/storage.dart'; +import '../services/shop_service.dart'; +import '../models/shop_models.dart'; +import 'coupon_screen.dart'; + +class MyCouponScreen extends StatefulWidget { + const MyCouponScreen({super.key}); + + @override + State createState() => _MyCouponScreenState(); +} + +class _MyCouponScreenState extends State { + final ShopService _shopService = new ShopService(); + final _storage = AppStorage.storage; + + List _history = []; + bool _isLoading = true; + String _errorMessage = ''; + String? _accessToken; + + static const String _BASE_URL = 'http://10.0.2.2:8080'; + + String _calculateDDay(String exchangedAt, String status, String? validUntil) { + if (status == 'USED') { + return '사용 완료'; + } + if (status != 'USEABLE' || validUntil == null) { + return '만료'; + } + + try { + // 1. 유효 기간 파싱: 서버에서 받은 전체 문자열을 DateTime으로 파싱 + final expiryDateTime = DateTime.parse(validUntil); + + // 2. 날짜만 추출 (시간 정보를 00:00:00로 설정하여 UTC 기준으로 통일) + // .toUtc()를 통해 시간대 영향을 제거하고, 날짜만 비교하도록 함 + final expiryDateOnly = DateTime.utc(expiryDateTime.year, expiryDateTime.month, expiryDateTime.day); + + // 3. 현재 날짜만 추출 (마찬가지로 UTC 기준 00:00:00로 통일) + final now = DateTime.now().toUtc(); // 현재 시간을 UTC로 변환 + final todayDateOnly = DateTime.utc(now.year, now.month, now.day); // UTC 기준으로 날짜만 추출 + + // 4. 날짜 차이 계산 + final remainingDays = expiryDateOnly.difference(todayDateOnly).inDays; + + if (remainingDays < 0) return '만료'; + + // 유효 기간이 오늘까지라면 D-0으로 표시 + return 'D-$remainingDays'; + + } catch (e) { + print('날짜 파싱 오류: $e, validUntil: $validUntil'); + return '만료 (오류)'; // 파싱 오류 시 만료 처리 + } + } + + void _navigateToDetail(ExchangeHistoryItemModel coupon) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => CouponScreen(coupon: coupon), + ), + ); + } + + @override + void initState() { + super.initState(); + _fetchHistory(); + } + + Future _fetchHistory() async { + setState(() { + _isLoading = true; + _errorMessage = ''; + }); + + try { + final token = await _storage.read(key: 'accessToken'); + if (token == null) { + setState(() { + _errorMessage = '로그인 정보가 없습니다.'; + _isLoading = false; + }); + return; + } + _accessToken = token; + + final fetchedHistory = await _shopService.getExchangeHistory(_accessToken!); + + if (mounted) { + setState(() { + + _history = fetchedHistory.where((item) { + if (item.status != 'USEABLE' || item.validUntil == null) { + return false; + } + + try { + // D-Day 계산 로직과 동일하게 UTC 기반의 날짜만 비교 + final expiryDateTime = DateTime.parse(item.validUntil!); + final expiryDateOnly = DateTime.utc(expiryDateTime.year, expiryDateTime.month, expiryDateTime.day); + + final now = DateTime.now().toUtc(); + final todayDateOnly = DateTime.utc(now.year, now.month, now.day); + + // 유효기간이 오늘(today)보다 같거나 미래여야 함 + return expiryDateOnly.isAfter(todayDateOnly) || expiryDateOnly.isAtSameMomentAs(todayDateOnly); + + } catch (e) { + print('필터링 중 날짜 파싱 오류: $e'); + return false; + } + + }).toList(); + + // 만료일에 따라 정렬 (남은 일수가 적은 순) + _history.sort((a, b) { + final dDayA = int.tryParse(_calculateDDay(a.exchangedAt, a.status, a.validUntil).replaceFirst('D-', '')) ?? 999; + final dDayB = int.tryParse(_calculateDDay(b.exchangedAt, b.status, b.validUntil).replaceFirst('D-', '')) ?? 999; + return dDayA.compareTo(dDayB); + }); + + + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + print('[log] >> [ERROR] 쿠폰 내역 조회 실패: ${e.toString()}'); + + final errorDetail = e.toString().contains(':') ? e.toString().split(':')[1].trim() : e.toString(); + if (errorDetail.contains('type \'Null\'')) { + _errorMessage = '내역 로드 실패: 서버 응답 데이터에 문제가 있습니다. (Null 값 오류)'; + } else { + _errorMessage = '내역 로드 실패: $errorDetail'; + } + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final availableCouponsCount = _history.length; + + return Scaffold( + backgroundColor: const Color(0xfff7f2e9), + appBar: AppBar( + backgroundColor: const Color(0xfff7f2e9), + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.black), + onPressed: () => Navigator.of(context).pop(), + ), + ), + + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // --- '내 쿠폰함' 제목 --- + const SizedBox(height: 8), + const Text( + "내 쿠폰함", + style: TextStyle( + fontSize: 30, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + const SizedBox(height: 16), + Text( + "사용 가능한 쿠폰이 $availableCouponsCount개 있습니다.", + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: Colors.grey,), + ), + const SizedBox(height: 20), + + Expanded( + child: _buildContent(), + ), + ], + ), + ), + ); + } + + Widget _buildContent() { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_errorMessage.isNotEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Text( + _errorMessage, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 18, color: Colors.redAccent), + ), + ), + ); + } + + if (_history.isEmpty) { + return const Center( + child: Text("사용 가능한 교환 내역이 없습니다.", style: TextStyle(fontSize: 18, color: Colors.black54)), + ); + } + + return GridView.builder( + itemCount: _history.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 0.78, + crossAxisSpacing: 16, + mainAxisSpacing: 20, + ), + itemBuilder: (context, index) { + final item = _history[index]; + return _buildHistoryItem(item); + }, + ); + } + + Widget _buildHistoryItem(ExchangeHistoryItemModel item) { + final dDay = _calculateDDay(item.exchangedAt, item.status, item.validUntil); + final productName = item.productName; + + final String brandDisplay = item.brandName.isEmpty ? productName : item.brandName; + + final String imageUrl = item.thumbnail != null && !item.thumbnail!.startsWith('http') + ? _BASE_URL + item.thumbnail!.replaceFirst('.', '') // './image' -> '/image'로 변환 후 결합 + : item.thumbnail ?? 'https://placehold.co/100x100/eeeeee/333333?text=NO+IMAGE'; + + return GestureDetector( + onTap: () => _navigateToDetail(item), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + spreadRadius: 2, + blurRadius: 5, + offset: const Offset(0, 3), + ), + ], + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 8, right: 8), + child: Align( + alignment: Alignment.topRight, // 오른쪽 정렬로 변경 + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: dDay.startsWith('D-') && int.tryParse(dDay.substring(2)) != null && int.parse(dDay.substring(2)) < 30 + ? Colors.red[100] // 30일 미만 + : const Color(0xffd9d9d9), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + dDay, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: dDay.startsWith('D-') && int.tryParse(dDay.substring(2)) != null && int.parse(dDay.substring(2)) < 30 + ? Colors.red[900] + : Colors.black, + ), + ), + ), + ), + ), + + // Image + Expanded( + child: Image.network( + imageUrl, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.redeem, size: 40, color: Colors.grey), + const SizedBox(height: 8), + Text( + brandDisplay, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.black87) + ) + ] + ) + ); + }, + ), + ), + + const SizedBox(height: 4), + + // Brand (작은 글씨) + Text( + brandDisplay, + style: const TextStyle( + fontSize: 12, + color: Colors.grey, + fontWeight: FontWeight.w500), + ), + + // Name (큰 글씨) + Padding( + padding: const EdgeInsets.only(bottom: 12, top: 2), + child: Text( + productName, + style: const TextStyle( + fontSize: 15, fontWeight: FontWeight.w600), + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/shop/screens/shop_screen.dart b/lib/features/shop/screens/shop_screen.dart new file mode 100644 index 0000000..6180848 --- /dev/null +++ b/lib/features/shop/screens/shop_screen.dart @@ -0,0 +1,387 @@ +import 'package:flutter/material.dart'; +import 'package:guardpayfront/core/services/storage.dart'; +import '../../auth/widgets/bottom_nav.dart'; +import '../services/shop_service.dart'; +import '../models/shop_models.dart'; +import '../widgets/purchase_dialog.dart'; + +const String kImageBaseUrl = 'http://10.0.2.2:8080'; + +class ShopScreen extends StatefulWidget { + const ShopScreen({super.key}); + + @override + State createState() => _ShopScreenState(); +} + +class _ShopScreenState extends State { + final ShopService _shopService = ShopService(); // 서비스 인스턴스 + final _storage = AppStorage.storage; + + String? _accessToken; + bool _isLoadingToken = true; + + List _allProducts = []; + List _filteredProducts = []; + bool _isLoading = true; + String _errorMessage = ''; + + int _userPoint = 0; + + final List _categories = ["카페/음료", "편의점", "디저트", "외식"]; + int _selectedIndex = 0; + + @override + void initState() { + super.initState(); + _initializeScreen(); + } + + Future _initializeScreen() async { + final token = await _storage.read(key: 'accessToken'); + + if (mounted) { + setState(() { + _accessToken = token; + _isLoadingToken = false; + }); + + if (_accessToken != null) { + _fetchData(); + } else { + setState(() { + _isLoading = false; + _errorMessage = "로그인이 필요합니다."; + }); + } + } + } + + Future _fetchData() async { + if (_accessToken == null) return; + + await Future.wait([ + _fetchProfile(), + _fetchProducts(), + ]); + } + + Future _fetchProfile() async { + if (_accessToken == null) return; // 토큰 체크 추가 + try { + final ProfileModel profile = await _shopService.getProfile(_accessToken!); + + if (mounted) { + setState(() { + _userPoint = profile.points; + }); + } + } catch (e) { + print('프로필 로드 실패: ${e.toString()}'); + } + } + + Future _fetchProducts() async { + if (_accessToken == null) return; + + setState(() { + _isLoading = true; + _errorMessage = ''; + }); + + try { + final List fetchedProducts = await _shopService.getProducts(); + + setState(() { + _allProducts = fetchedProducts; + _filterProductsByCategory(_selectedIndex); + _isLoading = false; + }); + } catch (e) { + setState(() { + _errorMessage = '상품 로드 실패: ${e.toString()}'; + _isLoading = false; + }); + } + } + + void _filterProductsByCategory(int index) { + final String selectedCategory = _categories[index]; + + _filteredProducts = _allProducts + .where((p) => p.category == selectedCategory) + .toList(); + } + + Future _exchangeProduct(int productId) async { + if (_accessToken == null) return; + + try { + final ExchangeResponseModel result = + await _shopService.exchangeProduct(productId, _accessToken!); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('상품 교환 성공! 쿠폰 코드: ${result.couponCode}')), + ); + _fetchData(); + + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('교환 실패: ${e.toString().contains(':') ? e.toString().split(':')[1].trim() : '알 수 없는 오류'}'), + backgroundColor: Colors.red, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoadingToken) { + return const Scaffold( + backgroundColor: Color(0xfffaf7ef), + body: Center(child: CircularProgressIndicator()), + ); + } + + if (_accessToken == null) { + return const Scaffold( + backgroundColor: Color(0xfffaf7ef), + body: Center( + child: Text( + "GuardPay 쇼핑을 이용하려면 로그인이 필요합니다.", + style: TextStyle(fontSize: 18, color: Colors.black54), + ), + ), + ); + } + + return Scaffold( + backgroundColor: const Color(0xfffaf7ef), + bottomNavigationBar: const BottomNav(selectedIndex: 2), + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 50), + // 1. 타이틀 + const Center( + child: Text( + "GuardPay 쇼핑", + style: TextStyle( + color: Color(0xFF4CAF50), + fontSize: 33, + fontWeight: FontWeight.bold, + ), + ), + ), + + const SizedBox(height: 20), + + Container( + width: double.infinity, + color: Colors.white, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 15.0), + ), + + Row( + children: [ + const Spacer(), + Container( + margin: const EdgeInsets.only(right: 16), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: Color(0xFFBBDAB4), + borderRadius: BorderRadius.circular(30), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 5, + ) + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.monetization_on, color: Colors.amber), + const SizedBox(width: 10), + Text( + "$_userPoint", + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w500), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 20), + const Padding( + padding: EdgeInsets.only(left: 23), + child: Text( + "금융 퀴즈 풀고\n모바일 상품권으로 교환하세요!", + style: TextStyle(fontSize: 23, fontWeight: FontWeight.w600, height: 1.4), + ), + ), + + + const SizedBox(height: 20), + ], + ), + ), + + SizedBox( + height: 40, + child: Row( + children: List.generate(_categories.length, (index) { + return _category( + _categories[index], + _selectedIndex == index, + () { + setState(() { + _selectedIndex = index; + _filterProductsByCategory(index); + }); + }, + ); + }), + ), + ), + + Expanded( + child: _isLoading && _accessToken != null + ? const Center(child: CircularProgressIndicator()) + : _errorMessage.isNotEmpty + ? Center(child: Text(_errorMessage)) + : _filteredProducts.isEmpty + ? const Center(child: Text("현재 상품이 없습니다.")) + : GridView.count( + crossAxisCount: 2, + padding: const EdgeInsets.all(16), + mainAxisSpacing: 16, + crossAxisSpacing: 16, + childAspectRatio: 0.72, + children: _filteredProducts.map((product) { + return _itemCard(product); + }).toList(), + ), + ), + ], + ), + ), + ); + } + + Widget _category(String text, bool selected, VoidCallback onTap) { + return Expanded( + child: InkWell( + onTap: onTap, + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + text, + style: TextStyle( + fontWeight: selected ? FontWeight.w500 : FontWeight.normal, + fontSize: selected ? 19 : 18, + ), + ), + const SizedBox(height: 4), + Container( + height: 1.3, + width: selected ? 70 : 0, + color: selected ? Colors.black : Colors.transparent, + ), + ], + ), + ), + ); + } + + Widget _itemCard(ProductModel product) { + String relativePath = product.thumbnail; + if (relativePath.startsWith('./')) { + relativePath = relativePath.substring(2); + } else if (relativePath.startsWith('/')) { + relativePath = relativePath.substring(1); + } + + String fullImageUrl = '$kImageBaseUrl/$relativePath'; + String encodedUrl = Uri.encodeFull(fullImageUrl); + + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Center( + child: Image.network( + encodedUrl, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return const Icon(Icons.image_not_supported, size: 50, color: Colors.grey); + }, + ), + ), + ), + const SizedBox(height: 6), + Text(product.brand, style: const TextStyle(color: Colors.grey, fontSize: 15)), + Text(product.name, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500)), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // 1. 가격 표시 + Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.monetization_on, color: Colors.amber, size: 20.0), + const SizedBox(width: 6), + Text( + product.pricePoint.toString(), + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold) + ), + ], + ), + + // 2. 교환 버튼 + InkWell( + onTap: () { + // 다이얼로그 띄우기 + showPurchaseDialog( + context, + title: '상품을 교환할까요?', + content: '구매 후에는 환불 및 기간 연장이 불가합니다.', + onConfirm: () async { + await _exchangeProduct(product.id); + }, + ); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular(10), + ), + child: const Text( + "교환", + style: TextStyle(color: Colors.white, fontWeight: FontWeight.w500, fontSize: 15), + ), + ), + ) + ], + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/shop/services/shop_service.dart b/lib/features/shop/services/shop_service.dart new file mode 100644 index 0000000..41e8acc --- /dev/null +++ b/lib/features/shop/services/shop_service.dart @@ -0,0 +1,102 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +import '../models/shop_models.dart'; + +const String _baseUrl = "http://10.0.2.2:8080/api"; + +class ShopService { + final http.Client _client = http.Client(); + + // 사용자 프로필 조회 (포인트 로드) + Future getProfile(String token) async { + final url = Uri.parse('$_baseUrl/members/profile'); + + try { + final response = await _client.get( + url, + headers: { + 'Authorization': 'Bearer $token', + }, + ); + + final Map responseBody = jsonDecode(utf8.decode(response.bodyBytes)); + + if (response.statusCode == 200) { + return ProfileModel.fromJson(responseBody); + } else { + throw Exception('프로필 조회 실패. Status Code: ${response.statusCode}'); + } + } catch (e) { + throw Exception('네트워크 오류로 프로필 조회 실패: $e'); + } + } + + // 상품 목록 조회 + Future> getProducts() async { + final url = Uri.parse('$_baseUrl/shop/products'); + + try { + final response = await _client.get(url); + + if (response.statusCode == 200) { + final Map responseBody = jsonDecode(utf8.decode(response.bodyBytes)); + final List productListJson = responseBody['data']; + + return productListJson.map((json) => ProductModel.fromJson(json)).toList(); + } else { + throw Exception('상품 목록 조회 실패. Status Code: ${response.statusCode}'); + } + } catch (e) { + throw Exception('네트워크 오류로 상품 목록 조회 실패: $e'); + } + } + + // 상품 교환 + Future exchangeProduct(int productId, String token) async { + final url = Uri.parse('$_baseUrl/shop/exchange/$productId'); + final response = await _client.post( + url, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ); + + final Map responseBody = jsonDecode(utf8.decode(response.bodyBytes)); + + if (response.statusCode == 200) { + return ExchangeResponseModel.fromJson(responseBody['data']); + } else if (response.statusCode == 400) { + final message = responseBody['message'] ?? '상품 교환에 실패했습니다.'; + throw Exception(message); + } else { + throw Exception('상품 교환 실패. Status Code: ${response.statusCode}'); + } + } + + // 교환 내역 조회 + Future> getExchangeHistory(String token) async { + final url = Uri.parse('$_baseUrl/shop/history'); + + try { + final response = await _client.get( + url, + headers: { + 'Authorization': 'Bearer $token', + }, + ); + + if (response.statusCode == 200) { + final Map responseBody = jsonDecode(utf8.decode(response.bodyBytes)); + final List historyListJson = responseBody['data']['exchanges']; + + return historyListJson.map((json) => ExchangeHistoryItemModel.fromJson(json)).toList(); + } else { + throw Exception('교환 내역 조회 실패. Status Code: ${response.statusCode}'); + } + } catch (e) { + throw Exception('교환 내역 조회 중 네트워크 오류: $e'); + } + } +} \ No newline at end of file diff --git a/lib/features/shop/widgets/purchase_dialog.dart b/lib/features/shop/widgets/purchase_dialog.dart new file mode 100644 index 0000000..8c23b2e --- /dev/null +++ b/lib/features/shop/widgets/purchase_dialog.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; + +class PurchaseDialog extends StatelessWidget { + final String title; + final String content; + final Function() onConfirm; + + const PurchaseDialog({ + Key? key, + required this.title, + required this.content, + required this.onConfirm, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return AlertDialog( + backgroundColor: Colors.white, + title: Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 25, + fontWeight: FontWeight.w600, + color: Colors.black, + ), + ), + + content: Text( + content, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w400, + color: Colors.red, + height: 1.5, + ), + ), + + actions: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 취소 버튼 + SizedBox( + width: 135, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey[200], + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), + ), + padding: const EdgeInsets.symmetric(vertical: 15), + elevation: 0, + ), + child: const Text('취소', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)), + onPressed: () => Navigator.of(context).pop(), + ), + ), + const SizedBox(width: 8), + + // 교환하기 버튼 + SizedBox( + width: 135, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFBBDAB4), + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), + ), + padding: const EdgeInsets.symmetric(vertical: 15), + elevation: 0, + ), + child: const Text('교환하기', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)), + onPressed: () { onConfirm(); Navigator.of(context).pop(); }, + ), + ), + ], + ), + ], + actionsPadding: const EdgeInsets.only( + left: 0, + right: 0, + top: 10, + bottom: 20 + ), + ); + } +} + +Future showPurchaseDialog( + BuildContext context, { + required String title, + required String content, + required Function() onConfirm, + }) async { + return showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return PurchaseDialog( + title: title, + content: content, + onConfirm: onConfirm, + ); + }, + ); +} \ No newline at end of file diff --git a/lib/features/video/models/video_model.dart b/lib/features/video/models/video_model.dart new file mode 100644 index 0000000..9fe407a --- /dev/null +++ b/lib/features/video/models/video_model.dart @@ -0,0 +1,65 @@ +import 'package:flutter/foundation.dart'; + +class VideoCategory { + final int id; + final String name; + final String description; + final String icon; + final int videoCount; + + const VideoCategory({ + required this.id, + required this.name, + required this.description, + required this.icon, + required this.videoCount, + }); + + factory VideoCategory.fromJson(Map json) { + return VideoCategory( + id: json['id'], + name: json['name'], + description: json['description'] ?? '', + icon: json['icon'] ?? '', + videoCount: json['videoCount'] ?? 0, + ); + } +} + +class PreventionVideo { + final int id; + final String title; + final String description; + final String youtubeId; + final String youtubeUrl; + final String thumbnailUrl; + final String duration; + final int viewCount; + final int categoryId; + + const PreventionVideo({ + required this.id, + required this.title, + required this.description, + required this.youtubeId, + required this.youtubeUrl, + required this.thumbnailUrl, + required this.duration, + required this.viewCount, + required this.categoryId, + }); + + factory PreventionVideo.fromJson(Map json) { + return PreventionVideo( + id: json['id'], + title: json['title'], + description: json['description'] ?? '', + youtubeId: json['youtubeId'], + youtubeUrl: json['youtubeUrl'], + thumbnailUrl: json['thumbnailUrl'], + duration: json['duration'] ?? '', + viewCount: json['viewCount'] ?? 0, + categoryId: json['categoryId'], + ); + } +} diff --git a/lib/features/video/screens/video_category_screen.dart b/lib/features/video/screens/video_category_screen.dart new file mode 100644 index 0000000..42c52eb --- /dev/null +++ b/lib/features/video/screens/video_category_screen.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; + +class VideoCategoryScreen extends StatefulWidget { + const VideoCategoryScreen({super.key}); + + @override + State createState() => _VideoCategoryScreenState(); +} + +class _VideoCategoryScreenState extends State { + // 목업 데이터 (디자인 테스트용) + final List> _mockCategories = [ + { + 'id': 1, + 'name': '보이스피싱', + 'description': '전화를 이용한 금융사기 예방', + 'icon': 'phone_warning', + }, + { + 'id': 2, + 'name': '스미싱', + 'description': '문자 메시지를 이용한 사기 예방', + 'icon': 'message_warning', + }, + { + 'id': 3, + 'name': '대출사기', + 'description': '불법 대출 유도 사기 예방', + 'icon': 'money_warning', + }, + { + 'id': 4, + 'name': '파밍', + 'description': '개인정보 탈취 사기 예방', + 'icon': 'security_warning', + }, + ]; + + IconData getIcon(String iconName) { + switch (iconName) { + case 'phone_warning': + return Icons.phone_in_talk; + case 'message_warning': + return Icons.sms_failed; + case 'money_warning': + return Icons.attach_money; + case 'security_warning': + return Icons.security; + default: + return Icons.play_circle_fill; + } + } + + Color getColor(int index) { + final colors = [ + const Color(0xFFFF6B6B), // 빨강 - 보이스피싱 + const Color(0xFF4ECDC4), // 청록 - 스미싱 + const Color(0xFFFFBE0B), // 노랑 - 대출사기 + const Color(0xFF9B59B6), // 보라 - 파밍 + ]; + return colors[index % colors.length]; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF9F5EC), + appBar: AppBar( + title: const Text( + '금융사기 예방 영상', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.black, + ), + ), + backgroundColor: Colors.white, + elevation: 1, + iconTheme: const IconThemeData(color: Colors.black), + ), + body: ListView.builder( + padding: const EdgeInsets.only(left: 20.0, right: 20.0, top: 20.0, bottom: 20.0), + itemCount: _mockCategories.length, + itemBuilder: (context, index) { + final category = _mockCategories[index]; + final color = getColor(index); + + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.06), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () { + Navigator.pushNamed( + context, + '/videoList', + arguments: { + 'categoryId': category['id'], + 'categoryName': category['name'], + }, + ); + }, + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Row( + children: [ + // 아이콘 + Container( + width: 60, + height: 100, + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(14), + ), + child: Icon( + getIcon(category['icon']), + size: 30, + color: color, + ), + ), + const SizedBox(width: 16), + // 텍스트 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + category['name'], + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + color: Colors.black87, + ), + ), + const SizedBox(height: 4), + Text( + category['description'], + style: TextStyle( + fontSize: 13, + color: Colors.grey[600], + ), + ), + ], + ), + ), + // 화살표 + Icon( + Icons.chevron_right, + color: Colors.grey[400], + size: 28, + ), + ], + ), + ), + ), + ), + ); + }, + ), + ); + } +} + +/* +🔧 실제 API 연결 시 사용할 코드: + +import 'package:guardpayfront/features/video/services/video_service.dart'; +import 'package:guardpayfront/features/video/models/video_model.dart'; + +class _VideoCategoryScreenState extends State { + final VideoService _videoService = VideoService(); + Future>? _categories; + + @override + void initState() { + super.initState(); + _categories = _videoService.fetchCategories(); + } + + // body를 FutureBuilder로 감싸기 +} +*/ \ No newline at end of file diff --git a/lib/features/video/screens/video_list_screen.dart b/lib/features/video/screens/video_list_screen.dart new file mode 100644 index 0000000..91edfc7 --- /dev/null +++ b/lib/features/video/screens/video_list_screen.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:guardpayfront/features/video/models/video_model.dart'; +import 'package:guardpayfront/features/video/services/video_service.dart'; +import 'package:cached_network_image/cached_network_image.dart'; + +class VideoListScreen extends StatefulWidget { + final int categoryId; + final String categoryName; + + const VideoListScreen({ + super.key, + required this.categoryId, + required this.categoryName, + }); + + @override + State createState() => _VideoListScreenState(); +} + +class _VideoListScreenState extends State { + final VideoService _videoService = VideoService(); + late Future> _videos; + + @override + void initState() { + super.initState(); + _videos = _videoService.fetchVideosByCategory(widget.categoryId); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF9F5EC), + appBar: AppBar( + title: Text( + '${widget.categoryName} 예방 영상', + style: const TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + ), + ), + backgroundColor: Colors.white, + foregroundColor: Colors.black, + elevation: 0.5, + ), + body: FutureBuilder>( + future: _videos, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + print('❌ Error: ${snapshot.error}'); + print('❌ StackTrace: ${snapshot.stackTrace}'); + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('영상 데이터를 불러오지 못했습니다.'), + const SizedBox(height: 10), + Text( + '${snapshot.error}', + style: const TextStyle(fontSize: 12, color: Colors.red), + ), + ], + ), + ); + } + if (!snapshot.hasData || snapshot.data!.isEmpty) { + return const Center(child: Text('등록된 영상이 없습니다.')); + } + + final videos = snapshot.data!; + return ListView.builder( + itemCount: videos.length, + itemBuilder: (context, index) { + final video = videos[index]; + return GestureDetector( + onTap: () { + Navigator.pushNamed( + context, + '/videoPlayer', + arguments: {'videoId': video.id, 'title': video.title}, + ); + }, + child: Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 3, + child: Row( + children: [ + ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + bottomLeft: Radius.circular(16), + ), + child: CachedNetworkImage( + imageUrl: video.thumbnailUrl, + width: 130, + height: 80, + fit: BoxFit.cover, + errorWidget: (context, error, stackTrace) => + const Icon(Icons.broken_image, size: 50), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + video.title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 5), + Text( + video.duration, + style: const TextStyle( + color: Colors.grey, + fontSize: 13, + ), + ), + Text( + '조회수 ${video.viewCount}', + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + ), + ), + ], + ), + ), + ), + const Icon(Icons.play_circle_fill, color: Colors.redAccent), + const SizedBox(width: 10), + ], + ), + ), + ); + }, + ); + }, + ), + ); + } +} diff --git a/lib/features/video/screens/video_player_screen.dart b/lib/features/video/screens/video_player_screen.dart new file mode 100644 index 0000000..bff076d --- /dev/null +++ b/lib/features/video/screens/video_player_screen.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import 'package:youtube_player_flutter/youtube_player_flutter.dart'; +import 'package:guardpayfront/features/video/services/video_service.dart'; +import 'package:guardpayfront/features/video/models/video_model.dart'; + +class VideoPlayerScreen extends StatefulWidget { + final int videoId; + final String title; + + const VideoPlayerScreen({ + super.key, + required this.videoId, + required this.title, + }); + + @override + State createState() => _VideoPlayerScreenState(); +} + +class _VideoPlayerScreenState extends State { + final VideoService _videoService = VideoService(); + late Future _videoFuture; + + @override + void initState() { + super.initState(); + _videoFuture = _videoService.fetchVideoDetail(widget.videoId); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF9F5EC), + appBar: AppBar( + title: Text( + widget.title, + style: const TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + ), + ), + backgroundColor: Colors.white, + foregroundColor: Colors.black, + elevation: 0.5, + ), + body: FutureBuilder( + future: _videoFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Text('영상을 불러올 수 없습니다.\n${snapshot.error}'), + ); + } + + if (!snapshot.hasData) { + return const Center(child: Text('영상 정보가 없습니다.')); + } + + final video = snapshot.data!; + + // ✅ 여기서 controller 생성 + final controller = YoutubePlayerController( + initialVideoId: video.youtubeId, + flags: const YoutubePlayerFlags( + autoPlay: true, + mute: false, + ), + ); + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ✅ YouTube 플레이어 + YoutubePlayer( + controller: controller, + showVideoProgressIndicator: true, + ), + + // 영상 정보 + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + video.title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + '조회수 ${video.viewCount} • ${video.duration}', + style: const TextStyle( + color: Colors.grey, + fontSize: 14, + ), + ), + const SizedBox(height: 16), + Text( + video.description, + style: const TextStyle(fontSize: 15), + ), + ], + ), + ), + ], + ), + ); + }, + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/video/services/video_service.dart b/lib/features/video/services/video_service.dart new file mode 100644 index 0000000..e701e8a --- /dev/null +++ b/lib/features/video/services/video_service.dart @@ -0,0 +1,74 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:guardpayfront/features/video/models/video_model.dart'; + +class VideoService { + final String baseUrl = 'http://10.0.2.2:8080/api/videos'; + + /// ✅ [1] 영상 카테고리 목록 불러오기 + Future> fetchCategories() async { + final response = await http.get(Uri.parse('$baseUrl/categories')); + if (response.statusCode == 200) { + final List jsonList = jsonDecode(utf8.decode(response.bodyBytes)); + return jsonList.map((v) => VideoCategory.fromJson(v)).toList(); + } else { + throw Exception('카테고리 불러오기 실패 (${response.statusCode})'); + } + } + + /// ✅ [2] 카테고리별 영상 목록 (수정됨) + Future> fetchVideosByCategory(int categoryId) async { + try { + final response = await http.get(Uri.parse('$baseUrl/categories/$categoryId')); + + print('📡 Response Status: ${response.statusCode}'); + print('📡 Response Body: ${utf8.decode(response.bodyBytes)}'); + + if (response.statusCode == 200) { + final decodedBody = jsonDecode(utf8.decode(response.bodyBytes)); + + // 🔍 응답 타입 확인 + print('🔍 Response Type: ${decodedBody.runtimeType}'); + + // ✅ Map인 경우 'videos' 키에서 배열 추출 + if (decodedBody is Map) { + // 서버가 { "videos": [...] } 형태로 반환하는 경우 + final List jsonList = decodedBody['videos'] as List; + return jsonList.map((v) => PreventionVideo.fromJson(v)).toList(); + } + // ✅ List인 경우 그대로 파싱 + else if (decodedBody is List) { + return decodedBody.map((v) => PreventionVideo.fromJson(v)).toList(); + } + else { + throw Exception('예상치 못한 응답 형식: ${decodedBody.runtimeType}'); + } + } else { + throw Exception('카테고리별 영상 조회 실패 (${response.statusCode})'); + } + } catch (e, stackTrace) { + print('❌ Error in fetchVideosByCategory: $e'); + print('❌ StackTrace: $stackTrace'); + rethrow; + } + } + + /// ✅ [3] 영상 상세 조회 + Future fetchVideoDetail(int videoId) async { + final response = await http.get(Uri.parse('$baseUrl/$videoId')); + if (response.statusCode == 200) { + final Map json = jsonDecode(utf8.decode(response.bodyBytes)); + return PreventionVideo.fromJson(json); + } else { + throw Exception('영상 상세 조회 실패 (${response.statusCode})'); + } + } + + /// ✅ [4] 조회수 증가 + Future increaseViewCount(int videoId) async { + final response = await http.post(Uri.parse('$baseUrl/$videoId/view')); + if (response.statusCode != 200) { + throw Exception('조회수 증가 실패 (${response.statusCode})'); + } + } +} \ No newline at end of file diff --git a/lib/features/video/widgets/video_category_card.dart b/lib/features/video/widgets/video_category_card.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/features/video/widgets/video_list_tile.dart b/lib/features/video/widgets/video_list_tile.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/features/video/widgets/video_player.dart b/lib/features/video/widgets/video_player.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/main.dart b/lib/main.dart index b043f09..b6b2617 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,35 +1,135 @@ -// Flutter 앱의 시작점입니다. +import 'dart:developer'; import 'package:flutter/material.dart'; -// 1. 우리가 만든 회원가입 화면 파일을 불러옵니다. -import 'signup_screen.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:guardpayfront/features/bank/screens/account_selection_screen.dart'; +import 'package:guardpayfront/features/bank/screens/mock_transfer_screen.dart'; +import 'package:guardpayfront/features/quiz/screens/quiz_screen.dart'; +import 'package:kakao_flutter_sdk_user/kakao_flutter_sdk_user.dart'; // ✅ 중복 제거 +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -void main() { - runApp(const MyApp()); +// AuthService 임포트 +import 'features/auth/services/auth_service.dart'; +import 'config/theme.dart'; +import 'features/auth/screens/login_screen.dart'; +import 'features/auth/screens/signup_screen.dart'; +import 'features/auth/screens/reset_password_screen.dart'; +import 'features/auth/screens/home_screen.dart'; +import 'features/assessment/screens/assessment_screen.dart'; +import 'features/quiz/screens/quiz_category_screen.dart'; +import 'features/quiz/screens/quiz_screen.dart'; +import 'features/auth/screens/mypage_screen.dart'; + +// 영상 관련 import +import 'package:guardpayfront/features/video/screens/video_category_screen.dart'; +import 'package:guardpayfront/features/video/screens/video_list_screen.dart'; +import 'package:guardpayfront/features/video/screens/video_player_screen.dart'; + +void main() async { + // 1. Flutter 엔진과 위젯 바인딩 초기화 + WidgetsFlutterBinding.ensureInitialized(); + + // 2. 환경 변수(.env) 로드 + try { + await dotenv.load(fileName: ".env"); + log("✅ .env 파일 로드 성공"); + } catch (e) { + log("🚨 Error loading .env file: $e"); + } + + // 3. ✅ Kakao SDK 초기화 (.env에서 가져오기) + final kakaoNativeAppKey = dotenv.env['KAKAO_NATIVE_APP_KEY']; + + if (kakaoNativeAppKey != null && kakaoNativeAppKey.isNotEmpty) { + KakaoSdk.init(nativeAppKey: kakaoNativeAppKey); + log("✅ Kakao SDK 초기화 성공: $kakaoNativeAppKey"); + } else { + log("🚨 KAKAO_NATIVE_APP_KEY가 .env 파일에 없습니다!"); + } + + // 4. 저장된 액세스 토큰과 리프레시 토큰 확인 + final storage = const FlutterSecureStorage(); + final accessToken = await storage.read(key: 'accessToken'); + final refreshToken = await storage.read(key: 'refreshToken'); + + // 5. 토큰 유효성 검사 및 갱신 시도 + String initialRoute = '/login'; // 기본값은 로그인 화면 + + if (accessToken != null && refreshToken != null) { + try { + final authService = AuthService(); + + // Refresh Token을 이용해 Access Token을 갱신하고 저장 + final bool isTokenRefreshed = await authService.checkAndRefreshTokens(refreshToken); + + if (isTokenRefreshed) { + // 갱신 성공: 홈으로 이동 + log('✅ [Auth Check] 토큰 갱신 성공. 홈 화면으로 이동.'); + initialRoute = '/home'; + } else { + // 갱신 실패: 로그아웃 처리 + await storage.deleteAll(); + log('🚨 [Auth Check] Refresh Token 만료. 로그인 페이지로 이동.'); + initialRoute = '/login'; + } + } catch (e) { + // 서버 통신 오류 등: 로그아웃 처리 + await storage.deleteAll(); + log('🚨 [Auth Check] 토큰 갱신 중 예외 발생 ($e). 로그인 페이지로 이동.'); + initialRoute = '/login'; + } + } else { + log('ℹ️ [Auth Check] 저장된 토큰 없음. 로그인 페이지로 이동.'); + } + + // 6. 앱 실행 + runApp(MyApp(initialRoute: initialRoute)); } class MyApp extends StatelessWidget { - const MyApp({super.key}); + final String initialRoute; + const MyApp({super.key, required this.initialRoute}); @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, title: 'GuardPay App', - theme: ThemeData( - // 앱의 전반적인 색상 톤을 설정합니다. - primarySwatch: Colors.green, - // 배경색을 React Native 버전과 유사하게 설정합니다. - scaffoldBackgroundColor: const Color(0xFFF9F5EC), - // 입력창(TextField)의 기본 디자인을 설정합니다. - inputDecorationTheme: const InputDecorationTheme( - border: OutlineInputBorder(), - filled: true, - fillColor: Colors.white, - ), - ), - // 2. 앱이 시작될 때 보여줄 첫 화면으로 SignupScreen을 지정합니다. - home: const SignupScreen(), + // theme: appTheme(), + initialRoute: initialRoute, + routes: { + '/login': (context) => const LoginScreen(), + '/signup': (context) => const SignupScreen(), + '/reset': (context) => const ResetPasswordScreen(), + '/home': (context) => const HomeScreen(), + '/assessment': (context) => const AssessmentScreen(), + '/quizCategory': (context) => const QuizCategoryScreen(), + '/quiz': (context) => const QuizScreen(), + '/accountSelection': (context) => const AccountSelectionScreen(), + '/mypage': (context) => const MypageScreen(), + + // 예방 영상 관련 라우트 + '/video': (context) => VideoCategoryScreen(), + '/videoList': (context) { + final args = ModalRoute.of(context)!.settings.arguments as Map; + final int categoryId = args['categoryId']; + final String categoryName = args['categoryName']; + + return VideoListScreen( + categoryId: categoryId, + categoryName: categoryName, + ); + }, + '/videoPlayer': (context) { + final args = ModalRoute.of(context)!.settings.arguments as Map; + final int videoId = args['videoId']; + final String title = args['title']; + + return VideoPlayerScreen( + videoId: videoId, + title: title, + ); + }, + }, ); } -} - +} \ No newline at end of file diff --git a/lib/signup_screen.dart b/lib/signup_screen.dart deleted file mode 100644 index 7699dad..0000000 --- a/lib/signup_screen.dart +++ /dev/null @@ -1,234 +0,0 @@ -import 'package:flutter/material.dart'; -// HTTP 요청을 위해 http 패키지를 불러옵니다. -import 'package:http/http.dart' as http; -// 데이터를 JSON으로 변환하기 위해 필요합니다. -import 'dart:convert'; - -// React의 함수형 컴포넌트가 Flutter의 StatefulWidget으로 변경됩니다. -// 화면의 내용이 바뀌어야 할 때 (예: 글자 입력) StatefulWidget을 사용합니다. -class SignupScreen extends StatefulWidget { - const SignupScreen({super.key}); - - @override - State createState() => _SignupScreenState(); -} - -// 위젯의 '상태'를 관리하는 클래스입니다. React의 useState 훅의 역할을 합니다. -class _SignupScreenState extends State { - // 1. 폼 데이터 상태 관리 - // React의 formData 객체 대신, 각 입력 필드를 TextEditingController로 관리합니다. - final _emailController = TextEditingController(); - final _authCodeController = TextEditingController(); - final _passwordController = TextEditingController(); - final _passwordConfirmController = TextEditingController(); - final _nicknameController = TextEditingController(); - - // 체크박스와 이메일 확인 상태는 boolean으로 관리합니다. - bool _termsAgreed = false; - bool _isEmailConfirmed = false; - - // 2. 입력값 변경 시 상태 업데이트 - // Controller를 사용하면 따로 핸들러가 필요 없지만, 체크박스를 위해 만듭니다. - void _toggleTerms(bool? value) { - // setState는 React의 setFormData와 같습니다. 화면을 다시 그리도록 명령합니다. - setState(() { - _termsAgreed = value ?? false; - }); - } - - // 3. 이메일 '확인' 버튼 함수 - void _handleEmailConfirm() { - final email = _emailController.text; - final emailRegex = RegExp(r"^[^\s@]+@[^\s@]+\.[^\s@]+$"); - if (email.isEmpty || !emailRegex.hasMatch(email)) { - // alert() 대신 ScaffoldMessenger와 SnackBar를 사용해 메시지를 보여줍니다. - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('올바른 이메일 주소를 입력해주세요.')), - ); - return; - } - - print('이메일 확인 완료: $email'); - setState(() { - _isEmailConfirmed = true; - }); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('이메일이 확인되었습니다.')), - ); - } - - // 4. '가입하기' 버튼 함수 (handleSubmit) - Future _handleSubmit() async { - // 유효성 검사 - if (_emailController.text.isEmpty || - _passwordController.text.isEmpty || - _nicknameController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('필수 항목(*)을 모두 입력해주세요.')), - ); - return; - } - if (_passwordController.text != _passwordConfirmController.text) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('비밀번호가 일치하지 않습니다.')), - ); - return; - } - if (!_termsAgreed) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('약관에 동의해주세요.')), - ); - return; - } - - // 🚨 중요: API 주소는 React Native와 동일하게 10.0.2.2를 사용합니다. - const apiUrl = 'http://10.0.2.2:8080/api/users/signup'; - - // 서버에 보낼 데이터 (JavaScript의 객체 -> Dart의 Map) - final signupData = { - 'email': _emailController.text, - 'password': _passwordController.text, - 'nickname': _nicknameController.text, - }; - - try { - // axios.post -> http.post - final response = await http.post( - Uri.parse(apiUrl), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode(signupData), // 데이터를 JSON 문자열로 인코딩 - ); - - if (response.statusCode == 200 || response.statusCode == 201) { - print('가입 성공 응답: ${response.body}'); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('회원가입이 완료되었습니다!')), - ); - // TODO: 로그인 화면으로 이동하는 로직 추가 - } else { - print('가입 실패: ${response.body}'); - final errorBody = jsonDecode(response.body); - final errorMessage = errorBody['message'] ?? '가입에 실패했습니다.'; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(errorMessage)), - ); - } - } catch (error) { - print('가입 요청 실패: $error'); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('서버와 통신할 수 없습니다.')), - ); - } - } - - // React의 return (...) 부분은 Flutter의 build 메소드에 해당합니다. - // JSX 대신 위젯(Widget)을 조립하여 UI를 만듭니다. - @override - Widget build(BuildContext context) { - // Scaffold는 화면의 기본 구조(상단 바, 본문 등)를 제공합니다. - return Scaffold( - // 키보드가 올라올 때 화면이 가려지지 않도록 스크롤 가능하게 만듭니다. - body: SingleChildScrollView( - // 화면 전체에 여백을 줍니다. - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 60), - child: Column( - // 자식 위젯들을 세로로 정렬합니다. - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text( - 'GuardPay', - textAlign: TextAlign.center, - style: TextStyle( - color: Color(0xFF6AA84F), - fontSize: 36, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 30), // 여백 - - // 이메일 입력 - const Text('이메일 *', style: TextStyle(fontWeight: FontWeight.bold)), - const SizedBox(height: 5), - TextField( - controller: _emailController, - decoration: const InputDecoration( - hintText: '이메일', - border: OutlineInputBorder(), - filled: true, - fillColor: Colors.white, - ), - keyboardType: TextInputType.emailAddress, - ), - const SizedBox(height: 15), - - // 비밀번호 입력 - const Text('비밀번호 *', style: TextStyle(fontWeight: FontWeight.bold)), - const SizedBox(height: 5), - TextField( - controller: _passwordController, - decoration: const InputDecoration( - hintText: '비밀번호', - border: OutlineInputBorder(), - filled: true, - fillColor: Colors.white, - ), - obscureText: true, // 비밀번호 가리기 - ), - const SizedBox(height: 10), - TextField( - controller: _passwordConfirmController, - decoration: const InputDecoration( - hintText: '비밀번호 재입력', - border: OutlineInputBorder(), - filled: true, - fillColor: Colors.white, - ), - obscureText: true, - ), - const SizedBox(height: 15), - - // 닉네임 입력 - const Text('닉네임 *', style: TextStyle(fontWeight: FontWeight.bold)), - const SizedBox(height: 5), - TextField( - controller: _nicknameController, - decoration: const InputDecoration( - hintText: '닉네임', - border: OutlineInputBorder(), - filled: true, - fillColor: Colors.white, - ), - ), - const SizedBox(height: 25), - - // 약관 동의 - Row( - children: [ - Checkbox( - value: _termsAgreed, - onChanged: _toggleTerms, - ), - const Text('약관 전체 동의'), - ], - ), - const SizedBox(height: 20), - - // 가입하기 버튼 - ElevatedButton( - onPressed: _handleSubmit, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF6AA84F), - padding: const EdgeInsets.symmetric(vertical: 15), - textStyle: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - child: const Text('가입하기'), - ), - ], - ), - ), - ); - } -} diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..2072990 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "guardpayfront") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.guardpayfront") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..1d8dcf9 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,31 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) desktop_webview_window_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopWebviewWindowPlugin"); + desktop_webview_window_plugin_register_with_registrar(desktop_webview_window_registrar); + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) window_to_front_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowToFrontPlugin"); + window_to_front_plugin_register_with_registrar(window_to_front_registrar); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..29b443b --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,28 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + desktop_webview_window + file_selector_linux + flutter_secure_storage_linux + url_launcher_linux + window_to_front +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 0000000..9e468a9 --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,144 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView *view) +{ + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "guardpayfront"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "guardpayfront"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..7da2827 --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,34 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import desktop_webview_window +import file_selector_macos +import flutter_inappwebview_macos +import flutter_secure_storage_macos +import flutter_web_auth_2 +import google_sign_in_ios +import path_provider_foundation +import shared_preferences_foundation +import sqflite_darwin +import url_launcher_macos +import webview_flutter_wkwebview +import window_to_front + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + DesktopWebviewWindowPlugin.register(with: registry.registrar(forPlugin: "DesktopWebviewWindowPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + FlutterWebAuth2Plugin.register(with: registry.registrar(forPlugin: "FlutterWebAuth2Plugin")) + FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) + WindowToFrontPlugin.register(with: registry.registrar(forPlugin: "WindowToFrontPlugin")) +} diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..1ab279f --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* guardpayfront.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "guardpayfront.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* guardpayfront.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* guardpayfront.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.guardpayfront.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/guardpayfront.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/guardpayfront"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.guardpayfront.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/guardpayfront.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/guardpayfront"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.guardpayfront.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/guardpayfront.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/guardpayfront"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..aff9c66 --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..87d5eac --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = guardpayfront + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.guardpayfront + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/pubspec.lock b/pubspec.lock index b910700..116ae2e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,22 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + asn1lib: + dependency: transitive + description: + name: asn1lib + sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" + url: "https://pub.dev" + source: hosted + version: "1.6.5" async: dependency: transitive description: @@ -9,6 +25,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.0" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.dev" + source: hosted + version: "2.2.9" + barcode_widget: + dependency: "direct main" + description: + name: barcode_widget + sha256: "6f2c5b08659b1a5f4d88d183e6007133ea2f96e50e7b8bb628f03266c3931427" + url: "https://pub.dev" + source: hosted + version: "2.0.4" boolean_selector: dependency: transitive description: @@ -17,6 +49,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" characters: dependency: transitive description: @@ -41,6 +97,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "942a4791cd385a68ccb3b32c71c427aba508a1bb949b86dff2adbe4049f16239" + url: "https://pub.dev" + source: hosted + version: "0.3.5" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" cupertino_icons: dependency: "direct main" description: @@ -49,6 +129,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + desktop_webview_window: + dependency: transitive + description: + name: desktop_webview_window + sha256: "57cf20d81689d5cbb1adfd0017e96b669398a669d927906073b0e42fc64111c0" + url: "https://pub.dev" + source: hosted + version: "0.2.3" + dio: + dependency: transitive + description: + name: dio + sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + url: "https://pub.dev" + source: hosted + version: "5.9.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + encrypt: + dependency: transitive + description: + name: encrypt + sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" + url: "https://pub.dev" + source: hosted + version: "5.0.3" fake_async: dependency: transitive description: @@ -57,11 +169,147 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "54cbbd957e1156d29548c7d9b9ec0c0ebb6de0a90452198683a7d23aed617a33" + url: "https://pub.dev" + source: hosted + version: "0.9.3+2" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "19124ff4a3d8864fdc62072b6a2ef6c222d55a3404fe14893a3c02744907b60c" + url: "https://pub.dev" + source: hosted + version: "0.9.4+4" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b" + url: "https://pub.dev" + source: hosted + version: "0.9.3+4" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + flutter_dotenv: + dependency: "direct main" + description: + name: flutter_dotenv + sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b + url: "https://pub.dev" + source: hosted + version: "5.2.1" + flutter_inappwebview: + dependency: "direct main" + description: + name: flutter_inappwebview + sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5" + url: "https://pub.dev" + source: hosted + version: "6.1.5" + flutter_inappwebview_android: + dependency: transitive + description: + name: flutter_inappwebview_android + sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba" + url: "https://pub.dev" + source: hosted + version: "1.1.3" + flutter_inappwebview_internal_annotations: + dependency: transitive + description: + name: flutter_inappwebview_internal_annotations + sha256: "787171d43f8af67864740b6f04166c13190aa74a1468a1f1f1e9ee5b90c359cd" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + flutter_inappwebview_ios: + dependency: transitive + description: + name: flutter_inappwebview_ios + sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_macos: + dependency: transitive + description: + name: flutter_inappwebview_macos + sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_platform_interface: + dependency: transitive + description: + name: flutter_inappwebview_platform_interface + sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500 + url: "https://pub.dev" + source: hosted + version: "1.3.0+1" + flutter_inappwebview_web: + dependency: transitive + description: + name: flutter_inappwebview_web + sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_windows: + dependency: transitive + description: + name: flutter_inappwebview_windows + sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055" + url: "https://pub.dev" + source: hosted + version: "0.6.0" flutter_lints: dependency: "direct dev" description: @@ -70,11 +318,136 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: c2fe1001710127dfa7da89977a08d591398370d099aacdaa6d44da7eb14b8476 + url: "https://pub.dev" + source: hosted + version: "2.0.31" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" + flutter_web_auth_2: + dependency: "direct main" + description: + name: flutter_web_auth_2 + sha256: "3c14babeaa066c371f3a743f204dd0d348b7d42ffa6fae7a9847a521aff33696" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + flutter_web_auth_2_platform_interface: + dependency: transitive + description: + name: flutter_web_auth_2_platform_interface + sha256: c63a472c8070998e4e422f6b34a17070e60782ac442107c70000dd1bed645f4d + url: "https://pub.dev" + source: hosted + version: "4.1.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: d0a2c3bcb06e607bb11e4daca48bd4b6120f0bbc4015ccebbe757d24ea60ed2a + url: "https://pub.dev" + source: hosted + version: "6.3.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: d5e23c56a4b84b6427552f1cf3f98f716db3b1d1a647f16b96dbb5b93afa2805 + url: "https://pub.dev" + source: hosted + version: "6.2.1" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: "102005f498ce18442e7158f6791033bbc15ad2dcc0afa4cf4752e2722a516c96" + url: "https://pub.dev" + source: hosted + version: "5.9.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "5f6f79cf139c197261adb6ac024577518ae48fdff8e53205c5373b5f6430a8aa" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: "460547beb4962b7623ac0fb8122d6b8268c951cf0b646dd150d60498430e4ded" + url: "https://pub.dev" + source: hosted + version: "0.12.4+4" http: dependency: "direct main" description: @@ -91,6 +464,110 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "28f3987ca0ec702d346eae1d90eda59603a2101b52f1e234ded62cff1d5cfa6e" + url: "https://pub.dev" + source: hosted + version: "0.8.13+1" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "40c2a6a0da15556dc0f8e38a3246064a971a9f512386c3339b89f76db87269b6" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: eb06fe30bab4c4497bad449b66448f50edcc695f1c59408e78aa3a8059eb8f0e + url: "https://pub.dev" + source: hosted + version: "0.8.13" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: d58cd9d67793d52beefd6585b12050af0a7663c0c2a6ece0fb110a35d6955e04 + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + kakao_flutter_sdk_auth: + dependency: transitive + description: + name: kakao_flutter_sdk_auth + sha256: "028d8803b7545cc5f41d20cda43b813683700e45c6c13aa4ca56f4b9c673305c" + url: "https://pub.dev" + source: hosted + version: "1.9.7+3" + kakao_flutter_sdk_common: + dependency: transitive + description: + name: kakao_flutter_sdk_common + sha256: "1c4944cc50c363d4626e9006ab39ee496c8f5ca602b96154272b90d40544aaca" + url: "https://pub.dev" + source: hosted + version: "1.9.7+3" + kakao_flutter_sdk_user: + dependency: "direct main" + description: + name: kakao_flutter_sdk_user + sha256: "5157feeafe58d677d314baa5ccdcb435fd9680c485c3b23e9ad7d97a0c93694f" + url: "https://pub.dev" + source: hosted + version: "1.9.7+3" leak_tracker: dependency: transitive description: @@ -147,6 +624,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" path: dependency: transitive description: @@ -155,6 +648,166 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37" + url: "https://pub.dev" + source: hosted + version: "2.2.19" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.dev" + source: hosted + version: "3.9.1" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: bd14436108211b0d4ee5038689a56d4ae3620fd72fd6036e113bf1345bc74d9e + url: "https://pub.dev" + source: hosted + version: "2.4.13" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shelf: + dependency: "direct main" + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_static: + dependency: "direct main" + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" sky_engine: dependency: transitive description: flutter @@ -168,6 +821,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.1" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" stack_trace: dependency: transitive description: @@ -192,6 +885,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" term_glyph: dependency: transitive description: @@ -216,6 +917,78 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "81777b08c498a292d93ff2feead633174c386291e35612f8da438d6e92c4447e" + url: "https://pub.dev" + source: hosted + version: "6.3.20" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 + url: "https://pub.dev" + source: hosted + version: "6.3.4" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" vector_math: dependency: transitive description: @@ -240,6 +1013,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + webview_flutter: + dependency: "direct main" + description: + name: webview_flutter + sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba + url: "https://pub.dev" + source: hosted + version: "4.13.0" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: "9a25f6b4313978ba1c2cda03a242eea17848174912cfb4d2d8ee84a556f248e3" + url: "https://pub.dev" + source: hosted + version: "4.10.1" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0" + url: "https://pub.dev" + source: hosted + version: "2.14.0" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: fb46db8216131a3e55bcf44040ca808423539bc6732e7ed34fb6d8044e3d512f + url: "https://pub.dev" + source: hosted + version: "3.23.0" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + window_to_front: + dependency: transitive + description: + name: window_to_front + sha256: "7aef379752b7190c10479e12b5fd7c0b9d92adc96817d9e96c59937929512aee" + url: "https://pub.dev" + source: hosted + version: "0.0.3" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + youtube_player_flutter: + dependency: "direct main" + description: + name: youtube_player_flutter + sha256: e64eeebaa5f7dc1d55d103cc9abf05f87d8013bae0d3b6a11aad5d33a2f7f5b4 + url: "https://pub.dev" + source: hosted + version: "9.1.3" sdks: - dart: ">=3.7.0-0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=3.8.0 <4.0.0" + flutter: ">=3.29.0" diff --git a/pubspec.yaml b/pubspec.yaml index bf7f827..0dda796 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,91 +1,48 @@ name: guardpayfront description: "A new Flutter project." -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: 'none' -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. version: 1.0.0+1 environment: - sdk: '>=3.4.3 <4.0.0' + sdk: '^3.4.3' -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. + # 🔹 외부 패키지들 + flutter_web_auth_2: ^4.1.0 + flutter_dotenv: ^5.1.0 + kakao_flutter_sdk_user: ^1.9.1+2 + google_sign_in: ^6.2.1 cupertino_icons: ^1.0.8 - # 🚨 서버 통신을 위해 http 패키지를 추가했습니다. http: ^1.2.1 + flutter_secure_storage: ^9.0.0 + cached_network_image: ^3.3.1 + youtube_player_flutter: ^9.1.3 + flutter_inappwebview: ^6.0.0-beta.26 + #kakao_map_plugin: ^0.3.2 # 혹은 최신 버전 + webview_flutter: ^4.4.2 + shelf: ^1.4.1 # ✅ 추가 + shelf_static: ^1.1.2 # ✅ 추가 + barcode_widget: ^2.0.4 + + # ✅ 이미지 선택 관련 패키지 (갤러리/카메라) + image_picker: ^1.1.2 + + # ✅ 파일 경로 및 저장 관련 + path_provider: ^2.1.1 dev_dependencies: flutter_test: sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. flutter_lints: ^3.0.0 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package + assets: + - assets/images/ + - .env - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..f3f8c2f --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,17 @@ +pluginManagement { + val flutterSdkPath = System.getenv("FLUTTER_ROOT") + ?: error("FLUTTER_ROOT environment variable not set") + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.9.0" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} +include(":app") diff --git a/test/widget_test.dart b/test/widget_test.dart index 2b736b2..2537abc 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -13,7 +13,7 @@ import 'package:guardpayfront/main.dart'; void main() { testWidgets('Counter increments smoke test', (WidgetTester tester) async { // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + await tester.pumpWidget(const MyApp(initialRoute: '/login')); // Verify that our counter starts at 0. expect(find.text('0'), findsOneWidget); diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..f50cce9 --- /dev/null +++ b/web/index.html @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + guardpayfront + + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..ec23b29 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "guardpayfront", + "short_name": "guardpayfront", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..635389b --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(guardpayfront LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "guardpayfront") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..049d850 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,29 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + DesktopWebviewWindowPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("DesktopWebviewWindowPlugin")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); + WindowToFrontPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowToFrontPlugin")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..f4f7a1a --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,29 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + desktop_webview_window + file_selector_windows + flutter_inappwebview_windows + flutter_secure_storage_windows + url_launcher_windows + window_to_front +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..fa67d7b --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "guardpayfront" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "guardpayfront" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "guardpayfront.exe" "\0" + VALUE "ProductName", "guardpayfront" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..a5b1086 --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"guardpayfront", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_