diff --git a/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/BoxF1_curve.png b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/BoxF1_curve.png new file mode 100644 index 0000000..1b5b484 Binary files /dev/null and b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/BoxF1_curve.png differ diff --git a/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/BoxPR_curve.png b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/BoxPR_curve.png new file mode 100644 index 0000000..cba0b11 Binary files /dev/null and b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/BoxPR_curve.png differ diff --git a/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/BoxP_curve.png b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/BoxP_curve.png new file mode 100644 index 0000000..9570457 Binary files /dev/null and b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/BoxP_curve.png differ diff --git a/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/BoxR_curve.png b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/BoxR_curve.png new file mode 100644 index 0000000..9534197 Binary files /dev/null and b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/BoxR_curve.png differ diff --git a/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/confusion_matrix.png b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/confusion_matrix.png new file mode 100644 index 0000000..91bacab Binary files /dev/null and b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/confusion_matrix.png differ diff --git a/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/confusion_matrix_normalized.png b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/confusion_matrix_normalized.png new file mode 100644 index 0000000..0d7dc49 Binary files /dev/null and b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/confusion_matrix_normalized.png differ diff --git a/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/labels.jpg b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/labels.jpg new file mode 100644 index 0000000..25b7999 Binary files /dev/null and b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/labels.jpg differ diff --git a/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/results.png b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/results.png new file mode 100644 index 0000000..ab4f07d Binary files /dev/null and b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/results.png differ diff --git a/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/train_batch0.jpg b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/train_batch0.jpg new file mode 100644 index 0000000..1ae1a7f Binary files /dev/null and b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/train_batch0.jpg differ diff --git a/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/train_batch1.jpg b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/train_batch1.jpg new file mode 100644 index 0000000..19dd535 Binary files /dev/null and b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/train_batch1.jpg differ diff --git a/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/train_batch2.jpg b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/train_batch2.jpg new file mode 100644 index 0000000..22725e0 Binary files /dev/null and b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/train_batch2.jpg differ diff --git a/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/val_batch0_labels.jpg b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/val_batch0_labels.jpg new file mode 100644 index 0000000..d02e11b Binary files /dev/null and b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/val_batch0_labels.jpg differ diff --git a/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/val_batch0_pred.jpg b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/val_batch0_pred.jpg new file mode 100644 index 0000000..fd146d0 Binary files /dev/null and b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/val_batch0_pred.jpg differ diff --git a/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/val_batch1_labels.jpg b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/val_batch1_labels.jpg new file mode 100644 index 0000000..3ff6010 Binary files /dev/null and b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/val_batch1_labels.jpg differ diff --git a/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/val_batch1_pred.jpg b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/val_batch1_pred.jpg new file mode 100644 index 0000000..2349a83 Binary files /dev/null and b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/val_batch1_pred.jpg differ diff --git a/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/val_batch2_labels.jpg b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/val_batch2_labels.jpg new file mode 100644 index 0000000..82dd729 Binary files /dev/null and b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/val_batch2_labels.jpg differ diff --git a/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/val_batch2_pred.jpg b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/val_batch2_pred.jpg new file mode 100644 index 0000000..c093b18 Binary files /dev/null and b/ai-service/models/Detection/Model_routing_1104/yolov8s_imgsz_2048_9cls/train/val_batch2_pred.jpg differ diff --git a/bugreport-sdk_gphone64_arm64-BP41.250822.007-2025-11-12-13-15-52.zip b/bugreport-sdk_gphone64_arm64-BP41.250822.007-2025-11-12-13-15-52.zip new file mode 100644 index 0000000..38ec517 Binary files /dev/null and b/bugreport-sdk_gphone64_arm64-BP41.250822.007-2025-11-12-13-15-52.zip differ diff --git a/bugreport-sdk_gphone64_arm64-BP41.250822.007-2025-11-12-13-17-37.zip b/bugreport-sdk_gphone64_arm64-BP41.250822.007-2025-11-12-13-17-37.zip new file mode 100644 index 0000000..ce71844 Binary files /dev/null and b/bugreport-sdk_gphone64_arm64-BP41.250822.007-2025-11-12-13-17-37.zip differ diff --git a/frontend/android/app/build.gradle.kts b/frontend/android/app/build.gradle.kts index b144a83..84895a6 100644 --- a/frontend/android/app/build.gradle.kts +++ b/frontend/android/app/build.gradle.kts @@ -3,6 +3,8 @@ plugins { id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") + // Firebase 추가 + id("com.google.gms.google-services") } android { @@ -13,6 +15,8 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 + // Core library desugaring 활성화 (flutter_local_notifications 필요) + isCoreLibraryDesugaringEnabled = true } kotlinOptions { @@ -42,3 +46,13 @@ android { flutter { source = "../.." } + +dependencies { + // Firebase 추가 + implementation(platform("com.google.firebase:firebase-bom:33.7.0")) + implementation("com.google.firebase:firebase-messaging") + implementation("com.google.firebase:firebase-analytics") + + // Core library desugaring (flutter_local_notifications 필요) + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} diff --git a/frontend/android/app/google-services.json b/frontend/android/app/google-services.json new file mode 100644 index 0000000..dbe31b0 --- /dev/null +++ b/frontend/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "120799260544", + "project_id": "gradi-bd52c", + "storage_bucket": "gradi-bd52c.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:120799260544:android:a8e550dc78b59824b19b65", + "android_client_info": { + "package_name": "com.gradi.gradi_frontend" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyDoT9s6gZZQ6dCznYqVK3_aMDJCXVIHcRk" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/frontend/android/app/src/main/AndroidManifest.xml b/frontend/android/app/src/main/AndroidManifest.xml index c1a4a0a..d2e88a0 100644 --- a/frontend/android/app/src/main/AndroidManifest.xml +++ b/frontend/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,24 @@ + + + + + + + + + + + + + + :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/frontend/ios/Podfile.lock b/frontend/ios/Podfile.lock new file mode 100644 index 0000000..efe541e --- /dev/null +++ b/frontend/ios/Podfile.lock @@ -0,0 +1,136 @@ +PODS: + - Firebase/CoreOnly (11.15.0): + - FirebaseCore (~> 11.15.0) + - Firebase/Messaging (11.15.0): + - Firebase/CoreOnly + - FirebaseMessaging (~> 11.15.0) + - firebase_core (3.15.2): + - Firebase/CoreOnly (= 11.15.0) + - Flutter + - firebase_messaging (15.2.10): + - Firebase/Messaging (= 11.15.0) + - firebase_core + - Flutter + - FirebaseCore (11.15.0): + - FirebaseCoreInternal (~> 11.15.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - FirebaseCoreInternal (11.15.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseInstallations (11.15.0): + - FirebaseCore (~> 11.15.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - PromisesObjC (~> 2.4) + - FirebaseMessaging (11.15.0): + - FirebaseCore (~> 11.15.0) + - FirebaseInstallations (~> 11.0) + - GoogleDataTransport (~> 10.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Reachability (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - nanopb (~> 3.30910.0) + - Flutter (1.0.0) + - flutter_local_notifications (0.0.1): + - Flutter + - geolocator_apple (1.2.0): + - Flutter + - FlutterMacOS + - GoogleDataTransport (10.1.0): + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - GoogleUtilities/AppDelegateSwizzler (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Privacy + - GoogleUtilities/Environment (8.1.0): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/Network (8.1.0): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Privacy + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (8.1.0)": + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/Reachability (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/UserDefaults (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - image_picker_ios (0.0.1): + - Flutter + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) + - PromisesObjC (2.4.0) + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - firebase_core (from `.symlinks/plugins/firebase_core/ios`) + - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) + - Flutter (from `Flutter`) + - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) + - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + +SPEC REPOS: + trunk: + - Firebase + - FirebaseCore + - FirebaseCoreInternal + - FirebaseInstallations + - FirebaseMessaging + - GoogleDataTransport + - GoogleUtilities + - nanopb + - PromisesObjC + +EXTERNAL SOURCES: + firebase_core: + :path: ".symlinks/plugins/firebase_core/ios" + firebase_messaging: + :path: ".symlinks/plugins/firebase_messaging/ios" + Flutter: + :path: Flutter + flutter_local_notifications: + :path: ".symlinks/plugins/flutter_local_notifications/ios" + geolocator_apple: + :path: ".symlinks/plugins/geolocator_apple/darwin" + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + +SPEC CHECKSUMS: + Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e + firebase_core: 995454a784ff288be5689b796deb9e9fa3601818 + firebase_messaging: f4a41dd102ac18b840eba3f39d67e77922d3f707 + FirebaseCore: efb3893e5b94f32b86e331e3bd6dadf18b66568e + FirebaseCoreInternal: 9afa45b1159304c963da48addb78275ef701c6b4 + FirebaseInstallations: 317270fec08a5d418fdbc8429282238cab3ac843 + FirebaseMessaging: 3b26e2cee503815e01c3701236b020aa9b576f09 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_local_notifications: 395056b3175ba4f08480a7c5de30cd36d69827e4 + geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e + GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e + +COCOAPODS: 1.16.2 diff --git a/frontend/ios/Runner.xcodeproj/project.pbxproj b/frontend/ios/Runner.xcodeproj/project.pbxproj index 6600e1d..1e53225 100644 --- a/frontend/ios/Runner.xcodeproj/project.pbxproj +++ b/frontend/ios/Runner.xcodeproj/project.pbxproj @@ -10,10 +10,12 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 67F1F235865D41A8AC041331 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62E7CF9BBFC5EE1542832891 /* Pods_RunnerTests.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + C8E68B25785E14B7EC8281D7 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EAB97F8AE2516CEA9D2CBE82 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -45,9 +47,13 @@ 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 62BE2AF662A3DA048C5A2743 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 62E7CF9BBFC5EE1542832891 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 8355CFCCC8D3B7D7D44F9496 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 83E289D0077BBD097DD0C1A9 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -55,19 +61,41 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + ABBD770F1CE3DBD688BC9D28 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + D072D449FF26B78F521EB1DD /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + EAB97F8AE2516CEA9D2CBE82 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + FE38EA114836D3510C2E29D6 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 37CB58D838F221DF2F7793F4 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 67F1F235865D41A8AC041331 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C8E68B25785E14B7EC8281D7 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 1D556364884FA141A357534F /* Frameworks */ = { + isa = PBXGroup; + children = ( + EAB97F8AE2516CEA9D2CBE82 /* Pods_Runner.framework */, + 62E7CF9BBFC5EE1542832891 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 331C8082294A63A400263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -76,6 +104,20 @@ path = RunnerTests; sourceTree = ""; }; + 7BB045AB70CB4278220F2375 /* Pods */ = { + isa = PBXGroup; + children = ( + ABBD770F1CE3DBD688BC9D28 /* Pods-Runner.debug.xcconfig */, + 83E289D0077BBD097DD0C1A9 /* Pods-Runner.release.xcconfig */, + FE38EA114836D3510C2E29D6 /* Pods-Runner.profile.xcconfig */, + 62BE2AF662A3DA048C5A2743 /* Pods-RunnerTests.debug.xcconfig */, + D072D449FF26B78F521EB1DD /* Pods-RunnerTests.release.xcconfig */, + 8355CFCCC8D3B7D7D44F9496 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -94,6 +136,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + 7BB045AB70CB4278220F2375 /* Pods */, + 1D556364884FA141A357534F /* Frameworks */, ); sourceTree = ""; }; @@ -128,8 +172,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + DDB6D5790E411E8FE8FF65B5 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, + 37CB58D838F221DF2F7793F4 /* Frameworks */, ); buildRules = ( ); @@ -145,12 +191,15 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 2C15F4A9DE85EC80718098C0 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 74C368189A1BE97070672D34 /* [CP] Embed Pods Frameworks */, + B4F28619E97ED39F39498BCC /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -222,6 +271,28 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 2C15F4A9DE85EC80718098C0 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -238,6 +309,23 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 74C368189A1BE97070672D34 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -253,6 +341,45 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + B4F28619E97ED39F39498BCC /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + DDB6D5790E411E8FE8FF65B5 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -378,6 +505,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 62BE2AF662A3DA048C5A2743 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -395,6 +523,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = D072D449FF26B78F521EB1DD /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -410,6 +539,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 8355CFCCC8D3B7D7D44F9496 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; diff --git a/frontend/ios/Runner.xcworkspace/contents.xcworkspacedata b/frontend/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/frontend/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/frontend/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/frontend/ios/Runner/GoogleService-Info.plist b/frontend/ios/Runner/GoogleService-Info.plist new file mode 100644 index 0000000..ed15c41 --- /dev/null +++ b/frontend/ios/Runner/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyAYs3bpt4mcglJvZaQXFc6eha2FCVZf72Y + GCM_SENDER_ID + 120799260544 + PLIST_VERSION + 1 + BUNDLE_ID + com.gradi.gradiFrontend + PROJECT_ID + gradi-bd52c + STORAGE_BUCKET + gradi-bd52c.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:120799260544:ios:6dba36b595142ec0b19b65 + + \ No newline at end of file diff --git a/frontend/ios/Runner/Info.plist b/frontend/ios/Runner/Info.plist index 2b671ed..93950eb 100644 --- a/frontend/ios/Runner/Info.plist +++ b/frontend/ios/Runner/Info.plist @@ -45,5 +45,15 @@ UIApplicationSupportsIndirectInputEvents + NSPhotoLibraryUsageDescription + 학습 문제를 업로드하기 위해 갤러리 접근이 필요합니다. + NSCameraUsageDescription + 학습 문제를 촬영하기 위해 카메라 접근이 필요합니다. + NSLocationWhenInUseUsageDescription + 근처 학원을 찾기 위해 위치 정보가 필요합니다. + NSLocationAlwaysUsageDescription + 근처 학원을 찾기 위해 위치 정보가 필요합니다. + FirebaseAppDelegateProxyEnabled + diff --git a/frontend/lib/application/explanation/explanation_service.dart b/frontend/lib/application/explanation/explanation_service.dart new file mode 100644 index 0000000..43405c8 --- /dev/null +++ b/frontend/lib/application/explanation/explanation_service.dart @@ -0,0 +1,147 @@ +import '../../domain/explanation/explanation_entity.dart'; +import '../../domain/explanation/explanation_repository.dart'; +import '../../domain/explanation/explanation_source.dart'; +import '../../domain/explanation/get_explanation_use_case.dart'; +import '../../domain/explanation/request_explanation_use_case.dart'; +import '../../services/auth_service.dart'; +import '../../services/academy_service.dart'; +import '../../utils/app_logger.dart'; + +/// 해설 조회/생성 결과 +class ExplanationLoadResult { + final ExplanationEntity? explanation; + final bool requestPerformed; + + const ExplanationLoadResult({ + required this.explanation, + required this.requestPerformed, + }); +} + +/// 해설 조회/생성 흐름을 오케스트레이션하는 Application Service +class ExplanationService { + final ExplanationRepository _repository; + final GetExplanationUseCase _getExplanationUseCase; + final RequestExplanationUseCase _requestExplanationUseCase; + final AuthService _authService; + final AcademyService _academyService; + + ExplanationService({ + required ExplanationRepository repository, + required GetExplanationUseCase getExplanationUseCase, + required RequestExplanationUseCase requestExplanationUseCase, + required AuthService authService, + required AcademyService academyService, + }) : _repository = repository, + _getExplanationUseCase = getExplanationUseCase, + _requestExplanationUseCase = requestExplanationUseCase, + _authService = authService, + _academyService = academyService; + + /// 이미 생성된 해설만 조회합니다. + /// + /// 존재하지 않으면 null을 반환하며, POST 요청은 절대 수행하지 않습니다. + Future loadExistingExplanation( + ExplanationSource source, + ) async { + return _getExplanationUseCase(source); + } + + /// 해설을 불러오거나, 없으면 생성 요청 후 다시 불러옵니다. + /// + /// [ExplanationLoadResult.requestPerformed]가 true이면 + /// 이번 호출에서 실제로 POST 요청이 수행되었음을 의미합니다. + /// + /// 흐름: + /// 1. GET (Redis에서 LLM 해설 가져오기) - 404/400이어도 계속 진행 + /// 2. GET (정답 가져오기) + /// 3. POST (해설 생성 요청) + /// 4. GET (Redis에서 LLM 해설 가져오기) + Future loadOrRequestExplanation( + ExplanationSource source, + ) async { + appLog('[explanation] ========== 해설 요청 흐름 시작 =========='); + appLog( + '[explanation] source: studentResponseId=${source.studentResponseId}, academyUserId=${source.academyUserId}, question=${source.question.questionNumber}, subQuestion=${source.question.subQuestionNumber}', + ); + + // 1) 캐시/기존 해설 조회 (Redis에서 LLM 해설 가져오기) + // 404/400이어도 null을 반환하고 계속 진행 + appLog('[explanation] [1/4] GET (Redis에서 LLM 해설 가져오기) 시작'); + final existing = await _getExplanationUseCase(source); + + if (existing != null) { + appLog( + '[explanation] [1/4] GET (Redis에서 LLM 해설 가져오기) 완료: 해설 존재, POST 생략', + ); + appLog('[explanation] ========== 해설 요청 흐름 종료 (기존 해설 반환) =========='); + return ExplanationLoadResult( + explanation: existing, + requestPerformed: false, + ); + } + + appLog( + '[explanation] [1/4] GET (Redis에서 LLM 해설 가져오기) 완료: 해설 없음 (404/400), 다음 단계 진행', + ); + + // 2) 학생 답안 정보 조회 (정답 가져오기) + // 첫 번째 GET에서 404/400이 나왔어도 정상적으로 진행 + appLog('[explanation] [2/4] GET (정답 가져오기) 시작'); + final studentAnswerInfo = await _repository.findStudentAnswer(source); + appLog( + '[explanation] [2/4] GET (정답 가져오기) 완료: answer=${studentAnswerInfo.answer}', + ); + + // 3) 요청자 ID 확보 + final userIdStr = await _authService.getUserId(); + if (userIdStr == null) { + throw Exception('사용자 정보를 찾을 수 없습니다. 다시 로그인해 주세요.'); + } + final userId = int.tryParse(userIdStr) ?? 0; + if (userId == 0) { + throw Exception('유효하지 않은 사용자 ID입니다.'); + } + + // 4) academyId 가져오기 (학원 목록에서 academyUserId로 찾기) + final academies = await _academyService.loadAcademiesFromCache(); + if (academies == null || academies.isEmpty) { + throw Exception('학원 정보를 찾을 수 없습니다.'); + } + + final academy = academies.firstWhere( + (a) => a.academy_user_id == source.academyUserId, + orElse: () => academies.first, + ); + + final academyId = academy.academy_id; + if (academyId == null) { + throw Exception('학원 ID를 찾을 수 없습니다.'); + } + + appLog( + '[explanation] POST 준비: userId=$userId, academyId=$academyId, answer=${studentAnswerInfo.answer}', + ); + + // 5) 해설 생성 요청 (POST) + appLog('[explanation] [3/4] POST (해설 생성 요청) 시작'); + await _requestExplanationUseCase( + source, + requestedByUserId: userId, + academyId: academyId, + answer: studentAnswerInfo.answer, + ); + appLog('[explanation] [3/4] POST (해설 생성 요청) 완료'); + + // 6) 다시 조회 (Redis에서 LLM 해설 가져오기) + // 생성 후 캐시에 반영되었다는 가정 + appLog('[explanation] [4/4] GET (Redis에서 LLM 해설 가져오기) 시작'); + final after = await _getExplanationUseCase(source); + appLog( + '[explanation] [4/4] GET (Redis에서 LLM 해설 가져오기) 완료: ${after != null ? "해설 존재" : "해설 없음"}', + ); + appLog('[explanation] ========== 해설 요청 흐름 종료 =========='); + + return ExplanationLoadResult(explanation: after, requestPerformed: true); + } +} diff --git a/frontend/lib/application/explanation/question_explanation_controller.dart b/frontend/lib/application/explanation/question_explanation_controller.dart new file mode 100644 index 0000000..30b48f8 --- /dev/null +++ b/frontend/lib/application/explanation/question_explanation_controller.dart @@ -0,0 +1,128 @@ +import 'package:flutter/foundation.dart'; + +import '../../domain/explanation/explanation_entity.dart'; +import '../../domain/explanation/explanation_source.dart'; +import 'explanation_service.dart'; + +/// 해설 화면 상태 +class QuestionExplanationState { + final bool isLoading; + final String? errorMessage; + final ExplanationEntity? explanation; + + /// 마지막 load 호출에서 실제로 해설 생성 요청(POST)이 수행되었는지 여부 + final bool lastRequestPerformed; + + const QuestionExplanationState({ + required this.isLoading, + this.errorMessage, + this.explanation, + required this.lastRequestPerformed, + }); + + const QuestionExplanationState.initial() + : isLoading = false, + errorMessage = null, + explanation = null, + lastRequestPerformed = false; + + QuestionExplanationState copyWith({ + bool? isLoading, + String? errorMessage, + ExplanationEntity? explanation, + bool? lastRequestPerformed, + }) { + return QuestionExplanationState( + isLoading: isLoading ?? this.isLoading, + errorMessage: errorMessage, + explanation: explanation ?? this.explanation, + lastRequestPerformed: lastRequestPerformed ?? this.lastRequestPerformed, + ); + } +} + +/// 해설 조회/생성을 담당하는 Controller (ViewModel) +class QuestionExplanationController extends ChangeNotifier { + final ExplanationService _service; + + QuestionExplanationState _state = + const QuestionExplanationState.initial(); + QuestionExplanationState get state => _state; + + QuestionExplanationController({required ExplanationService service}) + : _service = service; + + /// 이미 생성된 해설만 조회하는 진입 시 로딩용 메서드 + /// + /// 해설이 없으면 POST를 수행하지 않고, [lastRequestPerformed]도 false로 유지됩니다. + Future loadExisting(ExplanationSource source) async { + _setState( + _state.copyWith( + isLoading: true, + errorMessage: null, + lastRequestPerformed: false, + ), + ); + try { + final existing = await _service.loadExistingExplanation(source); + _setState( + _state.copyWith( + isLoading: false, + errorMessage: null, + explanation: existing, + lastRequestPerformed: false, + ), + ); + } catch (_) { + _setState( + _state.copyWith( + isLoading: false, + errorMessage: '해설을 불러오지 못했습니다.', + lastRequestPerformed: false, + ), + ); + } + } + + /// 해설 요청 버튼 클릭 시 사용하는 메서드 + /// + /// 필요 시 해설 생성 POST까지 수행하며, 이때 [lastRequestPerformed]가 true가 됩니다. + Future load(ExplanationSource source) async { + _setState( + _state.copyWith( + isLoading: true, + errorMessage: null, + lastRequestPerformed: false, + ), + ); + try { + final result = await _service.loadOrRequestExplanation(source); + _setState( + _state.copyWith( + isLoading: false, + errorMessage: null, + explanation: result.explanation, + lastRequestPerformed: result.requestPerformed, + ), + ); + } catch (_) { + _setState( + _state.copyWith( + isLoading: false, + errorMessage: '해설을 불러오지 못했습니다.', + lastRequestPerformed: false, + ), + ); + } + } + + Future requestAgain(ExplanationSource source) async { + await load(source); + } + + void _setState(QuestionExplanationState newState) { + _state = newState; + notifyListeners(); + } +} + diff --git a/frontend/lib/application/notification/notification_notifier.dart b/frontend/lib/application/notification/notification_notifier.dart new file mode 100644 index 0000000..591e503 --- /dev/null +++ b/frontend/lib/application/notification/notification_notifier.dart @@ -0,0 +1,103 @@ +import 'dart:developer' as developer; +import 'package:flutter/foundation.dart'; +import '../../domain/notification/notification_entity.dart'; +import '../../domain/notification/notification_repository.dart'; + +/// 알림 상태 관리자 (ValueNotifier 기반) +class NotificationNotifier { + final NotificationRepository repository; + + NotificationNotifier(this.repository); + + /// 알림 목록 상태 + final ValueNotifier> notifications = + ValueNotifier>([]); + + /// 로딩 상태 + final ValueNotifier isLoading = ValueNotifier(false); + + /// 읽지 않은 알림 개수 + int get unreadCount => + notifications.value.where((n) => !n.isRead).length; + + /// 알림 목록 로드 + Future load() async { + isLoading.value = true; + try { + final list = await repository.fetchNotifications(); + notifications.value = list; + developer.log('✅ 알림 목록 로드 완료: ${list.length}개'); + } catch (e) { + developer.log('❌ 알림 목록 로드 실패: $e'); + notifications.value = []; + } finally { + isLoading.value = false; + } + } + + /// 알림 읽음 처리 + Future markAsRead(String id) async { + try { + await repository.markAsRead(id); + // 로컬 상태 업데이트 + notifications.value = notifications.value + .map((n) => n.id == id ? n.copyWith(isRead: true) : n) + .toList(); + developer.log('✅ 알림 읽음 처리 완료: $id'); + } catch (e) { + developer.log('❌ 알림 읽음 처리 실패: $e'); + rethrow; + } + } + + /// 전체 알림 읽음 처리 + Future markAllAsRead() async { + try { + await repository.markAllAsRead(); + // 로컬 상태 업데이트 + notifications.value = + notifications.value.map((n) => n.copyWith(isRead: true)).toList(); + developer.log('✅ 전체 알림 읽음 처리 완료'); + } catch (e) { + developer.log('❌ 전체 알림 읽음 처리 실패: $e'); + rethrow; + } + } + + /// 알림 삭제 + Future delete(String id) async { + try { + await repository.delete(id); + // 로컬 상태 업데이트 + notifications.value = + notifications.value.where((n) => n.id != id).toList(); + developer.log('✅ 알림 삭제 완료: $id'); + } catch (e) { + developer.log('❌ 알림 삭제 실패: $e'); + rethrow; + } + } + + /// FCM 알림 추가 (Repository 저장 + 로컬 상태 즉시 반영) + Future addFromFCM(NotificationEntity notification) async { + try { + await repository.saveNotification(notification); + // 로컬 상태에 즉시 추가 (최신순 유지) + final currentList = notifications.value; + final filtered = currentList.where((n) => n.id != notification.id).toList(); + filtered.insert(0, notification); + notifications.value = filtered; + developer.log('✅ FCM 알림 추가 완료: ${notification.id}'); + } catch (e) { + developer.log('❌ FCM 알림 추가 실패: $e'); + rethrow; + } + } + + /// 리소스 정리 + void dispose() { + notifications.dispose(); + isLoading.dispose(); + } +} + diff --git a/frontend/lib/config/api_config.dart b/frontend/lib/config/api_config.dart new file mode 100644 index 0000000..c258972 --- /dev/null +++ b/frontend/lib/config/api_config.dart @@ -0,0 +1,115 @@ +/// API 설정 관리 +/// +/// 환경별 서버 URL 및 엔드포인트를 중앙에서 관리합니다. +/// TODO: 환경별로 분리 (개발/프로덕션) +class ApiConfig { + // 기본 서버 URL + static const String baseUrl = 'https://3.34.214.133'; + + // ========== 엔드포인트 정의 ========== + + // 사용자 정보 + static const String meEndpoint = '/me'; + + // 인증 관련 + static const String signInEndpoint = '/sign-in'; + static const String signUpEndpoint = '/sign-up'; + static const String refreshTokenEndpoint = '/refresh-token'; + + // 계정 관리 + static const String checkAccountIdEndpoint = '/users/check-accountId'; + static const String resetPasswordEndpoint = '/users/reset-password'; + static const String changeResetPasswordEndpoint = '/change/reset_password'; + static const String signOutEndpoint = '/sign-out'; + static const String uploadStreamEndpoint = '/storage/upload-stream'; + static const String uploadUrlBatchEndpoint = '/storage/upload-url/batch'; + + // 인증 코드 발송 + static const String sendCodeSignUpEndpoint = '/send-code/sign_up'; + static const String sendCodeResetPasswordEndpoint = + '/send-code/reset_password'; + static const String sendCodeFindAccountEndpoint = '/send-code/find_account'; + + // 인증 코드 확인 + static const String verifySignUpEndpoint = '/verify/sign_up'; + static const String verifyResetPasswordEndpoint = '/verify/reset_password'; + static const String verifyFindAccountEndpoint = '/verify/find_account'; + + // Assessment 관련 + static const String assessmentsAssigneeEndpoint = + '/grading/assessments/assignee'; + + // Academy 관련 + static const String academyClassesEndpoint = '/academy/academy-users/classes'; + + // ========== URI 생성 헬퍼 메서드 ========== + + // 사용자 정보 + static Uri getMeUri() => Uri.parse('$baseUrl$meEndpoint'); + + // 인증 관련 + static Uri getSignInUri() => Uri.parse('$baseUrl$signInEndpoint'); + static Uri getSignUpUri() => Uri.parse('$baseUrl$signUpEndpoint'); + static Uri getRefreshTokenUri() => Uri.parse('$baseUrl$refreshTokenEndpoint'); + + // 계정 관리 + static Uri getCheckAccountIdUri(String accountId) => + Uri.parse('$baseUrl$checkAccountIdEndpoint?accountId=$accountId'); + static Uri getResetPasswordUri() => + Uri.parse('$baseUrl$resetPasswordEndpoint'); + static Uri getChangeResetPasswordUri() => + Uri.parse('$baseUrl$changeResetPasswordEndpoint'); + static Uri getSignOutUri() => Uri.parse('$baseUrl$signOutEndpoint'); + static Uri getUploadStreamUri() => Uri.parse('$baseUrl$uploadStreamEndpoint'); + static Uri getUploadUrlBatchUri() => + Uri.parse('$baseUrl$uploadUrlBatchEndpoint'); + + // 인증 코드 발송 + static Uri getSendCodeSignUpUri() => + Uri.parse('$baseUrl$sendCodeSignUpEndpoint'); + static Uri getSendCodeResetPasswordUri() => + Uri.parse('$baseUrl$sendCodeResetPasswordEndpoint'); + static Uri getSendCodeFindAccountUri() => + Uri.parse('$baseUrl$sendCodeFindAccountEndpoint'); + + // 인증 코드 확인 + static Uri getVerifySignUpUri() => Uri.parse('$baseUrl$verifySignUpEndpoint'); + static Uri getVerifyResetPasswordUri() => + Uri.parse('$baseUrl$verifyResetPasswordEndpoint'); + static Uri getVerifyFindAccountUri() => + Uri.parse('$baseUrl$verifyFindAccountEndpoint'); + + // Assessment 관련 + static Uri getAssessmentsAssigneeUri( + String userAcademyId, { + DateTime? dateTime, // ISO 8601 형식으로 변환할 DateTime + String? iso8601String, // 직접 ISO 8601 문자열 전달 (선택사항) + }) { + // 새로운 방식: DateTime을 ISO 8601 형식으로 변환 + if (dateTime != null) { + final monthStart = DateTime.utc(dateTime.year, dateTime.month, 1); + final iso8601Str = monthStart.toIso8601String().split('.').first; + final encodedIso8601 = Uri.encodeComponent(iso8601Str); + return Uri.parse( + '$baseUrl$assessmentsAssigneeEndpoint/$userAcademyId/$encodedIso8601', + ); + } + + // 직접 ISO 8601 문자열 전달 + if (iso8601String != null) { + final encodedIso8601 = Uri.encodeComponent(iso8601String); + return Uri.parse( + '$baseUrl$assessmentsAssigneeEndpoint/$userAcademyId/$encodedIso8601', + ); + } + + // dateTime과 iso8601String이 모두 null이면 예외 발생 + throw ArgumentError('dateTime 또는 iso8601String 중 하나는 필수입니다.'); + } + + // Academy 관련 + static Uri getAcademyClassesUri(List assigneeIds) { + final idsParam = assigneeIds.join(','); + return Uri.parse('$baseUrl$academyClassesEndpoint?ids=$idsParam'); + } +} diff --git a/frontend/lib/config/app_dependencies.dart b/frontend/lib/config/app_dependencies.dart new file mode 100644 index 0000000..46089bc --- /dev/null +++ b/frontend/lib/config/app_dependencies.dart @@ -0,0 +1,46 @@ +import 'package:get_it/get_it.dart'; + +import '../domain/chapter/get_chapters_for_book_use_case.dart'; +import '../services/get_chapter_question_statuses_use_case.dart'; +import '../domain/student_answer/student_answer_repository.dart'; +import '../domain/student_answer/get_student_answers_for_response_use_case.dart'; +import '../domain/student_answer/update_student_answers_use_case.dart'; +import '../domain/student_answer/update_single_student_answer_use_case.dart'; +import '../domain/section_image/get_section_image_use_case.dart'; + +final GetIt _getIt = GetIt.instance; + +/// AppDependencies는 과거 static 싱글톤을 사용하던 코드를 위한 +/// **임시 래퍼**입니다. +/// +/// 새 코드는 `getIt<>()`을 직접 사용하는 것을 권장하며, +/// 이 클래스는 점진적으로 제거될 예정입니다. +@Deprecated('Use getIt<>() from di_container.dart directly instead.') +class AppDependencies { + // Chapter + static GetChaptersForBookUseCase get getChaptersForBookUseCase => + _getIt(); + + static GetChapterQuestionStatusesUseCase + get getChapterQuestionStatusesUseCase => + _getIt(); + + // Student Answer + static StudentAnswerRepository get studentAnswerRepository => + _getIt(); + + static GetStudentAnswersForResponseUseCase + get getStudentAnswersForResponseUseCase => + _getIt(); + + static UpdateStudentAnswersUseCase get updateStudentAnswersUseCase => + _getIt(); + + static UpdateSingleStudentAnswerUseCase + get updateSingleStudentAnswerUseCase => + _getIt(); + + // Section Image + static GetSectionImageUseCase get getSectionImageUseCase => + _getIt(); +} diff --git a/frontend/lib/config/di_container.dart b/frontend/lib/config/di_container.dart new file mode 100644 index 0000000..d8d3eb4 --- /dev/null +++ b/frontend/lib/config/di_container.dart @@ -0,0 +1,359 @@ +import 'package:get_it/get_it.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../services/auth_service.dart'; +import '../services/location_service.dart'; +import '../services/user_service.dart'; +import '../services/academy_service.dart'; +import '../data/notification/notification_local_data_source.dart'; +import '../services/assessment_local_store.dart'; +import '../domain/notification/notification_repository.dart'; +import '../data/notification/notification_repository_impl.dart'; +import '../services/fcm_service.dart'; +import '../services/upload_sse_service.dart'; +import '../services/grading_history_api.dart'; +import '../services/workbook_api.dart'; +import '../services/assessment_api.dart'; +import '../services/continuous_learning_api.dart'; +import '../services/student_answer_api.dart'; +import '../services/section_image_api.dart'; +import '../data/chapter/chapter_api.dart'; +import '../data/mappers/grading_history_mapper.dart'; +import '../data/mappers/workbook_mapper.dart'; +import '../services/student_answer_mapper.dart'; +import '../services/section_image_mapper.dart'; +import '../domain/grading_history/grading_history_repository.dart'; +import '../domain/workbook/workbook_repository.dart'; +import '../domain/student_answer/student_answer_repository.dart'; +import '../domain/section_image/section_image_repository.dart'; +import '../domain/chapter/chapter_repository.dart'; +import '../services/assessment_repository.dart'; +import '../services/grading_history_repository_impl.dart'; +import '../services/workbook_repository_impl.dart'; +import '../services/student_answer_repository_impl.dart'; +import '../services/section_image_repository_impl.dart'; +import '../services/explanation_repository_impl.dart'; +import '../data/chapter/chapter_repository_impl.dart'; +import '../domain/chapter/get_chapters_for_book_use_case.dart'; +import '../services/get_chapter_question_statuses_use_case.dart'; +import '../domain/student_answer/get_student_answers_for_response_use_case.dart'; +import '../domain/student_answer/update_student_answers_use_case.dart'; +import '../domain/student_answer/update_single_student_answer_use_case.dart'; +import '../domain/section_image/get_section_image_use_case.dart'; +import '../services/get_monthly_learning_status_use_case_impl.dart'; +import '../domain/learning/get_monthly_learning_status_use_case.dart'; +import '../services/learning_completion_service_impl.dart'; +import '../services/policies/answer_selection_policy.dart'; +import '../services/explanation_api.dart'; +import '../data/explanation/explanation_mapper.dart'; +import '../domain/explanation/explanation_repository.dart'; +import '../domain/explanation/get_explanation_use_case.dart'; +import '../domain/explanation/request_explanation_use_case.dart'; +import '../application/explanation/explanation_service.dart'; +import '../application/explanation/question_explanation_controller.dart'; +import '../services/daily_learning_service.dart'; + +/// 전역 DI Container 인스턴스 +final getIt = GetIt.instance; + +/// Core 의존성 등록 (외부 라이브러리 등) +void registerCore() { + getIt.registerLazySingleton(() => http.Client()); +} + +/// Infrastructure Services 등록 +void registerInfrastructureServices() { + // Auth & User & Academy & Location + getIt.registerLazySingleton(() => AuthService()); + getIt.registerLazySingleton(() => LocationService()); + getIt.registerLazySingleton( + () => UserService(authService: getIt()), + ); + getIt.registerLazySingleton( + () => AcademyService(authService: getIt()), + ); + + // Notification Repository + getIt.registerLazySingleton( + () => NotificationRepositoryImpl( + getIt(), + ), + ); + + // FCM Service (상태 없는 singleton) + getIt.registerLazySingleton( + () => FCMService( + notificationRepository: getIt(), + ), + ); + + // Upload SSE Service (연결 상태를 가지므로 factory) + getIt.registerFactory( + () => UploadSseService( + authService: getIt(), + ), + ); + + // Daily Learning Service + getIt.registerLazySingleton( + () => DailyLearningService( + workbookApi: getIt(), + academyService: getIt(), + authService: getIt(), + ), + ); +} + +/// Data Sources 등록 +void registerDataSources({required SharedPreferences sharedPrefs}) { + // SharedPreferences를 singleton으로 등록 + getIt.registerSingleton(sharedPrefs); + + // Local stores / data sources + getIt.registerLazySingleton(() => AssessmentLocalStore()); + + getIt.registerLazySingleton( + () => NotificationLocalDataSource(getIt()), + ); +} + +/// Mapper 등록 +void registerMappers() { + getIt.registerLazySingleton(() => GradingHistoryMapper()); + getIt.registerLazySingleton(() => WorkbookMapper()); + getIt.registerLazySingleton(() => StudentAnswerMapper()); + getIt.registerLazySingleton(() => SectionImageMapper()); + getIt.registerLazySingleton(() => ExplanationMapper()); +} + +/// API 등록 +void registerApis() { + getIt.registerLazySingleton( + () => GradingHistoryApi( + authService: getIt(), + httpClient: getIt(), + ), + ); + + getIt.registerLazySingleton( + () => WorkbookApi( + authService: getIt(), + httpClient: getIt(), + ), + ); + + getIt.registerLazySingleton( + () => AssessmentApi( + authService: getIt(), + httpClient: getIt(), + ), + ); + + getIt.registerLazySingleton( + () => ContinuousLearningApi( + authService: getIt(), + httpClient: getIt(), + ), + ); + + getIt.registerLazySingleton( + () => StudentAnswerApi( + authService: getIt(), + httpClient: getIt(), + ), + ); + + getIt.registerLazySingleton( + () => SectionImageApi( + authService: getIt(), + httpClient: getIt(), + ), + ); + + getIt.registerLazySingleton( + () => ChapterApi( + authService: getIt(), + httpClient: getIt(), + ), + ); + + getIt.registerLazySingleton( + () => ExplanationApi( + tokenProvider: () => getIt().ensureValidAccessToken(), + httpClient: getIt(), + ), + ); +} + +/// Repository 등록 +void registerRepositories() { + // Impl 등록 + getIt.registerLazySingleton( + () => AssessmentRepository( + api: getIt(), + localStore: getIt(), + academyService: getIt(), + ), + ); + + getIt.registerLazySingleton( + () => GradingHistoryRepositoryImpl( + api: getIt(), + mapper: getIt(), + academyService: getIt(), + ), + ); + + getIt.registerLazySingleton( + () => WorkbookRepositoryImpl( + api: getIt(), + mapper: getIt(), + academyService: getIt(), + ), + ); + + getIt.registerLazySingleton( + () => StudentAnswerRepositoryImpl( + api: getIt(), + mapper: getIt(), + ), + ); + + getIt.registerLazySingleton( + () => SectionImageRepositoryImpl( + api: getIt(), + mapper: getIt(), + ), + ); + + getIt.registerLazySingleton( + () => ChapterRepositoryImpl( + api: getIt(), + ), + ); + + getIt.registerLazySingleton( + () => ExplanationRepositoryImpl( + api: getIt(), + mapper: getIt(), + ), + ); + + // Interface 타입으로도 등록 + getIt.registerLazySingleton( + () => getIt(), + ); + getIt.registerLazySingleton( + () => getIt(), + ); + getIt.registerLazySingleton( + () => getIt(), + ); + getIt.registerLazySingleton( + () => getIt(), + ); + getIt.registerLazySingleton( + () => getIt(), + ); + getIt.registerLazySingleton( + () => getIt(), + ); +} + +/// UseCase 등록 +void registerUseCases() { + // Chapter + getIt.registerLazySingleton( + () => GetChaptersForBookUseCase( + repository: getIt(), + ), + ); + + getIt.registerLazySingleton( + () => GetChapterQuestionStatusesUseCase( + chapterRepository: getIt(), + studentAnswerRepository: getIt(), + selectionPolicy: AnswerSelectionPolicy(), + ), + ); + + // Student Answer + getIt.registerLazySingleton( + () => GetStudentAnswersForResponseUseCase( + repository: getIt(), + ), + ); + + getIt.registerLazySingleton( + () => UpdateStudentAnswersUseCase( + repository: getIt(), + ), + ); + + getIt.registerLazySingleton( + () => UpdateSingleStudentAnswerUseCase( + api: getIt(), + ), + ); + + // Section Image + getIt.registerLazySingleton( + () => GetSectionImageUseCase( + repository: getIt(), + ), + ); + + // Monthly Learning Status + getIt.registerLazySingleton( + () => GetMonthlyLearningStatusUseCaseImpl( + assessmentRepository: getIt(), + gradingHistoryRepository: getIt(), + completionService: const LearningCompletionServiceImpl(), + ), + ); + + // Explanation + getIt.registerLazySingleton( + () => GetExplanationUseCase( + repository: getIt(), + ), + ); + + getIt.registerLazySingleton( + () => RequestExplanationUseCase( + repository: getIt(), + ), + ); + + getIt.registerLazySingleton( + () => ExplanationService( + repository: getIt(), + getExplanationUseCase: getIt(), + requestExplanationUseCase: getIt(), + authService: getIt(), + academyService: getIt(), + ), + ); + + getIt.registerFactory( + () => QuestionExplanationController( + service: getIt(), + ), + ); +} + +/// DI Container 초기화 +/// +/// SharedPreferences는 main.dart에서 한 번만 생성해 주입합니다. +Future setupDependencies({required SharedPreferences sharedPrefs}) async { + registerCore(); + registerInfrastructureServices(); + registerDataSources(sharedPrefs: sharedPrefs); + registerMappers(); + registerApis(); + registerRepositories(); + registerUseCases(); +} + + diff --git a/frontend/lib/constants/learning_widget_spacing.dart b/frontend/lib/constants/learning_widget_spacing.dart new file mode 100644 index 0000000..b202a30 --- /dev/null +++ b/frontend/lib/constants/learning_widget_spacing.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; + +/// 연속 학습 위젯의 spacing 상수 정의 +/// +/// 디바이스 tier별로 고정 px 값을 제공합니다. +/// - Phone: 기본값 +/// - Tablet: 중간 크기 +/// - Large Tablet: 큰 태블릿 +class LearningWidgetSpacing { + LearningWidgetSpacing._(); + + /// 디바이스가 태블릿인지 확인 + static bool isTablet(BuildContext context) { + final width = MediaQuery.of(context).size.width; + return width >= 600 && width < 1200; + } + + /// 디바이스가 대형 태블릿인지 확인 + static bool isLargeTablet(BuildContext context) { + final width = MediaQuery.of(context).size.width; + return width >= 1200; + } + + /// ListView 외부 padding (좌우) + /// Phone: 20px, Tablet: 32px, Large Tablet: 40px + static double getOuterPadding(BuildContext context) { + if (isLargeTablet(context)) return 40.0; + if (isTablet(context)) return 32.0; + return 20.0; + } + + /// DateItem 내부 padding (좌우) + /// Phone: 4px, Tablet: 8px, Large Tablet: 12px + static double getInnerPadding(BuildContext context) { + if (isLargeTablet(context)) return 12.0; + if (isTablet(context)) return 8.0; + return 4.0; + } + + /// Connector 너비 (연결선) + /// Phone: 6px, Tablet: 10px, Large Tablet: 14px + static double getConnectorWidth(BuildContext context) { + if (isLargeTablet(context)) return 14.0; + if (isTablet(context)) return 10.0; + return 6.0; + } + + /// 힌트 내부 좌우 padding + /// Phone: 12px, Tablet: 16px, Large Tablet: 20px + static double getHintInnerHorizontalPadding(BuildContext context) { + if (isLargeTablet(context)) return 20.0; + if (isTablet(context)) return 16.0; + return 12.0; + } + + /// 힌트 내부 상하 padding + /// Phone: 8px, Tablet: 10px, Large Tablet: 12px + static double getHintInnerVerticalPadding(BuildContext context) { + if (isLargeTablet(context)) return 12.0; + if (isTablet(context)) return 10.0; + return 8.0; + } + + /// 힌트 외부 좌우 padding (고정값) + static const double hintOuterHorizontalPadding = 8.0; + + /// 힌트 외부 상하 padding (고정값) + static const double hintOuterVerticalPadding = 8.0; +} + diff --git a/frontend/lib/data/chapter/chapter_api.dart b/frontend/lib/data/chapter/chapter_api.dart new file mode 100644 index 0000000..057c559 --- /dev/null +++ b/frontend/lib/data/chapter/chapter_api.dart @@ -0,0 +1,244 @@ +import 'dart:convert'; +import 'dart:developer' as developer; +import 'package:http/http.dart' as http; +import '../../config/api_config.dart'; +import '../../services/auth_service.dart'; +import '../../utils/app_logger.dart'; + +/// 챕터 API 응답 DTO +/// +/// 서버 응답 구조를 그대로 반영합니다. +class ChapterApiResponse { + final int chapterId; + final int bookId; + final int mainChapterNumber; + final int subChapterNumber; + final String? chapterName; + final int chapterStartPage; + final int chapterEndPage; + final int chapterStartQuestion; + final int chapterEndQuestion; + final int totalChapterQuestion; + final int studentAnswerCount; + + ChapterApiResponse({ + required this.chapterId, + required this.bookId, + required this.mainChapterNumber, + required this.subChapterNumber, + this.chapterName, + required this.chapterStartPage, + required this.chapterEndPage, + required this.chapterStartQuestion, + required this.chapterEndQuestion, + required this.totalChapterQuestion, + required this.studentAnswerCount, + }); + + factory ChapterApiResponse.fromJson(Map json) { + // 안전한 int 변환 헬퍼 함수 + int _toInt(dynamic value, {int defaultValue = 0}) { + if (value == null) return defaultValue; + if (value is int) return value; + if (value is double) return value.toInt(); + if (value is String) { + final parsed = int.tryParse(value); + if (parsed != null) return parsed; + } + return defaultValue; + } + + // 필수 필드: null이면 예외 발생 (버그 조기 발견) + final chapterId = json['chapter_id']; + final bookId = json['book_id']; + + if (chapterId == null || bookId == null) { + throw FormatException( + 'ChapterApiResponse: chapter_id or book_id is null', + jsonEncode(json), + ); + } + + // count/번호 필드는 0 기본값 허용 + return ChapterApiResponse( + chapterId: _toInt(chapterId), + bookId: _toInt(bookId), + mainChapterNumber: _toInt(json['main_chapter_number']), + subChapterNumber: _toInt(json['sub_chapter_number']), + chapterName: json['chapter_name'] as String?, + chapterStartPage: _toInt(json['chapter_start_page']), + chapterEndPage: _toInt(json['chapter_end_page']), + chapterStartQuestion: _toInt(json['chapter_start_question']), + chapterEndQuestion: _toInt(json['chapter_end_question']), + // 하위 호환성: total_chapter_question이 없으면 total_chapter_problem 사용 + totalChapterQuestion: _toInt( + json['total_chapter_question'] ?? json['total_chapter_problem'], + ), + studentAnswerCount: _toInt(json['student_answer_count']), + ); + } +} + +/// 챕터 API 호출 전용 레이어 +class ChapterApi { + ChapterApi({ + required AuthService authService, + required http.Client httpClient, + }) : _authService = authService, + _httpClient = httpClient; + + final AuthService _authService; + final http.Client _httpClient; + + /// 챕터 목록 조회 + /// + /// **API 스펙**: + /// - Method: GET + /// - Endpoint: `/grading/chapter/book/{book_id}/user/{academy_user_id}` + /// - Headers: `Authorization: Bearer {token}` + /// + /// [bookId]: 문제집 ID + /// [academyUserId]: 학원 사용자 ID + /// + /// 반환: 챕터 API 응답 리스트 + Future> fetchChapters( + int bookId, + int academyUserId, + ) async { + appLog( + '📖 [ChapterApi] 챕터 목록 조회 시작 - bookId: $bookId, academyUserId: $academyUserId', + ); + + final token = await _authService.ensureValidAccessToken(); + if (token == null) { + appLog('❌ [ChapterApi] 인증 토큰이 없습니다.'); + throw Exception('인증 토큰이 없습니다. 로그인이 필요합니다.'); + } + + final uri = Uri.parse( + '${ApiConfig.baseUrl}/grading/chapter/book/$bookId/user/$academyUserId', + ); + + appLog('📖 [ChapterApi] GET $uri'); + developer.log('📖 [ChapterApi] GET $uri'); + + final response = await _httpClient + .get( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ) + .timeout(const Duration(seconds: 10)); + + appLog('📖 [ChapterApi] Response status: ${response.statusCode}'); + developer.log('📖 [ChapterApi] Response status: ${response.statusCode}'); + + if (response.statusCode == 401) { + appLog('❌ [ChapterApi] 인증 실패 (401)'); + throw Exception('인증에 실패했습니다. 다시 로그인해주세요.'); + } + if (response.statusCode != 200) { + appLog('❌ [ChapterApi] API 호출 실패 (status: ${response.statusCode})'); + throw Exception('Chapter API 호출 실패 (status: ${response.statusCode})'); + } + + try { + final List data = json.decode(response.body); + appLog('📖 [ChapterApi] 응답 파싱 성공 - 챕터 개수: ${data.length}'); + + final result = data + .map( + (item) => ChapterApiResponse.fromJson(item as Map), + ) + .toList(); + + // 응답 구조 전체 로그 출력 + appLog('📖 [ChapterApi] 응답 구조:'); + for (var i = 0; i < result.length; i++) { + final chapter = result[i]; + appLog(' [챕터 ${i + 1}]'); + appLog(' - chapterId: ${chapter.chapterId}'); + appLog(' - bookId: ${chapter.bookId}'); + appLog(' - mainChapterNumber: ${chapter.mainChapterNumber}'); + appLog(' - subChapterNumber: ${chapter.subChapterNumber}'); + appLog(' - chapterName: ${chapter.chapterName ?? "null"}'); + appLog(' - chapterStartPage: ${chapter.chapterStartPage}'); + appLog(' - chapterEndPage: ${chapter.chapterEndPage}'); + appLog(' - chapterStartQuestion: ${chapter.chapterStartQuestion}'); + appLog(' - chapterEndQuestion: ${chapter.chapterEndQuestion}'); + appLog(' - totalChapterQuestion: ${chapter.totalChapterQuestion}'); + appLog(' - studentAnswerCount: ${chapter.studentAnswerCount}'); + } + + return result; + } catch (e) { + appLog('❌ [ChapterApi] 응답 파싱 실패: $e'); + appLog('❌ [ChapterApi] Response body: ${response.body}'); + developer.log('❌ [ChapterApi] 응답 파싱 실패: $e'); + developer.log('❌ [ChapterApi] Response body: ${response.body}'); + rethrow; + } + } + + /// chapterId로 단일 챕터 정보 조회 + /// + /// **API 스펙**: + /// - Method: GET + /// - Endpoint: `/grading/chapter/{chapter_id}` + /// - Headers: `Authorization: Bearer {token}` + /// + /// [chapterId]: 챕터 ID + /// + /// 반환: 챕터 API 응답 + Future fetchChapterById(int chapterId) async { + appLog('📖 [ChapterApi] 챕터 조회 시작 - chapterId: $chapterId'); + + final token = await _authService.ensureValidAccessToken(); + if (token == null) { + appLog('❌ [ChapterApi] 인증 토큰이 없습니다.'); + throw Exception('인증 토큰이 없습니다. 로그인이 필요합니다.'); + } + + final uri = Uri.parse('${ApiConfig.baseUrl}/grading/chapter/$chapterId'); + + appLog('📖 [ChapterApi] GET $uri'); + developer.log('📖 [ChapterApi] GET $uri'); + + final response = await _httpClient + .get( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ) + .timeout(const Duration(seconds: 10)); + + appLog('📖 [ChapterApi] Response status: ${response.statusCode}'); + developer.log('📖 [ChapterApi] Response status: ${response.statusCode}'); + + if (response.statusCode == 401) { + appLog('❌ [ChapterApi] 인증 실패 (401)'); + throw Exception('인증에 실패했습니다. 다시 로그인해주세요.'); + } + if (response.statusCode != 200) { + appLog('❌ [ChapterApi] API 호출 실패 (status: ${response.statusCode})'); + throw Exception('Chapter API 호출 실패 (status: ${response.statusCode})'); + } + + try { + final Map data = json.decode(response.body); + appLog('📖 [ChapterApi] 응답 파싱 성공'); + + return ChapterApiResponse.fromJson(data); + } catch (e) { + appLog('❌ [ChapterApi] 응답 파싱 실패: $e'); + appLog('❌ [ChapterApi] Response body: ${response.body}'); + developer.log('❌ [ChapterApi] 응답 파싱 실패: $e'); + developer.log('❌ [ChapterApi] Response body: ${response.body}'); + rethrow; + } + } +} diff --git a/frontend/lib/data/chapter/chapter_repository_impl.dart b/frontend/lib/data/chapter/chapter_repository_impl.dart new file mode 100644 index 0000000..9616256 --- /dev/null +++ b/frontend/lib/data/chapter/chapter_repository_impl.dart @@ -0,0 +1,70 @@ +import 'dart:developer' as developer; +import '../../domain/chapter/chapter_entity.dart'; +import '../../domain/chapter/chapter_repository.dart'; +import 'chapter_api.dart'; + +/// 챕터 Repository 구현체 +/// +/// API 호출 및 DTO → Entity 변환을 담당합니다. +class ChapterRepositoryImpl implements ChapterRepository { + ChapterRepositoryImpl({required ChapterApi api}) : _api = api; + + final ChapterApi _api; + + @override + Future> getChaptersByBookIdAndAcademyUserId( + int bookId, + int academyUserId, + ) async { + try { + final apiResponses = await _api.fetchChapters(bookId, academyUserId); + + // API 응답 → 도메인 엔티티 변환 + return apiResponses + .map( + (response) => ChapterEntity( + chapterId: response.chapterId, + bookId: response.bookId, + mainChapterNumber: response.mainChapterNumber, + subChapterNumber: response.subChapterNumber, + chapterName: response.chapterName, + chapterStartPage: response.chapterStartPage, + chapterEndPage: response.chapterEndPage, + chapterStartQuestion: response.chapterStartQuestion, + chapterEndQuestion: response.chapterEndQuestion, + totalChapterQuestion: response.totalChapterQuestion, + studentAnswerCount: response.studentAnswerCount, + ), + ) + .toList(); + } catch (e) { + developer.log('❌ [ChapterRepository] API 호출 실패: $e'); + rethrow; + } + } + + @override + Future getChapterById(int chapterId) async { + try { + final apiResponse = await _api.fetchChapterById(chapterId); + + // API 응답 → 도메인 엔티티 변환 + return ChapterEntity( + chapterId: apiResponse.chapterId, + bookId: apiResponse.bookId, + mainChapterNumber: apiResponse.mainChapterNumber, + subChapterNumber: apiResponse.subChapterNumber, + chapterName: apiResponse.chapterName, + chapterStartPage: apiResponse.chapterStartPage, + chapterEndPage: apiResponse.chapterEndPage, + chapterStartQuestion: apiResponse.chapterStartQuestion, + chapterEndQuestion: apiResponse.chapterEndQuestion, + totalChapterQuestion: apiResponse.totalChapterQuestion, + studentAnswerCount: apiResponse.studentAnswerCount, + ); + } catch (e) { + developer.log('❌ [ChapterRepository] 챕터 조회 실패: $e'); + rethrow; + } + } +} diff --git a/frontend/lib/data/explanation/explanation_mapper.dart b/frontend/lib/data/explanation/explanation_mapper.dart new file mode 100644 index 0000000..0579aa3 --- /dev/null +++ b/frontend/lib/data/explanation/explanation_mapper.dart @@ -0,0 +1,46 @@ +import '../../domain/question/question_identifier.dart'; +import '../../domain/explanation/explanation_entity.dart'; + +/// Explanation API 응답을 도메인 엔티티로 변환하는 Mapper +class ExplanationMapper { + /// POST /grading/student-answers/explanation 응답 파싱 + ExplanationEntity fromPostResponse(Map json) { + final explanation = + json['explanation'] as Map? ?? const {}; + + final question = QuestionIdentifier( + bookId: explanation['book_id'] as int? ?? 0, + chapterId: explanation['chapter_id'] as int? ?? 0, + page: explanation['page'] as int? ?? 0, + questionNumber: explanation['question_number'] as int? ?? 0, + subQuestionNumber: explanation['sub_question_number'] as int? ?? 0, + ); + + final text = explanation['explanation'] as String? ?? ''; + + return ExplanationEntity( + question: question, + text: text, + ); + } + + /// GET /grading/student-answers/get-explanation 응답 파싱 + ExplanationEntity fromGetResponse(Map json) { + final question = QuestionIdentifier( + bookId: json['book_id'] as int? ?? 0, + chapterId: json['chapter_id'] as int? ?? 0, + page: json['page'] as int? ?? 0, + questionNumber: json['question_number'] as int? ?? 0, + subQuestionNumber: json['sub_question_number'] as int? ?? 0, + ); + + final text = json['explanation'] as String? ?? ''; + + return ExplanationEntity( + question: question, + text: text, + ); + } +} + + diff --git a/frontend/lib/data/mappers/grading_history_mapper.dart b/frontend/lib/data/mappers/grading_history_mapper.dart new file mode 100644 index 0000000..eaaeaf5 --- /dev/null +++ b/frontend/lib/data/mappers/grading_history_mapper.dart @@ -0,0 +1,68 @@ +import '../../domain/grading_history/grading_history_entity.dart'; +import '../../services/grading_history_api.dart'; + +/// Grading History Mapper +/// +/// API 응답을 도메인 엔티티로 변환하는 책임만을 가집니다. +class GradingHistoryMapper { + /// API 응답 리스트를 도메인 엔티티 리스트로 변환 + /// + /// [responses]: API 응답 리스트 (flat array) + /// [classNameMap]: academyUserId → className 매핑 + /// + /// 반환값: 도메인 엔티티 리스트 (정렬 없음, 순수 변환만) + /// 정렬은 UI 레이어에서 처리합니다. + List convertApiResponsesToEntities( + List responses, + Map classNameMap, + ) { + return responses.map((response) { + // 1. createdAt 파싱 (UTC → 로컬 변환, 방어 로직 포함) + final createdAt = _parseCreatedAt(response.createdAt); + + // 2. className 주입 + final className = classNameMap[response.academyUserId]; + + // 3. 엔티티 생성 + return GradingHistoryEntity( + studentResponseId: response.studentResponseId, + academyUserId: response.academyUserId, + bookId: response.bookId, + bookName: response.bookName, + bookCoverImageUrl: response.bookImageUrl, + startPage: response.responseStartPage, + endPage: response.responseEndPage, + className: className, + gradingDate: createdAt, + assessId: response.assessId, + unrecognizedResponseCount: response.unrecognizedResponseCount, + ); + }).toList(); + } + + /// createdAt 문자열을 DateTime으로 안전하게 파싱 + /// + /// 빈 문자열이거나 파싱 실패 시 과거 고정값 반환 (정렬 시 뒤로 가도록) + /// + /// 규칙: + /// - created_at은 서버에서 UTC 기준 ISO 문자열로 내려온다는 전제 하에 toLocal() 적용 + /// - 만약 서버가 이미 KST로 내려주면 toLocal() 제거 필요 + /// - 파싱 실패 시 DateTime(1970, 1, 1) 반환 (정렬 시 가장 뒤로) + DateTime _parseCreatedAt(String value) { + if (value.isEmpty) { + // 빈 문자열이면 과거 고정값 반환 (정렬 시 뒤로 가도록) + return DateTime(1970, 1, 1); + } + + try { + // created_at은 서버에서 UTC 기준 ISO 문자열로 내려온다는 전제 하에 toLocal() 적용 + // 만약 서버가 이미 KST로 내려주면 toLocal() 제거 필요 + return DateTime.parse(value).toLocal(); + } catch (_) { + // 파싱 실패 시 과거 고정값 반환 (정렬 시 가장 뒤로) + return DateTime(1970, 1, 1); + } + } +} + + diff --git a/frontend/lib/data/mappers/workbook_mapper.dart b/frontend/lib/data/mappers/workbook_mapper.dart new file mode 100644 index 0000000..24b6714 --- /dev/null +++ b/frontend/lib/data/mappers/workbook_mapper.dart @@ -0,0 +1,178 @@ +import '../../domain/workbook/workbook_summary_entity.dart'; +import '../../services/workbook_api.dart'; +import '../../screens/workbook/models/class_data.dart'; +import '../../screens/workbook/models/workbook_info.dart'; +import '../../screens/workbook/models/workbook_data.dart'; + +/// Workbook 데이터 변환 Mapper +/// +/// API 응답을 도메인 엔티티 및 UI 모델로 변환하는 책임만을 가집니다. +class WorkbookMapper { + /// API 응답을 WorkbookSummaryEntity로 변환 + /// + /// [apiResponses]: API 응답 리스트 (여러 academyUserId) + /// [classNameMap]: academyUserId → className 매핑 (Repository에서 조회됨) + Future>> convertApiResponsesToSummaries( + List apiResponses, + Map classNameMap, + ) async { + final summariesMap = >{}; + + for (final apiResponse in apiResponses) { + final summaries = []; + final className = classNameMap[apiResponse.academyUserId]; + + for (final bookData in apiResponse.books) { + // latest_updated_at 파싱 + DateTime? lastStudyDate; + if (bookData.latestUpdatedAt != null && + bookData.latestUpdatedAt!.isNotEmpty) { + try { + lastStudyDate = DateTime.parse(bookData.latestUpdatedAt!).toLocal(); + } catch (e) { + // 파싱 실패 시 null 유지 + } + } + + // WorkbookSummaryEntity 생성 + final summary = WorkbookSummaryEntity( + bookId: bookData.bookId, + bookName: bookData.bookName ?? '알 수 없는 문제집', + coverImageUrl: bookData.bookImageUrl, + totalPages: bookData.bookPage, // book_page 사용 + totalSolvedPages: bookData.totalSolvedPages, + lastStudyDate: lastStudyDate, + academyUserId: apiResponse.academyUserId, + className: className, + bookSemester: bookData.bookSemester, + ); + + summaries.add(summary); + } + + summariesMap[apiResponse.academyUserId] = summaries; + } + + return summariesMap; + } + + /// WorkbookSummaryEntity 리스트를 ClassData로 변환 + /// + /// DateTime 기준으로 정렬 후 문자열 변환합니다. + List convertToClassData( + Map> summariesByAcademyUserId, + ) { + final classDataList = []; + + summariesByAcademyUserId.forEach((academyUserId, summaries) { + if (summaries.isEmpty) return; + + final className = summaries.first.className ?? '알 수 없는 클래스'; + + // DateTime 기준으로 최신 날짜 찾기 + final lastStudyDate = _getLatestDateTime(summaries); + + // WorkbookInfo 리스트 생성 + final workbooks = summaries.map((summary) { + return WorkbookInfo( + name: summary.bookName, + lastStudyDate: summary.formattedLastStudyDate, + progress: summary.progress, + thumbnailPath: summary.thumbnailPath, + bookId: summary.bookId, + academyUserId: summary.academyUserId, + ); + }).toList(); + + classDataList.add( + ClassData( + className: className, + lastStudyDate: _formatDateTime(lastStudyDate), + workbooks: workbooks, + ), + ); + }); + + // DateTime 기준 정렬 후 문자열 변환 + classDataList.sort((a, b) { + final dateA = _parseFormattedDate(a.lastStudyDate); + final dateB = _parseFormattedDate(b.lastStudyDate); + return dateB.compareTo(dateA); // 최신순 + }); + + return classDataList; + } + + /// WorkbookSummaryEntity 리스트를 WorkbookData로 변환 + /// + /// DateTime 기준으로 정렬 후 문자열 변환합니다. + List convertToWorkbookData( + Map> summariesByAcademyUserId, + ) { + final workbookDataList = []; + + summariesByAcademyUserId.forEach((academyUserId, summaries) { + for (final summary in summaries) { + workbookDataList.add( + WorkbookData( + workbookName: summary.bookName, + lastStudyDate: summary.formattedLastStudyDate, + progress: summary.progress, + thumbnailPath: summary.thumbnailPath, + className: summary.className ?? '알 수 없는 클래스', + bookId: summary.bookId, + academyUserId: summary.academyUserId, + ), + ); + } + }); + + // DateTime 기준 정렬 후 문자열 변환 + workbookDataList.sort((a, b) { + final dateA = _parseFormattedDate(a.lastStudyDate); + final dateB = _parseFormattedDate(b.lastStudyDate); + return dateB.compareTo(dateA); // 최신순 + }); + + return workbookDataList; + } + + /// summaries에서 가장 최근 DateTime 찾기 + DateTime? _getLatestDateTime(List summaries) { + DateTime? latest; + for (final summary in summaries) { + if (summary.lastStudyDate != null) { + if (latest == null || summary.lastStudyDate!.isAfter(latest)) { + latest = summary.lastStudyDate; + } + } + } + return latest; + } + + /// DateTime을 YYYY.MM.DD 형식으로 변환 + String _formatDateTime(DateTime? dateTime) { + if (dateTime == null) return ''; + return '${dateTime.year}.${dateTime.month.toString().padLeft(2, '0')}.${dateTime.day.toString().padLeft(2, '0')}'; + } + + /// YYYY.MM.DD 형식 문자열을 DateTime으로 파싱 + DateTime _parseFormattedDate(String dateStr) { + if (dateStr.isEmpty) return DateTime(1970, 1, 1); // 기본값 + try { + final parts = dateStr.split('.'); + if (parts.length == 3) { + return DateTime( + int.parse(parts[0]), + int.parse(parts[1]), + int.parse(parts[2]), + ); + } + } catch (e) { + // 파싱 실패 시 기본값 + } + return DateTime(1970, 1, 1); + } +} + + diff --git a/frontend/lib/data/notification/notification_local_data_source.dart b/frontend/lib/data/notification/notification_local_data_source.dart new file mode 100644 index 0000000..0ee690f --- /dev/null +++ b/frontend/lib/data/notification/notification_local_data_source.dart @@ -0,0 +1,34 @@ +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// 알림 로컬 데이터 소스 (SharedPreferences 직접 접근) +class NotificationLocalDataSource { + static const String _key = 'notifications_list'; + + final SharedPreferences prefs; + + NotificationLocalDataSource(this.prefs); + + /// 알림 목록 로드 (JSON 문자열을 List으로 변환) + Future>> loadRawList() async { + final jsonString = prefs.getString(_key); + if (jsonString == null || jsonString.isEmpty) { + return []; + } + + try { + final List decoded = jsonDecode(jsonString); + return decoded.cast>(); + } catch (e) { + // JSON 파싱 실패 시 빈 리스트 반환 + return []; + } + } + + /// 알림 목록 저장 (List을 JSON 문자열로 저장) + Future saveRawList(List> items) async { + final jsonString = jsonEncode(items); + await prefs.setString(_key, jsonString); + } +} + diff --git a/frontend/lib/data/notification/notification_repository_impl.dart b/frontend/lib/data/notification/notification_repository_impl.dart new file mode 100644 index 0000000..34d8aca --- /dev/null +++ b/frontend/lib/data/notification/notification_repository_impl.dart @@ -0,0 +1,123 @@ +import 'dart:developer' as developer; +import '../../domain/notification/notification_entity.dart'; +import '../../domain/notification/notification_repository.dart'; +import 'notification_local_data_source.dart'; + +/// 알림 저장소 구현체 (SharedPreferences 기반) +class NotificationRepositoryImpl implements NotificationRepository { + final NotificationLocalDataSource local; + + NotificationRepositoryImpl(this.local); + + static const int _maxCount = 100; // 최대 알림 개수 + + @override + Future> fetchNotifications() async { + try { + final rawList = await local.loadRawList(); + final list = rawList + .map((json) => NotificationEntity.fromJson(json)) + .toList() + ..sort((a, b) => b.time.compareTo(a.time)); // 최신순 정렬 + + return list; + } catch (e) { + developer.log('❌ 알림 목록 로드 실패: $e'); + return []; + } + } + + @override + Future saveNotification(NotificationEntity notification) async { + try { + final list = await fetchNotifications(); + + // 중복 제거 (동일 id 제거) + final filtered = list.where((n) => n.id != notification.id).toList(); + + // 새 알림을 맨 앞에 추가 + filtered.insert(0, notification); + + // 최대 개수 제한 + final truncated = filtered.take(_maxCount).toList(); + + // 저장 + await local.saveRawList( + truncated.map((e) => e.toJson()).toList(), + ); + + developer.log('✅ 알림 저장 완료: ${notification.id}'); + } catch (e) { + developer.log('❌ 알림 저장 실패: $e'); + rethrow; + } + } + + @override + Future markAsRead(String id) async { + try { + final list = await fetchNotifications(); + final updated = list.map((n) { + if (n.id == id) { + return n.copyWith(isRead: true); + } + return n; + }).toList(); + + await local.saveRawList( + updated.map((e) => e.toJson()).toList(), + ); + + developer.log('✅ 알림 읽음 처리 완료: $id'); + } catch (e) { + developer.log('❌ 알림 읽음 처리 실패: $e'); + rethrow; + } + } + + @override + Future markAllAsRead() async { + try { + final list = await fetchNotifications(); + final updated = list.map((n) => n.copyWith(isRead: true)).toList(); + + await local.saveRawList( + updated.map((e) => e.toJson()).toList(), + ); + + developer.log('✅ 전체 알림 읽음 처리 완료'); + } catch (e) { + developer.log('❌ 전체 알림 읽음 처리 실패: $e'); + rethrow; + } + } + + @override + Future delete(String id) async { + try { + final list = await fetchNotifications(); + final filtered = list.where((n) => n.id != id).toList(); + + await local.saveRawList( + filtered.map((e) => e.toJson()).toList(), + ); + + developer.log('✅ 알림 삭제 완료: $id'); + } catch (e) { + developer.log('❌ 알림 삭제 실패: $e'); + rethrow; + } + } + + @override + Future clearAll() async { + try { + await local.saveRawList([]); + developer.log('✅ 전체 알림 삭제 완료'); + } catch (e) { + developer.log('❌ 전체 알림 삭제 실패: $e'); + rethrow; + } + } +} + diff --git a/frontend/lib/domain/chapter/chapter_entity.dart b/frontend/lib/domain/chapter/chapter_entity.dart new file mode 100644 index 0000000..8c1a275 --- /dev/null +++ b/frontend/lib/domain/chapter/chapter_entity.dart @@ -0,0 +1,64 @@ +/// 챕터 도메인 엔티티 +/// +/// API 응답을 도메인 모델로 변환합니다. +class ChapterEntity { + final int chapterId; + final int bookId; + final int mainChapterNumber; + final int subChapterNumber; + final String? chapterName; + final int chapterStartPage; + final int chapterEndPage; + final int chapterStartQuestion; + final int chapterEndQuestion; + final int totalChapterQuestion; + final int studentAnswerCount; + + ChapterEntity({ + required this.chapterId, + required this.bookId, + required this.mainChapterNumber, + required this.subChapterNumber, + this.chapterName, + required this.chapterStartPage, + required this.chapterEndPage, + required this.chapterStartQuestion, + required this.chapterEndQuestion, + required this.totalChapterQuestion, + required this.studentAnswerCount, + }); + + /// 진행률 계산 (0.0 ~ 1.0) + /// + /// **주의**: progress는 "학생이 푼 문제 수 / 전체 문제 수"를 의미합니다. + /// studentAnswerCount는 서버에서 제공하는 "학생이 답안을 제출한 문제 수"입니다. + /// 아직 채점되지 않은 문제도 포함될 수 있으므로, 실제 완료율과는 다를 수 있습니다. + /// + /// totalChapterQuestion이 0이면 0.0을 반환합니다. + /// + /// **경계 조건**: + /// - totalChapterQuestion == 0 && studentAnswerCount > 0인 경우: + /// 백엔드 스펙상 불가능한 상황이지만, 안전하게 0.0을 반환합니다. + double get progress { + if (totalChapterQuestion <= 0) return 0.0; + return (studentAnswerCount / totalChapterQuestion).clamp(0.0, 1.0); + } + + /// 챕터명 포맷팅 "{chapterId}. {chapterName}" + /// + /// chapterId를 인덱스로 사용하여 각 챕터를 고유하게 식별합니다. + /// chapterName이 null이거나 빈 문자열이면 "{chapterId}." 형식으로 반환합니다. + /// 예: "1. 소인수분해" 또는 "1." + String get formattedName { + final base = '$chapterId.'; + if (chapterName == null || chapterName!.trim().isEmpty) { + return base; + } + return '$base $chapterName'; + } + + /// 문제 수 표시 문자열 "{studentAnswerCount} / {totalChapterQuestion}" + String get problemCountDisplay { + return '$studentAnswerCount / $totalChapterQuestion'; + } +} diff --git a/frontend/lib/domain/chapter/chapter_repository.dart b/frontend/lib/domain/chapter/chapter_repository.dart new file mode 100644 index 0000000..2b2259e --- /dev/null +++ b/frontend/lib/domain/chapter/chapter_repository.dart @@ -0,0 +1,24 @@ +import 'chapter_entity.dart'; + +/// 챕터 Repository 인터페이스 +/// +/// 도메인 레이어에서 데이터 레이어에 의존하지 않도록 추상화합니다. +abstract class ChapterRepository { + /// bookId와 academyUserId로 챕터 목록 조회 + /// + /// [bookId]: 문제집 ID + /// [academyUserId]: 학원 사용자 ID + /// + /// 반환: 챕터 엔티티 리스트 (정렬되지 않은 상태) + Future> getChaptersByBookIdAndAcademyUserId( + int bookId, + int academyUserId, + ); + + /// chapterId로 단일 챕터 정보 조회 + /// + /// [chapterId]: 챕터 ID + /// + /// 반환: 챕터 엔티티 + Future getChapterById(int chapterId); +} diff --git a/frontend/lib/domain/chapter/get_chapters_for_book_use_case.dart b/frontend/lib/domain/chapter/get_chapters_for_book_use_case.dart new file mode 100644 index 0000000..3e4438d --- /dev/null +++ b/frontend/lib/domain/chapter/get_chapters_for_book_use_case.dart @@ -0,0 +1,41 @@ +import 'chapter_entity.dart'; +import 'chapter_repository.dart'; + +/// 문제집의 챕터 목록 조회 UseCase +/// +/// 비즈니스 규칙: +/// 1. 챕터를 mainChapterNumber, subChapterNumber 순으로 정렬 +/// 2. 도메인 엔티티로 변환 +class GetChaptersForBookUseCase { + final ChapterRepository _repository; + + GetChaptersForBookUseCase({required ChapterRepository repository}) + : _repository = repository; + + /// 챕터 목록 조회 및 정렬 + /// + /// [bookId]: 문제집 ID + /// [academyUserId]: 학원 사용자 ID + /// + /// 반환: 정렬된 챕터 엔티티 리스트 + Future> call({ + required int bookId, + required int academyUserId, + }) async { + final chapters = await _repository.getChaptersByBookIdAndAcademyUserId( + bookId, + academyUserId, + ); + + // 비즈니스 규칙: mainChapterNumber 우선, 그 다음 subChapterNumber로 정렬 + // ⚠️ Repository가 캐싱된 리스트를 반환할 수 있으므로, 복사 후 정렬 + final sorted = List.from(chapters); + sorted.sort((a, b) { + final mainCompare = a.mainChapterNumber.compareTo(b.mainChapterNumber); + if (mainCompare != 0) return mainCompare; + return a.subChapterNumber.compareTo(b.subChapterNumber); + }); + + return sorted; + } +} diff --git a/frontend/lib/domain/explanation/explanation_entity.dart b/frontend/lib/domain/explanation/explanation_entity.dart new file mode 100644 index 0000000..b9bb7b5 --- /dev/null +++ b/frontend/lib/domain/explanation/explanation_entity.dart @@ -0,0 +1,17 @@ +import '../question/question_identifier.dart'; + +/// 문제 해설 도메인 엔티티 +class ExplanationEntity { + /// 어떤 문제에 대한 해설인지 + final QuestionIdentifier question; + + /// 해설 텍스트 + final String text; + + const ExplanationEntity({ + required this.question, + required this.text, + }); +} + + diff --git a/frontend/lib/domain/explanation/explanation_repository.dart b/frontend/lib/domain/explanation/explanation_repository.dart new file mode 100644 index 0000000..62f896a --- /dev/null +++ b/frontend/lib/domain/explanation/explanation_repository.dart @@ -0,0 +1,52 @@ +import 'explanation_entity.dart'; +import 'explanation_source.dart'; + +/// 학생 답안 정보 (findStudentAnswer 응답) +class StudentAnswerInfo { + final int studentAnswerId; + final int studentResponseId; + final int chapterId; + final int page; + final int questionNumber; + final int subQuestionNumber; + final String answer; + final String? sectionUrl; + final double score; + final bool correct; + + const StudentAnswerInfo({ + required this.studentAnswerId, + required this.studentResponseId, + required this.chapterId, + required this.page, + required this.questionNumber, + required this.subQuestionNumber, + required this.answer, + this.sectionUrl, + required this.score, + required this.correct, + }); +} + +/// 문제 해설 조회/생성을 위한 도메인 Repository 인터페이스 +abstract class ExplanationRepository { + /// 학생 답안 정보를 조회한다. + Future findStudentAnswer(ExplanationSource source); + + /// 이미 생성/저장된 해설을 조회한다. 없으면 null. + Future getExplanation(ExplanationSource source); + + /// 해설 생성을 요청하고, 결과 스냅샷을 반환한다. + /// + /// [requestedByUserId]는 누가 해설 생성을 요청했는지에 대한 정보로, + /// Domain에서는 의미를 해석하지 않고 그대로 전달만 한다. + /// [academyId]와 [answer]는 findStudentAnswer에서 가져온 정보를 사용한다. + Future requestExplanation( + ExplanationSource source, { + required int requestedByUserId, + required int academyId, + required String answer, + }); +} + + diff --git a/frontend/lib/domain/explanation/explanation_source.dart b/frontend/lib/domain/explanation/explanation_source.dart new file mode 100644 index 0000000..4cff59a --- /dev/null +++ b/frontend/lib/domain/explanation/explanation_source.dart @@ -0,0 +1,21 @@ +import '../question/question_identifier.dart'; + +/// 해설 조회/생성에 필요한 외부 식별자 + 문제 식별자 묶음 +class ExplanationSource { + /// 학생 답안 응답 ID + final int studentResponseId; + + /// 학원 사용자 ID + final int academyUserId; + + /// 어떤 문제에 대한 해설인지 + final QuestionIdentifier question; + + const ExplanationSource({ + required this.studentResponseId, + required this.academyUserId, + required this.question, + }); +} + + diff --git a/frontend/lib/domain/explanation/get_explanation_use_case.dart b/frontend/lib/domain/explanation/get_explanation_use_case.dart new file mode 100644 index 0000000..549e3e2 --- /dev/null +++ b/frontend/lib/domain/explanation/get_explanation_use_case.dart @@ -0,0 +1,17 @@ +import 'explanation_entity.dart'; +import 'explanation_repository.dart'; +import 'explanation_source.dart'; + +/// 이미 생성된 해설을 조회하는 UseCase +class GetExplanationUseCase { + final ExplanationRepository _repository; + + GetExplanationUseCase({required ExplanationRepository repository}) + : _repository = repository; + + Future call(ExplanationSource source) { + return _repository.getExplanation(source); + } +} + + diff --git a/frontend/lib/domain/explanation/request_explanation_use_case.dart b/frontend/lib/domain/explanation/request_explanation_use_case.dart new file mode 100644 index 0000000..0ca8862 --- /dev/null +++ b/frontend/lib/domain/explanation/request_explanation_use_case.dart @@ -0,0 +1,27 @@ +import 'explanation_entity.dart'; +import 'explanation_repository.dart'; +import 'explanation_source.dart'; + +/// 해설 생성을 요청하는 UseCase +class RequestExplanationUseCase { + final ExplanationRepository _repository; + + RequestExplanationUseCase({required ExplanationRepository repository}) + : _repository = repository; + + Future call( + ExplanationSource source, { + required int requestedByUserId, + required int academyId, + required String answer, + }) { + return _repository.requestExplanation( + source, + requestedByUserId: requestedByUserId, + academyId: academyId, + answer: answer, + ); + } +} + + diff --git a/frontend/lib/domain/grading_history/grading_history_entity.dart b/frontend/lib/domain/grading_history/grading_history_entity.dart new file mode 100644 index 0000000..8e70362 --- /dev/null +++ b/frontend/lib/domain/grading_history/grading_history_entity.dart @@ -0,0 +1,99 @@ +/// 채점 히스토리 도메인 엔티티 +/// +/// API 응답의 student response 정보를 도메인 모델로 변환합니다. +class GradingHistoryEntity { + final int studentResponseId; + final int academyUserId; + final int? bookId; + final String? bookName; + final String? bookCoverImageUrl; // book_image_url + final int startPage; // response_start_page + final int endPage; // response_end_page + final String? className; // Repository에서 주입 + final DateTime gradingDate; // created_at 파싱 후 toLocal() + + // 선택적 필드 (현재 UI에서 사용 안 하지만 향후 확장 가능성) + final int? assessId; + final int unrecognizedResponseCount; + + const GradingHistoryEntity({ + required this.studentResponseId, + required this.academyUserId, + this.bookId, + this.bookName, + this.bookCoverImageUrl, + required this.startPage, + required this.endPage, + this.className, + required this.gradingDate, + this.assessId, + this.unrecognizedResponseCount = 0, + }); + + /// ⚠️ 서버 API 응답이 아니라, SharedPreferences 캐시 전용 JSON 스키마입니다. + /// + /// API 응답은 GradingHistoryApiResponse → UseCase → GradingHistoryEntity로 변환되며, + /// 이 메서드는 캐시에서 복원할 때만 사용됩니다. + /// + /// 캐시 스키마: + /// - book_cover_image_url (API: book_image_url) + /// - start_page (API: response_start_page) + /// - end_page (API: response_end_page) + /// - grading_date (API: created_at) + factory GradingHistoryEntity.fromCacheJson(Map json) { + return GradingHistoryEntity( + studentResponseId: json['student_response_id'] as int? ?? 0, + academyUserId: json['academy_user_id'] as int? ?? 0, + bookId: json['book_id'] as int?, + bookName: json['book_name'] as String?, + bookCoverImageUrl: json['book_cover_image_url'] as String?, + startPage: json['start_page'] as int? ?? 0, + endPage: json['end_page'] as int? ?? 0, + className: json['class_name'] as String?, + gradingDate: _parseGradingDate(json['grading_date']), + assessId: json['assess_id'] as int?, + unrecognizedResponseCount: + json['unrecognized_response_count'] as int? ?? 0, + ); + } + + /// ⚠️ 서버 API 응답이 아니라, SharedPreferences 캐시 전용 JSON 스키마입니다. + /// + /// 캐시 저장 시 사용되며, API 스키마와는 다른 키 이름을 사용합니다. + Map toCacheJson() { + return { + 'student_response_id': studentResponseId, + 'academy_user_id': academyUserId, + 'book_id': bookId, + 'book_name': bookName, + 'book_cover_image_url': bookCoverImageUrl, + 'start_page': startPage, + 'end_page': endPage, + 'class_name': className, + 'grading_date': gradingDate.toIso8601String(), + 'assess_id': assessId, + 'unrecognized_response_count': unrecognizedResponseCount, + }; + } + + /// 캐시에서 grading_date를 안전하게 파싱 + /// + /// 스키마 변경이나 손상된 캐시 데이터에 대비한 방어 로직 + static DateTime _parseGradingDate(dynamic value) { + if (value == null) { + // 값이 없으면 과거 고정값 반환 (정렬 시 뒤로 가도록) + return DateTime(1970, 1, 1); + } + + if (value is! String) { + return DateTime(1970, 1, 1); + } + + try { + return DateTime.parse(value).toLocal(); + } catch (_) { + // 파싱 실패 시 과거 고정값 반환 + return DateTime(1970, 1, 1); + } + } +} diff --git a/frontend/lib/domain/grading_history/grading_history_repository.dart b/frontend/lib/domain/grading_history/grading_history_repository.dart new file mode 100644 index 0000000..016da9d --- /dev/null +++ b/frontend/lib/domain/grading_history/grading_history_repository.dart @@ -0,0 +1,11 @@ +import 'grading_history_entity.dart'; + +/// 채점 히스토리 Repository 인터페이스 +abstract class GradingHistoryRepository { + /// 여러 academyUserId에 대한 채점 히스토리 조회 + /// + /// 반환값: academyUserId를 키로 하는 Map + /// UI에서 필요시 flat하게 합쳐서 사용 + Future>> + getGradingHistoriesByAcademyUserIds(List academyUserIds); +} diff --git a/frontend/lib/domain/learning/daily_learning_status.dart b/frontend/lib/domain/learning/daily_learning_status.dart new file mode 100644 index 0000000..4385c30 --- /dev/null +++ b/frontend/lib/domain/learning/daily_learning_status.dart @@ -0,0 +1,71 @@ +/// 일별 학습 상태 도메인 엔티티 +/// +/// UI 레이어에서 사용하는 통합 모델로, +/// Assessment와 GradingHistory를 모두 추상화합니다. +/// +/// 주의사항: +/// - date는 항상 DateTime(year, month, day) (시간 00:00)로 정규화해서 저장/비교합니다. +class DailyLearningStatus { + final DateTime date; + final bool isCompleted; + + // 향후 확장 필드 + final List? bookProgresses; // 해당 날짜에 푼 문제집별 진행률 + + const DailyLearningStatus({ + required this.date, + required this.isCompleted, + this.bookProgresses, + }); + + /// 날짜 문자열 (YYYY-MM-DD) + String get dateString { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + + /// DateTime을 정규화 (시간을 00:00으로 설정) + /// + /// Map의 key로 사용하거나 비교할 때 일관성을 보장하기 위해 사용합니다. + static DateTime normalizeDate(DateTime date) { + return DateTime(date.year, date.month, date.day); + } +} + +/// 문제집별 진행률 Value Object +/// +/// 특정 날짜에 대한 문제집별 학습 진행률을 표현합니다. +class BookProgress { + final int? bookId; + final String bookName; + final String? bookCoverImageUrl; + final int todayPages; // 해당 날짜에 푼 페이지 수 + final int accumulatedPages; // 해당 날짜 이전까지 누적 페이지 수 + final int totalPages; // 전체 페이지 수 + + const BookProgress({ + this.bookId, + required this.bookName, + this.bookCoverImageUrl, + required this.todayPages, + required this.accumulatedPages, + required this.totalPages, + }); + + /// 누적 진행률 (0.0 ~ 1.0) + double get accumulatedRatio { + if (totalPages <= 0) return 0.0; + return (accumulatedPages / totalPages).clamp(0.0, 1.0); + } + + /// 당일 진행률 (0.0 ~ 1.0) + double get todayRatio { + if (totalPages <= 0) return 0.0; + return (todayPages / totalPages).clamp(0.0, 1.0); + } + + /// 전체 진행률 (누적 + 당일) + double get totalRatio { + if (totalPages <= 0) return 0.0; + return ((accumulatedPages + todayPages) / totalPages).clamp(0.0, 1.0); + } +} diff --git a/frontend/lib/domain/learning/get_monthly_learning_status_use_case.dart b/frontend/lib/domain/learning/get_monthly_learning_status_use_case.dart new file mode 100644 index 0000000..bdbf694 --- /dev/null +++ b/frontend/lib/domain/learning/get_monthly_learning_status_use_case.dart @@ -0,0 +1,25 @@ +import 'daily_learning_status.dart'; + +/// 월별 학습 상태 조회 UseCase 인터페이스 +/// +/// Domain Layer의 추상 인터페이스로, +/// Data Layer에서 구현합니다. +abstract class GetMonthlyLearningStatusUseCase { + /// 특정 월의 일별 학습 상태 조회 + /// + /// [month]: 조회할 월 (첫 번째 날짜로 표현, 예: DateTime(2025, 11, 1)) + /// + /// 반환값: 해당 월의 모든 날짜에 대한 DailyLearningStatus 리스트 + /// 날짜 순서대로 정렬되어 반환됩니다. + /// 각 DailyLearningStatus.date는 정규화된 DateTime(year, month, day)입니다. + Future> call(DateTime month); + + /// 특정 날짜의 학습 상태 조회 + /// + /// [date]: 조회할 날짜 + /// + /// 반환값: 해당 날짜의 DailyLearningStatus + /// date는 정규화된 DateTime(year, month, day)입니다. + Future getStatusForDate(DateTime date); +} + diff --git a/frontend/lib/domain/learning/learning_completion_service.dart b/frontend/lib/domain/learning/learning_completion_service.dart new file mode 100644 index 0000000..2e123b5 --- /dev/null +++ b/frontend/lib/domain/learning/learning_completion_service.dart @@ -0,0 +1,79 @@ +import '../../models/assessment.dart'; +import '../grading_history/grading_history_entity.dart'; + +/// 학습 완료 여부 판단 Service 인터페이스 +/// +/// Domain Layer의 추상 인터페이스로, +/// "어떤 날을 완료로 볼 것인가"라는 도메인 규칙을 정의합니다. +/// +/// 주의: 이 인터페이스는 순수 함수형으로 설계되어 있습니다. +/// 내부 상태를 가지지 않으며, 매개변수로 받은 데이터만으로 판단합니다. +abstract class LearningCompletionService { + /// 특정 날짜가 완료되었는지 판단 + /// + /// [date]: 판단할 날짜 (정규화된 DateTime) + /// [assessmentsByDate]: 날짜별 Assessment 맵 (YYYY-MM-DD 형식의 키) + /// [gradingHistoriesByDate]: 날짜별 GradingHistory 맵 (YYYY-MM-DD 형식의 키) + /// + /// 반환값: 완료되었으면 true + /// + /// 규칙: + /// 1. GradingHistory가 있으면 → GradingHistory 기준으로 판단 (우선순위 1) + /// - 해당 날짜에 gradingDate가 있는 GradingHistory가 1개 이상 있으면 완료 + /// 2. GradingHistory가 없으면 → Assessment 기준으로 판단 (우선순위 2) + /// - 해당 날짜의 Assessment 중 하나라도 assessStatus == 'Y'이면 완료 + /// 3. 둘 다 없으면 → false + /// + /// 하루에 여러 번 푼 경우: + /// - GradingHistory: 여러 history가 있어도 1개 이상이면 완료로 판단 + /// - Assessment: 여러 개 있어도 하나라도 'Y'면 완료로 판단 + bool isCompleted({ + required DateTime date, + Map>? assessmentsByDate, + Map>? gradingHistoriesByDate, + }); + + /// 날짜 범위의 완료 여부 맵 조회 + /// + /// [startDate]: 시작 날짜 (정규화된 DateTime) + /// [endDate]: 종료 날짜 (정규화된 DateTime, 포함) + /// [assessmentsByDate]: 날짜별 Assessment 맵 + /// [gradingHistoriesByDate]: 날짜별 GradingHistory 맵 + /// + /// 반환값: 날짜별 완료 여부 맵 (정규화된 DateTime을 키로 사용) + Map getCompletionMap({ + required DateTime startDate, + required DateTime endDate, + Map>? assessmentsByDate, + Map>? gradingHistoriesByDate, + }); + + /// 연속 학습일 계산 + /// + /// [date]: 기준 날짜 (이 날짜부터 역순으로 계산, 정규화된 DateTime) + /// [assessmentsByDate]: 날짜별 Assessment 맵 + /// [gradingHistoriesByDate]: 날짜별 GradingHistory 맵 + /// + /// 반환값: 연속 학습일 수 + int getConsecutiveDays({ + required DateTime date, + Map>? assessmentsByDate, + Map>? gradingHistoriesByDate, + }); + + /// 연속 학습일 연결선 표시 여부 판단 + /// + /// [date]: 현재 날짜 (정규화된 DateTime) + /// [nextDate]: 다음 날짜 (정규화된 DateTime) + /// [assessmentsByDate]: 날짜별 Assessment 맵 + /// [gradingHistoriesByDate]: 날짜별 GradingHistory 맵 + /// + /// 반환값: 두 날짜 모두 완료되고 같은 월에 속하면 true + bool shouldShowConnector({ + required DateTime date, + required DateTime nextDate, + Map>? assessmentsByDate, + Map>? gradingHistoriesByDate, + }); +} + diff --git a/frontend/lib/domain/notification/notification_entity.dart b/frontend/lib/domain/notification/notification_entity.dart new file mode 100644 index 0000000..83bf366 --- /dev/null +++ b/frontend/lib/domain/notification/notification_entity.dart @@ -0,0 +1,136 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'dart:convert'; +import 'notification_type.dart'; +import '../../utils/app_logger.dart'; + +/// 알림 엔티티 (도메인 모델) +class NotificationEntity { + final String id; + final NotificationType type; + final String title; + final String message; + final DateTime time; + final bool isRead; + final Map? data; + + const NotificationEntity({ + required this.id, + required this.type, + required this.title, + required this.message, + required this.time, + required this.isRead, + this.data, + }); + + /// Immutable copyWith 메서드 + NotificationEntity copyWith({ + String? id, + NotificationType? type, + String? title, + String? message, + DateTime? time, + bool? isRead, + Map? data, + }) { + return NotificationEntity( + id: id ?? this.id, + type: type ?? this.type, + title: title ?? this.title, + message: message ?? this.message, + time: time ?? this.time, + isRead: isRead ?? this.isRead, + data: data ?? this.data, + ); + } + + /// JSON으로 변환 + Map toJson() { + return { + 'id': id, + 'type': type.toStringValue(), + 'title': title, + 'message': message, + 'time': time.toIso8601String(), + 'isRead': isRead, + if (data != null) 'data': data, + }; + } + + /// JSON에서 생성 + factory NotificationEntity.fromJson(Map json) { + return NotificationEntity( + id: json['id'] as String, + type: NotificationTypeUtil.fromString(json['type'] as String), + title: json['title'] as String, + message: json['message'] as String, + time: DateTime.parse(json['time'] as String), + isRead: json['isRead'] as bool? ?? false, + data: json['data'] as Map?, + ); + } + + /// FCM RemoteMessage에서 생성 + factory NotificationEntity.fromRemoteMessage(RemoteMessage message) { + appLog( + '[notification:notification_entity][timecheck] fromRemoteMessage 호출', + ); + appLog( + '[notification:notification_entity][timecheck] 메시지 data: ${json.encode(message.data)}', + ); + if (message.notification != null) { + appLog( + '[notification:notification_entity][timecheck] notification.title: ${message.notification!.title}', + ); + appLog( + '[notification:notification_entity][timecheck] notification.body: ${message.notification!.body}', + ); + } + appLog( + '[notification:notification_entity] 전체 메시지 JSON: ${json.encode({ + 'messageId': message.messageId, + 'data': message.data, + 'notification': message.notification != null ? {'title': message.notification!.title, 'body': message.notification!.body} : null, + 'sentTime': message.sentTime?.toIso8601String(), + })}', + ); + + final data = message.data; + final notification = message.notification; + + // ID 생성: messageId 우선, 없으면 data['notificationId'], 없으면 timestamp 기반 + final id = + message.messageId ?? + data['notificationId'] ?? + 'fcm_${DateTime.now().millisecondsSinceEpoch}'; + + // 제목 추출: data['title'] 우선, 없으면 notification.title, 없으면 기본값 + final title = data['title'] as String? ?? notification?.title ?? '알림'; + + // 타입 추출: data['type'] 우선, 없으면 title 기반으로 판단, 없으면 기본값 + final typeString = data['type'] as String?; + final type = typeString != null + ? NotificationTypeUtil.fromString(typeString) + : NotificationTypeUtil.fromTitle(title); + + // 메시지 추출: data['message'] 우선, 없으면 notification.body, 없으면 기본값 + final messageText = + data['message'] as String? ?? + data['body'] as String? ?? + notification?.body ?? + ''; + + // 시간: sentTime 우선, 없으면 현재 시간 + final time = message.sentTime ?? DateTime.now(); + + return NotificationEntity( + id: id, + type: type, + title: title, + message: messageText, + time: time, + isRead: false, // 새 알림은 항상 읽지 않음 + data: data.isNotEmpty ? data : null, + ); + } +} diff --git a/frontend/lib/domain/notification/notification_repository.dart b/frontend/lib/domain/notification/notification_repository.dart new file mode 100644 index 0000000..bf04fec --- /dev/null +++ b/frontend/lib/domain/notification/notification_repository.dart @@ -0,0 +1,23 @@ +import 'notification_entity.dart'; + +/// 알림 저장소 인터페이스 +abstract class NotificationRepository { + /// 알림 목록 가져오기 (최신순) + Future> fetchNotifications(); + + /// 알림 저장 + Future saveNotification(NotificationEntity notification); + + /// 알림 읽음 처리 + Future markAsRead(String id); + + /// 전체 알림 읽음 처리 + Future markAllAsRead(); + + /// 알림 삭제 + Future delete(String id); + + /// 전체 알림 삭제 + Future clearAll(); +} + diff --git a/frontend/lib/domain/notification/notification_type.dart b/frontend/lib/domain/notification/notification_type.dart new file mode 100644 index 0000000..50191ee --- /dev/null +++ b/frontend/lib/domain/notification/notification_type.dart @@ -0,0 +1,75 @@ +/// 알림 타입 +enum NotificationType { + homework, // 숙제 관련 + learningReminder, // 학습 리마인더 + academyNotice, // 학원 공지사항 + grading, // 채점 완료 + achievement, // 학습 목표 달성 +} + +/// NotificationType 확장 메서드 +extension NotificationTypeExtension on NotificationType { + /// NotificationType을 문자열로 변환 + String toStringValue() { + switch (this) { + case NotificationType.homework: + return 'homework'; + case NotificationType.learningReminder: + return 'learningReminder'; + case NotificationType.academyNotice: + return 'academyNotice'; + case NotificationType.grading: + return 'grading'; + case NotificationType.achievement: + return 'achievement'; + } + } +} + +/// NotificationType 유틸리티 +class NotificationTypeUtil { + /// 문자열에서 NotificationType으로 변환 + static NotificationType fromString(String value) { + switch (value.toLowerCase()) { + case 'homework': + return NotificationType.homework; + case 'learningreminder': + case 'learning_reminder': + return NotificationType.learningReminder; + case 'academynotice': + case 'academy_notice': + return NotificationType.academyNotice; + case 'grading': + return NotificationType.grading; + case 'achievement': + return NotificationType.achievement; + default: + return NotificationType.learningReminder; // 기본값 + } + } + + /// 제목(title)에서 NotificationType으로 변환 + static NotificationType fromTitle(String title) { + final lowerTitle = title.toLowerCase(); + + // 우선순위: 더 구체적인 키워드부터 확인 + if (lowerTitle.contains('채점') || lowerTitle.contains('해설')) { + return NotificationType.grading; + } + if (lowerTitle.contains('숙제')) { + return NotificationType.homework; + } + if (lowerTitle.contains('목표 달성')) { + return NotificationType.achievement; + } + if (lowerTitle.contains('리마인더')) { + return NotificationType.learningReminder; + } + if (lowerTitle.contains('학원') || lowerTitle.contains('공지사항')) { + return NotificationType.academyNotice; + } + + // 기본값 + return NotificationType.learningReminder; + } +} diff --git a/frontend/lib/domain/question/question_identifier.dart b/frontend/lib/domain/question/question_identifier.dart new file mode 100644 index 0000000..ae1de59 --- /dev/null +++ b/frontend/lib/domain/question/question_identifier.dart @@ -0,0 +1,27 @@ +/// 문제를 식별하기 위한 도메인 Value Object +class QuestionIdentifier { + /// 문제집 ID + final int bookId; + + /// 챕터 ID + final int chapterId; + + /// 페이지 번호 + final int page; + + /// 문제 번호 + final int questionNumber; + + /// 서브 문항 번호 (0이면 서브 문항 없음) + final int subQuestionNumber; + + const QuestionIdentifier({ + required this.bookId, + required this.chapterId, + required this.page, + required this.questionNumber, + required this.subQuestionNumber, + }); +} + + diff --git a/frontend/lib/domain/section_image/get_section_image_use_case.dart b/frontend/lib/domain/section_image/get_section_image_use_case.dart new file mode 100644 index 0000000..e7c8ba8 --- /dev/null +++ b/frontend/lib/domain/section_image/get_section_image_use_case.dart @@ -0,0 +1,30 @@ +import 'section_image_entity.dart'; +import 'section_image_repository.dart'; + +/// Section 이미지 URL을 조회하는 UseCase +class GetSectionImageUseCase { + final SectionImageRepository _repository; + + GetSectionImageUseCase({ + required SectionImageRepository repository, + }) : _repository = repository; + + /// Section 이미지 URL 조회 + /// + /// 반환: 이미지가 있으면 SectionImageEntity, 없으면 null + Future call({ + required int academyUserId, + required int studentResponseId, + required int questionNumber, + required int subQuestionNumber, + }) { + return _repository.getSectionImageUrl( + academyUserId: academyUserId, + studentResponseId: studentResponseId, + questionNumber: questionNumber, + subQuestionNumber: subQuestionNumber, + ); + } +} + + diff --git a/frontend/lib/domain/section_image/section_image_entity.dart b/frontend/lib/domain/section_image/section_image_entity.dart new file mode 100644 index 0000000..6213d8e --- /dev/null +++ b/frontend/lib/domain/section_image/section_image_entity.dart @@ -0,0 +1,10 @@ +/// Section 이미지 도메인 엔티티 +class SectionImageEntity { + final String imageUrl; // 완성된 이미지 URL + + const SectionImageEntity({ + required this.imageUrl, + }); +} + + diff --git a/frontend/lib/domain/section_image/section_image_repository.dart b/frontend/lib/domain/section_image/section_image_repository.dart new file mode 100644 index 0000000..f905de1 --- /dev/null +++ b/frontend/lib/domain/section_image/section_image_repository.dart @@ -0,0 +1,23 @@ +import 'section_image_entity.dart'; + +/// Section 이미지 Repository 인터페이스 +abstract class SectionImageRepository { + /// Section 이미지 URL 조회 + /// + /// [academyUserId]: 학원 사용자 ID + /// [studentResponseId]: 학생 응답 ID + /// [questionNumber]: 문제 번호 + /// [subQuestionNumber]: 소문제 번호 (0이면 메인 문제) + /// + /// 반환: Section 이미지 엔티티 (이미지가 없으면 null) + /// + /// 참고: 이미지가 없는 경우는 정상적인 케이스로 간주하여 null을 반환합니다. + Future getSectionImageUrl({ + required int academyUserId, + required int studentResponseId, + required int questionNumber, + required int subQuestionNumber, + }); +} + + diff --git a/frontend/lib/domain/student_answer/get_student_answers_for_response_use_case.dart b/frontend/lib/domain/student_answer/get_student_answers_for_response_use_case.dart new file mode 100644 index 0000000..cb7d905 --- /dev/null +++ b/frontend/lib/domain/student_answer/get_student_answers_for_response_use_case.dart @@ -0,0 +1,16 @@ +import 'student_answer_entity.dart'; +import 'student_answer_repository.dart'; + +/// studentResponseId로 학생 답안 목록을 조회하는 UseCase +class GetStudentAnswersForResponseUseCase { + final StudentAnswerRepository _repository; + + GetStudentAnswersForResponseUseCase({ + required StudentAnswerRepository repository, + }) : _repository = repository; + + /// studentResponseId로 학생 답안 목록 조회 + Future> call(int studentResponseId) { + return _repository.getStudentAnswersByResponseId(studentResponseId); + } +} diff --git a/frontend/lib/domain/student_answer/student_answer_entity.dart b/frontend/lib/domain/student_answer/student_answer_entity.dart new file mode 100644 index 0000000..a48058a --- /dev/null +++ b/frontend/lib/domain/student_answer/student_answer_entity.dart @@ -0,0 +1,35 @@ +/// 학생 답안 도메인 엔티티 +class StudentAnswerEntity { + final int studentAnswerId; // 필수 (0이면 유효하지 않음) + final int studentResponseId; // 필수 (0이면 유효하지 않음) + final int? chapterId; + final int page; + final int questionNumber; + final int subQuestionNumber; + final String answer; // 문자열 (객관식/주관식 모두 포함) + final String? sectionUrl; // 문제 이미지 URL (향후 확장) + final bool? isCorrect; // null = 답안 없음, true = 맞음, false = 틀림 + final double score; + + const StudentAnswerEntity({ + required this.studentAnswerId, + required this.studentResponseId, + this.chapterId, + required this.page, + required this.questionNumber, + required this.subQuestionNumber, + required this.answer, + this.sectionUrl, + required this.isCorrect, + required this.score, + }); + + /// answer가 비어있는지 확인 (인식 실패 여부 판단용) + /// + /// 현재는 answer가 비어있으면 인식 실패로 간주하지만, + /// 나중에 서버에서 별도의 인식 실패 플래그가 생기면 그걸 사용할 예정 + /// + /// 참고: UI 레이어에서는 공백만 있는 경우도 빈 값으로 처리하지만, + /// Domain 레이어에서는 공백도 유효한 답으로 취급합니다. + bool get isEmptyAnswer => answer.isEmpty; +} diff --git a/frontend/lib/domain/student_answer/student_answer_query.dart b/frontend/lib/domain/student_answer/student_answer_query.dart new file mode 100644 index 0000000..8e8c98e --- /dev/null +++ b/frontend/lib/domain/student_answer/student_answer_query.dart @@ -0,0 +1,37 @@ +/// 학생 답안 조회 조건 (타입 안전한 Query 객체) +/// +/// 필터 조건을 명시적으로 타입으로 표현하여 +/// 런타임 오류를 컴파일 타임으로 이동 +abstract class StudentAnswerQuery { + const StudentAnswerQuery(); + + /// chapterId + academyUserId로 조회 + factory StudentAnswerQuery.byChapter({ + required int chapterId, + required int academyUserId, + }) = ChapterAndAcademyQuery; + + /// studentResponseId로 조회 + factory StudentAnswerQuery.byResponse({ + required int studentResponseId, + }) = ResponseIdQuery; +} + +/// 챕터와 학원 사용자 ID로 조회 +class ChapterAndAcademyQuery extends StudentAnswerQuery { + final int chapterId; + final int academyUserId; + + const ChapterAndAcademyQuery({ + required this.chapterId, + required this.academyUserId, + }); +} + +/// 학생 응답 ID로 조회 +class ResponseIdQuery extends StudentAnswerQuery { + final int studentResponseId; + + const ResponseIdQuery({required this.studentResponseId}); +} + diff --git a/frontend/lib/domain/student_answer/student_answer_repository.dart b/frontend/lib/domain/student_answer/student_answer_repository.dart new file mode 100644 index 0000000..2628a05 --- /dev/null +++ b/frontend/lib/domain/student_answer/student_answer_repository.dart @@ -0,0 +1,28 @@ +import 'student_answer_entity.dart'; +import 'student_answer_update.dart'; +import 'student_answer_query.dart'; + +/// Student Answer Repository 인터페이스 +abstract class StudentAnswerRepository { + /// studentResponseId로 학생 답안 목록 조회 + Future> getStudentAnswersByResponseId( + int studentResponseId, + ); + + /// Query 객체 기반 학생 답안 조회 + /// + /// [query]: 조회 조건 (chapterId + academyUserId 또는 studentResponseId) + /// + /// 반환: 학생 답안 엔티티 리스트 + Future> getStudentAnswers( + StudentAnswerQuery query, + ); + + /// 수정된 답안들을 서버에 저장 + /// + /// [updates]: 수정된 답안 목록 (studentAnswerId와 새로운 answer 포함) + /// + /// 참고: 현재는 answer만 수정하고, is_correct(정답 여부)는 서버 기준 그대로 유지됩니다. + /// 향후 정답 여부 재계산 기능이 필요하면 별도 API로 확장 예정입니다. + Future updateStudentAnswers(List updates); +} diff --git a/frontend/lib/domain/student_answer/student_answer_update.dart b/frontend/lib/domain/student_answer/student_answer_update.dart new file mode 100644 index 0000000..a318673 --- /dev/null +++ b/frontend/lib/domain/student_answer/student_answer_update.dart @@ -0,0 +1,10 @@ +/// 답안 수정 정보 (도메인 Value Object) +class StudentAnswerUpdate { + final int studentAnswerId; + final String newAnswer; // 수정된 답안 + + const StudentAnswerUpdate({ + required this.studentAnswerId, + required this.newAnswer, + }); +} diff --git a/frontend/lib/domain/student_answer/update_single_student_answer_use_case.dart b/frontend/lib/domain/student_answer/update_single_student_answer_use_case.dart new file mode 100644 index 0000000..465c193 --- /dev/null +++ b/frontend/lib/domain/student_answer/update_single_student_answer_use_case.dart @@ -0,0 +1,59 @@ +import '../../services/student_answer_api.dart'; + +/// 단일 학생 답안을 수정하는 UseCase +class UpdatedStudentAnswer { + final int studentAnswerId; + final int studentResponseId; + final int questionNumber; + final int subQuestionNumber; + final String recognizedAnswer; + final bool? isCorrect; + final double score; + + UpdatedStudentAnswer({ + required this.studentAnswerId, + required this.studentResponseId, + required this.questionNumber, + required this.subQuestionNumber, + required this.recognizedAnswer, + required this.isCorrect, + required this.score, + }); +} + +class UpdateSingleStudentAnswerUseCase { + final StudentAnswerApi _api; + + UpdateSingleStudentAnswerUseCase({required StudentAnswerApi api}) + : _api = api; + + Future call({ + required int studentAnswerId, + required int studentResponseId, + required int questionNumber, + required int subQuestionNumber, + required String newAnswer, + required int chapterId, + }) async { + final dto = await _api.updateSingleStudentAnswer( + studentAnswerId: studentAnswerId, + studentResponseId: studentResponseId, + questionNumber: questionNumber, + subQuestionNumber: subQuestionNumber, + answer: newAnswer, + chapterId: chapterId, + ); + + return UpdatedStudentAnswer( + studentAnswerId: dto.studentAnswerId, + studentResponseId: dto.studentResponseId, + questionNumber: dto.questionNumber, + subQuestionNumber: dto.subQuestionNumber, + recognizedAnswer: dto.answer, + isCorrect: dto.correct, + score: dto.score, + ); + } +} + + diff --git a/frontend/lib/domain/student_answer/update_student_answers_use_case.dart b/frontend/lib/domain/student_answer/update_student_answers_use_case.dart new file mode 100644 index 0000000..c65f1dc --- /dev/null +++ b/frontend/lib/domain/student_answer/update_student_answers_use_case.dart @@ -0,0 +1,18 @@ +import 'student_answer_repository.dart'; +import 'student_answer_update.dart'; + +/// 수정된 답안들을 서버에 저장하는 UseCase +class UpdateStudentAnswersUseCase { + final StudentAnswerRepository _repository; + + UpdateStudentAnswersUseCase({required StudentAnswerRepository repository}) + : _repository = repository; + + /// 수정된 답안들을 서버에 저장 + /// + /// [updates]: 수정된 답안 목록 + Future call(List updates) async { + if (updates.isEmpty) return; + await _repository.updateStudentAnswers(updates); + } +} diff --git a/frontend/lib/domain/workbook/workbook_repository.dart b/frontend/lib/domain/workbook/workbook_repository.dart new file mode 100644 index 0000000..1f91b71 --- /dev/null +++ b/frontend/lib/domain/workbook/workbook_repository.dart @@ -0,0 +1,38 @@ +import 'workbook_summary_entity.dart'; + +/// Workbook Repository 인터페이스 +/// +/// 문제집 요약 정보를 조회하는 Repository의 추상 인터페이스입니다. +abstract class WorkbookRepository { + /// 여러 academyUserId에 대한 문제집 요약 목록 조회 (한 번에) + /// + /// 여러 academyUserId를 한 번에 조회하여 각 academyUserId별로 + /// WorkbookSummaryEntity 리스트를 반환합니다. + /// + /// [academyUserIds]: 조회할 academyUserId 리스트 + /// + /// 반환값: Map> + Future>> getWorkbookSummariesByAcademyUserIds( + List academyUserIds, + ); + + /// 캐시에서 문제집 요약 목록 조회 + /// + /// [academyUserId]: 조회할 academyUserId + /// + /// 반환값: 캐시된 WorkbookSummaryEntity 리스트 또는 null + Future?> getCachedWorkbookSummaries(int academyUserId); + + /// 문제집 요약 목록 캐시 저장 + /// + /// [academyUserId]: 저장할 academyUserId + /// [summaries]: 저장할 WorkbookSummaryEntity 리스트 + Future cacheWorkbookSummaries( + int academyUserId, + List summaries, + ); + + /// 캐시 초기화 + Future clearCache(); +} + diff --git a/frontend/lib/domain/workbook/workbook_summary_entity.dart b/frontend/lib/domain/workbook/workbook_summary_entity.dart new file mode 100644 index 0000000..153ec73 --- /dev/null +++ b/frontend/lib/domain/workbook/workbook_summary_entity.dart @@ -0,0 +1,91 @@ +/// 문제집 요약 도메인 엔티티 (UI 표시용) +/// +/// API 응답의 book 정보를 도메인 모델로 변환합니다. +class WorkbookSummaryEntity { + final int? bookId; + final String bookName; + final String? coverImageUrl; // book_image_url + final int totalPages; // book_page + final int totalSolvedPages; // total_solved_pages + final DateTime? lastStudyDate; // latest_updated_at + final int academyUserId; + final String? className; // 상위 레이어에서 주입 + final String? bookSemester; // book_semester + + WorkbookSummaryEntity({ + this.bookId, + required this.bookName, + this.coverImageUrl, + required this.totalPages, + required this.totalSolvedPages, + this.lastStudyDate, + required this.academyUserId, + this.className, + this.bookSemester, + }); + + /// 진행률 계산 (0~100) + /// + /// 규칙: + /// - totalPages가 null/0이면 → 0 반환 + /// - totalSolvedPages / totalPages * 100 (소수점 반올림) + int get progress { + if (totalPages <= 0) return 0; + return ((totalSolvedPages / totalPages) * 100).round().clamp(0, 100); + } + + /// 마지막 학습일 포맷팅 (YYYY.MM.DD) + String get formattedLastStudyDate { + if (lastStudyDate == null) return ''; + final date = lastStudyDate!; + return '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}'; + } + + /// 썸네일 경로 결정 전략 + /// + /// 1. coverImageUrl(book_image_url)이 있으면 → 네트워크 URL 반환 + /// 2. 없으면 → bookId 기반 asset 경로 매핑 (기본값) + String get thumbnailPath { + if (coverImageUrl != null && coverImageUrl!.isNotEmpty) { + return coverImageUrl!; + } + // Asset 경로 매핑 (fallback) + return _getAssetPathForBook(bookId); + } + + /// bookId → asset 경로 매핑 (fallback용) + String _getAssetPathForBook(int? bookId) { + // TODO: bookId 기반 매핑 로직 (필요시) + return 'assets/images/bookcovers/BookCover_Blacklabel.png'; + } + + Map toJson() { + return { + 'book_id': bookId, + 'book_name': bookName, + 'cover_image_url': coverImageUrl, + 'total_pages': totalPages, + 'total_solved_pages': totalSolvedPages, + 'last_study_date': lastStudyDate?.toIso8601String(), + 'academy_user_id': academyUserId, + 'class_name': className, + 'book_semester': bookSemester, + }; + } + + factory WorkbookSummaryEntity.fromJson(Map json) { + return WorkbookSummaryEntity( + bookId: json['book_id'] as int?, + bookName: json['book_name'] as String? ?? '', + coverImageUrl: json['cover_image_url'] as String?, + totalPages: json['total_pages'] as int? ?? 0, + totalSolvedPages: json['total_solved_pages'] as int? ?? 0, + lastStudyDate: json['last_study_date'] != null + ? DateTime.parse(json['last_study_date']).toLocal() + : null, + academyUserId: json['academy_user_id'] as int? ?? 0, + className: json['class_name'] as String?, + bookSemester: json['book_semester'] as String?, + ); + } +} diff --git a/frontend/lib/firebase_options.dart b/frontend/lib/firebase_options.dart new file mode 100644 index 0000000..df9b611 --- /dev/null +++ b/frontend/lib/firebase_options.dart @@ -0,0 +1,66 @@ +import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb, TargetPlatform; + +/// Default [FirebaseOptions] for use with your Firebase apps. +/// +/// Example: +/// ```dart +/// import 'firebase_options.dart'; +/// // ... +/// await Firebase.initializeApp( +/// options: DefaultFirebaseOptions.currentPlatform, +/// ); +/// ``` +class DefaultFirebaseOptions { + static FirebaseOptions get currentPlatform { + if (kIsWeb) { + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for web - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + } + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return android; + case TargetPlatform.iOS: + return ios; + case TargetPlatform.macOS: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for macos - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + case TargetPlatform.windows: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for windows - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + case TargetPlatform.linux: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for linux - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + default: + throw UnsupportedError( + 'DefaultFirebaseOptions are not supported for this platform.', + ); + } + } + + static const FirebaseOptions android = FirebaseOptions( + apiKey: 'AIzaSyAYs3bpt4mcglJvZaQXFc6eha2FCVZf72Y', + appId: '1:120799260544:android:a8e550dc78b59824b19b65', + messagingSenderId: '120799260544', + projectId: 'gradi-bd52c', + storageBucket: 'gradi-bd52c.firebasestorage.app', + ); + + static const FirebaseOptions ios = FirebaseOptions( + apiKey: 'AIzaSyAYs3bpt4mcglJvZaQXFc6eha2FCVZf72Y', + appId: '1:120799260544:ios:6dba36b595142ec0b19b65', + messagingSenderId: '120799260544', + projectId: 'gradi-bd52c', + storageBucket: 'gradi-bd52c.firebasestorage.app', + iosBundleId: 'com.gradi.gradiFrontend', + ); +} diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index cd54d66..3635798 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -1,9 +1,76 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:io'; +import 'firebase_options.dart'; import 'routes/app_routes.dart'; import 'theme/app_theme.dart'; +import 'config/di_container.dart'; +import 'services/fcm_service.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // 1. SharedPreferences 동기 인스턴스 확보 + final sharedPrefs = await SharedPreferences.getInstance(); + + // 2. DI Container 초기화 (SharedPreferences 포함) + await setupDependencies(sharedPrefs: sharedPrefs); + + // 🔥 개발 환경에서만 SSL 인증서 검증 우회 + // 프로덕션에서는 제거하거나 kDebugMode로 감싸기 + if (kDebugMode) { + HttpOverrides.global = MyHttpOverrides(); + } + + // Firebase 초기화 (이미 초기화되어 있으면 스킵) + try { + // 이미 초기화되어 있는지 확인 + if (Firebase.apps.isEmpty) { + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + debugPrint('✅ Firebase 초기화 성공'); + } else { + debugPrint('ℹ️ Firebase는 이미 초기화되어 있습니다.'); + } + } catch (e) { + debugPrint('❌ Firebase 초기화 실패: $e'); + // 중복 초기화 오류는 무시 (Hot Reload/Restart 시 발생 가능) + if (e.toString().contains('duplicate-app')) { + debugPrint('ℹ️ Firebase가 이미 초기화되어 있습니다. (Hot Reload/Restart)'); + } else { + // iOS에서 GoogleService-Info.plist를 찾지 못하는 경우 + if (defaultTargetPlatform == TargetPlatform.iOS) { + debugPrint( + '⚠️ iOS: GoogleService-Info.plist 파일이 Xcode 프로젝트에 포함되어 있는지 확인하세요.', + ); + debugPrint(' 파일 경로: ios/Runner/GoogleService-Info.plist'); + debugPrint(' Xcode에서: Runner.xcworkspace를 열고 파일이 프로젝트에 추가되어 있는지 확인'); + } + rethrow; + } + } + + // FCM 초기화 + try { + await getIt().initialize(); + } catch (e) { + debugPrint('❌ FCM 초기화 실패: $e'); + // FCM 초기화 실패해도 앱은 계속 실행 + } + + // 사용자 정보 초기화는 로딩 페이지에서 처리 + // (자동 로그인 시 로딩 페이지를 표시하면서 API 호출) + + // 세로 방향 고정 + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); -void main() { runApp(const GradiApp()); } @@ -28,7 +95,7 @@ class GradiApp extends StatelessWidget { theme: AppTheme.lightTheme, // Routing configuration - initialRoute: '/login', // login_page.dart 이 페이지로 시작 + initialRoute: AppRoutes.loading, // 자동 로그인 로직 실행을 위해 로딩 페이지로 시작 routes: AppRoutes.routes, onGenerateRoute: AppRoutes.onGenerateRoute, @@ -67,3 +134,14 @@ class _UnknownRoutePage extends StatelessWidget { ); } } + +// 개발 환경에서 SSL 인증서 검증 우회를 위한 클래스 +// 프로덕션에서는 제거하거나 kDebugMode로 감싸기 +class MyHttpOverrides extends HttpOverrides { + @override + HttpClient createHttpClient(SecurityContext? context) { + return super.createHttpClient(context) + ..badCertificateCallback = + (X509Certificate cert, String host, int port) => true; + } +} diff --git a/frontend/lib/models/assessment.dart b/frontend/lib/models/assessment.dart new file mode 100644 index 0000000..778e4bd --- /dev/null +++ b/frontend/lib/models/assessment.dart @@ -0,0 +1,149 @@ +/// Assessment 데이터 모델 +/// +/// 서버에서 받아온 평가/과제 정보를 표현합니다. +class Assessment { + final String bookId; + final String bookCoverImage; + final String assessName; // 숙제 이름 (assessChapter에서 변경) + final String assessPage; // "15-25" 형식 (시작페이지-끝페이지) + final String assessClass; // 클래스명 (assigneeId를 통해 API로 조회) + // assessStatus 종류: + // - 'N': No, 아직 숙제가 완료되지 않음 + // - 'Y': Yes, 숙제가 완료됨 + final String assessStatus; + + Assessment({ + required this.bookId, + required this.bookCoverImage, + required this.assessName, + required this.assessPage, + required this.assessClass, + required this.assessStatus, + }); + + /// JSON에서 Assessment 객체 생성 + /// + /// 두 가지 형식을 지원: + /// 1. API 응답 형식 (camelCase): assessName, assessStartPage, assigneeId, book.bookId 등 + /// 2. 캐시 저장 형식 (snake_case): assess_name, assess_page, assess_class 등 + factory Assessment.fromJson(Map json) { + + // 1. assessPage 파싱 (두 가지 형식 지원) + String assessPage; + if (json.containsKey('assessStartPage') && + json.containsKey('assessEndPage')) { + // API 응답 형식: assessStartPage, assessEndPage + final startPage = json['assessStartPage']?.toString() ?? '0'; + final endPage = json['assessEndPage']?.toString() ?? '0'; + assessPage = '$startPage-$endPage'; + } else if (json.containsKey('assess_page')) { + // 캐시 형식: assess_page (이미 "15-25" 형식) + assessPage = json['assess_page']?.toString() ?? '0-0'; + } else { + assessPage = '0-0'; + } + + // 2. assessClass 파싱 (두 가지 형식 지원) + String assessClass; + if (json.containsKey('assigneeId')) { + // API 응답 형식: assigneeId + assessClass = json['assigneeId']?.toString() ?? ''; + } else if (json.containsKey('assess_class')) { + // 캐시 형식: assess_class (이미 className이거나 assigneeId) + assessClass = json['assess_class']?.toString() ?? ''; + } else { + assessClass = ''; + } + + // 3. book 정보 파싱 (두 가지 형식 지원) + String bookId; + String bookImageUrl; + if (json.containsKey('book') && json['book'] is Map) { + // API 응답 형식: book 객체 + final book = json['book'] as Map; + bookId = book['bookId']?.toString() ?? ''; + bookImageUrl = book['bookImageUrl'] as String? ?? ''; + } else { + // 캐시 형식: book_id, book_cover_image + bookId = json['book_id']?.toString() ?? ''; + bookImageUrl = json['book_cover_image']?.toString() ?? ''; + } + + // 4. assessName 파싱 (두 가지 형식 지원) + final assessName = + json['assessName']?.toString() ?? json['assess_name']?.toString() ?? ''; + + // 5. assessStatus 파싱 (두 가지 형식 지원) + final assessStatus = + json['assessStatus']?.toString() ?? + json['assess_status']?.toString() ?? + 'N'; + + + return Assessment( + bookId: bookId, + bookCoverImage: bookImageUrl, + assessName: assessName, + assessPage: assessPage, + assessClass: assessClass, + assessStatus: assessStatus, + ); + } + + /// 클래스명을 업데이트한 새 Assessment 인스턴스 생성 + Assessment copyWith({String? assessClass}) { + return Assessment( + bookId: bookId, + bookCoverImage: bookCoverImage, + assessName: assessName, + assessPage: assessPage, + assessClass: assessClass ?? this.assessClass, + assessStatus: assessStatus, + ); + } + + /// Assessment 객체를 JSON으로 변환 + Map toJson() { + return { + 'book_id': bookId, + 'book_cover_image': bookCoverImage, + 'assess_name': assessName, + 'assess_page': assessPage, + 'assess_class': assessClass, + 'assess_status': assessStatus, + }; + } + + @override + String toString() { + return 'Assessment(bookId: $bookId, name: $assessName, status: $assessStatus)'; + } +} + +/// 날짜별 Assessment 데이터 +class DateAssessment { + final String date; // 'YYYY-MM-DD' 형식 + final List assessments; + + DateAssessment({required this.date, required this.assessments}); + + /// JSON에서 DateAssessment 객체 생성 + factory DateAssessment.fromJson(Map json) { + return DateAssessment( + date: json['date'] ?? '', + assessments: + (json['assessments'] as List?) + ?.map((item) => Assessment.fromJson(item)) + .toList() ?? + [], + ); + } + + /// DateAssessment 객체를 JSON으로 변환 + Map toJson() { + return { + 'date': date, + 'assessments': assessments.map((a) => a.toJson()).toList(), + }; + } +} diff --git a/frontend/lib/models/user.dart b/frontend/lib/models/user.dart new file mode 100644 index 0000000..d002b04 --- /dev/null +++ b/frontend/lib/models/user.dart @@ -0,0 +1,59 @@ +/// 사용자 정보 모델 +/// +/// 서버에서 받아온 사용자 정보를 표현합니다. +class User { + final int userId; + final String name; + final String? email; + final String? accountId; + final String? phoneNumber; + final String? birthDate; + final String? profileImageUrl; + + User({ + required this.userId, + required this.name, + this.email, + this.accountId, + this.phoneNumber, + this.birthDate, + this.profileImageUrl, + }); + + /// JSON에서 User 객체 생성 + factory User.fromJson(Map json) { + final dynamic userIdValue = json['user_id'] ?? json['userId']; + final int parsedUserId = userIdValue is int + ? userIdValue + : int.tryParse(userIdValue?.toString() ?? '') ?? 0; + + return User( + userId: parsedUserId, + name: json['name'] as String, + email: json['email'] as String?, + accountId: json['account_id'] as String? ?? json['accountId'] as String?, + phoneNumber: + json['phone_number'] as String? ?? json['phoneNumber'] as String?, + birthDate: json['birth_date'] as String? ?? json['birthDate'] as String?, + profileImageUrl: json['profile_image_url'] as String?, + ); + } + + /// User 객체를 JSON으로 변환 + Map toJson() { + return { + 'user_id': userId, + 'name': name, + 'email': email, + 'account_id': accountId, + 'phone_number': phoneNumber, + 'birth_date': birthDate, + 'profile_image_url': profileImageUrl, + }; + } + + @override + String toString() { + return 'User(userId: $userId, name: $name, email: $email, accountId: $accountId, phoneNumber: $phoneNumber, birthDate: $birthDate, profileImageUrl: $profileImageUrl)'; + } +} diff --git a/frontend/lib/routes/app_routes.dart b/frontend/lib/routes/app_routes.dart index 16238dc..d06da56 100644 --- a/frontend/lib/routes/app_routes.dart +++ b/frontend/lib/routes/app_routes.dart @@ -20,9 +20,33 @@ import '../screens/academy/academy_page.dart'; import '../screens/academy/academy_list_page.dart'; import '../screens/academy/academy_detail_page.dart'; import '../screens/workbook/workbook_page.dart'; +import '../screens/workbook/workbook_detail_page.dart'; +import 'package:get_it/get_it.dart'; +import '../config/app_dependencies.dart'; +import '../screens/workbook/chapter_detail_page.dart'; +import '../screens/workbook/question_detail_page.dart'; +import '../application/explanation/question_explanation_controller.dart'; + +// QuestionStatus enum을 사용하기 위해 chapter_detail_page import +// (QuestionStatus는 chapter_detail_page.dart에 정의되어 있음) +import '../screens/mypage/mypage.dart'; +import '../screens/mypage/display_settings_page.dart'; +import '../screens/mypage/homework_status_page.dart'; +import '../screens/mypage/learning_statistics_page.dart'; +import '../screens/mypage/account_management_page.dart'; +import '../screens/mypage/academy_management_page.dart'; +import '../screens/mypage/notification_settings_page.dart'; +import '../screens/notification/notification_page.dart'; +import '../screens/upload/upload_images_page.dart'; +import '../screens/grading_history/edit_grading_result_page.dart'; +import '../screens/continuous_learning_detail_page.dart'; +import '../screens/loading_page.dart'; +import '../screens/problem_solution_temp_page.dart'; +import '../domain/learning/get_monthly_learning_status_use_case.dart'; class AppRoutes { static const String mainNavigation = '/'; + static const String loading = '/loading'; static const String login = '/login'; static const String signup = '/signup'; static const String signupTerms = '/signup-terms'; @@ -32,6 +56,18 @@ class AppRoutes { static const String academyList = '/academy/list'; static const String academyDetail = '/academy/detail'; static const String workbook = '/workbook'; + static const String workbookDetail = '/workbook/detail'; + static const String chapterDetail = '/workbook/chapter-detail'; + static const String questionDetail = '/workbook/question-detail'; + static const String mypage = '/mypage'; + static const String displaySettings = '/mypage/display-settings'; + static const String homeworkStatus = '/mypage/homework-status'; + static const String learningStatistics = '/mypage/learning-statistics'; + static const String accountManagement = '/mypage/account-management'; + static const String academyManagement = '/mypage/academy-management'; + static const String notificationSettings = '/mypage/notification-settings'; + static const String notification = '/notification'; + static const String continuousLearningDetail = '/continuous-learning-detail'; static const String findId = '/find-id'; static const String findIdError = '/find-id-error'; static const String findIdVerification = '/find-id-verification'; @@ -43,9 +79,13 @@ class AppRoutes { static const String passwordResetForm = '/password-reset-form'; static const String passwordResetSuccess = '/password-reset-success'; static const String resetPassword = '/reset-password'; + static const String uploadImages = '/upload/images'; + static const String editGradingResult = '/upload/edit-result'; + static const String problemTemp = '/problem-temp'; static Map get routes => { mainNavigation: (context) => const MainNavigationPage(), + loading: (context) => const LoadingPage(), login: (context) => const LoginPage(), signup: (context) => const SignUpPage(), signupTerms: (context) => const SignUpTermsPage(), @@ -54,13 +94,24 @@ class AppRoutes { academy: (context) => const AcademyPage(), academyList: (context) => const AcademyListPage(), workbook: (context) => const WorkbookPage(), + mypage: (context) => const MyPage(), + displaySettings: (context) => const DisplaySettingsPage(), + homeworkStatus: (context) => const HomeworkStatusPage(), + learningStatistics: (context) => const LearningStatisticsPage(), + accountManagement: (context) => const AccountManagementPage(), + academyManagement: (context) => const AcademyManagementPage(), + notificationSettings: (context) => const NotificationSettingsPage(), + notification: (context) => const NotificationPage(), findId: (context) => const FindIDPage(), findIdError: (context) => const FindIDErrorPage(), findPassword: (context) => const FindPasswordPage(), findPasswordError: (context) => const FindPasswordErrorPage(), + uploadImages: (context) => const UploadImagesPage(), + problemTemp: (context) => const ProblemSolutionTempPage(), }; static Route? onGenerateRoute(RouteSettings settings) { + final getIt = GetIt.instance; switch (settings.name) { case findIdVerification: return MaterialPageRoute( @@ -106,6 +157,149 @@ class AppRoutes { return MaterialPageRoute( builder: (context) => AcademyDetailPage(academy: args), ); + case workbookDetail: + final args = settings.arguments as Map?; + if (args == null) { + return null; + } + + final bookId = args['bookId'] as int?; + final academyUserId = args['academyUserId'] as int?; + + if (bookId == null || academyUserId == null) { + // 필수 파라미터가 없으면 에러 처리 + return MaterialPageRoute( + builder: (context) => const Scaffold( + body: Center(child: Text('문제집 정보가 올바르지 않습니다.')), + ), + ); + } + + return MaterialPageRoute( + builder: (context) => WorkbookDetailPage( + workbookName: args['workbookName'] as String, + thumbnailPath: args['thumbnailPath'] as String?, + bookId: bookId, + academyUserId: academyUserId, + getChaptersUseCase: + AppDependencies.getChaptersForBookUseCase, + ), + ); + case chapterDetail: + final args = settings.arguments as Map?; + if (args == null) { + return null; + } + + final chapterId = args['chapterId'] as int?; + final academyUserId = args['academyUserId'] as int?; + + if (chapterId == null || academyUserId == null) { + return MaterialPageRoute( + builder: (context) => const Scaffold( + body: Center(child: Text('챕터 정보가 올바르지 않습니다.')), + ), + ); + } + + return MaterialPageRoute( + builder: (context) => ChapterDetailPage( + chapterId: chapterId, + academyUserId: academyUserId, + workbookName: args['workbookName'] as String, + chapterName: args['chapterName'] as String, + getChapterQuestionStatusesUseCase: + AppDependencies.getChapterQuestionStatusesUseCase, + ), + ); + case questionDetail: + final args = settings.arguments as Map?; + if (args == null) { + return MaterialPageRoute( + builder: (context) => const Scaffold( + body: Center(child: Text('문제 정보가 올바르지 않습니다.')), + ), + ); + } + + final chapterId = args['chapterId'] as int?; + final academyUserId = args['academyUserId'] as int?; + final workbookName = args['workbookName'] as String?; + final chapterName = args['chapterName'] as String?; + final questionNumber = args['questionNumber'] as int?; + final status = args['status'] as QuestionStatus?; + final studentResponseId = args['studentResponseId'] as int?; + + if (chapterId == null || + academyUserId == null || + workbookName == null || + chapterName == null || + questionNumber == null || + status == null) { + return MaterialPageRoute( + builder: (context) => const Scaffold( + body: Center(child: Text('문제 정보가 올바르지 않습니다.')), + ), + ); + } + + final explanationController = getIt(); + + return MaterialPageRoute( + builder: (context) => QuestionDetailPage( + chapterId: chapterId, + academyUserId: academyUserId, + workbookName: workbookName, + chapterName: chapterName, + questionNumber: questionNumber, + status: status, + initialStudentResponseId: studentResponseId, + explanationController: explanationController, + ), + ); + case editGradingResult: + final args = settings.arguments as Map?; + if (args == null || + args['studentResponseId'] == null || + args['academyUserId'] == null) { + // studentResponseId 또는 academyUserId가 없으면 에러 처리 + return MaterialPageRoute( + builder: (context) => Scaffold( + body: Center( + child: Text( + args == null || args['studentResponseId'] == null + ? '학생 응답 ID가 필요합니다.' + : '학원 사용자 ID가 필요합니다.', + ), + ), + ), + settings: settings, + ); + } + + return MaterialPageRoute( + builder: (context) => EditGradingResultPage( + studentResponseId: args['studentResponseId'] as int, + academyUserId: args['academyUserId'] as int, + getStudentAnswersUseCase: + AppDependencies.getStudentAnswersForResponseUseCase, + updateStudentAnswersUseCase: + AppDependencies.updateStudentAnswersUseCase, + getSectionImageUseCase: + AppDependencies.getSectionImageUseCase, + updateSingleStudentAnswerUseCase: + AppDependencies.updateSingleStudentAnswerUseCase, + ), + settings: settings, + ); + case continuousLearningDetail: + return MaterialPageRoute( + builder: (context) => ContinuousLearningDetailPage( + monthlyStatusUseCase: + getIt(), + ), + settings: settings, + ); default: return null; } diff --git a/frontend/lib/screens/academy/academy_detail_page.dart b/frontend/lib/screens/academy/academy_detail_page.dart index d13eb68..8bc8276 100644 --- a/frontend/lib/screens/academy/academy_detail_page.dart +++ b/frontend/lib/screens/academy/academy_detail_page.dart @@ -1,11 +1,147 @@ import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; import 'academy_list_page.dart'; +import '../../services/academy_service.dart'; +import '../../services/auth_service.dart'; +import '../../utils/app_logger.dart'; -class AcademyDetailPage extends StatelessWidget { +class AcademyDetailPage extends StatefulWidget { final AcademyData academy; const AcademyDetailPage({super.key, required this.academy}); + @override + State createState() => _AcademyDetailPageState(); +} + +class _AcademyDetailPageState extends State { + final GetIt _getIt = GetIt.instance; + + late final AcademyService _academyService; + late final AuthService _authService; + bool _isLoading = false; + + // 학원 스케줄 관련 상태 + AcademyScheduleResponse? _academySchedule; + bool _isLoadingSchedule = false; + String? _scheduleError; + + // 학원 이미지 관련 상태 + List _academyImageUrls = []; + bool _isLoadingImages = false; + String? _imageError; + final PageController _imagePageController = PageController(); + int _currentImageIndex = 0; + + @override + void initState() { + super.initState(); + _academyService = _getIt(); + _authService = _getIt(); + _loadAcademySchedule(); + _loadAcademyImages(); + } + + @override + void dispose() { + _imagePageController.dispose(); + super.dispose(); + } + + /// 학원 스케줄 데이터 로드 + Future _loadAcademySchedule() async { + appLog('[academy:academy_detail_page] _loadAcademySchedule 시작'); + + // academyCode null 체크 (String? 타입이므로 필요) + if (widget.academy.academyCode == null) { + appLog('[academy:academy_detail_page] academyCode가 null'); + if (!mounted) return; + setState(() { + _scheduleError = '학원 코드가 없습니다.'; + }); + return; + } + + appLog( + '[academy:academy_detail_page] academyCode: ${widget.academy.academyCode}', + ); + + if (!mounted) return; + setState(() { + _isLoadingSchedule = true; + _scheduleError = null; + }); + + try { + final schedule = await _academyService.getAcademySchedule( + widget.academy.academyCode!, + ); + + appLog( + '[academy:academy_detail_page] API 호출 완료 - academyName: ${schedule.academy.academyName}, schedules 개수: ${schedule.schedules.length}', + ); + + // 스케줄 상세 로그 + for (final s in schedule.schedules) { + appLog( + '[academy:academy_detail_page] Schedule - dayOfWeek: ${s.dayOfWeek} (${s.dayName}), startTime: ${s.startTime}, endTime: ${s.endTime}', + ); + } + + if (!mounted) return; + setState(() { + _academySchedule = schedule; + _isLoadingSchedule = false; + }); + + appLog('[academy:academy_detail_page] UI 업데이트 완료'); + } catch (e) { + appLog('[academy:academy_detail_page] 에러 발생: $e'); + if (!mounted) return; + setState(() { + _isLoadingSchedule = false; + // 사용자용 간단한 메시지 (상세 에러는 로그에만) + _scheduleError = '학원 정보를 가져오지 못했습니다. 잠시 후 다시 시도해주세요.'; + }); + } + } + + /// 학원 이미지 데이터 로드 + Future _loadAcademyImages() async { + // academyCode null 체크 + if (widget.academy.academyCode == null) { + if (!mounted) return; + setState(() { + _imageError = '학원 코드가 없습니다.'; + }); + return; + } + + if (!mounted) return; + setState(() { + _isLoadingImages = true; + _imageError = null; + }); + + try { + final imageResponse = await _academyService.getAcademyImages( + widget.academy.academyCode!, + ); + + if (!mounted) return; + setState(() { + _academyImageUrls = imageResponse.academyImageUrls; + _isLoadingImages = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _isLoadingImages = false; + _imageError = '이미지를 가져오지 못했습니다.'; + }); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -35,11 +171,6 @@ class AcademyDetailPage extends StatelessWidget { const SizedBox(height: 16), - // 학원 소개 - _buildAcademyDescription(), - - const SizedBox(height: 32), - // 등록 버튼 _buildRegisterButton(context), @@ -79,7 +210,7 @@ class AcademyDetailPage extends StatelessWidget { text: TextSpan( children: [ TextSpan( - text: academy.name, + text: widget.academy.name, style: const TextStyle( fontFamily: 'Pretendard', fontWeight: FontWeight.w700, @@ -87,15 +218,16 @@ class AcademyDetailPage extends StatelessWidget { color: Color(0xFF333333), ), ), - TextSpan( - text: ' #DF850', - style: TextStyle( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w700, - fontSize: 18, - color: Colors.grey[400], + if (widget.academy.academyCode != null) + TextSpan( + text: ' #${widget.academy.academyCode}', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 18, + color: Colors.grey[400], + ), ), - ), ], ), ), @@ -115,20 +247,128 @@ class AcademyDetailPage extends StatelessWidget { } Widget _buildAcademyImage() { - return Container( + // 로딩 상태 + if (_isLoadingImages) { + return Container( + width: double.infinity, + height: 200, + color: const Color(0xFFE0E5EB), + child: const Center(child: CircularProgressIndicator()), + ); + } + + // 에러 상태 또는 이미지가 없는 경우 + if (_imageError != null || _academyImageUrls.isEmpty) { + return Container( + width: double.infinity, + height: 200, + color: const Color(0xFFE0E5EB), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.image_outlined, + size: 48, + color: Color(0xFF999999), + ), + const SizedBox(height: 8), + Text( + _imageError ?? '이미지가 없습니다.', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF999999), + ), + ), + ], + ), + ), + ); + } + + // 이미지가 1개인 경우 슬라이더 없이 표시 + if (_academyImageUrls.length == 1) { + return Container( + width: double.infinity, + height: 200, + child: Image.network( + _academyImageUrls[0], + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + color: const Color(0xFFE0E5EB), + child: const Center( + child: Icon( + Icons.image_outlined, + size: 48, + color: Color(0xFF999999), + ), + ), + ); + }, + ), + ); + } + + // 이미지가 여러 개인 경우 슬라이더로 표시 + return SizedBox( width: double.infinity, height: 200, - color: const Color(0xFFE0E5EB), - child: const Center( - child: Text( - '학원 이미지 영역', - style: TextStyle( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w500, - fontSize: 14, - color: Color(0xFF999999), + child: Stack( + children: [ + // 이미지 슬라이더 + PageView.builder( + controller: _imagePageController, + itemCount: _academyImageUrls.length, + onPageChanged: (index) { + setState(() { + _currentImageIndex = index; + }); + }, + itemBuilder: (context, index) { + return Image.network( + _academyImageUrls[index], + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + color: const Color(0xFFE0E5EB), + child: const Center( + child: Icon( + Icons.image_outlined, + size: 48, + color: Color(0xFF999999), + ), + ), + ); + }, + ); + }, ), - ), + // 인디케이터 (하단 중앙) + Positioned( + bottom: 12, + left: 0, + right: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(_academyImageUrls.length, (index) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _currentImageIndex == index + ? Colors.white + : Colors.white.withOpacity(0.5), + ), + ); + }), + ), + ), + ], ), ); } @@ -152,14 +392,28 @@ class AcademyDetailPage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ // 주소 섹션 - _buildInfoRow(Icons.location_on, '주소', academy.address), + _buildInfoRow( + Icons.location_on, + '주소', + _academySchedule?.academy != null + ? '${_academySchedule!.academy.academyRoadAddress} ${_academySchedule!.academy.academyDetailAddress}' + .trim() + : widget.academy.address, + ), const SizedBox(height: 16), const Divider(color: Color(0xFFE9ECEF), height: 1), const SizedBox(height: 16), - // 과목 섹션 - _buildInfoRow(Icons.menu_book, '과목', '초·중등 수학, 영어, 논술'), + // 소개 섹션 + _buildInfoRow( + Icons.info_outline, + '소개', + _academySchedule?.academy != null && + _academySchedule!.academy.academyDescription.isNotEmpty + ? _academySchedule!.academy.academyDescription + : '${widget.academy.name}에 대한 소개 정보가 없습니다.', + ), const SizedBox(height: 16), const Divider(color: Color(0xFFE9ECEF), height: 1), @@ -216,6 +470,142 @@ class AcademyDetailPage extends StatelessWidget { } Widget _buildOperatingHours() { + // 로딩 상태 + if (_isLoadingSchedule) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.access_time, color: Color(0xFF666666), size: 20), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '영업시간', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 14, + color: Color(0xFF333333), + ), + ), + const SizedBox(height: 4), + const Text( + '영업시간을 불러오는 중입니다...', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + fontSize: 14, + color: Color(0xFF666666), + ), + ), + ], + ), + ), + ], + ); + } + + // 에러 상태 + if (_scheduleError != null) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.access_time, color: Color(0xFF666666), size: 20), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '영업시간', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 14, + color: Color(0xFF333333), + ), + ), + const SizedBox(height: 4), + const Text( + '영업시간 정보를 가져오지 못했습니다.', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + fontSize: 14, + color: Color(0xFF666666), + ), + ), + ], + ), + ), + ], + ); + } + + // 스케줄이 없는 경우 + if (_academySchedule == null || _academySchedule!.schedules.isEmpty) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.access_time, color: Color(0xFF666666), size: 20), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '영업시간', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 14, + color: Color(0xFF333333), + ), + ), + const SizedBox(height: 4), + const Text( + '등록된 영업시간이 없습니다.', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + fontSize: 14, + color: Color(0xFF666666), + ), + ), + ], + ), + ), + ], + ); + } + + // 스케줄 그룹핑 및 정렬 + final schedules = _academySchedule!.schedules; + + // dayOfWeek 기준으로 그룹핑 + final Map> groupedByDay = {}; + for (final schedule in schedules) { + if (schedule.dayOfWeek >= 0 && schedule.dayOfWeek <= 6) { + groupedByDay.putIfAbsent(schedule.dayOfWeek, () => []); + groupedByDay[schedule.dayOfWeek]!.add(schedule); + } + } + + // 요일별로 startTime 오름차순 정렬 (문자열 비교, HH:MM:SS 포맷 전제) + groupedByDay.forEach((day, daySchedules) { + daySchedules.sort((a, b) => a.startTime.compareTo(b.startTime)); + }); + + // 요일 순서: 월요일(1) ~ 토요일(6), 일요일(0)은 마지막 + final sortedDays = groupedByDay.keys.toList() + ..sort((a, b) { + if (a == 0) return 1; // 일요일은 뒤로 + if (b == 0) return -1; + return a.compareTo(b); + }); + return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -235,35 +625,29 @@ class AcademyDetailPage extends StatelessWidget { ), ), const SizedBox(height: 4), - const Text( - '월요일 - 금요일 : 9:00 AM - 8:00 PM', - style: TextStyle( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w400, - fontSize: 14, - color: Color(0xFF666666), - ), - ), - const SizedBox(height: 2), - const Text( - '토요일 : 10:00 AM - 6:00 PM', - style: TextStyle( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w400, - fontSize: 14, - color: Color(0xFF666666), - ), - ), - const SizedBox(height: 2), - const Text( - '일요일 : 12:00 PM - 5:00 PM', - style: TextStyle( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w400, - fontSize: 14, - color: Color(0xFF666666), - ), - ), + // 요일별로 표시 + ...sortedDays.map((day) { + final daySchedules = groupedByDay[day]!; + final dayName = daySchedules.first.dayName; + final timeRanges = daySchedules + .map( + (s) => '${s.formattedStartTime} - ${s.formattedEndTime}', + ) + .join(', '); + + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + '$dayName : $timeRanges', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + fontSize: 14, + color: Color(0xFF666666), + ), + ), + ); + }).toList(), ], ), ), @@ -272,6 +656,17 @@ class AcademyDetailPage extends StatelessWidget { } Widget _buildContactSection() { + final academy = _academySchedule?.academy; + final phone = (academy?.academyPhone.isNotEmpty == true) + ? academy!.academyPhone + : '전화 정보가 없습니다.'; + final email = (academy?.academyEmail.isNotEmpty == true) + ? academy!.academyEmail + : '이메일 정보가 없습니다.'; + final website = (academy?.academyWebsite.isNotEmpty == true) + ? academy!.academyWebsite + : '웹사이트 정보가 없습니다.'; + return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -291,9 +686,9 @@ class AcademyDetailPage extends StatelessWidget { ), ), const SizedBox(height: 4), - const Text( - 'Phone: (555) 123-4567', - style: TextStyle( + Text( + 'Phone: $phone', + style: const TextStyle( fontFamily: 'Pretendard', fontWeight: FontWeight.w400, fontSize: 14, @@ -301,9 +696,9 @@ class AcademyDetailPage extends StatelessWidget { ), ), const SizedBox(height: 2), - const Text( - 'Email: info@grandlibrary.org', - style: TextStyle( + Text( + 'Email: $email', + style: const TextStyle( fontFamily: 'Pretendard', fontWeight: FontWeight.w400, fontSize: 14, @@ -311,9 +706,9 @@ class AcademyDetailPage extends StatelessWidget { ), ), const SizedBox(height: 2), - const Text( - 'Website: www.grandlibrary.org', - style: TextStyle( + Text( + 'Website: $website', + style: const TextStyle( fontFamily: 'Pretendard', fontWeight: FontWeight.w400, fontSize: 14, @@ -327,24 +722,61 @@ class AcademyDetailPage extends StatelessWidget { ); } - Widget _buildAcademyDescription() { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFFF8F9FA), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - '${academy.name}은(는) 입시와 공무원 시험에 특화된 전문 교육 기관입니다. 체계적인 커리큘럼과 1:1 맞춤형 학습 관리로 높은 합격률을 자랑합니다. 단국대학교 죽전캠퍼스에서 ${academy.distance} 거리에 위치하고 있습니다.', - style: const TextStyle( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w400, - fontSize: 14, - color: Color(0xFF666666), - height: 1.6, + Future _handleRegister() async { + // academyCode가 없는 경우 처리 + if (widget.academy.academyCode == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('학원 정보가 올바르지 않습니다.'), + backgroundColor: Colors.red, ), - ), - ); + ); + return; + } + + setState(() { + _isLoading = true; + }); + + try { + // user_id 가져오기 + final userId = await _authService.getUserId(); + if (userId == null) { + throw Exception('사용자 정보를 가져올 수 없습니다. 다시 로그인해주세요.'); + } + + // 학원 등록 요청 + await _academyService.joinAcademyRequest( + academy_id: widget.academy.academyCode!, + user_id: int.parse(userId), + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${widget.academy.name} 등록 요청이 완료되었습니다.'), + backgroundColor: Colors.green, + ), + ); + // 등록 성공 후 이전 페이지로 돌아가기 (성공 여부 전달) + Navigator.of(context).pop(true); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toString().replaceAll('Exception: ', '')), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } } Widget _buildRegisterButton(BuildContext context) { @@ -360,12 +792,7 @@ class AcademyDetailPage extends StatelessWidget { borderRadius: BorderRadius.circular(12), ), child: ElevatedButton( - onPressed: () { - // TODO: 학원 등록 API 호출 - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${academy.name} 등록 요청 (구현 예정)')), - ); - }, + onPressed: _isLoading ? null : _handleRegister, style: ElevatedButton.styleFrom( backgroundColor: Colors.transparent, shadowColor: Colors.transparent, @@ -373,15 +800,24 @@ class AcademyDetailPage extends StatelessWidget { borderRadius: BorderRadius.circular(12), ), ), - child: const Text( - '학원 등록하기', - style: TextStyle( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w700, - fontSize: 16, - color: Colors.white, - ), - ), + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Text( + '학원 등록하기', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 16, + color: Colors.white, + ), + ), ), ); } diff --git a/frontend/lib/screens/academy/academy_list_page.dart b/frontend/lib/screens/academy/academy_list_page.dart index 667d09d..1144e5c 100644 --- a/frontend/lib/screens/academy/academy_list_page.dart +++ b/frontend/lib/screens/academy/academy_list_page.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'dart:io'; import '../../widgets/back_button.dart'; +import '../../services/location_service.dart'; +import '../../services/academy_service.dart'; class AcademyListPage extends StatefulWidget { const AcademyListPage({super.key}); @@ -10,14 +13,18 @@ class AcademyListPage extends StatefulWidget { class _AcademyListPageState extends State { final TextEditingController _searchController = TextEditingController(); + final LocationService _locationService = LocationService(); + final AcademyService _academyService = AcademyService(); + List _filteredAcademies = []; List _allAcademies = []; + bool _isLoading = true; + String? _errorMessage; @override void initState() { super.initState(); - _initializeAcademies(); - _filteredAcademies = _allAcademies; + _fetchNearbyAcademies(); } @override @@ -25,105 +32,63 @@ class _AcademyListPageState extends State { _searchController.dispose(); super.dispose(); } - // TODO: Implement logic to fetch information of 20 nearby academies from the database. - // Details: - // - The academies should be displayed in order of proximity. - // - Implement a search functionality where typing "이투스" in the search bar - // will display all academies with "이투스" in their name. - - void _initializeAcademies() { - _allAcademies = [ - AcademyData( - name: '정다훈 영어학원', - distance: '0.5km', - address: '경기도 용인시 기흥구 죽전로 152', - thumbnail: 'assets/images/academy1.jpg', - ), - AcademyData( - name: '청담어학원 죽전캠퍼스', - distance: '0.8km', - address: '경기도 용인시 기흥구 죽전로 200', - thumbnail: 'assets/images/academy2.jpg', - ), - AcademyData( - name: '메가스터디 영어학원', - distance: '1.2km', - address: '경기도 용인시 기흥구 죽전로 300', - thumbnail: 'assets/images/academy3.jpg', - ), - AcademyData( - name: '대성마이맥 영어학원', - distance: '1.5km', - address: '경기도 용인시 기흥구 신갈로 100', - thumbnail: 'assets/images/academy4.jpg', - ), - AcademyData( - name: '이투스 영어학원', - distance: '2.0km', - address: '경기도 용인시 기흥구 신갈로 200', - thumbnail: 'assets/images/academy5.jpg', - ), - AcademyData( - name: '청심영어학원', - distance: '2.3km', - address: '경기도 용인시 기흥구 보정로 50', - thumbnail: 'assets/images/academy6.jpg', - ), - AcademyData( - name: '윤선생 영어학원', - distance: '2.8km', - address: '경기도 용인시 기흥구 보정로 150', - thumbnail: 'assets/images/academy7.jpg', - ), - AcademyData( - name: '파고다 영어학원', - distance: '3.1km', - address: '경기도 용인시 기흥구 보정로 250', - thumbnail: 'assets/images/academy8.jpg', - ), - AcademyData( - name: 'YBM 영어학원', - distance: '3.5km', - address: '경기도 용인시 기흥구 구갈로 100', - thumbnail: 'assets/images/academy9.jpg', - ), - AcademyData( - name: '스터디포스 영어학원', - distance: '4.0km', - address: '경기도 용인시 기흥구 구갈로 200', - thumbnail: 'assets/images/academy10.jpg', - ), - AcademyData( - name: '글로벌어학원', - distance: '4.2km', - address: '경기도 용인시 기흥구 신갈로 300', - thumbnail: 'assets/images/academy11.jpg', - ), - AcademyData( - name: '어학원 스카이', - distance: '4.8km', - address: '경기도 용인시 기흥구 신갈로 400', - thumbnail: 'assets/images/academy12.jpg', - ), - AcademyData( - name: '영어마을학원', - distance: '5.1km', - address: '경기도 용인시 기흥구 죽전로 500', - thumbnail: 'assets/images/academy13.jpg', - ), - AcademyData( - name: '토익마스터 학원', - distance: '5.5km', - address: '경기도 용인시 기흥구 죽전로 600', - thumbnail: 'assets/images/academy14.jpg', - ), - AcademyData( - name: '토플전문학원', - distance: '6.0km', - address: '경기도 용인시 기흥구 죽전로 700', - thumbnail: 'assets/images/academy15.jpg', - ), - ]; + + /// 근처 학원 리스트 조회 + Future _fetchNearbyAcademies() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + // 개발 환경에서 SSL 인증서 검증 우회 (프로덕션에서는 제거) + HttpOverrides.global = MyHttpOverrides(); + + // 현재 위치 가져오기 + final position = await _locationService.getCurrentLocation(); + + if (position == null) { + setState(() { + _isLoading = false; + _errorMessage = '위치 정보를 가져올 수 없습니다. 위치 권한을 확인해주세요.'; + }); + return; + } + + // API 호출하여 근처 학원 리스트 가져오기 + final academies = await _academyService.getNearbyAcademies( + latitude: position.latitude, + longitude: position.longitude, + ); + + // 응답 데이터를 AcademyData로 변환 + _allAcademies = academies.map((academy) { + return AcademyData( + name: academy.academyName, + distance: '${academy.distanceKm.toStringAsFixed(1)}km', + address: academy.academyRoadAddress, + thumbnail: 'assets/images/academy1.jpg', // 기본 썸네일 + academyCode: academy.academyCode, + ); + }).toList(); + + // 거리순으로 정렬 (이미 서버에서 정렬되어 있을 수 있지만 확실히 하기 위해) + _allAcademies.sort((a, b) { + final distanceA = double.parse(a.distance.replaceAll('km', '')); + final distanceB = double.parse(b.distance.replaceAll('km', '')); + return distanceA.compareTo(distanceB); + }); + + setState(() { + _filteredAcademies = _allAcademies; + _isLoading = false; + }); + } catch (e) { + setState(() { + _isLoading = false; + _errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + } } void _onSearchChanged(String query) { @@ -171,8 +136,12 @@ class _AcademyListPageState extends State { height: MediaQuery.of(context).size.height * 0.03, ), - // 학원 목록 - _buildAcademyList(), + // 학원 목록 또는 로딩/에러 상태 + _isLoading + ? _buildLoadingState() + : _errorMessage != null + ? _buildErrorState() + : _buildAcademyList(), Container( height: MediaQuery.of(context).size.height * 0.025, @@ -252,16 +221,99 @@ class _AcademyListPageState extends State { ), prefixIcon: Icon(Icons.search, color: Color(0xFF666666), size: 23), border: InputBorder.none, - contentPadding: EdgeInsets.symmetric( - horizontal: 22, - vertical: 11, - ), + contentPadding: EdgeInsets.symmetric(horizontal: 22, vertical: 11), + ), + ), + ); + } + + Widget _buildLoadingState() { + return const Center( + child: Padding( + padding: EdgeInsets.all(40.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text( + '근처 학원을 찾는 중...', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF666666), + ), + ), + ], + ), + ), + ); + } + + Widget _buildErrorState() { + return Center( + child: Padding( + padding: const EdgeInsets.all(40.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 64, color: Color(0xFFADADAD)), + const SizedBox(height: 16), + Text( + _errorMessage ?? '오류가 발생했습니다', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF666666), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: _fetchNearbyAcademies, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF333333), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + child: const Text( + '다시 시도', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ), + ], ), ), ); } Widget _buildAcademyList() { + if (_filteredAcademies.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(40.0), + child: Text( + '근처에 학원이 없습니다.', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF666666), + ), + ), + ), + ); + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -295,8 +347,18 @@ class _AcademyListPageState extends State { Widget _buildAcademyCard(AcademyData academy) { return GestureDetector( - onTap: () { - Navigator.pushNamed(context, '/academy/detail', arguments: academy); + onTap: () async { + // 학원 상세 페이지로 이동하고 등록 성공 여부를 받음 + final result = await Navigator.pushNamed( + context, + '/academy/detail', + arguments: academy, + ); + // 등록 성공 시 academy_page의 캐시 갱신을 위해 결과 전달 + if (result == true && mounted) { + // academy_list_page에서 academy_page로 결과 전달 + Navigator.of(context).pop(true); + } }, child: Container( padding: EdgeInsets.all(MediaQuery.of(context).size.width * 0.025), @@ -308,19 +370,7 @@ class _AcademyListPageState extends State { child: Row( children: [ // 학원 썸네일 - Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.25, - minWidth: 80, - maxHeight: MediaQuery.of(context).size.width * 0.25, - minHeight: 80, - ), - decoration: BoxDecoration( - color: const Color(0xFF666666), - borderRadius: BorderRadius.circular(5), - ), - child: const Icon(Icons.school, color: Colors.white, size: 40), - ), + _buildAcademyThumbnail(academy), Container(width: MediaQuery.of(context).size.width * 0.04), @@ -377,6 +427,67 @@ class _AcademyListPageState extends State { ), ); } + + Widget _buildAcademyThumbnail(AcademyData academy) { + return FutureBuilder( + future: academy.academyCode != null + ? _academyService + .getAcademyImages(academy.academyCode!) + .then((response) => response.mainImageUrl) + .catchError((e) { + return null; + }) + : Future.value(null), + builder: (context, snapshot) { + final imageUrl = snapshot.data; + final isLoading = snapshot.connectionState == ConnectionState.waiting; + + return Container( + width: MediaQuery.of(context).size.width * 0.25, + height: MediaQuery.of(context).size.width * 0.25, + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.25, + minWidth: 80, + maxHeight: MediaQuery.of(context).size.width * 0.25, + minHeight: 80, + ), + decoration: BoxDecoration( + color: const Color(0xFF666666), + borderRadius: BorderRadius.circular(5), + ), + child: isLoading + ? const Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + ) + : imageUrl != null && imageUrl.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(5), + child: Image.network( + imageUrl, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + errorBuilder: (context, error, stackTrace) { + return const Icon( + Icons.school, + color: Colors.white, + size: 40, + ); + }, + ), + ) + : const Icon(Icons.school, color: Colors.white, size: 40), + ); + }, + ); + } } class AcademyData { @@ -384,11 +495,23 @@ class AcademyData { final String distance; final String address; final String thumbnail; + final String? academyCode; // 학원 코드 AcademyData({ required this.name, required this.distance, required this.address, required this.thumbnail, + this.academyCode, }); } + +// 개발 환경에서 SSL 인증서 검증 우회를 위한 클래스 (프로덕션에서는 제거) +class MyHttpOverrides extends HttpOverrides { + @override + HttpClient createHttpClient(SecurityContext? context) { + return super.createHttpClient(context) + ..badCertificateCallback = + (X509Certificate cert, String host, int port) => true; + } +} diff --git a/frontend/lib/screens/academy/academy_page.dart b/frontend/lib/screens/academy/academy_page.dart index fe7d3c6..b5f3b04 100644 --- a/frontend/lib/screens/academy/academy_page.dart +++ b/frontend/lib/screens/academy/academy_page.dart @@ -1,4 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:convert'; +import 'dart:io'; +import '../../widgets/app_header.dart'; +import '../../widgets/app_header_title.dart'; +import '../../widgets/app_header_menu_button.dart'; +import '../../services/auth_service.dart'; +import '../../services/academy_service.dart'; class AcademyPage extends StatefulWidget { const AcademyPage({super.key}); @@ -8,9 +17,179 @@ class AcademyPage extends StatefulWidget { } class _AcademyPageState extends State { - // TODO: 서버에서 등록된 학원 목록을 불러오는 로직 구현 - // 임시로 빈 리스트 사용 final List _registeredAcademies = []; + final GetIt _getIt = GetIt.instance; + + late final AuthService _authService; + late final AcademyService _academyService; + + bool _isLoading = false; + bool _hasLoadedOnce = false; // 메모리 캐싱: 이미 로드했는지 확인 + bool _hasRefreshedOnReturn = false; // didChangeDependencies에서 이미 갱신했는지 확인 + String? _errorMessage; + + static const String _cacheKeyBase = 'user_academies_cache'; + + @override + void initState() { + super.initState(); + _authService = _getIt(); + _academyService = _getIt(); + _loadAcademies(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // 다른 페이지에서 돌아올 때만 한 번 갱신 (과도한 API 호출 방지) + if (!_hasRefreshedOnReturn) { + _hasRefreshedOnReturn = true; + _loadAcademies(forceRefresh: true); + } + } + + /// 외부에서 호출 가능한 새로고침 메서드 + /// 탭 전환 시 MainNavigationPage에서 호출 + void refresh() { + _hasRefreshedOnReturn = false; // 플래그 리셋 + _loadAcademies(forceRefresh: true); + } + + /// 학원 목록 로드 (캐시에서만 로드) + /// [forceRefresh]가 true이면 API 호출하여 최신 데이터 가져오기 + Future _loadAcademies({bool forceRefresh = false}) async { + // [케이스 1] 기본: SharedPreferences에서 캐시 로드 + // [케이스 2] 같은 세션 내 재진입: 메모리 캐시 사용 (API 호출 없음) + // [케이스 3] 강제 갱신: API 호출하여 최신 데이터 가져오기 + + if (_hasLoadedOnce && !forceRefresh) { + // 같은 세션 내 재진입: 메모리 캐시 사용 + return; + } + + // 강제 갱신 시 _hasLoadedOnce 플래그 리셋 + if (forceRefresh) { + _hasLoadedOnce = false; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final userId = await _authService.getUserId(); + if (userId == null) { + setState(() { + _isLoading = false; + _errorMessage = '사용자 정보를 가져올 수 없습니다. 다시 로그인해주세요.'; + }); + return; + } + final cacheKey = '${_cacheKeyBase}_$userId'; + if (forceRefresh) { + // 개발 환경에서 SSL 인증서 검증 우회 (프로덕션에서는 제거) + HttpOverrides.global = MyHttpOverrides(); + + try { + final academies = await _academyService.getUserAcademies(userId); + + // API 응답을 AcademyItem으로 변환 (에러 처리 개선) + final academyItems = []; + for (var academy in academies) { + try { + final item = AcademyItem( + name: academy.academyName, + distance: '', // API 응답에 거리 정보가 없으므로 빈 문자열 + address: academy.academyRoadAddress, + thumbnail: null, + registerStatus: academy.registerStatus, + academyCode: academy.academyCode, + ); + academyItems.add(item); + } catch (e) { + // 에러가 발생해도 계속 진행 + } + } + + // 메모리 및 SharedPreferences에 저장 + setState(() { + _registeredAcademies.clear(); + _registeredAcademies.addAll(academyItems); + _hasLoadedOnce = true; + _isLoading = false; + }); + + await _saveToCache(academyItems, cacheKey); + } catch (e) { + setState(() { + _isLoading = false; + _errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + } + } else { + // 기본: SharedPreferences에서 캐시 로드 + final cachedData = await _loadFromCache(cacheKey); + if (cachedData.isNotEmpty) { + setState(() { + _registeredAcademies.clear(); + _registeredAcademies.addAll(cachedData); + _hasLoadedOnce = true; + _isLoading = false; + }); + } else { + // 캐시가 없으면 빈 상태 표시 + setState(() { + _registeredAcademies.clear(); + _isLoading = false; + }); + } + } + } catch (e) { + setState(() { + _isLoading = false; + _errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + } + } + + /// SharedPreferences에서 캐시 로드 + Future> _loadFromCache(String cacheKey) async { + try { + final prefs = await SharedPreferences.getInstance(); + final cachedJson = prefs.getString(cacheKey); + if (cachedJson == null) { + if (prefs.containsKey(_cacheKeyBase)) { + await prefs.remove(_cacheKeyBase); + } + return []; + } + + final List data = json.decode(cachedJson); + return data.map((item) => AcademyItem.fromJson(item)).toList(); + } catch (e) { + return []; + } + } + + /// SharedPreferences에 캐시 저장 + Future _saveToCache( + List academies, + String cacheKey, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonData = json.encode( + academies.map((academy) => academy.toJson()).toList(), + ); + await prefs.setString(cacheKey, jsonData); + if (prefs.containsKey(_cacheKeyBase)) { + await prefs.remove(_cacheKeyBase); + } + } catch (e) { + // 캐시 저장 실패는 무시 + } + } @override Widget build(BuildContext context) { @@ -24,19 +203,35 @@ class _AcademyPageState extends State { // 메인 콘텐츠 Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Column( - children: [ - const SizedBox(height: 18), + child: RefreshIndicator( + onRefresh: () async { + await _loadAcademies(forceRefresh: true); + }, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), // Pull-to-refresh를 위해 항상 스크롤 가능하도록 + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + const SizedBox(height: 18), - // 학원 목록 또는 빈 상태 - _registeredAcademies.isEmpty - ? _buildEmptyState() - : _buildAcademyList(), + // 로딩 상태 + if (_isLoading) + const Padding( + padding: EdgeInsets.all(40.0), + child: CircularProgressIndicator(), + ) + // 에러 상태 + else if (_errorMessage != null) + _buildErrorState() + // 학원 목록 또는 빈 상태 + else if (_registeredAcademies.isEmpty) + _buildEmptyState() + else + _buildAcademyList(), - const SizedBox(height: 20), - ], + const SizedBox(height: 20), + ], + ), ), ), ), @@ -47,44 +242,54 @@ class _AcademyPageState extends State { } Widget _buildHeader() { - return Container( - padding: const EdgeInsets.fromLTRB(30, 17, 30, 17), - decoration: const BoxDecoration( - color: Color(0xFFF8F9FA), - boxShadow: [ - BoxShadow( - color: Color(0x1A000000), - blurRadius: 4, - offset: Offset(0, 4), - ), - ], - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const SizedBox(width: 24), // 시각적 균형을 위한 공간 - const Text( - '학원', - style: TextStyle( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w700, - fontSize: 20, - color: Color(0xFF585B69), - ), + return const AppHeader( + title: AppHeaderTitle('학원', textAlign: TextAlign.center), + trailing: AppHeaderMenuButton(), + ); + } + + Widget _buildErrorState() { + return Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: const Color(0xFFFFF5F5), + border: Border.all(color: const Color(0xFFFFCCCC)), + borderRadius: BorderRadius.circular(10), ), - IconButton( - icon: const Icon(Icons.menu, color: Color(0xFF585B69), size: 24), - onPressed: () { - // TODO: 메뉴 기능 구현 - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('메뉴 기능 구현 예정'))); - }, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), + child: Column( + children: [ + const Icon( + Icons.error_outline, + color: Color(0xFFFF6B6B), + size: 32, + ), + const SizedBox(height: 12), + Text( + _errorMessage ?? '오류가 발생했습니다.', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFFFF6B6B), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + ElevatedButton( + onPressed: _loadAcademies, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFFF6B6B), + foregroundColor: Colors.white, + ), + child: const Text('다시 시도'), + ), + ], ), - ], - ), + ), + ], ); } @@ -117,8 +322,13 @@ class _AcademyPageState extends State { // "+" 버튼 카드 GestureDetector( - onTap: () { - Navigator.pushNamed(context, '/academy/list'); + onTap: () async { + // 학원 목록 페이지로 이동하고 등록 성공 여부를 받음 + final result = await Navigator.pushNamed(context, '/academy/list'); + // 등록 성공 시 캐시 갱신 + if (result == true) { + await _loadAcademies(forceRefresh: true); + } }, child: Container( width: double.infinity, @@ -155,8 +365,13 @@ class _AcademyPageState extends State { // "+" 버튼 카드 GestureDetector( - onTap: () { - Navigator.pushNamed(context, '/academy/list'); + onTap: () async { + // 학원 목록 페이지로 이동하고 등록 성공 여부를 받음 + final result = await Navigator.pushNamed(context, '/academy/list'); + // 등록 성공 시 캐시 갱신 + if (result == true) { + await _loadAcademies(forceRefresh: true); + } }, child: Container( width: double.infinity, @@ -176,76 +391,186 @@ class _AcademyPageState extends State { } Widget _buildAcademyCard(AcademyItem academy) { - return Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: const Color(0xFFF8F9FA), - border: Border.all(color: const Color(0xFFE1E7ED)), - borderRadius: BorderRadius.circular(10), - ), - child: Row( - children: [ - // 학원 썸네일 - Container( - width: 97, - height: 97, - decoration: BoxDecoration( - color: const Color(0xFFE1E7ED), - borderRadius: BorderRadius.circular(5), - ), - child: const Icon(Icons.school, color: Colors.white, size: 40), + // registerStatus에 따라 스타일 결정 + // 'Y' (Yes): 선명하게, 'P' (Pending): 흐리게 + final bool isPending = academy.registerStatus == 'P'; + final double opacity = isPending ? 0.5 : 1.0; + final bool isSelectable = + academy.registerStatus == 'Y' && academy.academyCode != null; + + return Opacity( + opacity: opacity, + child: GestureDetector( + onTap: isSelectable + ? () async { + // 등록완료된 학원만 선택 가능 + if (academy.academyCode != null) { + await _academyService.saveDefaultAcademyCode( + academy.academyCode!, + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${academy.name}이(가) 기본 학원으로 설정되었습니다.'), + backgroundColor: Colors.green, + duration: const Duration(seconds: 2), + ), + ); + } + } + } + : null, + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE1E7ED)), + borderRadius: BorderRadius.circular(10), ), + child: Row( + children: [ + // 학원 썸네일 + _buildAcademyThumbnail(academy), - const SizedBox(width: 15), - - // 학원 정보 - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 학원명 - Text( - academy.name, - style: const TextStyle( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w600, - fontSize: 14, - color: Color(0xFF585B69), - ), - ), + const SizedBox(width: 15), - const SizedBox(height: 28), + // 학원 정보 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 학원명 및 상태 표시 + Row( + children: [ + Expanded( + child: Text( + academy.name, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 14, + color: Color(0xFF585B69), + ), + ), + ), + // 상태 뱃지 + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: isPending + ? const Color(0xFFFFF3CD) + : const Color(0xFFD4EDDA), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + isPending ? '대기중' : '등록완료', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 10, + color: isPending + ? const Color(0xFF856404) + : const Color(0xFF155724), + ), + ), + ), + ], + ), - // 거리 - Text( - academy.distance, - style: const TextStyle( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w400, - fontSize: 12, - color: Color(0xFF585B69), - ), - ), + const SizedBox(height: 28), - const SizedBox(height: 4), + // 거리 (있을 경우만 표시) + if (academy.distance.isNotEmpty) ...[ + Text( + academy.distance, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + fontSize: 12, + color: Color(0xFF585B69), + ), + ), + const SizedBox(height: 4), + ], - // 주소 - Text( - academy.address, - style: const TextStyle( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w400, - fontSize: 12, - color: Color(0xFF585B69), - ), + // 주소 + Text( + academy.address, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + fontSize: 12, + color: Color(0xFF585B69), + ), + ), + ], ), - ], - ), + ), + ], ), - ], + ), ), ); } + + Widget _buildAcademyThumbnail(AcademyItem academy) { + return FutureBuilder( + future: academy.academyCode != null + ? _academyService + .getAcademyImages(academy.academyCode!) + .then((response) => response.mainImageUrl) + .catchError((e) { + return null; + }) + : Future.value(null), + builder: (context, snapshot) { + final imageUrl = snapshot.data; + final isLoading = snapshot.connectionState == ConnectionState.waiting; + + return Container( + width: 97, + height: 97, + decoration: BoxDecoration( + color: const Color(0xFFE1E7ED), + borderRadius: BorderRadius.circular(5), + ), + child: isLoading + ? const Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + ) + : imageUrl != null && imageUrl.isNotEmpty + ? ClipRRect( + borderRadius: BorderRadius.circular(5), + child: Image.network( + imageUrl, + fit: BoxFit.cover, + width: 97, + height: 97, + errorBuilder: (context, error, stackTrace) { + return const Icon( + Icons.school, + color: Colors.white, + size: 40, + ); + }, + ), + ) + : const Icon(Icons.school, color: Colors.white, size: 40), + ); + }, + ); + } } class AcademyItem { @@ -253,11 +578,49 @@ class AcademyItem { final String distance; final String address; final String? thumbnail; + final String registerStatus; // 'Y' 또는 'P' + final String? academyCode; // 학원 코드 AcademyItem({ required this.name, required this.distance, required this.address, this.thumbnail, + required this.registerStatus, + this.academyCode, }); + + /// JSON으로 변환 (SharedPreferences 저장용) + Map toJson() { + return { + 'name': name, + 'distance': distance, + 'address': address, + 'thumbnail': thumbnail, + 'registerStatus': registerStatus, + 'academyCode': academyCode, + }; + } + + /// JSON에서 생성 (SharedPreferences 로드용) + factory AcademyItem.fromJson(Map json) { + return AcademyItem( + name: json['name'] ?? '', + distance: json['distance'] ?? '', + address: json['address'] ?? '', + thumbnail: json['thumbnail'], + registerStatus: json['registerStatus'] ?? 'P', + academyCode: json['academyCode']?.toString(), + ); + } +} + +// 개발 환경에서 SSL 인증서 검증 우회를 위한 클래스 (프로덕션에서는 제거) +class MyHttpOverrides extends HttpOverrides { + @override + HttpClient createHttpClient(SecurityContext? context) { + return super.createHttpClient(context) + ..badCertificateCallback = + (X509Certificate cert, String host, int port) => true; + } } diff --git a/frontend/lib/screens/account/find_id_page.dart b/frontend/lib/screens/account/find_id_page.dart index 27b7259..3c2a527 100644 --- a/frontend/lib/screens/account/find_id_page.dart +++ b/frontend/lib/screens/account/find_id_page.dart @@ -7,6 +7,7 @@ import '../../widgets/back_button.dart' as custom; import '../../widgets/page_title.dart'; import '../../widgets/labeled_input_field.dart'; import '../../widgets/next_button.dart'; +import '../../config/api_config.dart'; class FindIDPage extends StatefulWidget { const FindIDPage({super.key}); @@ -97,8 +98,7 @@ class _FindIDPageState extends State { HttpOverrides.global = MyHttpOverrides(); // 서버 IP 설정 (필요에 따라 변경) - const String serverIp = '3.34.214.133'; // 실제 서버 IP로 변경해주세요 - const String url = 'https://$serverIp/send-code/find_account'; + final url = ApiConfig.getSendCodeFindAccountUri(); // 요청 데이터 준비 final Map requestData = { @@ -114,7 +114,7 @@ class _FindIDPageState extends State { // HTTP POST 요청 final response = await http.post( - Uri.parse(url), + url, headers: {'Content-Type': 'application/json'}, body: json.encode(requestData), ); diff --git a/frontend/lib/screens/account/find_id_verification_page.dart b/frontend/lib/screens/account/find_id_verification_page.dart index 308a571..38fb599 100644 --- a/frontend/lib/screens/account/find_id_verification_page.dart +++ b/frontend/lib/screens/account/find_id_verification_page.dart @@ -7,6 +7,7 @@ import '../../widgets/back_button.dart' as custom; import '../../widgets/page_title.dart'; import '../../widgets/verification_code_input.dart'; import '../../widgets/next_button.dart'; +import '../../config/api_config.dart'; class FindIDVerificationPage extends StatefulWidget { const FindIDVerificationPage({super.key}); @@ -65,8 +66,7 @@ class _FindIDVerificationPageState extends State { HttpOverrides.global = MyHttpOverrides(); // 서버 IP 설정 (필요에 따라 변경) - const String serverIp = '3.34.214.133'; // 실제 서버 IP로 변경해주세요 - const String url = 'https://$serverIp/verify/find_account'; + final url = ApiConfig.getVerifyFindAccountUri(); // 요청 데이터 준비 final Map requestData = { @@ -84,7 +84,7 @@ class _FindIDVerificationPageState extends State { // API 호출 final response = await http.post( - Uri.parse(url), + url, headers: {'Content-Type': 'application/json'}, body: json.encode(requestData), ); diff --git a/frontend/lib/screens/account/find_password_page.dart b/frontend/lib/screens/account/find_password_page.dart index d3eb46b..d916ffa 100644 --- a/frontend/lib/screens/account/find_password_page.dart +++ b/frontend/lib/screens/account/find_password_page.dart @@ -7,6 +7,7 @@ import '../../widgets/back_button.dart' as custom; import '../../widgets/page_title.dart'; import '../../widgets/labeled_input_field.dart'; import '../../widgets/next_button.dart'; +import '../../config/api_config.dart'; class FindPasswordPage extends StatefulWidget { const FindPasswordPage({super.key}); @@ -112,8 +113,7 @@ class _FindPasswordPageState extends State { HttpOverrides.global = MyHttpOverrides(); // 서버 IP 설정 (필요에 따라 변경) - const String serverIp = '3.34.214.133'; // 실제 서버 IP로 변경해주세요 - const String url = 'https://$serverIp/send-code/reset_password'; + final url = ApiConfig.getSendCodeResetPasswordUri(); // 요청 데이터 준비 final Map requestData = { @@ -130,7 +130,7 @@ class _FindPasswordPageState extends State { // HTTP POST 요청 final response = await http.post( - Uri.parse(url), + url, headers: {'Content-Type': 'application/json'}, body: json.encode(requestData), ); diff --git a/frontend/lib/screens/account/find_password_reset_page.dart b/frontend/lib/screens/account/find_password_reset_page.dart index 4989dff..d7eb389 100644 --- a/frontend/lib/screens/account/find_password_reset_page.dart +++ b/frontend/lib/screens/account/find_password_reset_page.dart @@ -7,6 +7,7 @@ import '../../widgets/back_button.dart' as custom; import '../../widgets/page_title.dart'; import '../../widgets/labeled_input_field.dart'; import '../../widgets/next_button.dart'; +import '../../config/api_config.dart'; class FindPasswordResetPage extends StatefulWidget { const FindPasswordResetPage({super.key}); @@ -179,8 +180,7 @@ class _FindPasswordResetPageState extends State { HttpOverrides.global = MyHttpOverrides(); // 서버 IP 설정 (필요에 따라 변경) - const String serverIp = '3.34.214.133'; // 실제 서버 IP로 변경해주세요 - const String url = 'https://$serverIp/change/reset_password'; + final url = ApiConfig.getChangeResetPasswordUri(); // TODO: JSON 형식 미정 - 서버 API 스펙에 맞게 수정 필요 // 요청 데이터 준비 @@ -195,7 +195,7 @@ class _FindPasswordResetPageState extends State { // HTTP POST 요청 final response = await http.post( - Uri.parse(url), + url, headers: {'Content-Type': 'application/json'}, body: json.encode(requestData), ); diff --git a/frontend/lib/screens/account/find_password_verification_page.dart b/frontend/lib/screens/account/find_password_verification_page.dart index c90ad26..3d64414 100644 --- a/frontend/lib/screens/account/find_password_verification_page.dart +++ b/frontend/lib/screens/account/find_password_verification_page.dart @@ -7,6 +7,7 @@ import '../../widgets/back_button.dart' as custom; import '../../widgets/page_title.dart'; import '../../widgets/verification_code_input.dart'; import '../../widgets/next_button.dart'; +import '../../config/api_config.dart'; class FindPasswordVerificationPage extends StatefulWidget { const FindPasswordVerificationPage({super.key}); @@ -61,8 +62,7 @@ class _FindPasswordVerificationPageState HttpOverrides.global = MyHttpOverrides(); // 서버 IP 설정 (필요에 따라 변경) - const String serverIp = '3.34.214.133'; // 실제 서버 IP로 변경해주세요 - const String url = 'https://$serverIp/verify/reset_password'; + final url = ApiConfig.getVerifyResetPasswordUri(); // 요청 데이터 준비 final Map requestData = { @@ -74,7 +74,7 @@ class _FindPasswordVerificationPageState // API 호출 final response = await http.post( - Uri.parse(url), + url, headers: {'Content-Type': 'application/json'}, body: json.encode(requestData), ); diff --git a/frontend/lib/screens/auth/login_page.dart b/frontend/lib/screens/auth/login_page.dart index fa4c433..fb9c70c 100644 --- a/frontend/lib/screens/auth/login_page.dart +++ b/frontend/lib/screens/auth/login_page.dart @@ -8,7 +8,10 @@ import '../../widgets/input_field.dart'; import '../../widgets/login_button.dart'; import '../../widgets/sns_button.dart'; import '../../widgets/links_section.dart'; +import '../../services/auth_service.dart'; +import '../../services/user_service.dart'; import '../../widgets/sns_divider.dart'; +import '../../config/api_config.dart'; class LoginPage extends StatefulWidget { const LoginPage({super.key}); @@ -26,6 +29,16 @@ class _LoginPageState extends State { bool _isPasswordFieldError = false; bool _isLoading = false; + // 자동 로그인 및 아이디 저장 설정 + bool _isAutoLoginEnabled = false; + bool _isSaveAccountIdEnabled = false; + + @override + void initState() { + super.initState(); + _loadSettings(); + } + @override void dispose() { _usernameController.dispose(); @@ -33,6 +46,26 @@ class _LoginPageState extends State { super.dispose(); } + /// 저장된 설정 및 아이디 로드 + Future _loadSettings() async { + final authService = AuthService(); + + // 저장된 설정 로드 + final autoLoginEnabled = await authService.isAutoLoginEnabled(); + final saveAccountIdEnabled = await authService.isSaveAccountIdEnabled(); + + // 저장된 아이디 로드 + final savedAccountId = await authService.getSavedAccountId(); + + setState(() { + _isAutoLoginEnabled = autoLoginEnabled; + _isSaveAccountIdEnabled = saveAccountIdEnabled; + if (savedAccountId != null && savedAccountId.isNotEmpty) { + _usernameController.text = savedAccountId; + } + }); + } + void _clearErrors() { setState(() { _isUsernameFieldError = false; @@ -77,9 +110,8 @@ class _LoginPageState extends State { // 개발 환경에서 SSL 인증서 검증 우회 (프로덕션에서는 제거 필요) HttpOverrides.global = MyHttpOverrides(); - // 서버 IP 설정 (필요에 따라 변경) - const String serverIp = '3.34.214.133'; // 실제 서버 IP로 변경해주세요 - const String url = 'https://$serverIp/sign-in'; + // API URL (ApiConfig에서 중앙 관리) + final url = ApiConfig.getSignInUri(); // 이미지 JSON 형식에 맞춰 요청 데이터 준비 final Map requestData = { @@ -95,7 +127,7 @@ class _LoginPageState extends State { // HTTP POST 요청 final response = await http.post( - Uri.parse(url), + url, headers: {'Content-Type': 'application/json'}, body: json.encode(requestData), ); @@ -116,14 +148,44 @@ class _LoginPageState extends State { if (responseData['accessToken'] != null && responseData['refreshToken'] != null) { - // 토큰 저장 (향후 SecureStorage 사용 권장) + // 토큰 저장 final accessToken = responseData['accessToken']; final refreshToken = responseData['refreshToken']; - // final grantType = responseData['grantType'] ?? 'Bearer'; // 향후 사용 예정 - developer.log('Login successful - tokens received'); + // AuthService를 사용하여 토큰 저장 + final authService = AuthService(); + + // 자동 로그인 설정 저장 + await authService.setAutoLogin(_isAutoLoginEnabled); + + // 아이디 저장 설정에 따라 처리 + if (_isSaveAccountIdEnabled) { + await authService.setSaveAccountId(true); + await authService.saveAccountId(_usernameController.text.trim()); + } else { + await authService.setSaveAccountId(false); + await authService.clearSavedAccountId(); + } + + // 로그인 성공 시 항상 토큰 저장 (현재 세션 유지) + // 자동 로그인 설정은 다음 앱 시작 시에만 영향 + await authService.saveAccessToken(accessToken); + await authService.saveRefreshToken(refreshToken); + + developer.log('Login successful - tokens received and saved'); developer.log('Access Token: ${accessToken.substring(0, 20)}...'); developer.log('Refresh Token: ${refreshToken.substring(0, 20)}...'); + developer.log('Auto login enabled: $_isAutoLoginEnabled'); + developer.log('Save account ID enabled: $_isSaveAccountIdEnabled'); + + // 사용자 정보 가져오기 + try { + await UserService().fetchUserFromServer(); + developer.log('User info fetched successfully after login'); + } catch (e) { + developer.log('Failed to fetch user info after login: $e'); + // 사용자 정보 가져오기 실패해도 로그인은 성공으로 처리 + } // 메인 네비게이션 화면으로 이동 if (mounted) { @@ -145,7 +207,7 @@ class _LoginPageState extends State { ).showSnackBar(const SnackBar(content: Text('서버 응답을 처리할 수 없습니다'))); } } - } else if (response.statusCode == 401) { + } else if (response.statusCode == 401 || response.statusCode == 403) { // 인증 실패 if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -262,8 +324,85 @@ class _LoginPageState extends State { ), const SizedBox( - height: 40, - ), // Space between login button and links + height: 20, + ), // Space between login button and checkboxes + // 자동 로그인 및 아이디 저장 체크박스 + Container( + width: MediaQuery.of(context).size.width * 0.9, + constraints: const BoxConstraints( + maxWidth: 400, + minWidth: 300, + ), + child: Column( + children: [ + // 자동 로그인 체크박스 + Row( + children: [ + Checkbox( + value: _isAutoLoginEnabled, + onChanged: (value) { + setState(() { + _isAutoLoginEnabled = value ?? false; + }); + }, + activeColor: const Color(0xFFAC5BF8), + ), + GestureDetector( + onTap: () { + setState(() { + _isAutoLoginEnabled = !_isAutoLoginEnabled; + }); + }, + child: const Text( + '자동 로그인', + style: TextStyle( + fontFamily: 'Pretendard', + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF333333), + ), + ), + ), + ], + ), + // 아이디 저장 체크박스 + Row( + children: [ + Checkbox( + value: _isSaveAccountIdEnabled, + onChanged: (value) { + setState(() { + _isSaveAccountIdEnabled = value ?? false; + }); + }, + activeColor: const Color(0xFFAC5BF8), + ), + GestureDetector( + onTap: () { + setState(() { + _isSaveAccountIdEnabled = + !_isSaveAccountIdEnabled; + }); + }, + child: const Text( + '아이디 저장', + style: TextStyle( + fontFamily: 'Pretendard', + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF333333), + ), + ), + ), + ], + ), + ], + ), + ), + + const SizedBox( + height: 20, + ), // Space between checkboxes and links // Links Section LinksSection( onSignUp: _handleSignUp, diff --git a/frontend/lib/screens/auth/password_reset_form_page.dart b/frontend/lib/screens/auth/password_reset_form_page.dart index 0316b9a..e7a159a 100644 --- a/frontend/lib/screens/auth/password_reset_form_page.dart +++ b/frontend/lib/screens/auth/password_reset_form_page.dart @@ -7,6 +7,7 @@ import '../../widgets/back_button.dart' as custom; import '../../widgets/page_title.dart'; import '../../widgets/next_button.dart'; import '../../widgets/input_field.dart'; +import '../../config/api_config.dart'; class PasswordResetFormPage extends StatefulWidget { const PasswordResetFormPage({super.key}); @@ -196,8 +197,7 @@ class _PasswordResetFormPageState extends State { // 개발 환경에서 SSL 인증서 검증 우회 HttpOverrides.global = MyHttpOverrides(); - const String serverIp = '3.34.214.133'; - const String url = 'https://$serverIp/users/reset-password'; + final url = ApiConfig.getResetPasswordUri(); // 요청 데이터 준비 final Map requestData = { @@ -209,7 +209,7 @@ class _PasswordResetFormPageState extends State { // API 호출 final response = await http.post( - Uri.parse(url), + url, headers: {'Content-Type': 'application/json'}, body: json.encode(requestData), ); diff --git a/frontend/lib/screens/auth/reset_password_page.dart b/frontend/lib/screens/auth/reset_password_page.dart index 88d7171..56fa8b5 100644 --- a/frontend/lib/screens/auth/reset_password_page.dart +++ b/frontend/lib/screens/auth/reset_password_page.dart @@ -7,6 +7,7 @@ import '../../widgets/back_button.dart' as custom; import '../../widgets/page_title.dart'; import '../../widgets/input_field.dart'; import '../../widgets/next_button.dart'; +import '../../config/api_config.dart'; class ResetPasswordPage extends StatefulWidget { const ResetPasswordPage({super.key}); @@ -199,8 +200,7 @@ class _ResetPasswordPageState extends State { HttpOverrides.global = MyHttpOverrides(); // 서버 IP 설정 (필요에 따라 변경) - const String serverIp = '3.34.214.133'; // 실제 서버 IP로 변경해주세요 - const String url = 'https://$serverIp/users/reset-password'; + final url = ApiConfig.getResetPasswordUri(); // 요청 데이터 준비 final Map requestData = { @@ -212,7 +212,7 @@ class _ResetPasswordPageState extends State { // API 호출 final response = await http.post( - Uri.parse(url), + url, headers: {'Content-Type': 'application/json'}, body: json.encode(requestData), ); diff --git a/frontend/lib/screens/auth/signup_page.dart b/frontend/lib/screens/auth/signup_page.dart index 7e043aa..c6f02b2 100644 --- a/frontend/lib/screens/auth/signup_page.dart +++ b/frontend/lib/screens/auth/signup_page.dart @@ -7,6 +7,7 @@ import '../../widgets/back_button.dart' as custom; import '../../widgets/page_title.dart'; import '../../widgets/labeled_input_field.dart'; import '../../widgets/next_button.dart'; +import '../../config/api_config.dart'; class SignUpPage extends StatefulWidget { const SignUpPage({super.key}); @@ -274,17 +275,15 @@ class _SignUpPageState extends State { try { HttpOverrides.global = _MyHttpOverrides(); - const String serverIp = '3.34.214.133'; final String accountId = _idController.text.trim(); - final String url = - 'https://$serverIp/users/check-accountId?accountId=$accountId'; + final url = ApiConfig.getCheckAccountIdUri(accountId); developer.log('GET $url'); developer.log('Checking account ID: $accountId'); // HTTP GET 요청 final response = await http.get( - Uri.parse(url), + url, headers: {'Content-Type': 'application/json'}, ); @@ -369,8 +368,7 @@ class _SignUpPageState extends State { try { HttpOverrides.global = _MyHttpOverrides(); - const String serverIp = '3.34.214.133'; - final String url = 'https://$serverIp/send-code/sign_up'; + final url = ApiConfig.getSendCodeSignUpUri(); final Map requestData = { 'email': _emailController.text.trim(), }; @@ -379,7 +377,7 @@ class _SignUpPageState extends State { developer.log('Request: $requestData'); final response = await http.post( - Uri.parse(url), + url, headers: {'Content-Type': 'application/json'}, body: json.encode(requestData), ); @@ -452,8 +450,7 @@ class _SignUpPageState extends State { try { HttpOverrides.global = _MyHttpOverrides(); - const String serverIp = '3.34.214.133'; - final String url = 'https://$serverIp/verify/sign_up'; + final url = ApiConfig.getVerifySignUpUri(); setState(() { _isVerifyingEmailCode = true; @@ -472,7 +469,7 @@ class _SignUpPageState extends State { developer.log('Request body: ${json.encode(requestData)}'); final response = await http.post( - Uri.parse(url), + url, headers: {'Content-Type': 'application/json'}, body: json.encode(requestData), ); diff --git a/frontend/lib/screens/auth/signup_terms_page.dart b/frontend/lib/screens/auth/signup_terms_page.dart index 042aacd..8fbaec8 100644 --- a/frontend/lib/screens/auth/signup_terms_page.dart +++ b/frontend/lib/screens/auth/signup_terms_page.dart @@ -6,6 +6,7 @@ import 'dart:io'; import '../../widgets/back_button.dart' as custom; import '../../widgets/page_title.dart'; import '../../widgets/next_button.dart'; +import '../../config/api_config.dart'; class SignUpTermsPage extends StatefulWidget { const SignUpTermsPage({super.key}); @@ -271,8 +272,7 @@ class _SignUpTermsPageState extends State { HttpOverrides.global = _MyHttpOverrides(); // 서버 IP 설정 - const String serverIp = '3.34.214.133'; - const String url = 'https://$serverIp/sign-up'; + final url = ApiConfig.getSignUpUri(); // 이미지의 JSON 형식에 맞춰 요청 데이터 준비 final Map requestData = { @@ -288,7 +288,7 @@ class _SignUpTermsPageState extends State { // HTTP POST 요청 final response = await http.post( - Uri.parse(url), + url, headers: {'Content-Type': 'application/json'}, body: json.encode(requestData), ); @@ -302,58 +302,11 @@ class _SignUpTermsPageState extends State { _isLoading = false; }); - // 응답 처리 if (response.statusCode == 200) { - // 응답이 단순 문자열인지 JSON인지 확인 - final responseBody = response.body.trim(); - - // 단순 문자열 응답 처리 - if (responseBody == 'Sign-up successful') { - // 성공: 회원가입 완료 페이지로 이동 - if (mounted) { - Navigator.pushNamed(context, '/signup-success'); - } - developer.log('Sign-up successful'); - } else if (responseBody == 'User ID already exists!') { - // 중복된 아이디 - if (mounted) { - _showErrorDialog(message: '이미 존재하는 아이디입니다.\n다른 아이디를 사용해주세요.'); - } - developer.log('Sign-up failed: User ID already exists'); - } else { - // JSON 응답 시도 - try { - final responseData = json.decode(responseBody); - if (responseData['success'] == true || - responseData['sign_up'] == 'successful') { - // 성공: 회원가입 완료 페이지로 이동 - if (mounted) { - Navigator.pushNamed(context, '/signup-success'); - } - developer.log('Sign-up successful'); - } else { - // 실패: 에러 팝업 표시 - final errorMessage = - responseData['error'] ?? - responseData['message'] ?? - '회원가입에 실패했습니다.'; - if (mounted) { - _showErrorDialog(message: errorMessage); - } - developer.log('Sign-up failed: $responseData'); - } - } catch (e) { - // JSON 파싱 실패 - 서버 응답 그대로 표시 - developer.log('Non-JSON response: $responseBody'); - if (mounted) { - _showErrorDialog( - message: responseBody.isNotEmpty - ? responseBody - : '회원가입에 실패했습니다.', - ); - } - } + if (mounted) { + Navigator.pushNamed(context, '/signup-success'); } + developer.log('Sign-up successful (status 200)'); } else { // 서버 오류 if (mounted) { diff --git a/frontend/lib/screens/continuous_learning_detail_page.dart b/frontend/lib/screens/continuous_learning_detail_page.dart new file mode 100644 index 0000000..616f642 --- /dev/null +++ b/frontend/lib/screens/continuous_learning_detail_page.dart @@ -0,0 +1,782 @@ +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import '../widgets/app_header.dart'; +import '../widgets/app_header_menu_button.dart'; +import '../widgets/back_button.dart'; +import '../services/continuous_learning_api.dart'; +import '../services/academy_service.dart'; +import '../services/auth_service.dart'; +import '../services/daily_learning_service.dart'; +import '../services/workbook_api.dart'; +import '../services/get_monthly_learning_status_use_case_impl.dart'; +import '../domain/learning/get_monthly_learning_status_use_case.dart'; +import '../domain/learning/daily_learning_status.dart'; +import '../utils/academy_utils.dart'; +import '../utils/app_logger.dart'; +import 'dart:developer' as developer; + +/// 연속학습 상세 페이지 +/// +/// Figma 디자인 기반 캘린더 뷰 +class ContinuousLearningDetailPage extends StatefulWidget { + final GetMonthlyLearningStatusUseCase monthlyStatusUseCase; + + const ContinuousLearningDetailPage({ + super.key, + required this.monthlyStatusUseCase, + }); + + @override + State createState() => + _ContinuousLearningDetailPageState(); +} + +class _ContinuousLearningDetailPageState + extends State { + late int _currentMonth; + late int _currentYear; + + final GetIt _getIt = GetIt.instance; + + // Services (DI에서 주입) + late final AuthService _authService; + late final AcademyService _academyService; + late final ContinuousLearningApi _continuousLearningApi; + + // UseCase 접근 (widget을 통해) + GetMonthlyLearningStatusUseCase get _monthlyStatusUseCase => + widget.monthlyStatusUseCase; + + // 현재 월의 상태 캐시 + Map? _currentMonthStatuses; + + // Statistics data + int _totalDays = 0; + int _totalProblems = 0; + bool _isLoadingStatistics = false; + + // Selected date learning data + DateTime? _selectedDate; + DailyLearningResult? _selectedDateResult; + bool _isLoadingSelectedDate = false; + + @override + void initState() { + super.initState(); + _authService = _getIt(); + _academyService = _getIt(); + _continuousLearningApi = _getIt(); + // 현재 날짜를 기반으로 초기 월/년 설정 + final now = DateTime.now(); + _currentMonth = now.month; + _currentYear = now.year; + + // 통계 데이터 로드 + _loadStatistics(); + // 현재 월의 학습 상태 로드 + _loadMonthlyStatuses(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // 다른 페이지에서 돌아올 때 강제 새로고침 + _loadStatistics(forceRefresh: true); + _loadMonthlyStatuses(); + } + + /// UseCase로 현재 월의 학습 상태 로드 + Future _loadMonthlyStatuses() async { + try { + // 사용자 학원 정보 조회 + final userId = await _authService.getUserId(); + if (userId == null) { + setState(() { + _currentMonthStatuses = {}; + }); + return; + } + + final academies = await _academyService.getUserAcademies(userId); + final registeredAcademies = academies + .where((a) => a.registerStatus == 'Y') + .toList(); + + if (registeredAcademies.isEmpty) { + setState(() { + _currentMonthStatuses = {}; + }); + return; + } + + // academyId와 academyUserIds 조회 + final userAcademyId = await getUserAcademyId( + academyService: _academyService, + registeredAcademies: registeredAcademies, + ); + + if (userAcademyId == null) { + setState(() { + _currentMonthStatuses = {}; + }); + return; + } + + final academyUserIds = registeredAcademies + .map((a) => a.academy_user_id) + .whereType() + .toList(); + + // UseCase 구현체의 callWithContext 호출 + final month = DateTime(_currentYear, _currentMonth, 1); + if (_monthlyStatusUseCase is GetMonthlyLearningStatusUseCaseImpl) { + final statuses = + await (_monthlyStatusUseCase as GetMonthlyLearningStatusUseCaseImpl) + .callWithContext( + month: month, + academyId: userAcademyId, + academyUserIds: academyUserIds, + ); + + setState(() { + _currentMonthStatuses = { + for (var status in statuses) + DailyLearningStatus.normalizeDate(status.date): status, + }; + }); + } else { + // 기본 UseCase 인터페이스 사용 (빈 상태) + final statuses = await _monthlyStatusUseCase.call(month); + setState(() { + _currentMonthStatuses = { + for (var status in statuses) + DailyLearningStatus.normalizeDate(status.date): status, + }; + }); + } + } catch (e) { + // 에러 발생 시 빈 상태 유지 + } + } + + /// 통계 데이터 로드 + Future _loadStatistics({bool forceRefresh = false}) async { + if (_isLoadingStatistics) return; + + setState(() { + _isLoadingStatistics = true; + }); + + try { + // 1. UserId로 academyUserIds 조회 + final userId = await _authService.getUserId(); + if (userId == null) { + throw Exception('사용자 정보를 가져올 수 없습니다.'); + } + + final academies = await _academyService.getUserAcademies(userId); + final academyUserIds = academies + .where((a) => a.registerStatus == 'Y') + .map((a) => a.academy_user_id) + .whereType() + .toList(); + + if (academyUserIds.isEmpty) { + setState(() { + _totalDays = 0; + _totalProblems = 0; + _isLoadingStatistics = false; + }); + return; + } + + // 2. 여러 academyUserId에 대한 통계 조회 (병렬) + appLog( + '[continuous_learning:continuous_learning_detail_page] 통계 조회 시작 - academyUserIds: $academyUserIds', + ); + final summariesMap = await _continuousLearningApi + .fetchGradingHistorySummaries(academyUserIds); + + // 3. 데이터 합산 + int totalDays = 0; + double totalScore = 0.0; + + summariesMap.forEach((academyUserId, summary) { + appLog( + '[continuous_learning:continuous_learning_detail_page] academyUserId: $academyUserId - totalScore: ${summary.totalScore}, daysSinceStartOfYear: ${summary.daysSinceStartOfYear}', + ); + // days_since_start_of_year는 가장 큰 값 사용 (가장 오래된 시작일 기준) + if (summary.daysSinceStartOfYear > totalDays) { + totalDays = summary.daysSinceStartOfYear; + } + // total_score는 합산 + totalScore += summary.totalScore; + }); + + appLog( + '[continuous_learning:continuous_learning_detail_page] 통계 합산 완료 - totalDays: $totalDays, totalScore: $totalScore', + ); + + // 4. UI 업데이트 + setState(() { + _totalDays = totalDays; + _totalProblems = totalScore.round(); // total_score를 문제 수로 사용 + _isLoadingStatistics = false; + }); + } catch (e) { + appLog( + '[continuous_learning:continuous_learning_detail_page] 통계 로드 실패: $e', + ); + developer.log('❌ [ContinuousLearningDetailPage] 통계 로드 실패: $e'); + setState(() { + _isLoadingStatistics = false; + // 에러 발생 시 기본값 유지 + }); + } + } + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final screenHeight = MediaQuery.of(context).size.height; + + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Column( + children: [ + _buildHeader(), + Expanded( + child: SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: screenWidth * 0.05), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: screenHeight * 0.03), + _buildCalendarSection(), + SizedBox(height: screenHeight * 0.033), + _buildStatisticsSection(), + if (_selectedDate != null) ...[ + SizedBox(height: screenHeight * 0.016), + _buildSelectedDateLearningSection(), + ], + SizedBox(height: screenHeight * 0.02), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildHeader() { + return AppHeader( + leading: CustomBackButton(), + title: const Text( + '연속학습', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 20, + color: Color(0xFF585B69), + ), + ), + trailing: const AppHeaderMenuButton(), + titleAlignment: 'left', + ); + } + + Widget _buildCalendarSection() { + final screenHeight = MediaQuery.of(context).size.height; + + return Container( + padding: EdgeInsets.fromLTRB( + 8, + screenHeight * 0.029, + 8, + screenHeight * 0.018, + ), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE1E7ED)), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + children: [ + // 월/년 표시와 이전/다음 달 버튼 + _buildCalendarHeader(), + SizedBox(height: screenHeight * 0.018), + // 요일 표시 + _buildWeekDays(), + SizedBox(height: screenHeight * 0.037), + // 날짜 그리드 (7열 x 5행) + _buildDateGrid(), + ], + ), + ); + } + + Widget _buildCalendarHeader() { + final screenWidth = MediaQuery.of(context).size.width; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 이전 달 버튼 + IconButton( + icon: Icon(Icons.chevron_left, size: screenWidth * 0.037), + onPressed: () { + setState(() { + if (_currentMonth > 1) { + _currentMonth--; + } else { + _currentMonth = 12; + _currentYear--; + } + }); + _loadMonthlyStatuses(); + }, + ), + const SizedBox(width: 32), + // 월/년 표시 + Column( + children: [ + Text( + '$_currentMonth월', + style: const TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.w500, + fontSize: 22, + color: Color(0xFF000000), + height: 1.0, + ), + ), + Text( + '$_currentYear', + style: const TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.w400, + fontSize: 13, + color: Color(0xFF000000), + height: 1.23, + ), + ), + ], + ), + const SizedBox(width: 32), + // 다음 달 버튼 + IconButton( + icon: Icon(Icons.chevron_right, size: screenWidth * 0.037), + onPressed: () { + setState(() { + if (_currentMonth < 12) { + _currentMonth++; + } else { + _currentMonth = 1; + _currentYear++; + } + }); + _loadMonthlyStatuses(); + }, + ), + ], + ); + } + + Widget _buildWeekDays() { + final weekDays = ['월', '화', '수', '목', '금', '토', '일']; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: weekDays.map((day) { + return SizedBox( + width: MediaQuery.of(context).size.width / 15, + child: Text( + day, + style: const TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + fontSize: 15, + color: Color(0xFF707070), + ), + textAlign: TextAlign.center, + ), + ); + }).toList(), + ); + } + + Widget _buildDateGrid() { + final firstDayOfMonth = DateTime(_currentYear, _currentMonth, 1); + final lastDayOfMonth = DateTime(_currentYear, _currentMonth + 1, 0); + + // 첫 번째 날의 요일 (월요일 = 1, 일요일 = 7) + // Flutter의 weekday: 월요일=1, 화요일=2, ..., 일요일=7 + // 우리는 월요일을 0으로 변환 (0~6) + final firstWeekday = firstDayOfMonth.weekday == 7 + ? 0 + : firstDayOfMonth.weekday - 1; + + // 필요한 주 수 계산: 첫 번째 날의 위치 + 마지막 날의 위치를 고려 + // 총 날짜 수 + 첫 번째 날 이전의 빈 칸 수를 7로 나눈 후 올림 + final totalDays = lastDayOfMonth.day; + final totalCells = firstWeekday + totalDays; + final weeksNeeded = (totalCells / 7).ceil(); + + // 이전 달의 마지막 날짜들 계산 + final prevMonth = _currentMonth == 1 ? 12 : _currentMonth - 1; + final prevYear = _currentMonth == 1 ? _currentYear - 1 : _currentYear; + final daysInPrevMonth = DateTime(prevYear, prevMonth + 1, 0).day; + + // Table 위젯을 사용하여 정확한 그리드 정렬 + return Table( + border: TableBorder.all(color: Colors.transparent), + columnWidths: {for (int i = 0; i < 7; i++) i: const FlexColumnWidth(1.0)}, + children: List.generate(weeksNeeded, (weekIndex) { + return TableRow( + children: List.generate(7, (dayIndex) { + final cellIndex = weekIndex * 7 + dayIndex; + final day = cellIndex - firstWeekday + 1; + + // 이전 달의 날짜 + if (day <= 0) { + final dayInPrevMonth = daysInPrevMonth + day; + return _buildDateCell( + day: dayInPrevMonth, + isCurrentMonth: false, + isCompleted: false, + ); + } + // 다음 달의 날짜 + else if (day > totalDays) { + final dayInNextMonth = day - totalDays; + return _buildDateCell( + day: dayInNextMonth, + isCurrentMonth: false, + isCompleted: false, + ); + } + // 현재 달의 날짜 + else { + final date = DateTime(_currentYear, _currentMonth, day); + final isCompleted = _isDateCompleted(day); + return _buildDateCell( + day: day, + isCurrentMonth: true, + isCompleted: isCompleted, + date: date, + ); + } + }), + ); + }), + ); + } + + /// 날짜가 완료되었는지 판단 + bool _isDateCompleted(int day) { + final date = DailyLearningStatus.normalizeDate( + DateTime(_currentYear, _currentMonth, day), + ); + return _currentMonthStatuses?[date]?.isCompleted ?? false; + } + + /// 날짜 셀 탭 핸들러 + void _onDateCellTapped(DateTime date) { + _showDateLearningDialog(date); + } + + /// 날짜 클릭 시 학습 데이터 표시 + /// + /// [date]: 선택된 날짜 (어떤 타임존이든 상관없음, KST로 변환됨) + Future _showDateLearningDialog(DateTime date) async { + // 같은 날짜를 다시 클릭하면 숨기기 + if (_selectedDate != null && + _selectedDate!.year == date.year && + _selectedDate!.month == date.month && + _selectedDate!.day == date.day) { + setState(() { + _selectedDate = null; + _selectedDateResult = null; + }); + return; + } + + setState(() { + _selectedDate = date; + _isLoadingSelectedDate = true; + _selectedDateResult = null; + }); + + // 데이터 로드 + final learningService = _getIt(); + final result = await learningService.getDailyLearningData(date); + + // 데이터 로드 완료 후 상태 업데이트 + if (!mounted) return; + + setState(() { + _selectedDateResult = result; + _isLoadingSelectedDate = false; + }); + } + + Widget _buildDateCell({ + required int day, + required bool isCurrentMonth, + required bool isCompleted, + DateTime? date, + }) { + return GestureDetector( + onTap: date != null && isCurrentMonth + ? () => _onDateCellTapped(date) + : null, + child: Padding( + padding: const EdgeInsets.only(bottom: 32), + child: Stack( + alignment: Alignment.center, + children: [ + // 날짜 박스 + if (isCurrentMonth) + Container( + width: 41, + height: 43, + decoration: isCompleted + ? BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + // 로고 그라데이션: linear-gradient(121.67deg, #AC5BF8 19.64%, #636ACF 77.54%) + colors: [Color(0xFFAC5BF8), Color(0xFF636ACF)], + stops: [0.1964, 0.7754], + ), + borderRadius: BorderRadius.circular(10), + ) + : BoxDecoration( + color: const Color(0xFFE1E7ED), // 회색 + borderRadius: BorderRadius.circular(10), + ), + ), + // 날짜 텍스트 + Text( + '$day', + style: TextStyle( + fontFamily: 'Inter', + fontWeight: FontWeight.w500, + fontSize: 15, + color: isCurrentMonth + ? (isCompleted ? Colors.white : const Color(0xFF000000)) + : const Color(0xFF707070), + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + Widget _buildStatisticsSection() { + final screenWidth = MediaQuery.of(context).size.width; + final screenHeight = MediaQuery.of(context).size.height; + + return Container( + padding: EdgeInsets.fromLTRB( + screenWidth * 0.087, + screenHeight * 0.011, + screenWidth * 0.087, + screenHeight * 0.011, + ), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE1E7ED)), + borderRadius: BorderRadius.circular(10), + ), + child: _isLoadingStatistics + ? const Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Color(0xFFAC5BF8)), + ), + ), + ) + : Text( + '$_totalDays일동안 $_totalProblems문제를 풀었어요.', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 20, + height: 1.2, + foreground: Paint() + ..shader = LinearGradient( + colors: [Color(0xFFAC5BF8), Color(0xFF636ACF)], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ).createShader(const Rect.fromLTWH(0, 0, 1000, 70)), + ), + ), + ); + } + + Widget _buildSelectedDateLearningSection() { + if (_isLoadingSelectedDate) { + return const Center( + child: Padding( + padding: EdgeInsets.all(20), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Color(0xFFAC5BF8)), + ), + ), + ), + ); + } + + if (_selectedDateResult == null) { + return const SizedBox.shrink(); + } + + final result = _selectedDateResult!; + + // 에러 상태 + if (result.hasError) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE1E7ED)), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + children: [ + const Icon(Icons.error_outline, color: Color(0xFFFF6B6B), size: 48), + const SizedBox(height: 12), + Text( + result.errorMessage ?? '데이터를 불러오는데 실패했습니다.', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFFFF6B6B), + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + // 빈 상태 + if (!result.hasData) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE1E7ED)), + borderRadius: BorderRadius.circular(10), + ), + child: const Text( + '해당 날짜에 학습 기록이 없습니다.', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF999999), + ), + ), + ); + } + + // 데이터 있음 + final books = DailyLearningService.extractBooks(result); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: books + .map((book) => _buildSelectedDateLearningItem(book)) + .toList(), + ); + } + + Widget _buildSelectedDateLearningItem(BookData book) { + final progress = book.bookPage > 0 + ? book.totalSolvedPages / book.bookPage + : 0.0; + + final screenHeight = MediaQuery.of(context).size.height; + + return Container( + margin: EdgeInsets.only(bottom: screenHeight * 0.016), + padding: const EdgeInsets.all(15), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE1E7ED)), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 책 이름 + Text( + book.bookName ?? '문제집 ${book.bookId}', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 14, + color: Color(0xFF333333), + ), + ), + const SizedBox(height: 12), + // 프로그레스 바 + Row( + children: [ + Expanded( + child: Container( + height: 12, + decoration: const BoxDecoration( + color: Color(0xFFE9ECEF), + borderRadius: BorderRadius.all(Radius.circular(10)), + ), + child: FractionallySizedBox( + widthFactor: progress.clamp(0.0, 1.0), + alignment: Alignment.centerLeft, + child: Container( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFAC5BF8), Color(0xFF636ACF)], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + borderRadius: const BorderRadius.all( + Radius.circular(10), + ), + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 8), + // 진행 정보 + Text( + '${book.totalSolvedPages} / ${book.bookPage} 페이지', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 12, + color: Color(0xFF666666), + ), + ), + ], + ), + ); + } +} diff --git a/frontend/lib/screens/grading_history/edit_grading_result_page.dart b/frontend/lib/screens/grading_history/edit_grading_result_page.dart new file mode 100644 index 0000000..7bd853b --- /dev/null +++ b/frontend/lib/screens/grading_history/edit_grading_result_page.dart @@ -0,0 +1,789 @@ +import 'package:flutter/material.dart'; +import '../../domain/student_answer/get_student_answers_for_response_use_case.dart'; +import '../../domain/student_answer/update_student_answers_use_case.dart'; +import '../../domain/student_answer/update_single_student_answer_use_case.dart'; +import '../../domain/section_image/get_section_image_use_case.dart'; +import 'models/grading_result.dart'; + +class EditGradingResultPage extends StatefulWidget { + final int studentResponseId; // 필수 파라미터 + final int academyUserId; // 추가 + final GetStudentAnswersForResponseUseCase getStudentAnswersUseCase; + final UpdateStudentAnswersUseCase updateStudentAnswersUseCase; + final GetSectionImageUseCase getSectionImageUseCase; // 추가 + final UpdateSingleStudentAnswerUseCase updateSingleStudentAnswerUseCase; + + const EditGradingResultPage({ + super.key, + required this.studentResponseId, + required this.academyUserId, // 추가 + required this.getStudentAnswersUseCase, + required this.updateStudentAnswersUseCase, + required this.getSectionImageUseCase, // 추가 + required this.updateSingleStudentAnswerUseCase, + }); + + @override + State createState() => _EditGradingResultPageState(); +} + +class _EditGradingResultPageState extends State { + // 상태 변수 + bool _isLoading = false; + bool _isSavingSingle = false; // 단일 수정 저장 중 + String? _errorMessage; + List _results = []; + List _originalResults = []; // 원본 데이터 (수정 여부 판단용) + + bool get _isSaving => _isSavingSingle; + + // UI 상태 + int? _selectedProblemIndex; + final TextEditingController _answerController = TextEditingController(); + bool _isEditingAnswer = false; + + // Section 이미지 캐싱 및 상태 관리 + final Map _sectionImageUrls = {}; // 캐시: cacheKey -> imageUrl + final Map _imageLoadingStates = {}; // 로딩 상태 + final Map _imageErrors = {}; // 에러 메시지 + + @override + void initState() { + super.initState(); + _loadStudentAnswers(); + } + + @override + void dispose() { + _answerController.dispose(); + super.dispose(); + } + + /// API로 학생 답안 데이터 로드 + Future _loadStudentAnswers() async { + if (!mounted) return; + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final entities = await widget.getStudentAnswersUseCase.call( + widget.studentResponseId, + ); + + if (!mounted) return; + + // Entity를 UI 모델로 변환 + final results = entities + .map((entity) => GradingResult.fromEntity(entity)) + .toList(); + + // 정렬: (questionNumber, subQuestionNumber) 튜플 정렬 + results.sort((a, b) { + final questionCompare = a.questionNumber.compareTo(b.questionNumber); + if (questionCompare != 0) return questionCompare; + return a.subQuestionNumber.compareTo(b.subQuestionNumber); + }); + + setState(() { + _results = results; + _originalResults = results + .map( + (r) => GradingResult( + questionNumber: r.questionNumber, + subQuestionNumber: r.subQuestionNumber, + studentAnswerId: r.studentAnswerId, + chapterId: r.chapterId, + recognizedAnswer: r.recognizedAnswer, + correctStatus: r.correctStatus, + ), + ) + .toList(); // 깊은 복사 + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + + setState(() { + _errorMessage = '답안 정보를 불러오지 못했습니다. 잠시 후 다시 시도해주세요.'; + _isLoading = false; + }); + } + } + + + void _updateAnswer() { + if (_selectedProblemIndex == null) return; + + final index = _selectedProblemIndex!; + final newAnswer = _answerController.text.trim(); + final oldAnswer = _results[index].recognizedAnswer.trim(); + + if (newAnswer.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('답안을 입력해주세요.')), + ); + return; + } + + if (newAnswer == oldAnswer) { + // 버튼 비활성 상태가 기본이지만 방어적으로 한 번 더 체크 + return; + } + + _updateAnswerInternal(index: index, newAnswer: newAnswer); + } + + Future _updateAnswerInternal({ + required int index, + required String newAnswer, + }) async { + if (!mounted) return; + + setState(() { + _isSavingSingle = true; + }); + + try { + final current = _results[index]; + + // chapterId가 null이거나 0이면 에러 발생 + if (current.chapterId == null || current.chapterId == 0) { + throw Exception('챕터 정보가 없어 답안을 수정할 수 없습니다.'); + } + + final updated = + await widget.updateSingleStudentAnswerUseCase.call( + studentAnswerId: current.studentAnswerId, + studentResponseId: widget.studentResponseId, + questionNumber: current.questionNumber, + subQuestionNumber: current.subQuestionNumber, + newAnswer: newAnswer, + chapterId: current.chapterId!, + ); + + if (!mounted) return; + + // 새로운 GradingResult 객체 생성하여 교체 (Flutter가 변경 감지하도록) + final currentResult = _results[index]; + + // 서버 응답의 isCorrect 값을 사용하여 correctStatus 계산 + final newCorrectStatus = updated.isCorrect == true ? '정답' : '오답'; + + final updatedResult = GradingResult( + questionNumber: currentResult.questionNumber, + subQuestionNumber: currentResult.subQuestionNumber, + studentAnswerId: currentResult.studentAnswerId, + chapterId: currentResult.chapterId, + recognizedAnswer: updated.recognizedAnswer, + correctStatus: newCorrectStatus, + ); + + final updatedOriginalResult = GradingResult( + questionNumber: currentResult.questionNumber, + subQuestionNumber: currentResult.subQuestionNumber, + studentAnswerId: currentResult.studentAnswerId, + chapterId: currentResult.chapterId, + recognizedAnswer: updated.recognizedAnswer, + correctStatus: newCorrectStatus, + ); + + setState(() { + _results[index] = updatedResult; + _originalResults[index] = updatedOriginalResult; + _isEditingAnswer = false; + _answerController.clear(); + _isSavingSingle = false; + }); + + // 성공 메시지 표시 + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('답안이 수정되었습니다.'), + duration: Duration(seconds: 2), + backgroundColor: Color(0xFF4CAF50), + ), + ); + } + } catch (e) { + if (!mounted) return; + setState(() { + _isSavingSingle = false; + }); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('저장 실패'), + content: const Text( + '답안 수정에 실패했습니다. 잠시 후 다시 시도해주세요.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('확인'), + ), + ], + ), + ); + } + } + + /// Section 이미지 로드 + /// + /// 문제 번호를 클릭했을 때 호출됩니다. + /// 캐시에 있으면 재요청하지 않고, 로딩 중이면 중복 요청을 방지합니다. + Future _loadSectionImage( + int questionNumber, + int subQuestionNumber, + ) async { + // 캐시 키 생성: subQuestionNumber가 0이면 questionNumber만, 아니면 "questionNumber-subQuestionNumber" + final cacheKey = subQuestionNumber == 0 + ? questionNumber.toString() + : '$questionNumber-$subQuestionNumber'; + + // 이미 캐시에 있거나 로딩 중이면 스킵 + if (_sectionImageUrls.containsKey(cacheKey) || + _imageLoadingStates[cacheKey] == true) { + return; + } + + // 로딩 상태 설정 + if (!mounted) return; + setState(() { + _imageLoadingStates[cacheKey] = true; + _imageErrors[cacheKey] = null; + }); + + try { + // UseCase 호출 + final entity = await widget.getSectionImageUseCase.call( + academyUserId: widget.academyUserId, + studentResponseId: widget.studentResponseId, + questionNumber: questionNumber, + subQuestionNumber: subQuestionNumber, + ); + + if (!mounted) return; + + setState(() { + _imageLoadingStates[cacheKey] = false; + if (entity == null) { + // 이미지가 없는 경우 (404) - 정상 케이스 + _imageErrors[cacheKey] = '이미지가 없습니다.'; + } else { + // 이미지 URL 저장 + _sectionImageUrls[cacheKey] = entity.imageUrl; + } + }); + } catch (e) { + if (!mounted) return; + + setState(() { + _imageLoadingStates[cacheKey] = false; + _imageErrors[cacheKey] = '이미지를 불러오지 못했습니다.'; + }); + } + } + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final screenHeight = MediaQuery.of(context).size.height; + + // 로딩 상태 (초기 로딩) + if (_isLoading && _results.isEmpty) { + return Scaffold( + backgroundColor: Colors.white, + body: const Center(child: CircularProgressIndicator()), + ); + } + + // 에러 상태 + if (_errorMessage != null && _results.isEmpty) { + return Scaffold( + backgroundColor: Colors.white, + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(_errorMessage!), + ElevatedButton( + onPressed: _loadStudentAnswers, + child: const Text('다시 시도'), + ), + ], + ), + ), + ); + } + + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Stack( + children: [ + Column( + children: [ + _buildHeader(screenWidth, screenHeight), + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + SizedBox(height: screenHeight * 0.015), + _buildResultsTable(screenWidth, screenHeight), + if (_selectedProblemIndex != null) ...[ + SizedBox(height: screenHeight * 0.02), + _buildProblemDetail(screenWidth, screenHeight), + ], + SizedBox(height: screenHeight * 0.02), + ], + ), + ), + ), + ], + ), + // 저장 중 오버레이 + if (_isSaving) + Container( + color: Colors.black.withOpacity(0.3), + child: const Center(child: CircularProgressIndicator()), + ), + ], + ), + ), + ); + } + + Widget _buildHeader(double screenWidth, double screenHeight) { + return Container( + padding: EdgeInsets.fromLTRB( + screenWidth * 0.075, + screenHeight * 0.021, + screenWidth * 0.075, + screenHeight * 0.012, + ), + decoration: const BoxDecoration( + color: Color(0xFFF8F9FA), + boxShadow: [ + BoxShadow( + color: Color(0x1A000000), + offset: Offset(0, 4), + blurRadius: 4, + ), + ], + ), + child: Stack( + children: [ + Positioned( + left: 0, + child: GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + width: 38, + height: 38, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.arrow_back_ios_new, + size: 18, + color: Color(0xFF5C5C5C), + ), + ), + ), + ), + const Center( + child: Text( + '채점 결과', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 20, + color: Color(0xFF585B69), + ), + ), + ), + ], + ), + ); + } + + Widget _buildResultsTable(double screenWidth, double screenHeight) { + final unrecognizedCount = _results.where((r) => r.isEmptyAnswer).length; + + return Container( + width: screenWidth * 0.9, + margin: EdgeInsets.symmetric(horizontal: screenWidth * 0.05), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '인식하지 못한 답: ${unrecognizedCount.toString().padLeft(2, '0')}개', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 16, + color: Color(0xFF585B69), + ), + ), + SizedBox(height: screenHeight * 0.01), + Container( + padding: EdgeInsets.symmetric( + horizontal: screenWidth * 0.047, + vertical: screenHeight * 0.017, + ), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE1E7ED)), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTableColumn( + '문제 번호', + _results + .map((r) => r.displayNumber) + .toList(), // displayNumber 사용 + const Color(0xFF585B69), + ), + Container( + width: 1, + height: screenHeight * 0.293, + color: const Color(0xFFE1E7ED), + margin: EdgeInsets.symmetric(horizontal: screenWidth * 0.087), + ), + _buildTableColumn( + '인식한 답', + _results.map((r) => r.recognizedAnswer).toList(), + const Color(0xFF7F818E), + ), + Container( + width: 1, + height: screenHeight * 0.293, + color: const Color(0xFFE1E7ED), + margin: EdgeInsets.symmetric(horizontal: screenWidth * 0.087), + ), + _buildTableColumn( + '정답 여부', + _results.map((r) => r.correctStatus).toList(), + const Color(0xFF7F818E), + highlightWrong: true, + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildTableColumn( + String header, + List items, + Color textColor, { + bool highlightWrong = false, + }) { + return Expanded( + child: Column( + children: [ + GestureDetector( + onTap: highlightWrong ? null : () {}, + child: Text( + header, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 12, + color: Color(0xFF585B69), + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 8), + ...List.generate(items.length, (index) { + final isWrong = highlightWrong && items[index] == '오답'; + return GestureDetector( + onTap: () { + if (_isSaving) return; + setState(() { + _selectedProblemIndex = index; + _isEditingAnswer = false; + _answerController.clear(); + }); + // 문제 번호 컬럼에서만 이미지 로드 + if (!highlightWrong) { + final result = _results[index]; + _loadSectionImage( + result.questionNumber, + result.subQuestionNumber, + ); + } + }, + child: Container( + margin: const EdgeInsets.only(bottom: 8), + child: Text( + items[index], + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: highlightWrong + ? FontWeight.w600 + : FontWeight.w500, + fontSize: 12, + color: isWrong ? const Color(0xFFFF4258) : textColor, + ), + textAlign: TextAlign.center, + ), + ), + ); + }), + ], + ), + ); + } + + Widget _buildProblemDetail(double screenWidth, double screenHeight) { + if (_selectedProblemIndex == null) return const SizedBox.shrink(); + + return Container( + width: screenWidth * 0.9, + margin: EdgeInsets.symmetric(horizontal: screenWidth * 0.05), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + '${_results[_selectedProblemIndex!].displayNumber}번 문제', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 16, + color: Color(0xFF585B69), + ), + ), + const SizedBox(width: 10), + GestureDetector( + onTap: () { + setState(() { + _isEditingAnswer = !_isEditingAnswer; + if (_isEditingAnswer) { + _answerController.text = + _results[_selectedProblemIndex!].recognizedAnswer; + } + }); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 3, + ), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE1E7ED)), + borderRadius: BorderRadius.circular(5), + ), + child: const Text( + '답안 재입력', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 11, + color: Color(0xFF585B69), + ), + ), + ), + ), + ], + ), + SizedBox(height: screenHeight * 0.013), + + // 문제 이미지 표시 (네트워크 이미지) + Builder( + builder: (context) { + final result = _results[_selectedProblemIndex!]; + final cacheKey = result.subQuestionNumber == 0 + ? result.questionNumber.toString() + : '${result.questionNumber}-${result.subQuestionNumber}'; + + final imageUrl = _sectionImageUrls[cacheKey]; + final isLoading = _imageLoadingStates[cacheKey] == true; + final errorMessage = _imageErrors[cacheKey]; + + // 로딩 중 + if (isLoading) { + return Container( + width: screenWidth * 0.9, + height: screenHeight * 0.3, + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE1E7ED)), + borderRadius: BorderRadius.circular(10), + ), + child: const Center(child: CircularProgressIndicator()), + ); + } + + // 에러 (이미지 없음 또는 로드 실패) + if (errorMessage != null) { + return Container( + width: screenWidth * 0.9, + height: screenHeight * 0.3, + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE1E7ED)), + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.image_not_supported, + size: 48, + color: Color(0xFF999999), + ), + const SizedBox(height: 8), + Text( + errorMessage, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF999999), + ), + ), + ], + ), + ), + ); + } + + // 이미지 표시 + if (imageUrl != null) { + return Container( + width: screenWidth * 0.9, + constraints: BoxConstraints(maxHeight: screenHeight * 0.3), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE1E7ED)), + borderRadius: BorderRadius.circular(10), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Image.network( + imageUrl, + fit: BoxFit.contain, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return const Center(child: CircularProgressIndicator()); + }, + errorBuilder: (context, error, stackTrace) { + return Container( + color: const Color(0xFFF8F9FA), + child: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.image_not_supported, + size: 48, + color: Color(0xFF999999), + ), + SizedBox(height: 8), + Text( + '이미지를 불러오지 못했습니다.', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF999999), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ); + } + + // 이미지가 아직 로드되지 않음 (초기 상태) + return const SizedBox.shrink(); + }, + ), + + // 답안 입력 폼 + if (_isEditingAnswer) ...[ + SizedBox(height: screenHeight * 0.01), + Container( + width: screenWidth * 0.9, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFAC5BF8), width: 2), + borderRadius: BorderRadius.circular(5), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _answerController, + decoration: const InputDecoration( + hintText: '답안을 입력하세요', + border: InputBorder.none, + hintStyle: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + fontSize: 14, + color: Color(0xFF999999), + ), + ), + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF585B69), + ), + ), + ), + GestureDetector( + onTap: _updateAnswer, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFAC5BF8), Color(0xFF636ACF)], + ), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + '수정', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 12, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ], + ], + ), + ); + } + +} diff --git a/frontend/lib/screens/grading_history/grading_history_page.dart b/frontend/lib/screens/grading_history/grading_history_page.dart new file mode 100644 index 0000000..734e2aa --- /dev/null +++ b/frontend/lib/screens/grading_history/grading_history_page.dart @@ -0,0 +1,443 @@ +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import '../../widgets/app_header.dart'; +import '../../widgets/app_header_title.dart'; +import '../../widgets/back_button.dart'; +import '../../theme/app_theme.dart'; +import '../../services/auth_service.dart'; +import '../../services/academy_service.dart'; +import '../../services/grading_history_repository_impl.dart'; +import '../../domain/grading_history/grading_history_entity.dart'; +import '../../config/app_dependencies.dart'; +import 'edit_grading_result_page.dart'; +import 'models/grading_history_item.dart'; +import '../../utils/app_logger.dart'; +import 'dart:developer' as developer; + +/// 채점 히스토리 페이지 +/// 사용자가 지금까지 채점한 모든 기록을 시간순으로 표시하는 페이지 +/// +/// 구성: +/// 1. 헤더: "채점 히스토리" 타이틀 +/// 2. 채점 기록 목록: 날짜별 그룹핑, 문제집명, 클래스명, 페이지 범위, 채점 시간 등 +class GradingHistoryPage extends StatefulWidget { + const GradingHistoryPage({super.key}); + + @override + State createState() => _GradingHistoryPageState(); +} + +class _GradingHistoryPageState extends State { + bool _isLoading = false; + List _historyItems = []; + String? _errorMessage; + + // DI + final GetIt _getIt = GetIt.instance; + + // Services (DI에서 주입) + late final AuthService _authService; + late final AcademyService _academyService; + late final GradingHistoryRepositoryImpl _repository; + + @override + void initState() { + super.initState(); + _authService = _getIt(); + _academyService = _getIt(); + _repository = _getIt(); + _loadGradingHistory(); + } + + /// 채점 히스토리 데이터 로드 + Future _loadGradingHistory() async { + if (!mounted) return; + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + // 1. userId로 academyUserIds 조회 + final userId = await _authService.getUserId(); + if (userId == null) { + throw Exception('사용자 정보를 가져올 수 없습니다.'); + } + + if (!mounted) return; + + final academies = await _academyService.getUserAcademies(userId); + final academyUserIds = academies + .where((a) => a.registerStatus == 'Y') + .map((a) => a.academy_user_id) + .whereType() + .toList(); + + if (academyUserIds.isEmpty) { + if (!mounted) return; + setState(() { + _historyItems = []; + _isLoading = false; + }); + return; + } + + // 2. Repository 호출 (Map> 반환) + final historiesMap = await _repository + .getGradingHistoriesByAcademyUserIds(academyUserIds); + + if (!mounted) return; + + // 3. Map을 flat 리스트로 변환 + final allEntities = []; + historiesMap.values.forEach((entities) { + allEntities.addAll(entities); + }); + + // 4. 날짜순 정렬 (최신순) - UI 레이어에서 처리 + allEntities.sort((a, b) => b.gradingDate.compareTo(a.gradingDate)); + + // 5. UI 모델로 변환 + final items = allEntities + .map((entity) => GradingHistoryItem.fromEntity(entity)) + .toList(); + + if (!mounted) return; + + setState(() { + _historyItems = items; + _isLoading = false; + }); + } catch (e, stack) { + // 상세 로그는 appLog/developer.log로만 남김 + appLog('[grading_history:grading_history_page] load error: $e\n$stack'); + developer.log('❌ [GradingHistoryPage] load error: $e\n$stack'); + + if (!mounted) return; + + setState(() { + // 사용자용 간단한 메시지만 표시 + _errorMessage = '채점 히스토리를 불러오지 못했습니다. 잠시 후 다시 시도해주세요.'; + _isLoading = false; + }); + } + } + + /// 날짜별로 그룹핑 + Map> _groupByDate( + List items, + ) { + final Map> grouped = {}; + + for (final item in items) { + final dateKey = _formatDateKey(item.gradingDate); + if (!grouped.containsKey(dateKey)) { + grouped[dateKey] = []; + } + grouped[dateKey]!.add(item); + } + + // 각 날짜 그룹 내에서 시간순 정렬 (최신순) + for (final key in grouped.keys) { + grouped[key]!.sort((a, b) => b.gradingDate.compareTo(a.gradingDate)); + } + + return grouped; + } + + /// 날짜 키 포맷팅 (예: "2025년 11월 29일") + String _formatDateKey(DateTime date) { + return '${date.year}년 ${date.month}월 ${date.day}일'; + } + + /// 날짜 헤더 포맷팅 + String _formatDateHeader(DateTime date) { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final targetDate = DateTime(date.year, date.month, date.day); + + if (targetDate == today) { + return '오늘'; + } else if (targetDate == today.subtract(const Duration(days: 1))) { + return '어제'; + } else { + return _formatDateKey(date); + } + } + + /// 시간을 한국어 오전/오후 형식으로 포맷팅 + /// 예: "오전 9:32", "오후 2:15" + String _formatTimeToKoreanAmPm(DateTime date) { + final isAm = date.hour < 12; + final hour12 = date.hour % 12 == 0 ? 12 : date.hour % 12; + final minute = date.minute.toString().padLeft(2, '0'); + final prefix = isAm ? '오전' : '오후'; + return '$prefix $hour12:$minute'; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppTheme.whiteColor, + body: SafeArea( + child: Column( + children: [ + // 헤더 + AppHeader( + leading: const CustomBackButton(), + title: const AppHeaderTitle('채점 히스토리'), + ), + + // 채점 히스토리 목록 + Expanded( + child: _isLoading + ? const Center( + child: CircularProgressIndicator( + color: AppTheme.primaryColor, + ), + ) + : _errorMessage != null + ? _buildErrorState() + : _historyItems.isEmpty + ? _buildEmptyState() + : _buildHistoryList(), + ), + ], + ), + ), + ); + } + + /// 에러 상태 위젯 + Widget _buildErrorState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 64, color: AppTheme.errorColor), + const SizedBox(height: 16), + Text( + '채점 히스토리를 불러오는데 실패했습니다', + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: AppTheme.textPrimary), + ), + const SizedBox(height: 8), + Text( + _errorMessage ?? '알 수 없는 오류', + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: () { + _loadGradingHistory(); + }, + child: const Text('다시 시도'), + ), + ], + ), + ); + } + + /// 빈 상태 위젯 + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.history, size: 64, color: AppTheme.textSecondary), + const SizedBox(height: 16), + Text( + '채점 기록이 없습니다', + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), + ), + ], + ), + ); + } + + /// 채점 히스토리 리스트 + Widget _buildHistoryList() { + final grouped = _groupByDate(_historyItems); + final sortedDates = grouped.keys.toList() + ..sort((a, b) { + // 날짜 문자열을 파싱해서 비교 (최신순) + final dateA = _parseDateKey(a); + final dateB = _parseDateKey(b); + return dateB.compareTo(dateA); + }); + + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + itemCount: sortedDates.length, + itemBuilder: (context, index) { + final dateKey = sortedDates[index]; + final items = grouped[dateKey]!; + final firstItem = items.first; + final dateHeader = _formatDateHeader(firstItem.gradingDate); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 날짜 헤더 + Padding( + padding: EdgeInsets.only(bottom: 12, top: index > 0 ? 24 : 0), + child: Text( + dateHeader, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: AppTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + ), + + // 해당 날짜의 채점 기록들 + ...items.map((item) => _buildHistoryItem(item)), + ], + ); + }, + ); + } + + /// 날짜 키 파싱 + DateTime _parseDateKey(String dateKey) { + // "2025년 11월 29일" 형식을 파싱 + try { + final parts = dateKey + .replaceAll('년', '') + .replaceAll('월', '') + .replaceAll('일', '') + .trim() + .split(' '); + if (parts.length >= 3) { + final year = int.parse(parts[0]); + final month = int.parse(parts[1]); + final day = int.parse(parts[2]); + return DateTime(year, month, day); + } + } catch (e) { + // 파싱 실패 시 현재 날짜 반환 + } + return DateTime.now(); + } + + /// 채점 히스토리 아이템 위젯 + Widget _buildHistoryItem(GradingHistoryItem item) { + final screenWidth = MediaQuery.of(context).size.width; + + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => EditGradingResultPage( + studentResponseId: item.studentResponseId, + academyUserId: item.academyUserId, + getStudentAnswersUseCase: + AppDependencies.getStudentAnswersForResponseUseCase, + updateStudentAnswersUseCase: + AppDependencies.updateStudentAnswersUseCase, + getSectionImageUseCase: + AppDependencies.getSectionImageUseCase, + updateSingleStudentAnswerUseCase: + AppDependencies.updateSingleStudentAnswerUseCase, + ), + ), + ); + }, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.backgroundColor, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + // 왼쪽: 책 표지 이미지 + Container( + width: screenWidth * 0.15, // Figma 기준 상대 크기 + height: screenWidth * 0.15, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: AppTheme.textSecondary.withOpacity(0.1), + ), + child: + item.bookCoverImageUrl != null && + item.bookCoverImageUrl!.startsWith('http') + ? ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.network( + item.bookCoverImageUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Icon( + Icons.book, + color: AppTheme.textSecondary, + ); + }, + ), + ) + : Icon(Icons.book, color: AppTheme.textSecondary), + ), + const SizedBox(width: 12), + + // 가운데: 4줄 정보 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. workbookName + Text( + item.workbookName, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.textPrimary, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + // 2. className + Text( + item.className ?? '클래스 정보 없음', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + // 3. pageRange + Text( + '${item.pageRange}페이지', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + const SizedBox(height: 4), + // 4. gradingDate (오전/오후 형식) + Text( + _formatTimeToKoreanAmPm(item.gradingDate), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + + // 오른쪽: chevron_right 아이콘 + Icon(Icons.chevron_right, color: AppTheme.textSecondary, size: 24), + ], + ), + ), + ); + } +} diff --git a/frontend/lib/screens/grading_history/models/grading_history_item.dart b/frontend/lib/screens/grading_history/models/grading_history_item.dart new file mode 100644 index 0000000..26e8d2c --- /dev/null +++ b/frontend/lib/screens/grading_history/models/grading_history_item.dart @@ -0,0 +1,44 @@ +import '../../../../domain/grading_history/grading_history_entity.dart'; + +/// 채점 히스토리 UI 모델 +/// +/// 기존 grading_history_page.dart의 GradingHistoryItem을 완전히 대체합니다. +class GradingHistoryItem { + final int studentResponseId; + final int academyUserId; // 추가 + final String workbookName; // bookName + final String? className; + final int startPage; + final int endPage; + final String? bookCoverImageUrl; + final DateTime gradingDate; + + /// 페이지 범위 문자열 (예: "14-20", start == end이면 "14"만 표시) + String get pageRange => + startPage == endPage ? '$endPage' : '$startPage-$endPage'; + + const GradingHistoryItem({ + required this.studentResponseId, + required this.academyUserId, // 추가 + required this.workbookName, + this.className, + required this.startPage, + required this.endPage, + this.bookCoverImageUrl, + required this.gradingDate, + }); + + /// 도메인 엔티티에서 UI 모델로 변환 + factory GradingHistoryItem.fromEntity(GradingHistoryEntity entity) { + return GradingHistoryItem( + studentResponseId: entity.studentResponseId, + academyUserId: entity.academyUserId, // 추가 + workbookName: entity.bookName ?? '책 이름 없음', + className: entity.className, + startPage: entity.startPage, + endPage: entity.endPage, + bookCoverImageUrl: entity.bookCoverImageUrl, + gradingDate: entity.gradingDate, + ); + } +} diff --git a/frontend/lib/screens/grading_history/models/grading_result.dart b/frontend/lib/screens/grading_history/models/grading_result.dart new file mode 100644 index 0000000..473ab1d --- /dev/null +++ b/frontend/lib/screens/grading_history/models/grading_result.dart @@ -0,0 +1,53 @@ +import '../../../domain/student_answer/student_answer_entity.dart'; + +/// UI 전용 모델 (EditGradingResultPage에서 사용) +class GradingResult { + final int questionNumber; + final int subQuestionNumber; + final int studentAnswerId; // 수정 시 필요 + final int? chapterId; // 단일 수정 API 호출 시 사용 + String recognizedAnswer; // 수정 가능 + final String correctStatus; // '정답' 또는 '오답' + // 참고: 답안 수정 시 서버에서 정답 여부를 재계산하여 반환합니다. + + GradingResult({ + required this.questionNumber, + required this.subQuestionNumber, + required this.studentAnswerId, + required this.chapterId, + required this.recognizedAnswer, + required this.correctStatus, + }); + + /// 문제 번호 표시 문자열 + /// + /// 예: questionNumber=1, subQuestionNumber=0 → "1" + /// questionNumber=1, subQuestionNumber=2 → "1-2" + String get displayNumber { + if (subQuestionNumber == 0) { + return questionNumber.toString(); + } + return '$questionNumber-$subQuestionNumber'; + } + + /// answer가 비어있는지 확인 (인식 실패 여부 판단용) + /// + /// getter로 구현하여 recognizedAnswer 변경 시 자동으로 반영됨 + /// UI 레이어에서는 공백만 있는 경우도 빈 값으로 처리합니다. + bool get isEmptyAnswer => recognizedAnswer.trim().isEmpty; + + /// StudentAnswerEntity에서 UI 모델로 변환 + factory GradingResult.fromEntity(StudentAnswerEntity entity) { + // 정답 여부: is_correct에 따라 + final correctStatus = entity.isCorrect == true ? '정답' : '오답'; + + return GradingResult( + questionNumber: entity.questionNumber, + subQuestionNumber: entity.subQuestionNumber, + studentAnswerId: entity.studentAnswerId, + chapterId: entity.chapterId, + recognizedAnswer: entity.answer, + correctStatus: correctStatus, + ); + } +} diff --git a/frontend/lib/screens/home_page.dart b/frontend/lib/screens/home_page.dart index 97d10cf..da74d06 100644 --- a/frontend/lib/screens/home_page.dart +++ b/frontend/lib/screens/home_page.dart @@ -1,5 +1,33 @@ import 'package:flutter/material.dart'; -import '../widgets/continuous_learning_widget.dart'; +import 'package:get_it/get_it.dart'; +import '../routes/app_routes.dart'; +import '../widgets/app_header.dart'; +import '../widgets/app_header_menu_button.dart'; +import '../widgets/continuous_learning_widget_v2.dart'; +import '../services/assessment_repository.dart'; +import '../services/academy_service.dart'; +import '../services/auth_service.dart'; +import '../services/daily_learning_service.dart'; +import '../domain/learning/get_monthly_learning_status_use_case.dart'; +import '../models/assessment.dart'; +import '../utils/academy_utils.dart'; +import '../utils/app_logger.dart'; +import 'dart:developer' as developer; + +/// 홈 화면 - 개선된 UI/UX +/// +/// 주요 기능: +/// 1. 연속학습 위젯 - 날짜 기반 스와이프 캘린더 +/// 2. 오늘의 숙제 - 문제집 표지 + 상세 정보 카드 +/// 3. 누적 학습량 - 2단계 프로그레스 바 + +/// 학원 상태 enum +enum AcademyState { + loading, // 초기화 중 + none, // 학원 없음 + ready, // 학원 있음, 데이터 로드 완료 + error, // 에러 발생 +} class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -9,91 +37,585 @@ class HomePage extends StatefulWidget { } class _HomePageState extends State { - // 상수 정의 - static const double _sectionSpacing = 0.04; // 섹션 간 간격 (화면 높이의 4%) - static const double _smallSpacing = 0.01; // 작은 간격 (화면 높이의 1%) - static const double _horizontalPadding = 0.05; // 수평 패딩 (화면 너비의 5%) - static const double _containerPadding = 0.048; // 컨테이너 패딩 (화면 너비의 4.8%) - static const double _progressFactor = 0.7; // 진행률 기본값 (70%) - static const int _consecutiveDays = 2; // 연속 학습 일수 - - // TODO: DB 연동 구현 필요 - // 1. 연속 학습 데이터 (consecutiveDays, weeklyProgress) - DB에서 사용자의 학습 기록 조회 - // 2. 오늘의 숙제 목록 - DB에서 해당 사용자의 오늘 할당된 숙제 목록 조회 - // 3. 오늘의 학습 현황 - DB에서 오늘 완료한 학습 과목별 진행률 조회 - // 4. 주간 학습 현황 - DB에서 지난 7일간의 학습 통계 데이터 조회 - // 5. 학원 정보 (헤더의 '정다훈 학원') - DB에서 사용자가 등록한 학원 정보 조회 + final GetIt _getIt = GetIt.instance; + + late final AssessmentRepository _assessmentRepository; + late final AcademyService _academyService; + late final AuthService _authService; + final Map> _dateAssessments = {}; + DateTime _selectedDate = DateTime.now(); + bool _isLoadingAssessments = false; + + // UseCase 인스턴스 (DI에서 주입) + late final GetMonthlyLearningStatusUseCase _monthlyStatusUseCase; + + // 학원 상태 관리 (enum 사용) + AcademyState _academyState = AcademyState.loading; + String _academyName = '학원'; + List _registeredAcademies = []; // 등록완료된 학원 목록 + + // 선택된 날짜의 학습 데이터 + DailyLearningResult? _selectedDateLearningResult; + bool _isLoadingSelectedDateLearning = false; + + @override + void initState() { + super.initState(); + _assessmentRepository = _getIt(); + _academyService = _getIt(); + _authService = _getIt(); + _monthlyStatusUseCase = _getIt(); + _academyService.defaultAcademyVersion.addListener(_onDefaultAcademyChanged); + // 단일 진입점만 호출 + _initializeAcademyData(forceRefresh: false); + } + + @override + void dispose() { + _academyService.defaultAcademyVersion.removeListener( + _onDefaultAcademyChanged, + ); + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // 다른 페이지에서 돌아올 때 강제 새로고침 + _initializeAcademyData(forceRefresh: true); + } + + /// 오늘의 숙제 정보 동기화 + /// 다른 페이지에서 돌아올 때 선택된 날짜의 Assessment 데이터를 최신 정보로 갱신 + Future _synchronizeTodayHomework() async { + if (_academyState != AcademyState.ready) return; + + // _loadDateData를 forceRefresh로 호출하여 최신 데이터 가져오기 + await _loadDateData(_selectedDate, forceRefresh: true); + } + + /// 외부에서 호출 가능한 새로고침 메서드 + /// 탭 전환 시 MainNavigationPage에서 호출 + void refresh() { + developer.log('🔄 [HomePage] refresh() called from external'); + _initializeAcademyData(forceRefresh: true); + } + + /// 외부에서 특정 날짜를 열도록 요청할 때 사용 + void focusOnDate(DateTime date) { + developer.log('🎯 [HomePage] focusOnDate 요청: $date'); + final normalized = DateTime(date.year, date.month, date.day); + setState(() { + _selectedDate = normalized; + }); + _loadDateData(normalized); + } + + void _onDefaultAcademyChanged() { + if (!mounted) return; + developer.log('🔁 [HomePage] 디폴트 학원 변경 감지, 데이터 재초기화'); + _dateAssessments.clear(); + _initializeAcademyData(forceRefresh: true); + } + + /// 학원 관련 전체 초기화 (단일 진입점) + /// + /// 모든 학원 관련 로직의 진입점: + /// - initState에서 호출 + /// - didChangeDependencies에서 호출 + /// - 수동 새로고침 시 호출 + Future _initializeAcademyData({bool forceRefresh = false}) async { + // 1. 상태 초기화 (항상 명시적으로) + if (mounted) { + setState(() { + _academyState = AcademyState.loading; + }); + } + + try { + // 2. 학원 목록 로드 (API or 캐시) + List? academies; + + if (forceRefresh) { + // API 호출 + final userId = await _authService.getUserId(); + if (userId == null) { + developer.log('⚠️ 사용자 ID를 가져올 수 없습니다.'); + // 사용자 ID 없음 → 캐시에서 로드 + academies = await _academyService.loadAcademiesFromCache(); + } else { + try { + academies = await _academyService.getUserAcademies(userId); + } catch (e) { + developer.log('⚠️ API 호출 실패, 캐시에서 로드 시도: $e'); + academies = await _academyService.loadAcademiesFromCache(); + } + } + } else { + // 캐시에서만 로드 + academies = await _academyService.loadAcademiesFromCache(); + } + + // 3. early return 시 상태 정리 (명시적으로) + if (academies == null) { + if (mounted) { + setState(() { + _academyState = AcademyState.none; + _registeredAcademies = []; + }); + } + return; + } + + // 4. 학원 목록 처리 + await _processAcademyList(academies, shouldSaveCache: forceRefresh); + } catch (e) { + developer.log('❌ 학원 정보 초기화 실패: $e'); + if (mounted) { + setState(() { + _academyState = AcademyState.error; + _registeredAcademies = []; + }); + + // 사용자 피드백 + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('학원 정보를 불러오는데 실패했습니다: ${e.toString()}'), + backgroundColor: Colors.red, + action: SnackBarAction( + label: '다시 시도', + onPressed: () => _initializeAcademyData(forceRefresh: true), + ), + ), + ); + } + } + } + + /// 학원 목록 처리 (캐시 저장 + 필터링 + 디폴트 학원 설정) + /// + /// [academies]: 전체 학원 목록 (registerStatus 'Y'/'P' 모두 포함) + /// [shouldSaveCache]: 캐시에 저장할지 여부 (API에서 가져온 경우만 true) + Future _processAcademyList( + List academies, { + required bool shouldSaveCache, + }) async { + // 1. 캐시 저장 (mounted와 무관하게 실행) + if (shouldSaveCache) { + await _academyService.saveAcademiesToCache(academies); + } + + // 2. mounted 체크 (UI 업데이트 전에만) + if (!mounted) return; + + // 3. 등록완료된 학원만 필터링 + final registeredAcademies = academies + .where((academy) => academy.registerStatus == 'Y') + .toList(); + + setState(() { + _registeredAcademies = registeredAcademies; + }); + + // 4. 디폴트 학원 설정 + if (registeredAcademies.isNotEmpty) { + await _setupDefaultAcademy(registeredAcademies); + } else { + // 등록완료된 학원이 없음 + setState(() { + _academyState = AcademyState.none; + }); + } + } + + /// 디폴트 학원 설정 및 관련 데이터 로드 + /// + /// [academies]: 등록완료된 학원 목록 (registerStatus == 'Y') + Future _setupDefaultAcademy(List academies) async { + if (!mounted) return; + + // 1. 디폴트 학원 선택 + final defaultAcademyCode = await _academyService.selectDefaultAcademyAsync( + academies, + ); + + if (defaultAcademyCode == null) { + // 디폴트 학원을 선택할 수 없음 (로직 오류 가능성) + developer.log('⚠️ 디폴트 학원 선택 실패: 학원 목록은 있지만 선택할 수 없음'); + if (mounted) { + setState(() { + _academyState = AcademyState.error; + // _registeredAcademies는 유지 (디버깅용) + }); + } + return; + } + + // 2. 디폴트 학원 저장 + await _academyService.saveDefaultAcademyCode(defaultAcademyCode); + + // 3. 학원명 설정 (메모리에서 찾기) + final academy = academies.firstWhere( + (a) => a.academyCode == defaultAcademyCode, + orElse: () => academies.first, // fallback + ); + + if (mounted) { + setState(() { + _academyName = academy.academyName; + _academyState = AcademyState.ready; + }); + + // 4. Assessment 데이터 로드 + await _loadRemainingMonthData(); + + // 5. 선택된 날짜의 학습 데이터 로드 + await _loadSelectedDateLearningData(_selectedDate); + + // 6. 오늘의 숙제 정보 동기화 (최신 정보로 갱신) + await _synchronizeTodayHomework(); + } + } + + // _loadAcademyName, _hasAcademy, _isCheckingAcademy는 + // 이전 구조에서 사용되었으나 현재 로직에서는 사용되지 않아 제거했습니다. + + /// 이번 달과 다음 달 데이터 로드 + /// + /// 변경: 월 단위로 데이터를 가져오도록 수정 + Future _loadRemainingMonthData() async { + if (_isLoadingAssessments) return; + + // 학원이 없으면 Assessment 호출 건너뛰기 + if (_academyState != AcademyState.ready) { + developer.log('⚠️ 학원이 없어 Assessment 데이터 로드를 건너뜀'); + return; + } + + setState(() { + _isLoadingAssessments = true; + }); + + try { + final userAcademyId = await getUserAcademyId( + academyService: _academyService, + registeredAcademies: _registeredAcademies, + ); + + // 학원 ID가 없으면 건너뛰기 + if (userAcademyId == null) { + developer.log('⚠️ 학원 ID가 없어 Assessment 데이터 로드를 건너뜀'); + return; + } + + final now = DateTime.now(); + + // 현재 달의 첫 번째 날 (UTC) + final currentMonthStart = DateTime.utc(now.year, now.month, 1); + + // 이전/다음 달 계산 (set 함수 사용) + final previousMonthStart = _getPreviousMonth(currentMonthStart); + final nextMonthStart = _getNextMonth(currentMonthStart); + + // 캐시 초기화 (메모리 캐시와 SharedPreferences) + await _assessmentRepository.clearAll(); + developer.log('🔄 [HomePage] Assessment 캐시 초기화 완료'); + + // 이전 달, 현재 달, 다음 달 데이터를 병렬로 가져오기 + final results = await Future.wait([ + _assessmentRepository.getForMonth( + academyId: userAcademyId, + dateTime: previousMonthStart, + ), + _assessmentRepository.getForMonth( + academyId: userAcademyId, + dateTime: currentMonthStart, + ), + _assessmentRepository.getForMonth( + academyId: userAcademyId, + dateTime: nextMonthStart, + ), + ]); + + // 세 달의 데이터를 합치기 + setState(() { + _dateAssessments.addAll(results[0]); // 이전 달 + _dateAssessments.addAll(results[1]); // 현재 달 + _dateAssessments.addAll(results[2]); // 다음 달 + }); + + developer.log('✅ [HomePage] 이전/이번/다음 달 Assessment 데이터 로드 완료'); + developer.log('📊 [HomePage] 현재 _dateAssessments 상태:'); + + _dateAssessments.forEach((date, assessments) { + developer.log('[HomePage] - $date: ${assessments.length}개 과제'); + for (var assessment in assessments) { + developer.log( + '[HomePage] • ${assessment.assessName} (${assessment.assessClass}) - 상태: ${assessment.assessStatus}', + ); + } + }); + } catch (e) { + developer.log('⚠️ 이번 달 남은 날짜 데이터 로드 실패: $e'); + } finally { + setState(() { + _isLoadingAssessments = false; + }); + } + } + + /// 다음 달 계산 헬퍼 함수 + DateTime _getNextMonth(DateTime dateTime) { + if (dateTime.month == 12) { + return DateTime.utc(dateTime.year + 1, 1, 1); + } else { + return DateTime.utc(dateTime.year, dateTime.month + 1, 1); + } + } + + /// 이전 달 계산 헬퍼 함수 + DateTime _getPreviousMonth(DateTime dateTime) { + if (dateTime.month == 1) { + return DateTime.utc(dateTime.year - 1, 12, 1); + } else { + return DateTime.utc(dateTime.year, dateTime.month - 1, 1); + } + } + + /// 날짜 선택 시 호출 + /// 연속학습 위젯에서 날짜를 클릭하면 이 메서드가 호출됩니다 + void _onDateSelected(DateTime date) { + final dateStr = _formatDate(date); + appLog('[continuous_learning:home_page] 날짜 선택됨 - $dateStr (연속학습 위젯에서 클릭)'); + + setState(() { + _selectedDate = date; + }); + + // 날짜 선택 시 항상 최신 데이터로 동기화 + _loadDateData(date, forceRefresh: true); + + // 선택된 날짜의 학습 데이터 로드 + _loadSelectedDateLearningData(date); + } + + /// 특정 날짜 데이터 로드 + /// + /// [date]: 로드할 날짜 + /// [forceRefresh]: true면 캐시를 무시하고 서버에서 최신 데이터를 가져옵니다 + Future _loadDateData(DateTime date, {bool forceRefresh = false}) async { + final dateStr = _formatDate(date); + + // 학원이 없으면 건너뛰기 + if (_academyState != AcademyState.ready) { + return; + } + + try { + final userAcademyId = await getUserAcademyId( + academyService: _academyService, + registeredAcademies: _registeredAcademies, + ); + + // 학원 ID가 없으면 빈 리스트 설정 + if (userAcademyId == null) { + setState(() { + _dateAssessments[dateStr] = []; + }); + return; + } + + final assessments = await _assessmentRepository.getForDate( + academyId: userAcademyId, + date: dateStr, + forceRefresh: forceRefresh, + ); + + setState(() { + _dateAssessments[dateStr] = assessments; + }); + + if (forceRefresh) { + developer.log( + '✅ [HomePage] 날짜 데이터 강제 새로고침 완료: $dateStr - ${assessments.length}개 과제', + ); + } + } catch (e) { + developer.log('⚠️ 날짜 데이터 로드 실패: $e'); + // 에러 발생 시 빈 리스트 설정 + setState(() { + _dateAssessments[dateStr] = []; + }); + } + } + + /// 선택된 날짜의 학습 데이터 로드 + /// + /// [date]: 조회할 날짜 (어떤 타임존이든 상관없음, KST로 변환됨) + /// 기기 타임존과 무관하게 항상 KST 기준으로 조회됩니다. + Future _loadSelectedDateLearningData(DateTime date) async { + if (_isLoadingSelectedDateLearning) return; + if (_academyState != AcademyState.ready) return; + + // setState 한 번만 호출 + setState(() { + _isLoadingSelectedDateLearning = true; + _selectedDateLearningResult = null; // 이전 결과 초기화 + }); + + try { + final learningService = _getIt(); + final result = await learningService.getDailyLearningData(date); + + // setState 한 번만 호출 (성공/실패 모두) + if (mounted) { + setState(() { + _selectedDateLearningResult = result; + _isLoadingSelectedDateLearning = false; + }); + } + } catch (e) { + developer.log('⚠️ 선택된 날짜의 학습 데이터 로드 실패: $e'); + if (mounted) { + setState(() { + _selectedDateLearningResult = DailyLearningResult.error( + '데이터를 불러오는데 실패했습니다.', + ); + _isLoadingSelectedDateLearning = false; + }); + } + } + } + + /// 날짜 포맷팅 (YYYY-MM-DD) + String _formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } + + /// 선택된 날짜의 완료된 날짜 Set 계산 + Set _getCompletedDates() { + final completedDates = []; + _dateAssessments.forEach((date, assessments) { + // 과제가 하나라도 있을 때, "모든 과제가 Y"인 날만 완료로 간주 + if (assessments.isNotEmpty && + assessments.every((a) => a.assessStatus == 'Y')) { + completedDates.add(date); + } + }); + return completedDates.toSet(); + } + + /// 선택된 날짜의 숙제 마감일 Set 계산 + Set _getHomeworkDeadlines() { + final deadlines = []; + _dateAssessments.forEach((date, assessments) { + if (assessments.isNotEmpty) { + deadlines.add(date); + } + }); + return deadlines.toSet(); + } @override Widget build(BuildContext context) { + final screenHeight = MediaQuery.of(context).size.height; + return Scaffold( backgroundColor: Colors.white, body: SafeArea( child: Column( children: [ - // 헤더 _buildHeader(), + Expanded(child: _buildBody(screenHeight)), + ], + ), + ), + ); + } - // 메인 콘텐츠 - Expanded( - child: SingleChildScrollView( - padding: EdgeInsets.symmetric( - horizontal: - MediaQuery.of(context).size.width * _horizontalPadding, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - height: MediaQuery.of(context).size.height * 0.032, - ), - - // 연속학습 섹션 - // TODO: DB에서 사용자의 연속 학습 데이터 조회하여 동적으로 설정 - // - consecutiveDays: DB에서 사용자의 연속 학습 일수 조회 - // - weeklyProgress: DB에서 지난 7일간의 학습 완료 여부 조회 - ContinuousLearningWidget( - consecutiveDays: _consecutiveDays, // TODO: DB 데이터로 교체 - weeklyProgress: [ - true, // TODO: DB에서 월요일 학습 완료 여부 조회 - true, // TODO: DB에서 화요일 학습 완료 여부 조회 - false, // TODO: DB에서 수요일 학습 완료 여부 조회 - false, // TODO: DB에서 목요일 학습 완료 여부 조회 - false, // TODO: DB에서 금요일 학습 완료 여부 조회 - false, // TODO: DB에서 토요일 학습 완료 여부 조회 - false, // TODO: DB에서 일요일 학습 완료 여부 조회 - ], - ), - - Container( - height: - MediaQuery.of(context).size.height * _sectionSpacing, - ), - - // 오늘의 숙제 섹션 - _buildTodayHomework(), - - Container( - height: - MediaQuery.of(context).size.height * _sectionSpacing, - ), + Widget _buildBody(double screenHeight) { + switch (_academyState) { + case AcademyState.loading: + return const Center(child: CircularProgressIndicator()); - // 오늘의 학습 현황 섹션 - _buildTodayLearning(), + case AcademyState.none: + return _buildNoAcademyState(); - Container( - height: - MediaQuery.of(context).size.height * _sectionSpacing, - ), + case AcademyState.error: + return _buildErrorState(); - // 주간 학습 현황 섹션 - _buildWeeklyLearning(), + case AcademyState.ready: + return RefreshIndicator( + onRefresh: () async { + await _initializeAcademyData(forceRefresh: true); + }, + child: SingleChildScrollView( + physics: + const AlwaysScrollableScrollPhysics(), // Pull-to-refresh를 위해 항상 스크롤 가능하도록 + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: screenHeight * 0.0297), // 26px → 2.97% + ContinuousLearningWidgetV2( + consecutiveDays: _getConsecutiveDays(), + homeworkDeadlines: _getHomeworkDeadlines(), + onDateSelected: _onDateSelected, + selectedDate: _selectedDate, + // 새 구조: UseCase 사용 + monthlyStatusUseCase: _monthlyStatusUseCase, + // 하위 호환성 (추후 제거 예정) + completedDates: _getCompletedDates(), + dateAssessments: _dateAssessments, + ), + SizedBox(height: screenHeight * 0.0297), // 26px → 2.97% + _buildTodayHomeworkSection(), + SizedBox(height: screenHeight * 0.0297), // 26px → 2.97% + _buildAccumulatedLearningSection(), + const SizedBox(height: 20), + ], + ), + ), + ); + } + } - Container( - height: - MediaQuery.of(context).size.height * _sectionSpacing, - ), - ], + Widget _buildErrorState() { + return Center( + child: Padding( + padding: const EdgeInsets.all(40.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 64, color: Color(0xFFFF6B6B)), + const SizedBox(height: 24), + const Text( + '학원 정보를 불러올 수 없습니다', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 18, + color: Color(0xFF333333), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + ElevatedButton( + onPressed: () => _initializeAcademyData(forceRefresh: true), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFAC5BF8), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + child: const Text( + '다시 시도', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 14, ), ), ), @@ -104,337 +626,820 @@ class _HomePageState extends State { } Widget _buildHeader() { - return Container( - padding: EdgeInsets.fromLTRB( - MediaQuery.of(context).size.width * 0.075, - MediaQuery.of(context).size.height * 0.021, - MediaQuery.of(context).size.width * 0.075, - MediaQuery.of(context).size.height * 0.012, - ), - decoration: const BoxDecoration( - color: Color(0xFFF8F9FA), - boxShadow: [ - BoxShadow( - color: Color(0x1A000000), - offset: Offset(0, 4), - blurRadius: 4, - ), - ], - ), - child: Column( - children: [ - // 헤더 콘텐츠 - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - // 학원명과 드롭다운 - // TODO: DB에서 사용자가 등록한 학원 정보 조회 - // - 사용자가 등록한 학원명 표시 - // - 여러 학원에 등록된 경우 드롭다운으로 선택 가능 - Row( + // ✅ Rule 1: 아이콘 사이즈도 상대 크기로 + final iconSize = MediaQuery.of(context).size.width * 0.06; + + // 학원이 정상적으로 설정된 상태인지 여부 + final hasAcademy = + _academyState == AcademyState.ready && _registeredAcademies.isNotEmpty; + + // 학원이 2개 이상일 때만 드롭다운 활성화 (단, 학원이 있을 때만) + final canShowDropdown = hasAcademy && _registeredAcademies.length > 1; + + return AppHeader( + titleAlignment: hasAcademy ? 'left' : 'center', + title: !hasAcademy + // 학원이 없을 때는 학원 이름("Gradi 학원" 등)을 표시하지 않고 + // 단순히 홈 타이틀만 표시 + ? const Text( + '홈', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 20, + color: Color(0xFF333333), + ), + ) + : canShowDropdown + ? PopupMenuButton( + child: Row( + mainAxisSize: MainAxisSize.min, children: [ - const Text( - '정다훈 학원', // TODO: DB에서 조회한 실제 학원명으로 교체 - style: TextStyle( + Text( + _academyName, + style: const TextStyle( fontFamily: 'Pretendard', fontWeight: FontWeight.w700, fontSize: 20, color: Color(0xFF333333), ), ), - Container(width: MediaQuery.of(context).size.width * 0.015), + const SizedBox(width: 8), Icon( Icons.keyboard_arrow_down, - color: Colors.grey[600], - size: 24, + color: const Color(0xFF333333), + size: iconSize, ), ], ), + itemBuilder: (context) { + return _registeredAcademies.map((academy) { + return PopupMenuItem( + value: academy.academyCode, + child: FutureBuilder( + future: _academyService.getDefaultAcademyCode(), + builder: (context, snapshot) { + final currentAcademyCode = snapshot.data; + final isSelected = + academy.academyCode == currentAcademyCode; - // 메뉴 버튼 - Icon(Icons.menu, color: Colors.grey[600], size: 24), - ], - ), - ], - ), + return Row( + children: [ + Expanded( + child: Text( + academy.academyName, + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w400, + fontSize: 14, + color: isSelected + ? const Color(0xFFAC5BF8) + : const Color(0xFF333333), + ), + ), + ), + if (isSelected) + const Icon( + Icons.check, + color: Color(0xFFAC5BF8), + size: 20, + ), + ], + ); + }, + ), + ); + }).toList(); + }, + onSelected: (academyCode) { + _onAcademySelected(academyCode); + }, + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _academyName, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 20, + color: Color(0xFF333333), + ), + ), + const SizedBox(width: 8), + Icon( + Icons.keyboard_arrow_down, + color: const Color(0xFF333333), + size: iconSize, + ), + ], + ), + trailing: const AppHeaderMenuButton(), ); } - Widget _buildTodayHomework() { - // TODO: DB에서 오늘의 숙제 데이터 조회 - // - 사용자가 등록한 학원의 오늘 할당된 숙제 목록 조회 - // - 숙제 제목, 완료 여부, 마감일 등 정보 포함 - // - 완료된 숙제는 체크박스 표시, 미완료 숙제는 빈 체크박스 표시 + /// 학원 선택 시 호출 (드롭다운에서 선택) + Future _onAcademySelected(String academyCode) async { + try { + // 디폴트 학원 저장 + await _academyService.saveDefaultAcademyCode(academyCode); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSectionHeader('오늘의 숙제'), - Container(height: MediaQuery.of(context).size.height * _smallSpacing), - _buildHomeworkContainer(), - ], - ); - } + // 메모리에서 학원 찾기 + final academy = _registeredAcademies.firstWhere( + (a) => a.academyCode == academyCode, + ); - Widget _buildHomeworkContainer() { - return Container( - padding: EdgeInsets.fromLTRB( - MediaQuery.of(context).size.width * _containerPadding, - MediaQuery.of(context).size.height * 0.019, - MediaQuery.of(context).size.width * _containerPadding, - MediaQuery.of(context).size.height * 0.026, - ), - decoration: BoxDecoration( - color: const Color(0xFFF8F9FA), - border: Border.all(color: const Color(0xFFE9ECEF)), - borderRadius: BorderRadius.circular(10), - ), - child: Column(children: _buildHomeworkItems()), - ); - } + if (mounted) { + setState(() { + _academyName = academy.academyName; + // _academyState는 ready 유지 + }); + + // Assessment 데이터 다시 로드 + _dateAssessments.clear(); + await _loadRemainingMonthData(); - List _buildHomeworkItems() { - // TODO: DB에서 조회한 숙제 목록을 동적으로 생성 - return [ - _buildHomeworkItem('오늘의 숙제 리스트'), - _buildHomeworkItem('오늘의 숙제 리스트'), - _buildHomeworkItem('오늘의 숙제 리스트'), - _buildHomeworkItem('오늘의 숙제 리스트'), - _buildHomeworkItem('오늘의 숙제 리스트'), - ]; + // 오늘의 숙제 정보 동기화 + await _synchronizeTodayHomework(); + + developer.log('✅ 학원 변경 완료: ${academy.academyName}'); + } + } catch (e) { + developer.log('⚠️ 학원 선택 처리 실패: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('학원 변경에 실패했습니다: ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } } - Widget _buildHomeworkItem(String title) { - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).size.height * 0.012, - ), - child: Row( - children: [ - Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.03, - minWidth: 10, - maxHeight: MediaQuery.of(context).size.width * 0.03, - minHeight: 10, + /// 학원이 없을 때 표시할 안내 위젯 + Widget _buildNoAcademyState() { + return Center( + child: Padding( + padding: const EdgeInsets.all(40.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.school_outlined, + size: 64, + color: Color(0xFFADADAD), + ), + const SizedBox(height: 24), + const Text( + '학원을 등록해주세요', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 18, + color: Color(0xFF333333), + ), + textAlign: TextAlign.center, ), - decoration: BoxDecoration( - color: const Color(0xFFE9ECEF), - borderRadius: BorderRadius.circular(3), + const SizedBox(height: 12), + const Text( + '학원을 등록하면 숙제와 학습 정보를\n확인할 수 있습니다.', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + fontSize: 14, + color: Color(0xFF666666), + ), + textAlign: TextAlign.center, ), - ), - Container(width: MediaQuery.of(context).size.width * 0.02), - Text( - title, - style: const TextStyle( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w500, - fontSize: 12, - color: Color(0xFF333333), + const SizedBox(height: 32), + ElevatedButton( + onPressed: () { + Navigator.pushNamed(context, '/academy/list'); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFAC5BF8), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + child: const Text( + '학원 등록하기', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), ), - ), - ], + ], + ), ), ); } - Widget _buildTodayLearning() { - // TODO: DB에서 오늘의 학습 현황 데이터 조회 - // - 사용자가 오늘 학습한 과목별 진행률 조회 - // - 각 과목의 아이콘, 진행률, 과목명 정보 포함 - // - 학습 완료율에 따른 진행률 바 표시 - + /// 오늘의 숙제 섹션 + Widget _buildTodayHomeworkSection() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSectionHeader('오늘의 학습 현황'), - Container(height: MediaQuery.of(context).size.height * _smallSpacing), - _buildLearningContainer(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '오늘의 숙제', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 18, + color: Color(0xFF333333), + ), + ), + InkWell( + onTap: () { + Navigator.pushNamed(context, AppRoutes.homeworkStatus); + }, + borderRadius: BorderRadius.circular(24), + child: Padding( + padding: const EdgeInsets.all(4), + child: Icon( + Icons.chevron_right, + color: Colors.grey[600], + size: MediaQuery.of(context).size.width * 0.06, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + + // TODO: DB에서 선택된 날짜의 숙제 목록 조회 + _buildHomeworkList(), ], ); } - Widget _buildSectionHeader(String title) { - return Row( - children: [ - Text( - title, - style: const TextStyle( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w600, - fontSize: 14, - color: Color(0xFF333333), + Widget _buildHomeworkList() { + // 메서드 호출 여부 확인 + developer.log('🔵 [HomePage] _buildHomeworkList() 호출됨'); + developer.log('🔵 [HomePage] _academyState: $_academyState'); + + // 학원이 없을 때 안내 표시 + if (_academyState != AcademyState.ready) { + developer.log('⚠️ [HomePage] _academyState가 ready가 아님. early return'); + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE1E7ED)), + borderRadius: BorderRadius.circular(10), + ), + child: const Center( + child: Text( + '학원을 등록해주세요', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF999999), + ), ), ), - const Spacer(), - Icon(Icons.chevron_right, color: Colors.grey[600], size: 17), - ], - ); - } + ); + } - Widget _buildLearningContainer() { - final screenHeight = MediaQuery.of(context).size.height; - final sectionHeight = screenHeight * 0.2; // 화면 높이의 20% + // 선택된 날짜의 Assessment 데이터 가져오기 + final dateStr = _formatDate(_selectedDate); + final assessments = _dateAssessments[dateStr] ?? []; - return Container( - height: sectionHeight, - padding: EdgeInsets.symmetric( - horizontal: sectionHeight * 0.05, // 5% 수평 패딩 - vertical: sectionHeight * 0.1, // 10% 수직 패딩 - ), - decoration: BoxDecoration( - color: const Color(0xFFF8F9FA), - border: Border.all(color: const Color(0xFFE9ECEF)), - borderRadius: BorderRadius.circular(10), - ), - child: LayoutBuilder( - builder: (context, innerConstraints) { - final availableHeight = innerConstraints.maxHeight; - return _buildLearningList(availableHeight); - }, - ), + developer.log('📋 [HomePage] 선택된 날짜: $dateStr'); + developer.log('📋 [HomePage] 선택된 날짜의 과제 수: ${assessments.length}'); + developer.log( + '📋 [HomePage] 선택된 날짜의 과제 목록: ${assessments.map((e) => e.assessName).toList()}', + ); + developer.log( + '📋 [HomePage] _dateAssessments 전체 키: ${_dateAssessments.keys.toList()}', ); - } - Widget _buildLearningList(double availableHeight) { - return ListView( - scrollDirection: Axis.horizontal, - children: [ - _buildLearningItem( - 'assets/images/bookcovers/workbook_2026.jpg', - availableHeight, - ), - SizedBox(width: availableHeight * 0.15), - _buildLearningItem( - 'assets/images/bookcovers/workbook_2025.jpg', - availableHeight, + for (var assessment in assessments) { + developer.log('📋 [HomePage] 과제 이름: ${assessment.assessName}'); + developer.log('📋 [HomePage] 과제 클래스: ${assessment.assessClass}'); + developer.log('📋 [HomePage] 과제 상태: ${assessment.assessStatus}'); + developer.log('📋 [HomePage] 과제 페이지: ${assessment.assessPage}'); + developer.log('📋 [HomePage] 과제 썸네일: ${assessment.bookCoverImage}'); + } + + if (assessments.isEmpty) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE1E7ED)), + borderRadius: BorderRadius.circular(10), ), - SizedBox(width: availableHeight * 0.15), - _buildLearningItem( - 'assets/images/bookcovers/workbook_2024.jpg', - availableHeight, + child: const Center( + child: Text( + '해당 날짜에 숙제가 없습니다.', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF999999), + ), + ), ), - SizedBox(width: availableHeight * 0.15), - ], - ); - } + ); + } - Widget _buildLearningItem(String imagePath, double containerHeight) { - // 표지와 상태바가 컨테이너 높이의 90%를 차지하도록 설정 (더 크게!) - final totalItemHeight = containerHeight * 0.9; + // Assessment를 클래스명으로 먼저 그룹화, 그 다음 bookId로 그룹화 + final Map>> groupedByClass = {}; + for (var assessment in assessments) { + // 클래스명이 없으면 학원 이름 사용 + final className = assessment.assessClass.isNotEmpty + ? assessment.assessClass + : _academyName; - // 표지 높이: 전체 아이템 높이의 80% - final bookHeight = totalItemHeight * 0.8; - // 3:4 비율에 맞춰 너비 계산 - final bookWidth = bookHeight * 0.75; // 3/4 = 0.75 + if (!groupedByClass.containsKey(className)) { + groupedByClass[className] = {}; + } - return SizedBox( - height: containerHeight, // 전체 컨테이너 높이 사용 - child: Column( - mainAxisAlignment: MainAxisAlignment.center, // 세로축 중앙 정렬 - children: [ - // 학습 아이콘 (3:4 비율 사각형) - 교재 표지 이미지 - Container( - width: bookWidth, // 상대 크기 (3:4 비율) - height: bookHeight, // 상대 크기 (컨테이너 높이의 80%) - decoration: BoxDecoration( - color: Colors.grey[100], - borderRadius: BorderRadius.circular( - bookHeight * 0.1, - ), // 상대적 둥근 모서리 - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(bookHeight * 0.1), - child: Image.asset( - imagePath, // 교재 표지 이미지 경로 - width: bookWidth, - height: bookHeight, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - // 이미지 로드 실패 시 기본 아이콘 표시 - return Icon( - Icons.book, - color: Colors.grey[400], - size: bookHeight * 0.4, - ); - }, + final classGroup = groupedByClass[className]!; + if (!classGroup.containsKey(assessment.bookId)) { + classGroup[assessment.bookId] = []; + } + classGroup[assessment.bookId]!.add(assessment); + } + + // 각 클래스별로 섹션 생성 + return Column( + children: groupedByClass.entries.map((classEntry) { + final className = classEntry.key; + final bookGroups = classEntry.value; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 클래스명 헤더 + Padding( + padding: EdgeInsets.only( + bottom: 12, + top: classEntry == groupedByClass.entries.first ? 0 : 20, + ), + child: Text( + className, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 16, + color: Color(0xFF333333), + ), ), ), - ), + // 각 문제집별로 카드 생성 + ...bookGroups.entries.map((entry) { + final bookId = entry.key; + final bookAssessments = entry.value; - SizedBox(height: totalItemHeight * 0.1), // 표지와 진행률 바 사이 간격 - // 진행률 바 - Container( - width: bookWidth, - height: totalItemHeight * 0.1, // 전체 아이템 높이의 10% - decoration: BoxDecoration( - color: Colors.grey[200], - borderRadius: BorderRadius.circular(totalItemHeight * 0.05), - ), - child: FractionallySizedBox( - alignment: Alignment.centerLeft, - widthFactor: _progressFactor, // 70% 진행률 - child: Container( + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(15), decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFFAC5BF8), Color(0xFF636ACF)], - ), - borderRadius: BorderRadius.circular(totalItemHeight * 0.05), + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE1E7ED)), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 문제집별 Assessment 목록 + ...bookAssessments.asMap().entries.map((entry) { + final index = entry.key; + final assessment = entry.value; + // 항상 서버에서 가져온 최신 상태를 사용 + final isCompleted = assessment.assessStatus == 'Y'; + + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _buildWorkbookItemFromAssessment( + assessment, + bookId, + index, + isCompleted, + ), + ); + }).toList(), + ], + ), + ); + }).toList(), + ], + ); + }).toList(), + ); + } + + /// assessPage를 "P.15~P.25" 형식으로 변환 + String _formatAssessPage(String assessPage) { + if (assessPage.isEmpty || !assessPage.contains('-')) { + return assessPage; + } + + final parts = assessPage.split('-'); + if (parts.length == 2) { + final startPage = parts[0].trim(); + final endPage = parts[1].trim(); + return 'P.$startPage~P.$endPage'; + } + + return assessPage; + } + + /// 문제집 아이템 (Assessment 기반) + Widget _buildWorkbookItemFromAssessment( + Assessment assessment, + String bookId, + int index, + bool isCompleted, + ) { + final screenWidth = MediaQuery.of(context).size.width; + final thumbnailWidth = screenWidth * 0.12; + final thumbnailHeight = thumbnailWidth * 1.34; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 문제집 썸네일 + ClipRRect( + borderRadius: BorderRadius.circular(5), + child: assessment.bookCoverImage.startsWith('http') + ? Image.network( + assessment.bookCoverImage, + width: thumbnailWidth, + height: thumbnailHeight, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: thumbnailWidth, + height: thumbnailHeight, + color: Colors.grey[300], + child: const Icon(Icons.book, color: Colors.grey), + ); + }, + ) + : Image.asset( + assessment.bookCoverImage, + width: thumbnailWidth, + height: thumbnailHeight, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: thumbnailWidth, + height: thumbnailHeight, + color: Colors.grey[300], + child: const Icon(Icons.book, color: Colors.grey), + ); + }, + ), + ), + const SizedBox(width: 12), + + // 챕터 + 페이지 정보 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + assessment.assessName, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 14, + color: Color(0xFF333333), ), ), + const SizedBox(height: 4), + Text( + _formatAssessPage(assessment.assessPage), + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 12, + color: Color(0xFF999999), + ), + ), + ], + ), + ), + + // 체크박스 (사용자 조작 불가, assessStatus에 따라 자동으로 표시됨) + Container( + width: screenWidth * 0.06, + height: screenWidth * 0.06, + decoration: BoxDecoration( + color: isCompleted ? const Color(0xFFAC5BF8) : Colors.white, + border: Border.all( + color: isCompleted + ? const Color(0xFFAC5BF8) + : const Color(0xFFCED4DA), + width: 2, ), + borderRadius: BorderRadius.circular(5), ), - ], - ), + child: isCompleted + ? Icon(Icons.check, size: screenWidth * 0.04, color: Colors.white) + : null, + ), + ], ); } - Widget _buildWeeklyLearning() { - // TODO: DB에서 주간 학습 현황 데이터 조회 - // - 지난 7일간의 학습 통계 데이터 조회 - // - 일별 학습 시간, 완료한 과목 수, 성취도 등 정보 포함 - // - 차트 라이브러리(fl_chart 등)를 사용하여 시각화 구현 - + /// 오늘의 학습 섹션 + Widget _buildAccumulatedLearningSection() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSectionHeader('주간 학습 현황'), - Container(height: MediaQuery.of(context).size.height * _smallSpacing), - _buildWeeklyChartContainer(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '오늘의 학습', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 18, + color: Color(0xFF333333), + ), + ), + Icon( + Icons.chevron_right, + color: Colors.grey[600], + size: MediaQuery.of(context).size.width * 0.06, + ), + ], + ), + const SizedBox(height: 12), + + // TODO: DB에서 선택된 날짜에 학습한 문제집 목록 조회 + _buildLearningProgressCard(), ], ); } - Widget _buildWeeklyChartContainer() { - return Container( - padding: EdgeInsets.fromLTRB( - MediaQuery.of(context).size.width * _containerPadding, - MediaQuery.of(context).size.height * 0.019, - MediaQuery.of(context).size.width * _containerPadding, - MediaQuery.of(context).size.height * 0.026, - ), - decoration: BoxDecoration( - color: const Color(0xFFF8F9FA), - border: Border.all(color: const Color(0xFFE9ECEF)), - borderRadius: BorderRadius.circular(10), - ), - child: Container( - constraints: BoxConstraints( - minHeight: MediaQuery.of(context).size.height * 0.1, - maxHeight: MediaQuery.of(context).size.height * 0.15, + Widget _buildLearningProgressCard() { + // 로딩 중 + if (_isLoadingSelectedDateLearning) { + return Container( + padding: const EdgeInsets.all(15), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE1E7ED)), + borderRadius: BorderRadius.circular(10), + ), + child: const Center(child: CircularProgressIndicator()), + ); + } + + // 결과가 없음 + if (_selectedDateLearningResult == null) { + return const SizedBox.shrink(); + } + + // 에러 상태 + if (_selectedDateLearningResult!.hasError) { + return Container( + padding: const EdgeInsets.all(15), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE1E7ED)), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + children: [ + const Icon(Icons.error_outline, color: Color(0xFFFF6B6B)), + const SizedBox(height: 8), + Text( + _selectedDateLearningResult!.errorMessage ?? '데이터를 불러오는데 실패했습니다.', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFFFF6B6B), + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + // 빈 상태 (학습 기록 없음) + if (!_selectedDateLearningResult!.hasData) { + return Container( + padding: const EdgeInsets.all(15), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE1E7ED)), + borderRadius: BorderRadius.circular(10), + ), + child: const Center( + child: Text( + '해당 날짜에 학습 기록이 없습니다.', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF999999), + ), + ), + ), + ); + } + + // 데이터 있음 - 가장 많이 학습한 책 선택 + final books = DailyLearningService.extractBooks( + _selectedDateLearningResult!, + ); + final selectedBook = DailyLearningService.selectMostLearnedBook(books); + + if (selectedBook == null) { + return Container( + padding: const EdgeInsets.all(15), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE1E7ED)), + borderRadius: BorderRadius.circular(10), ), child: const Center( child: Text( - '주간 학습 현황 차트', // TODO: 실제 차트로 교체 (fl_chart 등 사용) + '학습 데이터를 표시할 수 없습니다.', style: TextStyle( fontFamily: 'Pretendard', fontWeight: FontWeight.w500, fontSize: 14, - color: Color(0xFF666666), + color: Color(0xFF999999), ), ), ), + ); + } + + // Progress 계산 + final progress = selectedBook.bookPage > 0 + ? selectedBook.totalSolvedPages / selectedBook.bookPage + : 0.0; + + final screenWidth = MediaQuery.of(context).size.width; + final thumbnailWidth = screenWidth * 0.17; + final thumbnailHeight = thumbnailWidth * 1.33; + + return Container( + padding: const EdgeInsets.all(15), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE1E7ED)), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 문제집 썸네일 + ClipRRect( + borderRadius: BorderRadius.circular(5), + child: + selectedBook.bookImageUrl != null && + selectedBook.bookImageUrl!.startsWith('http') + ? Image.network( + selectedBook.bookImageUrl!, + width: thumbnailWidth, + height: thumbnailHeight, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + width: thumbnailWidth, + height: thumbnailHeight, + color: Colors.grey[300], + child: Icon( + Icons.book, + color: Colors.grey, + size: thumbnailWidth * 0.57, + ), + ); + }, + ) + : Container( + width: thumbnailWidth, + height: thumbnailHeight, + color: Colors.grey[300], + child: Icon( + Icons.book, + color: Colors.grey, + size: thumbnailWidth * 0.57, + ), + ), + ), + const SizedBox(width: 15), + + // 학습 정보 및 프로그레스 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 문제집명 + Text( + selectedBook.bookName ?? '문제집 ${selectedBook.bookId}', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 16, + color: Color(0xFF333333), + ), + ), + const SizedBox(height: 12), + + // 프로그레스 바 + LayoutBuilder( + builder: (context, constraints) { + final progressHeight = constraints.maxWidth * 0.04; + return Stack( + children: [ + // 전체 배경 (회색) + Container( + height: progressHeight, + decoration: BoxDecoration( + color: const Color(0xFFE9ECEF), + borderRadius: BorderRadius.circular(10), + ), + ), + // 진행률 (보라색) + FractionallySizedBox( + widthFactor: progress.clamp(0.0, 1.0), + child: Container( + height: progressHeight, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFAC5BF8), Color(0xFF7C3AED)], + ), + borderRadius: BorderRadius.circular(10), + ), + ), + ), + ], + ); + }, + ), + const SizedBox(height: 8), + + // 진행 정보 + Text( + '${selectedBook.totalSolvedPages} / ${selectedBook.bookPage} 페이지', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 13, + color: Color(0xFF333333), + ), + ), + ], + ), + ), + ], ), ); } + + // 헬퍼 메서드들 + + int _getConsecutiveDays() { + final completedDates = _getCompletedDates(); + if (completedDates.isEmpty) return 0; + + final completedSet = completedDates.toSet(); + DateTime today = DateTime.now(); + DateTime cursor = DateTime(today.year, today.month, today.day); + + final todayKey = _formatDate(cursor); + if (!completedSet.contains(todayKey)) { + cursor = cursor.subtract(const Duration(days: 1)); + } + + int streak = 0; + while (true) { + final key = _formatDate(cursor); + if (!completedSet.contains(key)) { + break; + } + streak++; + cursor = cursor.subtract(const Duration(days: 1)); + } + + return streak; + } } diff --git a/frontend/lib/screens/loading_page.dart b/frontend/lib/screens/loading_page.dart new file mode 100644 index 0000000..6660cbf --- /dev/null +++ b/frontend/lib/screens/loading_page.dart @@ -0,0 +1,251 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get_it/get_it.dart'; +import '../services/auth_service.dart'; +import '../services/user_service.dart'; +import '../services/assessment_repository.dart'; +import '../services/academy_service.dart'; +import '../services/fcm_service.dart'; +import 'dart:developer' as developer; + +/// 앱 시작 시 로딩 페이지 +/// +/// 동작 방식: +/// 1. 자동 로그인이 선택된 경우 (토큰이 있는 경우): +/// - 로딩 페이지를 표시하면서 필요한 API 호출 +/// - 완료 후 메인 네비게이션 페이지로 이동 +/// 2. 로그인되지 않은 경우: +/// - 로그인 페이지로 이동 +class LoadingPage extends StatefulWidget { + const LoadingPage({super.key}); + + @override + State createState() => _LoadingPageState(); +} + +class _LoadingPageState extends State { + final getIt = GetIt.instance; + + @override + void initState() { + super.initState(); + _initializeApp(); + } + + /// 앱 초기화 로직 + Future _initializeApp() async { + try { + final authService = getIt(); + final assessmentRepository = getIt(); + + // 자동 로그인 설정 확인 + final isAutoLoginEnabled = await authService.isAutoLoginEnabled(); + + if (!mounted) return; + + if (!isAutoLoginEnabled) { + // 자동 로그인 비활성화 → 로그인 페이지로 이동 + developer.log('ℹ️ 자동 로그인 비활성화 - 로그인 페이지로 이동'); + // 자동 로그인이 비활성화된 경우 토큰이 남아있을 수 있으므로 삭제 + await authService.clearTokens(clearAutoLogin: false); + if (mounted) { + Navigator.pushNamedAndRemoveUntil( + context, + '/login', + (route) => false, + ); + } + return; + } + + // 자동 로그인 활성화 → Access Token 확인 + final accessToken = await authService.getAccessToken(); + + if (accessToken == null || accessToken.isEmpty) { + // 토큰 없음 → 로그인 페이지로 이동 + developer.log('ℹ️ 토큰 없음 - 로그인 페이지로 이동'); + if (mounted) { + Navigator.pushNamedAndRemoveUntil( + context, + '/login', + (route) => false, + ); + } + return; + } + + // 토큰 만료 확인 및 갱신 + final isExpired = authService.isTokenExpired(accessToken); + + if (isExpired) { + developer.log('⚠️ Access Token 만료됨 - Refresh Token으로 갱신 시도...'); + final refreshed = await authService.refreshAccessToken(); + + if (!refreshed) { + // Refresh Token도 만료되었거나 갱신 실패 → 로그인 페이지로 이동 + developer.log('❌ 토큰 갱신 실패 - 로그인 페이지로 이동'); + await authService.clearTokens(); + if (mounted) { + Navigator.pushNamedAndRemoveUntil( + context, + '/login', + (route) => false, + ); + } + return; + } + + developer.log('✅ Access Token 갱신 성공'); + } else { + developer.log('✅ Access Token 유효함'); + } + + // 자동 로그인: 필요한 API 호출 + developer.log('🔄 자동 로그인 감지 - 사용자 정보 초기화 중...'); + + try { + // 사용자 정보 초기화 (API 호출) + // fetchUserFromServer 내부에서 토큰 만료 시 자동 갱신 처리됨 + await getIt().initialize(); + developer.log('✅ 사용자 정보 초기화 완료'); + } catch (e) { + developer.log('❌ 사용자 정보 초기화 실패: $e'); + // 초기화 실패해도 메인 페이지로 이동 (캐시된 데이터 사용) + } + + // 학원 목록 API 호출 및 디폴트 학원 선택 + try { + final userId = await authService.getUserId(); + if (userId != null) { + developer.log('🔄 학원 목록 조회 중...'); + final academyService = getIt(); + final academies = await academyService.getUserAcademies(userId); + + // SharedPreferences에 학원 목록 저장 + await academyService.saveAcademiesToCache(academies); + developer.log('✅ 학원 목록 캐시 저장 완료 (${academies.length}개)'); + + // 디폴트 학원 선택 및 저장 + final defaultAcademyCode = await academyService + .selectDefaultAcademyAsync(academies); + if (defaultAcademyCode != null) { + await academyService.saveDefaultAcademyCode(defaultAcademyCode); + developer.log('✅ 디폴트 학원 선택 및 저장 완료 (Code: $defaultAcademyCode)'); + + // 현재 달과 다음 달의 Assessment 데이터 로드 (디폴트 학원 사용) + try { + final now = DateTime.now(); + + // 현재 달의 첫 번째 날 (UTC) + final currentMonthStart = DateTime.utc(now.year, now.month, 1); + + // 다음 달 계산 (set 함수 사용) + final nextMonthStart = _getNextMonth(currentMonthStart); + + // 현재 달과 다음 달 데이터를 병렬로 가져오기 + await Future.wait([ + assessmentRepository.getForMonth( + academyId: defaultAcademyCode, + dateTime: currentMonthStart, + ), + assessmentRepository.getForMonth( + academyId: defaultAcademyCode, + dateTime: nextMonthStart, + ), + ]); + + developer.log('✅ 현재 달과 다음 달 Assessment 데이터 로드 완료'); + } catch (e) { + developer.log('⚠️ Assessment 데이터 로드 실패: $e'); + // 실패해도 앱은 계속 진행 + } + } else { + developer.log('⚠️ 디폴트 학원을 선택할 수 없음 (등록완료된 학원이 없음)'); + } + } else { + developer.log('⚠️ 사용자 ID를 가져올 수 없어 학원 목록 조회를 건너뜀'); + } + } catch (e) { + developer.log('⚠️ 학원 목록 조회 실패: $e'); + // 실패해도 앱은 계속 진행 (캐시된 데이터 사용 가능) + } + + // FCM 토큰을 백엔드와 동기화 + try { + final fcmService = getIt(); + await fcmService.syncTokenWithServer(); + developer.log('✅ FCM 토큰 동기화 완료'); + } catch (e) { + developer.log('⚠️ FCM 토큰 동기화 실패: $e'); + // 실패해도 앱은 계속 진행 + } + + // 메인 네비게이션 페이지로 이동 + if (mounted) { + Navigator.pushNamedAndRemoveUntil(context, '/', (route) => false); + } + } catch (e) { + developer.log('❌ 앱 초기화 중 오류 발생: $e'); + // 오류 발생 시 로그인 페이지로 이동 + if (mounted) { + Navigator.pushNamedAndRemoveUntil(context, '/login', (route) => false); + } + } + } + + /// 다음 달 계산 헬퍼 함수 + DateTime _getNextMonth(DateTime dateTime) { + if (dateTime.month == 12) { + return DateTime.utc(dateTime.year + 1, 1, 1); + } else { + return DateTime.utc(dateTime.year, dateTime.month + 1, 1); + } + } + + @override + Widget build(BuildContext context) { + // 상태바 스타일 설정 (흰색 아이콘) + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + systemNavigationBarColor: Colors.transparent, + systemNavigationBarIconBrightness: Brightness.light, + ), + ); + + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + // Figma: linear-gradient(153deg, rgba(172, 91, 248, 1) 9%, rgba(99, 106, 207, 1) 92%) + colors: [ + Color(0xFFAC5BF8), // rgba(172, 91, 248, 1) + Color(0xFF636ACF), // rgba(99, 106, 207, 1) + ], + stops: [0.09, 0.92], // 9%, 92% + transform: GradientRotation(2.67), // 153 degrees in radians + ), + ), + child: Center( + child: Text( + 'GRADI', + style: TextStyle( + fontFamily: 'AppleSDGothicNeoH00', + fontWeight: FontWeight.w400, + fontSize: 110.648, + height: 1.491, // line-height: 165px / 110.648px + letterSpacing: -0.06, // -6% + color: Colors.white, + ), + textAlign: TextAlign.center, + ), + ), + ), + ); + } +} diff --git a/frontend/lib/screens/main_navigation_page.dart b/frontend/lib/screens/main_navigation_page.dart index 40cdd24..3e31e4d 100644 --- a/frontend/lib/screens/main_navigation_page.dart +++ b/frontend/lib/screens/main_navigation_page.dart @@ -3,6 +3,9 @@ import '../widgets/bottom_navigation_widget.dart'; import 'home_page.dart'; import 'workbook/workbook_page.dart'; import 'academy/academy_page.dart'; +import 'mypage/mypage.dart'; +import 'notification/notification_page.dart'; +import 'upload/upload_images_page.dart'; class MainNavigationPage extends StatefulWidget { const MainNavigationPage({super.key}); @@ -13,19 +16,32 @@ class MainNavigationPage extends StatefulWidget { class _MainNavigationPageState extends State { int _currentIndex = 0; + bool _handledRouteArgs = false; + + // HomePage의 State에 접근하기 위한 GlobalKey + final GlobalKey> _homePageKey = GlobalKey(); + // UploadImagesPage의 State에 접근하기 위한 GlobalKey + final GlobalKey> _uploadPageKey = GlobalKey(); + // WorkbookPage의 State에 접근하기 위한 GlobalKey + final GlobalKey> _workbookPageKey = GlobalKey(); + // AcademyPage의 State에 접근하기 위한 GlobalKey + final GlobalKey> _academyPageKey = GlobalKey(); + // NotificationPage의 State에 접근하기 위한 GlobalKey + final GlobalKey> _notificationPageKey = GlobalKey(); // 모든 탭 페이지들 - final List _pages = [ - const HomePage(), - const WorkbookPage(), - const PlaceholderPage(title: '이미지업로드'), - const AcademyPage(), - const PlaceholderPage(title: '마이페이지'), - const PlaceholderPage(title: '알람'), + late final List _pages = [ + HomePage(key: _homePageKey), // GlobalKey 전달 + WorkbookPage(key: _workbookPageKey), // GlobalKey 전달 + UploadImagesPage(key: _uploadPageKey), // GlobalKey 전달 + AcademyPage(key: _academyPageKey), // GlobalKey 전달 + const MyPage(), + NotificationPage(key: _notificationPageKey), // GlobalKey 전달 ]; @override Widget build(BuildContext context) { + _handleRouteArgumentsIfNeeded(); return Scaffold( body: IndexedStack(index: _currentIndex, children: _pages), bottomNavigationBar: BottomNavigationWidget( @@ -34,10 +50,105 @@ class _MainNavigationPageState extends State { setState(() { _currentIndex = index; }); + + // 홈 탭(인덱스 0)으로 전환 시 새로고침 + if (index == 0) { + final homeState = _homePageKey.currentState; + // dynamic으로 캐스팅하여 refresh() 메서드 호출 + if (homeState != null) { + try { + (homeState as dynamic).refresh(); + } catch (e) { + // refresh() 메서드가 없는 경우 무시 + } + } + } + + // 문제집 탭(인덱스 1)으로 전환 시 새로고침 + if (index == 1) { + final workbookState = _workbookPageKey.currentState; + // dynamic으로 캐스팅하여 refresh() 메서드 호출 + if (workbookState != null) { + try { + (workbookState as dynamic).refresh(); + } catch (e) { + // refresh() 메서드가 없는 경우 무시 + } + } + } + + // 이미지 업로드 탭(인덱스 2)으로 전환 시 새로고침 + if (index == 2) { + final uploadState = _uploadPageKey.currentState; + // dynamic으로 캐스팅하여 refresh() 메서드 호출 + if (uploadState != null) { + try { + (uploadState as dynamic).refresh(); + } catch (e) { + // refresh() 메서드가 없는 경우 무시 + } + } + } + + // 학원 탭(인덱스 3)으로 전환 시 새로고침 + if (index == 3) { + final academyState = _academyPageKey.currentState; + // dynamic으로 캐스팅하여 refresh() 메서드 호출 + if (academyState != null) { + try { + (academyState as dynamic).refresh(); + } catch (e) { + // refresh() 메서드가 없는 경우 무시 + } + } + } + + // 알림 탭(인덱스 5)으로 전환 시 새로고침 + if (index == 5) { + final notificationState = _notificationPageKey.currentState; + // dynamic으로 캐스팅하여 refresh() 메서드 호출 + if (notificationState != null) { + try { + (notificationState as dynamic).refresh(); + } catch (e) { + // refresh() 메서드가 없는 경우 무시 + } + } + } }, ), ); } + + void _handleRouteArgumentsIfNeeded() { + if (_handledRouteArgs) return; + final args = ModalRoute.of(context)?.settings.arguments; + if (args is Map && args['targetDate'] is String) { + final targetDate = DateTime.tryParse(args['targetDate'] as String); + if (targetDate != null) { + _handledRouteArgs = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _navigateHomeAndFocus(targetDate); + }); + } + } + } + + void _navigateHomeAndFocus(DateTime date) { + setState(() { + _currentIndex = 0; + }); + final homeState = _homePageKey.currentState; + if (homeState != null) { + try { + (homeState as dynamic).focusOnDate(date); + } catch (e) { + try { + (homeState as dynamic).refresh(); + } catch (_) {} + } + } + } } // 미구현 페이지들을 위한 플레이스홀더 diff --git a/frontend/lib/screens/mypage/academy_management_page.dart b/frontend/lib/screens/mypage/academy_management_page.dart new file mode 100644 index 0000000..b51e973 --- /dev/null +++ b/frontend/lib/screens/mypage/academy_management_page.dart @@ -0,0 +1,429 @@ +import 'package:flutter/material.dart'; +import '../../widgets/back_button.dart'; +import '../../services/academy_service.dart'; +import '../../services/auth_service.dart'; + +/// 학원 관리 페이지 +/// 등록된 학원 관리, 학원 추가/삭제, 학원 전환 등을 관리하는 페이지 +class AcademyManagementPage extends StatefulWidget { + const AcademyManagementPage({super.key}); + + @override + State createState() => _AcademyManagementPageState(); +} + +class _AcademyManagementPageState extends State { + final AcademyService _academyService = AcademyService(); + final AuthService _authService = AuthService(); + + List _academies = []; + bool _isLoading = true; + String? _errorMessage; + String? _defaultAcademyCode; + + @override + void initState() { + super.initState(); + _loadAcademies(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Column( + children: [ + _buildHeader(), + Expanded(child: _buildBody()), + ], + ), + ), + ); + } + + Widget _buildBody() { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (_errorMessage != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _errorMessage!, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 16, + color: Color(0xFF333333), + ), + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadAcademies, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFAC5BF8), + foregroundColor: Colors.white, + ), + child: const Text('다시 시도'), + ), + ], + ), + ), + ); + } + if (_academies.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + '등록된 학원이 없습니다.', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 18, + color: Color(0xFF333333), + ), + ), + const SizedBox(height: 12), + const Text( + '학원을 등록하면 숙제와 학습 정보를 확인할 수 있습니다.', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + fontSize: 14, + color: Color(0xFF666666), + ), + ), + const SizedBox(height: 24), + _buildAddAcademyButton(fullWidth: true), + ], + ), + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 20), + const Text( + '등록된 학원', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 18, + color: Color(0xFF333333), + ), + ), + const SizedBox(height: 12), + ..._academies.map((academy) => _buildAcademyCard(academy)), + const SizedBox(height: 24), + _buildAddAcademyButton(), + const SizedBox(height: 20), + ], + ), + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.fromLTRB(20, 17, 20, 17), + decoration: const BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Color(0x0D000000), + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + child: Row( + children: const [ + CustomBackButton(), + SizedBox(width: 20), + Text( + '학원 관리', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 20, + color: Color(0xFF333333), + ), + ), + ], + ), + ); + } + + Widget _buildAcademyCard(UserAcademyResponse academy) { + final isActive = + academy.academyCode != null && + academy.academyCode == _defaultAcademyCode; + final isPending = academy.registerStatus != 'Y'; + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all( + color: isActive ? const Color(0xFFAC5BF8) : const Color(0xFFE9ECEF), + width: isActive ? 2 : 1, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + academy.academyName, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 18, + color: Color(0xFF333333), + ), + ), + ), + if (isActive) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + decoration: BoxDecoration( + color: const Color(0xFFAC5BF8), + borderRadius: BorderRadius.circular(12), + ), + child: const Text( + '활성', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 12, + color: Colors.white, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + const Icon( + Icons.location_on_outlined, + size: 16, + color: Color(0xFF999999), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + academy.academyRoadAddress.isNotEmpty + ? academy.academyRoadAddress + : '주소 정보 없음', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + fontSize: 14, + color: Color(0xFF666666), + ), + ), + ), + ], + ), + const SizedBox(height: 4), + const SizedBox(height: 12), + Row( + children: [ + if (isPending) + Expanded( + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 8, + ), + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0xFFFFF4E5), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + '등록 승인 대기중', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 13, + color: Color(0xFFFF9800), + ), + ), + ), + ) + else ...[ + Expanded( + child: OutlinedButton( + onPressed: isActive + ? null + : () => _setDefaultAcademy(academy.academyCode), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFFAC5BF8), + side: const BorderSide(color: Color(0xFFAC5BF8)), + ), + child: Text(isActive ? '활성화됨' : '활성화'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton( + onPressed: () => _showDeleteDialog(academy), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFFF44336), + side: const BorderSide(color: Color(0xFFF44336)), + ), + child: const Text('삭제'), + ), + ), + ], + ], + ), + ], + ), + ); + } + + Widget _buildAddAcademyButton({bool fullWidth = false}) { + final button = OutlinedButton.icon( + onPressed: () => Navigator.pushNamed(context, '/academy/list'), + icon: const Icon(Icons.add), + label: const Text('학원 추가하기'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + foregroundColor: const Color(0xFFAC5BF8), + side: const BorderSide(color: Color(0xFFAC5BF8)), + ), + ); + return SizedBox( + width: double.infinity, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: fullWidth ? 0 : 0), + child: button, + ), + ); + } + + void _showDeleteDialog(UserAcademyResponse academy) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('학원 삭제'), + content: Text('${academy.academyName}을(를) 삭제하시겠습니까?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('취소'), + ), + TextButton( + onPressed: () { + _removeAcademy(academy); + Navigator.pop(context); + }, + style: TextButton.styleFrom( + foregroundColor: const Color(0xFFF44336), + ), + child: const Text('삭제'), + ), + ], + ), + ); + } + + Future _loadAcademies() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + _defaultAcademyCode = await _academyService.getDefaultAcademyCode(); + + final cached = await _academyService.loadAcademiesFromCache(); + if (cached != null) { + setState(() { + _academies = cached; + }); + } + + final userId = await _authService.getUserId(); + if (userId == null) { + throw Exception('사용자 정보를 확인할 수 없습니다.'); + } + + final academies = await _academyService.getUserAcademies(userId); + await _academyService.saveAcademiesToCache(academies); + setState(() { + _academies = academies; + _isLoading = false; + }); + } catch (e) { + setState(() { + _isLoading = false; + _errorMessage = '학원 정보를 불러오지 못했습니다.\n$e'; + }); + } + } + + Future _setDefaultAcademy(String? academyCode) async { + if (academyCode == null) return; + await _academyService.saveDefaultAcademyCode(academyCode); + setState(() { + _defaultAcademyCode = academyCode; + }); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('활성 학원이 변경되었습니다'))); + } + + Future _removeAcademy(UserAcademyResponse academy) async { + final academyUserId = academy.academy_user_id; + if (academyUserId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('학원 정보를 확인할 수 없습니다.')), + ); + return; + } + + setState(() { + _isLoading = true; + }); + + try { + await _academyService.leaveAcademy(academyUserId); + await _loadAcademies(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('탈퇴 요청이 처리 중입니다.')), + ); + } catch (e) { + if (!mounted) return; + setState(() { + _isLoading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('학원 탈퇴에 실패했습니다.\n$e')), + ); + } + } +} diff --git a/frontend/lib/screens/mypage/account_management_page.dart b/frontend/lib/screens/mypage/account_management_page.dart new file mode 100644 index 0000000..757c1c7 --- /dev/null +++ b/frontend/lib/screens/mypage/account_management_page.dart @@ -0,0 +1,449 @@ +import 'package:flutter/material.dart'; +import '../../routes/app_routes.dart'; +import '../../services/auth_service.dart'; +import '../../services/user_service.dart'; +import '../../models/user.dart'; +import '../../widgets/back_button.dart'; + +/// 계정 관리 페이지 +/// 개인정보 수정, 비밀번호 변경, 계정 설정 등을 관리하는 페이지 +class AccountManagementPage extends StatefulWidget { + const AccountManagementPage({super.key}); + + @override + State createState() => _AccountManagementPageState(); +} + +class _AccountManagementPageState extends State { + final UserService _userService = UserService(); + bool _isLoadingProfile = true; + bool _isLoggingOut = false; + String? _email; + String? _phoneNumber; + String? _birthDate; + + @override + void initState() { + super.initState(); + _userService.addListener(_handleUserUpdated); + _loadUserProfile(); + } + + @override + void dispose() { + _userService.removeListener(_handleUserUpdated); + super.dispose(); + } + + Future _loadUserProfile() async { + final cachedUser = _userService.getUser(); + if (cachedUser != null) { + setState(() { + _applyUserData(cachedUser); + _isLoadingProfile = false; + }); + } else { + setState(() { + _isLoadingProfile = true; + }); + } + + final fetchedUser = await _userService.fetchUserFromServer(); + if (!mounted) return; + + if (fetchedUser != null) { + setState(() { + _applyUserData(fetchedUser); + }); + } else if (cachedUser == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('사용자 정보를 불러오지 못했습니다. 잠시 후 다시 시도해주세요.')), + ); + } + + setState(() { + _isLoadingProfile = false; + }); + } + + void _handleUserUpdated(User? user) { + if (!mounted || user == null) return; + setState(() { + _applyUserData(user); + }); + } + + void _applyUserData(User user) { + _email = user.email; + _phoneNumber = user.phoneNumber; + _birthDate = user.birthDate; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Column( + children: [ + _buildHeader(), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 20), + + // 개인정보 섹션 + _buildSectionTitle('개인정보'), + const SizedBox(height: 12), + _buildInfoItem( + '이메일', + _isLoadingProfile ? null : _formatEmail(_email), + Icons.email_outlined, + ), + _buildInfoItem( + '전화번호', + _isLoadingProfile + ? null + : _formatPhoneNumber(_phoneNumber), + Icons.phone_outlined, + ), + _buildInfoItem( + '생년월일', + _isLoadingProfile ? null : _formatBirthDate(_birthDate), + Icons.cake_outlined, + ), + + const SizedBox(height: 32), + + // 보안 섹션 + _buildSectionTitle('보안'), + const SizedBox(height: 12), + _buildActionItem('비밀번호 변경', Icons.lock_outlined, () { + // TODO: 비밀번호 변경 페이지로 이동 + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('비밀번호 변경 기능 구현 예정')), + ); + }), + _buildActionItem('2단계 인증', Icons.security_outlined, () { + // TODO: 2단계 인증 설정 페이지로 이동 + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('2단계 인증 기능 구현 예정')), + ); + }), + + const SizedBox(height: 32), + + // 계정 작업 + _buildSectionTitle('계정 작업'), + const SizedBox(height: 12), + _buildActionItem( + '로그아웃', + Icons.logout_outlined, + () { + _showLogoutDialog(); + }, + isDestructive: false, + isBusy: _isLoggingOut, + ), + _buildActionItem('회원 탈퇴', Icons.person_remove_outlined, () { + _showDeleteAccountDialog(); + }, isDestructive: true), + + const SizedBox(height: 20), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.fromLTRB(20, 17, 20, 17), + decoration: const BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Color(0x0D000000), + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + child: Row( + children: const [ + CustomBackButton(), + SizedBox(width: 20), + Text( + '계정 관리', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 20, + color: Color(0xFF333333), + ), + ), + ], + ), + ); + } + + Widget _buildSectionTitle(String title) { + return Text( + title, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 18, + color: Color(0xFF333333), + ), + ); + } + + Widget _buildInfoItem(String label, String? value, IconData icon) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE9ECEF)), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Tooltip( + message: '$label 아이콘', + child: Semantics( + label: '$label 아이콘', + child: Icon(icon, size: 24, color: const Color(0xFF666666)), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 12, + color: Color(0xFF999999), + ), + ), + const SizedBox(height: 4), + Text( + value ?? '불러오는 중...', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 16, + color: value == null + ? const Color(0xFF999999) + : const Color(0xFF333333), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildActionItem( + String title, + IconData icon, + VoidCallback onTap, { + bool isDestructive = false, + bool isBusy = false, + }) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + child: Material( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: isBusy ? null : onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all( + color: isDestructive + ? const Color(0xFFF44336) + : const Color(0xFFE9ECEF), + ), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + icon, + size: 24, + color: isDestructive + ? const Color(0xFFF44336) + : const Color(0xFF666666), + ), + const SizedBox(width: 16), + Expanded( + child: Text( + title, + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 16, + color: isDestructive + ? const Color(0xFFF44336) + : const Color(0xFF333333), + ), + ), + ), + if (isBusy) + const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Color(0xFF999999)), + ), + ) + else + Icon( + Icons.chevron_right, + size: 24, + color: isDestructive + ? const Color(0xFFF44336) + : const Color(0xFF999999), + ), + ], + ), + ), + ), + ), + ); + } + + void _showLogoutDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('로그아웃'), + content: const Text('정말 로그아웃 하시겠습니까?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('취소'), + ), + TextButton( + onPressed: _isLoggingOut + ? null + : () { + Navigator.pop(context); + _performLogout(); + }, + child: const Text('로그아웃'), + ), + ], + ), + ); + } + + Future _performLogout() async { + if (_isLoggingOut) return; + setState(() { + _isLoggingOut = true; + }); + + final messenger = ScaffoldMessenger.of(context); + try { + final authService = AuthService(); + await authService.signOutFromServer(); + await authService.clearTokens(); + if (!mounted) return; + Navigator.pushNamedAndRemoveUntil( + context, + AppRoutes.login, + (route) => false, + ); + } catch (e) { + if (!mounted) return; + messenger.showSnackBar(SnackBar(content: Text('로그아웃 중 오류가 발생했습니다: $e'))); + } finally { + if (mounted) { + setState(() { + _isLoggingOut = false; + }); + } + } + } + + void _showDeleteAccountDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('회원 탈퇴'), + content: const Text('정말 탈퇴하시겠습니까?\n모든 데이터가 삭제되며 복구할 수 없습니다.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('취소'), + ), + TextButton( + onPressed: () { + // TODO: 회원 탈퇴 로직 구현 + Navigator.pop(context); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('회원 탈퇴 기능 구현 예정'))); + }, + style: TextButton.styleFrom( + foregroundColor: const Color(0xFFF44336), + ), + child: const Text('탈퇴'), + ), + ], + ), + ); + } + + String _formatEmail(String? rawEmail) { + if (rawEmail == null || rawEmail.trim().isEmpty) { + return '미등록'; + } + return rawEmail.trim().toLowerCase(); + } + + String _formatPhoneNumber(String? rawNumber) { + if (rawNumber == null || rawNumber.trim().isEmpty) { + return '미등록'; + } + final digits = rawNumber.replaceAll(RegExp(r'\D'), ''); + if (digits.length == 11) { + return '${digits.substring(0, 3)}-${digits.substring(3, 7)}-${digits.substring(7)}'; + } + return rawNumber; + } + + String _formatBirthDate(String? rawDate) { + if (rawDate == null || rawDate.trim().isEmpty) { + return '미등록'; + } + try { + final date = DateTime.parse(rawDate); + return '${date.year.toString().padLeft(4, '0')}.' + '${date.month.toString().padLeft(2, '0')}.' + '${date.day.toString().padLeft(2, '0')}'; + } catch (_) { + return rawDate; + } + } +} diff --git a/frontend/lib/screens/mypage/display_settings_page.dart b/frontend/lib/screens/mypage/display_settings_page.dart new file mode 100644 index 0000000..95b41b2 --- /dev/null +++ b/frontend/lib/screens/mypage/display_settings_page.dart @@ -0,0 +1,224 @@ +import 'package:flutter/material.dart'; +import '../../widgets/back_button.dart'; + +/// 화면 설정 페이지 +/// 테마, 폰트 크기, 화면 밝기 등을 설정하는 페이지 +class DisplaySettingsPage extends StatefulWidget { + const DisplaySettingsPage({super.key}); + + @override + State createState() => _DisplaySettingsPageState(); +} + +class _DisplaySettingsPageState extends State { + // TODO: 설정 값들을 SharedPreferences에 저장 + bool _darkMode = false; + double _fontSize = 1.0; // 기본 크기 + bool _autoBrightness = true; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Column( + children: [ + _buildHeader(), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 20), + + // 다크 모드 + _buildSwitchSetting( + title: '다크 모드', + subtitle: '어두운 테마 사용', + value: _darkMode, + onChanged: (value) { + setState(() { + _darkMode = value; + }); + // TODO: 테마 변경 적용 + }, + ), + + const SizedBox(height: 16), + + // 폰트 크기 + _buildFontSizeSetting(), + + const SizedBox(height: 16), + + // 자동 밝기 + _buildSwitchSetting( + title: '자동 밝기', + subtitle: '주변 환경에 따라 화면 밝기 자동 조절', + value: _autoBrightness, + onChanged: (value) { + setState(() { + _autoBrightness = value; + }); + // TODO: 밝기 설정 적용 + }, + ), + + const SizedBox(height: 20), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.fromLTRB(20, 17, 20, 17), + decoration: const BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Color(0x0D000000), + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + child: Row( + children: const [ + CustomBackButton(), + SizedBox(width: 20), + Text( + '화면', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 20, + color: Color(0xFF333333), + ), + ), + ], + ), + ); + } + + Widget _buildSwitchSetting({ + required String title, + required String subtitle, + required bool value, + required ValueChanged onChanged, + }) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE9ECEF)), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 16, + color: Color(0xFF333333), + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + fontSize: 12, + color: Color(0xFF999999), + ), + ), + ], + ), + ), + Switch( + value: value, + onChanged: onChanged, + activeColor: const Color(0xFFAC5BF8), + ), + ], + ), + ); + } + + Widget _buildFontSizeSetting() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE9ECEF)), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '폰트 크기', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 16, + color: Color(0xFF333333), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + const Text( + '작게', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + fontSize: 12, + color: Color(0xFF999999), + ), + ), + Expanded( + child: Slider( + value: _fontSize, + min: 0.8, + max: 1.2, + divisions: 4, + activeColor: const Color(0xFFAC5BF8), + onChanged: (value) { + setState(() { + _fontSize = value; + }); + // TODO: 폰트 크기 변경 적용 + }, + ), + ), + const Text( + '크게', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + fontSize: 12, + color: Color(0xFF999999), + ), + ), + ], + ), + ], + ), + ); + } +} + diff --git a/frontend/lib/screens/mypage/homework_status_page.dart b/frontend/lib/screens/mypage/homework_status_page.dart new file mode 100644 index 0000000..15d5131 --- /dev/null +++ b/frontend/lib/screens/mypage/homework_status_page.dart @@ -0,0 +1,475 @@ +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import '../../services/assessment_repository.dart'; +import '../../services/academy_service.dart'; +import '../../services/auth_service.dart'; +import '../../utils/academy_utils.dart'; +import '../../routes/app_routes.dart'; +import '../../widgets/back_button.dart'; +import '../../widgets/empty_state_message.dart'; + +enum HomeworkStatusViewState { + loading, + normal, // 숙제 목록 정상 표시 + noAcademy, // 학원이 하나도 없음 + error, // 기타 에러 +} + +/// 숙제 현황 페이지 +/// 할당된 숙제 목록과 제출 현황을 보여주는 페이지 +class HomeworkStatusPage extends StatefulWidget { + const HomeworkStatusPage({super.key}); + + @override + State createState() => _HomeworkStatusPageState(); +} + +class _HomeworkStatusPageState extends State { + final getIt = GetIt.instance; + + late final AssessmentRepository _assessmentRepository = + getIt(); + late final AcademyService _academyService = getIt(); + late final AuthService _authService = getIt(); + + HomeworkStatusViewState _viewState = HomeworkStatusViewState.loading; + String? _errorMessage; + List _homeworks = []; + + // Invariants: + // - when _viewState == HomeworkStatusViewState.error, _errorMessage != null (best-effort) + // - when _viewState == HomeworkStatusViewState.noAcademy, _homeworks is always empty + + @override + Widget build(BuildContext context) { + final pendingHomeworks = _homeworks.where((h) => !h.isCompleted).toList(); + final completedHomeworks = _homeworks.where((h) => h.isCompleted).toList(); + + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Column( + children: [ + _buildHeader(), + Expanded( + child: _buildBody( + pendingHomeworks: pendingHomeworks, + completedHomeworks: completedHomeworks, + ), + ), + ], + ), + ), + ); + } + + Widget _buildBody({ + required List pendingHomeworks, + required List completedHomeworks, + }) { + if (_viewState == HomeworkStatusViewState.loading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_viewState == HomeworkStatusViewState.error) { + return Padding( + padding: const EdgeInsets.all(20), + child: Center( + child: EmptyStateMessage( + icon: Icons.error_outline, + title: '숙제 정보를 불러오지 못했습니다.', + description: _errorMessage, + primaryActionLabel: '다시 시도', + onPrimaryAction: _loadAssessments, + ), + ), + ); + } + + if (_viewState == HomeworkStatusViewState.noAcademy) { + return const Center( + child: EmptyStateMessage.academy( + title: '등록된 학원이 없어요.', + description: '마이페이지에서 학원을 먼저 등록해주세요.', + ), + ); + } + + // 여기까지 왔으면 normal 상태 + if (_homeworks.isEmpty) { + return const Center( + child: EmptyStateMessage.homework( + title: '등록된 숙제가 없어요.', + ), + ); + } + + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 20), + + // 요약 카드 + _buildSummaryCard(pendingHomeworks.length), + + const SizedBox(height: 24), + + // 미완료 숙제 + if (pendingHomeworks.isNotEmpty) ...[ + const Text( + '미완료 숙제', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 18, + color: Color(0xFF333333), + ), + ), + const SizedBox(height: 12), + ...pendingHomeworks.map((hw) => _buildHomeworkCard(hw)), + const SizedBox(height: 24), + ], + + // 완료된 숙제 + if (completedHomeworks.isNotEmpty) ...[ + const Text( + '완료된 숙제', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 18, + color: Color(0xFF333333), + ), + ), + const SizedBox(height: 12), + ...completedHomeworks.map((hw) => _buildHomeworkCard(hw)), + ], + + const SizedBox(height: 20), + ], + ), + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.fromLTRB(20, 17, 20, 17), + decoration: const BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Color(0x0D000000), + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + child: Row( + children: const [ + CustomBackButton(), + SizedBox(width: 20), + Text( + '숙제 현황', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 20, + color: Color(0xFF333333), + ), + ), + ], + ), + ); + } + + Widget _buildSummaryCard(int pendingCount) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFAC5BF8), Color(0xFF7C3AED)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Icon(Icons.assignment_outlined, size: 40, color: Colors.white), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '미완료 숙제', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Colors.white, + ), + ), + const SizedBox(height: 4), + Text( + '$pendingCount개', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 32, + color: Colors.white, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildHomeworkCard(HomeworkItem homework) { + final daysLeft = homework.dueDate.difference(DateTime.now()).inDays; + final isOverdue = daysLeft < 0; + final isDueSoon = daysLeft >= 0 && daysLeft <= 2; + + return GestureDetector( + onTap: () => _navigateToHomeDate(homework.dueDate), + child: Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: homework.isCompleted ? const Color(0xFFF8F9FA) : Colors.white, + border: Border.all( + color: homework.isCompleted + ? const Color(0xFFE9ECEF) + : (isOverdue + ? const Color(0xFFF44336) + : (isDueSoon + ? const Color(0xFFFF9800) + : const Color(0xFFE9ECEF))), + ), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + homework.title, + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 16, + color: homework.isCompleted + ? const Color(0xFF999999) + : const Color(0xFF333333), + decoration: homework.isCompleted + ? TextDecoration.lineThrough + : null, + ), + ), + ), + if (homework.isCompleted) + const Icon( + Icons.check_circle, + color: Color(0xFF4CAF50), + size: 24, + ), + ], + ), + const SizedBox(height: 8), + Text( + homework.assignedBy, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + fontSize: 12, + color: Color(0xFF666666), + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.schedule, + size: 14, + color: isOverdue + ? const Color(0xFFF44336) + : const Color(0xFF999999), + ), + const SizedBox(width: 4), + Text( + '마감: ${_formatDate(homework.dueDate)}', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + fontSize: 12, + color: isOverdue + ? const Color(0xFFF44336) + : const Color(0xFF999999), + ), + ), + if (!homework.isCompleted && isDueSoon) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: const Color(0xFFFF9800), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + '곧 마감', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 10, + color: Colors.white, + ), + ), + ), + ], + ], + ), + ], + ), + ), + ); + } + + void _navigateToHomeDate(DateTime dueDate) { + final targetDate = DateTime(dueDate.year, dueDate.month, dueDate.day); + Navigator.pushNamedAndRemoveUntil( + context, + AppRoutes.mainNavigation, + (route) => false, + arguments: {'targetDate': _formatRouteDate(targetDate)}, + ); + } + + String _formatRouteDate(DateTime date) { + final mm = date.month.toString().padLeft(2, '0'); + final dd = date.day.toString().padLeft(2, '0'); + return '${date.year}-$mm-$dd'; + } + + String _formatDate(DateTime date) { + return '${date.year}.${date.month.toString().padLeft(2, '0')}.${date.day.toString().padLeft(2, '0')}'; + } + + @override + void initState() { + super.initState(); + _loadAssessments(); + } + + Future _loadAssessments() async { + setState(() { + _viewState = HomeworkStatusViewState.loading; + _errorMessage = null; + }); + + try { + final defaultAcademyCode = await _academyService.getDefaultAcademyCode(); + if (defaultAcademyCode == null) { + throw Exception('디폴트 학원을 찾을 수 없습니다.'); + } + + String? userAcademyId = await getUserAcademyId( + academyService: _academyService, + academyCode: defaultAcademyCode, + ); + if (userAcademyId == null) { + final userId = await _authService.getUserId(); + if (userId != null) { + try { + final academies = await _academyService.getUserAcademies(userId); + if (academies.isNotEmpty) { + await _academyService.saveAcademiesToCache(academies); + userAcademyId = await getUserAcademyId( + academyService: _academyService, + academyCode: defaultAcademyCode, + registeredAcademies: academies + .where((academy) => academy.registerStatus == 'Y') + .toList(), + ); + } + } catch (e) { + print('[HomeworkStatusPage] 학원 목록 갱신 실패: $e'); + } + } + } + if (userAcademyId == null) { + // 등록된 학원이 전혀 없는 경우 + setState(() { + _viewState = HomeworkStatusViewState.noAcademy; + _homeworks = []; + }); + return; + } + + final now = DateTime.now(); + final monthStart = DateTime.utc(now.year, now.month, 1); + print( + '[HomeworkStatusPage] 숙제 데이터 조회 시작 | 학원 코드: $defaultAcademyCode, 학원 사용자 ID: $userAcademyId, 기준 월: $monthStart', + ); + final data = await _assessmentRepository.getForMonth( + academyId: userAcademyId, + dateTime: monthStart, + ); + print('[HomeworkStatusPage] 레포지토리에서 ${data.length}개의 날짜 데이터를 수신'); + + final mapped = []; + data.forEach((dateString, assessments) { + final parsedDate = DateTime.tryParse(dateString); + final dueDate = parsedDate ?? DateTime.now(); + + for (final assessment in assessments) { + mapped.add( + HomeworkItem( + title: assessment.assessName, + dueDate: dueDate, + isCompleted: assessment.assessStatus.toUpperCase() == 'Y', + assignedBy: assessment.assessClass.isNotEmpty + ? assessment.assessClass + : '담당 선생님 미지정', + ), + ); + } + }); + mapped.sort((a, b) => a.dueDate.compareTo(b.dueDate)); + + setState(() { + _homeworks = mapped; + _viewState = HomeworkStatusViewState.normal; + }); + } catch (e) { + print('[HomeworkStatusPage] 숙제 데이터를 불러오지 못했습니다: $e'); + setState(() { + _errorMessage = e.toString(); + _viewState = HomeworkStatusViewState.error; + }); + } + } +} + +class HomeworkItem { + final String title; + final DateTime dueDate; + final bool isCompleted; + final String assignedBy; + + HomeworkItem({ + required this.title, + required this.dueDate, + required this.isCompleted, + required this.assignedBy, + }); +} diff --git a/frontend/lib/screens/mypage/learning_statistics_page.dart b/frontend/lib/screens/mypage/learning_statistics_page.dart new file mode 100644 index 0000000..f616a47 --- /dev/null +++ b/frontend/lib/screens/mypage/learning_statistics_page.dart @@ -0,0 +1,281 @@ +import 'package:flutter/material.dart'; +import '../../widgets/back_button.dart'; + +/// 학습 통계/학습 리포트 페이지 +/// 상세한 학습 분석, 강점/약점 분석, 과목별 통계를 제공하는 페이지 +class LearningStatisticsPage extends StatefulWidget { + const LearningStatisticsPage({super.key}); + + @override + State createState() => + _LearningStatisticsPageState(); +} + +class _LearningStatisticsPageState extends State { + String _selectedPeriod = '이번 주'; + + // TODO: 서버에서 통계 데이터 가져오기 + final Map _subjectProgress = { + '수학': 75, + '영어': 85, + '과학': 60, + '국어': 70, + }; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Column( + children: [ + _buildHeader(), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 20), + + // 기간 선택 + _buildPeriodSelector(), + + const SizedBox(height: 24), + + // 전체 학습 현황 카드 + _buildOverallStatusCard(), + + const SizedBox(height: 24), + + // 과목별 통계 + _buildSubjectStatistics(), + + const SizedBox(height: 24), + + // TODO: 강점/약점 분석 + _buildStrengthWeaknessCard(), + + const SizedBox(height: 20), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.fromLTRB(20, 17, 20, 17), + decoration: const BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Color(0x0D000000), + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + child: Row( + children: const [ + CustomBackButton(), + SizedBox(width: 20), + Text( + '학습 통계', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 20, + color: Color(0xFF333333), + ), + ), + ], + ), + ); + } + + Widget _buildPeriodSelector() { + final periods = ['이번 주', '이번 달', '전체']; + return Row( + children: periods.map((period) { + final isSelected = _selectedPeriod == period; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(period), + selected: isSelected, + onSelected: (selected) { + setState(() { + _selectedPeriod = period; + }); + // TODO: 기간별 데이터 조회 + }, + selectedColor: const Color(0xFFAC5BF8), + labelStyle: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 14, + color: isSelected ? Colors.white : const Color(0xFF666666), + ), + ), + ); + }).toList(), + ); + } + + Widget _buildOverallStatusCard() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFAC5BF8), Color(0xFF7C3AED)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildStatItem('학습 시간', '15시간'), + _buildStatItem('푼 문제', '127개'), + _buildStatItem('정답률', '82%'), + ], + ), + ], + ), + ); + } + + Widget _buildStatItem(String label, String value) { + return Column( + children: [ + Text( + value, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 24, + color: Colors.white, + ), + ), + const SizedBox(height: 4), + Text( + label, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 12, + color: Colors.white, + ), + ), + ], + ); + } + + Widget _buildSubjectStatistics() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '과목별 진행률', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 18, + color: Color(0xFF333333), + ), + ), + const SizedBox(height: 12), + ..._subjectProgress.entries.map((entry) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE9ECEF)), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + entry.key, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 16, + color: Color(0xFF333333), + ), + ), + Text( + '${entry.value}%', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 16, + color: Color(0xFFAC5BF8), + ), + ), + ], + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Container( + height: 8, + decoration: const BoxDecoration(color: Color(0xFFE9ECEF)), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: entry.value / 100, + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xFFAC5BF8), Color(0xFF7C3AED)], + ), + ), + ), + ), + ), + ), + ], + ), + ); + }), + ], + ); + } + + Widget _buildStrengthWeaknessCard() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE9ECEF)), + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: Text( + '강점/약점 분석\n(추후 구현 예정)', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF999999), + ), + ), + ), + ); + } +} + diff --git a/frontend/lib/screens/mypage/mypage.dart b/frontend/lib/screens/mypage/mypage.dart new file mode 100644 index 0000000..0e3fa8d --- /dev/null +++ b/frontend/lib/screens/mypage/mypage.dart @@ -0,0 +1,324 @@ +import 'package:flutter/material.dart'; +import '../../widgets/app_header.dart'; +import '../../widgets/app_header_title.dart'; +import '../../widgets/app_header_menu_button.dart'; +import '../../services/user_service.dart'; +import '../../models/user.dart'; + +/// 마이페이지 +/// 사용자 프로필, 학습 현황, 각종 설정 메뉴를 제공하는 페이지 +/// +/// 구성: +/// 1. 헤더: 마이페이지 타이틀 + 햄버거 메뉴 +/// 2. 프로필 섹션: 프로필 이미지 + 이름 + 편집 버튼 +/// 3. 학습 현황 카드 (추후 구현) +/// 4. 설정 메뉴 목록 +class MyPage extends StatefulWidget { + const MyPage({super.key}); + + @override + State createState() => _MyPageState(); +} + +class _MyPageState extends State { + final UserService _userService = UserService(); + + // UserService에서 사용자 정보 가져오기 + String _userName = '게스트'; + String? _profileImageUrl; + + @override + void initState() { + super.initState(); + _loadUserData(); + + // 리스너 등록 (프로필 수정 시 자동 업데이트) + _userService.addListener(_onUserChanged); + } + + @override + void dispose() { + // 리스너 제거 + _userService.removeListener(_onUserChanged); + super.dispose(); + } + + /// UserService에서 사용자 정보 로드 + /// 1. 캐시에서 먼저 로드 (빠른 UI 표시) + /// 2. 서버에서 최신 정보 가져오기 + Future _loadUserData() async { + // 1. 캐시에서 먼저 로드 + final cachedUser = _userService.getUser(); + if (cachedUser != null) { + setState(() { + _userName = cachedUser.name; + _profileImageUrl = cachedUser.profileImageUrl; + }); + } + + // 2. 서버에서 최신 정보 가져오기 + final fetchedUser = await _userService.fetchUserFromServer(); + if (!mounted) return; + + if (fetchedUser != null) { + setState(() { + _userName = fetchedUser.name; + _profileImageUrl = fetchedUser.profileImageUrl; + }); + } + } + + /// 사용자 정보 변경 리스너 + void _onUserChanged(User? user) { + if (user != null && mounted) { + setState(() { + _userName = user.name; + _profileImageUrl = user.profileImageUrl; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Column( + children: [ + // 헤더 + _buildHeader(), + + // 메인 콘텐츠 + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 20), + + // 프로필 섹션 + _buildProfileSection(), + + const SizedBox(height: 24), + + // TODO: 학습 현황 카드 구현 + _buildLearningStatusCard(), + + const SizedBox(height: 24), + + // 설정 메뉴 목록 + _buildSettingsMenu(), + + const SizedBox(height: 20), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildHeader() { + return const AppHeader( + title: AppHeaderTitle('마이페이지', textAlign: TextAlign.center), + trailing: AppHeaderMenuButton(), + ); + } + + /// 프로필 섹션 + Widget _buildProfileSection() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE9ECEF)), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + // 프로필 이미지 + Container( + width: 60, + height: 60, + decoration: const BoxDecoration( + color: Color(0xFFE9ECEF), + shape: BoxShape.circle, + ), + child: _profileImageUrl != null + ? ClipOval( + child: Image.network( + _profileImageUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return const Icon( + Icons.person, + size: 32, + color: Color(0xFF999999), + ); + }, + ), + ) + : const Icon(Icons.person, size: 32, color: Color(0xFF999999)), + ), + const SizedBox(width: 16), + + // 이름 + Expanded( + child: Text( + _userName, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 18, + color: Color(0xFF333333), + ), + ), + ), + + // 편집 버튼 + IconButton( + icon: const Icon(Icons.edit_outlined, size: 24), + color: const Color(0xFF666666), + onPressed: () { + // TODO: 프로필 편집 페이지로 이동 + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('프로필 편집 기능 구현 예정'))); + }, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ); + } + + /// 학습 현황 카드 + /// TODO: 추후 구현 예정 + /// - 연속 학습 일수 + /// - 주간/월간 학습 통계 + /// - 최근 학습 문제집 + /// - 전체 진행률 + Widget _buildLearningStatusCard() { + return Container( + width: double.infinity, + height: 150, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE9ECEF)), + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: Text( + '학습 현황\n(추후 구현 예정)', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF999999), + ), + ), + ), + ); + } + + /// 설정 메뉴 목록 + Widget _buildSettingsMenu() { + final menuItems = [ + _MenuItem( + icon: Icons.smartphone, + title: '화면', + route: '/mypage/display-settings', + ), + _MenuItem( + icon: Icons.assignment_outlined, + title: '숙제 현황', + route: '/mypage/homework-status', + ), + _MenuItem( + icon: Icons.bar_chart_outlined, + title: '학습 통계/학습 리포트', + route: '/mypage/learning-statistics', + ), + _MenuItem( + icon: Icons.person_outline, + title: '계정 관리', + route: '/mypage/account-management', + ), + _MenuItem( + icon: Icons.school_outlined, + title: '학원 관리', + route: '/mypage/academy-management', + ), + _MenuItem( + icon: Icons.notifications_outlined, + title: '알림 설정', + route: '/mypage/notification-settings', + ), + ]; + + return Column( + children: menuItems.map((item) => _buildMenuItem(item)).toList(), + ); + } + + /// 개별 메뉴 아이템 + Widget _buildMenuItem(_MenuItem item) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + child: Material( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: () { + Navigator.pushNamed(context, item.route); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE9ECEF)), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(item.icon, size: 24, color: const Color(0xFF666666)), + const SizedBox(width: 16), + Expanded( + child: Text( + item.title, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 16, + color: Color(0xFF333333), + ), + ), + ), + const Icon( + Icons.chevron_right, + size: 24, + color: Color(0xFF999999), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// 메뉴 아이템 모델 +class _MenuItem { + final IconData icon; + final String title; + final String route; + + _MenuItem({required this.icon, required this.title, required this.route}); +} diff --git a/frontend/lib/screens/mypage/notification_settings_page.dart b/frontend/lib/screens/mypage/notification_settings_page.dart new file mode 100644 index 0000000..6689d77 --- /dev/null +++ b/frontend/lib/screens/mypage/notification_settings_page.dart @@ -0,0 +1,306 @@ +import 'package:flutter/material.dart'; +import '../../widgets/back_button.dart'; + +/// 알림 설정 페이지 +/// 푸시 알림, 숙제 마감 알림, 학습 리마인더 등을 설정하는 페이지 +class NotificationSettingsPage extends StatefulWidget { + const NotificationSettingsPage({super.key}); + + @override + State createState() => + _NotificationSettingsPageState(); +} + +class _NotificationSettingsPageState extends State { + // TODO: 설정 값들을 SharedPreferences에 저장 + bool _pushNotification = true; + bool _homeworkDeadline = true; + bool _learningReminder = false; + bool _academyNotice = true; + bool _marketingNotification = false; + + String _reminderTime = '20:00'; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Column( + children: [ + _buildHeader(), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 20), + + // 전체 알림 + _buildSectionTitle('전체 알림'), + const SizedBox(height: 12), + _buildSwitchSetting( + title: '푸시 알림', + subtitle: '모든 알림 수신 여부', + value: _pushNotification, + onChanged: (value) { + setState(() { + _pushNotification = value; + if (!value) { + // 푸시 알림 끄면 모든 알림 끄기 + _homeworkDeadline = false; + _learningReminder = false; + _academyNotice = false; + _marketingNotification = false; + } + }); + // TODO: 알림 설정 저장 + }, + ), + + const SizedBox(height: 24), + + // 학습 관련 알림 + _buildSectionTitle('학습 관련'), + const SizedBox(height: 12), + _buildSwitchSetting( + title: '숙제 마감 알림', + subtitle: '숙제 마감 1일 전, 당일에 알림', + value: _homeworkDeadline, + onChanged: _pushNotification + ? (value) { + setState(() { + _homeworkDeadline = value; + }); + // TODO: 알림 설정 저장 + } + : null, + ), + const SizedBox(height: 12), + _buildSwitchSetting( + title: '학습 리마인더', + subtitle: '매일 정해진 시간에 학습 알림', + value: _learningReminder, + onChanged: _pushNotification + ? (value) { + setState(() { + _learningReminder = value; + }); + // TODO: 알림 설정 저장 + } + : null, + ), + if (_learningReminder) ...[ + const SizedBox(height: 12), + _buildTimeSelector(), + ], + + const SizedBox(height: 24), + + // 학원 관련 알림 + _buildSectionTitle('학원 관련'), + const SizedBox(height: 12), + _buildSwitchSetting( + title: '학원 공지사항', + subtitle: '등록된 학원의 공지사항 알림', + value: _academyNotice, + onChanged: _pushNotification + ? (value) { + setState(() { + _academyNotice = value; + }); + // TODO: 알림 설정 저장 + } + : null, + ), + + const SizedBox(height: 24), + + // 기타 알림 + _buildSectionTitle('기타'), + const SizedBox(height: 12), + _buildSwitchSetting( + title: '마케팅 알림', + subtitle: '이벤트, 혜택 등의 마케팅 정보', + value: _marketingNotification, + onChanged: _pushNotification + ? (value) { + setState(() { + _marketingNotification = value; + }); + // TODO: 알림 설정 저장 + } + : null, + ), + + const SizedBox(height: 20), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.fromLTRB(20, 17, 20, 17), + decoration: const BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Color(0x0D000000), + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + child: Row( + children: const [ + CustomBackButton(), + SizedBox(width: 20), + Text( + '알림 설정', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 20, + color: Color(0xFF333333), + ), + ), + ], + ), + ); + } + + Widget _buildSectionTitle(String title) { + return Text( + title, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 18, + color: Color(0xFF333333), + ), + ); + } + + Widget _buildSwitchSetting({ + required String title, + required String subtitle, + required bool value, + required ValueChanged? onChanged, + }) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE9ECEF)), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 16, + color: onChanged == null + ? const Color(0xFF999999) + : const Color(0xFF333333), + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + fontSize: 12, + color: Color(0xFF999999), + ), + ), + ], + ), + ), + Switch( + value: value, + onChanged: onChanged, + activeColor: const Color(0xFFAC5BF8), + ), + ], + ), + ); + } + + Widget _buildTimeSelector() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE9ECEF)), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Text( + '알림 시간', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 16, + color: Color(0xFF333333), + ), + ), + const Spacer(), + TextButton( + onPressed: () async { + final TimeOfDay? picked = await showTimePicker( + context: context, + initialTime: TimeOfDay( + hour: int.parse(_reminderTime.split(':')[0]), + minute: int.parse(_reminderTime.split(':')[1]), + ), + ); + if (picked != null) { + setState(() { + _reminderTime = + '${picked.hour.toString().padLeft(2, '0')}:${picked.minute.toString().padLeft(2, '0')}'; + }); + // TODO: 알림 시간 저장 + } + }, + child: Row( + children: [ + Text( + _reminderTime, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 18, + color: Color(0xFFAC5BF8), + ), + ), + const SizedBox(width: 4), + const Icon( + Icons.access_time, + size: 20, + color: Color(0xFFAC5BF8), + ), + ], + ), + ), + ], + ), + ); + } +} + diff --git a/frontend/lib/screens/notification/notification_page.dart b/frontend/lib/screens/notification/notification_page.dart new file mode 100644 index 0000000..83f5d12 --- /dev/null +++ b/frontend/lib/screens/notification/notification_page.dart @@ -0,0 +1,495 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../widgets/app_header.dart'; +import '../../widgets/app_header_title.dart'; +import '../../widgets/app_header_menu_button.dart'; +import '../../domain/notification/notification_entity.dart'; +import '../../domain/notification/notification_type.dart'; +import '../../data/notification/notification_local_data_source.dart'; +import '../../data/notification/notification_repository_impl.dart'; +import '../../application/notification/notification_notifier.dart'; +import '../../routes/app_routes.dart'; +import '../workbook/chapter_detail_page.dart' show QuestionStatus; + +/// 알림 페이지 +/// 학습 관련 알림, 숙제 마감 알림, 학원 공지사항 등을 표시하는 페이지 +/// +/// 구성: +/// 1. 헤더: 알림 타이틀 + 전체 읽음 처리 버튼 +/// 2. 알림 목록: 타입별 아이콘, 내용, 시간, 읽음/안읽음 상태 +class NotificationPage extends StatefulWidget { + const NotificationPage({super.key}); + + @override + State createState() => _NotificationPageState(); +} + +class _NotificationPageState extends State { + NotificationNotifier? _notificationNotifier; + + @override + void initState() { + super.initState(); + _initializeNotifier(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // 페이지가 다시 표시될 때마다 알림 목록 새로고침 + if (_notificationNotifier != null) { + _notificationNotifier!.load(); + } + } + + Future _initializeNotifier() async { + final prefs = await SharedPreferences.getInstance(); + final local = NotificationLocalDataSource(prefs); + final repository = NotificationRepositoryImpl(local); + final notifier = NotificationNotifier(repository); + await notifier.load(); + if (mounted) { + setState(() { + _notificationNotifier = notifier; + }); + } + } + + /// 외부에서 호출 가능한 새로고침 메서드 + /// MainNavigationPage에서 탭 전환 시 호출 + void refresh() { + _notificationNotifier?.load(); + } + + @override + void dispose() { + _notificationNotifier?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_notificationNotifier == null) { + return const Scaffold( + backgroundColor: Colors.white, + body: SafeArea(child: Center(child: CircularProgressIndicator())), + ); + } + + final notifier = _notificationNotifier!; + + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: ValueListenableBuilder( + valueListenable: notifier.isLoading, + builder: (context, isLoading, _) { + if (isLoading && notifier.notifications.value.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + return ValueListenableBuilder>( + valueListenable: notifier.notifications, + builder: (context, notifications, _) { + final unreadCount = notifier.unreadCount; + + return Column( + children: [ + // 헤더 + _buildHeader(unreadCount), + + // 알림 목록 + Expanded( + child: notifications.isEmpty + ? _buildEmptyState() + : ListView.builder( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + itemCount: notifications.length, + itemBuilder: (context, index) { + return _buildNotificationCard( + notifications[index], + index, + ); + }, + ), + ), + ], + ); + }, + ); + }, + ), + ), + ); + } + + Widget _buildHeader(int unreadCount) { + return AppHeader( + title: Row( + children: [ + const AppHeaderTitle('알림'), + if (unreadCount > 0) ...[ + const SizedBox(width: 8), // ✅ Rule 1: spacing은 OK + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), // ✅ Rule 5: trailing comma + decoration: BoxDecoration( + color: const Color(0xFFF44336), + borderRadius: BorderRadius.circular(10), + ), // ✅ Rule 5: trailing comma + child: Text( + unreadCount.toString(), + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 12, + color: Colors.white, + ), // ✅ Rule 5: trailing comma + ), // ✅ Rule 5: trailing comma + ), + ], + const Spacer(), + ], + ), // ✅ Rule 5: trailing comma + trailing: unreadCount > 0 + ? TextButton( + onPressed: _markAllAsRead, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), // ✅ Rule 5: trailing comma + child: const Text( + '모두 읽음', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 14, + color: Color(0xFFAC5BF8), + ), // ✅ Rule 5: trailing comma + ), // ✅ Rule 5: trailing comma + ) + : const AppHeaderMenuButton(), + ); + } + + Widget _buildNotificationCard(NotificationEntity notification, int index) { + return Dismissible( + key: Key('notification_${notification.id}'), + direction: DismissDirection.endToStart, + onDismissed: (direction) async { + if (_notificationNotifier != null) { + await _notificationNotifier!.delete(notification.id); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('알림이 삭제되었습니다'))); + } + } + }, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + decoration: BoxDecoration( + color: const Color(0xFFF44336), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.delete_outline, color: Colors.white, size: 24), + ), + child: Container( + margin: const EdgeInsets.only(bottom: 12), + child: Material( + color: notification.isRead ? Colors.white : const Color(0xFFF8F9FA), + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: () { + _markAsRead(notification); + _handleNotificationTap(notification); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all( + color: notification.isRead + ? const Color(0xFFE9ECEF) + : const Color(0xFFAC5BF8), + width: notification.isRead ? 1 : 2, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 알림 타입 아이콘 + _buildNotificationIcon(notification.type), + const SizedBox(width: 12), + + // 알림 내용 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + notification.title, + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: notification.isRead + ? FontWeight.w600 + : FontWeight.w700, + fontSize: 16, + color: const Color(0xFF333333), + ), + ), + ), + if (!notification.isRead) + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: Color(0xFFAC5BF8), + shape: BoxShape.circle, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + notification.message, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + fontSize: 14, + color: Color(0xFF666666), + ), + ), + const SizedBox(height: 8), + Text( + _formatTime(notification.time), + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + fontSize: 12, + color: Color(0xFF999999), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildNotificationIcon(NotificationType type) { + IconData icon; + Color color; + + switch (type) { + case NotificationType.homework: + icon = Icons.assignment_outlined; + color = const Color(0xFFFF9800); + break; + case NotificationType.learningReminder: + icon = Icons.schedule; + color = const Color(0xFFAC5BF8); + break; + case NotificationType.academyNotice: + icon = Icons.school_outlined; + color = const Color(0xFF2196F3); + break; + case NotificationType.grading: + icon = Icons.check_circle_outline; + color = const Color(0xFF4CAF50); + break; + case NotificationType.achievement: + icon = Icons.emoji_events_outlined; + color = const Color(0xFFFFC107); + break; + } + + return Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: color.withAlpha(26), + shape: BoxShape.circle, + ), + child: Icon(icon, color: color, size: 24), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.notifications_none, size: 64, color: Colors.grey[400]), + const SizedBox(height: 16), + const Text( + '알림이 없습니다', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 18, + color: Color(0xFF666666), + ), + ), + const SizedBox(height: 8), + Text( + '새로운 알림이 도착하면 여기에 표시됩니다', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + fontSize: 14, + color: Colors.grey[600], + ), + ), + ], + ), + ); + } + + Future _markAsRead(NotificationEntity notification) async { + if (!notification.isRead && _notificationNotifier != null) { + await _notificationNotifier!.markAsRead(notification.id); + } + } + + Future _markAllAsRead() async { + if (_notificationNotifier != null) { + await _notificationNotifier!.markAllAsRead(); + } + } + + String _formatTime(DateTime time) { + final now = DateTime.now(); + final difference = now.difference(time); + + if (difference.inMinutes < 1) { + return '방금 전'; + } else if (difference.inMinutes < 60) { + return '${difference.inMinutes}분 전'; + } else if (difference.inHours < 24) { + return '${difference.inHours}시간 전'; + } else if (difference.inDays < 7) { + return '${difference.inDays}일 전'; + } else { + return '${time.year}.${time.month.toString().padLeft(2, '0')}.${time.day.toString().padLeft(2, '0')}'; + } + } + + void _handleNotificationTap(NotificationEntity notification) { + // 해설 생성 완료 알림인 경우, 해당 문제 상세 페이지로 이동 + if (notification.title == '해설 생성 완료' && notification.data != null) { + final data = notification.data!; + + try { + int? _toInt(dynamic value) { + if (value is int) return value; + if (value is String) return int.tryParse(value); + return null; + } + + bool? _toBool(dynamic value) { + if (value is bool) return value; + if (value is String) { + final lower = value.toLowerCase(); + if (lower == 'true') return true; + if (lower == 'false') return false; + } + return null; + } + + // 해설 생성 완료 알림 payload 예시: + // { + // "student_response_id": 1764510387709, + // "user_id": 1, + // "chapter_id": 201, + // "academy_user_id": 20, + // "book_id": 1, + // "question_number": 1, + // "sub_question_number": 0, + // "is_correct": false, + // "score": 1 + // } + + final studentResponseId = _toInt(data['student_response_id']); + final academyUserId = _toInt(data['academy_user_id']); + final questionNumber = _toInt(data['question_number']); + // sub_question_number, book_id, score 등은 현재 화면 이동에는 사용하지 않지만 + // payload 구조 검증 및 향후 확장을 위해 한 번 읽어둡니다. + _toInt(data['sub_question_number']); + _toInt(data['book_id']); + _toInt(data['score']); + final chapterIdFromPayload = _toInt(data['chapter_id']); + final isCorrect = _toBool(data['is_correct']); + + if (studentResponseId == null || + academyUserId == null || + questionNumber == null || + isCorrect == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('문제 정보가 부족하여 화면으로 이동할 수 없습니다.')), + ); + return; + } + + // 정답/오답 여부를 QuestionStatus로 매핑 + final status = isCorrect + ? QuestionStatus.correct + : QuestionStatus.incorrect; + + // 알림 body에서 클래스/문제집 이름 추출 (예: "[고급수학반] 수업의 [기초수학교재] 3번 문제 해설 생성 완료") + final body = notification.message; + final matches = RegExp(r'\[(.*?)\]').allMatches(body).toList(); + final academyName = matches.isNotEmpty + ? matches[0].group(1) ?? '학원' + : '학원'; + final workbookName = matches.length > 1 + ? matches[1].group(1) ?? '문제집' + : '문제집'; + + // chapterId는 payload에서 넘어온 값을 사용하되, + // 문제가 있을 경우에는 0으로 fallback 합니다. + final chapterId = chapterIdFromPayload ?? 0; + // chapterName은 헤더 표시용으로만 사용 + final chapterName = '$academyName 수업'; + + Navigator.pushNamed( + context, + AppRoutes.questionDetail, + arguments: { + 'chapterId': chapterId, + 'academyUserId': academyUserId, + 'workbookName': workbookName, + 'chapterName': chapterName, + 'questionNumber': questionNumber, + 'status': status, + 'studentResponseId': studentResponseId, + }, + ); + return; + } catch (_) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('알림 데이터 형식이 올바르지 않아 이동에 실패했습니다.')), + ); + return; + } + } + + // 그 외 알림은 현재 별도 동작 없음 (읽음 처리만 수행) + } +} diff --git a/frontend/lib/screens/problem_solution_temp_page.dart b/frontend/lib/screens/problem_solution_temp_page.dart new file mode 100644 index 0000000..88fa40f --- /dev/null +++ b/frontend/lib/screens/problem_solution_temp_page.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; + +/// 임시 문제 풀이 화면 +class ProblemSolutionTempPage extends StatelessWidget { + const ProblemSolutionTempPage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + title: const Text( + '문제 풀이', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + ), + ), + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF333333), + elevation: 0, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '1번 문제', + style: TextStyle( + fontFamily: 'Pretendard', + fontSize: 22, + fontWeight: FontWeight.w700, + color: Color(0xFF333333), + ), + ), + const SizedBox(height: 16), + ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Image.asset( + 'assets/images/temp/problem1.png', + fit: BoxFit.cover, + ), + ), + const SizedBox(height: 24), + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: const Color(0xFFF8F8FF), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE0E0F6)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '풀이', + style: TextStyle( + fontFamily: 'Pretendard', + fontSize: 18, + fontWeight: FontWeight.w700, + color: Color(0xFF6C63FF), + ), + ), + SizedBox(height: 12), + Text( + '지문에서는 운동이 상업화되며 돈·시간을 많이 쓰게 되고, 마라톤·극한 스포츠까지 하면서 "운동은 건강에 좋다"는 압박 때문에 오히려 불안과 혼란의 원인이 되었다고 말한다. ' + '‘be exercised about’는 ‘~때문에 불안해하다, 신경 쓰다’라는 뜻이므로, 건강을 위해 하는 운동이 오히려 스트레스를 준다는 아이러니를 말한 것이다. ' + '따라서 운동이 웰빙을 명분으로 오히려 스트레스를 유발한다는 ①이 정답이다.', + style: TextStyle( + fontFamily: 'Pretendard', + fontSize: 15, + height: 1.6, + color: Color(0xFF444444), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/frontend/lib/screens/upload/upload_images_page.dart b/frontend/lib/screens/upload/upload_images_page.dart new file mode 100644 index 0000000..34f97a8 --- /dev/null +++ b/frontend/lib/screens/upload/upload_images_page.dart @@ -0,0 +1,701 @@ +import 'dart:collection'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:http/http.dart' as http; +import 'package:image_picker/image_picker.dart'; + +import '../../services/upload_batch_service.dart'; +import '../../services/upload_sse_service.dart'; +import '../../services/academy_service.dart'; +import '../../services/auth_service.dart'; +import '../../widgets/app_header.dart'; +import '../../widgets/app_header_title.dart'; +import '../../widgets/app_header_menu_button.dart'; +import '../../routes/app_routes.dart'; +import '../../utils/app_logger.dart'; + +enum ProblemType { + newProblem, // 새로 풀기 + correctMistakes, // 오답 수정 +} + +enum UploadPageAcademyState { + checking, // 학원 정보 확인 중 + hasAcademy, + noAcademy, + error, // 학원 정보를 불러오지 못함 +} + +class UploadImagesPage extends StatefulWidget { + const UploadImagesPage({super.key}); + + @override + State createState() => _UploadImagesPageState(); +} + +class _UploadImagesPageState extends State { + List _selectedImages = []; + ProblemType? _selectedType; + final ImagePicker _picker = ImagePicker(); + final getIt = GetIt.instance; + + late final UploadSseService _sseService = getIt(); + late final AcademyService _academyService = getIt(); + late final AuthService _authService = getIt(); + final Queue _pendingUploads = Queue(); + + bool _isSseConnecting = false; + bool _isUploading = false; + String? _uploadError; + String? _uploadSuccessMessage; + int? _uploadedCount; + int? _totalCount; + UploadPageAcademyState _academyState = UploadPageAcademyState.checking; + + /// 메인 네비게이션에서 탭을 다시 선택했을 때 + /// 업로드 페이지 상태를 초기화하기 위한 메서드 + void refresh() { + setState(() { + _selectedImages = []; + _selectedType = null; + _pendingUploads.clear(); + _isUploading = false; + _uploadError = null; + _uploadSuccessMessage = null; + _uploadedCount = null; + _totalCount = null; + _academyState = UploadPageAcademyState.checking; + }); + + // 학원 정보 다시 확인 + _checkAcademyState(); + } + + @override + void initState() { + super.initState(); + _checkAcademyState(); + _connectSse(); + } + + @override + void dispose() { + _sseService.dispose(); + super.dispose(); + } + + Future _connectSse() async { + setState(() { + _isSseConnecting = true; + }); + try { + await _sseService.connect(); + _sseService.uploadUrlStream.listen(_onUploadUrlReceived); + } catch (e) { + debugPrint('SSE connect error: $e'); + setState(() { + _uploadError = '실시간 업로드 채널 연결에 실패했습니다. 다시 시도해주세요.'; + }); + } finally { + if (mounted) { + setState(() { + _isSseConnecting = false; + }); + } + } + } + + Future _onUploadUrlReceived(String url) async { + if (_pendingUploads.isEmpty) { + debugPrint('Upload URL received but no pending images. url=$url'); + return; + } + + final file = _pendingUploads.removeFirst(); + try { + final bytes = await file.readAsBytes(); + final response = await http.put( + Uri.parse(url), + headers: { + // 서버에서 별도 Content-Type 요구 시 확장자 기반으로 조정 가능 + 'Content-Type': 'image/jpeg', + }, + body: bytes, + ); + + debugPrint('Uploaded ${file.path} → ${response.statusCode}'); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('이미지 업로드 실패 (${response.statusCode})'); + } + } catch (e) { + setState(() { + _uploadError = '이미지 업로드 중 오류가 발생했습니다: $e'; + }); + debugPrint('Upload error: $e'); + } finally { + if (_pendingUploads.isEmpty && mounted) { + setState(() { + _isUploading = false; + }); + } + } + } + + Future _pickImages() async { + final List images = await _picker.pickMultiImage(); + if (!mounted) return; + + setState(() { + _selectedImages = images; + }); + } + + /// 문제 등록 성공 다이얼로그 표시 + Future _showSuccessDialog() async { + if (!mounted) return; + + final timestamp = DateTime.now().toIso8601String(); + appLog('[UploadImagesPage][timecheck][$timestamp] 문제 등록 완료 팝업 표시'); + + await showDialog( + context: context, + barrierDismissible: false, // 배경 탭으로 닫기 방지 + builder: (BuildContext dialogContext) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: const Text( + '문제 등록 완료', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 18, + color: Color(0xFF333333), + ), + textAlign: TextAlign.center, + ), + content: const Text( + '문제 등록이 완료되었습니다. 채점이 완료되면 알림으로 알려드릴게요!', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF666666), + height: 1.5, + ), + textAlign: TextAlign.center, + ), + actions: [ + Center( + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + Navigator.of(dialogContext).pop(); // 다이얼로그 닫기 + Navigator.of(context).pushNamedAndRemoveUntil( + AppRoutes.mainNavigation, + (route) => false, // 모든 이전 라우트 제거 + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFAC5BF8), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + '네, 알겠어요.', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 14, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ); + }, + ); + } + + Future _registerProblems() async { + if (_selectedImages.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('이미지를 선택해주세요'))); + return; + } + + if (_selectedType == null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('문제 유형을 선택해주세요'))); + return; + } + + if (_academyState == UploadPageAcademyState.checking) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('학원 정보를 확인하는 중입니다. 잠시만 기다려주세요.')), + ); + return; + } + + if (_academyState == UploadPageAcademyState.noAcademy) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('학원 등록을 먼저 해주세요.'))); + return; + } + + if (_isSseConnecting) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('업로드 채널을 준비 중입니다. 잠시 후 다시 시도해주세요.')), + ); + return; + } + + final timestamp = DateTime.now().toIso8601String(); + appLog( + '[UploadImagesPage][timecheck][$timestamp] 문제 등록 요청 시작 - imageCount=${_selectedImages.length}, type=$_selectedType', + ); + + setState(() { + _uploadError = null; + _uploadSuccessMessage = null; + _uploadedCount = 0; + _totalCount = _selectedImages.length; + _isUploading = true; + }); + + try { + debugPrint( + '[UploadImagesPage] 🚀 업로드 시작: ${_selectedImages.length}개 이미지', + ); + + // uploadImages 외부 함수 사용 (진행 상황 콜백 포함) + final response = await uploadImages( + images: _selectedImages, + onProgress: (uploadedCount, totalCount) { + debugPrint( + '[UploadImagesPage] 📤 업로드 진행: $uploadedCount/$totalCount', + ); + if (mounted) { + setState(() { + _uploadedCount = uploadedCount; + _totalCount = totalCount; + }); + } + }, + ); + + appLog( + '[UploadImagesPage][timecheck] ✅ 업로드 완료 - studentResponseId=${response.studentResponseId}, total=${_selectedImages.length}', + ); + + debugPrint( + '[UploadImagesPage] ✅ 업로드 완료: studentResponseId=${response.studentResponseId}', + ); + + if (mounted) { + setState(() { + _isUploading = false; + _uploadedCount = _selectedImages.length; + }); + + // 성공 팝업 표시 + _showSuccessDialog(); + } + } on NoAcademyException catch (e) { + debugPrint('[UploadImagesPage] ❌ 업로드 실패(학원 없음): $e'); + if (!mounted) return; + setState(() { + _isUploading = false; + _academyState = UploadPageAcademyState.noAcademy; + }); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(e.message))); + } catch (e) { + debugPrint('[UploadImagesPage] ❌ 업로드 실패: $e'); + if (mounted) { + setState(() { + _isUploading = false; + _uploadError = e.toString(); + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('문제 등록 요청에 실패했습니다: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _checkAcademyState() async { + setState(() { + _academyState = UploadPageAcademyState.checking; + }); + + try { + final userId = await _authService.getUserId(); + if (userId == null) { + if (!mounted) return; + setState(() { + _academyState = UploadPageAcademyState.error; + }); + return; + } + + final academies = await _academyService.getUserAcademies(userId); + final registered = academies + .where((a) => a.registerStatus == 'Y') + .toList(); + + if (!mounted) return; + setState(() { + _academyState = registered.isEmpty + ? UploadPageAcademyState.noAcademy + : UploadPageAcademyState.hasAcademy; + }); + } catch (e) { + debugPrint('[UploadImagesPage] 학원 상태 조회 실패: $e'); + if (!mounted) return; + setState(() { + _academyState = UploadPageAcademyState.error; + }); + } + } + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final screenHeight = MediaQuery.of(context).size.height; + + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Column( + children: [ + _buildHeader(), + Expanded( + child: RefreshIndicator( + onRefresh: () async { + refresh(); + }, + child: SingleChildScrollView( + physics: + const AlwaysScrollableScrollPhysics(), // Pull-to-refresh를 위해 항상 스크롤 가능하도록 + child: Column( + children: [ + SizedBox(height: screenHeight * 0.015), + _buildImageGrid(screenWidth, screenHeight), + SizedBox(height: screenHeight * 0.02), + ], + ), + ), + ), + ), + _buildBottomButtons(screenWidth, screenHeight), + ], + ), + ), + ); + } + + Widget _buildHeader() { + return const AppHeader( + title: AppHeaderTitle('이미지 업로드', textAlign: TextAlign.center), + trailing: AppHeaderMenuButton(), + ); + } + + Widget _buildImageGrid(double screenWidth, double screenHeight) { + if (_selectedImages.isEmpty) { + return Container( + width: screenWidth * 0.866, // 348/402 + height: screenHeight * 0.375, // 328/874 + margin: EdgeInsets.symmetric(horizontal: screenWidth * 0.067), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFE1E7ED)), + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_academyState == UploadPageAcademyState.noAcademy) ...[ + const Text( + '학원 등록을 먼저 해주세요.', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF7F818E), + ), + ), + ] else if (_academyState == UploadPageAcademyState.error) ...[ + const Text( + '학원 정보를 불러오지 못했어요.', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF7F818E), + ), + ), + ] else if (_academyState == UploadPageAcademyState.checking) ...[ + const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(height: 8), + const Text( + '학원 정보를 확인하는 중입니다...', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF7F818E), + ), + ), + ] else ...[ + const Text( + '이미지를 선택해주세요', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF7F818E), + ), + ), + ], + if (_uploadError != null) ...[ + const SizedBox(height: 8), + Text( + _uploadError!, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 12, + color: Color(0xFFFF4258), + ), + ), + ], + if (_uploadSuccessMessage != null) ...[ + const SizedBox(height: 8), + Text( + _uploadSuccessMessage!, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 12, + color: Color(0xFF4CAF50), + ), + ), + ], + if (_isUploading && _totalCount != null) ...[ + const SizedBox(height: 8), + Text( + '업로드 중: ${_uploadedCount ?? 0}/$_totalCount', + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 12, + color: Color(0xFFAC5BF8), + ), + ), + ], + ], + ), + ), + ); + } + + // 4x4 그리드 레이아웃 + final gridWidth = screenWidth * 0.866; // 348/402 + final itemWidth = (gridWidth - 3 * 8) / 4; // 4개 열, 간격 8 + final itemHeight = itemWidth * 0.938; // 76/81 비율 + + return Container( + width: gridWidth, + margin: EdgeInsets.symmetric(horizontal: screenWidth * 0.067), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: List.generate( + _selectedImages.length > 16 ? 16 : _selectedImages.length, + (index) => Container( + width: itemWidth, + height: itemHeight, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + border: Border.all(color: const Color(0xFFE1E7ED)), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.file( + File(_selectedImages[index].path), + fit: BoxFit.cover, + ), + ), + ), + ), + ), + ); + } + + Widget _buildBottomButtons(double screenWidth, double screenHeight) { + final buttonHeight = screenHeight * 0.045; // 39/874 + + return Container( + padding: EdgeInsets.symmetric( + horizontal: screenWidth * 0.072, + vertical: screenHeight * 0.015, + ), + child: Row( + children: [ + // 새로 풀기 버튼 + Expanded( + child: GestureDetector( + onTap: () { + setState(() { + _selectedType = ProblemType.newProblem; + }); + }, + child: Container( + height: buttonHeight, + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all( + color: _selectedType == ProblemType.newProblem + ? const Color(0xFFAC5BF8) + : const Color(0xFFE1E7ED), + width: _selectedType == ProblemType.newProblem ? 2 : 1, + ), + borderRadius: BorderRadius.circular(5), + ), + child: const Center( + child: Text( + '새로 풀기', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 14, + color: Color(0xFF585B69), + ), + ), + ), + ), + ), + ), + + SizedBox(width: screenWidth * 0.02), + + // 중앙 버튼 (이미지 가져오기 / 문제 등록) + Expanded( + flex: 2, + child: GestureDetector( + onTap: _selectedImages.isEmpty ? _pickImages : _registerProblems, + child: Container( + height: buttonHeight, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFAC5BF8), Color(0xFF636ACF)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(5), + ), + child: Center( + child: Text( + () { + if (_selectedImages.isEmpty) { + if (_academyState == UploadPageAcademyState.noAcademy) { + return '학원 등록 필요'; + } + if (_academyState == UploadPageAcademyState.checking) { + return '학원 정보 확인 중...'; + } + if (_academyState == UploadPageAcademyState.error) { + return '다시 시도'; + } + return '이미지 가져오기'; + } + + if (_isUploading) { + if (_uploadedCount != null && _totalCount != null) { + return '업로드 중... ($_uploadedCount/$_totalCount)'; + } + return '업로드 중...'; + } + + return '문제 등록'; + }(), + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 16, + color: Color(0xFFF8F9FA), + ), + ), + ), + ), + ), + ), + + SizedBox(width: screenWidth * 0.02), + + // 오답 수정 버튼 + Expanded( + child: GestureDetector( + onTap: () { + setState(() { + _selectedType = ProblemType.correctMistakes; + }); + }, + child: Container( + height: buttonHeight, + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all( + color: _selectedType == ProblemType.correctMistakes + ? const Color(0xFFAC5BF8) + : const Color(0xFFE1E7ED), + width: _selectedType == ProblemType.correctMistakes ? 2 : 1, + ), + borderRadius: BorderRadius.circular(5), + ), + child: const Center( + child: Text( + '오답 수정', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 14, + color: Color(0xFF585B69), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/frontend/lib/screens/workbook/API_RESPONSE_STRUCTURE.md b/frontend/lib/screens/workbook/API_RESPONSE_STRUCTURE.md new file mode 100644 index 0000000..6891722 --- /dev/null +++ b/frontend/lib/screens/workbook/API_RESPONSE_STRUCTURE.md @@ -0,0 +1,84 @@ +# WorkbookDetailPage API 응답 구조 + +## API 엔드포인트 + +**Method**: `GET` +**Endpoint**: `/grading/chapter/book/{book_id}/user/{academy_user_id}` +**Headers**: + +- `Content-Type: application/json` +- `Authorization: Bearer {token}` + +**Path Parameters**: + +- `book_id` (int): 문제집 ID +- `academy_user_id` (int): 학원 사용자 ID + +## 응답 구조 + +### 응답 형식 + +```json +[ + { + "chapter_id": 1, + "book_id": 1, + "main_chapter_number": 1, + "sub_chapter_number": 0, + "chapter_name": "Gradi의 이해", + "chapter_start_page": 0, + "chapter_end_page": 0, + "chapter_start_question": 0, + "chapter_end_question": 0, + "total_chapter_question": 20, + "student_answer_count": 0 + }, + ... +] +``` + +**응답 타입**: JSON 배열 (`List`) + +### 응답 필드 상세 + +| 필드명 (JSON) | 타입 | 필수 여부 | 기본값 | 설명 | +| ------------------------ | ---------------- | --------- | ------ | ------------------------------------ | +| `chapter_id` | `int` | 필수 | - | 챕터 고유 ID | +| `book_id` | `int` | 필수 | - | 문제집 ID | +| `main_chapter_number` | `int` | 선택 | `0` | 대단원 번호 (정렬 기준) | +| `sub_chapter_number` | `int` | 선택 | `0` | 소단원 번호 (정렬 기준) | +| `chapter_name` | `string \| null` | 선택 | `null` | 챕터 이름 | +| `chapter_start_page` | `int` | 선택 | `0` | 챕터 시작 페이지 | +| `chapter_end_page` | `int` | 선택 | `0` | 챕터 종료 페이지 | +| `chapter_start_question` | `int` | 선택 | `0` | 챕터 시작 문제 번호 | +| `chapter_end_question` | `int` | 선택 | `0` | 챕터 종료 문제 번호 | +| `total_chapter_question` | `int` | 선택 | `0` | 챕터 전체 문제 수 | +| `student_answer_count` | `int` | 선택 | `0` | 학생이 제출한 답안 수 (완료 문제 수) | + +## 정렬 규칙 + +UseCase에서 자동으로 정렬됩니다: + +1. `main_chapter_number` 오름차순 +2. `sub_chapter_number` 오름차순 + +## 도메인 엔티티 변환 + +API 응답(`ChapterApiResponse`)은 도메인 엔티티(`ChapterEntity`)로 변환되며, 추가로 다음 계산된 속성을 제공합니다: + +- `progress` (double): `student_answer_count / total_chapter_question` (0.0 ~ 1.0) +- `formattedName` (string): `"{chapterId}. {chapterName}"` 형식 (chapterId를 인덱스로 사용) +- `problemCountDisplay` (string): `"{studentAnswerCount} / {totalChapterQuestion}"` 형식 + +## 에러 처리 + +- **401 Unauthorized**: 인증 실패 (토큰 만료 또는 유효하지 않음) +- **200 OK**: 성공 (빈 배열일 수 있음) +- **기타 상태 코드**: API 호출 실패 예외 발생 + +## 참고 파일 + +- API 호출: `frontend/lib/data/chapter/chapter_api.dart` +- Repository: `frontend/lib/data/chapter/chapter_repository_impl.dart` +- UseCase: `frontend/lib/domain/chapter/get_chapters_for_book_use_case.dart` +- Entity: `frontend/lib/domain/chapter/chapter_entity.dart` diff --git a/frontend/lib/screens/workbook/chapter_detail_page.dart b/frontend/lib/screens/workbook/chapter_detail_page.dart new file mode 100644 index 0000000..e30ac5a --- /dev/null +++ b/frontend/lib/screens/workbook/chapter_detail_page.dart @@ -0,0 +1,391 @@ +import 'package:flutter/material.dart'; +import 'dart:developer' as developer; +import '../../widgets/back_button.dart'; +import '../../services/get_chapter_question_statuses_use_case.dart'; +import '../../services/models/question_status_model.dart'; + +/// 문제 풀이 상태 (UI 전용 enum, Domain에 없음) +enum QuestionStatus { + correct, // isCorrect == true + incorrect, // isCorrect == false + unsolved, // isCorrect == null +} + +/// 챕터 상세 페이지 +/// WorkbookDetailPage에서 챕터를 선택하면 표시되는 페이지 +/// +/// 구성: +/// 1. 헤더: 뒤로가기 버튼 + 챕터명 +/// 2. 문제 목록: 각 문제의 풀이 상태를 색상으로 표시 +/// - 초록색: 풀고 맞은 문제 +/// - 빨간색: 풀고 틀린 문제 +/// - 회색: 풀지 않은 문제 +class ChapterDetailPage extends StatefulWidget { + final int chapterId; + final int academyUserId; + final String workbookName; + final String chapterName; + final GetChapterQuestionStatusesUseCase getChapterQuestionStatusesUseCase; + + const ChapterDetailPage({ + super.key, + required this.chapterId, + required this.academyUserId, + required this.workbookName, + required this.chapterName, + required this.getChapterQuestionStatusesUseCase, + }); + + @override + State createState() => _ChapterDetailPageState(); +} + +class _ChapterDetailPageState extends State { + List _questionStatuses = []; + bool _isLoading = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadQuestionStatuses(); + } + + Future _loadQuestionStatuses() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final statuses = await widget.getChapterQuestionStatusesUseCase.call( + chapterId: widget.chapterId, + academyUserId: widget.academyUserId, + ); + + if (!mounted) return; + + setState(() { + _questionStatuses = statuses; + _isLoading = false; + }); + } catch (e) { + developer.log('❌ [ChapterDetailPage] 문제 상태 로드 실패: $e'); + + if (!mounted) return; + + setState(() { + _errorMessage = '문제 정보를 불러오지 못했습니다.'; + _isLoading = false; + }); + } + } + + /// UI 정책: isCorrect? -> QuestionStatus 변환 + QuestionStatus _getQuestionStatus(QuestionStatusModel model) { + final isCorrect = model.isCorrect; + + if (isCorrect == null) { + return QuestionStatus.unsolved; // UI에서 판단 + } else if (isCorrect) { + return QuestionStatus.correct; + } else { + return QuestionStatus.incorrect; + } + } + + // 통계 계산 (UI 레이어에서 처리) + int _getCorrectCount() { + return _questionStatuses.where((model) => model.isCorrect == true).length; + } + + int _getIncorrectCount() { + return _questionStatuses.where((model) => model.isCorrect == false).length; + } + + int _getUnsolvedCount() { + return _questionStatuses.where((model) => model.isCorrect == null).length; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Column( + children: [ + // 헤더 + _buildHeader(), + + // 메인 콘텐츠 + Expanded(child: _buildContent()), + ], + ), + ), + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.fromLTRB(20, 17, 20, 17), + decoration: const BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Color(0x0D000000), + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + const CustomBackButton(), + const SizedBox(width: 20), + Expanded( + child: Text( + widget.chapterName, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 20, + color: Color(0xFF333333), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + Widget _buildContent() { + // 로딩 상태 + if (_isLoading) { + return const Center( + child: Padding( + padding: EdgeInsets.all(40.0), + child: CircularProgressIndicator(), + ), + ); + } + + // 에러 상태 + if (_errorMessage != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + color: Color(0xFFFF6B6B), + size: 48, + ), + const SizedBox(height: 16), + Text( + _errorMessage!, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFFFF6B6B), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadQuestionStatuses, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFFF6B6B), + foregroundColor: Colors.white, + ), + child: const Text('다시 시도'), + ), + ], + ), + ), + ); + } + + // 빈 상태 + if (_questionStatuses.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Text( + '등록된 문제가 없습니다.', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF999999), + ), + ), + ), + ); + } + + // 정상 상태 + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 20), + + // 챕터 정보 + _buildChapterInfo(), + + const SizedBox(height: 24), + + // 문제 목록 + _buildQuestionGrid(), + + const SizedBox(height: 20), + ], + ), + ); + } + + /// 챕터 정보 (진행 상태) + Widget _buildChapterInfo() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE9ECEF)), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildStatusIndicator('맞은 문제', Colors.green, _getCorrectCount()), + _buildStatusIndicator('틀린 문제', Colors.red, _getIncorrectCount()), + _buildStatusIndicator('안 푼 문제', Colors.grey, _getUnsolvedCount()), + ], + ), + ); + } + + Widget _buildStatusIndicator(String label, Color color, int count) { + return Column( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: color.withAlpha(51), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + count.toString(), + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 16, + color: color, + ), + ), + ), + ), + const SizedBox(height: 8), + Text( + label, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 12, + color: Color(0xFF666666), + ), + ), + ], + ); + } + + /// 문제 그리드 (4열) + Widget _buildQuestionGrid() { + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 1, + ), + itemCount: _questionStatuses.length, + itemBuilder: (context, index) { + final model = _questionStatuses[index]; + final status = _getQuestionStatus(model); + return _buildQuestionCard(model.questionNumber, status); + }, + ); + } + + /// 개별 문제 카드 + Widget _buildQuestionCard(int questionNumber, QuestionStatus status) { + Color backgroundColor; + Color textColor; + + switch (status) { + case QuestionStatus.correct: + backgroundColor = const Color(0xFF4CAF50); // 초록색 + textColor = Colors.white; + break; + case QuestionStatus.incorrect: + backgroundColor = const Color(0xFFF44336); // 빨간색 + textColor = Colors.white; + break; + case QuestionStatus.unsolved: + backgroundColor = const Color(0xFFE9ECEF); // 회색 + textColor = const Color(0xFF666666); + break; + } + + return GestureDetector( + onTap: () { + // TODO: QuestionDetailPage로 이동 + Navigator.pushNamed( + context, + '/workbook/question-detail', + arguments: { + 'chapterId': widget.chapterId, + 'academyUserId': widget.academyUserId, + 'workbookName': widget.workbookName, + 'chapterName': widget.chapterName, + 'questionNumber': questionNumber, + 'status': status, + }, + ); + }, + child: Container( + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(13), + offset: const Offset(0, 2), + blurRadius: 4, + ), + ], + ), + child: Center( + child: Text( + questionNumber.toString(), + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 20, + color: textColor, + ), + ), + ), + ), + ); + } +} diff --git a/frontend/lib/screens/workbook/models/class_data.dart b/frontend/lib/screens/workbook/models/class_data.dart new file mode 100644 index 0000000..a4adbab --- /dev/null +++ b/frontend/lib/screens/workbook/models/class_data.dart @@ -0,0 +1,17 @@ +import 'workbook_info.dart'; + +/// 클래스 데이터 모델 (UI용) +/// +/// 클래스별 문제집 목록을 표시하기 위한 UI 모델입니다. +class ClassData { + final String className; + final String lastStudyDate; + final List workbooks; + + ClassData({ + required this.className, + required this.lastStudyDate, + required this.workbooks, + }); +} + diff --git a/frontend/lib/screens/workbook/models/workbook_data.dart b/frontend/lib/screens/workbook/models/workbook_data.dart new file mode 100644 index 0000000..eb5989e --- /dev/null +++ b/frontend/lib/screens/workbook/models/workbook_data.dart @@ -0,0 +1,23 @@ +/// 문제집 데이터 모델 (문제집 순 페이지용, UI용) +/// +/// 문제집 순 뷰에서 표시되는 문제집 정보입니다. +class WorkbookData { + final String workbookName; + final String lastStudyDate; + final int progress; + final String thumbnailPath; + final String className; + final int? bookId; + final int academyUserId; + + WorkbookData({ + required this.workbookName, + required this.lastStudyDate, + required this.progress, + required this.thumbnailPath, + required this.className, + this.bookId, + required this.academyUserId, + }); +} + diff --git a/frontend/lib/screens/workbook/models/workbook_info.dart b/frontend/lib/screens/workbook/models/workbook_info.dart new file mode 100644 index 0000000..6527f3d --- /dev/null +++ b/frontend/lib/screens/workbook/models/workbook_info.dart @@ -0,0 +1,21 @@ +/// 문제집 정보 모델 (클래스 내부용, UI용) +/// +/// 클래스 카드 내부에 표시되는 문제집 정보입니다. +class WorkbookInfo { + final String name; + final String lastStudyDate; + final int progress; + final String thumbnailPath; + final int? bookId; + final int academyUserId; + + WorkbookInfo({ + required this.name, + required this.lastStudyDate, + required this.progress, + required this.thumbnailPath, + this.bookId, + required this.academyUserId, + }); +} + diff --git a/frontend/lib/screens/workbook/question_detail_page.dart b/frontend/lib/screens/workbook/question_detail_page.dart new file mode 100644 index 0000000..1e2b585 --- /dev/null +++ b/frontend/lib/screens/workbook/question_detail_page.dart @@ -0,0 +1,723 @@ +import 'package:flutter/material.dart'; +import '../../widgets/back_button.dart'; +import 'chapter_detail_page.dart'; +import '../../domain/student_answer/student_answer_repository.dart'; +import '../../domain/student_answer/student_answer_query.dart'; +import '../../domain/section_image/get_section_image_use_case.dart'; +import '../../config/app_dependencies.dart'; +import '../../utils/app_logger.dart'; +import '../../domain/question/question_identifier.dart'; +import '../../domain/explanation/explanation_source.dart'; +import '../../application/explanation/question_explanation_controller.dart'; + +/// 문제 상세 페이지 +/// ChapterDetailPage에서 문제를 선택하면 표시되는 페이지 +/// +/// 구성: +/// 1. 헤더: 뒤로가기 버튼 + 문제 번호 +/// 2. Section 이미지: AI가 인식한 문제 영역 이미지 (crop된 이미지) +/// 3. 채점 히스토리: 해당 문제의 과거 채점 기록 +/// - 채점 일시 +/// - 채점 결과 (정답/오답) +/// - 학생 답안 이미지 +class QuestionDetailPage extends StatefulWidget { + final int chapterId; + final int academyUserId; + final String workbookName; + final String chapterName; + final int questionNumber; + final QuestionStatus status; + final int? initialStudentResponseId; + final StudentAnswerRepository studentAnswerRepository; + final GetSectionImageUseCase getSectionImageUseCase; + final QuestionExplanationController explanationController; + + QuestionDetailPage({ + super.key, + required this.chapterId, + required this.academyUserId, + required this.workbookName, + required this.chapterName, + required this.questionNumber, + required this.status, + this.initialStudentResponseId, + StudentAnswerRepository? studentAnswerRepository, + GetSectionImageUseCase? getSectionImageUseCase, + required this.explanationController, + }) : studentAnswerRepository = + studentAnswerRepository ?? AppDependencies.studentAnswerRepository, + getSectionImageUseCase = + getSectionImageUseCase ?? AppDependencies.getSectionImageUseCase; + + @override + State createState() => _QuestionDetailPageState(); +} + +class _QuestionDetailPageState extends State { + ExplanationSource? _explanationSource; + bool _hasShownRequestSuccessPopup = false; + + /// Section 이미지 URL + String? _sectionImageUrl; + bool _isLoadingImage = false; + String? _imageError; + + /// 채점 히스토리 목록 + /// TODO: 서버에서 가져오기 (나중에 구현) + + final List _gradingHistory = []; + + @override + void initState() { + super.initState(); + _loadSectionImage(); + } + + /// Section 이미지 로드 + /// + /// chapterId + academyUserId 또는 studentResponseId로 답안을 조회하여 + /// studentResponseId를 찾고, 그것으로 이미지를 조회합니다. + Future _loadSectionImage() async { + setState(() { + _isLoadingImage = true; + _imageError = null; + }); + + try { + // 1. 답안 조회하여 studentResponseId 찾기 + final query = widget.initialStudentResponseId != null + ? StudentAnswerQuery.byResponse( + studentResponseId: widget.initialStudentResponseId!, + ) + : StudentAnswerQuery.byChapter( + chapterId: widget.chapterId, + academyUserId: widget.academyUserId, + ); + + final answers = await widget.studentAnswerRepository.getStudentAnswers( + query, + ); + + // 2. 해당 문제 번호의 답안 찾기 (subQuestionNumber는 0 우선, 없으면 첫 번째) + final matchingAnswers = answers + .where((a) => a.questionNumber == widget.questionNumber) + .toList(); + + if (matchingAnswers.isEmpty) { + if (!mounted) return; + setState(() { + _isLoadingImage = false; + _imageError = '답안 정보를 찾을 수 없습니다.'; + }); + return; + } + + // subQuestionNumber가 0인 답안을 우선 선택, 없으면 첫 번째 답안 + final answer = matchingAnswers.firstWhere( + (a) => a.subQuestionNumber == 0, + orElse: () => matchingAnswers.first, + ); + + // ExplanationSource 구성 (bookId는 현재 컨텍스트에서 알 수 없으므로 0으로 둠) + final questionId = QuestionIdentifier( + bookId: 0, + chapterId: answer.chapterId ?? widget.chapterId, + page: answer.page, + questionNumber: answer.questionNumber, + subQuestionNumber: answer.subQuestionNumber, + ); + + _explanationSource = ExplanationSource( + studentResponseId: answer.studentResponseId, + academyUserId: widget.academyUserId, + question: questionId, + ); + + if (!mounted) return; + + // 3. 이미지 조회 + // 답안의 questionNumber와 subQuestionNumber를 사용 + // (답안이 실제로 저장된 문제 번호를 사용해야 함) + final imageQuestionNumber = answer.questionNumber; + final imageSubQuestionNumber = answer.subQuestionNumber; + + appLog( + '[question_detail_page] 이미지 조회 시작 - questionNumber: $imageQuestionNumber, subQuestionNumber: $imageSubQuestionNumber, studentResponseId: ${answer.studentResponseId}', + ); + + final imageEntity = await widget.getSectionImageUseCase.call( + academyUserId: widget.academyUserId, + studentResponseId: answer.studentResponseId, + questionNumber: imageQuestionNumber, + subQuestionNumber: imageSubQuestionNumber, + ); + + // 해설 로딩 (에러가 나도 이미지 로딩에는 영향 없음) + final source = _explanationSource; + if (source != null) { + // 페이지 진입 시에는 이미 생성된 해설만 조회하고, + // 해설이 없으면 아무 작업도 하지 않습니다 (POST 미수행). + await widget.explanationController.loadExisting(source); + } + + if (!mounted) return; + + setState(() { + _sectionImageUrl = imageEntity?.imageUrl; + _isLoadingImage = false; + if (imageEntity == null) { + _imageError = '이미지를 찾을 수 없습니다.'; + appLog('[question_detail_page] 이미지 엔티티가 null입니다.'); + } else { + appLog('[question_detail_page] 이미지 URL 설정됨: ${imageEntity.imageUrl}'); + } + }); + } catch (e) { + appLog('[question_detail_page] 이미지 로드 실패: $e'); + + if (!mounted) return; + + setState(() { + _isLoadingImage = false; + _imageError = '이미지를 불러오지 못했습니다.'; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Column( + children: [ + // 헤더 + _buildHeader(), + + // 메인 콘텐츠 + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 20), + + // Section 이미지 (문제 영역) + _buildSectionImage(), + + const SizedBox(height: 24), + + // 해설 섹션 + _buildExplanationSection(), + + const SizedBox(height: 24), + + // 채점 히스토리 섹션 + _buildGradingHistorySection(), + + const SizedBox(height: 20), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.fromLTRB(20, 17, 20, 17), + decoration: const BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Color(0x0D000000), + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + const CustomBackButton(), + const SizedBox(width: 20), + Expanded( + child: Row( + children: [ + Text( + '문제 ${widget.questionNumber}', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 20, + color: Color(0xFF333333), + ), + ), + const SizedBox(width: 12), + _buildStatusBadge(), + ], + ), + ), + ], + ), + ); + } + + /// 문제 상태 배지 + Widget _buildStatusBadge() { + String text; + Color backgroundColor; + Color textColor; + + switch (widget.status) { + case QuestionStatus.correct: + text = '정답'; + backgroundColor = const Color(0xFF4CAF50); + textColor = Colors.white; + break; + case QuestionStatus.incorrect: + text = '오답'; + backgroundColor = const Color(0xFFF44336); + textColor = Colors.white; + break; + case QuestionStatus.unsolved: + text = '미풀이'; + backgroundColor = const Color(0xFFE9ECEF); + textColor = const Color(0xFF666666); + break; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + text, + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 12, + color: textColor, + ), + ), + ); + } + + /// Section 이미지 (AI가 인식한 문제 영역) + /// TODO: 실제 서버에서 가져온 이미지 표시 + Widget _buildSectionImage() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '문제 영역', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 18, + color: Color(0xFF333333), + ), + ), + const SizedBox(height: 12), + Container( + width: double.infinity, + height: 200, + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE9ECEF)), + borderRadius: BorderRadius.circular(12), + ), + child: _buildImageContent(), + ), + ], + ); + } + + Widget _buildExplanationSection() { + return AnimatedBuilder( + animation: widget.explanationController, + builder: (context, _) { + final state = widget.explanationController.state; + + if (state.lastRequestPerformed && !_hasShownRequestSuccessPopup) { + _hasShownRequestSuccessPopup = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: const Text('해설 요청 완료'), + content: const Text( + '정상적으로 해설 생성 요청이 완료되었습니다.\n' + '해설이 준비되면 알림으로 알려드릴게요!', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('확인'), + ), + ], + ); + }, + ); + }); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + '해설', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 18, + color: Color(0xFF333333), + ), + ), + const SizedBox(width: 8), + TextButton( + onPressed: state.isLoading || _explanationSource == null + ? null + : () async { + final source = _explanationSource; + if (source != null) { + await widget.explanationController.requestAgain( + source, + ); + } + }, + child: state.isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + state.explanation == null ? '해설 요청' : '해설 다시 요청', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 12, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE9ECEF)), + ), + child: _buildExplanationContent(state), + ), + ], + ); + }, + ); + } + + Widget _buildExplanationContent(QuestionExplanationState state) { + if (state.isLoading && state.explanation == null) { + return const Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + + if (state.errorMessage != null) { + return Text( + state.errorMessage!, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFFF44336), + ), + ); + } + + if (state.explanation == null) { + return const Text( + '아직 생성된 해설이 없습니다.\n해설 요청 버튼을 눌러 해설을 생성해보세요.', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF999999), + ), + ); + } + + return Text( + state.explanation!.text, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + fontSize: 14, + color: Color(0xFF333333), + height: 1.5, + ), + ); + } + + Widget _buildImageContent() { + // 로딩 중 + if (_isLoadingImage) { + return const Center(child: CircularProgressIndicator()); + } + + // 에러 상태 + if (_imageError != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.image_not_supported, + size: 48, + color: Color(0xFFCCCCCC), + ), + const SizedBox(height: 8), + Text( + _imageError!, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF999999), + ), + ), + ], + ), + ); + } + + // 이미지 표시 + if (_sectionImageUrl != null) { + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.network( + _sectionImageUrl!, + fit: BoxFit.contain, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return const Center(child: CircularProgressIndicator()); + }, + errorBuilder: (context, error, stackTrace) { + appLog('[question_detail_page] 이미지 로드 에러: $error'); + appLog('[question_detail_page] 이미지 URL: $_sectionImageUrl'); + appLog('[question_detail_page] StackTrace: $stackTrace'); + return _buildPlaceholder('이미지를 불러오지 못했습니다.'); + }, + ), + ); + } + + // 이미지 없음 + return _buildPlaceholder('이미지가 없습니다.'); + } + + Widget _buildPlaceholder([String? message]) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.image_outlined, size: 48, color: Color(0xFFCCCCCC)), + const SizedBox(height: 8), + Text( + message ?? '이미지가 없습니다.', + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF999999), + ), + ), + ], + ), + ); + } + + /// 채점 히스토리 섹션 + Widget _buildGradingHistorySection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '채점 히스토리', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 18, + color: Color(0xFF333333), + ), + ), + const SizedBox(height: 12), + + _gradingHistory.isEmpty + ? _buildEmptyHistory() + : ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _gradingHistory.length, + separatorBuilder: (context, index) => + const SizedBox(height: 12), + itemBuilder: (context, index) { + return _buildHistoryCard(_gradingHistory[index], index + 1); + }, + ), + ], + ); + } + + Widget _buildEmptyHistory() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE9ECEF)), + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: Text( + '아직 채점 기록이 없습니다', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF999999), + ), + ), + ), + ); + } + + /// 개별 채점 히스토리 카드 + Widget _buildHistoryCard(GradingHistory history, int attemptNumber) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE9ECEF)), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(13), + offset: const Offset(0, 2), + blurRadius: 8, + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 헤더: 시도 번호 + 결과 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '$attemptNumber번째 시도', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 16, + color: Color(0xFF333333), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + decoration: BoxDecoration( + color: history.isCorrect + ? const Color(0xFF4CAF50) + : const Color(0xFFF44336), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + history.isCorrect ? '정답' : '오답', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 12, + color: Colors.white, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + + // 채점 일시 + Text( + _formatDateTime(history.gradingDate), + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + fontSize: 12, + color: Color(0xFF999999), + ), + ), + const SizedBox(height: 12), + + // 피드백 + if (history.feedback != null) + Text( + history.feedback!, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF666666), + ), + ), + + // TODO: 학생 답안 이미지 표시 + // if (history.studentAnswerImagePath != null) + // _buildStudentAnswerImage(history.studentAnswerImagePath!), + ], + ), + ); + } + + /// 날짜 포맷팅 + String _formatDateTime(DateTime dateTime) { + return '${dateTime.year}.${dateTime.month.toString().padLeft(2, '0')}.${dateTime.day.toString().padLeft(2, '0')} ' + '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}'; + } +} + +/// 채점 히스토리 모델 +class GradingHistory { + final DateTime gradingDate; + final bool isCorrect; + final String? studentAnswerImagePath; + final String? feedback; + + GradingHistory({ + required this.gradingDate, + required this.isCorrect, + this.studentAnswerImagePath, + this.feedback, + }); +} diff --git a/frontend/lib/screens/workbook/workbook_detail_page.dart b/frontend/lib/screens/workbook/workbook_detail_page.dart new file mode 100644 index 0000000..dfaf385 --- /dev/null +++ b/frontend/lib/screens/workbook/workbook_detail_page.dart @@ -0,0 +1,369 @@ +import 'package:flutter/material.dart'; +import '../../widgets/back_button.dart'; +import '../../domain/chapter/chapter_entity.dart'; +import '../../domain/chapter/get_chapters_for_book_use_case.dart'; +import 'dart:developer' as developer; + +/// 문제집 상세 페이지 +/// WorkbookPage에서 문제집을 선택하면 표시되는 페이지 +/// +/// 구성: +/// 1. 헤더: 뒤로가기 버튼 + 문제집 이름 +/// 2. 문제집 풀이 현황 섹션 (공간만 확보, 추후 구현) +/// 3. 챕터 목록: 각 챕터의 이름과 "푼 문제 수/전체 문제 수" 표시 +class WorkbookDetailPage extends StatefulWidget { + final String workbookName; + final String? + thumbnailPath; // TODO(downy): workbook 썸네일을 헤더 우측에 표시 (Figma node-id=... 참조) + final int bookId; + final int academyUserId; + final GetChaptersForBookUseCase getChaptersUseCase; + + const WorkbookDetailPage({ + super.key, + required this.workbookName, + this.thumbnailPath, + required this.bookId, + required this.academyUserId, + required this.getChaptersUseCase, + }); + + @override + State createState() => _WorkbookDetailPageState(); +} + +class _WorkbookDetailPageState extends State { + // 에러 메시지 상수화 (나중에 AppStrings로 이동 가능) + static const String _defaultChapterErrorMessage = '챕터 정보를 불러오지 못했습니다.'; + static const String _emptyChapterMessage = '등록된 챕터가 없습니다.'; + + // 상태 관리 + List _chapters = []; + bool _isLoading = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _loadChapters(); + } + + /// 챕터 데이터 로드 + Future _loadChapters() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final chapters = await widget.getChaptersUseCase.call( + bookId: widget.bookId, + academyUserId: widget.academyUserId, + ); + + if (!mounted) return; + + setState(() { + _chapters = chapters; + _isLoading = false; + }); + } catch (e) { + developer.log('❌ [WorkbookDetailPage] 챕터 로드 실패: $e'); + + if (!mounted) return; + + setState(() { + _errorMessage = _defaultChapterErrorMessage; + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: Column( + children: [ + // 헤더 + _buildHeader(), + + // 메인 콘텐츠 + Expanded(child: _buildContent()), + ], + ), + ), + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.fromLTRB(20, 17, 20, 17), + decoration: const BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Color(0x0D000000), + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + const CustomBackButton(), + const SizedBox(width: 20), + Expanded( + child: Text( + widget.workbookName, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 20, + color: Color(0xFF333333), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } + + /// 메인 콘텐츠 (스크롤 구조 개선) + /// + /// CustomScrollView + SliverList로 통합하여 성능 최적화 + Widget _buildContent() { + // 로딩 상태 + if (_isLoading) { + return const Center( + child: Padding( + padding: EdgeInsets.all(40.0), + child: CircularProgressIndicator(), + ), + ); + } + + // 에러 상태 + if (_errorMessage != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + color: Color(0xFFFF6B6B), + size: 48, + ), + const SizedBox(height: 16), + Text( + _errorMessage!, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFFFF6B6B), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadChapters, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFFF6B6B), + foregroundColor: Colors.white, + ), + child: const Text('다시 시도'), + ), + ], + ), + ), + ); + } + + // 빈 상태 + if (_chapters.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Text( + _emptyChapterMessage, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF999999), + ), + ), + ), + ); + } + + // 정상 상태: CustomScrollView로 통합 + // padding은 horizontal만 적용하고, 세로 여백은 SliverToBoxAdapter로 관리 + return CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 20), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == 0) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 20), + _buildProgressSection(), + const SizedBox(height: 24), + const Text( + '챕터 목록', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 18, + color: Color(0xFF333333), + ), + ), + const SizedBox(height: 16), + ], + ); + } + // 챕터 카드 (index 1부터 시작) + final chapterIndex = index - 1; + final chapter = _chapters[chapterIndex]; + return Padding( + padding: EdgeInsets.only( + bottom: chapterIndex < _chapters.length - 1 ? 12 : 0, + ), + child: _buildChapterCard(chapter), + ); + }, + childCount: _chapters.length + 1, // 타이틀 섹션 + 챕터 개수 + ), + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 20)), + ], + ); + } + + Widget _buildProgressSection() { + return Container( + width: double.infinity, + height: 120, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: const Color(0xFFE9ECEF)), + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: Text( + '문제집 풀이 현황\n(추후 구현 예정)', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFF999999), + ), + ), + ), + ); + } + + /// 개별 챕터 카드 + /// + /// workbook_page.dart의 progress bar 디자인과 동일하게 구현 + Widget _buildChapterCard(ChapterEntity chapter) { + return GestureDetector( + onTap: () { + // TODO: ChapterDetailPage로 이동 + Navigator.pushNamed( + context, + '/workbook/chapter-detail', + arguments: { + 'chapterId': chapter.chapterId, + 'academyUserId': widget.academyUserId, + 'workbookName': widget.workbookName, + 'chapterName': chapter.formattedName, + }, + ); + }, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE9ECEF)), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(13), + offset: const Offset(0, 2), + blurRadius: 8, + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 챕터명 + Text( + chapter.formattedName, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 16, + color: Color(0xFF333333), + ), + ), + const SizedBox(height: 12), + + // 진행 상태 (workbook_page.dart와 동일한 스타일) + Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Container( + height: 8, // workbook_page.dart와 동일 + decoration: const BoxDecoration(color: Color(0xFFE9ECEF)), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: chapter.progress, + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xFFAC5BF8), Color(0xFF7C3AED)], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + ), + ), + ), + ), + ), + ), + const SizedBox(width: 12), + Text( + chapter + .problemCountDisplay, // "{studentAnswerCount} / {totalChapterQuestion}" + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 14, + color: Color(0xFF666666), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/frontend/lib/screens/workbook/workbook_page.dart b/frontend/lib/screens/workbook/workbook_page.dart index 1353f68..68c7e7d 100644 --- a/frontend/lib/screens/workbook/workbook_page.dart +++ b/frontend/lib/screens/workbook/workbook_page.dart @@ -1,5 +1,17 @@ import 'package:flutter/material.dart'; -import '../../widgets/continuous_learning_widget.dart'; +import 'package:get_it/get_it.dart'; +import '../../widgets/app_header.dart'; +import '../../widgets/app_header_title.dart'; +import '../../widgets/app_header_menu_button.dart'; +import '../../widgets/empty_state_message.dart'; +import '../../services/academy_service.dart'; +import '../../services/auth_service.dart'; +import '../../services/workbook_repository_impl.dart'; +import '../../data/mappers/workbook_mapper.dart'; +import 'models/class_data.dart'; +import 'models/workbook_info.dart'; +import 'models/workbook_data.dart'; +import 'dart:developer' as developer; enum WorkbookViewType { byClass, // 클래스 순 @@ -16,110 +28,110 @@ class WorkbookPage extends StatefulWidget { class _WorkbookPageState extends State { WorkbookViewType _currentView = WorkbookViewType.byClass; - // TODO: Fetch data from server - // 클래스 순 데이터 - final List _classData = [ - ClassData( - className: '오세종 선생님 3반', - lastStudyDate: '2025.10.09', - workbooks: [ - WorkbookInfo( - name: '블랙라벨 중등수학 1-1', - lastStudyDate: '2025.10.09', - progress: 65, - thumbnailPath: 'assets/images/bookcovers/BookCover_Blacklabel.png', - ), - WorkbookInfo( - name: '라이트쎈 중등수학 1-1', - lastStudyDate: '2025.10.06', - progress: 40, - thumbnailPath: 'assets/images/bookcovers/BookCover_LightSsen.png', - ), - ], - ), - ClassData( - className: '조성재 선생님 1반', - lastStudyDate: '2025.10.07', - workbooks: [ - WorkbookInfo( - name: '100발 100중 중등수학 2-2', - lastStudyDate: '2025.10.07', - progress: 55, - thumbnailPath: 'assets/images/bookcovers/BookCover_100to100.png', - ), - ], - ), - ClassData( - className: '최상일 선생님 2반', - lastStudyDate: '2025.10.05', - workbooks: [ - WorkbookInfo( - name: '수능완성 영어 2026', - lastStudyDate: '2025.10.05', - progress: 75, - thumbnailPath: 'assets/images/bookcovers/workbook_2026.jpg', - ), - WorkbookInfo( - name: '수능완성 영어 2025', - lastStudyDate: '2025.10.03', - progress: 90, - thumbnailPath: 'assets/images/bookcovers/workbook_2025.jpg', - ), - WorkbookInfo( - name: '수능완성 영어 2024', - lastStudyDate: '2025.10.01', - progress: 100, - thumbnailPath: 'assets/images/bookcovers/workbook_2024.jpg', - ), - ], - ), - ]; - - // 문제집 순 데이터 (모든 문제집을 마지막 학습일 순으로 정렬) - final List _workbookData = [ - WorkbookData( - workbookName: '블랙라벨 중등수학 1-1', - lastStudyDate: '2025.10.09', - progress: 65, - thumbnailPath: 'assets/images/bookcovers/BookCover_Blacklabel.png', - className: '오세종 선생님 3반', - ), - WorkbookData( - workbookName: '100발 100중 중등수학 2-2', - lastStudyDate: '2025.10.07', - progress: 55, - thumbnailPath: 'assets/images/bookcovers/BookCover_100to100.png', - className: '조성재 선생님 1반', - ), - WorkbookData( - workbookName: '라이트쎈 중등수학 1-1', - lastStudyDate: '2025.10.06', - progress: 40, - thumbnailPath: 'assets/images/bookcovers/BookCover_LightSsen.png', - className: '오세종 선생님 3반', - ), - WorkbookData( - workbookName: '수능완성 영어 2026', - lastStudyDate: '2025.10.05', - progress: 75, - thumbnailPath: 'assets/images/bookcovers/workbook_2026.jpg', - className: '최상일 선생님 2반', - ), - WorkbookData( - workbookName: '수능완성 영어 2025', - lastStudyDate: '2025.10.03', - progress: 90, - thumbnailPath: 'assets/images/bookcovers/workbook_2025.jpg', - className: '최상일 선생님 2반', - ), - WorkbookData( - workbookName: '수능완성 영어 2024', - lastStudyDate: '2025.10.01', - progress: 100, - thumbnailPath: 'assets/images/bookcovers/workbook_2024.jpg', - className: '최상일 선생님 2반', - ), - ]; + final GetIt _getIt = GetIt.instance; + + // Services (DI에서 주입) + late final AuthService _authService; + late final AcademyService _academyService; + late final WorkbookRepositoryImpl _workbookRepository; + late final WorkbookMapper _workbookMapper; + + // Data + List _classData = []; + List _workbookData = []; + bool _isLoading = false; + String? _errorMessage; + bool _hasLoadedOnce = false; + bool _hasRefreshedOnReturn = false; + + @override + void initState() { + super.initState(); + _authService = _getIt(); + _academyService = _getIt(); + _workbookRepository = _getIt(); + _workbookMapper = _getIt(); + _loadWorkbooks(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // 다른 페이지에서 돌아올 때 강제 새로고침 + if (!_hasRefreshedOnReturn) { + _hasRefreshedOnReturn = true; + _loadWorkbooks(forceRefresh: true); + } + } + + /// 외부에서 호출 가능한 새로고침 메서드 + void refresh() { + developer.log('🔄 [WorkbookPage] refresh() called from external'); + _hasRefreshedOnReturn = false; + _loadWorkbooks(forceRefresh: true); + } + + /// 문제집 데이터 로드 + Future _loadWorkbooks({bool forceRefresh = false}) async { + if (_hasLoadedOnce && !forceRefresh) { + return; // 메모리 캐시 사용 + } + + if (forceRefresh) { + _hasLoadedOnce = false; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + // 1. UserId로 academyUserIds 조회 + final userId = await _authService.getUserId(); + if (userId == null) { + throw Exception('사용자 정보를 가져올 수 없습니다.'); + } + + final academies = await _academyService.getUserAcademies(userId); + final academyUserIds = academies + .where((a) => a.registerStatus == 'Y') + .map((a) => a.academy_user_id) + .whereType() + .toList(); + + if (academyUserIds.isEmpty) { + setState(() { + _classData = []; + _workbookData = []; + _isLoading = false; + }); + return; + } + + // 2. Repository 호출 (className은 Repository 내부에서 처리) + final summariesMap = await _workbookRepository + .getWorkbookSummariesByAcademyUserIds(academyUserIds); + + // 3. Mapper로 UI 모델 변환 + final classData = _workbookMapper.convertToClassData(summariesMap); + final workbookData = _workbookMapper.convertToWorkbookData(summariesMap); + + // 4. UI 업데이트 + setState(() { + _classData = classData; + _workbookData = workbookData; + _isLoading = false; + _hasLoadedOnce = true; + }); + } catch (e) { + developer.log('❌ [WorkbookPage] 데이터 로드 실패: $e'); + setState(() { + _isLoading = false; + _errorMessage = e.toString().replaceAll('Exception: ', ''); + }); + } + } @override Widget build(BuildContext context) { @@ -131,38 +143,46 @@ class _WorkbookPageState extends State { // 헤더 _buildHeader(), - // 메인 콘텐츠 (스크롤 가능) + // 메인 콘텐츠 (스크롤 가능 + Pull-to-refresh) Expanded( - child: SingleChildScrollView( - padding: EdgeInsets.symmetric( - horizontal: MediaQuery.of(context).size.width * 0.05, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - height: MediaQuery.of(context).size.height * 0.032, - ), - - // 주간 캘린더 - _buildWeeklyCalendar(), - - Container( - height: MediaQuery.of(context).size.height * 0.01, - ), + child: RefreshIndicator( + onRefresh: () async { + await _loadWorkbooks(forceRefresh: true); + }, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), // Pull-to-refresh를 위해 항상 스크롤 가능하도록 + padding: EdgeInsets.symmetric( + horizontal: MediaQuery.of(context).size.width * 0.05, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: MediaQuery.of(context).size.height * 0.032, + ), - // 토글 버튼 - _buildToggle(), + // 토글 버튼 + _buildToggle(), - Container( - height: MediaQuery.of(context).size.height * 0.01, - ), + Container( + height: MediaQuery.of(context).size.height * 0.01, + ), - // 메인 콘텐츠 - _currentView == WorkbookViewType.byClass - ? _buildClassView() - : _buildWorkbookView(), - ], + // 메인 콘텐츠 + _isLoading + ? const Center( + child: Padding( + padding: EdgeInsets.all(40.0), + child: CircularProgressIndicator(), + ), + ) + : _errorMessage != null + ? _buildErrorState() + : _currentView == WorkbookViewType.byClass + ? _buildClassView() + : _buildWorkbookView(), + ], + ), ), ), ), @@ -173,48 +193,54 @@ class _WorkbookPageState extends State { } Widget _buildHeader() { - return Container( - padding: EdgeInsets.fromLTRB( - MediaQuery.of(context).size.width * 0.05, - MediaQuery.of(context).size.height * 0.021, - MediaQuery.of(context).size.width * 0.05, - MediaQuery.of(context).size.height * 0.012, - ), - decoration: const BoxDecoration(color: Colors.white), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - '문제집', - style: TextStyle( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w700, - fontSize: 20, - color: Color(0xFF333333), - ), - ), - IconButton( - icon: const Icon(Icons.menu, color: Color(0xFF333333)), - onPressed: () { - // TODO: 메뉴 기능 구현 - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('메뉴 기능 구현 예정'))); - }, - ), - ], - ), + return const AppHeader( + title: AppHeaderTitle('문제집'), + trailing: AppHeaderMenuButton(), ); } - Widget _buildWeeklyCalendar() { - return ContinuousLearningWidget( - consecutiveDays: 2, - weeklyProgress: const [true, true, false, false, false, false, false], + Widget _buildErrorState() { + return Center( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, color: Color(0xFFFF6B6B), size: 48), + const SizedBox(height: 16), + Text( + _errorMessage ?? '오류가 발생했습니다.', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 14, + color: Color(0xFFFF6B6B), + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => _loadWorkbooks(forceRefresh: true), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFFF6B6B), + foregroundColor: Colors.white, + ), + child: const Text('다시 시도'), + ), + ], + ), + ), ); } Widget _buildToggle() { + final screenWidth = MediaQuery.of(context).size.width; + // 토글 크기 계산 (Figma 비율: 28:17 유지) + final toggleWidth = screenWidth * 0.07; + final toggleHeight = toggleWidth * (17 / 28); // 비율 유지 + final circleSize = toggleWidth * (13 / 28); // 비율 유지 + final circlePadding = toggleWidth * (2 / 28); // 비율 유지 + return Row( children: [ // 토글 스위치 @@ -227,12 +253,8 @@ class _WorkbookPageState extends State { }); }, child: Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.12, - minWidth: 40, - maxHeight: 20, - minHeight: 16, - ), + width: toggleWidth, + height: toggleHeight, decoration: BoxDecoration( gradient: const LinearGradient( colors: [Color(0xFFAC5BF8), Color(0xFF7C3AED)], @@ -247,15 +269,9 @@ class _WorkbookPageState extends State { ? Alignment.centerLeft : Alignment.centerRight, child: Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.05, - minWidth: 7, - maxHeight: MediaQuery.of(context).size.width * 0.05, - minHeight: 7, - ), - margin: EdgeInsets.symmetric( - horizontal: MediaQuery.of(context).size.width * 0.005, - ), + width: circleSize, + height: circleSize, + margin: EdgeInsets.all(circlePadding), decoration: const BoxDecoration( color: Colors.white, shape: BoxShape.circle, @@ -264,7 +280,7 @@ class _WorkbookPageState extends State { ), ), ), - Container(width: MediaQuery.of(context).size.width * 0.03), + Container(width: screenWidth * 0.03), // 토글 라벨 Text( _currentView == WorkbookViewType.byClass ? '최근 클래스 순' : '최근 문제집 순', @@ -280,6 +296,19 @@ class _WorkbookPageState extends State { } Widget _buildClassView() { + if (_classData.isEmpty) { + final screenHeight = MediaQuery.of(context).size.height; + return SizedBox( + height: screenHeight * 0.6, + child: const Center( + child: EmptyStateMessage.classRoom( + title: '등록된 클래스가 없어요.', + description: '학원에서 클래스를 배정해주면 이곳에 표시돼요.', + ), + ), + ); + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -290,7 +319,8 @@ class _WorkbookPageState extends State { separatorBuilder: (context, index) => Container(height: MediaQuery.of(context).size.height * 0.02), itemBuilder: (context, index) { - return _buildClassCard(_classData[index]); + final classItem = _classData[index]; + return _buildClassCard(classItem); }, ), ], @@ -329,21 +359,23 @@ class _WorkbookPageState extends State { Container(height: MediaQuery.of(context).size.height * 0.02), // 문제집 썸네일과 진행률 리스트 - Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: List.generate( - classData.workbooks.length, - (index) => Padding( - padding: EdgeInsets.only( - right: index < classData.workbooks.length - 1 - ? MediaQuery.of(context).size.width * 0.04 - : 0, + classData.workbooks.isEmpty + ? const SizedBox.shrink() + : Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: List.generate(classData.workbooks.length, (index) { + final workbook = classData.workbooks[index]; + return Padding( + padding: EdgeInsets.only( + right: index < classData.workbooks.length - 1 + ? MediaQuery.of(context).size.width * 0.04 + : 0, + ), + child: _buildWorkbookProgress(workbook), + ); + }), ), - child: _buildWorkbookProgress(classData.workbooks[index]), - ), - ), - ), ], ), ); @@ -354,57 +386,77 @@ class _WorkbookPageState extends State { final thumbnailWidth = screenWidth * 0.15; // 화면 너비의 15% final thumbnailHeight = thumbnailWidth * 1.33; // 3:4 비율 유지 - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 문제집 썸네일 - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Container( - width: thumbnailWidth, - height: thumbnailHeight, - color: const Color(0xFFE9ECEF), - child: Image.asset( - workbook.thumbnailPath, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return const Center( - child: Icon(Icons.book, color: Color(0xFF999999)), - ); - }, + return GestureDetector( + onTap: () { + if (workbook.bookId == null) { + // bookId가 없으면 이동하지 않음 + return; + } + // WorkbookDetailPage로 이동 + Navigator.pushNamed( + context, + '/workbook/detail', + arguments: { + 'workbookName': workbook.name, + 'thumbnailPath': workbook.thumbnailPath, + 'bookId': workbook.bookId, + 'academyUserId': workbook.academyUserId, + }, + ); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 문제집 썸네일 + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Container( + width: thumbnailWidth, + height: thumbnailHeight, + color: const Color(0xFFE9ECEF), + child: _buildThumbnailImage(workbook.thumbnailPath), ), ), - ), - Container(height: MediaQuery.of(context).size.height * 0.01), - // 진행률 바 - SizedBox( - width: thumbnailWidth, - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Container( - height: 6, - decoration: const BoxDecoration(color: Color(0xFFE9ECEF)), - child: FractionallySizedBox( - alignment: Alignment.centerLeft, - widthFactor: workbook.progress / 100, - child: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [Color(0xFFAC5BF8), Color(0xFF7C3AED)], - begin: Alignment.centerLeft, - end: Alignment.centerRight, + Container(height: MediaQuery.of(context).size.height * 0.01), + // 진행률 바 + SizedBox( + width: thumbnailWidth, + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Container( + height: 6, + decoration: const BoxDecoration(color: Color(0xFFE9ECEF)), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: workbook.progress / 100, + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xFFAC5BF8), Color(0xFF7C3AED)], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), ), ), ), ), ), ), - ), - ], + ], + ), ); } Widget _buildWorkbookView() { + if (_workbookData.isEmpty) { + return const Center( + child: EmptyStateMessage.workbook( + title: '현재 등록된 문제집이 없어요.', + description: '선생님이 문제집을 배정하면 이곳에 표시돼요.', + ), + ); + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -423,178 +475,167 @@ class _WorkbookPageState extends State { } Widget _buildWorkbookCard(WorkbookData workbookData) { - return Container( - padding: EdgeInsets.all(MediaQuery.of(context).size.width * 0.04), - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: const Color(0xFFE9ECEF)), - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withAlpha(13), - offset: const Offset(0, 2), - blurRadius: 8, - ), - ], - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 문제집 썸네일 - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.18, - minWidth: 60, - maxHeight: MediaQuery.of(context).size.width * 0.23, - minHeight: 80, - ), - color: const Color(0xFFE74C3C), - child: Image.asset( - workbookData.thumbnailPath, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return const Center( - child: Text( - 'blacklabel', - style: TextStyle( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w700, - fontSize: 10, - color: Colors.white, - ), - ), - ); - }, + return GestureDetector( + onTap: () { + if (workbookData.bookId == null) { + // bookId가 없으면 이동하지 않음 + return; + } + // WorkbookDetailPage로 이동 + Navigator.pushNamed( + context, + '/workbook/detail', + arguments: { + 'workbookName': workbookData.workbookName, + 'thumbnailPath': workbookData.thumbnailPath, + 'bookId': workbookData.bookId, + 'academyUserId': workbookData.academyUserId, + }, + ); + }, + child: Container( + padding: EdgeInsets.all(MediaQuery.of(context).size.width * 0.04), + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: const Color(0xFFE9ECEF)), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(13), + offset: const Offset(0, 2), + blurRadius: 8, + ), + ], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 문제집 썸네일 + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.18, + minWidth: 60, + maxHeight: MediaQuery.of(context).size.width * 0.23, + minHeight: 80, + ), + color: const Color(0xFFE74C3C), + child: _buildThumbnailImage(workbookData.thumbnailPath), ), ), - ), - Container(width: MediaQuery.of(context).size.width * 0.04), - - // 문제집 정보 - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 문제집명 - Text( - workbookData.workbookName, - style: const TextStyle( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w700, - fontSize: 16, - color: Color(0xFF333333), + Container(width: MediaQuery.of(context).size.width * 0.04), + + // 문제집 정보 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 문제집명 + Text( + workbookData.workbookName, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 16, + color: Color(0xFF333333), + ), ), - ), - Container(height: MediaQuery.of(context).size.height * 0.01), + Container(height: MediaQuery.of(context).size.height * 0.01), - // 학습 정보 - Text( - '${workbookData.className}에서 진행 중', - style: const TextStyle( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w400, - fontSize: 12, - color: Color(0xFF666666), + // 학습 정보 + Text( + '${workbookData.className}에서 진행 중', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + fontSize: 12, + color: Color(0xFF666666), + ), ), - ), - Container(height: MediaQuery.of(context).size.height * 0.005), + Container(height: MediaQuery.of(context).size.height * 0.005), - // 마지막 학습일 - Text( - '마지막 학습 일 ${workbookData.lastStudyDate}', - style: const TextStyle( - fontFamily: 'Pretendard', - fontWeight: FontWeight.w400, - fontSize: 12, - color: Color(0xFF999999), + // 마지막 학습일 + Text( + '마지막 학습 일 ${workbookData.lastStudyDate}', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + fontSize: 12, + color: Color(0xFF999999), + ), ), - ), - Container(height: MediaQuery.of(context).size.height * 0.015), - - // 진행률 - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Container( - height: 8, - decoration: const BoxDecoration( - color: Color(0xFFE9ECEF), - ), - child: FractionallySizedBox( - alignment: Alignment.centerLeft, - widthFactor: workbookData.progress / 100, - child: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [Color(0xFFAC5BF8), Color(0xFF7C3AED)], - begin: Alignment.centerLeft, - end: Alignment.centerRight, + Container(height: MediaQuery.of(context).size.height * 0.015), + + // 진행률 + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Container( + height: 8, + decoration: const BoxDecoration( + color: Color(0xFFE9ECEF), + ), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: workbookData.progress / 100, + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFAC5BF8), + Color(0xFF7C3AED), + ], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), ), ), ), ), ), - ), - ], - ), - ], + ], + ), + ], + ), ), - ), - ], + ], + ), ), ); } -} -// 클래스 데이터 모델 -class ClassData { - final String className; - final String lastStudyDate; - final List workbooks; - - ClassData({ - required this.className, - required this.lastStudyDate, - required this.workbooks, - }); -} - -// 문제집 정보 모델 (클래스 내부용) -class WorkbookInfo { - final String name; - final String lastStudyDate; - final int progress; - final String thumbnailPath; - - WorkbookInfo({ - required this.name, - required this.lastStudyDate, - required this.progress, - required this.thumbnailPath, - }); -} - -// 문제집 데이터 모델 (문제집 순 페이지용) -class WorkbookData { - final String workbookName; - final String lastStudyDate; - final int progress; - final String thumbnailPath; - final String className; - - WorkbookData({ - required this.workbookName, - required this.lastStudyDate, - required this.progress, - required this.thumbnailPath, - required this.className, - }); + /// 썸네일 이미지 로드 분기 처리 + /// + /// 네트워크 URL인 경우 Image.network 사용, + /// asset 경로인 경우 Image.asset 사용 + Widget _buildThumbnailImage(String thumbnailPath) { + if (thumbnailPath.startsWith('http')) { + return Image.network( + thumbnailPath, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return const Center( + child: Icon(Icons.book, color: Color(0xFF999999)), + ); + }, + ); + } else { + return Image.asset( + thumbnailPath, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return const Center( + child: Icon(Icons.book, color: Color(0xFF999999)), + ); + }, + ); + } + } } diff --git a/frontend/lib/services/academy_service.dart b/frontend/lib/services/academy_service.dart new file mode 100644 index 0000000..6f77547 --- /dev/null +++ b/frontend/lib/services/academy_service.dart @@ -0,0 +1,835 @@ +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:convert'; +import 'auth_service.dart'; +import '../config/api_config.dart'; +import '../utils/app_logger.dart'; + +/// 학원 관련 API 호출 및 캐싱을 담당하는 서비스 +/// +/// DI Container에서 singleton으로 관리되며, +/// AuthService는 생성자 주입을 통해 전달됩니다. +class AcademyService { + final AuthService _authService; + + AcademyService({AuthService? authService}) + : _authService = authService ?? AuthService(); + + // SharedPreferences 키 + static const String _academiesCacheKey = 'user_academies_cache'; + static const String _defaultAcademyCodeKey = 'default_academy_code'; + + // 클래스 정보 캐시 (메모리) + final Map _classCache = {}; + + /// 디폴트 학원 변경 감지용 노티파이어 + final ValueNotifier defaultAcademyVersion = ValueNotifier(0); + + /// 근처 학원 리스트 조회 + Future> getNearbyAcademies({ + required double latitude, + required double longitude, + }) async { + try { + // JWT 토큰 가져오기 + final token = await _authService.getAccessToken(); + if (token == null) { + throw Exception('인증 토큰이 없습니다. 로그인이 필요합니다.'); + } + + // API 엔드포인트 구성 (radius, page, size는 백엔드 디폴트값 사용) + // 에뮬레이터/실기기에서 전달받은 현재 위치(lat, lng)를 그대로 사용 + final uri = Uri.parse( + '${ApiConfig.baseUrl}/academy/nearby?lat=$latitude&lng=$longitude', + ); + + // API 호출 + final response = await http.get( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ); + + if (response.statusCode == 200) { + final List data = json.decode(response.body); + return data.map((item) => AcademyResponse.fromJson(item)).toList(); + } else if (response.statusCode == 401) { + throw Exception('인증에 실패했습니다. 다시 로그인해주세요.'); + } else { + throw Exception('학원 정보를 가져오는데 실패했습니다: ${response.statusCode}'); + } + } catch (e) { + rethrow; + } + } + + /// 사용자 등록 학원 리스트 조회 + Future> getUserAcademies(String userId) async { + try { + final token = await _authService.getAccessToken(); + if (token == null) { + throw Exception('인증 토큰이 없습니다. 로그인이 필요합니다.'); + } + + final uri = Uri.parse( + '${ApiConfig.baseUrl}/academy/academy-users/$userId/academies', + ); + + final response = await http.get( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ); + + if (response.statusCode == 200) { + try { + final List data = json.decode(response.body); + return data.map((item) { + try { + return UserAcademyResponse.fromJson(item); + } catch (e) { + rethrow; + } + }).toList(); + } catch (e) { + throw Exception('학원 정보 파싱에 실패했습니다: $e'); + } + } else if (response.statusCode == 404) { + // 404는 빈 배열 반환 (학원이 없는 경우) + return []; + } else if (response.statusCode == 401) { + throw Exception('인증에 실패했습니다. 다시 로그인해주세요.'); + } else { + throw Exception('학원 정보를 가져오는데 실패했습니다: ${response.statusCode}'); + } + } catch (e) { + rethrow; + } + } + + /// 학원 등록 요청 + /// + /// 응답: status code만 확인 (200/201이면 성공) + /// 응답 body는 파싱하지 않으며, academyId, userId를 받지 않음 + Future joinAcademyRequest({ + required String academy_id, + required int user_id, + }) async { + try { + final token = await _authService.getAccessToken(); + if (token == null) { + throw Exception('인증 토큰이 없습니다. 로그인이 필요합니다.'); + } + + final uri = Uri.parse( + '${ApiConfig.baseUrl}/academy/academy-users/join/request', + ); + + final requestBody = json.encode({ + 'academy': {'academy_code': academy_id}, + 'user_id': user_id, + // TODO: 실제 클래스 연동 시 서버 스키마에 맞게 값 교체 + // 현재는 항상 0을 전달 (백엔드 기본 반/전체 반 등 처리용) + 'class_id': 0, + }); + + final response = await http.post( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + body: requestBody, + ); + + if (response.statusCode == 200 || + response.statusCode == 201 || + response.statusCode == 202) { + // 성공 응답이므로 아무것도 반환하지 않음 (응답 body 파싱 불필요) + return; + } else if (response.statusCode == 401) { + throw Exception('인증에 실패했습니다. 다시 로그인해주세요.'); + } else { + // 에러 응답 처리 (응답 body가 JSON이 아닐 수 있으므로 안전하게 처리) + String errorMessage = '학원 등록 요청에 실패했습니다: ${response.statusCode}'; + + // 요청 본문 정보를 에러 메시지에 포함 + String requestInfo = '\n전송된 데이터: $requestBody'; + + if (response.body.isNotEmpty) { + try { + final errorBody = json.decode(response.body); + final serverMessage = + errorBody['message'] ?? + errorBody['error'] ?? + errorBody['detail'] ?? + response.body; + errorMessage = '$errorMessage\n서버 응답: $serverMessage$requestInfo'; + } catch (e) { + // JSON 파싱 실패 시 응답 body를 그대로 사용 + errorMessage = '$errorMessage\n서버 응답: ${response.body}$requestInfo'; + } + } else { + errorMessage = '$errorMessage$requestInfo'; + } + + throw Exception(errorMessage); + } + } catch (e) { + rethrow; + } + } + + /// 학원 탈퇴 요청 + Future leaveAcademy(int academyUserId) async { + try { + final token = await _authService.getAccessToken(); + if (token == null) { + throw Exception('인증 토큰이 없습니다. 다시 로그인해주세요.'); + } + + final uri = Uri.parse( + '${ApiConfig.baseUrl}/academy/academy-users/$academyUserId/leave', + ); + + final response = await http.delete( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ); + + if (response.statusCode == 200 || response.statusCode == 202) { + await _removeAcademyFromCache(academyUserId); + return; + } else if (response.statusCode == 401) { + throw Exception('인증에 실패했습니다. 다시 로그인해주세요.'); + } else { + final message = response.body.isNotEmpty ? response.body : '응답 없음'; + throw Exception('학원 탈퇴 요청에 실패했습니다: $message'); + } + } catch (e) { + rethrow; + } + } + + // TODO: 백엔드에서 academyId로 학원 상세 정보를 가져오는 API가 구현 중입니다. + // 완료 후 frontend에서도 구현 예정입니다. + + /// 학원 목록을 SharedPreferences에 저장 (사용자별) + Future saveAcademiesToCache(List academies) async { + try { + final prefs = await SharedPreferences.getInstance(); + final userId = await _authService.getUserId(); + if (userId == null) { + return; + } + final cacheKey = '${_academiesCacheKey}_$userId'; + final jsonData = json.encode( + academies.map((academy) => academy.toJson()).toList(), + ); + await prefs.setString(cacheKey, jsonData); + // 구 버전 캐시 제거 + if (prefs.containsKey(_academiesCacheKey)) { + await prefs.remove(_academiesCacheKey); + } + } catch (e) { + // 캐시 저장 실패는 무시 + } + } + + /// SharedPreferences에서 학원 목록 로드 (사용자별) + Future?> loadAcademiesFromCache() async { + try { + final prefs = await SharedPreferences.getInstance(); + final userId = await _authService.getUserId(); + if (userId == null) { + return null; + } + final cacheKey = '${_academiesCacheKey}_$userId'; + final cachedJson = prefs.getString(cacheKey); + if (cachedJson == null) { + // 구 버전 캐시 제거 + if (prefs.containsKey(_academiesCacheKey)) { + await prefs.remove(_academiesCacheKey); + } + return null; + } + + final List data = json.decode(cachedJson); + return data.map((item) => UserAcademyResponse.fromJson(item)).toList(); + } catch (e) { + return null; + } + } + + /// 디폴트 학원 코드를 SharedPreferences에 저장 + Future saveDefaultAcademyCode(String academyCode) async { + try { + final prefs = await SharedPreferences.getInstance(); + final currentCode = prefs.getString(_defaultAcademyCodeKey); + if (currentCode == academyCode) { + return; + } + + await prefs.setString(_defaultAcademyCodeKey, academyCode); + defaultAcademyVersion.value++; + } catch (e) { + // 캐시 저장 실패는 무시 + } + } + + /// SharedPreferences에서 디폴트 학원 코드 로드 + Future getDefaultAcademyCode() async { + try { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_defaultAcademyCodeKey); + } catch (e) { + return null; + } + } + + /// 디폴트 학원 선택 로직 + /// + /// 선택 우선순위: + /// 1. SharedPreferences에 저장된 마지막 선택 학원 (registerStatus == 'Y'인 경우만) + /// 2. registerStatus == 'Y'인 첫 번째 학원 + /// 3. 없으면 null 반환 + String? selectDefaultAcademy(List academies) { + if (academies.isEmpty) { + return null; + } + + // 등록완료된 학원만 필터링 + final registeredAcademies = academies + .where((academy) => academy.registerStatus == 'Y') + .toList(); + + if (registeredAcademies.isEmpty) { + return null; + } + + // 1. SharedPreferences에서 마지막 선택한 학원 코드 확인 + // (비동기이므로 동기적으로 처리하기 위해 Future를 사용하지 않음) + // 대신 academies 리스트에서 직접 확인 + // 이 메서드는 나중에 getDefaultAcademyCode()와 함께 사용됨 + + // 2. 등록완료된 첫 번째 학원 반환 + final firstRegistered = registeredAcademies.first; + if (firstRegistered.academyCode != null) { + return firstRegistered.academyCode; + } + + return null; + } + + /// 마지막 선택한 학원 코드와 현재 학원 목록을 비교하여 디폴트 학원 선택 + /// + /// 이 메서드는 비동기로 SharedPreferences를 확인하고, + /// 저장된 학원 코드가 현재 목록에 있고 등록완료 상태인지 확인 + Future selectDefaultAcademyAsync( + List academies, + ) async { + if (academies.isEmpty) { + return null; + } + + // 등록완료된 학원만 필터링 + final registeredAcademies = academies + .where((academy) => academy.registerStatus == 'Y') + .toList(); + + if (registeredAcademies.isEmpty) { + return null; + } + + // 1. SharedPreferences에서 마지막 선택한 학원 코드 확인 + final savedAcademyCode = await getDefaultAcademyCode(); + + if (savedAcademyCode != null) { + // 저장된 학원 코드가 현재 목록에 있고 등록완료 상태인지 확인 + final savedAcademy = registeredAcademies.firstWhere( + (academy) => academy.academyCode == savedAcademyCode, + orElse: () => registeredAcademies.first, + ); + + if (savedAcademy.academyCode != null) { + return savedAcademy.academyCode; + } + } + + // 2. 저장된 학원이 없거나 유효하지 않으면 등록완료된 첫 번째 학원 반환 + final firstRegistered = registeredAcademies.first; + if (firstRegistered.academyCode != null) { + return firstRegistered.academyCode; + } + + return null; + } + + /// 학원 코드로 학원 정보 조회 (캐시에서) + Future getAcademyByCode(String academyCode) async { + final academies = await loadAcademiesFromCache(); + if (academies == null) { + return null; + } + + try { + return academies.firstWhere( + (academy) => academy.academyCode == academyCode, + ); + } catch (e) { + return null; + } + } + + /// 클래스 정보 조회 + /// + /// 여러 assigneeId를 받아서 academyUserId와 className 쌍을 반환합니다. + /// assigneeId와 academyUserId는 항상 일치하므로, academyUserId를 키로 사용합니다. + /// + /// [assigneeIds]: 클래스 정보를 조회할 assigneeId 리스트 + /// + /// 반환값: Map 형태 + /// 예: {"10": "조성제 선생님 3반", "11": "조성제 선생님 3반", "12": "오세종 선생님 3반"} + Future> getClassesByAssigneeIds( + List assigneeIds, + ) async { + if (assigneeIds.isEmpty) { + return {}; + } + + // 1. 캐시에서 확인 + final uncachedIds = assigneeIds + .where((id) => !_classCache.containsKey(id)) + .toList(); + + // 2. 캐시에 없는 ID들만 API 호출 + if (uncachedIds.isNotEmpty) { + try { + final token = await _authService.getAccessToken(); + if (token == null) { + throw Exception('인증 토큰이 없습니다. 로그인이 필요합니다.'); + } + + final uri = ApiConfig.getAcademyClassesUri(uncachedIds); + + final response = await http.get( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ); + + if (response.statusCode == 200) { + final List data = json.decode(response.body); + + // academyUserId == assigneeId 이므로, academyUserId를 키로 사용 + for (var item in data) { + final academyUserId = item['academyUserId']?.toString() ?? ''; + final className = item['className']?.toString() ?? ''; + + if (academyUserId.isNotEmpty && className.isNotEmpty) { + _classCache[academyUserId] = className; + } + } + } else if (response.statusCode == 401) { + throw Exception('인증에 실패했습니다. 다시 로그인해주세요.'); + } else { + // API 실패 시 빈 맵 반환 (에러는 throw하지 않음) + } + } catch (e) { + // 에러 발생 시에도 캐시된 데이터는 반환 + } + } + + // 3. 캐시에서 결과 반환 (요청한 모든 ID에 대해) + final result = Map.fromEntries( + assigneeIds.map((id) => MapEntry(id, _classCache[id] ?? '')), + ); + return result; + } + + /// 클래스 정보 캐시 초기화 + void clearClassCache() { + _classCache.clear(); + } + + /// 학원 코드로 학원 정보 및 스케줄 조회 + Future getAcademySchedule(String academyCode) async { + appLog('[academy:academy_service] API 호출 시작 - academyCode: $academyCode'); + + try { + // ensureValidAccessToken 사용 (다른 서비스와 일관성 유지) + final token = await _authService.ensureValidAccessToken(); + if (token == null) { + appLog('[academy:academy_service] 인증 토큰 없음'); + throw Exception('인증 토큰이 없습니다. 로그인이 필요합니다.'); + } + + final uri = Uri.parse( + '${ApiConfig.baseUrl}/academy/academy-schedules/$academyCode', + ); + + appLog('[academy:academy_service] GET 요청: $uri'); + + final response = await http.get( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ); + + appLog('[academy:academy_service] 응답 상태 코드: ${response.statusCode}'); + appLog('[academy:academy_service] 응답 본문: ${response.body}'); + + if (response.statusCode == 200) { + final Map data = json.decode(response.body); + appLog('[academy:academy_service] 응답 파싱 성공'); + appLog('[academy:academy_service] 파싱된 데이터: $data'); + + final result = AcademyScheduleResponse.fromJson(data); + appLog( + '[academy:academy_service] AcademyScheduleResponse 생성 완료 - academyName: ${result.academy.academyName}, schedules 개수: ${result.schedules.length}', + ); + + return result; + } else if (response.statusCode == 401) { + appLog('[academy:academy_service] 인증 실패 (401)'); + throw Exception('인증에 실패했습니다. 다시 로그인해주세요.'); + } else if (response.statusCode == 404) { + appLog('[academy:academy_service] 학원 정보 없음 (404)'); + throw Exception('학원 정보를 찾을 수 없습니다.'); + } else { + // 로그에는 상세 정보, 예외에는 일반 메시지 + appLog( + '[academy:academy_service] API 호출 실패 - status: ${response.statusCode}', + ); + throw Exception('학원 정보를 가져오지 못했습니다.'); + } + } catch (e) { + appLog('[academy:academy_service] 에러 발생: $e'); + rethrow; + } + } + + Future _removeAcademyFromCache(int academyUserId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final userId = await _authService.getUserId(); + if (userId == null) return; + + final cacheKey = '${_academiesCacheKey}_$userId'; + final cachedJson = prefs.getString(cacheKey); + if (cachedJson == null) return; + + final List data = json.decode(cachedJson); + data.removeWhere((item) => item['academy_user_id'] == academyUserId); + await prefs.setString(cacheKey, json.encode(data)); + } catch (e) { + // 캐시 제거 실패는 무시 + } + } + + /// 학원 이미지 리스트 조회 + /// + /// [academyCode]: 학원 코드 + /// 반환값: AcademyImageResponse (academy_code, academy_image_urls, main_image_url) + Future getAcademyImages(String academyCode) async { + try { + final token = await _authService.ensureValidAccessToken(); + if (token == null) { + throw Exception('인증 토큰이 없습니다. 로그인이 필요합니다.'); + } + + final uri = Uri.parse('${ApiConfig.baseUrl}/academy/images/$academyCode'); + + final response = await http.get( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ); + + if (response.statusCode == 200) { + final Map data = json.decode(response.body); + return AcademyImageResponse.fromJson(data); + } else if (response.statusCode == 401) { + throw Exception('인증에 실패했습니다. 다시 로그인해주세요.'); + } else if (response.statusCode == 404) { + throw Exception('학원 이미지를 찾을 수 없습니다.'); + } else { + throw Exception('학원 이미지를 가져오지 못했습니다.'); + } + } catch (e) { + rethrow; + } + } +} + +/// 학원 응답 모델 +class AcademyResponse { + final String academyName; + final String academyRoadAddress; + final double distanceKm; + final String? academyCode; // 학원 코드 + + AcademyResponse({ + required this.academyName, + required this.academyRoadAddress, + required this.distanceKm, + this.academyCode, + }); + + factory AcademyResponse.fromJson(Map json) { + return AcademyResponse( + academyName: json['academyName'] ?? '', + academyRoadAddress: json['academyRoadAddress'] ?? '', + distanceKm: (json['distanceKM'] ?? json['distanceKm'] ?? 0.0).toDouble(), + academyCode: json['academyCode']?.toString(), + ); + } +} + +/// 사용자 등록 학원 응답 모델 +class UserAcademyResponse { + // 추가 필드들 (snake_case) + final int? academy_user_id; + final int? academy_id; + final int? class_id; + final int? user_id; + final int? learner_id; + + // 기존 필드들 (camelCase로 유지 - UI에서 사용) + final String academyName; + final String academyRoadAddress; + final String registerStatus; // 'Y' 또는 'P' + final String? academyCode; // 학원 코드 + + UserAcademyResponse({ + this.academy_user_id, + this.academy_id, + this.class_id, + this.user_id, + this.learner_id, + required this.academyName, + required this.academyRoadAddress, + required this.registerStatus, + this.academyCode, + }); + + factory UserAcademyResponse.fromJson(Map json) { + try { + // academy 객체 추출 (null 안전) + final academy = json['academy'] as Map? ?? {}; + + final academyName = academy['academy_name']?.toString() ?? ''; + final academyCode = academy['academy_code']?.toString(); + final registerStatus = json['register_status']?.toString() ?? 'P'; + + return UserAcademyResponse( + // 추가 필드들 (snake_case) + academy_user_id: json['academy_user_id'] as int?, + academy_id: json['academy_id'] as int?, + class_id: json['class_id'] as int?, + user_id: json['user_id'] as int?, + learner_id: json['learner_id'] as int?, + + // academy 객체에서 추출한 필드들 + academyName: academyName, + academyRoadAddress: academy['academy_road_address']?.toString() ?? '', + registerStatus: registerStatus, + academyCode: academyCode, + ); + } catch (e) { + rethrow; + } + } + + /// JSON으로 변환 (SharedPreferences 저장용) + Map toJson() { + return { + 'academy_user_id': academy_user_id, + 'academy_id': academy_id, + 'class_id': class_id, + 'user_id': user_id, + 'learner_id': learner_id, + 'academyName': academyName, + 'academyRoadAddress': academyRoadAddress, + 'registerStatus': registerStatus, + 'academyCode': academyCode, + }; + } +} + +/// 학원 스케줄 API 응답 모델 +class AcademyScheduleResponse { + final AcademyInfo academy; + final List schedules; + + AcademyScheduleResponse({required this.academy, required this.schedules}); + + factory AcademyScheduleResponse.fromJson(Map json) { + return AcademyScheduleResponse( + academy: AcademyInfo.fromJson(json['academy'] as Map), + schedules: + (json['schedules'] as List?) + ?.map((item) => Schedule.fromJson(item as Map)) + .toList() ?? + [], + ); + } +} + +/// 학원 정보 DTO (API 응답 전용) +/// +/// 주의: AcademyData와 중복 가능성이 있습니다. +/// 나중에 도메인 모델 통합을 고려해야 합니다. +class AcademyInfo { + final String academyName; + final String academyCode; + final String academyDescription; + final String academyPhone; + final String academyEmail; + final String academyWebsite; + final String academyRoadAddress; + final String academyDetailAddress; + final double academyLatitude; + final double academyLongitude; + + AcademyInfo({ + required this.academyName, + required this.academyCode, + required this.academyDescription, + required this.academyPhone, + required this.academyEmail, + required this.academyWebsite, + required this.academyRoadAddress, + required this.academyDetailAddress, + required this.academyLatitude, + required this.academyLongitude, + }); + + factory AcademyInfo.fromJson(Map json) { + return AcademyInfo( + academyName: json['academy_name'] as String? ?? '', + academyCode: json['academy_code'] as String? ?? '', + academyDescription: json['academy_description'] as String? ?? '', + academyPhone: json['academy_phone'] as String? ?? '', + academyEmail: json['academy_email'] as String? ?? '', + academyWebsite: json['academy_website'] as String? ?? '', + academyRoadAddress: json['academy_road_address'] as String? ?? '', + academyDetailAddress: json['academy_detail_address'] as String? ?? '', + academyLatitude: (json['academy_latitude'] as num?)?.toDouble() ?? 0.0, + academyLongitude: (json['academy_longitude'] as num?)?.toDouble() ?? 0.0, + ); + } +} + +/// 학원 스케줄 모델 +class Schedule { + final int id; + final String academyCode; + final int dayOfWeek; // 0=일요일, 1=월요일, ..., 6=토요일 + final String startTime; // "HH:MM:SS" + final String endTime; // "HH:MM:SS" + + Schedule({ + required this.id, + required this.academyCode, + required this.dayOfWeek, + required this.startTime, + required this.endTime, + }); + + factory Schedule.fromJson(Map json) { + return Schedule( + id: json['id'] as int? ?? 0, + academyCode: json['academy_code'] as String? ?? '', + dayOfWeek: json['day_of_week'] as int? ?? 0, + startTime: json['start_time'] as String? ?? '', + endTime: json['end_time'] as String? ?? '', + ); + } + + /// dayOfWeek를 요일 이름으로 변환 + /// + /// 범위 체크를 통해 0~6 외 값이 들어와도 안전하게 처리합니다. + String get dayName { + const days = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일']; + // 범위 체크: 0~6 외 값 방어 + if (dayOfWeek < 0 || dayOfWeek > 6) { + return '알 수 없음'; + } + return days[dayOfWeek]; + } + + /// 시간 포맷팅 (HH:MM:SS → HH:MM AM/PM) + /// + /// TODO: 나중에 시간 관련 로직이 복잡해지면 DateTime/TimeOfDay로 파싱 고려 + /// TODO: 국제화 필요 시 intl 패키지 DateFormat 사용 고려 + String get formattedStartTime { + return _formatTime(startTime); + } + + String get formattedEndTime { + return _formatTime(endTime); + } + + String _formatTime(String timeStr) { + if (timeStr.isEmpty) return ''; + try { + final parts = timeStr.split(':'); + if (parts.length >= 2) { + final hour = int.parse(parts[0]); + final minute = parts[1]; + if (hour == 0) { + return '12:$minute AM'; + } else if (hour < 12) { + return '$hour:$minute AM'; + } else if (hour == 12) { + return '12:$minute PM'; + } else { + return '${hour - 12}:$minute PM'; + } + } + } catch (e) { + // 파싱 실패 시 원본 반환 + } + return timeStr; + } +} + +/// 학원 이미지 API 응답 모델 +class AcademyImageResponse { + final String academyCode; + final List academyImageUrls; + final String? mainImageUrl; + + AcademyImageResponse({ + required this.academyCode, + required this.academyImageUrls, + this.mainImageUrl, + }); + + factory AcademyImageResponse.fromJson(Map json) { + return AcademyImageResponse( + academyCode: json['academy_code'] as String? ?? '', + academyImageUrls: + (json['academy_image_urls'] as List?) + ?.map((url) => url.toString()) + .toList() ?? + [], + mainImageUrl: json['main_image_url']?.toString(), + ); + } +} diff --git a/frontend/lib/services/assessment_api.dart b/frontend/lib/services/assessment_api.dart new file mode 100644 index 0000000..46bb2dd --- /dev/null +++ b/frontend/lib/services/assessment_api.dart @@ -0,0 +1,149 @@ +import 'dart:convert'; +import 'dart:developer' as developer; + +import 'package:http/http.dart' as http; + +import '../config/api_config.dart'; +import '../models/assessment.dart'; +import '../utils/app_logger.dart'; +import 'auth_service.dart'; + +/// 서버와 통신하여 Assessment 데이터를 가져오는 전용 API 레이어. +class AssessmentApi { + AssessmentApi({ + required AuthService authService, + required http.Client httpClient, + }) : _authService = authService, + _httpClient = httpClient; + + final AuthService _authService; + final http.Client _httpClient; + + /// 지정된 학원 사용자의 특정 월 데이터를 조회합니다. + /// + /// [monthStart]는 해당 월의 첫째 날(UTC)이어야 합니다. + Future>> fetchAssessmentsForMonth({ + required String userAcademyId, + required DateTime monthStart, + }) async { + final token = await _authService.ensureValidAccessToken(); + if (token == null) { + throw Exception('인증 토큰이 없습니다. 로그인이 필요합니다.'); + } + + final uri = ApiConfig.getAssessmentsAssigneeUri( + userAcademyId, + dateTime: monthStart, + ); + print( + '[AssessmentApi] GET $uri | academy: $userAcademyId, monthStart: $monthStart', + ); + + // appLog로 API 호출 정보 기록 + appLog( + '[continuous_learning:assessment_api] API 호출 시작 - URL: $uri, userAcademyId: $userAcademyId, monthStart: $monthStart', + ); + + final response = await _httpClient + .get( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ) + .timeout(const Duration(seconds: 10)); + final previewLength = response.body.length > 200 + ? 200 + : response.body.length; + final previewBody = response.body.substring(0, previewLength); + print( + '[AssessmentApi] Response status: ${response.statusCode} | body preview: $previewBody', + ); + + // appLog로 API 응답 정보 기록 + appLog( + '[continuous_learning:assessment_api] API 응답 - status: ${response.statusCode}, body length: ${response.body.length}, preview: $previewBody', + ); + + if (response.statusCode == 401) { + throw Exception('인증에 실패했습니다. 다시 로그인해주세요.'); + } + if (response.statusCode != 200) { + throw Exception('Assessment API 호출 실패 (status: ${response.statusCode})'); + } + + try { + final dynamic data = json.decode(response.body); + final Map> monthData = {}; + + if (data is List) { + for (final item in data) { + final assessment = Assessment.fromJson(item); + final deadlineDate = item['assessDeadline'] as String?; + if (deadlineDate == null) continue; + + final formattedDate = _normalizeDate(deadlineDate); + monthData.putIfAbsent(formattedDate, () => []).add(assessment); + } + } else if (data is Map) { + data.forEach((key, value) { + if (value is List) { + monthData[key.toString()] = value + .map((item) => Assessment.fromJson(item)) + .toList(); + } + }); + } else { + developer.log('⚠️ [AssessmentApi] 알 수 없는 응답 형식: $data'); + } + + // appLog로 파싱된 데이터 정보 기록 + appLog( + '[continuous_learning:assessment_api] 데이터 파싱 완료 - 날짜별 과제 수: ${monthData.length}개 날짜', + ); + monthData.forEach((date, assessments) { + appLog( + '[continuous_learning:assessment_api] - $date: ${assessments.length}개 과제', + ); + }); + + return monthData; + } catch (e) { + developer.log('❌ [AssessmentApi] 응답 파싱 실패: $e'); + rethrow; + } + } + + /// 다양한 날짜 문자열을 YYYY-MM-DD 형식으로 정규화합니다. + String _normalizeDate(String date) { + try { + if (date.length == 10 && date.contains('-') && !date.contains('T')) { + return date; + } + + if (date.contains('T')) { + final parsed = DateTime.parse(date); + return _formatDate(parsed); + } + + if (date.length == 8 && !date.contains('-') && !date.contains('/')) { + return '${date.substring(0, 4)}-${date.substring(4, 6)}-${date.substring(6, 8)}'; + } + + if (date.contains('/')) { + return date.replaceAll('/', '-'); + } + + final parsed = DateTime.parse(date); + return _formatDate(parsed); + } catch (e) { + developer.log('⚠️ [AssessmentApi] 날짜 정규화 실패: $date, error: $e'); + return date; + } + } + + String _formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } +} diff --git a/frontend/lib/services/assessment_local_store.dart b/frontend/lib/services/assessment_local_store.dart new file mode 100644 index 0000000..4f1e44b --- /dev/null +++ b/frontend/lib/services/assessment_local_store.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; +import 'dart:developer' as developer; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/assessment.dart'; + +/// SharedPreferences 기반 로컬 스냅샷 저장소. +class AssessmentLocalStore { + AssessmentLocalStore({SharedPreferences? preferences}) + : _prefs = preferences; + + SharedPreferences? _prefs; + + static const String _cacheVersionKey = 'assessment_cache_version'; + static const int _currentCacheVersion = 3; + + Future get _prefsInstance async { + _prefs ??= await SharedPreferences.getInstance(); + return _prefs!; + } + + Future _ensureVersion() async { + final prefs = await _prefsInstance; + final version = prefs.getInt(_cacheVersionKey) ?? 1; + if (version < _currentCacheVersion) { + await _clearAllWithPrefs(prefs); + await prefs.setInt(_cacheVersionKey, _currentCacheVersion); + developer.log( + '🔄 Assessment 캐시 버전 업데이트: $version -> $_currentCacheVersion', + ); + } + } + + String _monthKey(String academyId, int year, int month) { + final formattedMonth = month.toString().padLeft(2, '0'); + return 'assessments_${academyId}_${year}-$formattedMonth'; + } + + Future?> loadDay({ + required String academyId, + required String date, + }) async { + final parsed = DateTime.tryParse(date); + if (parsed == null) return null; + + final monthData = await loadMonth( + academyId: academyId, + year: parsed.year, + month: parsed.month, + ); + return monthData[date]; + } + + Future>> loadMonth({ + required String academyId, + required int year, + required int month, + }) async { + await _ensureVersion(); + final prefs = await _prefsInstance; + final cacheKey = _monthKey(academyId, year, month); + final cachedJson = prefs.getString(cacheKey); + + if (cachedJson == null) { + return {}; + } + + try { + final Map monthData = json.decode(cachedJson); + final result = >{}; + + monthData.forEach((date, assessmentsJson) { + try { + final assessments = (assessmentsJson as List) + .map((item) => Assessment.fromJson(item)) + .toList(); + result[date] = assessments; + } catch (e) { + developer.log('⚠️ Assessment 캐시 파싱 실패 ($date): $e'); + } + }); + + return result; + } catch (e) { + developer.log('❌ Assessment 캐시 로드 실패: $e'); + return {}; + } + } + + Future saveMonth({ + required String academyId, + required int year, + required int month, + required Map> data, + }) async { + await _ensureVersion(); + final prefs = await _prefsInstance; + final cacheKey = _monthKey(academyId, year, month); + + final Map payload = {}; + data.forEach((date, assessments) { + payload[date] = assessments.map((a) => a.toJson()).toList(); + }); + + await prefs.setString(cacheKey, json.encode(payload)); + developer.log('✅ Assessment 캐시 저장: $cacheKey'); + } + + Future clearMonth({ + required String academyId, + required int year, + required int month, + }) async { + final prefs = await _prefsInstance; + await prefs.remove(_monthKey(academyId, year, month)); + } + + Future clearAll() async { + final prefs = await _prefsInstance; + await _clearAllWithPrefs(prefs); + await prefs.setInt(_cacheVersionKey, _currentCacheVersion); + } + + Future _clearAllWithPrefs(SharedPreferences prefs) async { + final keys = prefs.getKeys().where((key) => key.startsWith('assessments_')); + for (final key in keys) { + await prefs.remove(key); + } + } +} + diff --git a/frontend/lib/services/assessment_repository.dart b/frontend/lib/services/assessment_repository.dart new file mode 100644 index 0000000..25e70dc --- /dev/null +++ b/frontend/lib/services/assessment_repository.dart @@ -0,0 +1,253 @@ +import 'dart:async'; +import 'dart:developer' as developer; + +import '../models/assessment.dart'; +import 'academy_service.dart'; +import 'assessment_api.dart'; +import 'assessment_local_store.dart'; + +/// Assessment 데이터를 위한 단일 진입점. +/// +/// 메모리 캐시 → SharedPreferences → API 순서로 읽고, +/// API 성공 시 SharedPreferences → 메모리 순으로 동기화합니다. +class AssessmentRepository { + AssessmentRepository({ + required AssessmentApi api, + required AssessmentLocalStore localStore, + required AcademyService academyService, + }) : _api = api, + _localStore = localStore, + _academyService = academyService; + + final AssessmentApi _api; + final AssessmentLocalStore _localStore; + final AcademyService _academyService; + + final Map> _memoryCache = {}; + + /// 날짜 단위 키 생성 (academyId|YYYY-MM-DD) + String _cacheKey(String academyId, String date) => '$academyId|$date'; + + DateTime _monthStart(DateTime dateTime) => + DateTime.utc(dateTime.year, dateTime.month, 1); + + /// 읽기: 메모리 → 로컬 → API + Future> getForDate({ + required String academyId, + required String date, + bool forceRefresh = false, + }) async { + final key = _cacheKey(academyId, date); + + if (!forceRefresh && _memoryCache.containsKey(key)) { + return _memoryCache[key]!; + } + + if (!forceRefresh) { + final cached = await _localStore.loadDay( + academyId: academyId, + date: date, + ); + if (cached != null) { + _memoryCache[key] = cached; + _refreshMonthFromServerInBackground( + academyId: academyId, + monthStart: _monthStart(DateTime.parse(date)), + ); + return cached; + } + } + + final monthData = await getForMonth( + academyId: academyId, + dateTime: DateTime.parse(date), + forceRefresh: forceRefresh, + ); + return monthData[date] ?? []; + } + + Future>> getForDates({ + required String academyId, + required List dates, + bool forceRefresh = false, + }) async { + final Map> result = {}; + final Map> datesByMonth = {}; + + for (final date in dates) { + final parsed = DateTime.parse(date); + final monthKey = + '${parsed.year}-${parsed.month.toString().padLeft(2, '0')}'; + datesByMonth.putIfAbsent(monthKey, () => []).add(date); + } + + for (final entry in datesByMonth.entries) { + final year = int.parse(entry.key.split('-')[0]); + final month = int.parse(entry.key.split('-')[1]); + final monthData = await getForMonth( + academyId: academyId, + dateTime: DateTime.utc(year, month, 1), + forceRefresh: forceRefresh, + ); + for (final date in entry.value) { + result[date] = monthData[date] ?? []; + } + } + + return result; + } + + Future>> getForMonth({ + required String academyId, + required DateTime dateTime, + bool forceRefresh = false, + }) async { + final monthStart = _monthStart(dateTime); + final year = monthStart.year; + final month = monthStart.month; + final prefix = '$academyId|${year}-${month.toString().padLeft(2, '0')}-'; + + if (!forceRefresh) { + final fromMemory = _getMonthFromMemoryCache(prefix); + if (fromMemory.isNotEmpty) { + await _updateClassNamesInMonthData(fromMemory); + return fromMemory; + } + } + + if (!forceRefresh) { + final fromLocal = await _localStore.loadMonth( + academyId: academyId, + year: year, + month: month, + ); + if (fromLocal.isNotEmpty) { + _saveMonthToMemoryCache(academyId, fromLocal); + await _updateClassNamesInMonthData(fromLocal); + _refreshMonthFromServerInBackground( + academyId: academyId, + monthStart: monthStart, + ); + return fromLocal; + } + } + + final fromServer = await _api.fetchAssessmentsForMonth( + userAcademyId: academyId, + monthStart: monthStart, + ); + + await _updateClassNamesInMonthData(fromServer); + await _saveMonthSnapshot( + academyId: academyId, + monthStart: monthStart, + data: fromServer, + ); + return fromServer; + } + + Future clearAll() async { + _memoryCache.clear(); + await _localStore.clearAll(); + developer.log('✅ Assessment 캐시 전체 초기화'); + } + + void clearMemory() { + _memoryCache.clear(); + } + + Future _saveMonthSnapshot({ + required String academyId, + required DateTime monthStart, + required Map> data, + }) async { + _saveMonthToMemoryCache(academyId, data); + await _localStore.saveMonth( + academyId: academyId, + year: monthStart.year, + month: monthStart.month, + data: data, + ); + } + + void _saveMonthToMemoryCache( + String academyId, + Map> monthData, + ) { + monthData.forEach((date, assessments) { + _memoryCache[_cacheKey(academyId, date)] = assessments; + }); + } + + Map> _getMonthFromMemoryCache(String prefix) { + final result = >{}; + _memoryCache.forEach((key, value) { + if (key.startsWith(prefix)) { + final date = key.split('|')[1]; + result[date] = value; + } + }); + return result; + } + + void _refreshMonthFromServerInBackground({ + required String academyId, + required DateTime monthStart, + }) { + Future(() async { + try { + final fresh = await _api.fetchAssessmentsForMonth( + userAcademyId: academyId, + monthStart: monthStart, + ); + await _updateClassNamesInMonthData(fresh); + await _saveMonthSnapshot( + academyId: academyId, + monthStart: monthStart, + data: fresh, + ); + developer.log('🔄 Assessment 백그라운드 새로고침 완료'); + } catch (e) { + developer.log('⚠️ Assessment 백그라운드 새로고침 실패: $e'); + } + }); + } + + Future _updateClassNamesInMonthData( + Map> monthData, + ) async { + final Set assigneeIds = {}; + monthData.forEach((_, assessments) { + for (final assessment in assessments) { + if (assessment.assessClass.isNotEmpty && + RegExp(r'^\d+$').hasMatch(assessment.assessClass)) { + assigneeIds.add(assessment.assessClass); + } + } + }); + + if (assigneeIds.isEmpty) { + return; + } + + try { + final classMap = await _academyService.getClassesByAssigneeIds( + assigneeIds.toList(), + ); + + monthData.forEach((date, assessments) { + for (var i = 0; i < assessments.length; i++) { + final assessment = assessments[i]; + if (RegExp(r'^\d+$').hasMatch(assessment.assessClass)) { + final className = classMap[assessment.assessClass]; + if (className != null && className.isNotEmpty) { + assessments[i] = assessment.copyWith(assessClass: className); + } + } + } + }); + } catch (e) { + developer.log('⚠️ 클래스 정보 업데이트 실패: $e'); + } + } +} diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart new file mode 100644 index 0000000..b4e9a73 --- /dev/null +++ b/frontend/lib/services/auth_service.dart @@ -0,0 +1,451 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:http/http.dart' as http; +import 'dart:convert'; +import 'dart:developer' as developer; +import '../config/api_config.dart'; + +/// 인증 및 토큰 관리를 담당하는 서비스 +/// +/// DI Container에서 singleton으로 관리되며, +/// 더 이상 파일 내부에서 직접 싱글톤 패턴을 구현하지 않습니다. +class AuthService { + AuthService(); + + static const String _accessTokenKey = 'access_token'; + static const String _refreshTokenKey = 'refresh_token'; + static const String _userIdKey = 'user_id'; + static const String _autoLoginKey = 'auto_login'; + static const String _saveAccountIdKey = 'save_account_id'; + static const String _savedAccountIdKey = 'saved_account_id'; + + /// Refresh Token 저장 + Future saveRefreshToken(String token) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_refreshTokenKey, token); + developer.log('Refresh token saved successfully'); + } catch (e) { + developer.log('Failed to save refresh token: $e'); + } + } + + /// Access Token 가져오기 + Future getAccessToken() async { + try { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_accessTokenKey); + } catch (e) { + developer.log('Failed to get access token: $e'); + return null; + } + } + + /// Refresh Token 가져오기 + Future getRefreshToken() async { + try { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_refreshTokenKey); + } catch (e) { + developer.log('Failed to get refresh token: $e'); + return null; + } + } + + /// 로그인 상태 확인 + Future isLoggedIn() async { + final token = await getAccessToken(); + return token != null && token.isNotEmpty; + } + + /// JWT payload 디코딩 + Map? _decodeJwtPayload(String token) { + try { + // JWT는 header.payload.signature 형식 + final parts = token.split('.'); + if (parts.length != 3) { + developer.log('Invalid JWT token format'); + return null; + } + + // payload 부분 디코딩 + final payload = parts[1]; + + // Base64 URL 디코딩 (패딩 추가) + String normalizedPayload = payload + .replaceAll('-', '+') + .replaceAll('_', '/'); + switch (normalizedPayload.length % 4) { + case 1: + normalizedPayload += '==='; + break; + case 2: + normalizedPayload += '=='; + break; + case 3: + normalizedPayload += '='; + break; + } + + final decodedBytes = base64Url.decode(normalizedPayload); + final decodedString = utf8.decode(decodedBytes); + return json.decode(decodedString) as Map; + } catch (e) { + developer.log('Error decoding JWT payload: $e'); + return null; + } + } + + /// JWT 토큰 만료 확인 + bool isTokenExpired(String token) { + try { + final payload = _decodeJwtPayload(token); + if (payload == null) return true; + + // exp (expiration) 필드 확인 + final exp = payload['exp'] as int?; + if (exp == null) { + developer.log('Token has no expiration field'); + return true; // exp가 없으면 만료된 것으로 간주 + } + + final expirationTime = DateTime.fromMillisecondsSinceEpoch(exp * 1000); + final isExpired = DateTime.now().isAfter(expirationTime); + + if (isExpired) { + developer.log('Token expired at: ${expirationTime.toIso8601String()}'); + } + + return isExpired; + } catch (e) { + developer.log('Error checking token expiration: $e'); + return true; // 에러 발생 시 만료된 것으로 간주 + } + } + + /// JWT 토큰이 곧 만료될 예정인지 확인 (사전 갱신용) + /// [threshold]: 만료되기 전 얼마나 남았을 때 갱신할지 (기본값: 5분) + bool isTokenExpiringSoon( + String token, { + Duration threshold = const Duration(minutes: 5), + }) { + try { + final payload = _decodeJwtPayload(token); + if (payload == null) return true; + + // exp (expiration) 필드 확인 + final exp = payload['exp'] as int?; + if (exp == null) { + developer.log('Token has no expiration field'); + return true; // exp가 없으면 곧 만료될 것으로 간주 + } + + final expirationTime = DateTime.fromMillisecondsSinceEpoch(exp * 1000); + final now = DateTime.now(); + final timeUntilExpiry = expirationTime.difference(now); + + // 이미 만료되었거나, 임계값 이내로 남았으면 true + final isExpiringSoon = + timeUntilExpiry.isNegative || timeUntilExpiry <= threshold; + + if (isExpiringSoon && !timeUntilExpiry.isNegative) { + developer.log( + 'Token expiring soon: ${timeUntilExpiry.inMinutes} minutes remaining (threshold: ${threshold.inMinutes} minutes)', + ); + } + + return isExpiringSoon; + } catch (e) { + developer.log('Error checking token expiration soon: $e'); + return true; // 에러 발생 시 곧 만료될 것으로 간주 + } + } + + /// JWT 토큰에서 user_id 추출 + String? _extractUserIdFromToken(String token) { + try { + final payloadMap = _decodeJwtPayload(token); + if (payloadMap == null) { + return null; + } + + // user_id 또는 userId 필드 찾기 + final userId = + payloadMap['user_id'] ?? + payloadMap['userId'] ?? + payloadMap['sub'] ?? + payloadMap['id']; + + if (userId != null) { + developer.log('User ID extracted from token: $userId'); + return userId.toString(); + } else { + developer.log('User ID not found in token payload: $payloadMap'); + return null; + } + } catch (e) { + developer.log('Error extracting user ID from token: $e'); + return null; + } + } + + /// Access Token 저장 및 user_id 추출하여 저장 + Future saveAccessToken(String token) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_accessTokenKey, token); + developer.log('Access token saved successfully'); + + // JWT에서 user_id 추출하여 저장 + final userId = _extractUserIdFromToken(token); + if (userId != null) { + await prefs.setString(_userIdKey, userId); + developer.log('User ID saved successfully: $userId'); + } else { + developer.log('Warning: Could not extract user ID from token'); + } + } catch (e) { + developer.log('Failed to save access token: $e'); + } + } + + /// User ID 가져오기 + Future getUserId() async { + try { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getString(_userIdKey); + + // SharedPreferences에 없으면 토큰에서 추출 시도 + if (userId == null) { + final token = await getAccessToken(); + if (token != null) { + final extractedUserId = _extractUserIdFromToken(token); + if (extractedUserId != null) { + await prefs.setString(_userIdKey, extractedUserId); + return extractedUserId; + } + } + } + + return userId; + } catch (e) { + developer.log('Failed to get user ID: $e'); + return null; + } + } + + /// Refresh Token으로 Access Token 갱신 + Future refreshAccessToken() async { + try { + final refreshToken = await getRefreshToken(); + if (refreshToken == null || refreshToken.isEmpty) { + developer.log('❌ No refresh token found'); + return false; + } + + developer.log('🔄 Refreshing access token...'); + + // Refresh Token으로 새 Access Token 요청 + final response = await http + .post( + ApiConfig.getRefreshTokenUri(), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $refreshToken', + }, + ) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode == 200) { + try { + final responseData = json.decode(response.body); + + // 새 Access Token 저장 + if (responseData['accessToken'] != null) { + final newAccessToken = responseData['accessToken'] as String; + await saveAccessToken(newAccessToken); + + // 새 Refresh Token이 있으면 함께 저장 + if (responseData['refreshToken'] != null) { + await saveRefreshToken(responseData['refreshToken'] as String); + } + + developer.log('✅ Access token refreshed successfully'); + return true; + } else { + developer.log('❌ Refresh response missing accessToken'); + return false; + } + } catch (e) { + developer.log('❌ Error parsing refresh response: $e'); + return false; + } + } else if (response.statusCode == 401 || response.statusCode == 403) { + // Refresh Token도 만료된 경우 + developer.log( + '❌ Refresh token expired (${response.statusCode}) - 로그인 필요', + ); + await clearTokens(); + return false; + } else { + developer.log('❌ Failed to refresh token: ${response.statusCode}'); + developer.log(' Response: ${response.body}'); + return false; + } + } catch (e) { + developer.log('❌ Error refreshing access token: $e'); + return false; + } + } + + /// Access Token이 유효한지 확인하고 필요시 갱신 (사전 갱신 방식) + /// 만료되기 5분 전에 자동으로 갱신 시도 + /// 반환값: 유효한 Access Token (갱신 성공 또는 이미 유효한 경우), null (갱신 실패) + Future ensureValidAccessToken() async { + var token = await getAccessToken(); + if (token == null) { + developer.log('❌ No access token found'); + return null; + } + + // 토큰이 곧 만료될 예정이면 (만료 5분 전) 사전 갱신 시도 + if (isTokenExpiringSoon(token)) { + developer.log('⚠️ Access token expiring soon, refreshing proactively...'); + final refreshed = await refreshAccessToken(); + + if (refreshed) { + return await getAccessToken(); + } else { + // 사전 갱신 실패 시, 이미 만료된 경우인지 확인 + final newToken = await getAccessToken(); + if (newToken != null && !isTokenExpired(newToken)) { + // 갱신은 실패했지만 토큰이 아직 유효한 경우 + return newToken; + } + return null; + } + } + + // 토큰이 아직 유효하면 그대로 반환 + return token; + } + + /// 자동 로그인 설정 저장 + Future setAutoLogin(bool enabled) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_autoLoginKey, enabled); + developer.log('Auto login setting saved: $enabled'); + } catch (e) { + developer.log('Failed to save auto login setting: $e'); + } + } + + /// 자동 로그인 설정 조회 + Future isAutoLoginEnabled() async { + try { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_autoLoginKey) ?? false; + } catch (e) { + developer.log('Failed to get auto login setting: $e'); + return false; + } + } + + /// 아이디 저장 설정 저장 + Future setSaveAccountId(bool enabled) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_saveAccountIdKey, enabled); + developer.log('Save account ID setting saved: $enabled'); + } catch (e) { + developer.log('Failed to save account ID setting: $e'); + } + } + + /// 아이디 저장 설정 조회 + Future isSaveAccountIdEnabled() async { + try { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_saveAccountIdKey) ?? false; + } catch (e) { + developer.log('Failed to get save account ID setting: $e'); + return false; + } + } + + /// 저장된 아이디 조회 + Future getSavedAccountId() async { + try { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_savedAccountIdKey); + } catch (e) { + developer.log('Failed to get saved account ID: $e'); + return null; + } + } + + /// 아이디 저장 + Future saveAccountId(String accountId) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_savedAccountIdKey, accountId); + developer.log('Account ID saved: $accountId'); + } catch (e) { + developer.log('Failed to save account ID: $e'); + } + } + + /// 저장된 아이디 삭제 + Future clearSavedAccountId() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_savedAccountIdKey); + developer.log('Saved account ID cleared'); + } catch (e) { + developer.log('Failed to clear saved account ID: $e'); + } + } + + /// 토큰 삭제 (로그아웃 시) + /// [clearAutoLogin]: 자동 로그인 설정도 함께 삭제할지 여부 (기본값: true) + Future clearTokens({bool clearAutoLogin = true}) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_accessTokenKey); + await prefs.remove(_refreshTokenKey); + await prefs.remove(_userIdKey); + + if (clearAutoLogin) { + await prefs.remove(_autoLoginKey); + } + + developer.log('Tokens cleared successfully'); + } catch (e) { + developer.log('Failed to clear tokens: $e'); + } + } + + Future signOutFromServer() async { + try { + final token = await getAccessToken(); + if (token == null) { + developer.log('⚠️ signOut skipped: no access token'); + return; + } + + final response = await http.post( + ApiConfig.getSignOutUri(), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ); + + developer.log( + '[AuthService] sign-out response: ${response.statusCode} ${response.body}', + ); + } catch (e) { + developer.log('⚠️ signOutFromServer failed: $e'); + } + } +} diff --git a/frontend/lib/services/continuous_learning_api.dart b/frontend/lib/services/continuous_learning_api.dart new file mode 100644 index 0000000..8f6aa4e --- /dev/null +++ b/frontend/lib/services/continuous_learning_api.dart @@ -0,0 +1,139 @@ +import 'dart:convert'; +import 'dart:developer' as developer; +import 'package:http/http.dart' as http; +import '../config/api_config.dart'; +import '../utils/app_logger.dart'; +import 'auth_service.dart'; + +/// Grading History Summary API 응답 DTO +class GradingHistorySummaryResponse { + final double totalScore; + final int daysSinceStartOfYear; + + GradingHistorySummaryResponse({ + required this.totalScore, + required this.daysSinceStartOfYear, + }); + + factory GradingHistorySummaryResponse.fromJson(Map json) { + return GradingHistorySummaryResponse( + totalScore: (json['total_score'] as num?)?.toDouble() ?? 0.0, + daysSinceStartOfYear: json['days_since_start_of_year'] as int? ?? 0, + ); + } +} + +/// Continuous Learning API 호출 전용 레이어 +class ContinuousLearningApi { + ContinuousLearningApi({ + required AuthService authService, + required http.Client httpClient, + }) : _authService = authService, + _httpClient = httpClient; + + final AuthService _authService; + final http.Client _httpClient; + + /// Grading History Summary 조회 + /// + /// [academyUserId]: 조회할 academyUserId + /// + /// 반환값: GradingHistorySummaryResponse + Future fetchGradingHistorySummary( + int academyUserId, + ) async { + appLog( + '[continuous_learning:continuous_learning_api] API 호출 시작 - academyUserId: $academyUserId', + ); + + final token = await _authService.ensureValidAccessToken(); + if (token == null) { + appLog('[continuous_learning:continuous_learning_api] 인증 토큰 없음'); + throw Exception('인증 토큰이 없습니다. 로그인이 필요합니다.'); + } + + final uri = Uri.parse( + '${ApiConfig.baseUrl}/grading/grading-histories/summary?academyUserId=$academyUserId', + ); + + appLog('[continuous_learning:continuous_learning_api] GET 요청: $uri'); + developer.log('📊 [ContinuousLearningApi] GET $uri'); + + final response = await _httpClient + .get( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ) + .timeout(const Duration(seconds: 10)); + + appLog( + '[continuous_learning:continuous_learning_api] 응답 상태 코드: ${response.statusCode}', + ); + appLog( + '[continuous_learning:continuous_learning_api] 응답 본문: ${response.body}', + ); + developer.log('📊 [ContinuousLearningApi] Response status: ${response.statusCode}'); + + if (response.statusCode == 401) { + appLog('[continuous_learning:continuous_learning_api] 인증 실패 (401)'); + throw Exception('인증에 실패했습니다. 다시 로그인해주세요.'); + } + if (response.statusCode != 200) { + appLog( + '[continuous_learning:continuous_learning_api] API 호출 실패 - status: ${response.statusCode}', + ); + throw Exception( + 'Grading History Summary API 호출 실패 (status: ${response.statusCode})', + ); + } + + try { + final Map data = json.decode(response.body); + final result = GradingHistorySummaryResponse.fromJson(data); + appLog( + '[continuous_learning:continuous_learning_api] 응답 파싱 성공 - totalScore: ${result.totalScore}, daysSinceStartOfYear: ${result.daysSinceStartOfYear}', + ); + return result; + } catch (e) { + appLog( + '[continuous_learning:continuous_learning_api] 응답 파싱 실패: $e', + ); + appLog( + '[continuous_learning:continuous_learning_api] 응답 본문: ${response.body}', + ); + developer.log('❌ [ContinuousLearningApi] 응답 파싱 실패: $e'); + developer.log('❌ [ContinuousLearningApi] Response body: ${response.body}'); + rethrow; + } + } + + /// 여러 academyUserId에 대한 Grading History Summary 조회 (병렬) + /// + /// [academyUserIds]: 조회할 academyUserId 리스트 + /// + /// 반환값: 각 academyUserId별 GradingHistorySummaryResponse 맵 + Future> + fetchGradingHistorySummaries( + List academyUserIds, + ) async { + appLog( + '[continuous_learning:continuous_learning_api] 여러 academyUserId 조회 시작 - academyUserIds: $academyUserIds', + ); + + // 병렬 처리 + final results = await Future.wait( + academyUserIds.map((id) => fetchGradingHistorySummary(id)), + ); + + final resultMap = Map.fromIterables(academyUserIds, results); + appLog( + '[continuous_learning:continuous_learning_api] 여러 academyUserId 조회 완료 - 결과 개수: ${resultMap.length}', + ); + + return resultMap; + } +} + diff --git a/frontend/lib/services/daily_learning_service.dart b/frontend/lib/services/daily_learning_service.dart new file mode 100644 index 0000000..644668f --- /dev/null +++ b/frontend/lib/services/daily_learning_service.dart @@ -0,0 +1,145 @@ +import 'workbook_api.dart'; +import 'academy_service.dart'; +import 'auth_service.dart'; +import '../utils/kst_date_factory.dart'; +import '../utils/app_logger.dart'; +import 'dart:developer' as developer; + +/// 일일 학습 데이터 조회 결과 +class DailyLearningResult { + final List data; + final bool hasError; + final String? errorMessage; + + DailyLearningResult({ + required this.data, + this.hasError = false, + this.errorMessage, + }); + + /// 성공 결과 + factory DailyLearningResult.success(List data) { + return DailyLearningResult(data: data); + } + + /// 에러 결과 + factory DailyLearningResult.error(String message) { + return DailyLearningResult( + data: [], + hasError: true, + errorMessage: message, + ); + } + + /// 빈 결과 (학습 기록 없음) + factory DailyLearningResult.empty() { + return DailyLearningResult(data: []); + } + + /// 데이터가 있는지 확인 + bool get hasData => data.isNotEmpty && + data.any((response) => response.books.any((b) => b.bookId != null)); +} + +/// 일일 학습 데이터 조회 서비스 +/// +/// HomePage와 ContinuousLearningDetailPage에서 공통으로 사용하는 로직을 분리 +class DailyLearningService { + DailyLearningService({ + required WorkbookApi workbookApi, + required AcademyService academyService, + required AuthService authService, + }) : _workbookApi = workbookApi, + _academyService = academyService, + _authService = authService; + + final WorkbookApi _workbookApi; + final AcademyService _academyService; + final AuthService _authService; + + /// 특정 날짜의 학습 데이터 조회 + /// + /// [date]: 조회할 날짜 (어떤 타임존이든 상관없음, KST로 변환됨) + /// + /// 반환값: DailyLearningResult (성공/에러/빈 상태 구분) + /// + /// 주의: date는 KST로 변환되어 WorkbookApi에 전달됩니다. + /// WorkbookApi 내부에서는 추가 변환을 수행하지 않습니다. + Future getDailyLearningData(DateTime date) async { + try { + // 1. 사용자 ID 확인 + final userId = await _authService.getUserId(); + if (userId == null) { + return DailyLearningResult.error('사용자 정보를 가져올 수 없습니다.'); + } + + // 2. 학원 목록 조회 + final academies = await _academyService.getUserAcademies(userId); + final academyUserIds = academies + .where((a) => a.registerStatus == 'Y') + .map((a) => a.academy_user_id) + .whereType() + .toList(); + + if (academyUserIds.isEmpty) { + return DailyLearningResult.empty(); + } + + // 3. KST 날짜로 변환 (한 번만 수행) + final kstDate = KstDateFactory.toKstDate(date); + + // 4. API 호출 (kstDate는 이미 KST로 변환된 상태) + final data = await _workbookApi.fetchWorkbooksByDateRange( + academyUserIds: academyUserIds, + startDate: kstDate, // 이미 KST로 변환됨 + endDate: kstDate, // 이미 KST로 변환됨 + ); + + // 5. 결과 반환 + return DailyLearningResult.success(data); + } on Exception catch (e) { + appLog('[daily_learning:daily_learning_service] 조회 실패: $e'); + developer.log('❌ [DailyLearningService] 조회 실패: $e'); + return DailyLearningResult.error('학습 데이터를 불러오는데 실패했습니다.'); + } catch (e) { + appLog('[daily_learning:daily_learning_service] 예상치 못한 오류: $e'); + developer.log('❌ [DailyLearningService] 예상치 못한 오류: $e'); + return DailyLearningResult.error('알 수 없는 오류가 발생했습니다.'); + } + } + + /// 오늘의 학습 데이터 조회 + /// + /// 반환값: DailyLearningResult (성공/에러/빈 상태 구분) + Future getTodayLearningData() async { + final todayKst = KstDateFactory.getTodayKst(); + return getDailyLearningData(todayKst); + } + + /// 학습 데이터에서 모든 책 목록 추출 + /// + /// [result]: DailyLearningResult + /// 반환값: BookData 리스트 (bookId가 null이 아닌 것만) + static List extractBooks(DailyLearningResult result) { + if (!result.hasData) return []; + + final allBooks = []; + for (var response in result.data) { + allBooks.addAll(response.books.where((b) => b.bookId != null)); + } + return allBooks; + } + + /// 가장 많이 학습한 책 선택 + /// + /// [books]: BookData 리스트 + /// 반환값: 가장 많이 학습한 책 (totalSolvedPages 기준), 없으면 null + static BookData? selectMostLearnedBook(List books) { + if (books.isEmpty) return null; + + // totalSolvedPages가 가장 많은 책 선택 + books.sort((a, b) => b.totalSolvedPages.compareTo(a.totalSolvedPages)); + return books.first; + } +} + diff --git a/frontend/lib/services/explanation_api.dart b/frontend/lib/services/explanation_api.dart new file mode 100644 index 0000000..2df8560 --- /dev/null +++ b/frontend/lib/services/explanation_api.dart @@ -0,0 +1,215 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../config/api_config.dart'; +import '../utils/app_logger.dart'; + +/// 해설 생성/조회용 API 래퍼 +class ExplanationApi { + final Future Function() _tokenProvider; + final http.Client _httpClient; + + ExplanationApi({ + required Future Function() tokenProvider, + required http.Client httpClient, + }) : _tokenProvider = tokenProvider, + _httpClient = httpClient; + + /// 해설 생성 요청 + /// + /// POST /grading/student-answers/explanation + /// Body: + /// { + /// "student_response_id": ..., + /// "user_id": ..., + /// "academy_user_id": ..., + /// "academy_id": ..., + /// "page_number": ..., + /// "question_number": ..., + /// "answer": ... + /// } + Future> postExplanation({ + required int studentResponseId, + required int userId, + required int academyUserId, + required int academyId, + required int pageNumber, + required int questionNumber, + required String answer, + }) async { + final token = await _tokenProvider(); + if (token == null) { + throw Exception('인증 토큰이 없습니다. 다시 로그인해주세요.'); + } + + final uri = Uri.parse( + '${ApiConfig.baseUrl}/grading/student-answers/explanation', + ); + + final body = { + 'student_response_id': studentResponseId, + 'user_id': userId, + 'academy_user_id': academyUserId, + 'academy_id': academyId, + 'page_number': pageNumber, + 'question_number': questionNumber, + 'answer': answer, + }; + + appLog('[explanation] POST INPUT: $uri'); + appLog('[explanation] POST INPUT body: ${json.encode(body)}'); + + final response = await _httpClient + .post( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + body: json.encode(body), + ) + .timeout(const Duration(seconds: 15)); + + appLog('[explanation] POST OUTPUT status: ${response.statusCode}'); + + if (response.statusCode == 401) { + throw Exception('인증에 실패했습니다. 다시 로그인해주세요.'); + } + if (response.statusCode != 200) { + appLog('[explanation] POST OUTPUT error body: ${response.body}'); + throw Exception('해설 생성 요청에 실패했습니다. (status: ${response.statusCode})'); + } + + final decoded = json.decode(response.body) as Map; + appLog('[explanation] POST OUTPUT body: ${json.encode(decoded)}'); + return decoded; + } + + /// 학생 답안 정보 조회 + /// + /// GET /grading/student-answers/find + /// ?studentResponseId={id} + /// &questionNumber={n} + /// &subQuestionNumber={n} + Future> findStudentAnswer({ + required int studentResponseId, + required int questionNumber, + required int subQuestionNumber, + }) async { + final token = await _tokenProvider(); + if (token == null) { + throw Exception('인증 토큰이 없습니다. 다시 로그인해주세요.'); + } + + final uri = Uri.parse( + '${ApiConfig.baseUrl}/grading/student-answers/find' + '?studentResponseId=$studentResponseId' + '&questionNumber=$questionNumber' + '&subQuestionNumber=$subQuestionNumber', + ); + + appLog('[explanation] GET (정답 가져오기) INPUT: $uri'); + appLog( + '[explanation] GET (정답 가져오기) INPUT params: studentResponseId=$studentResponseId, questionNumber=$questionNumber, subQuestionNumber=$subQuestionNumber', + ); + + final response = await _httpClient + .get( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ) + .timeout(const Duration(seconds: 10)); + + appLog('[explanation] GET (정답 가져오기) OUTPUT status: ${response.statusCode}'); + + if (response.statusCode == 401) { + throw Exception('인증에 실패했습니다. 다시 로그인해주세요.'); + } + if (response.statusCode != 200) { + appLog('[explanation] GET (정답 가져오기) OUTPUT error body: ${response.body}'); + throw Exception('학생 답안 조회에 실패했습니다. (status: ${response.statusCode})'); + } + + final decoded = json.decode(response.body) as Map; + appLog('[explanation] GET (정답 가져오기) OUTPUT body: ${json.encode(decoded)}'); + return decoded; + } + + /// 해설 조회 (Redis에서 LLM 해설 가져오기) + /// + /// GET /grading/student-answers/get-explanation + /// ?studentResponseId={id} + /// &academyUserId={id} + /// &questionNumber={n} + /// &subquestionNumber={n} + /// + /// 404와 400 응답은 동일하게 처리하여 null을 반환합니다. + /// (해설이 아직 생성되지 않았거나 Redis에 없음을 의미) + Future?> getExplanation({ + required int studentResponseId, + required int academyUserId, + required int questionNumber, + required int subQuestionNumber, + }) async { + final token = await _tokenProvider(); + if (token == null) { + throw Exception('인증 토큰이 없습니다. 다시 로그인해주세요.'); + } + + final uri = Uri.parse( + '${ApiConfig.baseUrl}/grading/student-answers/get-explanation' + '?studentResponseId=$studentResponseId' + '&academyUserId=$academyUserId' + '&questionNumber=$questionNumber' + '&subQuestionNumber=$subQuestionNumber', + ); + + appLog('[explanation] GET (Redis에서 LLM 해설 가져오기) INPUT: $uri'); + appLog( + '[explanation] GET (Redis에서 LLM 해설 가져오기) INPUT params: studentResponseId=$studentResponseId, academyUserId=$academyUserId, questionNumber=$questionNumber, subQuestionNumber=$subQuestionNumber', + ); + + final response = await _httpClient + .get( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ) + .timeout(const Duration(seconds: 10)); + + appLog( + '[explanation] GET (Redis에서 LLM 해설 가져오기) OUTPUT status: ${response.statusCode}', + ); + + // 404와 400을 동일하게 처리: 해설이 아직 생성되지 않았거나 Redis에 없음 + if (response.statusCode == 404 || response.statusCode == 400) { + appLog( + '[explanation] GET (Redis에서 LLM 해설 가져오기) OUTPUT: 해설이 아직 생성되지 않았거나 Redis에 없음 (404/400)', + ); + appLog('[explanation] GET (Redis에서 LLM 해설 가져오기) OUTPUT body: null'); + return null; + } + if (response.statusCode == 401) { + appLog('[explanation] GET (Redis에서 LLM 해설 가져오기) OUTPUT error: 인증 실패'); + throw Exception('인증에 실패했습니다. 다시 로그인해주세요.'); + } + if (response.statusCode != 200) { + appLog( + '[explanation] GET (Redis에서 LLM 해설 가져오기) OUTPUT error body: ${response.body}', + ); + throw Exception('해설 조회에 실패했습니다. (status: ${response.statusCode})'); + } + + final decoded = json.decode(response.body) as Map; + appLog( + '[explanation] GET (Redis에서 LLM 해설 가져오기) OUTPUT body: ${json.encode(decoded)}', + ); + return decoded; + } +} diff --git a/frontend/lib/services/explanation_repository_impl.dart b/frontend/lib/services/explanation_repository_impl.dart new file mode 100644 index 0000000..c4fe876 --- /dev/null +++ b/frontend/lib/services/explanation_repository_impl.dart @@ -0,0 +1,77 @@ +import '../domain/explanation/explanation_entity.dart'; +import '../domain/explanation/explanation_repository.dart'; +import '../domain/explanation/explanation_source.dart'; +import '../data/explanation/explanation_mapper.dart'; +import 'explanation_api.dart'; + +/// Explanation 도메인 Repository 구현체 +class ExplanationRepositoryImpl implements ExplanationRepository { + final ExplanationApi _api; + final ExplanationMapper _mapper; + + ExplanationRepositoryImpl({ + required ExplanationApi api, + required ExplanationMapper mapper, + }) : _api = api, + _mapper = mapper; + + @override + Future findStudentAnswer(ExplanationSource source) async { + final json = await _api.findStudentAnswer( + studentResponseId: source.studentResponseId, + questionNumber: source.question.questionNumber, + subQuestionNumber: source.question.subQuestionNumber, + ); + + return StudentAnswerInfo( + studentAnswerId: json['studentAnswerId'] as int, + studentResponseId: json['studentResponseId'] as int, + chapterId: json['chapterId'] as int, + page: json['page'] as int, + questionNumber: json['questionNumber'] as int, + subQuestionNumber: json['subQuestionNumber'] as int, + answer: json['answer'] as String, + sectionUrl: json['section_url'] as String?, + score: (json['score'] as num).toDouble(), + correct: json['correct'] as bool, + ); + } + + @override + Future getExplanation(ExplanationSource source) async { + final json = await _api.getExplanation( + studentResponseId: source.studentResponseId, + academyUserId: source.academyUserId, + questionNumber: source.question.questionNumber, + subQuestionNumber: source.question.subQuestionNumber, + ); + + if (json == null) { + return null; + } + + return _mapper.fromGetResponse(json); + } + + @override + Future requestExplanation( + ExplanationSource source, { + required int requestedByUserId, + required int academyId, + required String answer, + }) async { + final json = await _api.postExplanation( + studentResponseId: source.studentResponseId, + userId: requestedByUserId, + academyUserId: source.academyUserId, + academyId: academyId, + pageNumber: source.question.page, + questionNumber: source.question.questionNumber, + answer: answer, + ); + + return _mapper.fromPostResponse(json); + } +} + + diff --git a/frontend/lib/services/fcm_service.dart b/frontend/lib/services/fcm_service.dart new file mode 100644 index 0000000..643659c --- /dev/null +++ b/frontend/lib/services/fcm_service.dart @@ -0,0 +1,410 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'dart:developer' as developer; +import 'package:flutter/foundation.dart'; +import 'dart:io'; +import 'package:http/http.dart' as http; +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../domain/notification/notification_entity.dart'; +import '../domain/notification/notification_repository.dart'; +import '../domain/notification/notification_type.dart'; +import '../data/notification/notification_local_data_source.dart'; +import '../data/notification/notification_repository_impl.dart'; +import '../utils/app_logger.dart'; + +/// FCM 및 로컬 알림을 관리하는 서비스 +/// +/// - DI Container에서 singleton으로 관리됩니다. +/// - 포그라운드 알림 저장 시 DI로 주입된 NotificationRepository를 사용합니다. +/// - 백그라운드 알림 저장은 Firebase 제약상 여전히 독립적인 팩토리 함수를 사용합니다. +class FCMService { + final FirebaseMessaging _firebaseMessaging; + final FlutterLocalNotificationsPlugin _localNotifications; + final NotificationRepository _notificationRepository; + + FCMService({ + FirebaseMessaging? firebaseMessaging, + FlutterLocalNotificationsPlugin? localNotifications, + required NotificationRepository notificationRepository, + }) : _firebaseMessaging = firebaseMessaging ?? FirebaseMessaging.instance, + _localNotifications = + localNotifications ?? FlutterLocalNotificationsPlugin(), + _notificationRepository = notificationRepository; + + String? _fcmToken; + String? get fcmToken => _fcmToken; + + /// FCM 초기화 + Future initialize() async { + // 알림 권한 요청 + NotificationSettings settings = await _firebaseMessaging.requestPermission( + alert: true, + badge: true, + sound: true, + provisional: false, + ); + + if (settings.authorizationStatus == AuthorizationStatus.authorized) { + developer.log('사용자가 알림 권한을 허용했습니다'); + } else { + developer.log('사용자가 알림 권한을 거부했습니다'); + return; + } + + // iOS에서 APNS 토큰 요청 (FCM 토큰을 받기 전에 필요) + if (Platform.isIOS) { + try { + developer.log('🍎 iOS: APNS 토큰 요청 중...'); + String? apnsToken = await _firebaseMessaging.getAPNSToken(); + if (apnsToken != null) { + developer.log('✅ APNS 토큰 수신 성공'); + debugPrint('✅ APNS Token: $apnsToken'); + } else { + developer.log('⚠️ APNS 토큰이 아직 설정되지 않았습니다 (시뮬레이터일 수 있음)'); + debugPrint('⚠️ APNS 토큰이 없습니다. 실제 기기에서 테스트하거나 나중에 다시 시도하세요.'); + // 시뮬레이터에서는 APNS 토큰을 얻을 수 없지만 계속 진행 + } + } catch (e) { + developer.log('⚠️ APNS 토큰 요청 중 오류 (시뮬레이터일 수 있음): $e'); + debugPrint('⚠️ APNS 토큰 오류 (시뮬레이터에서는 정상): $e'); + // 시뮬레이터에서는 APNS 토큰을 얻을 수 없지만 계속 진행 + } + } + + // FCM 토큰 가져오기 + developer.log('🔔 FCM 토큰 요청 시작...'); + try { + _fcmToken = await _firebaseMessaging.getToken(); + + if (_fcmToken != null) { + developer.log('✅ FCM 토큰 생성 성공!'); + developer.log('📱 FCM Token: $_fcmToken'); + developer.log('📏 토큰 길이: ${_fcmToken!.length}자'); + debugPrint('═══════════════════════════════════════'); + debugPrint('🔔 FCM 토큰 생성 완료'); + debugPrint('📱 Token: $_fcmToken'); + debugPrint('📏 길이: ${_fcmToken!.length}자'); + debugPrint('═══════════════════════════════════════'); + } else { + developer.log('❌ FCM 토큰 생성 실패: 토큰이 null입니다'); + debugPrint('❌ FCM 토큰 생성 실패!'); + } + } catch (e) { + developer.log('⚠️ FCM 토큰 요청 실패: $e'); + debugPrint('⚠️ FCM 토큰 요청 실패: $e'); + if (Platform.isIOS) { + developer.log('💡 iOS 시뮬레이터에서는 APNS 토큰이 없어 FCM 토큰을 받을 수 없습니다.'); + developer.log('💡 실제 기기에서 테스트하거나, APNS 인증서가 설정되어 있는지 확인하세요.'); + debugPrint('💡 iOS 시뮬레이터에서는 FCM 토큰을 받을 수 없습니다.'); + debugPrint('💡 실제 기기에서 테스트하세요.'); + } + // 에러가 발생해도 앱은 계속 실행 + } + + // 토큰 갱신 리스너 + _firebaseMessaging.onTokenRefresh.listen((newToken) { + _fcmToken = newToken; + developer.log('🔄 FCM Token 갱신됨'); + developer.log('📱 새 FCM Token: $newToken'); + developer.log('📏 새 토큰 길이: ${newToken.length}자'); + debugPrint('═══════════════════════════════════════'); + debugPrint('🔄 FCM 토큰 갱신 완료'); + debugPrint('📱 New Token: $newToken'); + debugPrint('📏 길이: ${newToken.length}자'); + debugPrint('═══════════════════════════════════════'); + // 서버에 새 토큰 전송 + _sendTokenToServer(newToken); + }); + + // 로컬 알림 초기화 + await _initializeLocalNotifications(); + + // 백그라운드 메시지 핸들러 설정 + FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); + + // 포그라운드 메시지 핸들러 + FirebaseMessaging.onMessage.listen(_handleForegroundMessage); + + // 백그라운드에서 알림 탭 핸들러 + FirebaseMessaging.onMessageOpenedApp.listen(_handleNotificationTap); + + // 앱이 종료된 상태에서 알림으로 앱 실행된 경우 + RemoteMessage? initialMessage = await _firebaseMessaging + .getInitialMessage(); + if (initialMessage != null) { + _handleNotificationTap(initialMessage); + } + + // 서버에 토큰 전송 (앱 최초 실행 시 1회) + await syncTokenWithServer(); + } + + /// 현재 보유한 FCM 토큰을 서버와 동기화 + /// + /// - 토큰이 없는 경우 한 번 더 getToken을 시도합니다. + /// - 토큰이 있으면 `_sendTokenToServer`를 호출합니다. + Future syncTokenWithServer() async { + try { + // 토큰이 아직 없는 경우 한 번 더 시도 + _fcmToken ??= await _firebaseMessaging.getToken(); + + if (_fcmToken == null) { + developer.log('⚠️ syncTokenWithServer: FCM 토큰이 없어 서버 전송을 건너뜀'); + return; + } + + await _sendTokenToServer(_fcmToken!); + } catch (e) { + developer.log('⚠️ syncTokenWithServer 중 오류 발생: $e'); + } + } + + /// 로컬 알림 초기화 + Future _initializeLocalNotifications() async { + const AndroidInitializationSettings androidSettings = + AndroidInitializationSettings('@mipmap/ic_launcher'); + + const DarwinInitializationSettings iosSettings = + DarwinInitializationSettings( + requestAlertPermission: true, + requestBadgePermission: true, + requestSoundPermission: true, + ); + + const InitializationSettings initSettings = InitializationSettings( + android: androidSettings, + iOS: iosSettings, + ); + + await _localNotifications.initialize( + initSettings, + onDidReceiveNotificationResponse: _onNotificationTapped, + ); + + // Android 채널 생성 + const AndroidNotificationChannel channel = AndroidNotificationChannel( + 'high_importance_channel', + 'High Importance Notifications', + description: 'This channel is used for important notifications.', + importance: Importance.high, + ); + + await _localNotifications + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >() + ?.createNotificationChannel(channel); + } + + /// 포그라운드 메시지 처리 + void _handleForegroundMessage(RemoteMessage message) async { + appLog( + '[notification:fcm_service] 포그라운드 메시지 수신 - messageId: ${message.messageId}', + ); + appLog('[notification:fcm_service] 메시지 data: ${json.encode(message.data)}'); + if (message.notification != null) { + appLog( + '[notification:fcm_service] notification.title: ${message.notification!.title}', + ); + appLog( + '[notification:fcm_service] notification.body: ${message.notification!.body}', + ); + } + appLog('[notification:fcm_service] sentTime: ${message.sentTime}'); + appLog( + '[notification:fcm_service] 전체 메시지 JSON: ${json.encode({ + 'messageId': message.messageId, + 'data': message.data, + 'notification': message.notification != null ? {'title': message.notification!.title, 'body': message.notification!.body} : null, + 'sentTime': message.sentTime?.toIso8601String(), + })}', + ); + + developer.log('포그라운드 메시지 수신: ${message.messageId}'); + + // 알림 표시 + _showLocalNotification(message); + + // 알림 저장 + try { + final entity = NotificationEntity.fromRemoteMessage(message); + + if (entity.type == NotificationType.grading) { + final timestamp = DateTime.now().toIso8601String(); + appLog( + '[notification:fcm_service][timecheck][$timestamp] 채점 완료 알림 수신(포그라운드) - id=${entity.id}, title=${entity.title}', + ); + } + + await _notificationRepository.saveNotification(entity); + developer.log('✅ 포그라운드 알림 저장 완료: ${entity.id}'); + } catch (e) { + developer.log('❌ 포그라운드 알림 저장 실패: $e'); + } + } + + /// 로컬 알림 표시 + Future _showLocalNotification(RemoteMessage message) async { + final notification = message.notification; + + if (notification != null) { + await _localNotifications.show( + notification.hashCode, + notification.title, + notification.body, + const NotificationDetails( + android: AndroidNotificationDetails( + 'high_importance_channel', + 'High Importance Notifications', + channelDescription: + 'This channel is used for important notifications.', + importance: Importance.high, + priority: Priority.high, + icon: '@mipmap/ic_launcher', + ), + iOS: DarwinNotificationDetails(), + ), + payload: message.data.toString(), + ); + } + } + + /// 알림 탭 처리 + void _handleNotificationTap(RemoteMessage message) { + appLog( + '[notification:fcm_service] 알림 탭됨 - messageId: ${message.messageId}', + ); + appLog('[notification:fcm_service] 메시지 data: ${json.encode(message.data)}'); + if (message.notification != null) { + appLog( + '[notification:fcm_service] notification.title: ${message.notification!.title}', + ); + appLog( + '[notification:fcm_service] notification.body: ${message.notification!.body}', + ); + } + appLog( + '[notification:fcm_service] 전체 메시지 JSON: ${json.encode({ + 'messageId': message.messageId, + 'data': message.data, + 'notification': message.notification != null ? {'title': message.notification!.title, 'body': message.notification!.body} : null, + 'sentTime': message.sentTime?.toIso8601String(), + })}', + ); + + developer.log('알림 탭됨: ${message.messageId}'); + + // TODO: 알림 타입에 따라 페이지 이동 + final data = message.data; + if (data.containsKey('type')) { + // 예: Navigator.pushNamed(context, '/notification-detail'); + } + } + + /// 로컬 알림 탭 처리 + void _onNotificationTapped(NotificationResponse response) { + developer.log('로컬 알림 탭됨: ${response.payload}'); + // TODO: 알림 상세 페이지로 이동 + } + + /// 서버에 FCM 토큰 전송 + Future _sendTokenToServer(String token) async { + try { + // TODO: 실제 서버 API 엔드포인트로 변경 + const String serverUrl = 'https://your-backend-url.com/api/fcm/token'; + + final response = await http.post( + Uri.parse(serverUrl), + headers: {'Content-Type': 'application/json'}, + body: json.encode({ + 'fcm_token': token, + 'device_type': 'mobile', // Android 또는 iOS + 'user_id': 'current_user_id', // TODO: 현재 로그인한 사용자 ID + }), + ); + + if (response.statusCode == 200) { + developer.log('FCM 토큰이 서버에 성공적으로 전송되었습니다'); + } else { + developer.log('FCM 토큰 전송 실패: ${response.statusCode}'); + } + } catch (e) { + developer.log('FCM 토큰 전송 중 오류 발생: $e'); + } + } + + /// 알림 읽음 처리 + Future markAsRead(String notificationId) async { + try { + // TODO: 실제 서버 API 엔드포인트로 변경 + final String serverUrl = + 'https://your-backend-url.com/api/notifications/$notificationId/read'; + + final response = await http.put( + Uri.parse(serverUrl), + headers: {'Content-Type': 'application/json'}, + ); + + if (response.statusCode == 200) { + developer.log('알림 읽음 처리 완료'); + } + } catch (e) { + developer.log('알림 읽음 처리 중 오류 발생: $e'); + } + } +} + +/// NotificationRepository 팩토리 함수 (백그라운드용) +Future +_buildNotificationRepositoryForBackground() async { + final prefs = await SharedPreferences.getInstance(); + final local = NotificationLocalDataSource(prefs); + return NotificationRepositoryImpl(local); +} + +/// 백그라운드 메시지 핸들러 (최상위 함수여야 함) +@pragma('vm:entry-point') +Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { + // appLog는 최상위 함수에서도 사용 가능 + appLog( + '[notification:fcm_service] 백그라운드 메시지 수신 - messageId: ${message.messageId}', + ); + appLog('[notification:fcm_service] 메시지 data: ${json.encode(message.data)}'); + if (message.notification != null) { + appLog( + '[notification:fcm_service] notification.title: ${message.notification!.title}', + ); + appLog( + '[notification:fcm_service] notification.body: ${message.notification!.body}', + ); + } + appLog('[notification:fcm_service] sentTime: ${message.sentTime}'); + appLog( + '[notification:fcm_service] 전체 메시지 JSON: ${json.encode({ + 'messageId': message.messageId, + 'data': message.data, + 'notification': message.notification != null ? {'title': message.notification!.title, 'body': message.notification!.body} : null, + 'sentTime': message.sentTime?.toIso8601String(), + })}', + ); + + developer.log('백그라운드 메시지 수신: ${message.messageId}'); + + try { + final repository = await _buildNotificationRepositoryForBackground(); + final entity = NotificationEntity.fromRemoteMessage(message); + + if (entity.type == NotificationType.grading) { + final timestamp = DateTime.now().toIso8601String(); + appLog( + '[notification:fcm_service][timecheck][$timestamp] 채점 완료 알림 수신(백그라운드) - id=${entity.id}, title=${entity.title}', + ); + } + + await repository.saveNotification(entity); + developer.log('✅ 백그라운드 알림 저장 완료: ${entity.id}'); + } catch (e) { + developer.log('❌ 백그라운드 알림 저장 실패: $e'); + } +} diff --git a/frontend/lib/services/get_chapter_question_statuses_use_case.dart b/frontend/lib/services/get_chapter_question_statuses_use_case.dart new file mode 100644 index 0000000..29d36c4 --- /dev/null +++ b/frontend/lib/services/get_chapter_question_statuses_use_case.dart @@ -0,0 +1,68 @@ +import '../domain/chapter/chapter_repository.dart'; +import '../domain/student_answer/student_answer_repository.dart'; +import '../domain/student_answer/student_answer_query.dart'; +import 'models/question_status_model.dart'; +import 'policies/answer_selection_policy.dart'; + +/// 챕터의 문제별 풀이 상태 조회 UseCase (Application Layer) +/// +/// **책임**: +/// - Domain Entity들을 조합하여 UI에 필요한 데이터 생성 +/// - 상태 계산 로직 포함 +/// - 정책 로직은 별도 객체로 분리 +class GetChapterQuestionStatusesUseCase { + final ChapterRepository _chapterRepository; + final StudentAnswerRepository _studentAnswerRepository; + final AnswerSelectionPolicy _selectionPolicy; + + GetChapterQuestionStatusesUseCase({ + required ChapterRepository chapterRepository, + required StudentAnswerRepository studentAnswerRepository, + AnswerSelectionPolicy? selectionPolicy, + }) : _chapterRepository = chapterRepository, + _studentAnswerRepository = studentAnswerRepository, + _selectionPolicy = selectionPolicy ?? AnswerSelectionPolicy(); + + /// 챕터의 문제별 풀이 상태 반환 + /// + /// 반환값: 문제 번호 순서대로 정렬된 상태 리스트 + Future> call({ + required int chapterId, + required int academyUserId, + }) async { + // 1. 챕터 정보 조회 + final chapter = await _chapterRepository.getChapterById(chapterId); + + // 2. 학생 답안 조회 (Query 객체 사용) + final answers = await _studentAnswerRepository.getStudentAnswers( + StudentAnswerQuery.byChapter( + chapterId: chapterId, + academyUserId: academyUserId, + ), + ); + + // 3. question_number별로 Map 생성 (정책 적용) + final answerMap = {}; + for (final answer in answers) { + final existing = answerMap[answer.questionNumber]; + answerMap[answer.questionNumber] = _selectionPolicy.selectAnswer( + existing, + answer.isCorrect, + ); + } + + // 4. 전체 문제 번호 리스트 생성 (1부터 totalChapterQuestion까지) + final result = []; + for (int questionNumber = 1; + questionNumber <= chapter.totalChapterQuestion; + questionNumber++) { + result.add(QuestionStatusModel( + questionNumber: questionNumber, + isCorrect: answerMap[questionNumber], // null일 수 있음 + )); + } + + return result; + } +} + diff --git a/frontend/lib/services/get_monthly_learning_status_use_case_impl.dart b/frontend/lib/services/get_monthly_learning_status_use_case_impl.dart new file mode 100644 index 0000000..39b6469 --- /dev/null +++ b/frontend/lib/services/get_monthly_learning_status_use_case_impl.dart @@ -0,0 +1,142 @@ +import '../domain/learning/get_monthly_learning_status_use_case.dart'; +import '../domain/learning/daily_learning_status.dart'; +import '../domain/learning/learning_completion_service.dart'; +import 'assessment_repository.dart'; +import '../domain/grading_history/grading_history_repository.dart'; +import '../domain/grading_history/grading_history_entity.dart'; + +/// GetMonthlyLearningStatusUseCase 구현체 +/// +/// Data Layer에서 Domain Layer 인터페이스를 구현합니다. +/// +/// 주의사항: +/// - 이 UseCase는 "월별 학습 상태 조회"에 집중합니다. +/// - 사용자/학원 컨텍스트는 호출하는 쪽(UI Layer)에서 결정하여 +/// 필요한 데이터(academyId, academyUserIds)를 매개변수로 전달받습니다. +class GetMonthlyLearningStatusUseCaseImpl + implements GetMonthlyLearningStatusUseCase { + final AssessmentRepository _assessmentRepository; + final GradingHistoryRepository _gradingHistoryRepository; + final LearningCompletionService _completionService; + + GetMonthlyLearningStatusUseCaseImpl({ + required AssessmentRepository assessmentRepository, + required GradingHistoryRepository gradingHistoryRepository, + required LearningCompletionService completionService, + }) : _assessmentRepository = assessmentRepository, + _gradingHistoryRepository = gradingHistoryRepository, + _completionService = completionService; + + @override + Future> call(DateTime month) async { + // 월의 첫 날로 정규화 + final normalizedMonth = DateTime(month.year, month.month, 1); + final lastDay = DateTime(normalizedMonth.year, normalizedMonth.month + 1, 0) + .day; + + // 빈 상태 리스트 생성 (데이터가 없어도 모든 날짜에 대해 상태 반환) + final statuses = []; + + for (int day = 1; day <= lastDay; day++) { + final date = DailyLearningStatus.normalizeDate( + DateTime(normalizedMonth.year, normalizedMonth.month, day), + ); + statuses.add(DailyLearningStatus( + date: date, + isCompleted: false, // 기본값, 아래에서 데이터 로드 후 업데이트 + )); + } + + return statuses; + } + + /// 사용자 학원 컨텍스트를 받아서 실제 데이터를 로드하고 완료 여부를 판단 + /// + /// 이 메서드는 UI Layer에서 호출하여 사용자/학원 정보를 전달받습니다. + /// UseCase 자체는 사용자 컨텍스트를 모르지만, 이 헬퍼 메서드를 통해 + /// 실제 데이터를 로드하고 완료 여부를 업데이트합니다. + /// + /// [month]: 조회할 월 + /// [academyId]: Assessment 조회용 academyId (userAcademyId) + /// [academyUserIds]: GradingHistory 조회용 academyUserId 리스트 + /// + /// 반환값: 완료 여부가 업데이트된 DailyLearningStatus 리스트 + Future> callWithContext({ + required DateTime month, + required String academyId, + required List academyUserIds, + }) async { + // 1. 해당 월의 Assessment 데이터 로드 + final assessmentsByDate = await _assessmentRepository.getForMonth( + academyId: academyId, + dateTime: month, + ); + + // 2. 해당 월의 GradingHistory 데이터 로드 + final gradingHistoriesByAcademyUserId = + await _gradingHistoryRepository + .getGradingHistoriesByAcademyUserIds(academyUserIds); + + // 3. GradingHistory를 날짜별로 그룹화 (해당 월에 속하는 것만) + final gradingHistoriesByDate = >{}; + gradingHistoriesByAcademyUserId.forEach((academyUserId, histories) { + for (var history in histories) { + // 해당 월에 속하는지 확인 + if (history.gradingDate.year == month.year && + history.gradingDate.month == month.month) { + final dateStr = _formatDate(history.gradingDate); + gradingHistoriesByDate.putIfAbsent(dateStr, () => []).add(history); + } + } + }); + + // 4. 해당 월의 모든 날짜에 대해 DailyLearningStatus 생성 + final normalizedMonth = DateTime(month.year, month.month, 1); + final lastDay = DateTime(normalizedMonth.year, normalizedMonth.month + 1, 0) + .day; + + final statuses = []; + + for (int day = 1; day <= lastDay; day++) { + final date = DailyLearningStatus.normalizeDate( + DateTime(normalizedMonth.year, normalizedMonth.month, day), + ); + + // LearningCompletionService로 완료 여부 판단 + final isCompleted = _completionService.isCompleted( + date: date, + assessmentsByDate: assessmentsByDate, + gradingHistoriesByDate: gradingHistoriesByDate, + ); + + statuses.add(DailyLearningStatus( + date: date, + isCompleted: isCompleted, + // Phase 3에서 bookProgresses 추가 예정 + )); + } + + return statuses; + } + + @override + Future getStatusForDate(DateTime date) async { + final month = DateTime(date.year, date.month, 1); + final statuses = await call(month); + final normalizedDate = DailyLearningStatus.normalizeDate(date); + + try { + return statuses.firstWhere( + (status) => DailyLearningStatus.normalizeDate(status.date) == normalizedDate, + ); + } catch (e) { + return DailyLearningStatus(date: normalizedDate, isCompleted: false); + } + } + + /// 날짜 포맷팅 (YYYY-MM-DD) + String _formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } +} + diff --git a/frontend/lib/services/grading_history_api.dart b/frontend/lib/services/grading_history_api.dart new file mode 100644 index 0000000..6a2e7c9 --- /dev/null +++ b/frontend/lib/services/grading_history_api.dart @@ -0,0 +1,155 @@ +import 'dart:convert'; +import 'dart:developer' as developer; +import 'package:http/http.dart' as http; +import '../config/api_config.dart'; +import '../utils/app_logger.dart'; +import 'auth_service.dart'; + +/// API 응답 DTO (서버 응답 구조 그대로) +/// +/// API 응답: flat array 형태 +/// [{student_response_id: ..., academy_user_id: 20, ...}, {student_response_id: ..., academy_user_id: 25, ...}] +class GradingHistoryApiResponse { + final int studentResponseId; // student_response_id + final int academyUserId; // academy_user_id + final int? assessId; // assess_id + final int? bookId; // book_id + final String? bookName; // book_name + final String? bookImageUrl; // book_image_url + final int responseStartPage; // response_start_page + final int responseEndPage; // response_end_page + final int unrecognizedResponseCount; // unrecognized_response_count + final String createdAt; // created_at (ISO 8601) + final String? updatedAt; // updated_at + + GradingHistoryApiResponse({ + required this.studentResponseId, + required this.academyUserId, + this.assessId, + this.bookId, + this.bookName, + this.bookImageUrl, + required this.responseStartPage, + required this.responseEndPage, + this.unrecognizedResponseCount = 0, + required this.createdAt, + this.updatedAt, + }); + + factory GradingHistoryApiResponse.fromJson(Map json) { + return GradingHistoryApiResponse( + studentResponseId: json['student_response_id'] as int? ?? 0, + academyUserId: json['academy_user_id'] as int? ?? 0, + assessId: json['assess_id'] as int?, + bookId: json['book_id'] as int?, + bookName: json['book_name'] as String?, + bookImageUrl: json['book_image_url'] as String?, + responseStartPage: json['response_start_page'] as int? ?? 0, + responseEndPage: json['response_end_page'] as int? ?? 0, + unrecognizedResponseCount: + json['unrecognized_response_count'] as int? ?? 0, + createdAt: json['created_at'] as String? ?? '', + updatedAt: json['updated_at'] as String?, + ); + } +} + +/// Grading History API 호출 전용 레이어 +class GradingHistoryApi { + GradingHistoryApi({ + required AuthService authService, + required http.Client httpClient, + }) : _authService = authService, + _httpClient = httpClient; + + final AuthService _authService; + final http.Client _httpClient; + + /// 여러 academyUserId에 대한 채점 히스토리 조회 + /// + /// [academyUserIds]: 조회할 academyUserId 리스트 + /// + /// 반환값: flat array 형태의 GradingHistoryApiResponse 리스트 + /// API 응답: [{...}, {...}] 형태의 배열 + /// + /// 현재는 전체 로드 방식이며, 필요 시 페이징 API로 확장 가능 + Future> fetchGradingHistories( + List academyUserIds, + ) async { + // 비즈니스 흐름 로그: appLog 사용 + appLog( + '[grading_history:grading_history_api] API 호출 시작 - academyUserIds: $academyUserIds', + ); + + // 1. 토큰 확인 + final token = await _authService.ensureValidAccessToken(); + if (token == null) { + appLog('[grading_history:grading_history_api] 인증 토큰 없음'); + throw Exception('인증 토큰이 없습니다. 로그인이 필요합니다.'); + } + + // 2. academyUserIds를 콤마로 구분한 문자열로 변환 + final idsParam = academyUserIds.join(','); + final uri = Uri.parse( + '${ApiConfig.baseUrl}/grading/student-responses/histories?academyUserIds=$idsParam', + ); + + appLog('[grading_history:grading_history_api] GET 요청: $uri'); + // 디버깅 디테일 로그: developer.log 사용 + developer.log('📚 [GradingHistoryApi] GET $uri'); + + // 3. API 호출 + final response = await _httpClient + .get( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ) + .timeout(const Duration(seconds: 10)); + + appLog( + '[grading_history:grading_history_api] 응답 상태 코드: ${response.statusCode}', + ); + appLog('[grading_history:grading_history_api] 응답 본문: ${response.body}'); + developer.log( + '📚 [GradingHistoryApi] Response status: ${response.statusCode}', + ); + + // 4. 에러 처리 + if (response.statusCode == 401) { + appLog('[grading_history:grading_history_api] 인증 실패 (401)'); + throw Exception('인증에 실패했습니다. 다시 로그인해주세요.'); + } + if (response.statusCode != 200) { + appLog( + '[grading_history:grading_history_api] API 호출 실패 - status: ${response.statusCode}', + ); + throw Exception('채점 히스토리 조회에 실패했습니다. (status: ${response.statusCode})'); + } + + // 5. JSON 파싱 + try { + final List data = json.decode(response.body); + final result = data + .map( + (item) => GradingHistoryApiResponse.fromJson( + item as Map, + ), + ) + .toList(); + + appLog( + '[grading_history:grading_history_api] 응답 파싱 성공 - 항목 수: ${result.length}', + ); + return result; + } catch (e) { + appLog('[grading_history:grading_history_api] 응답 파싱 실패: $e'); + appLog('[grading_history:grading_history_api] 응답 본문: ${response.body}'); + developer.log('❌ [GradingHistoryApi] 응답 파싱 실패: $e'); + developer.log('❌ [GradingHistoryApi] Response body: ${response.body}'); + rethrow; + } + } +} diff --git a/frontend/lib/services/grading_history_repository_impl.dart b/frontend/lib/services/grading_history_repository_impl.dart new file mode 100644 index 0000000..395bbdf --- /dev/null +++ b/frontend/lib/services/grading_history_repository_impl.dart @@ -0,0 +1,139 @@ +import 'dart:convert'; +import 'dart:developer' as developer; +import 'package:shared_preferences/shared_preferences.dart'; +import '../domain/grading_history/grading_history_entity.dart'; +import '../domain/grading_history/grading_history_repository.dart'; +import 'grading_history_api.dart'; +import '../data/mappers/grading_history_mapper.dart'; +import 'academy_service.dart'; + +/// Grading History Repository 구현체 +/// +/// API 호출, className 조회, 캐싱을 담당합니다. +class GradingHistoryRepositoryImpl implements GradingHistoryRepository { + GradingHistoryRepositoryImpl({ + required GradingHistoryApi api, + required GradingHistoryMapper mapper, + required AcademyService academyService, + }) : _api = api, + _mapper = mapper, + _academyService = academyService; + + final GradingHistoryApi _api; + final GradingHistoryMapper _mapper; + final AcademyService _academyService; + + static const String _cacheKeyPrefix = 'grading_history_'; + + @override + Future>> + getGradingHistoriesByAcademyUserIds(List academyUserIds) async { + if (academyUserIds.isEmpty) { + return {}; + } + + try { + // 1. API 호출 (flat array 반환) + final apiResponses = await _api.fetchGradingHistories(academyUserIds); + + // 2. className 조회 + final classNameMapStr = await _academyService.getClassesByAssigneeIds( + academyUserIds.map((id) => id.toString()).toList(), + ); + + // 3. String → int 키 변환 + final classNameMap = {}; + for (final id in academyUserIds) { + final className = classNameMapStr[id.toString()]; + classNameMap[id] = (className != null && className.isNotEmpty) + ? className + : null; + } + + // 4. Mapper로 변환 + final entities = _mapper.convertApiResponsesToEntities( + apiResponses, + classNameMap, + ); + + // 5. academyUserId별 그룹핑 + // ⚠️ 히스토리가 없는 academyUserId도 빈 리스트로 포함 (캐싱을 위해) + final groupedMap = >{}; + + // 먼저 모든 academyUserId에 대해 빈 리스트 초기화 + for (final id in academyUserIds) { + groupedMap[id] = []; + } + + // 실제 엔티티들을 그룹핑 + for (final entity in entities) { + groupedMap[entity.academyUserId]?.add(entity); + } + + // 6. 캐시 저장 (academyUserId별로, 빈 리스트도 저장) + for (final entry in groupedMap.entries) { + await _cacheGradingHistories(entry.key, entry.value); + } + + return groupedMap; + } catch (e) { + developer.log('❌ [GradingHistoryRepository] API 호출 실패: $e'); + + // 캐시에서 로드 시도 + final cachedMap = >{}; + for (final id in academyUserIds) { + final cached = await _getCachedGradingHistories(id); + if (cached != null) { + // 빈 리스트도 포함 (히스토리가 없는 경우도 캐시에 저장됨) + cachedMap[id] = cached; + } + } + + if (cachedMap.isNotEmpty) { + developer.log('✅ [GradingHistoryRepository] 캐시에서 로드 성공'); + return cachedMap; + } + + rethrow; + } + } + + /// 캐시 저장 + Future _cacheGradingHistories( + int academyUserId, + List entities, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + final cacheKey = '$_cacheKeyPrefix$academyUserId'; + final jsonList = entities.map((e) => e.toCacheJson()).toList(); + await prefs.setString(cacheKey, jsonEncode(jsonList)); + } catch (e) { + developer.log('⚠️ [GradingHistoryRepository] 캐시 저장 실패: $e'); + } + } + + /// 캐시에서 로드 + Future?> _getCachedGradingHistories( + int academyUserId, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + final cacheKey = '$_cacheKeyPrefix$academyUserId'; + final cachedJson = prefs.getString(cacheKey); + if (cachedJson == null) return null; + + final List jsonList = jsonDecode(cachedJson); + return jsonList + .map( + (json) => GradingHistoryEntity.fromCacheJson( + json as Map, + ), + ) + .toList(); + } catch (e) { + developer.log('⚠️ [GradingHistoryRepository] 캐시 로드 실패: $e'); + return null; + } + } +} diff --git a/frontend/lib/services/grading_history_use_case.dart b/frontend/lib/services/grading_history_use_case.dart new file mode 100644 index 0000000..25cb164 --- /dev/null +++ b/frontend/lib/services/grading_history_use_case.dart @@ -0,0 +1,66 @@ +import '../domain/grading_history/grading_history_entity.dart'; +import 'grading_history_api.dart'; + +/// Grading History UseCase +/// +/// API 응답을 도메인 엔티티로 변환하는 비즈니스 로직을 담당합니다. +class GradingHistoryUseCase { + /// API 응답 리스트를 도메인 엔티티 리스트로 변환 + /// + /// [responses]: API 응답 리스트 (flat array) + /// [classNameMap]: academyUserId → className 매핑 + /// + /// 반환값: 도메인 엔티티 리스트 (정렬 없음, 순수 변환만) + /// 정렬은 UI 레이어에서 처리합니다. + List convertApiResponsesToEntities( + List responses, + Map classNameMap, + ) { + return responses.map((response) { + // 1. createdAt 파싱 (UTC → 로컬 변환, 방어 로직 포함) + final createdAt = _parseCreatedAt(response.createdAt); + + // 2. className 주입 + final className = classNameMap[response.academyUserId]; + + // 3. 엔티티 생성 + return GradingHistoryEntity( + studentResponseId: response.studentResponseId, + academyUserId: response.academyUserId, + bookId: response.bookId, + bookName: response.bookName, + bookCoverImageUrl: response.bookImageUrl, + startPage: response.responseStartPage, + endPage: response.responseEndPage, + className: className, + gradingDate: createdAt, + assessId: response.assessId, + unrecognizedResponseCount: response.unrecognizedResponseCount, + ); + }).toList(); + } + + /// createdAt 문자열을 DateTime으로 안전하게 파싱 + /// + /// 빈 문자열이거나 파싱 실패 시 과거 고정값 반환 (정렬 시 뒤로 가도록) + /// + /// 규칙: + /// - created_at은 서버에서 UTC 기준 ISO 문자열로 내려온다는 전제 하에 toLocal() 적용 + /// - 만약 서버가 이미 KST로 내려주면 toLocal() 제거 필요 + /// - 파싱 실패 시 DateTime(1970, 1, 1) 반환 (정렬 시 가장 뒤로) + DateTime _parseCreatedAt(String value) { + if (value.isEmpty) { + // 빈 문자열이면 과거 고정값 반환 (정렬 시 뒤로 가도록) + return DateTime(1970, 1, 1); + } + + try { + // created_at은 서버에서 UTC 기준 ISO 문자열로 내려온다는 전제 하에 toLocal() 적용 + // 만약 서버가 이미 KST로 내려주면 toLocal() 제거 필요 + return DateTime.parse(value).toLocal(); + } catch (_) { + // 파싱 실패 시 과거 고정값 반환 (정렬 시 가장 뒤로) + return DateTime(1970, 1, 1); + } + } +} diff --git a/frontend/lib/services/learning_completion_service_impl.dart b/frontend/lib/services/learning_completion_service_impl.dart new file mode 100644 index 0000000..7af6028 --- /dev/null +++ b/frontend/lib/services/learning_completion_service_impl.dart @@ -0,0 +1,119 @@ +import '../domain/learning/learning_completion_service.dart'; +import '../domain/learning/daily_learning_status.dart'; +import '../models/assessment.dart'; +import '../domain/grading_history/grading_history_entity.dart'; + +/// LearningCompletionService 구현체 +/// +/// 순수 함수형으로 설계되어 내부 상태를 가지지 않습니다. +/// 모든 판단은 매개변수로 받은 데이터만으로 수행합니다. +class LearningCompletionServiceImpl implements LearningCompletionService { + const LearningCompletionServiceImpl(); + + @override + bool isCompleted({ + required DateTime date, + Map>? assessmentsByDate, + Map>? gradingHistoriesByDate, + }) { + final dateStr = _formatDate(date); + + // 우선순위 1: GradingHistory (실제 채점/제출 기록) + // - 해당 날짜에 gradingDate 가 1개 이상 있으면 "오늘의 학습이 존재"하므로 완료로 판단 + if (gradingHistoriesByDate != null) { + final histories = gradingHistoriesByDate[dateStr] ?? []; + if (histories.isNotEmpty) { + return true; + } + } + + // 우선순위 2: Assessment (숙제 완료 기록) + // - 과제가 하나라도 있으면 "모든 과제가 Y"일 때만 완료로 판단 + // - 과제가 아예 없을 때는 여기서는 완료로 보지 않음 + if (assessmentsByDate != null) { + final assessments = assessmentsByDate[dateStr] ?? []; + if (assessments.isEmpty) { + return false; + } + return assessments.every((a) => a.assessStatus == 'Y'); + } + + // GradingHistory, Assessment 모두 없는 경우 → 완료 아님 + return false; + } + + @override + Map getCompletionMap({ + required DateTime startDate, + required DateTime endDate, + Map>? assessmentsByDate, + Map>? gradingHistoriesByDate, + }) { + final map = {}; + var current = DailyLearningStatus.normalizeDate(startDate); + final normalizedEnd = DailyLearningStatus.normalizeDate(endDate); + + while (current.isBefore(normalizedEnd) || + current.isAtSameMomentAs(normalizedEnd)) { + map[current] = isCompleted( + date: current, + assessmentsByDate: assessmentsByDate, + gradingHistoriesByDate: gradingHistoriesByDate, + ); + current = current.add(const Duration(days: 1)); + } + + return map; + } + + @override + int getConsecutiveDays({ + required DateTime date, + Map>? assessmentsByDate, + Map>? gradingHistoriesByDate, + }) { + var current = DailyLearningStatus.normalizeDate(date); + int streak = 0; + + // 오늘부터 역순으로 계산 + while (isCompleted( + date: current, + assessmentsByDate: assessmentsByDate, + gradingHistoriesByDate: gradingHistoriesByDate, + )) { + streak++; + current = current.subtract(const Duration(days: 1)); + } + + return streak; + } + + @override + bool shouldShowConnector({ + required DateTime date, + required DateTime nextDate, + Map>? assessmentsByDate, + Map>? gradingHistoriesByDate, + }) { + final normalizedDate = DailyLearningStatus.normalizeDate(date); + final normalizedNext = DailyLearningStatus.normalizeDate(nextDate); + + return isCompleted( + date: normalizedDate, + assessmentsByDate: assessmentsByDate, + gradingHistoriesByDate: gradingHistoriesByDate, + ) && + isCompleted( + date: normalizedNext, + assessmentsByDate: assessmentsByDate, + gradingHistoriesByDate: gradingHistoriesByDate, + ) && + normalizedNext.month == normalizedDate.month && + normalizedNext.year == normalizedDate.year; + } + + /// 날짜 포맷팅 (YYYY-MM-DD) + String _formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } +} diff --git a/frontend/lib/services/location_service.dart b/frontend/lib/services/location_service.dart new file mode 100644 index 0000000..1777081 --- /dev/null +++ b/frontend/lib/services/location_service.dart @@ -0,0 +1,58 @@ +import 'package:geolocator/geolocator.dart'; +import 'dart:developer' as developer; + +/// 위치 권한 및 현재 위치 조회를 담당하는 서비스 +/// +/// DI Container에서 singleton으로 관리되며, +/// 이 파일에서는 별도의 싱글톤 패턴을 구현하지 않습니다. +class LocationService { + LocationService(); + + /// 위치 권한 요청 및 현재 위치 가져오기 + Future getCurrentLocation() async { + try { + // 위치 서비스 활성화 확인 + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + developer.log('Location services are disabled'); + return null; + } + + // 위치 권한 확인 + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + developer.log('Location permissions are denied'); + return null; + } + } + + if (permission == LocationPermission.deniedForever) { + developer.log( + 'Location permissions are permanently denied, we cannot request permissions.'); + return null; + } + + // 현재 위치 가져오기 + Position position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + ); + + developer.log( + 'Current location: ${position.latitude}, ${position.longitude}'); + return position; + } catch (e) { + developer.log('Error getting current location: $e'); + return null; + } + } + + /// 위치 권한 상태 확인 + Future hasLocationPermission() async { + LocationPermission permission = await Geolocator.checkPermission(); + return permission == LocationPermission.whileInUse || + permission == LocationPermission.always; + } +} + diff --git a/frontend/lib/services/models/question_status_model.dart b/frontend/lib/services/models/question_status_model.dart new file mode 100644 index 0000000..e26897d --- /dev/null +++ b/frontend/lib/services/models/question_status_model.dart @@ -0,0 +1,14 @@ +/// 문제 풀이 상태 모델 (Application Layer) +/// +/// Domain Entity를 UI에 맞게 변환한 모델 +/// UI는 이 모델을 받아서 자유롭게 렌더링 +class QuestionStatusModel { + final int questionNumber; + final bool? isCorrect; // null = 안 풀음, true = 맞음, false = 틀림 + + const QuestionStatusModel({ + required this.questionNumber, + required this.isCorrect, + }); +} + diff --git a/frontend/lib/services/policies/answer_selection_policy.dart b/frontend/lib/services/policies/answer_selection_policy.dart new file mode 100644 index 0000000..5a757a7 --- /dev/null +++ b/frontend/lib/services/policies/answer_selection_policy.dart @@ -0,0 +1,10 @@ +/// 중복 답안 선택 정책 +/// +/// 같은 문제 번호에 여러 답안이 있을 경우 어떤 답안을 선택할지 결정 +class AnswerSelectionPolicy { + /// 기존 답안을 우선 사용, 없으면 새 답안 사용 + bool? selectAnswer(bool? existing, bool? next) { + return existing ?? next; + } +} + diff --git a/frontend/lib/services/section_image_api.dart b/frontend/lib/services/section_image_api.dart new file mode 100644 index 0000000..306ea91 --- /dev/null +++ b/frontend/lib/services/section_image_api.dart @@ -0,0 +1,147 @@ +import 'dart:convert'; +import 'dart:developer' as developer; +import 'package:http/http.dart' as http; +import '../config/api_config.dart'; +import '../utils/app_logger.dart'; +import 'auth_service.dart'; + +/// API 응답 DTO (서버 응답 구조 그대로) +class SectionImageApiResponse { + final String url; // S3 base URL + final String key; // S3 object key + + SectionImageApiResponse({ + required this.url, + required this.key, + }); + + factory SectionImageApiResponse.fromJson(Map json) { + final url = json['url'] as String?; + final key = json['key'] as String?; + + if (url == null || url.isEmpty) { + throw FormatException( + '[SectionImageApiResponse.fromJson] url is null or empty', + json, + ); + } + if (key == null || key.isEmpty) { + throw FormatException( + '[SectionImageApiResponse.fromJson] key is null or empty', + json, + ); + } + + return SectionImageApiResponse( + url: url, + key: key, + ); + } +} + +/// Section 이미지가 없을 때 발생하는 예외 (Data Layer 내부용) +/// +/// Repository에서 null로 변환되어 UI 레이어로는 전파되지 않음 +class SectionImageNotFoundException implements Exception { + final String message; + SectionImageNotFoundException(this.message); + + @override + String toString() => message; +} + +/// Section 이미지 API 호출 전용 레이어 +class SectionImageApi { + final AuthService _authService; + final http.Client _httpClient; + + SectionImageApi({ + required AuthService authService, + required http.Client httpClient, + }) : _authService = authService, + _httpClient = httpClient; + + /// Section 이미지 URL 조회 + /// + /// API 스펙: GET /storage/section?academyUserId={id}&studentResponseId={id}&questionNumber={num}&subQuestionNumber={num} + /// (백엔드와 합의 완료) + Future fetchSectionImageUrl({ + required int academyUserId, + required int studentResponseId, + required int questionNumber, + required int subQuestionNumber, + }) async { + appLog( + '[section_image:section_image_api] API 호출 시작 - academyUserId: $academyUserId, studentResponseId: $studentResponseId, questionNumber: $questionNumber, subQuestionNumber: $subQuestionNumber', + ); + + // 1. 토큰 확인 + final token = await _authService.ensureValidAccessToken(); + if (token == null) { + appLog('[section_image:section_image_api] 인증 토큰 없음'); + throw Exception('인증 토큰이 없습니다. 로그인이 필요합니다.'); + } + + // 2. URI 생성 + final uri = Uri.parse( + '${ApiConfig.baseUrl}/storage/section?academyUserId=$academyUserId&studentResponseId=$studentResponseId&questionNumber=$questionNumber&subQuestionNumber=$subQuestionNumber', + ); + + appLog('[section_image:section_image_api] GET 요청: $uri'); + developer.log('🖼️ [SectionImageApi] GET $uri'); + + // 3. API 호출 + final response = await _httpClient + .get( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ) + .timeout(const Duration(seconds: 10)); + + appLog( + '[section_image:section_image_api] 응답 상태 코드: ${response.statusCode}', + ); + appLog( + '[section_image:section_image_api] 응답 본문: ${response.body}', + ); + developer.log( + '🖼️ [SectionImageApi] Response status: ${response.statusCode}', + ); + + // 4. 에러 처리 + if (response.statusCode == 401) { + appLog('[section_image:section_image_api] 인증 실패 (401)'); + throw Exception('인증에 실패했습니다. 다시 로그인해주세요.'); + } + if (response.statusCode == 404) { + appLog('[section_image:section_image_api] 이미지 없음 (404)'); + throw SectionImageNotFoundException('해당 문제의 이미지를 찾을 수 없습니다.'); + } + if (response.statusCode != 200) { + appLog( + '[section_image:section_image_api] API 호출 실패 - status: ${response.statusCode}', + ); + throw Exception('이미지 정보를 불러오지 못했습니다. 잠시 후 다시 시도해주세요.'); + } + + // 5. JSON 파싱 + try { + final Map data = json.decode(response.body); + final result = SectionImageApiResponse.fromJson(data); + + appLog('[section_image:section_image_api] 응답 파싱 성공'); + return result; + } catch (e) { + appLog('[section_image:section_image_api] 응답 파싱 실패: $e'); + appLog('[section_image:section_image_api] 응답 본문: ${response.body}'); + developer.log('❌ [SectionImageApi] 응답 파싱 실패: $e'); + developer.log('❌ [SectionImageApi] Response body: ${response.body}'); + rethrow; + } + } +} + + diff --git a/frontend/lib/services/section_image_mapper.dart b/frontend/lib/services/section_image_mapper.dart new file mode 100644 index 0000000..f5087c5 --- /dev/null +++ b/frontend/lib/services/section_image_mapper.dart @@ -0,0 +1,19 @@ +import '../domain/section_image/section_image_entity.dart'; +import 'section_image_api.dart'; + +/// Section 이미지 API 응답을 도메인 엔티티로 변환하는 Mapper +/// +/// Data Layer 내부 유틸리티로, DTO → Entity 변환만 담당 +class SectionImageMapper { + /// API 응답을 도메인 엔티티로 변환 + SectionImageEntity fromApi(SectionImageApiResponse response) { + // API 응답의 url은 이미 완성된 signed URL이므로 그대로 사용 + // (url에 query parameter가 포함되어 있어 key와 합치면 안 됨) + final imageUrl = response.url; + return SectionImageEntity( + imageUrl: imageUrl, + ); + } +} + + diff --git a/frontend/lib/services/section_image_repository_impl.dart b/frontend/lib/services/section_image_repository_impl.dart new file mode 100644 index 0000000..37cf224 --- /dev/null +++ b/frontend/lib/services/section_image_repository_impl.dart @@ -0,0 +1,48 @@ +import 'dart:developer' as developer; +import '../domain/section_image/section_image_entity.dart'; +import '../domain/section_image/section_image_repository.dart'; +import 'section_image_api.dart'; +import 'section_image_mapper.dart'; + +/// Section 이미지 Repository 구현체 +class SectionImageRepositoryImpl implements SectionImageRepository { + final SectionImageApi _api; + final SectionImageMapper _mapper; + + SectionImageRepositoryImpl({ + required SectionImageApi api, + required SectionImageMapper mapper, + }) : _api = api, + _mapper = mapper; + + @override + Future getSectionImageUrl({ + required int academyUserId, + required int studentResponseId, + required int questionNumber, + required int subQuestionNumber, + }) async { + try { + // 1. API 호출 + final apiResponse = await _api.fetchSectionImageUrl( + academyUserId: academyUserId, + studentResponseId: studentResponseId, + questionNumber: questionNumber, + subQuestionNumber: subQuestionNumber, + ); + + // 2. Mapper로 엔티티 변환 + return _mapper.fromApi(apiResponse); + } on SectionImageNotFoundException { + // 404 에러는 "이미지 없음"으로 간주하여 null 반환 (정상 케이스) + developer.log('ℹ️ [SectionImageRepository] 이미지 없음 (404) - 정상 케이스'); + return null; + } catch (e) { + // 그 외 에러는 예외로 전파 + developer.log('❌ [SectionImageRepository] API 호출 실패: $e'); + rethrow; + } + } +} + + diff --git a/frontend/lib/services/student_answer_api.dart b/frontend/lib/services/student_answer_api.dart new file mode 100644 index 0000000..64d2ff6 --- /dev/null +++ b/frontend/lib/services/student_answer_api.dart @@ -0,0 +1,454 @@ +import 'dart:convert'; +import 'dart:developer' as developer; +import 'package:http/http.dart' as http; +import '../config/api_config.dart'; +import '../utils/app_logger.dart'; +import 'auth_service.dart'; + +/// API 응답 DTO (서버 응답 구조 그대로) +class StudentAnswerApiResponse { + final int studentAnswerId; + final int studentResponseId; + final int? chapterId; + final int page; + final int questionNumber; + final int subQuestionNumber; + final String answer; // 문자열 + final String? sectionUrl; + final bool? isCorrect; // null = 답안 없음, true = 맞음, false = 틀림 + final double score; + + StudentAnswerApiResponse({ + required this.studentAnswerId, + required this.studentResponseId, + this.chapterId, + required this.page, + required this.questionNumber, + required this.subQuestionNumber, + required this.answer, + this.sectionUrl, + required this.isCorrect, + required this.score, + }); + + factory StudentAnswerApiResponse.fromJson(Map json) { + // 필수 필드 검증 (ID는 0이면 유효하지 않음) + final studentAnswerId = json['student_answer_id'] as int?; + final studentResponseId = json['student_response_id'] as int?; + + if (studentAnswerId == null || studentAnswerId == 0) { + throw FormatException( + '[StudentAnswerApiResponse.fromJson] student_answer_id is null or 0', + json, + ); + } + if (studentResponseId == null || studentResponseId == 0) { + throw FormatException( + '[StudentAnswerApiResponse.fromJson] student_response_id is null or 0', + json, + ); + } + + return StudentAnswerApiResponse( + studentAnswerId: studentAnswerId, + studentResponseId: studentResponseId, + chapterId: json['chapter_id'] as int?, + page: json['page'] as int? ?? 0, + questionNumber: json['question_number'] as int? ?? 0, + subQuestionNumber: json['sub_question_number'] as int? ?? 0, + answer: json['answer'] as String? ?? '', // null이면 빈 문자열 + sectionUrl: json['section_url'] as String?, + isCorrect: json['is_correct'] as bool?, // null 허용 + score: (json['score'] as num?)?.toDouble() ?? 0.0, + ); + } +} + +/// 단일 답안 수정 응답 DTO +class UpdatedStudentAnswerDto { + final int studentAnswerId; + final int studentResponseId; + final int? chapterId; + final int page; + final int questionNumber; + final int subQuestionNumber; + final String answer; + final String? sectionUrl; + final bool? correct; // null 허용 + final double score; + + UpdatedStudentAnswerDto({ + required this.studentAnswerId, + required this.studentResponseId, + this.chapterId, + required this.page, + required this.questionNumber, + required this.subQuestionNumber, + required this.answer, + this.sectionUrl, + required this.correct, + required this.score, + }); + + factory UpdatedStudentAnswerDto.fromJson(Map json) { + // 서버가 snake_case 또는 camelCase를 혼용할 수 있으므로 둘 다 대응 + final studentAnswerId = + json['studentAnswerId'] as int? ?? json['student_answer_id'] as int?; + final studentResponseId = json['studentResponseId'] as int? ?? + json['student_response_id'] as int?; + + if (studentAnswerId == null || studentAnswerId == 0) { + throw FormatException( + '[UpdatedStudentAnswerDto.fromJson] studentAnswerId is null or 0', + json, + ); + } + if (studentResponseId == null || studentResponseId == 0) { + throw FormatException( + '[UpdatedStudentAnswerDto.fromJson] studentResponseId is null or 0', + json, + ); + } + + final chapterId = + json['chapterId'] as int? ?? json['chapter_id'] as int?; + final page = json['page'] as int? ?? 0; + final questionNumber = + json['questionNumber'] as int? ?? json['question_number'] as int? ?? 0; + final subQuestionNumber = json['subQuestionNumber'] as int? ?? + json['sub_question_number'] as int? ?? + 0; + final answer = json['answer'] as String? ?? ''; + final sectionUrl = + json['sectionUrl'] as String? ?? json['section_url'] as String?; + final correct = + json['correct'] as bool? ?? json['is_correct'] as bool?; + final score = (json['score'] as num?)?.toDouble() ?? 0.0; + + return UpdatedStudentAnswerDto( + studentAnswerId: studentAnswerId, + studentResponseId: studentResponseId, + chapterId: chapterId, + page: page, + questionNumber: questionNumber, + subQuestionNumber: subQuestionNumber, + answer: answer, + sectionUrl: sectionUrl, + correct: correct, + score: score, + ); + } +} + +/// Student Answer API 호출 전용 레이어 +class StudentAnswerApi { + final AuthService _authService; + final http.Client _httpClient; + + StudentAnswerApi({ + required AuthService authService, + required http.Client httpClient, + }) : _authService = authService, + _httpClient = httpClient; + + /// studentResponseId로 학생 답안 목록 조회 + /// + /// API 스펙: GET /grading/student-answers/response?student_response_id={id} + /// (백엔드와 합의 완료) + Future> fetchStudentAnswers( + int studentResponseId, + ) async { + appLog( + '[student_answer:student_answer_api] API 호출 시작 - studentResponseId: $studentResponseId', + ); + + // 1. 토큰 확인 + final token = await _authService.ensureValidAccessToken(); + if (token == null) { + appLog('[student_answer:student_answer_api] 인증 토큰 없음'); + throw Exception('인증 토큰이 없습니다. 로그인이 필요합니다.'); + } + + // 2. URI 생성 + // ※ 서버 스펙 변경: student_response_id -> studentResponseId + final uri = Uri.parse( + '${ApiConfig.baseUrl}/grading/student-answers/response?studentResponseId=$studentResponseId', + ); + + appLog('[student_answer:student_answer_api] GET 요청: $uri'); + developer.log('📝 [StudentAnswerApi] GET $uri'); + + // 3. API 호출 + final response = await _httpClient + .get( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ) + .timeout(const Duration(seconds: 10)); + + appLog( + '[student_answer:student_answer_api] 응답 상태 코드: ${response.statusCode}', + ); + developer.log( + '📝 [StudentAnswerApi] Response status: ${response.statusCode}', + ); + + // 4. 에러 처리 + if (response.statusCode == 401) { + appLog('[student_answer:student_answer_api] 인증 실패 (401)'); + throw Exception('인증에 실패했습니다. 다시 로그인해주세요.'); + } + if (response.statusCode != 200) { + appLog( + '[student_answer:student_answer_api] API 호출 실패 - status: ${response.statusCode}', + ); + throw Exception('학생 답안 조회에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } + + // 5. JSON 파싱 + try { + final List data = json.decode(response.body); + final result = data + .map( + (item) => + StudentAnswerApiResponse.fromJson(item as Map), + ) + .toList(); + + appLog( + '[student_answer:student_answer_api] 응답 파싱 성공 - 항목 수: ${result.length}', + ); + appLog('[student_answer:student_answer_api] 응답 본문: ${response.body}'); + return result; + } catch (e) { + appLog('[student_answer:student_answer_api] 응답 파싱 실패: $e'); + appLog('[student_answer:student_answer_api] 응답 본문: ${response.body}'); + developer.log('❌ [StudentAnswerApi] 응답 파싱 실패: $e'); + developer.log('❌ [StudentAnswerApi] Response body: ${response.body}'); + rethrow; + } + } + + /// 수정된 답안들을 서버에 저장 + /// + /// API 스펙: PATCH /grading/student-answers (추정, 백엔드 확인 필요) + /// Payload: [{student_answer_id: 1, answer: "B"}, ...] + Future updateStudentAnswers(List> payload) async { + appLog( + '[student_answer:student_answer_api] 답안 수정 API 호출 시작 - 수정 항목 수: ${payload.length}', + ); + + // 1. 토큰 확인 + final token = await _authService.ensureValidAccessToken(); + if (token == null) { + throw Exception('인증 토큰이 없습니다. 로그인이 필요합니다.'); + } + + // 2. URI 생성 (백엔드 스펙 확인 필요) + final uri = Uri.parse('${ApiConfig.baseUrl}/grading/student-answers'); + + appLog('[student_answer:student_answer_api] PATCH 요청: $uri'); + developer.log('📝 [StudentAnswerApi] PATCH $uri'); + + // 3. API 호출 + final response = await _httpClient + .patch( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + body: json.encode(payload), + ) + .timeout(const Duration(seconds: 10)); + + appLog( + '[student_answer:student_answer_api] 응답 상태 코드: ${response.statusCode}', + ); + + // 4. 에러 처리 + if (response.statusCode == 401) { + throw Exception('인증에 실패했습니다. 다시 로그인해주세요.'); + } + if (response.statusCode != 200 && response.statusCode != 204) { + throw Exception('답안 저장에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } + + appLog('[student_answer:student_answer_api] 답안 수정 성공'); + } + + /// chapterId + academyUserId로 학생 답안 조회 (grass API) + /// + /// **API 스펙**: GET /grading/student-answers/grass?academyUserId={id}&chapterId={id} + /// + /// **응답 구조**: + /// - is_correct가 null이면 안 푼 문제 + /// - is_correct가 true면 맞은 문제 + /// - is_correct가 false면 틀린 문제 + Future> fetchStudentAnswersByChapterAndAcademy( + int chapterId, + int academyUserId, + ) async { + appLog( + '[student_answer:student_answer_api] Grass API 호출 시작 - chapterId: $chapterId, academyUserId: $academyUserId', + ); + + // 1. 토큰 확인 + final token = await _authService.ensureValidAccessToken(); + if (token == null) { + appLog('[student_answer:student_answer_api] 인증 토큰 없음'); + throw Exception('인증 토큰이 없습니다. 로그인이 필요합니다.'); + } + + // 2. URI 생성 + final uri = Uri.parse( + '${ApiConfig.baseUrl}/grading/student-answers/grass?academyUserId=$academyUserId&chapterId=$chapterId', + ); + + appLog('[student_answer:student_answer_api] GET 요청: $uri'); + developer.log('📝 [StudentAnswerApi] GET $uri'); + + // 3. API 호출 + final response = await _httpClient + .get( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ) + .timeout(const Duration(seconds: 10)); + + appLog( + '[student_answer:student_answer_api] 응답 상태 코드: ${response.statusCode}', + ); + developer.log( + '📝 [StudentAnswerApi] Response status: ${response.statusCode}', + ); + + // 4. 에러 처리 + if (response.statusCode == 401) { + appLog('[student_answer:student_answer_api] 인증 실패 (401)'); + throw Exception('인증에 실패했습니다. 다시 로그인해주세요.'); + } + if (response.statusCode != 200) { + appLog( + '[student_answer:student_answer_api] API 호출 실패 - status: ${response.statusCode}', + ); + throw Exception('학생 답안 조회에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } + + // 5. JSON 파싱 + try { + final List data = json.decode(response.body); + final result = data + .map( + (item) => + StudentAnswerApiResponse.fromJson(item as Map), + ) + .toList(); + + appLog( + '[student_answer:student_answer_api] 응답 파싱 성공 - 항목 수: ${result.length}', + ); + appLog('[student_answer:student_answer_api] 응답 본문: ${response.body}'); + return result; + } catch (e) { + appLog('[student_answer:student_answer_api] 응답 파싱 실패: $e'); + appLog('[student_answer:student_answer_api] 응답 본문: ${response.body}'); + developer.log('❌ [StudentAnswerApi] 응답 파싱 실패: $e'); + developer.log('❌ [StudentAnswerApi] Response body: ${response.body}'); + rethrow; + } + } + + /// 단일 답안 수정 API + /// + /// 스펙: PUT /grading/student-answers/update + /// Body: + /// { + /// "student_answer_id": 1, + /// "chapter_id": 1, + /// "student_response_id": 1764406625968, + /// "question_number": 1, + /// "sub_question_number": 0, + /// "answer": "test" + /// } + Future updateSingleStudentAnswer({ + required int studentAnswerId, + required int studentResponseId, + required int questionNumber, + required int subQuestionNumber, + required String answer, + required int chapterId, + }) async { + appLog( + '[student_answer:student_answer_api] 단일 답안 수정 API 호출 시작 - studentAnswerId: $studentAnswerId', + ); + + final token = await _authService.ensureValidAccessToken(); + if (token == null) { + throw Exception('인증 토큰이 없습니다. 로그인이 필요합니다.'); + } + + final uri = Uri.parse( + '${ApiConfig.baseUrl}/grading/student-answers/update', + ); + + final body = { + 'student_answer_id': studentAnswerId, + 'chapter_id': chapterId, + 'student_response_id': studentResponseId, + 'question_number': questionNumber, + 'sub_question_number': subQuestionNumber, + 'answer': answer, + }; + + appLog('[student_answer:student_answer_api] PUT 요청: $uri'); + appLog('[student_answer:student_answer_api] 요청 본문: ${json.encode(body)}'); + developer.log('📝 [StudentAnswerApi] PUT $uri'); + developer.log('📝 [StudentAnswerApi] Request body: ${json.encode(body)}'); + + final response = await _httpClient + .put( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + body: json.encode(body), + ) + .timeout(const Duration(seconds: 10)); + + appLog( + '[student_answer:student_answer_api] 단일 답안 수정 응답 코드: ${response.statusCode}', + ); + appLog( + '[student_answer:student_answer_api] 응답 본문: ${response.body}', + ); + + if (response.statusCode == 401) { + throw Exception('인증에 실패했습니다. 다시 로그인해주세요.'); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + developer.log('❌ [StudentAnswerApi] 응답 본문: ${response.body}'); + throw Exception('답안 수정에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } + + try { + final Map jsonBody = + json.decode(response.body) as Map; + final dto = UpdatedStudentAnswerDto.fromJson(jsonBody); + appLog('[student_answer:student_answer_api] 단일 답안 수정 응답 파싱 성공'); + return dto; + } catch (e) { + appLog('[student_answer:student_answer_api] 단일 답안 수정 응답 파싱 실패: $e'); + developer.log('❌ [StudentAnswerApi] updateSingleStudentAnswer parse error: $e'); + developer.log('❌ [StudentAnswerApi] body: ${response.body}'); + rethrow; + } + } +} diff --git a/frontend/lib/services/student_answer_mapper.dart b/frontend/lib/services/student_answer_mapper.dart new file mode 100644 index 0000000..2ee889d --- /dev/null +++ b/frontend/lib/services/student_answer_mapper.dart @@ -0,0 +1,25 @@ +import '../domain/student_answer/student_answer_entity.dart'; +import 'student_answer_api.dart'; + +/// Student Answer API 응답을 도메인 엔티티로 변환하는 Mapper +/// +/// Data Layer 내부 유틸리티로, DTO → Entity 변환만 담당 +class StudentAnswerMapper { + /// API 응답 리스트를 도메인 엔티티 리스트로 변환 + List fromApi(List responses) { + return responses.map((response) { + return StudentAnswerEntity( + studentAnswerId: response.studentAnswerId, + studentResponseId: response.studentResponseId, + chapterId: response.chapterId, + page: response.page, + questionNumber: response.questionNumber, + subQuestionNumber: response.subQuestionNumber, + answer: response.answer, + sectionUrl: response.sectionUrl, + isCorrect: response.isCorrect, + score: response.score, + ); + }).toList(); + } +} diff --git a/frontend/lib/services/student_answer_repository_impl.dart b/frontend/lib/services/student_answer_repository_impl.dart new file mode 100644 index 0000000..1c40bb7 --- /dev/null +++ b/frontend/lib/services/student_answer_repository_impl.dart @@ -0,0 +1,81 @@ +import 'dart:developer' as developer; +import '../domain/student_answer/student_answer_entity.dart'; +import '../domain/student_answer/student_answer_repository.dart'; +import '../domain/student_answer/student_answer_update.dart'; +import '../domain/student_answer/student_answer_query.dart'; +import 'student_answer_api.dart'; +import 'student_answer_mapper.dart'; + +/// Student Answer Repository 구현체 +class StudentAnswerRepositoryImpl implements StudentAnswerRepository { + final StudentAnswerApi _api; + final StudentAnswerMapper _mapper; + + StudentAnswerRepositoryImpl({ + required StudentAnswerApi api, + required StudentAnswerMapper mapper, + }) : _api = api, + _mapper = mapper; + + @override + Future> getStudentAnswersByResponseId( + int studentResponseId, + ) async { + try { + // 1. API 호출 + final apiResponses = await _api.fetchStudentAnswers(studentResponseId); + + // 2. Mapper로 엔티티 변환 + return _mapper.fromApi(apiResponses); + } catch (e) { + developer.log('❌ [StudentAnswerRepository] API 호출 실패: $e'); + rethrow; + } + } + + @override + Future updateStudentAnswers(List updates) async { + try { + // StudentAnswerUpdate → Map 변환 (API 레이어는 낮은 수준의 타입만 받음) + final payload = updates.map((update) { + return { + 'student_answer_id': update.studentAnswerId, + 'answer': update.newAnswer, + }; + }).toList(); + + await _api.updateStudentAnswers(payload); + } catch (e) { + developer.log('❌ [StudentAnswerRepository] 답안 수정 실패: $e'); + rethrow; + } + } + + @override + Future> getStudentAnswers( + StudentAnswerQuery query, + ) async { + try { + List apiResponses; + + if (query is ChapterAndAcademyQuery) { + // Grass API 호출 + apiResponses = await _api.fetchStudentAnswersByChapterAndAcademy( + query.chapterId, + query.academyUserId, + ); + } else if (query is ResponseIdQuery) { + // 기존 Response API 호출 + apiResponses = await _api.fetchStudentAnswers(query.studentResponseId); + } else { + throw ArgumentError('지원하지 않는 Query 타입입니다.'); + } + + // Mapper로 엔티티 변환 + return _mapper.fromApi(apiResponses); + } catch (e) { + developer.log('❌ [StudentAnswerRepository] 답안 조회 실패: $e'); + rethrow; + } + } +} diff --git a/frontend/lib/services/upload_batch_service.dart b/frontend/lib/services/upload_batch_service.dart new file mode 100644 index 0000000..3fa0f4f --- /dev/null +++ b/frontend/lib/services/upload_batch_service.dart @@ -0,0 +1,425 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:image_picker/image_picker.dart'; + +import '../config/api_config.dart'; +import '../utils/app_logger.dart'; +import 'auth_service.dart'; +import 'academy_service.dart'; + +class NoAcademyException implements Exception { + final String message; + NoAcademyException([ + this.message = '등록된 학원이 없습니다. 학원을 먼저 등록해주세요.', + ]); + + @override + String toString() => message; +} + +class UploadContext { + final int academyUserId; + final int userId; + final int? classId; // TODO: class_id는 null일 수 있으므로 추후 구현 시 확인 필요 + final int academyId; + + const UploadContext({ + required this.academyUserId, + required this.userId, + this.classId, // nullable이므로 required 제거 + required this.academyId, + }); +} + +/// Presigned URL 배치 응답 모델 +class PresignedBatchResponse { + final int studentResponseId; + final List presignedUrls; + final String message; + + PresignedBatchResponse({ + required this.studentResponseId, + required this.presignedUrls, + required this.message, + }); + + factory PresignedBatchResponse.fromJson(Map json) { + return PresignedBatchResponse( + studentResponseId: json['studentResponseId'] as int, + presignedUrls: (json['presignedUrls'] as List) + .map((url) => url as String) + .toList(), + message: json['message'] as String? ?? 'success', + ); + } +} + +class UploadBatchService { + final AuthService _authService; + final AcademyService _academyService; + + UploadBatchService({AuthService? authService, AcademyService? academyService}) + : _authService = authService ?? AuthService(), + _academyService = academyService ?? AcademyService(); + + /// 현재 디폴트 학원 기준 UploadContext 생성 + Future buildContext() async { + final userIdStr = await _authService.getUserId(); + if (userIdStr == null) { + throw Exception('사용자 ID를 가져올 수 없습니다.'); + } + + final academyCode = await _academyService.getDefaultAcademyCode(); + if (academyCode == null) { + throw NoAcademyException(); + } + + appLog('[UploadBatchService] 디폴트 학원 코드: $academyCode'); + + // 캐시에서 학원 정보 조회 시도 + var academy = await _academyService.getAcademyByCode(academyCode); + appLog('[UploadBatchService] 캐시 조회 결과: ${academy != null ? "성공" : "실패"}'); + + // 캐시에 없으면 API에서 가져와서 캐시에 저장 + if (academy == null) { + appLog('[UploadBatchService] 캐시에 학원 정보가 없음. API에서 가져오는 중...'); + try { + final academies = await _academyService.getUserAcademies(userIdStr); + appLog('[UploadBatchService] API에서 ${academies.length}개의 학원을 가져옴'); + + if (academies.isEmpty) { + throw NoAcademyException(); + } + + // 등록완료된 학원만 필터링 + final registeredAcademies = academies + .where((a) => a.registerStatus == 'Y') + .toList(); + + appLog('[UploadBatchService] 등록완료된 학원: ${registeredAcademies.length}개'); + + if (registeredAcademies.isEmpty) { + throw NoAcademyException(); + } + + // 학원 코드 목록 로깅 + final academyCodes = registeredAcademies + .map((a) => a.academyCode ?? 'null') + .toList(); + appLog('[UploadBatchService] 등록완료된 학원 코드 목록: $academyCodes'); + appLog('[UploadBatchService] 찾는 학원 코드: $academyCode'); + + // 해당 academyCode가 있는지 직접 확인 + final matchingAcademies = registeredAcademies + .where((a) => a.academyCode == academyCode) + .toList(); + + UserAcademyResponse foundAcademy; + if (matchingAcademies.isNotEmpty) { + foundAcademy = matchingAcademies.first; + appLog('[UploadBatchService] ✅ 디폴트 학원 코드($academyCode)를 찾았습니다.'); + } else { + foundAcademy = registeredAcademies.first; + appLog( + '[UploadBatchService] ⚠️ 디폴트 학원 코드($academyCode)가 없어서 첫 번째 학원(${foundAcademy.academyCode})을 사용합니다.', + ); + } + + await _academyService.saveAcademiesToCache(academies); + + // 찾은 학원 사용 + academy = foundAcademy; + appLog('[UploadBatchService] 학원 정보 설정 완료: ${academy.academyName}'); + } catch (e) { + appLog('[UploadBatchService] ❌ 학원 목록 갱신 실패: $e'); + rethrow; // 에러를 다시 던져서 상위에서 처리하도록 + } + } + + // academy는 이 시점에서 항상 non-null이어야 함 + // (캐시에서 찾았거나, API에서 가져왔거나, 예외가 발생했을 것) + // 하지만 방어적 프로그래밍을 위해 null 체크 유지 + // ignore: dead_code, unnecessary_null_comparison + if (academy == null) { + throw NoAcademyException(); + } + + final finalAcademy = academy; + appLog('[UploadBatchService] 최종 학원 정보:'); + appLog(' - academyName: ${finalAcademy.academyName}'); + appLog(' - academyCode: ${finalAcademy.academyCode}'); + appLog(' - registerStatus: ${finalAcademy.registerStatus}'); + // TODO: class_id는 null일 수 있으므로 추후 구현 시 확인 필요 + if (finalAcademy.academy_user_id == null || + finalAcademy.academy_id == null || + finalAcademy.user_id == null) { + appLog('[UploadBatchService] ❌ 학원 응답에 필요한 ID가 없음:'); + appLog(' - academy_user_id: ${finalAcademy.academy_user_id}'); + appLog(' - class_id: ${finalAcademy.class_id}'); + appLog(' - academy_id: ${finalAcademy.academy_id}'); + appLog(' - user_id: ${finalAcademy.user_id}'); + throw Exception('학원 응답에 필요한 ID가 없습니다. 학원 정보를 다시 확인해주세요.'); + } + + appLog('[UploadBatchService] ✅ UploadContext 생성 완료:'); + appLog(' - academyUserId: ${finalAcademy.academy_user_id}'); + appLog(' - userId: ${finalAcademy.user_id}'); + appLog(' - classId: ${finalAcademy.class_id}'); + appLog(' - academyId: ${finalAcademy.academy_id}'); + + return UploadContext( + academyUserId: finalAcademy.academy_user_id!, + userId: finalAcademy.user_id!, + classId: finalAcademy.class_id, // nullable이므로 null 체크 연산자 제거 + academyId: finalAcademy.academy_id!, + ); + } + + /// /upload-url/batch 호출하여 presigned URL 목록 받기 + Future requestPresignedUrls({ + required UploadContext context, + required List images, + }) async { + if (images.isEmpty) { + throw Exception('업로드할 이미지가 없습니다.'); + } + + final token = await _authService.ensureValidAccessToken(); + if (token == null) { + throw Exception('인증 토큰이 없습니다. 업로드 URL 배치를 요청할 수 없습니다.'); + } + + final uri = ApiConfig.getUploadUrlBatchUri(); + + final body = images.map((image) { + final segments = image.path.split('/'); + final fileName = segments.isNotEmpty ? segments.last : 'image.png'; + return { + 'academy_user_id': context.academyUserId, + 'user_id': context.userId, + 'class_id': context.classId, + 'academy_id': context.academyId, + 'file_name': fileName, + 'url': null, + }; + }).toList(); + + appLog('[UploadBatchService] POST $uri'); + appLog('[UploadBatchService] body: $body'); + + final response = await http.post( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + body: json.encode(body), + ); + + appLog( + '[UploadBatchService] status=${response.statusCode}, body=${response.body}', + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('Upload URL batch 요청 실패: ${response.statusCode}'); + } + + try { + final jsonResponse = json.decode(response.body) as Map; + final presignedResponse = PresignedBatchResponse.fromJson(jsonResponse); + appLog( + '[UploadBatchService] ✅ Presigned URLs 받음: ${presignedResponse.presignedUrls.length}개', + ); + return presignedResponse; + } catch (e) { + appLog('[UploadBatchService] ❌ 응답 파싱 실패: $e'); + throw Exception('Presigned URL 응답 파싱 실패: $e'); + } + } + + /// 파일 확장자로 Content-Type 결정 + String _getContentType(String fileName) { + final extension = fileName.split('.').last.toLowerCase(); + switch (extension) { + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + case 'png': + return 'image/png'; + case 'gif': + return 'image/gif'; + case 'webp': + return 'image/webp'; + default: + return 'image/jpeg'; // 기본값 + } + } + + /// Presigned URL로 단일 이미지 업로드 + Future uploadToPresignedUrl({ + required XFile file, + required String presignedUrl, + String? contentType, + }) async { + try { + // 파일명에서 Content-Type 결정 + final fileName = file.path.split('/').last; + final finalContentType = contentType ?? _getContentType(fileName); + + appLog( + '[UploadBatchService] S3 업로드 시작: $fileName (Content-Type: $finalContentType)', + ); + + // 파일 바이트 읽기 + final bytes = await file.readAsBytes(); + appLog('[UploadBatchService] 파일 크기: ${bytes.length} bytes'); + + // Presigned URL로 PUT 요청 + final response = await http.put( + Uri.parse(presignedUrl), + headers: {'Content-Type': finalContentType}, + body: bytes, + ); + + appLog('[UploadBatchService] S3 업로드 응답: status=${response.statusCode}'); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception( + 'S3 업로드 실패: HTTP ${response.statusCode} ${response.reasonPhrase}', + ); + } + + appLog('[UploadBatchService] ✅ S3 업로드 성공: $fileName'); + } catch (e) { + appLog('[UploadBatchService] ❌ S3 업로드 실패: $e'); + rethrow; + } + } + + /// 기존 메서드 호환성을 위한 래퍼 (deprecated) + @Deprecated('uploadImages를 사용하세요') + Future requestBatch({ + required UploadContext context, + required List images, + }) async { + await requestPresignedUrls(context: context, images: images); + } +} + +/// 파일 확장자로 Content-Type 결정 (외부 함수) +String getContentTypeFromFileName(String fileName) { + final extension = fileName.split('.').last.toLowerCase(); + switch (extension) { + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + case 'png': + return 'image/png'; + case 'gif': + return 'image/gif'; + case 'webp': + return 'image/webp'; + default: + return 'image/jpeg'; // 기본값 + } +} + +/// Presigned URL로 단일 이미지 업로드 (외부 함수) +Future uploadToPresignedUrl({ + required XFile file, + required String presignedUrl, + String? contentType, +}) async { + try { + // 파일명에서 Content-Type 결정 + final fileName = file.path.split('/').last; + final finalContentType = + contentType ?? getContentTypeFromFileName(fileName); + + appLog( + '[UploadBatchService] S3 업로드 시작: $fileName (Content-Type: $finalContentType)', + ); + + // 파일 바이트 읽기 + final bytes = await file.readAsBytes(); + appLog('[UploadBatchService] 파일 크기: ${bytes.length} bytes'); + + // Presigned URL로 PUT 요청 + final response = await http.put( + Uri.parse(presignedUrl), + headers: {'Content-Type': finalContentType}, + body: bytes, + ); + + appLog('[UploadBatchService] S3 업로드 응답: status=${response.statusCode}'); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception( + 'S3 업로드 실패: HTTP ${response.statusCode} ${response.reasonPhrase}', + ); + } + + appLog('[UploadBatchService] ✅ S3 업로드 성공: $fileName'); + } catch (e) { + appLog('[UploadBatchService] ❌ S3 업로드 실패: $e'); + rethrow; + } +} + +/// 전체 업로드 흐름: Presigned URL 요청 → S3 업로드 (외부 함수) +/// +/// 다른 코드에서도 사용할 수 있도록 외부 함수로 제공됩니다. +/// UploadBatchService 인스턴스를 자동으로 생성하여 사용합니다. +/// +/// [onProgress] 콜백은 각 이미지 업로드 완료 시 호출됩니다. +/// (uploadedCount, totalCount) 형태로 진행 상황을 전달합니다. +Future uploadImages({ + required List images, + UploadBatchService? uploadService, + void Function(int uploadedCount, int totalCount)? onProgress, +}) async { + if (images.isEmpty) { + throw Exception('업로드할 이미지가 없습니다.'); + } + + // UploadBatchService 인스턴스 생성 (없으면 기본 인스턴스 사용) + final service = uploadService ?? UploadBatchService(); + + appLog('[UploadBatchService] 🚀 이미지 업로드 시작: ${images.length}개'); + + // 1. UploadContext 생성 + final context = await service.buildContext(); + appLog('[UploadBatchService] ✅ UploadContext 생성 완료'); + + // 2. Presigned URL 배치 요청 + final presignedResponse = await service.requestPresignedUrls( + context: context, + images: images, + ); + + if (presignedResponse.presignedUrls.length != images.length) { + throw Exception( + 'Presigned URL 개수 불일치: 이미지 ${images.length}개, URL ${presignedResponse.presignedUrls.length}개', + ); + } + + // 3. 각 Presigned URL로 이미지 업로드 + appLog('[UploadBatchService] 📤 S3 업로드 시작...'); + for (int i = 0; i < images.length; i++) { + final file = images[i]; + final presignedUrl = presignedResponse.presignedUrls[i]; + + appLog( + '[UploadBatchService] 업로드 중: ${i + 1}/${images.length} - ${file.path.split('/').last}', + ); + + await uploadToPresignedUrl(file: file, presignedUrl: presignedUrl); + + // 진행 상황 콜백 호출 + onProgress?.call(i + 1, images.length); + } + + appLog('[UploadBatchService] ✅ 모든 이미지 업로드 완료!'); + return presignedResponse; +} diff --git a/frontend/lib/services/upload_sse_service.dart b/frontend/lib/services/upload_sse_service.dart new file mode 100644 index 0000000..d34dd41 --- /dev/null +++ b/frontend/lib/services/upload_sse_service.dart @@ -0,0 +1,105 @@ +import 'dart:async'; +import 'dart:developer' as developer; + +import 'package:launchdarkly_event_source_client/launchdarkly_event_source_client.dart'; + +import '../config/api_config.dart'; +import 'auth_service.dart'; + +/// 이미지 업로드용 SSE 스트림 서비스 +/// +/// - 연결: GET /storage/storage/upload-stream +/// - 이벤트: +/// - event: "upload-url" → data: presigned URL 1개 +/// - event: "ping" → keep-alive +class UploadSseService { + final AuthService _authService; + + UploadSseService({AuthService? authService}) + : _authService = authService ?? AuthService(); + + SSEClient? _sseClient; + StreamSubscription? _subscription; + final StreamController _uploadUrlController = + StreamController.broadcast(); + + Stream get uploadUrlStream => _uploadUrlController.stream; + + bool get isConnected => _sseClient != null; + + /// SSE 구독 시작 + Future connect() async { + if (_sseClient != null) { + return; + } + + try { + final token = await _authService.ensureValidAccessToken(); + if (token == null) { + throw Exception('인증 토큰이 없습니다. SSE 연결을 시작할 수 없습니다.'); + } + + final uri = ApiConfig.getUploadStreamUri(); + developer.log('[UploadSseService] Connecting to $uri'); + + // launchdarkly_event_source_client는 eventTypes를 Set으로 받습니다 + _sseClient = SSEClient( + uri, + {'upload-url', 'ping'}, // 수신할 이벤트 타입들 + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ); + + developer.log('[UploadSseService] SSE connected'); + + _subscription = _sseClient!.stream.listen( + (Event event) { + if (event is MessageEvent) { + final eventType = event.type; + final data = event.data; + + developer.log('[UploadSseService] event=$eventType, data=$data'); + + if (eventType == 'upload-url' && data.isNotEmpty) { + _uploadUrlController.add(data); + } else if (eventType == 'ping') { + // keep-alive + developer.log('[UploadSseService] ping'); + } + } else if (event is OpenEvent) { + developer.log('[UploadSseService] Connection opened'); + } + }, + onError: (error) { + developer.log('[UploadSseService] Stream error: $error'); + }, + onDone: () { + developer.log('[UploadSseService] Stream closed'); + }, + ); + } catch (e) { + developer.log('[UploadSseService] SSE connect error: $e'); + rethrow; + } + } + + /// 구독 종료 + void disconnect() { + try { + _subscription?.cancel(); + _subscription = null; + _sseClient?.close(); + _sseClient = null; + developer.log('[UploadSseService] SSE disconnected'); + } catch (e) { + developer.log('[UploadSseService] SSE disconnect error: $e'); + } + } + + void dispose() { + disconnect(); + _uploadUrlController.close(); + } +} diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart new file mode 100644 index 0000000..f47ed32 --- /dev/null +++ b/frontend/lib/services/user_service.dart @@ -0,0 +1,214 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:http/http.dart' as http; +import 'dart:convert'; +import 'dart:developer' as developer; +import '../models/user.dart'; +import '../config/api_config.dart'; +import 'auth_service.dart'; + +/// 사용자 정보 관리 서비스 +/// +/// 주요 기능: +/// 1. 서버에서 사용자 정보 가져오기 (/me API) +/// 2. SharedPreferences에 캐싱 +/// 3. 메모리 캐시로 빠른 접근 +/// 4. 상태 변경 알림 (listeners) +class UserService { + final AuthService _authService; + + UserService({AuthService? authService}) + : _authService = authService ?? AuthService(); + + static const String _userDataKey = 'user_data'; + + // 메모리 캐시 (빠른 접근) + User? _cachedUser; + + // 상태 변경 리스너 + final List _listeners = []; + + /// 리스너 등록 + void addListener(Function(User?) listener) { + _listeners.add(listener); + } + + /// 리스너 제거 + void removeListener(Function(User?) listener) { + _listeners.remove(listener); + } + + /// 리스너들에게 알림 + void _notifyListeners() { + for (var listener in _listeners) { + listener(_cachedUser); + } + } + + /// 사용자 정보 가져오기 (캐시에서) + User? getUser() { + return _cachedUser; + } + + /// 사용자 이름 가져오기 (편의 메서드) + String? getUserName() { + return _cachedUser?.name; + } + + /// 사용자 ID 가져오기 (편의 메서드) + int? getUserId() { + return _cachedUser?.userId; + } + + /// 프로필 이미지 URL 가져오기 (편의 메서드) + String? getProfileImageUrl() { + return _cachedUser?.profileImageUrl; + } + + /// 로컬에 저장된 사용자 정보 로드 (앱 시작 시) + Future loadUserFromCache() async { + try { + final prefs = await SharedPreferences.getInstance(); + final userDataString = prefs.getString(_userDataKey); + + if (userDataString != null) { + final userJson = json.decode(userDataString) as Map; + _cachedUser = User.fromJson(userJson); + developer.log('✅ User loaded from cache: ${_cachedUser?.name}'); + _notifyListeners(); + return _cachedUser; + } + developer.log('ℹ️ No cached user data found'); + return null; + } catch (e) { + developer.log('❌ Failed to load user from cache: $e'); + return null; + } + } + + /// 서버에서 사용자 정보 가져오기 (API 호출) + /// 토큰 만료 시 자동으로 갱신 시도 + Future fetchUserFromServer() async { + try { + // 유효한 Access Token 확인 및 필요시 갱신 + final token = await _authService.ensureValidAccessToken(); + if (token == null) { + developer.log('❌ No valid access token available'); + return null; + } + + developer.log('🌐 Fetching user info from server...'); + + // API 호출 + var response = await http + .get( + ApiConfig.getMeUri(), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ) + .timeout(const Duration(seconds: 10)); + + // 401 에러 발생 시 토큰 갱신 후 재시도 + if (response.statusCode == 401) { + developer.log('⚠️ Unauthorized (401): Attempting token refresh...'); + + final refreshed = await _authService.refreshAccessToken(); + if (refreshed) { + // 갱신된 토큰으로 재시도 + final newToken = await _authService.getAccessToken(); + if (newToken != null) { + developer.log('🔄 Retrying request with refreshed token...'); + response = await http + .get( + ApiConfig.getMeUri(), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $newToken', + }, + ) + .timeout(const Duration(seconds: 10)); + } else { + developer.log('❌ Failed to get refreshed token'); + return null; + } + } else { + developer.log('❌ Token refresh failed - 로그인 필요'); + return null; + } + } + + if (response.statusCode == 200) { + final responseData = json.decode(utf8.decode(response.bodyBytes)); + _cachedUser = User.fromJson(responseData); + + // SharedPreferences에 저장 + await _saveUserToCache(_cachedUser!); + + developer.log('✅ User fetched from server: ${_cachedUser?.name}'); + _notifyListeners(); + return _cachedUser; + } else { + developer.log('❌ Failed to fetch user: ${response.statusCode}'); + developer.log(' Response: ${response.body}'); + return null; + } + } catch (e) { + developer.log('❌ Error fetching user from server: $e'); + return null; + } + } + + /// 사용자 정보 저장 (내부용) + Future _saveUserToCache(User user) async { + try { + final prefs = await SharedPreferences.getInstance(); + final userDataString = json.encode(user.toJson()); + await prefs.setString(_userDataKey, userDataString); + developer.log('✅ User saved to cache'); + } catch (e) { + developer.log('❌ Failed to save user to cache: $e'); + } + } + + /// 사용자 정보 초기화 (앱 시작 시 또는 로그인 직후 호출) + /// + /// 전략: + /// 1. 캐시에서 먼저 로드 (빠른 UI 표시) + /// 2. 백그라운드에서 서버 동기화 (최신 데이터) + Future initialize() async { + developer.log('🔄 Initializing UserService...'); + + // 1. 캐시에서 먼저 로드 (빠른 UI 표시) + final cachedUser = await loadUserFromCache(); + + // 2. 백그라운드에서 서버 동기화 (최신 데이터) + fetchUserFromServer().catchError((e) { + developer.log('⚠️ Background sync failed: $e'); + // 캐시가 있으면 그대로 사용 + return null; + }); + + return cachedUser; + } + + /// 사용자 정보 강제 새로고침 + /// (프로필 수정 후 등) + Future refresh() async { + developer.log('🔄 Refreshing user data...'); + return await fetchUserFromServer(); + } + + /// 사용자 정보 삭제 (로그아웃 시) + Future clearUser() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_userDataKey); + _cachedUser = null; + developer.log('✅ User data cleared'); + _notifyListeners(); + } catch (e) { + developer.log('❌ Failed to clear user data: $e'); + } + } +} diff --git a/frontend/lib/services/workbook_api.dart b/frontend/lib/services/workbook_api.dart new file mode 100644 index 0000000..3ada0b3 --- /dev/null +++ b/frontend/lib/services/workbook_api.dart @@ -0,0 +1,321 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer' as developer; +import 'package:http/http.dart' as http; +import '../config/api_config.dart'; +import 'auth_service.dart'; +import '../utils/api_date_formatter.dart'; +import '../utils/app_logger.dart'; + +/// ⚠️ Dart 문법상 클래스는 파일 최상위에 선언 +/// WorkbookApiResponse와 BookData를 파일 최상위로 이동 + +/// API 응답 DTO (서버 응답 구조 그대로) +/// +/// API 응답은 배열 형태: [{academyUserId: 20, books: [...]}, {academyUserId: 25, books: [...]}] +class WorkbookApiResponse { + final int academyUserId; + final List books; + + WorkbookApiResponse({required this.academyUserId, required this.books}); + + factory WorkbookApiResponse.fromJson(Map json) { + return WorkbookApiResponse( + academyUserId: json['academyUserId'] as int? ?? 0, + books: + (json['books'] as List?) + ?.map((item) => BookData.fromJson(item)) + .toList() ?? + [], + ); + } +} + +/// Book 데이터 DTO +/// +/// API 응답의 books 배열 내부 객체 구조 +class BookData { + final int? bookId; // book_id + final String? bookName; // book_name + final int bookPage; // book_page + final String? bookSemester; // book_semester + final String? bookImageUrl; // book_image_url + final int totalSolvedPages; // total_solved_pages + final String? latestUpdatedAt; // latest_updated_at + + BookData({ + this.bookId, + this.bookName, + required this.bookPage, + this.bookSemester, + this.bookImageUrl, + required this.totalSolvedPages, + this.latestUpdatedAt, + }); + + factory BookData.fromJson(Map json) { + return BookData( + bookId: json['book_id'] as int?, + bookName: json['book_name'] as String?, + bookPage: json['book_page'] as int? ?? 0, + bookSemester: json['book_semester'] as String?, + bookImageUrl: json['book_image_url'] as String?, + totalSolvedPages: json['total_solved_pages'] as int? ?? 0, + latestUpdatedAt: json['latest_updated_at'] as String?, + ); + } +} + +/// Workbook API 호출 전용 레이어 +class WorkbookApi { + WorkbookApi({ + required AuthService authService, + required http.Client httpClient, + }) : _authService = authService, + _httpClient = httpClient; + + final AuthService _authService; + final http.Client _httpClient; + + /// 여러 academyUserId에 대한 문제집 조회 (한 번에) + /// + /// 여러 academyUserId를 콤마로 구분해서 한 번에 조회합니다. + /// 예: academyUserId=20,25 + /// + /// [academyUserIds]: 조회할 academyUserId 리스트 + /// + /// 반환값: 각 academyUserId별 WorkbookApiResponse 리스트 + Future> fetchWorkbooks( + List academyUserIds, + ) async { + final token = await _authService.ensureValidAccessToken(); + if (token == null) { + throw Exception('인증 토큰이 없습니다. 로그인이 필요합니다.'); + } + + // academyUserIds를 콤마로 구분한 문자열로 변환 + final idsParam = academyUserIds.join(','); + final uri = Uri.parse( + '${ApiConfig.baseUrl}/grading/student-responses/workbook?academyUserIds=$idsParam', + ); + + developer.log('📚 [WorkbookApi] GET $uri'); + + final response = await _httpClient + .get( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ) + .timeout(const Duration(seconds: 10)); + + developer.log('📚 [WorkbookApi] Response status: ${response.statusCode}'); + + if (response.statusCode == 401) { + throw Exception('인증에 실패했습니다. 다시 로그인해주세요.'); + } + if (response.statusCode != 200) { + throw Exception('Workbook API 호출 실패 (status: ${response.statusCode})'); + } + + try { + final List data = json.decode(response.body); + + // API 응답은 배열: [{academyUserId: 20, books: [...]}, {academyUserId: 25, books: [...]}] + final result = data + .map( + (item) => + WorkbookApiResponse.fromJson(item as Map), + ) + .toList(); + + return result; + } catch (e) { + developer.log('❌ [WorkbookApi] 응답 파싱 실패: $e'); + developer.log('❌ [WorkbookApi] Response body: ${response.body}'); + rethrow; + } + } + + /// 날짜 범위로 문제집 학습 현황 조회 + /// + /// **중요: 날짜 및 타임존 규약** + /// - startDate와 endDate는 KST(UTC+9) 기준으로 해석됩니다. + /// - 입력 DateTime은 반드시 UTC 기반이어야 하며, 이미 KST로 변환된 상태여야 합니다. + /// - API 요청 시 ISO8601 문자열에 timezone 정보(+09:00)가 포함됩니다. + /// - 서버는 이 timezone 정보를 기반으로 KST 기준으로 데이터를 조회합니다. + /// + /// **예시:** + /// - startDate: DateTime.utc(2025, 11, 1) (KST 기준 2025-11-01을 의미) + /// - 요청 파라미터: "2025-11-01T00:00:00+09:00" + /// - 서버는 KST 기준 2025-11-01 00:00:00 ~ 23:59:59 범위의 데이터를 조회합니다. + /// + /// [academyUserIds]: 조회할 academyUserId 리스트 (비어있으면 안됨) + /// [startDate]: 시작 날짜 (KST 기준, UTC 기반 DateTime, 시간 정보 무시) + /// [endDate]: 종료 날짜 (KST 기준, UTC 기반 DateTime, 시간 정보 무시) + /// + /// 반환값: 각 academyUserId별 WorkbookApiResponse 리스트 + /// + /// 예외: + /// - ArgumentError: startDate > endDate 또는 academyUserIds가 비어있을 때 + /// - Exception: API 호출 실패 시 + Future> fetchWorkbooksByDateRange({ + required List academyUserIds, + required DateTime startDate, + required DateTime endDate, + }) async { + // Validation + if (academyUserIds.isEmpty) { + throw ArgumentError('academyUserIds는 비어있을 수 없습니다.'); + } + + // 날짜 비교 (시간 정보 무시) + final startDateOnly = DateTime.utc(startDate.year, startDate.month, startDate.day); + final endDateOnly = DateTime.utc(endDate.year, endDate.month, endDate.day); + + if (startDateOnly.isAfter(endDateOnly)) { + throw ArgumentError('startDate는 endDate보다 이전이어야 합니다.'); + } + + final token = await _authService.ensureValidAccessToken(); + if (token == null) { + throw Exception('인증 토큰이 없습니다. 로그인이 필요합니다.'); + } + + // KST 기준 ISO8601 문자열 생성 (timezone 포함) + // 중요: startDate와 endDate는 이미 KST로 변환된 상태이므로 + // ApiDateFormatter가 UTC 기반으로 올바르게 변환합니다. + final startIso = ApiDateFormatter.formatDayStart(startDateOnly); + final endIso = ApiDateFormatter.formatDayEnd(endDateOnly); + + // academyUserIds를 콤마로 구분한 문자열로 변환 + final idsParam = academyUserIds.join(','); + + // URI 생성 + final uri = Uri.parse( + '${ApiConfig.baseUrl}/grading/student-responses/workbook/range' + '?academyUserIds=$idsParam' + '&startDate=${Uri.encodeComponent(startIso)}' + '&endDate=${Uri.encodeComponent(endIso)}', + ); + + developer.log('📚 [WorkbookApi] GET $uri'); + developer.log('📚 [WorkbookApi] startDate: $startIso, endDate: $endIso'); + appLog('[workbook:workbook_api] API 호출 시작'); + appLog('[workbook:workbook_api] GET 요청: $uri'); + appLog('[workbook:workbook_api] academyUserIds: $idsParam'); + appLog('[workbook:workbook_api] startDate: $startIso'); + appLog('[workbook:workbook_api] endDate: $endIso'); + + http.Response? response; + String? responseBody; + + try { + response = await _httpClient + .get( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $token', + }, + ) + .timeout(const Duration(seconds: 15)); + + developer.log('📚 [WorkbookApi] Response status: ${response.statusCode}'); + + // 상태 코드별 처리 + if (response.statusCode == 401) { + throw Exception('인증에 실패했습니다. 다시 로그인해주세요.'); + } + if (response.statusCode == 400) { + throw Exception('잘못된 요청입니다. 날짜 범위를 확인해주세요.'); + } + if (response.statusCode == 404) { + throw Exception('요청한 리소스를 찾을 수 없습니다.'); + } + if (response.statusCode == 204) { + return []; + } + if (response.statusCode != 200) { + throw Exception('Workbook API 호출 실패 (status: ${response.statusCode})'); + } + + // JSON 파싱 + responseBody = response.body; + if (responseBody.isEmpty) { + return []; + } + + final dynamic decoded = json.decode(responseBody); + + if (decoded is! List) { + developer.log('❌ [WorkbookApi] 응답이 배열이 아닙니다: ${decoded.runtimeType}'); + throw Exception('서버 응답 형식이 올바르지 않습니다.'); + } + + final List data = decoded; + + // 응답 구조 로그 출력 + appLog('[workbook:workbook_api] 응답 구조:'); + appLog('[workbook:workbook_api] 응답 항목 수: ${data.length}'); + for (var i = 0; i < data.length; i++) { + final item = data[i]; + if (item is Map) { + appLog('[workbook:workbook_api] [항목 ${i + 1}]'); + appLog('[workbook:workbook_api] - academyUserId: ${item['academyUserId']}'); + if (item['books'] != null && item['books'] is List) { + final books = item['books'] as List; + appLog('[workbook:workbook_api] - books 개수: ${books.length}'); + for (var j = 0; j < books.length; j++) { + final book = books[j]; + if (book is Map) { + appLog('[workbook:workbook_api] [책 ${j + 1}]'); + appLog('[workbook:workbook_api] - book_id: ${book['book_id']}'); + appLog('[workbook:workbook_api] - book_name: ${book['book_name']}'); + appLog('[workbook:workbook_api] - book_page: ${book['book_page']}'); + appLog('[workbook:workbook_api] - book_semester: ${book['book_semester']}'); + appLog('[workbook:workbook_api] - book_image_url: ${book['book_image_url']}'); + appLog('[workbook:workbook_api] - total_solved_pages: ${book['total_solved_pages']}'); + appLog('[workbook:workbook_api] - latest_updated_at: ${book['latest_updated_at']}'); + } + } + } else { + appLog('[workbook:workbook_api] - books: null 또는 배열이 아님'); + } + } + } + + final result = data + .map( + (item) { + if (item is! Map) { + throw Exception('응답 항목이 올바른 형식이 아닙니다.'); + } + return WorkbookApiResponse.fromJson(item); + }, + ) + .toList(); + + developer.log('📚 [WorkbookApi] 파싱 성공: ${result.length}개 항목'); + appLog('[workbook:workbook_api] 파싱 성공: ${result.length}개 항목'); + return result; + } on TimeoutException { + developer.log('❌ [WorkbookApi] 요청 시간 초과'); + throw Exception('요청 시간이 초과되었습니다. 네트워크 연결을 확인해주세요.'); + } on FormatException catch (e) { + developer.log('❌ [WorkbookApi] JSON 파싱 실패: $e'); + if (responseBody != null) { + developer.log('❌ [WorkbookApi] Response body: $responseBody'); + } + throw Exception('서버 응답을 파싱하는데 실패했습니다.'); + } catch (e) { + if (e is Exception) { + rethrow; + } + developer.log('❌ [WorkbookApi] 예상치 못한 오류: $e'); + throw Exception('알 수 없는 오류가 발생했습니다.'); + } + } +} diff --git a/frontend/lib/services/workbook_repository_impl.dart b/frontend/lib/services/workbook_repository_impl.dart new file mode 100644 index 0000000..a611938 --- /dev/null +++ b/frontend/lib/services/workbook_repository_impl.dart @@ -0,0 +1,141 @@ +import 'dart:developer' as developer; +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:convert'; +import '../domain/workbook/workbook_repository.dart'; +import '../domain/workbook/workbook_summary_entity.dart'; +import 'workbook_api.dart'; +import '../data/mappers/workbook_mapper.dart'; +import 'academy_service.dart'; + +/// Workbook Repository 구현체 +/// +/// API 호출, className 조회, 캐싱을 담당합니다. +class WorkbookRepositoryImpl implements WorkbookRepository { + WorkbookRepositoryImpl({ + required WorkbookApi api, + required WorkbookMapper mapper, + required AcademyService academyService, + }) : _api = api, + _mapper = mapper, + _academyService = academyService; + + final WorkbookApi _api; + final WorkbookMapper _mapper; + final AcademyService _academyService; + + static const String _cacheKeyPrefix = 'workbook_summaries_'; + + @override + Future>> + getWorkbookSummariesByAcademyUserIds(List academyUserIds) async { + if (academyUserIds.isEmpty) { + return {}; + } + + try { + // 1. Workbook API 호출 + final apiResponses = await _api.fetchWorkbooks(academyUserIds); + + // 2. className 조회 (AcademyService 사용) + // ⚠️ 주의: academyUserId와 assigneeId는 1:1 관계입니다. + final classNameMapStr = await _academyService.getClassesByAssigneeIds( + academyUserIds.map((id) => id.toString()).toList(), + ); + + // 3. String → int 키 변환 + final classNameMap = {}; + for (final id in academyUserIds) { + final className = classNameMapStr[id.toString()]; + // 빈 문자열도 null로 처리 + classNameMap[id] = (className != null && className.isNotEmpty) + ? className + : null; + } + + // 4. Mapper로 변환 (실제 classNameMap 전달) + final summariesMap = await _mapper.convertApiResponsesToSummaries( + apiResponses, + classNameMap, + ); + + // 5. 캐시 저장 + for (final entry in summariesMap.entries) { + await cacheWorkbookSummaries(entry.key, entry.value); + } + + return summariesMap; + } catch (e) { + developer.log('❌ [WorkbookRepository] API 호출 실패: $e'); + + // 캐시에서 로드 시도 + final cachedMap = >{}; + for (final id in academyUserIds) { + final cached = await getCachedWorkbookSummaries(id); + if (cached != null && cached.isNotEmpty) { + cachedMap[id] = cached; + } + } + + if (cachedMap.isNotEmpty) { + developer.log('✅ [WorkbookRepository] 캐시에서 로드 성공'); + return cachedMap; + } + + rethrow; + } + } + + @override + Future?> getCachedWorkbookSummaries( + int academyUserId, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + final cacheKey = '$_cacheKeyPrefix$academyUserId'; + final cachedJson = prefs.getString(cacheKey); + + if (cachedJson == null) { + return null; + } + + final List data = json.decode(cachedJson); + final result = data + .map((item) => WorkbookSummaryEntity.fromJson(item)) + .toList(); + return result; + } catch (e) { + developer.log('⚠️ [WorkbookRepository] 캐시 로드 실패: $e'); + return null; + } + } + + @override + Future cacheWorkbookSummaries( + int academyUserId, + List summaries, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + final cacheKey = '$_cacheKeyPrefix$academyUserId'; + final jsonData = json.encode(summaries.map((s) => s.toJson()).toList()); + await prefs.setString(cacheKey, jsonData); + developer.log('✅ [WorkbookRepository] 캐시 저장 완료: $academyUserId'); + } catch (e) { + developer.log('⚠️ [WorkbookRepository] 캐시 저장 실패: $e'); + } + } + + @override + Future clearCache() async { + try { + final prefs = await SharedPreferences.getInstance(); + final keys = prefs.getKeys().where((k) => k.startsWith(_cacheKeyPrefix)); + for (final key in keys) { + await prefs.remove(key); + } + developer.log('✅ [WorkbookRepository] 캐시 초기화 완료'); + } catch (e) { + developer.log('⚠️ [WorkbookRepository] 캐시 초기화 실패: $e'); + } + } +} diff --git a/frontend/lib/services/workbook_use_case.dart b/frontend/lib/services/workbook_use_case.dart new file mode 100644 index 0000000..9401fff --- /dev/null +++ b/frontend/lib/services/workbook_use_case.dart @@ -0,0 +1,173 @@ +import '../domain/workbook/workbook_summary_entity.dart'; +import '../services/workbook_api.dart'; +import '../screens/workbook/models/class_data.dart'; +import '../screens/workbook/models/workbook_info.dart'; +import '../screens/workbook/models/workbook_data.dart'; + +/// Workbook 데이터 변환 UseCase +/// +/// API 응답을 도메인 엔티티로 변환하고, UI 모델로 변환합니다. +/// className은 이미 Repository에서 조회되어 전달됩니다. +class WorkbookUseCase { + /// API 응답을 WorkbookSummaryEntity로 변환 + /// + /// [apiResponses]: API 응답 리스트 (여러 academyUserId) + /// [classNameMap]: academyUserId → className 매핑 (Repository에서 조회됨) + Future>> convertApiResponsesToSummaries( + List apiResponses, + Map classNameMap, + ) async { + final summariesMap = >{}; + + for (final apiResponse in apiResponses) { + final summaries = []; + final className = classNameMap[apiResponse.academyUserId]; + + for (final bookData in apiResponse.books) { + // latest_updated_at 파싱 + DateTime? lastStudyDate; + if (bookData.latestUpdatedAt != null && bookData.latestUpdatedAt!.isNotEmpty) { + try { + lastStudyDate = DateTime.parse(bookData.latestUpdatedAt!).toLocal(); + } catch (e) { + // 파싱 실패 시 null 유지 + } + } + + // WorkbookSummaryEntity 생성 + final summary = WorkbookSummaryEntity( + bookId: bookData.bookId, + bookName: bookData.bookName ?? '알 수 없는 문제집', + coverImageUrl: bookData.bookImageUrl, + totalPages: bookData.bookPage, // book_page 사용 + totalSolvedPages: bookData.totalSolvedPages, + lastStudyDate: lastStudyDate, + academyUserId: apiResponse.academyUserId, + className: className, + bookSemester: bookData.bookSemester, + ); + + summaries.add(summary); + } + + summariesMap[apiResponse.academyUserId] = summaries; + } + + return summariesMap; + } + + /// WorkbookSummaryEntity 리스트를 ClassData로 변환 + /// + /// DateTime 기준으로 정렬 후 문자열 변환합니다. + List convertToClassData( + Map> summariesByAcademyUserId, + ) { + final classDataList = []; + + summariesByAcademyUserId.forEach((academyUserId, summaries) { + if (summaries.isEmpty) return; + + final className = summaries.first.className ?? '알 수 없는 클래스'; + + // DateTime 기준으로 최신 날짜 찾기 + final lastStudyDate = _getLatestDateTime(summaries); + + // WorkbookInfo 리스트 생성 + final workbooks = summaries.map((summary) { + return WorkbookInfo( + name: summary.bookName, + lastStudyDate: summary.formattedLastStudyDate, + progress: summary.progress, + thumbnailPath: summary.thumbnailPath, + bookId: summary.bookId, + academyUserId: summary.academyUserId, + ); + }).toList(); + + classDataList.add(ClassData( + className: className, + lastStudyDate: _formatDateTime(lastStudyDate), + workbooks: workbooks, + )); + }); + + // DateTime 기준 정렬 후 문자열 변환 + classDataList.sort((a, b) { + final dateA = _parseFormattedDate(a.lastStudyDate); + final dateB = _parseFormattedDate(b.lastStudyDate); + return dateB.compareTo(dateA); // 최신순 + }); + + return classDataList; + } + + /// WorkbookSummaryEntity 리스트를 WorkbookData로 변환 + /// + /// DateTime 기준으로 정렬 후 문자열 변환합니다. + List convertToWorkbookData( + Map> summariesByAcademyUserId, + ) { + final workbookDataList = []; + + summariesByAcademyUserId.forEach((academyUserId, summaries) { + for (final summary in summaries) { + workbookDataList.add(WorkbookData( + workbookName: summary.bookName, + lastStudyDate: summary.formattedLastStudyDate, + progress: summary.progress, + thumbnailPath: summary.thumbnailPath, + className: summary.className ?? '알 수 없는 클래스', + bookId: summary.bookId, + academyUserId: summary.academyUserId, + )); + } + }); + + // DateTime 기준 정렬 후 문자열 변환 + workbookDataList.sort((a, b) { + final dateA = _parseFormattedDate(a.lastStudyDate); + final dateB = _parseFormattedDate(b.lastStudyDate); + return dateB.compareTo(dateA); // 최신순 + }); + + return workbookDataList; + } + + /// summaries에서 가장 최근 DateTime 찾기 + DateTime? _getLatestDateTime(List summaries) { + DateTime? latest; + for (final summary in summaries) { + if (summary.lastStudyDate != null) { + if (latest == null || summary.lastStudyDate!.isAfter(latest)) { + latest = summary.lastStudyDate; + } + } + } + return latest; + } + + /// DateTime을 YYYY.MM.DD 형식으로 변환 + String _formatDateTime(DateTime? dateTime) { + if (dateTime == null) return ''; + return '${dateTime.year}.${dateTime.month.toString().padLeft(2, '0')}.${dateTime.day.toString().padLeft(2, '0')}'; + } + + /// YYYY.MM.DD 형식 문자열을 DateTime으로 파싱 + DateTime _parseFormattedDate(String dateStr) { + if (dateStr.isEmpty) return DateTime(1970, 1, 1); // 기본값 + try { + final parts = dateStr.split('.'); + if (parts.length == 3) { + return DateTime( + int.parse(parts[0]), + int.parse(parts[1]), + int.parse(parts[2]), + ); + } + } catch (e) { + // 파싱 실패 시 기본값 + } + return DateTime(1970, 1, 1); + } +} + diff --git a/frontend/lib/utils/academy_utils.dart b/frontend/lib/utils/academy_utils.dart new file mode 100644 index 0000000..89849f6 --- /dev/null +++ b/frontend/lib/utils/academy_utils.dart @@ -0,0 +1,40 @@ +import 'dart:developer' as developer; + +import '../services/academy_service.dart'; + +/// 공통 학원 유틸 함수 모음 +Future getUserAcademyId({ + required AcademyService academyService, + List? registeredAcademies, + String? academyCode, +}) async { + final code = academyCode ?? await academyService.getDefaultAcademyCode(); + if (code == null) { + developer.log('⚠️ [AcademyUtils] 기본 학원 코드를 찾을 수 없습니다.'); + return null; + } + + UserAcademyResponse? academy; + + final cachedList = registeredAcademies; + if (cachedList != null && cachedList.isNotEmpty) { + try { + academy = cachedList.firstWhere((a) => a.academyCode == code); + } catch (_) { + // ignore and fallback to cache lookup + } + } + + academy ??= await academyService.getAcademyByCode(code); + + if (academy == null) { + developer.log('⚠️ [AcademyUtils] 학원 정보를 찾을 수 없습니다. code: $code'); + return null; + } + + final userAcademyId = academy.academy_user_id?.toString(); + developer.log( + '✅ [AcademyUtils] userAcademyId=$userAcademyId (academyCode: $code)', + ); + return userAcademyId; +} diff --git a/frontend/lib/utils/api_date_formatter.dart b/frontend/lib/utils/api_date_formatter.dart new file mode 100644 index 0000000..644df48 --- /dev/null +++ b/frontend/lib/utils/api_date_formatter.dart @@ -0,0 +1,27 @@ +import 'kst_date_factory.dart'; +import 'iso8601_formatter.dart'; + +/// API 요청용 날짜 포맷터 +/// +/// KST 날짜를 API 요청에 적합한 형식으로 변환합니다. +/// 내부적으로 KstDateFactory와 Iso8601Formatter를 사용합니다. +class ApiDateFormatter { + /// KST 날짜의 시작 시간을 API 요청용 ISO8601 문자열로 변환 + /// + /// [kstDate]: KST 기준 날짜 (UTC 기반 DateTime) + /// 반환값: ISO8601 문자열 with timezone (예: "2025-11-01T00:00:00+09:00") + static String formatDayStart(DateTime kstDate) { + final utcStart = KstDateFactory.getDayStartUtc(kstDate); + return Iso8601Formatter.toKstIso8601(utcStart); + } + + /// KST 날짜의 종료 시간을 API 요청용 ISO8601 문자열로 변환 + /// + /// [kstDate]: KST 기준 날짜 (UTC 기반 DateTime) + /// 반환값: ISO8601 문자열 with timezone (예: "2025-11-01T23:59:59+09:00") + static String formatDayEnd(DateTime kstDate) { + final utcEnd = KstDateFactory.getDayEndUtc(kstDate); + return Iso8601Formatter.toKstIso8601(utcEnd); + } +} + diff --git a/frontend/lib/utils/app_logger.dart b/frontend/lib/utils/app_logger.dart new file mode 100644 index 0000000..239fb2f --- /dev/null +++ b/frontend/lib/utils/app_logger.dart @@ -0,0 +1,8 @@ +import 'package:flutter/foundation.dart'; // kDebugMode +import 'package:flutter/material.dart'; // debugPrint + +void appLog(String message) { + if (!kDebugMode) return; // 디버그 모드가 아니면 바로 종료 + + debugPrint('[APP] $message'); +} diff --git a/frontend/lib/utils/iso8601_formatter.dart b/frontend/lib/utils/iso8601_formatter.dart new file mode 100644 index 0000000..25324bc --- /dev/null +++ b/frontend/lib/utils/iso8601_formatter.dart @@ -0,0 +1,57 @@ +/// ISO8601 문자열 포맷터 +/// +/// UTC 기반 DateTime을 ISO8601 문자열로 변환합니다. +/// API 요청 시 KST 타임존 정보를 명시적으로 포함합니다. +class Iso8601Formatter { + /// UTC DateTime을 KST 타임존이 포함된 ISO8601 문자열로 변환 + /// + /// [utcDateTime]: UTC 기반 DateTime + /// 반환값: ISO8601 문자열 with timezone (예: "2025-11-01T00:00:00+09:00") + /// + /// 주의: 입력 DateTime은 반드시 UTC 기반이어야 합니다. + static String toKstIso8601(DateTime utcDateTime) { + // UTC DateTime을 KST로 변환 (UTC + 9시간) + final kstDateTime = utcDateTime.add(const Duration(hours: 9)); + + // ISO8601 형식으로 변환 (KST 타임존 명시) + return _formatWithTimezone( + kstDateTime.year, + kstDateTime.month, + kstDateTime.day, + kstDateTime.hour, + kstDateTime.minute, + kstDateTime.second, + hoursOffset: 9, // KST = UTC+9 + ); + } + + /// ISO8601 형식 문자열 생성 (타임존 포함) + /// + /// [year, month, day, hour, minute, second]: 날짜/시간 구성 요소 + /// [hoursOffset]: UTC로부터의 시간 오프셋 (KST는 +9) + /// 반환값: ISO8601 문자열 (예: "2025-11-01T00:00:00+09:00") + static String _formatWithTimezone( + int year, + int month, + int day, + int hour, + int minute, + int second, { + required int hoursOffset, + }) { + final sign = hoursOffset >= 0 ? '+' : '-'; + final absOffset = hoursOffset.abs(); + final hours = absOffset.toString().padLeft(2, '0'); + final minutes = '00'; // KST는 정확히 9시간 오프셋 + + final yearStr = year.toString().padLeft(4, '0'); + final monthStr = month.toString().padLeft(2, '0'); + final dayStr = day.toString().padLeft(2, '0'); + final hourStr = hour.toString().padLeft(2, '0'); + final minuteStr = minute.toString().padLeft(2, '0'); + final secondStr = second.toString().padLeft(2, '0'); + + return '$yearStr-$monthStr-${dayStr}T$hourStr:$minuteStr:$secondStr$sign$hours:$minutes'; + } +} + diff --git a/frontend/lib/utils/kst_date_factory.dart b/frontend/lib/utils/kst_date_factory.dart new file mode 100644 index 0000000..95bebc6 --- /dev/null +++ b/frontend/lib/utils/kst_date_factory.dart @@ -0,0 +1,79 @@ +/// KST 날짜 생성 팩토리 +/// +/// 기기 타임존과 무관하게 항상 KST(UTC+9) 기준 날짜를 생성합니다. +/// 모든 DateTime은 UTC 기반으로 관리하여 타임존 왜곡을 방지합니다. +class KstDateFactory { + /// 현재 시각을 KST 기준 날짜로 변환 + /// + /// 기기가 어느 타임존에 있든 항상 KST 기준의 "오늘" 날짜를 반환합니다. + /// 반환값: UTC 기반 DateTime (날짜만 의미, 시간은 00:00:00 UTC) + /// + /// 예: 미국 LA에서 실행해도 KST 기준 오늘이 반환됩니다. + static DateTime getTodayKst() { + // 1. UTC 시각을 가져옴 + final nowUtc = DateTime.now().toUtc(); + + // 2. KST로 변환 (UTC + 9시간) + final nowKst = nowUtc.add(const Duration(hours: 9)); + + // 3. UTC 기반으로 날짜만 추출 (시간 정보 제거) + // 중요: UTC로 생성하여 타임존 왜곡 방지 + return DateTime.utc(nowKst.year, nowKst.month, nowKst.day); + } + + /// 주어진 날짜를 KST 기준 날짜로 변환 + /// + /// [date]: 변환할 날짜 (어떤 타임존이든 상관없음) + /// 반환값: UTC 기반 DateTime (날짜만 의미, 시간은 00:00:00 UTC) + /// + /// 예: DateTime(2025, 11, 1) (로컬) → DateTime.utc(2025, 11, 1) (KST 기준) + static DateTime toKstDate(DateTime date) { + // 1. UTC로 변환 + final utc = date.toUtc(); + + // 2. KST로 변환 (UTC + 9시간) + final kst = utc.add(const Duration(hours: 9)); + + // 3. UTC 기반으로 날짜만 추출 + return DateTime.utc(kst.year, kst.month, kst.day); + } + + /// KST 날짜의 시작 시간(00:00:00)을 UTC DateTime으로 생성 + /// + /// [kstDate]: KST 기준 날짜 (UTC 기반 DateTime) + /// 반환값: UTC 기반 DateTime (해당 날짜의 00:00:00 KST를 UTC로 변환한 값) + /// + /// 예: 2025-11-01 KST → 2025-10-31 15:00:00 UTC + static DateTime getDayStartUtc(DateTime kstDate) { + // KST 00:00:00 = UTC 15:00:00 (전날) + // 따라서 KST 날짜에서 하루를 빼고 15시로 설정 + final previousDay = kstDate.subtract(const Duration(days: 1)); + return DateTime.utc( + previousDay.year, + previousDay.month, + previousDay.day, + 15, // KST 00:00 = UTC 15:00 (전날) + 0, + 0, + ); + } + + /// KST 날짜의 종료 시간(23:59:59)을 UTC DateTime으로 생성 + /// + /// [kstDate]: KST 기준 날짜 (UTC 기반 DateTime) + /// 반환값: UTC 기반 DateTime (해당 날짜의 23:59:59 KST를 UTC로 변환한 값) + /// + /// 예: 2025-11-01 KST → 2025-11-01 14:59:59 UTC + static DateTime getDayEndUtc(DateTime kstDate) { + // KST 23:59:59 = UTC 14:59:59 (당일) + return DateTime.utc( + kstDate.year, + kstDate.month, + kstDate.day, + 14, // KST 23:59 = UTC 14:59 (당일) + 59, + 59, + ); + } +} + diff --git a/frontend/lib/widgets/app_header.dart b/frontend/lib/widgets/app_header.dart new file mode 100644 index 0000000..1350dc1 --- /dev/null +++ b/frontend/lib/widgets/app_header.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; + +/// 앱의 표준 헤더 위젯 +/// +/// Error Collector 규칙 준수: +/// - Rule 1: MediaQuery로 상대 크기 사용 +/// - Rule 4: super.key constructor +/// - Rule 10: 고정 크기 회피 +/// - Rule 14: 일관된 레이아웃 패턴 +/// +/// 사용 예시: +/// ```dart +/// AppHeader( +/// title: const AppHeaderTitle('문제집'), +/// trailing: const AppHeaderMenuButton(), +/// ) +/// ``` +class AppHeader extends StatelessWidget { + /// 왼쪽 영역 위젯 (선택적) + /// null이면 균형을 위한 공간이 자동으로 추가됨 + final Widget? leading; + + /// 중앙 타이틀 위젯 (필수) + final Widget title; + + /// 오른쪽 액션 위젯 (선택적) + final Widget? trailing; + + /// 타이틀 정렬 방식 (기본값: 중앙 정렬) + /// - left: 왼쪽 정렬 + /// - center: 중앙 정렬 + final String titleAlignment; + + const AppHeader({ + super.key, // ✅ Rule 4: super.key + this.leading, + required this.title, + this.trailing, + this.titleAlignment = 'center', + }); + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final screenHeight = MediaQuery.of(context).size.height; + + // ✅ Rule 1: 상대 크기 사용 + final horizontalPadding = screenWidth * 0.05; + final topPadding = screenHeight * 0.021; + final bottomPadding = screenHeight * 0.012; + + // ✅ Rule 1: 균형을 위한 공간도 상대 크기 + final balanceSpace = screenWidth * 0.06; // 24px ≈ 6% of 402px + + return Container( + padding: EdgeInsets.fromLTRB( + horizontalPadding, + topPadding, + horizontalPadding, + bottomPadding, + ), // ✅ Rule 5: trailing comma + decoration: const BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Color(0x1A000000), + offset: Offset(0, 4), + blurRadius: 4, + ), + ], + ), // ✅ Rule 5: trailing comma + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Leading (왼쪽) + if (leading != null) + leading! + else if (titleAlignment == 'left') + SizedBox(width: balanceSpace) // ✅ Rule 1: 상대 크기 (왼쪽 정렬 시) + else + SizedBox(width: balanceSpace), // ✅ Rule 1: 상대 크기 (중앙 정렬 시) + // Title (중앙 or 왼쪽) + Expanded( + child: titleAlignment == 'left' + ? Align(alignment: Alignment.centerLeft, child: title) + : Center(child: title), // ✅ Rule 5: trailing comma + ), // ✅ Rule 5: trailing comma + // Trailing (오른쪽) + if (trailing != null) + trailing! + else + SizedBox(width: balanceSpace), // ✅ Rule 1: 상대 크기 + ], + ), // ✅ Rule 5: trailing comma + ); + } +} diff --git a/frontend/lib/widgets/app_header_menu_button.dart b/frontend/lib/widgets/app_header_menu_button.dart new file mode 100644 index 0000000..dc830ef --- /dev/null +++ b/frontend/lib/widgets/app_header_menu_button.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import '../../screens/grading_history/grading_history_page.dart'; + +/// 헤더 채점 히스토리 버튼 위젯 +class AppHeaderMenuButton extends StatelessWidget { + final VoidCallback? onPressed; + + const AppHeaderMenuButton({ + super.key, // ✅ Rule 4 + this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return IconButton( + icon: const Icon( + Icons.history, + color: Color(0xFF333333), + ), // ✅ Rule 5: trailing comma + onPressed: + onPressed ?? + () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const GradingHistoryPage(), + ), + ); + }, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ); + } +} diff --git a/frontend/lib/widgets/app_header_title.dart b/frontend/lib/widgets/app_header_title.dart new file mode 100644 index 0000000..d6e7261 --- /dev/null +++ b/frontend/lib/widgets/app_header_title.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +/// 헤더 타이틀 표준 위젯 +/// +/// 일관된 텍스트 스타일을 제공 +class AppHeaderTitle extends StatelessWidget { + final String text; + final TextAlign? textAlign; + + const AppHeaderTitle( + this.text, { + super.key, // ✅ Rule 4 + this.textAlign, + }); + + @override + Widget build(BuildContext context) { + return Text( + text, + textAlign: textAlign, // 왼쪽/중앙 정렬 제어 + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 20, + color: Color(0xFF333333), + ), // ✅ Rule 5: trailing comma + ); + } +} diff --git a/frontend/lib/widgets/continuous_learning_widget_v2.dart b/frontend/lib/widgets/continuous_learning_widget_v2.dart new file mode 100644 index 0000000..aeebb97 --- /dev/null +++ b/frontend/lib/widgets/continuous_learning_widget_v2.dart @@ -0,0 +1,930 @@ +import 'package:flutter/material.dart'; +import '../models/assessment.dart'; +import '../domain/learning/daily_learning_status.dart'; +import '../domain/learning/get_monthly_learning_status_use_case.dart'; +import '../constants/learning_widget_spacing.dart'; + +/// 연속학습 위젯 V2 - 개선된 UI/UX +/// +/// 주요 기능: +/// 1. 월별 날짜 스크롤 (한 달 단위) +/// 2. 선택된 날짜 표시 +/// 3. 학습 완료 상태 표시 (그라데이션 박스) +/// 4. 숙제 마감일 표시 (책 아이콘) +/// 5. 연속 학습일 연결선 표시 +/// +/// 데이터 소스 우선순위: +/// 1. dailyStatusMap (새 구조, 권장) +/// 2. monthlyStatusUseCase (새 구조, 권장) +/// 3. dateAssessments (하위 호환성, 추후 제거 예정) +/// 4. completedDates (하위 호환성, 추후 제거 예정) +/// +/// 힌트 박스 정렬 기준: +/// - 힌트 박스는 DateItem의 1:1 박스(AspectRatio) 기준 중앙에 정렬됩니다. +/// - DateItem의 텍스트 영역은 힌트 정렬에 영향을 주지 않습니다. +/// - Stack 높이는 itemWidth(cardWidth)로 설정되어 박스 높이와 일치합니다. +class ContinuousLearningWidgetV2 extends StatefulWidget { + final int consecutiveDays; + final Set homeworkDeadlines; + final Function(DateTime)? onDateSelected; + final DateTime? selectedDate; + + // 새 구조 (권장) + final Map? dailyStatusMap; + final GetMonthlyLearningStatusUseCase? monthlyStatusUseCase; + + // 하위 호환성 (추후 제거 예정) + @Deprecated('Use dailyStatusMap or monthlyStatusUseCase instead') + final Set completedDates; + @Deprecated('Use dailyStatusMap or monthlyStatusUseCase instead') + final Map>? dateAssessments; + + const ContinuousLearningWidgetV2({ + super.key, + required this.consecutiveDays, + this.homeworkDeadlines = const {}, + this.onDateSelected, + this.selectedDate, + // 새 구조 + this.dailyStatusMap, + this.monthlyStatusUseCase, + // 하위 호환성 + @Deprecated('Use dailyStatusMap or monthlyStatusUseCase instead') + this.completedDates = const {}, + @Deprecated('Use dailyStatusMap or monthlyStatusUseCase instead') + this.dateAssessments, + }); + + @override + State createState() => + _ContinuousLearningWidgetV2State(); +} + +class _ContinuousLearningWidgetV2State + extends State { + late DateTime _selectedDate; + final ScrollController _dateScrollController = ScrollController(); + + // 현재 표시 중인 월/년도 + late DateTime _currentMonth; // 현재 표시 중인 월의 첫 날 (예: 2025-11-01) + + // 월 전환 중복 방지 + bool _isChangingMonth = false; + + // 마지막 월 변경 시간 (2초 쿨다운용) + DateTime? _lastMonthChangeTime; + + // 힌트 표시 관련 + bool _showNextMonthHint = false; // 다음 달 힌트 표시 여부 + bool _showPrevMonthHint = false; // 이전 달 힌트 표시 여부 + + // 아이템 너비 (한 곳에서 정의) + double? _itemWidth; + + // 현재 월의 상태 캐시 (UseCase 사용 시) + Map? _currentMonthStatuses; + + /// 아이템 너비 getter (저장된 값 반환) + /// + /// itemWidth는 didChangeDependencies에서 계산되며, + /// 이 getter는 저장된 값을 반환합니다. + /// null인 경우 에러가 발생합니다. + double get itemWidth { + assert( + _itemWidth != null, + 'itemWidth must be initialized in didChangeDependencies', + ); + return _itemWidth!; + } + + // 월 전환 트리거 관련 상수 + // 오버스크롤 임계값: 스크롤이 끝을 넘어서 일정 거리 이상 오버스크롤했을 때만 전환 + static const double _monthChangeOverScrollThreshold = + 0.3; // 아이템 0.3개 너비 이상 오버스크롤 (더 유연하게) + + // 경계 월 제한 (추후 비즈니스 룰에 따라 주입 가능) + DateTime? _minMonth; + DateTime? _maxMonth; + + @override + void initState() { + super.initState(); + _selectedDate = widget.selectedDate ?? DateTime.now(); + // 현재 월의 첫 날로 초기화 + _currentMonth = DateTime(_selectedDate.year, _selectedDate.month, 1); + + // 다음 달 이동 제한: 현재 달로부터 다음 달까지만 이동 가능 + // 예: 11월이면 12월까지만 이동 가능, 13월은 불가 + _maxMonth = DateTime(_selectedDate.year, _selectedDate.month + 1, 1); + // 이전 달 이동은 제한 없음 (_minMonth는 null로 유지) + + // UseCase가 있으면 현재 월 데이터 로드 + if (widget.monthlyStatusUseCase != null) { + _loadMonthlyStatuses(); + } + + // 오늘 날짜가 오른쪽 끝에 오도록 스크롤 위치 설정 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _scrollToDateInCurrentMonth(_selectedDate, jump: true); + } + }); + } + + /// UseCase로 현재 월의 학습 상태 로드 + /// + /// 동기화 시점: + /// - initState: 위젯 초기화 시 현재 월 데이터 로드 + /// - didChangeDependencies: 다른 페이지에서 돌아올 때 최신 데이터로 동기화 + /// - didUpdateWidget: monthlyStatusUseCase 변경 시 새로 로드 + /// - _changeMonth: 월 변경 시 새 월의 데이터 로드 + Future _loadMonthlyStatuses() async { + if (widget.monthlyStatusUseCase == null) return; + + try { + final statuses = await widget.monthlyStatusUseCase!.call(_currentMonth); + if (mounted) { + setState(() { + _currentMonthStatuses = { + for (var status in statuses) + DailyLearningStatus.normalizeDate(status.date): status, + }; + }); + } + } catch (e) { + // 에러 발생 시 빈 상태 유지 + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // 아이템 너비를 한 곳에서 정의 (캐싱) + // itemWidth와 cardWidth를 통일하기 위해 screenWidth / 7 사용 + if (_itemWidth == null) { + final screenWidth = MediaQuery.of(context).size.width; + _itemWidth = screenWidth / 7; + } + + // 다른 페이지에서 돌아올 때 현재 월의 학습 상태를 최신 정보로 동기화 + // (예: 숙제 완료 상태 변경 등) + if (widget.monthlyStatusUseCase != null) { + _loadMonthlyStatuses(); + } + } + + @override + void didUpdateWidget(covariant ContinuousLearningWidgetV2 oldWidget) { + super.didUpdateWidget(oldWidget); + + // monthlyStatusUseCase가 변경되었거나 추가되었을 때 상태 로드 + if (widget.monthlyStatusUseCase != null && + (oldWidget.monthlyStatusUseCase == null || + oldWidget.monthlyStatusUseCase != widget.monthlyStatusUseCase)) { + _loadMonthlyStatuses(); + } + + // dailyStatusMap이 변경되었을 때는 자동으로 반영됨 (widget.dailyStatusMap 사용) + + final newSelected = widget.selectedDate ?? DateTime.now(); + if (!_isSameDay(newSelected, _selectedDate)) { + setState(() { + _selectedDate = newSelected; + }); + // 외부에서 selectedDate 변경 시: _setMonthAndScroll 사용 (월+스크롤 모두 책임) + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _setMonthAndScroll(newSelected); + } + }); + } + } + + /// 월 변경 + 스크롤을 함께 처리하는 상위 메서드 + /// + /// 책임: 월 변경과 스크롤을 순서대로 처리하여 복잡도 감소 + void _setMonthAndScroll(DateTime targetDate) { + final targetMonth = DateTime(targetDate.year, targetDate.month, 1); + + // 월이 다르면 먼저 변경 + if (_currentMonth.year != targetMonth.year || + _currentMonth.month != targetMonth.month) { + // targetDate를 selectedDate로 전달하여 해당 날짜로 이동 + _changeMonth(targetMonth, selectedDate: targetDate); + // _changeMonth 내부에서 이미 스크롤 처리됨 + return; + } + + // 같은 월이면 스크롤만 + _scrollToDateInCurrentMonth(targetDate, jump: false); + } + + /// 현재 월 내에서 날짜로 스크롤 (책임: 스크롤 위치만 조정) + void _scrollToDateInCurrentMonth(DateTime date, {bool jump = false}) { + // 선택된 날짜가 현재 표시 중인 월에 속하는지 확인 + if (date.year != _currentMonth.year || date.month != _currentMonth.month) { + // 다른 월이면 월을 먼저 변경 (상위 메서드 호출) + _setMonthAndScroll(date); + return; + } + + if (!_dateScrollController.hasClients) return; + + final targetIndex = date.day - 1; // 1일부터 시작하므로 -1 + final offset = targetIndex * itemWidth; // getter 사용 + + if (jump) { + _dateScrollController.jumpTo(offset); + } else { + _dateScrollController.animateTo( + offset, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + } + } + + /// 다음 달로 이동 + void _navigateToNextMonth() { + // 2초 쿨다운 체크 + final now = DateTime.now(); + if (_lastMonthChangeTime != null) { + final timeSinceLastChange = now.difference(_lastMonthChangeTime!); + if (timeSinceLastChange.inSeconds < 4) { + return; // 4초가 지나지 않았으면 이동 불가 + } + } + + final nextMonth = DateTime(_currentMonth.year, _currentMonth.month + 1, 1); + + // 경계 체크 + if (_maxMonth != null && nextMonth.isAfter(_maxMonth!)) { + return; + } + + // 다음 달 첫 날로 이동 + final targetDate = DateTime(nextMonth.year, nextMonth.month, 1); + + _lastMonthChangeTime = now; + _changeMonth(nextMonth, selectedDate: targetDate); + } + + /// 이전 달로 이동 + void _navigateToPreviousMonth() { + // 2초 쿨다운 체크 + final now = DateTime.now(); + if (_lastMonthChangeTime != null) { + final timeSinceLastChange = now.difference(_lastMonthChangeTime!); + if (timeSinceLastChange.inSeconds < 4) { + return; // 4초가 지나지 않았으면 이동 불가 + } + } + + // 이전 달의 마지막 날 계산 + // DateTime(year, month, 0)은 이전 달의 마지막 날을 반환합니다 + // 예: DateTime(2025, 11, 0) → 2025년 10월 31일 + final prevMonthLastDay = DateTime( + _currentMonth.year, + _currentMonth.month, + 0, + ); + final prevMonthFirstDay = DateTime( + prevMonthLastDay.year, + prevMonthLastDay.month, + 1, + ); + + // 경계 체크 (첫 날 기준으로 체크) + if (_minMonth != null && prevMonthFirstDay.isBefore(_minMonth!)) { + return; + } + + // 이전 달의 마지막 날로 이동 + _lastMonthChangeTime = now; + _changeMonth(prevMonthFirstDay, selectedDate: prevMonthLastDay); + } + + /// 월 변경 (책임: 월 상태만 변경) + /// + /// [newMonth]: 새 월의 첫 날 + /// [selectedDate]: 선택할 날짜 (null이면 현재 일자를 새 월로 변환) + void _changeMonth(DateTime newMonth, {DateTime? selectedDate}) { + // 같은 월이면 무시 + if (_currentMonth.year == newMonth.year && + _currentMonth.month == newMonth.month) { + return; + } + + if (_isChangingMonth) return; + + _isChangingMonth = true; + + setState(() { + _currentMonth = newMonth; + // 선택된 날짜 설정 + if (selectedDate != null) { + // 명시적으로 전달된 날짜 사용 + _selectedDate = selectedDate; + } else { + // selectedDate가 없으면 현재 선택된 날짜의 일자를 새 월로 변환 시도 + final currentDay = _selectedDate.day; + final newMonthLastDay = DateTime( + newMonth.year, + newMonth.month + 1, + 0, + ).day; + final targetDay = currentDay <= newMonthLastDay + ? currentDay + : newMonthLastDay; + _selectedDate = DateTime(newMonth.year, newMonth.month, targetDay); + } + }); + + // 새 월의 학습 상태 로드 + if (widget.monthlyStatusUseCase != null) { + _loadMonthlyStatuses(); + } + + // 스크롤 위치 조정: _currentMonth와 _selectedDate가 이미 동기화되어 있으므로 직접 스크롤 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + _isChangingMonth = false; + return; + } + + if (!_dateScrollController.hasClients) { + _isChangingMonth = false; + return; + } + + // _currentMonth와 _selectedDate가 이미 동기화되어 있으므로 직접 스크롤 + // 애니메이션 없이 즉시 이동 (jumpTo)하여 무한 루프 방지 + final targetIndex = _selectedDate.day - 1; + final offset = targetIndex * itemWidth; + _dateScrollController + .animateTo( + offset, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ) + .then((_) { + if (mounted) { + _isChangingMonth = false; + } + }); + }); + } + + /// 스크롤 위치 업데이트하여 힌트 표시 여부 결정 + void _updateHintVisibility() { + if (!_dateScrollController.hasClients || _isChangingMonth) { + return; + } + + final position = _dateScrollController.position; + final pixels = position.pixels; + final max = position.maxScrollExtent; + final width = itemWidth; + + // 힌트 표시 임계값: 스크롤이 끝에 거의 도달했을 때만 표시 + // 오버스크롤(음수 또는 max 초과) 상태에서만 힌트 표시 + final hintThreshold = width * 0.2; // 0.2개 아이템 너비 (더 유연하게) + + // 다음 달 힌트 표시 조건: 오른쪽 끝을 넘어서 오버스크롤 상태 + final nextMonth = DateTime(_currentMonth.year, _currentMonth.month + 1, 1); + final canMoveToNext = + _maxMonth == null || + nextMonth.isBefore(_maxMonth!) || + nextMonth.isAtSameMomentAs(_maxMonth!); + // max를 넘어서 오버스크롤했을 때만 힌트 표시 + final shouldShowNextHint = pixels > max + hintThreshold && canMoveToNext; + + // 이전 달 힌트 표시 조건: 왼쪽 끝을 넘어서 오버스크롤 상태 + final prevMonth = DateTime(_currentMonth.year, _currentMonth.month - 1, 1); + final canMoveToPrev = + _minMonth == null || + prevMonth.isAfter(_minMonth!) || + prevMonth.isAtSameMomentAs(_minMonth!); + // 0을 넘어서 오버스크롤했을 때만 힌트 표시 + final shouldShowPrevHint = pixels < -hintThreshold && canMoveToPrev; + + if (mounted) { + setState(() { + _showNextMonthHint = shouldShowNextHint; + _showPrevMonthHint = shouldShowPrevHint; + }); + } + } + + /// ScrollNotification 처리 + /// + /// ScrollUpdateNotification: 힌트 표시 업데이트 + /// ScrollEndNotification: 월 전환 판단 + bool _handleScrollNotification(ScrollNotification notification) { + // 스크롤 업데이트 시 힌트 표시 업데이트 + if (notification is ScrollUpdateNotification) { + _updateHintVisibility(); + return false; // 계속 전파 + } + + // 스크롤이 끝난 시점에만 월 전환 처리 + if (notification is! ScrollEndNotification) { + return false; // 계속 전파 + } + + // 힌트 숨기기 + if (mounted) { + setState(() { + _showNextMonthHint = false; + _showPrevMonthHint = false; + }); + } + + if (!mounted || _isChangingMonth) { + return false; + } + + if (!_dateScrollController.hasClients) { + return false; + } + + final position = _dateScrollController.position; + final pixels = position.pixels; + final max = position.maxScrollExtent; + final width = itemWidth; // getter 사용 + + // 오른쪽 끝 근처에서 오버스크롤했을 때 → 다음 달 + // max를 넘어서 오버스크롤했거나, max 근처에서 스크롤이 끝났을 때 전환 + final nextMonthThreshold = width * _monthChangeOverScrollThreshold; + if (pixels > max - nextMonthThreshold || pixels > max) { + _navigateToNextMonth(); + return true; // 이벤트 소비 + } + + // 왼쪽 끝 근처에서 오버스크롤했을 때 → 이전 달 + // 0을 넘어서 오버스크롤했거나, 0 근처에서 스크롤이 끝났을 때 전환 + final prevMonthThreshold = width * _monthChangeOverScrollThreshold; + if (pixels < prevMonthThreshold || pixels < 0) { + _navigateToPreviousMonth(); + return true; // 이벤트 소비 + } + + return false; // 계속 전파 + } + + @override + void dispose() { + _dateScrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final screenHeight = MediaQuery.of(context).size.height; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 연속 학습 일수 표시 + 상세 페이지 이동 버튼 + GestureDetector( + onTap: () { + Navigator.pushNamed(context, '/continuous-learning-detail'); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + RichText( + text: TextSpan( + children: [ + TextSpan( + text: '${widget.consecutiveDays}일', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w700, + fontSize: 18, + color: Color(0xFFAC5BF8), + ), + ), + const TextSpan( + text: ' 연속으로 학습하고 있어요', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 18, + color: Color(0xFF333333), + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: Colors.grey[600], + size: MediaQuery.of(context).size.width * 0.06, + ), + ], + ), + ), + SizedBox(height: screenHeight * 0.0092), // 8px → 0.92% + // 날짜 스크롤 영역 (ListView 가로 스크롤) - 한 달 단위 + // Stack 높이 = DateItem 전체 높이 (날짜 텍스트 + 간격 + 박스) + // 날짜 텍스트: fontSize 11 * height 1.1 ≈ 12px, SizedBox: 3px + // 힌트는 DateItem의 1:1 박스 기준 중앙에 정렬됩니다. + SizedBox( + height: itemWidth + 15, // 박스 높이 + 날짜 텍스트 높이(약 12px) + 간격(3px) + child: Stack( + children: [ + NotificationListener( + onNotification: _handleScrollNotification, + child: ListView.builder( + controller: _dateScrollController, + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + padding: EdgeInsets.symmetric( + horizontal: LearningWidgetSpacing.getOuterPadding(context), + ), + itemExtent: itemWidth, // 카드 너비 (getter 사용) + itemCount: DateTime( + _currentMonth.year, + _currentMonth.month + 1, + 0, + ).day, + itemBuilder: (context, index) { + // _currentMonth를 기준으로 날짜 생성 + final date = DateTime( + _currentMonth.year, + _currentMonth.month, + index + 1, + ); + + final isSelected = _isSameDay(date, _selectedDate); + final isCompleted = _isDateCompleted(date); + final hasHomework = _hasHomeworkDeadline(date); + + // 다음 날짜의 완료 여부 확인 (연결선 표시용) + final nextDate = date.add(const Duration(days: 1)); + final isNextCompleted = _isDateCompleted(nextDate); + // 다음 날이 현재 월에 속할 때만 연결선 표시 + final showConnector = + isCompleted && + isNextCompleted && + nextDate.month == date.month && + nextDate.year == date.year; + + return _buildDateItem( + date: date, + isSelected: isSelected, + isCompleted: isCompleted, + hasHomework: hasHomework, + showConnector: showConnector, + ); + }, + ), + ), + // 힌트 표시 (오버레이) + // 힌트는 DateItem의 1:1 박스 기준 중앙에 정렬됩니다. + // 박스는 Stack 내에서 아래쪽에 위치하므로, top offset을 조정하여 박스 중앙에 맞춥니다. + if (_showNextMonthHint) + Positioned( + right: LearningWidgetSpacing.getOuterPadding( + context, + ), // ListView padding과 동일 + top: 15, // 날짜 텍스트 + 간격 높이만큼 offset + child: SizedBox( + height: itemWidth, // 박스 높이와 동일 + child: Center( + child: _buildMonthHint('더 드래그해서\n다음 달로', true), + ), + ), + ), + if (_showPrevMonthHint) + Positioned( + left: LearningWidgetSpacing.getOuterPadding( + context, + ), // ListView padding과 동일 + top: 15, // 날짜 텍스트 + 간격 높이만큼 offset + child: SizedBox( + height: itemWidth, // 박스 높이와 동일 + child: Center( + child: _buildMonthHint('더 드래그해서\n이전 달로', false), + ), + ), + ), + ], + ), + ), + ], + ); + } + + /// 날짜 아이템 UI + Widget _buildDateItem({ + required DateTime date, + required bool isSelected, + required bool isCompleted, + required bool hasHomework, + required bool showConnector, + }) { + // cardWidth는 itemWidth와 동일하게 설정 (통일) + final cardWidth = itemWidth; + + // 박스 배경 (학습 완료 여부에 따라) + // 로고 그라데이션: linear-gradient(121.67deg, #AC5BF8 19.64%, #636ACF 77.54%) + final boxDecoration = isCompleted + ? BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + // 121.67deg ≈ 2.12 라디안 + // CSS: linear-gradient(121.67deg, #AC5BF8 19.64%, #636ACF 77.54%) + colors: [ + Color(0xFFAC5BF8), // 19.64% + Color(0xFF636ACF), // 77.54% + ], + stops: [0.1964, 0.7754], + ), + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFFAC5BF8).withOpacity(0.4), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ) + : BoxDecoration( + color: const Color(0xFFE1E7ED), // 회색 + borderRadius: BorderRadius.circular(10), + ); + + // 날짜 텍스트 색상 + final dateColor = isSelected ? Colors.white : const Color(0xFF666666); + + // 책 아이콘 색상 + final bookIconColor = isCompleted ? Colors.white : const Color(0xFF7C3AED); + + return GestureDetector( + onTap: () { + // 날짜 탭 시: 같은 달만 바뀐다 → 월은 바꾸지 않고, 단순히 선택만 변경 + setState(() { + _selectedDate = date; + }); + if (widget.onDateSelected != null) { + widget.onDateSelected!(date); + } + // 스크롤 애니메이션 제거: 날짜 선택 시 자동 스크롤하지 않음 + }, + child: SizedBox( + width: cardWidth, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: LearningWidgetSpacing.getInnerPadding(context), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 날짜 (월/일) - 선택된 날은 그라데이션 배경 + Container( + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 0), + decoration: isSelected + ? BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFAC5BF8), Color(0xFF636ACF)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(4), + boxShadow: [ + BoxShadow( + color: const Color(0xFFAC5BF8).withOpacity(0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ) + : BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '${date.month}/${date.day}', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 11, + color: dateColor, + height: 1.1, // 줄 간격 줄임 + ), + ), + ), + const SizedBox(height: 3), + // 박스와 연결선을 Stack으로 구성 + Stack( + clipBehavior: Clip.none, + children: [ + // 박스 (책 아이콘 또는 체크 아이콘 또는 둘 다 또는 빈 공간) - 1:1 비율 강제 + AspectRatio( + aspectRatio: 1.0, + child: Container( + decoration: boxDecoration, + child: _buildBoxContent( + hasHomework: hasHomework, + isCompleted: isCompleted, + bookIconColor: bookIconColor, + cardHeight: cardWidth, + ), + ), + ), + // 연결선 (다음 날짜와 연결) + if (showConnector) + Positioned( + right: -LearningWidgetSpacing.getConnectorWidth(context), + top: + (cardWidth - + LearningWidgetSpacing.getConnectorWidth( + context, + )) / + 2, + child: Container( + width: LearningWidgetSpacing.getConnectorWidth(context), + height: LearningWidgetSpacing.getConnectorWidth( + context, + ), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Color(0xFFAC5BF8), Color(0xFF636ACF)], + stops: [0.1964, 0.7754], + ), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + /// 박스 내부 콘텐츠 (책 아이콘, 체크 아이콘, 둘 다, 또는 빈 공간) + Widget? _buildBoxContent({ + required bool hasHomework, + required bool isCompleted, + required Color bookIconColor, + required double cardHeight, + }) { + // 둘 다 있는 경우: 책 아이콘과 체크 아이콘을 함께 표시 + if (hasHomework && isCompleted) { + return Stack( + children: [ + // 책 아이콘 (중앙) + Center( + child: Icon( + Icons.menu_book, + color: bookIconColor, + size: cardHeight * 0.4, + ), + ), + // 체크 아이콘 (오른쪽 상단) + Positioned( + top: cardHeight * 0.1, + right: cardHeight * 0.1, + child: Icon( + Icons.check_circle, + color: Colors.white, + size: cardHeight * 0.25, + ), + ), + ], + ); + } + // 책 아이콘만 + else if (hasHomework) { + return Center( + child: Icon( + Icons.menu_book, + color: bookIconColor, + size: cardHeight * 0.5, + ), + ); + } + // 체크 아이콘만 (학습 완료) + else if (isCompleted) { + return Center( + child: Icon(Icons.check, color: Colors.white, size: cardHeight * 0.5), + ); + } + // 둘 다 없음 + return null; + } + + // 헬퍼 메서드들 + + bool _isSameDay(DateTime date1, DateTime date2) { + return date1.year == date2.year && + date1.month == date2.month && + date1.day == date2.day; + } + + bool _isDateCompleted(DateTime date) { + final normalizedDate = DailyLearningStatus.normalizeDate(date); + + // 우선순위 1: dailyStatusMap 사용 (새 구조) + if (widget.dailyStatusMap != null) { + return widget.dailyStatusMap![normalizedDate]?.isCompleted ?? false; + } + + // 우선순위 2: _currentMonthStatuses 사용 (UseCase 결과) + if (_currentMonthStatuses != null) { + return _currentMonthStatuses![normalizedDate]?.isCompleted ?? false; + } + + // 우선순위 3: 기존 dateAssessments 사용 (하위 호환성, 추후 제거 예정) + if (widget.dateAssessments != null) { + final dateStr = _formatDate(date); + final assessments = widget.dateAssessments![dateStr] ?? []; + // 과제가 하나라도 있을 때, "모든 과제가 Y"인 날만 완료로 간주 + if (assessments.isEmpty) { + return false; + } + return assessments.every((a) => a.assessStatus == 'Y'); + } + + // 우선순위 4: 기존 completedDates 사용 (하위 호환성, 추후 제거 예정) + final dateStr = _formatDate(date); + return widget.completedDates.contains(dateStr); + } + + /// 월 전환 힌트 위젯 생성 + Widget _buildMonthHint(String text, bool isRight) { + return IgnorePointer( + child: AnimatedOpacity( + opacity: isRight + ? (_showNextMonthHint ? 1.0 : 0.0) + : (_showPrevMonthHint ? 1.0 : 0.0), + duration: const Duration(milliseconds: 200), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: LearningWidgetSpacing.hintOuterHorizontalPadding, + vertical: LearningWidgetSpacing.hintOuterVerticalPadding, + ), + child: Container( + padding: EdgeInsets.symmetric( + horizontal: LearningWidgetSpacing.getHintInnerHorizontalPadding( + context, + ), + vertical: LearningWidgetSpacing.getHintInnerVerticalPadding( + context, + ), + ), + decoration: BoxDecoration( + color: const Color(0xFFAC5BF8).withOpacity(0.9), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: const Color(0xFFAC5BF8).withOpacity(0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Text( + text, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 12, + color: Colors.white, + height: 1.3, + ), + ), + ), + ), + ), + ); + } + + bool _hasHomeworkDeadline(DateTime date) { + final dateStr = _formatDate(date); + + // Assessment 데이터가 있으면 그것을 우선 사용 + if (widget.dateAssessments != null) { + final assessments = widget.dateAssessments![dateStr] ?? []; + return assessments.isNotEmpty; + } + + // 없으면 기존 homeworkDeadlines 사용 (하위 호환성) + return widget.homeworkDeadlines.contains(dateStr); + } + + /// 날짜 포맷팅 (YYYY-MM-DD) + String _formatDate(DateTime date) { + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + } +} diff --git a/frontend/lib/widgets/empty_state_message.dart b/frontend/lib/widgets/empty_state_message.dart new file mode 100644 index 0000000..80ae9f7 --- /dev/null +++ b/frontend/lib/widgets/empty_state_message.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; + +/// 공통 Empty State 위젯 +/// +/// - 아이콘, 제목, 설명, 기본 액션 버튼을 일관된 스타일로 렌더링합니다. +/// - 레이아웃 배치는 부모에서 담당합니다 (예: Center, SingleChildScrollView 등). +class EmptyStateMessage extends StatelessWidget { + final IconData icon; + final String title; + final String? description; + final MainAxisAlignment mainAxisAlignment; + final String? primaryActionLabel; + final VoidCallback? onPrimaryAction; + + const EmptyStateMessage({ + super.key, + required this.icon, + required this.title, + this.description, + this.mainAxisAlignment = MainAxisAlignment.center, + this.primaryActionLabel, + this.onPrimaryAction, + }); + + /// 숙제 도메인용 프리셋 + const EmptyStateMessage.homework({ + super.key, + String? title, + String? description, + String? primaryActionLabel, + VoidCallback? onPrimaryAction, + }) : icon = Icons.assignment_outlined, + title = title ?? '등록된 숙제가 없어요.', + description = description, + primaryActionLabel = primaryActionLabel, + onPrimaryAction = onPrimaryAction, + mainAxisAlignment = MainAxisAlignment.center; + + /// 문제집 도메인용 프리셋 + const EmptyStateMessage.workbook({ + super.key, + String? title, + String? description, + String? primaryActionLabel, + VoidCallback? onPrimaryAction, + }) : icon = Icons.book_outlined, + title = title ?? '현재 등록된 문제집이 없어요.', + description = description, + primaryActionLabel = primaryActionLabel, + onPrimaryAction = onPrimaryAction, + mainAxisAlignment = MainAxisAlignment.center; + + /// 학원 도메인용 프리셋 + const EmptyStateMessage.academy({ + super.key, + String? title, + String? description, + String? primaryActionLabel, + VoidCallback? onPrimaryAction, + }) : icon = Icons.school_outlined, + title = title ?? '등록된 학원이 없어요.', + description = description, + primaryActionLabel = primaryActionLabel, + onPrimaryAction = onPrimaryAction, + mainAxisAlignment = MainAxisAlignment.center; + + /// 클래스/반 도메인용 프리셋 + const EmptyStateMessage.classRoom({ + super.key, + String? title, + String? description, + String? primaryActionLabel, + VoidCallback? onPrimaryAction, + }) : icon = Icons.group_outlined, + title = title ?? '등록된 클래스가 없어요.', + description = description, + primaryActionLabel = primaryActionLabel, + onPrimaryAction = onPrimaryAction, + mainAxisAlignment = MainAxisAlignment.center; + + @override + Widget build(BuildContext context) { + final children = [ + Icon( + icon, + size: 64, + color: const Color(0xFF999999), + ), + const SizedBox(height: 16), + Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w500, + fontSize: 16, + color: Color(0xFF666666), + ), + ), + ]; + + if (description != null && description!.isNotEmpty) { + children.addAll([ + const SizedBox(height: 8), + Text( + description!, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w400, + fontSize: 13, + color: Color(0xFF999999), + ), + ), + ]); + } + + if (primaryActionLabel != null && onPrimaryAction != null) { + children.addAll([ + const SizedBox(height: 24), + SizedBox( + width: 160, + child: ElevatedButton( + onPressed: onPrimaryAction, + child: Text( + primaryActionLabel!, + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ), + ), + ]); + } + + return Column( + mainAxisAlignment: mainAxisAlignment, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: children, + ); + } +} + + diff --git a/frontend/linux/flutter/generated_plugin_registrant.cc b/frontend/linux/flutter/generated_plugin_registrant.cc index e71a16d..64a0ece 100644 --- a/frontend/linux/flutter/generated_plugin_registrant.cc +++ b/frontend/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + 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); } diff --git a/frontend/linux/flutter/generated_plugins.cmake b/frontend/linux/flutter/generated_plugins.cmake index 2e1de87..2db3c22 100644 --- a/frontend/linux/flutter/generated_plugins.cmake +++ b/frontend/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/frontend/macos/Flutter/Flutter-Debug.xcconfig b/frontend/macos/Flutter/Flutter-Debug.xcconfig index c2efd0b..4b81f9b 100644 --- a/frontend/macos/Flutter/Flutter-Debug.xcconfig +++ b/frontend/macos/Flutter/Flutter-Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/frontend/macos/Flutter/Flutter-Release.xcconfig b/frontend/macos/Flutter/Flutter-Release.xcconfig index c2efd0b..5caa9d1 100644 --- a/frontend/macos/Flutter/Flutter-Release.xcconfig +++ b/frontend/macos/Flutter/Flutter-Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/frontend/macos/Flutter/GeneratedPluginRegistrant.swift b/frontend/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..004132f 100644 --- a/frontend/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/frontend/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,18 @@ import FlutterMacOS import Foundation +import file_selector_macos +import firebase_core +import firebase_messaging +import flutter_local_notifications +import geolocator_apple +import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/frontend/macos/Podfile b/frontend/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/frontend/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/frontend/macos/Podfile.lock b/frontend/macos/Podfile.lock new file mode 100644 index 0000000..c6b6d12 --- /dev/null +++ b/frontend/macos/Podfile.lock @@ -0,0 +1,22 @@ +PODS: + - file_selector_macos (0.0.1): + - FlutterMacOS + - FlutterMacOS (1.0.0) + +DEPENDENCIES: + - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + +EXTERNAL SOURCES: + file_selector_macos: + :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos + FlutterMacOS: + :path: Flutter/ephemeral + +SPEC CHECKSUMS: + file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/frontend/macos/Runner.xcodeproj/project.pbxproj b/frontend/macos/Runner.xcodeproj/project.pbxproj index 9d551fe..6de2e8f 100644 --- a/frontend/macos/Runner.xcodeproj/project.pbxproj +++ b/frontend/macos/Runner.xcodeproj/project.pbxproj @@ -21,12 +21,14 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ + 08EDBFA5A68C57CA94D6059A /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DDC52E5AC5D019F310B8FBF4 /* Pods_RunnerTests.framework */; }; 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 */; }; + 81A5DA21989ABED36103675C /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0A4CA49938D5AEB6E7445BD0 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,11 +62,12 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0A4CA49938D5AEB6E7445BD0 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 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 /* gradi_frontend.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "gradi_frontend.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* gradi_frontend.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = gradi_frontend.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 = ""; }; @@ -76,8 +79,15 @@ 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 = ""; }; + 5854F0B823DCC749D0089F8F /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 728BB4969904FF8B9E6843D9 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 8C4EE3B67951AD7ADCCC85F5 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 939344BC4B0B8D812E6B52C6 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + D67BDDB9F7D12FE3DEE4C88F /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + DDC52E5AC5D019F310B8FBF4 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + EED1FC437379E7ECD5B43A8E /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 08EDBFA5A68C57CA94D6059A /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,6 +103,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 81A5DA21989ABED36103675C /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -125,6 +137,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + 946897B2109C8FE54D70DDC2 /* Pods */, ); sourceTree = ""; }; @@ -172,9 +185,25 @@ path = Runner; sourceTree = ""; }; + 946897B2109C8FE54D70DDC2 /* Pods */ = { + isa = PBXGroup; + children = ( + 939344BC4B0B8D812E6B52C6 /* Pods-Runner.debug.xcconfig */, + D67BDDB9F7D12FE3DEE4C88F /* Pods-Runner.release.xcconfig */, + EED1FC437379E7ECD5B43A8E /* Pods-Runner.profile.xcconfig */, + 5854F0B823DCC749D0089F8F /* Pods-RunnerTests.debug.xcconfig */, + 8C4EE3B67951AD7ADCCC85F5 /* Pods-RunnerTests.release.xcconfig */, + 728BB4969904FF8B9E6843D9 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + 0A4CA49938D5AEB6E7445BD0 /* Pods_Runner.framework */, + DDC52E5AC5D019F310B8FBF4 /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -186,6 +215,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 1C3A246C72F5663F88229C15 /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -204,11 +234,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 603B5F73CFD7F826452D5C58 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + 7DE2262C209214EEFAA57812 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -291,6 +323,28 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 1C3A246C72F5663F88229C15 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -329,6 +383,45 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + 603B5F73CFD7F826452D5C58 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 7DE2262C209214EEFAA57812 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -380,6 +473,7 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 5854F0B823DCC749D0089F8F /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -394,6 +488,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 8C4EE3B67951AD7ADCCC85F5 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -408,6 +503,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 728BB4969904FF8B9E6843D9 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; diff --git a/frontend/macos/Runner.xcworkspace/contents.xcworkspacedata b/frontend/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/frontend/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/frontend/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 61314e6..dd04493 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11 + url: "https://pub.dev" + source: hosted + version: "1.3.59" args: dependency: transitive description: @@ -49,6 +57,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" cupertino_icons: dependency: "direct main" description: @@ -57,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" fake_async: dependency: transitive description: @@ -65,6 +97,110 @@ 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: "88707a3bec4b988aaed3b4df5d7441ee4e987f20b286cddca5d6a8270cab23f2" + url: "https://pub.dev" + source: hosted + version: "0.9.4+5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: a3994c26f10378a039faa11de174d7b78eb8f79e4dd0af2a451410c1a5c3f66b + url: "https://pub.dev" + source: hosted + version: "2.6.2" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "320fcfb6f33caa90f0b58380489fc5ac05d99ee94b61aa96ec2bff0ba81d3c2b" + url: "https://pub.dev" + source: hosted + version: "0.9.3+4" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5" + url: "https://pub.dev" + source: hosted + version: "3.15.2" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37" + url: "https://pub.dev" + source: hosted + version: "2.24.1" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc" + url: "https://pub.dev" + source: hosted + version: "15.2.10" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: "685e1771b3d1f9c8502771ccc9f91485b376ffe16d553533f335b9183ea99754" + url: "https://pub.dev" + source: hosted + version: "4.6.10" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: "0d1be17bc89ed3ff5001789c92df678b2e963a51b6fa2bdb467532cc9dbed390" + url: "https://pub.dev" + source: hosted + version: "3.10.10" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -78,6 +214,38 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: ef41ae901e7529e52934feba19ed82827b11baa67336829564aeab3129460610 + url: "https://pub.dev" + source: hosted + version: "18.0.1" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: "8f685642876742c941b29c32030f6f4f6dacd0e4eaecb3efbb187d6a3812ca01" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "6c5b83c86bf819cdb177a9247a3722067dd8cc6313827ce7c77a4b238a26fd52" + url: "https://pub.dev" + source: hosted + version: "8.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "306f0596590e077338312f38837f595c04f28d6cdeeac392d3d74df2f0003687" + url: "https://pub.dev" + source: hosted + version: "2.0.32" flutter_svg: dependency: "direct main" description: @@ -91,6 +259,67 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2 + url: "https://pub.dev" + source: hosted + version: "13.0.4" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d + url: "https://pub.dev" + source: hosted + version: "4.6.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + url: "https://pub.dev" + source: hosted + version: "2.3.13" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" + url: "https://pub.dev" + source: hosted + version: "4.2.6" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + url: "https://pub.dev" + source: hosted + version: "4.1.3" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + get_it: + dependency: "direct main" + description: + name: get_it + sha256: d85128a5dae4ea777324730dc65edd9c9f43155c109d5cc0a69cab74139fbac1 + url: "https://pub.dev" + source: hosted + version: "7.7.0" http: dependency: "direct main" description: @@ -107,6 +336,78 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "736eb56a911cf24d1859315ad09ddec0b66104bc41a7f8c5b96b4e2620cf5041" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "58a85e6f09fe9c4484d53d18a0bd6271b72c53fce1d05e6f745ae36d8c18efca" + url: "https://pub.dev" + source: hosted + version: "0.8.13+5" + 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: e675c22790bcc24e9abd455deead2b7a88de4b79f7327a281812f14de1a56f58 + url: "https://pub.dev" + source: hosted + version: "0.8.13+1" + 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: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + 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" + launchdarkly_event_source_client: + dependency: "direct main" + description: + name: launchdarkly_event_source_client + sha256: "51c90efe9765bf6908772abd59ea16c193798c1a9f9381475c56299cb553e3b2" + url: "https://pub.dev" + source: hosted + version: "2.0.1" leak_tracker: dependency: transitive description: @@ -163,6 +464,14 @@ 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" path: dependency: transitive description: @@ -179,6 +488,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + 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" petitparser: dependency: transitive description: @@ -187,6 +520,78 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + 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" + shared_preferences: + dependency: "direct main" + 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: "34266009473bf71d748912da4bf62d439185226c03e01e2d9687bc65bbfcb713" + url: "https://pub.dev" + source: hosted + version: "2.4.15" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "1c33a907142607c40a7542768ec9badfd16293bac51da3a4482623d15845f88b" + url: "https://pub.dev" + source: hosted + version: "2.5.5" + 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" sky_engine: dependency: transitive description: flutter @@ -240,6 +645,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.6" + timezone: + dependency: transitive + description: + name: timezone + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + url: "https://pub.dev" + source: hosted + version: "0.10.1" typed_data: dependency: transitive description: @@ -248,6 +661,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" vector_graphics: dependency: transitive description: @@ -296,6 +717,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" xml: dependency: transitive description: @@ -306,4 +735,4 @@ packages: version: "6.6.1" sdks: dart: ">=3.9.2 <4.0.0" - flutter: ">=3.29.0" + flutter: ">=3.35.0" diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 81f7509..935c90d 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -35,7 +35,23 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 http: ^1.2.0 + get_it: ^7.7.0 flutter_svg: ^2.0.10+1 + image_picker: ^1.0.7 + launchdarkly_event_source_client: ^2.0.1 + + # Firebase + firebase_core: ^3.6.0 + firebase_messaging: ^15.1.3 + + # 로컬 알림 + flutter_local_notifications: ^18.0.1 + + # 위치 정보 + geolocator: ^13.0.1 + + # 로컬 저장소 + shared_preferences: ^2.2.2 dev_dependencies: flutter_test: @@ -64,6 +80,7 @@ flutter: - assets/images/icons/ - assets/images/bookcovers/ - assets/images/social_login/ + - assets/images/temp/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/frontend/windows/flutter/generated_plugin_registrant.cc b/frontend/windows/flutter/generated_plugin_registrant.cc index 8b6d468..08cddef 100644 --- a/frontend/windows/flutter/generated_plugin_registrant.cc +++ b/frontend/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,15 @@ #include "generated_plugin_registrant.h" +#include +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + FirebaseCorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); } diff --git a/frontend/windows/flutter/generated_plugins.cmake b/frontend/windows/flutter/generated_plugins.cmake index b93c4c3..36a3440 100644 --- a/frontend/windows/flutter/generated_plugins.cmake +++ b/frontend/windows/flutter/generated_plugins.cmake @@ -3,6 +3,9 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows + firebase_core + geolocator_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST