diff --git a/.gitignore b/.gitignore index 3820a95..20c5693 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +.env \ No newline at end of file diff --git a/.metadata b/.metadata index 3803806..2c6187b 100644 --- a/.metadata +++ b/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "a402d9a4376add5bc2d6b1e33e53edaae58c07f8" + revision: "b45fa18946ecc2d9b4009952c636ba7e2ffbb787" channel: "stable" project_type: app @@ -13,11 +13,26 @@ project_type: app migration: platforms: - platform: root - create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 - base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 + base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 - platform: android - create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 - base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 + base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 + - platform: ios + create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 + base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 + - platform: linux + create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 + base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 + - platform: macos + create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 + base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 + - platform: web + create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 + base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 + - platform: windows + create_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 + base_revision: b45fa18946ecc2d9b4009952c636ba7e2ffbb787 # User provided section diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 752019b..87182bb 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -10,10 +10,14 @@ + + + - + + @@ -51,18 +56,18 @@ - - - - + + + - - + - + + + + + + - + \ No newline at end of file diff --git a/assets/images/icons/gray_line_graph_icon.svg b/assets/images/icons/gray_line_graph_icon.svg new file mode 100644 index 0000000..77bd908 --- /dev/null +++ b/assets/images/icons/gray_line_graph_icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/images/icons/green_line_graph_icon.svg b/assets/images/icons/green_line_graph_icon.svg new file mode 100644 index 0000000..e1005d5 --- /dev/null +++ b/assets/images/icons/green_line_graph_icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/images/profiles/user1.png b/assets/images/profiles/user1.png deleted file mode 100644 index d3e9c87..0000000 Binary files a/assets/images/profiles/user1.png and /dev/null differ diff --git a/assets/images/profiles/user2.png b/assets/images/profiles/user2.png deleted file mode 100644 index d3e9c87..0000000 Binary files a/assets/images/profiles/user2.png and /dev/null differ diff --git a/assets/images/profiles/user3.png b/assets/images/profiles/user3.png deleted file mode 100644 index d3e9c87..0000000 Binary files a/assets/images/profiles/user3.png and /dev/null differ diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index cb2ac8d..e9b2236 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -30,6 +30,11 @@ + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSCameraUsageDescription 프로필 사진 촬영을 위해 카메라 권한이 필요합니다. NSPhotoLibraryUsageDescription diff --git a/lib/app.dart b/lib/app.dart index 232d0a6..4bceab8 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,9 +1,9 @@ // 최초 작성자: 김채영 import 'package:flutter/material.dart'; -import 'package:haenaem/features/social/screens/social_screen.dart'; +import 'package:haenaem/features/social/screens/social_main_screen.dart'; import 'features/challenge/create/screens/challenge_create_screen.dart'; -import 'features/user/screens/my_page_screen.dart'; +import 'features/user/screens/my_page_main_screen.dart'; import 'features/main/screens/main_screen.dart'; class App extends StatelessWidget { @@ -13,7 +13,7 @@ class App extends StatelessWidget { Widget build(BuildContext context) { return const MaterialApp( // theme:, - home: MyPageScreen(), + home: MyPageMainScreen(), //home: MainScreen(), //home: SocialScreen(), ); diff --git a/lib/core/network/dio_provider.dart b/lib/core/network/dio_provider.dart index b66c4ad..0fc2b42 100644 --- a/lib/core/network/dio_provider.dart +++ b/lib/core/network/dio_provider.dart @@ -13,9 +13,8 @@ part 'dio_provider.g.dart'; Dio dio(DioRef ref) { final dio = Dio( BaseOptions( - baseUrl: 'https://hanaem.onrender.com/', - connectTimeout: const Duration(seconds: 45), - receiveTimeout: const Duration(seconds: 45), + baseUrl: 'http://158.247.216.11:8080', + connectTimeout: const Duration(seconds: 5), ), ); @@ -24,6 +23,8 @@ Dio dio(DioRef ref) { onRequest: (options, handler) async { const storage = FlutterSecureStorage(); final String? token = await storage.read(key: 'accessToken'); + // 💡 [디버깅 로그] 저장소에서 꺼낸 생생한 토큰 상태를 확인합니다. + debugPrint('🕵️‍♂️ [Interceptor] Storage Read (accessToken): $token'); if (token != null) { options.headers['Authorization'] = 'Bearer $token'; } @@ -31,11 +32,46 @@ Dio dio(DioRef ref) { }, onError: (DioException e, handler) async { if (e.response?.statusCode == 401) { - final newToken = await AuthService.refreshTokens(); - if (newToken != null) { - e.requestOptions.headers['Authorization'] = 'Bearer $newToken'; - final response = await dio.fetch(e.requestOptions); - return handler.resolve(response); + const storage = FlutterSecureStorage(); + final refreshToken = await storage.read(key: 'refreshToken'); + if (refreshToken != null) { + try { + // 🎯 토큰 갱신 전용 가벼운 Dio 생성 (인터셉터 없음) + final refreshDio = Dio( + BaseOptions(baseUrl: e.requestOptions.baseUrl), + ); + + final response = await refreshDio.post( + '/api/token', + data: {"refreshToken": refreshToken}, + ); + + // 1. 서버 응답에서 새 토큰 추출 (백엔드 응답 키값에 맞게 수정하세요) + final newAccessToken = response.data['accessToken']; + final newRefreshToken = response.data['refreshToken']; + + // 2. 새 토큰 스토리지에 저장 + await storage.write(key: 'accessToken', value: newAccessToken); + if (newRefreshToken != null) { + await storage.write( + key: 'refreshToken', + value: newRefreshToken, + ); + } + + // 3. 실패했던 원래 요청의 헤더를 새 토큰으로 변경 + e.requestOptions.headers['Authorization'] = + 'Bearer $newAccessToken'; + + // 4. 원래 요청 재시도 및 결과 반환 + final retryResponse = await dio.fetch(e.requestOptions); + return handler.resolve(retryResponse); + } catch (err) { + // 재발급 실패 시: 토큰 찌꺼기 삭제 + debugPrint("재발급 실패! 저장된 토큰 삭제"); + await storage.deleteAll(); + return handler.next(e); + } } } return handler.next(e); diff --git a/lib/core/network/dio_provider.g.dart b/lib/core/network/dio_provider.g.dart index 92d4137..7668117 100644 --- a/lib/core/network/dio_provider.g.dart +++ b/lib/core/network/dio_provider.g.dart @@ -6,7 +6,7 @@ part of 'dio_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$dioHash() => r'db900e733514ad57f7f6f488298446b8ed14a932'; +String _$dioHash() => r'f946130859aa45b04593c36dacd077dbeacea6b6'; /// See also [dio]. @ProviderFor(dio) diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart index 341f0f3..de842b2 100644 --- a/lib/core/theme/app_colors.dart +++ b/lib/core/theme/app_colors.dart @@ -9,7 +9,8 @@ class AppColors { static const Color gray2 = Color(0xFF616161); static const Color gray3 = Color(0xFF8c8c8c); static const Color gray4 = Color(0xFFD9D9D9); - static final Color gray5 = const Color(0xFFd9d9d9).withValues(alpha: 0.5); + static const Color gray5 = Color(0x7Fd9d9d9); + //static final Color gray5 = const Color(0xFFd9d9d9).withValues(alpha: 0.5); // Green - primary static const Color primaryAble = Color(0xff009951); @@ -17,8 +18,8 @@ class AppColors { static const Color disable = Color(0xffd9e0d7); // Mainlist - static Color success = const Color(0xffbbf4bd).withValues(alpha: 0.5); - static Color warning = const Color(0xffffd6c8).withValues(alpha: 0.5); + static const success = Color(0x7fbbf4bd); + static const warning = Color(0x7fffd6c8); static const Color fire = Color(0xFFFB7039); static const Color notification = Color(0xffD11E1B); diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index bd20b5b..b162c05 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -1,6 +1,7 @@ // 최초 작성자: 김채영 import 'package:flutter/material.dart'; +import 'app_colors.dart'; class AppTheme { static ThemeData lightTheme = ThemeData( @@ -8,6 +9,16 @@ class AppTheme { fontFamily: 'Pretendard', scaffoldBackgroundColor: Colors.white, + // 스피너랑 커서 우리 앱 초록색으로 변경 + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.primaryAble, + ), + textSelectionTheme: TextSelectionThemeData( + cursorColor: AppColors.primaryAble, + selectionHandleColor: AppColors.primaryAble, + selectionColor: AppColors.primaryAble.withValues(alpha: 0.3), + ), + // 상단 AppBar 테마 설정 appBarTheme: const AppBarTheme( backgroundColor: Colors.white, diff --git a/lib/core/utils/image_utils.dart b/lib/core/utils/image_utils.dart new file mode 100644 index 0000000..9afda6f --- /dev/null +++ b/lib/core/utils/image_utils.dart @@ -0,0 +1,36 @@ +// 최초 작성자: 김채영 +import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flutter_image_compress/flutter_image_compress.dart'; +import 'package:path_provider/path_provider.dart'; + +// 클라우더리 용량 이슈 때문에 필요한 인증글의 이미지 압축 유틸 함수 +Future compressImageFile(File file) async { + final tempDir = await getTemporaryDirectory(); + final targetPath = + '${tempDir.path}/compressed_${DateTime.now().millisecondsSinceEpoch}.jpg'; + + final XFile? result = await FlutterImageCompress.compressAndGetFile( + file.absolute.path, + targetPath, + quality: 80, // 80 정도면 육안상 차이 거의 없음 + minWidth: 1080, // 긴 쪽 기준 최대 해상도 + minHeight: 1080, + format: CompressFormat.jpeg, + ); + + // ✅ 압축 전후 크기 비교 로그 + final int originalSize = await file.length(); + final int compressedSize = result != null + ? await File(result.path).length() + : 0; + + debugPrint('🖼️ 압축 전: ${(originalSize / 1024).toStringAsFixed(1)} KB'); + debugPrint('🖼️ 압축 후: ${(compressedSize / 1024).toStringAsFixed(1)} KB'); + debugPrint( + '🖼️ 압축률: ${((1 - compressedSize / originalSize) * 100).toStringAsFixed(1)}%', + ); + + return result != null ? File(result.path) : file; // 실패 시 원본 반환 +} diff --git a/lib/features/auth/login/login_screen.dart b/lib/features/auth/login/login_screen.dart index ed46901..69d74f2 100644 --- a/lib/features/auth/login/login_screen.dart +++ b/lib/features/auth/login/login_screen.dart @@ -1,5 +1,10 @@ // 최초 작성자 : 김채영 +import 'dart:convert'; +import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:crypto/crypto.dart'; // PKCE 해싱용 +import 'package:webview_flutter/webview_flutter.dart'; // 웹뷰용 import 'package:flutter_svg/flutter_svg.dart'; import 'package:haenaem/core/theme/app_colors.dart'; import 'package:haenaem/core/theme/app_typography.dart'; @@ -60,14 +65,38 @@ class LoginScreen extends StatelessWidget { textColor: Colors.black, iconPath: 'assets/images/icons/kakao_logo.svg', onTap: () async { - // 1. 인가 코드와 Verifier 가져오기 - final authResult = await AuthService.signInWithKakao(); + // 1. PKCE 데이터 및 URL 준비 (AuthService 이용) + final pkce = AuthService.generatePkcePair(); + final authUrl = AuthService.getKakaoAuthUrl( + pkce['challenge']!, + ); + String? kakaoAuthCode; - if (authResult != null && context.mounted) { - // 2. 백엔드가 정의한 @RequestBody 형식으로 쏘기 + if (!context.mounted) return; + + // 2. 웹뷰 실행 (UI 부분이라 Screen에 두는 게 적절합니다) + await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Colors.transparent, + builder: (ctx) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(ctx).viewInsets.bottom, + ), + child: _buildKakaoWebView( + context: ctx, + authUrl: authUrl, + onCodeCaptured: (code) => kakaoAuthCode = code, + ), + ), + ); + + // 3. 획득한 코드가 있다면 백엔드로 전송 + if (kakaoAuthCode != null && context.mounted) { await AuthService.sendKakaoAuthToBackend( - code: authResult['code']!, - codeVerifier: authResult['codeVerifier']!, + code: kakaoAuthCode!, + codeVerifier: pkce['codeVerifier']!, // 원본 열쇠 전송 context: context, ); } @@ -107,8 +136,42 @@ class LoginScreen extends StatelessWidget { backgroundColor: const Color(0xFF03C75A), textColor: Colors.white, iconPath: 'assets/images/icons/naver_logo.svg', - onTap: navigateToSignup, - //onTap = () {}, + onTap: () async { + // 1. 상태(state) 문자열 생성 (카카오의 PKCE 함수를 재사용하여 임의의 문자열 15자리 생성) + final state = AuthService.generatePkcePair()['challenge']! + .substring(0, 15); + final authUrl = AuthService.getNaverAuthUrl(state); + String? naverAuthCode; + + if (!context.mounted) return; + + // 2. 카카오처럼 바텀시트로 네이버 웹뷰 실행 + await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Colors.transparent, + builder: (ctx) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(ctx).viewInsets.bottom, + ), + child: _buildNaverWebView( + context: ctx, + authUrl: authUrl, + onCodeCaptured: (code) => naverAuthCode = code, + ), + ), + ); + + // 3. 획득한 코드가 있다면 백엔드로 전송 + if (naverAuthCode != null && context.mounted) { + await AuthService.sendNaverAuthToBackend( + code: naverAuthCode!, + state: state, + context: context, + ); + } + }, ), const SizedBox(height: 30), @@ -165,4 +228,160 @@ class LoginScreen extends StatelessWidget { ), ); } + + Widget _buildKakaoWebView({ + required BuildContext context, + required String authUrl, + required Function(String) onCodeCaptured, + }) { + late final WebViewController controller; + + controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setUserAgent( + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36", + ) + ..setNavigationDelegate( + NavigationDelegate( + onWebResourceError: (WebResourceError error) { + debugPrint(''' + ⚠️ 웹뷰 로딩 에러 발생! + - 코드: ${error.errorCode} + - 설명: ${error.description} + - URL: ${error.url} + '''); + }, + onNavigationRequest: (NavigationRequest request) async { + final url = request.url; + + // 📍 [중요] 주소 감지 로그 추가 + if (url.contains('/oauth/kakao/callback')) { + debugPrint('🎣 [감지 성공] 카카오 콜백 주소가 포착되었습니다!'); + final uri = Uri.parse(url); + final code = uri.queryParameters['code']; + + if (code != null) { + debugPrint('✅ 획득한 인가 코드: $code'); + onCodeCaptured(code); + Navigator.pop(context); // 웹뷰 닫기 + return NavigationDecision.prevent; // 페이지 이동 중단 + } + } + + // 2️⃣ 카카오톡 앱 호출 주소 처리 + if (url.startsWith('kakaotalk://') || url.startsWith('intent://')) { + try { + debugPrint('📱 카카오톡 앱 실행 시도: $url'); + + // intent:// 스킴인 경우 안드로이드용 특수 처리가 필요할 수 있지만, + // url_launcher가 대부분 해결해줍니다. + final canLaunch = await canLaunchUrl(Uri.parse(url)); + if (canLaunch) { + await launchUrl( + Uri.parse(url), + mode: LaunchMode.externalApplication, + ); + return NavigationDecision.prevent; + } + } catch (e) { + debugPrint('🚨 앱 실행 실패: $e'); + // 앱 실행 실패 시 웹에서 로그인하도록 유지 (prevent 하지 않음) + } + } + + // 리다이렉트 및 기타 주소 처리 + if (!url.startsWith('http://') && !url.startsWith('https://')) { + return NavigationDecision.prevent; + } + return NavigationDecision.navigate; + }, + onPageStarted: (url) { + debugPrint("🚀 웹뷰 로딩 시작됨: $url"); + // ✅ AuthService.kakaoRedirectUri로 시작하는지 감시 + if (url.startsWith(AuthService.kakaoRedirectUri)) { + debugPrint('🎣 리다이렉트 감지!'); + final uri = Uri.parse(url); + final code = uri.queryParameters['code']; + if (code != null) { + onCodeCaptured(code); + Navigator.pop(context); + } + } + }, + onPageFinished: (url) { + debugPrint("✅ 웹뷰 로딩 완료됨: $url"); + }, + ), + ) + ..loadRequest(Uri.parse(authUrl)); + + // 📍 로드하기 직전에 실제 어떤 주소를 부르는지 확인! + debugPrint("🌍 웹뷰 로딩 시도 URL: $authUrl"); + + return Container( + height: MediaQuery.of(context).size.height * 0.9, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: WebViewWidget(controller: controller), + ); + } +} + +Widget _buildNaverWebView({ + required BuildContext context, + required String authUrl, + required Function(String) onCodeCaptured, +}) { + late final WebViewController controller; + + controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setUserAgent( + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36", + ) + ..setNavigationDelegate( + NavigationDelegate( + onNavigationRequest: (NavigationRequest request) { + final url = request.url; + + // 📍 [핵심] 백엔드의 네이버 콜백 주소 감지 + if (url.contains('/oauth/naver/callback')) { + debugPrint('🎣 [감지 성공] 네이버 콜백 주소가 포착되었습니다!'); + final uri = Uri.parse(url); + final code = uri.queryParameters['code']; + + if (code != null) { + debugPrint('✅ 획득한 네이버 인가 코드: $code'); + onCodeCaptured(code); + Navigator.pop(context); // 웹뷰 닫기 + return NavigationDecision.prevent; // 리다이렉트 방지 + } + } + return NavigationDecision.navigate; + }, + onPageStarted: (url) { + // 이중 체크 (만약 onNavigationRequest에서 못 잡았을 경우) + if (url.startsWith(AuthService.naverRedirectUri)) { + final uri = Uri.parse(url); + final code = uri.queryParameters['code']; + if (code != null) { + onCodeCaptured(code); + Navigator.pop(context); + } + } + }, + ), + ) + ..loadRequest(Uri.parse(authUrl)); + + return Container( + height: MediaQuery.of(context).size.height * 0.9, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: WebViewWidget(controller: controller), + ); } diff --git a/lib/features/auth/services/auth_service.dart b/lib/features/auth/services/auth_service.dart index 40bafbd..a007432 100644 --- a/lib/features/auth/services/auth_service.dart +++ b/lib/features/auth/services/auth_service.dart @@ -1,11 +1,17 @@ // 최초 작성자: 김채영 import 'package:flutter/material.dart'; +import 'dart:convert'; +import 'dart:math'; +import 'package:crypto/crypto.dart'; import 'package:flutter_appauth/flutter_appauth.dart'; import 'package:dio/dio.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import '../../../core/network/dio_provider.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; // 토큰 저장을 위해 필요 import 'package:haenaem/features/auth/signup/screens/signup_main_screen.dart'; import 'package:haenaem/features/main/screens/main_screen.dart'; import 'package:kakao_flutter_sdk_user/kakao_flutter_sdk_user.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; // 구글 OAuth 2.0 기반의 사용자 인증과 JWT 토큰의 생명주기(발급, 재발급, 파기)를 전담하는 클래스 // 서버로부터 받은 userStatus(NEW/ACTIVE)를 분석하여 사용자별 맞춤형 초기 화면 진입 경로를 제어 @@ -14,54 +20,135 @@ class AuthService { static const _storage = FlutterSecureStorage(); // 보안 저장소 // 구글 설정 정보 - static const String androidClientId = - '433865217738-m3uqqdv9lumpf1ne8e3bkpsbtsa6919i.apps.googleusercontent.com'; + static String androidClientId = dotenv.env['GOOGLE_ANDROID_CLIENT_ID'] ?? ''; static const String customScheme = 'com.googleusercontent.apps.433865217738-m3uqqdv9lumpf1ne8e3bkpsbtsa6919i'; static const String redirectUri = '$customScheme:/oauth2redirect'; // 카카오 설정 정보 - static const String kakaoRestApiKey = '9fdd13c0777c415d8fa4055b5b26a6c5'; - static const String kakaoNativeAppKey = '05a36f172ea2945260862834654385ea'; + static String kakaoRestApiKey = dotenv.env['KAKAO_REST_API_KEY'] ?? ''; + static String kakaoNativeAppKey = dotenv.env['KAKAO_NATIVE_APP_KEY'] ?? ''; // static const String kakaoRedirectUri = // 'https://hanaem.onrender.com/api/oauth/kakao/token'; + static const String kakaoRedirectUri = - 'kakao9fdd13c0777c415d8fa4055b5b26a6c5://oauth'; + 'http://158.247.216.11:8080/oauth/kakao/callback'; + + //static const String kakaoRedirectUri = + //'kakao9fdd13c0777c415d8fa4055b5b26a6c5://oauth'; + + // 네이버 설정 정보 + static String naverClientId = dotenv.env['NAVER_CLIENT_ID'] ?? ''; + static const String naverRedirectUri = + 'http://158.247.216.11:8080/oauth/naver/callback'; + // ♥️ 기존 서버 static final Dio _dio = Dio( - BaseOptions(baseUrl: 'https://hanaem.onrender.com'), + BaseOptions(baseUrl: 'http://158.247.216.11:8080'), ); - // 카카오 로그인 - static Future?> signInWithKakao() async { + // ♥️ 로컬 서버로 테스트 + // static final Dio _dio = Dio( + // BaseOptions( + // baseUrl: 'https://ungenially-undebatable-sindy.ngrok-free.dev', + // headers: { + // 'ngrok-skip-browser-warning': 'true', + // 'Content-Type': 'application/json', + // }, + // ), + // ); + + // ---------------------------------------- + // 네이버 로그인 함수 + // ---------------------------------------- + + // 3. 네이버 인증 URL 생성 (네이버는 CSRF 방지를 위해 state 파라미터가 필수입니다) + static String getNaverAuthUrl(String state) { + final clientId = naverClientId; + final redirectUri = Uri.encodeComponent(naverRedirectUri); + + return 'https://nid.naver.com/oauth2.0/authorize' + '?response_type=code' + '&client_id=$clientId' + '&redirect_uri=$redirectUri' + '&state=$state'; + } + + // 4. 네이버 인가 코드를 서버로 전송 + static Future sendNaverAuthToBackend({ + required String code, + required String state, + required BuildContext context, + }) async { try { - // 구글과 동일하게 인가 요청을 보냅니다. - final AuthorizationResponse result = await _appAuth.authorize( - AuthorizationRequest( - kakaoRestApiKey, // clientId 자리에 REST API 키 사용 - kakaoRedirectUri, - serviceConfiguration: const AuthorizationServiceConfiguration( - authorizationEndpoint: 'https://kauth.kakao.com/oauth/authorize', - tokenEndpoint: 'https://kauth.kakao.com/oauth/token', - ), - scopes: ['profile_nickname', 'profile_image'], // 필요한 권한 - ), + debugPrint("🚀 서버로 네이버 인가 데이터 전송 시작..."); + + final response = await _dio.post( + '/api/oauth/naver/token', // 📍 이 주소가 맞는지 백엔드 팀과 꼭 확인하세요! + data: { + "code": code, + "state": state, // 네이버는 검증을 위해 state도 같이 보내는 경우가 많습니다. + "fcmToken": "", + }, + options: Options(contentType: Headers.jsonContentType), ); - if (result.authorizationCode != null && result.codeVerifier != null) { - debugPrint('✅ 카카오 인가 코드 획득 성공'); - return { - "code": result.authorizationCode!, - "codeVerifier": result.codeVerifier!, - }; + debugPrint("📥 네이버 로그인 서버 응답 코드: ${response.statusCode}"); + + if (response.statusCode == 200 && response.data != null) { + await _handleAuthResponse(response.data, context); } - } catch (e) { - debugPrint('🚨 카카오 PKCE 인증 에러: $e'); + } on DioException catch (e) { + debugPrint('🌐 네이버 서버 통신 에러: ${e.response?.statusCode}'); + debugPrint('내용: ${e.response?.data}'); } - return null; } - // 서버 통신 부분 (백엔드 엔드포인트에 맞춰 수정 필요) + // ---------------------------------------- + // 카카오 로그인 함수 + // ---------------------------------------- + + // 1. PKCE 쌍 생성 (RFC 7636 표준 방식) + static Map generatePkcePair() { + // 1-1. Verifier 생성: 표준에 정의된 [A-Z, a-z, 0-9, -, ., _, ~] 문자만 사용 + const chars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + final random = Random.secure(); + + // 64자리의 무작위 문자열 생성 (표준 범위 43~128자 준수) + final verifier = List.generate( + 64, + (_) => chars[random.nextInt(chars.length)], + ).join(); + debugPrint("🔒 verifier 생성: $verifier"); + + // 1-2. Challenge 생성: Verifier를 SHA256으로 해싱 후 Base64Url 인코딩 + final bytes = utf8.encode(verifier); // plain string을 바이트로 변환 + final digest = sha256.convert(bytes); // SHA256 해싱 + + // Base64UrlEncode 후 패딩(=) 제거 및 특수문자 치환 + final challenge = base64UrlEncode( + digest.bytes, + ).replaceAll('=', '').replaceAll('+', '-').replaceAll('/', '_'); + debugPrint('🔒 생성된 Challenge: $challenge'); + + return {'codeVerifier': verifier, 'challenge': challenge}; + } + + // 2. 카카오 인증 URL 생성 + static String getKakaoAuthUrl(String challenge) { + final clientId = kakaoRestApiKey; + final redirectUri = Uri.encodeComponent(kakaoRedirectUri); + + return 'https://kauth.kakao.com/oauth/authorize' + '?client_id=$clientId' + '&redirect_uri=$redirectUri' + '&response_type=code' + '&code_challenge=$challenge' + '&code_challenge_method=S256'; + } + + // 서버 통신 부분 static Future sendKakaoAuthToBackend({ required String code, required String codeVerifier, @@ -69,6 +156,7 @@ class AuthService { }) async { try { debugPrint("🚀 서버로 카카오 인가 데이터 전송 시작..."); + debugPrint("서버 전송 verifier: $codeVerifier"); final response = await _dio.post( '/api/oauth/kakao/token', @@ -281,10 +369,12 @@ class AuthService { debugPrint("🗑️ 회원 탈퇴 요청 시작"); // 서버에 계정 삭제 요청 (DELETE) // 리프레시 토큰뿐만 아니라 유저의 개인정보 등을 삭제하도록 서버에 명령합니다. - await _dio.delete( + final response = await _dio.delete( '/api/me', options: Options(headers: {'Authorization': 'Bearer $accessToken'}), ); + // 📍 서버가 진짜 뭐라고 대답했는지 찍어보기 + debugPrint("📥 탈퇴 응답 데이터: ${response.data}"); // 탈퇴 성공 후 클라이언트 데이터 정리 await _storage.deleteAll(); diff --git a/lib/features/auth/signup/providers/signup_provider.dart b/lib/features/auth/signup/providers/signup_provider.dart index 71942b2..7dbbdb2 100644 --- a/lib/features/auth/signup/providers/signup_provider.dart +++ b/lib/features/auth/signup/providers/signup_provider.dart @@ -3,9 +3,9 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:haenaem/features/challenge/data/challenge_repository.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:haenaem/features/challenge/model/challenge_model.dart'; +// import 'package:haenaem/features/challenge/models/challenge_model.dart'; +import 'package:haenaem/shared/models/tag_model.dart'; import '../models/signup_state.dart'; import 'package:haenaem/features/user/data/user_repository.dart'; diff --git a/lib/features/auth/signup/screens/auth_gate.dart b/lib/features/auth/signup/screens/auth_gate.dart index ad72f8d..9671471 100644 --- a/lib/features/auth/signup/screens/auth_gate.dart +++ b/lib/features/auth/signup/screens/auth_gate.dart @@ -6,7 +6,8 @@ import 'package:haenaem/features/main/screens/main_screen.dart'; import 'package:haenaem/features/auth/login/login_screen.dart'; import 'signup_main_screen.dart'; import 'package:haenaem/features/user/data/user_repository.dart'; -import 'package:haenaem/features/user/model/user_model.dart'; +import 'package:haenaem/shared/models/user_detail.dart'; +import 'package:haenaem/features/user/provider/user_provider.dart'; import 'package:haenaem/features/notification/services/fcm_service.dart'; // 앱을 껐다 켰을 때 저장된 토큰을 확인 @@ -28,7 +29,7 @@ class AuthGate extends ConsumerWidget { // 1. 토큰이 있는 경우 if (snapshot.hasData && snapshot.data != null) { // 💡 핵심: 서버에 내 프로필을 물어봐서 가입이 끝났는지 확인합니다. - return FutureBuilder( + return FutureBuilder( future: ref.read(userRepositoryProvider).getMyProfile(), builder: (context, profileSnapshot) { if (profileSnapshot.connectionState == ConnectionState.waiting) { @@ -37,24 +38,34 @@ class AuthGate extends ConsumerWidget { ); } + // 💡 에러가 발생한 경우 (예: 토큰 만료 후 재발급 실패 등) -> 로그인 화면으로 + if (profileSnapshot.hasError) { + debugPrint("⚠️ 프로필 로드 실패 (에러): 로그인 화면으로 안내"); + return const LoginScreen(); + } + // 프로필 정보가 있고, 특정 필드(예: 태그)가 비어있다면 가입 미완료로 간주 - final profile = profileSnapshot.data; - if (profile == null || profile.tags.isEmpty) { - debugPrint("⚠️ 가입 미완료 유저 감지: 회원가입 화면으로 이동"); - return const SignupMainScreen(); // 닉네임 설정부터 다시! + final user = profileSnapshot.data; + + // 가입 미완료 판별 로직 + if (user == null) { + debugPrint("⚠️ 가입 미완료 유저: 회원가입 화면으로 안내"); + return const SignupMainScreen(); } - // 가입 완료가 확인되어 메인으로 가기 전, FCM 토큰을 업데이트 + // 성공적으로 정보를 가져왔다면 전역 Provider에 저장 + // 프레임 렌더링 후에 상태를 업데이트하도록 처리 WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(currentUserProvider.notifier).setUser(user.user); + // FCM 초기화 등 추가 작업 ref.read(fcmServiceProvider).initialize(); }); - return const MainScreen(); // 모든 정보가 있을 때만 홈으로! + return const MainScreen(); }, ); } - - // 2. 토큰이 없는 경우 + // 토큰이 없는 경우 (로그아웃 상태) return const LoginScreen(); }, ); diff --git a/lib/features/auth/signup/screens/tag_screen.dart b/lib/features/auth/signup/screens/tag_screen.dart index 12d4142..a3d752b 100644 --- a/lib/features/auth/signup/screens/tag_screen.dart +++ b/lib/features/auth/signup/screens/tag_screen.dart @@ -7,7 +7,7 @@ import '../providers/signup_provider.dart'; import '../models/signup_state.dart'; import 'package:haenaem/features/auth/signup/widgets/signup_page_layout.dart'; import 'package:haenaem/shared/widgets/app_tag_chip.dart'; -import 'package:haenaem/shared/models/tag_data.dart'; +import 'package:haenaem/shared/models/tag_model.dart'; // 태그 설정 화면 class TagScreen extends ConsumerStatefulWidget { diff --git a/lib/features/auth/signup/services/user_service.dart b/lib/features/auth/signup/services/user_service.dart index 5e56b87..6b3fa1e 100644 --- a/lib/features/auth/signup/services/user_service.dart +++ b/lib/features/auth/signup/services/user_service.dart @@ -6,7 +6,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; // 유저 정보와 관련된 API 통신을 담당하는 서비스 클래스 class UserService { - final Dio _dio = Dio(BaseOptions(baseUrl: 'https://hanaem.onrender.com')); + final Dio _dio = Dio(BaseOptions(baseUrl: 'http://158.247.216.11:8080')); // final Dio _dio = Dio( // BaseOptions( // // 1. 서버 주소를 팀원이 준 ngrok 주소로 변경 @@ -16,7 +16,7 @@ class UserService { // // 2. ngrok 경고창 우회를 위한 헤더 필수 추가 // headers: {'ngrok-skip-browser-warning': 'true'}, // ), - //); + // ); final _storage = const FlutterSecureStorage(); // 로컬 기기에 저장된 토큰을 읽기 위한 보안 저장소 diff --git a/lib/features/challenge/create/data/challenge_create_repository.dart b/lib/features/challenge/create/data/challenge_create_repository.dart new file mode 100644 index 0000000..d8e899f --- /dev/null +++ b/lib/features/challenge/create/data/challenge_create_repository.dart @@ -0,0 +1,78 @@ +// 최초 작성자 : 강선욱 +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:haenaem/core/network/dio_provider.dart'; +import '../models/created_response.dart'; +import 'dart:io'; +import 'package:http_parser/http_parser.dart'; + +part 'challenge_create_repository.g.dart'; + +class ChallengeCreateRepository { + final Dio _dio; + + ChallengeCreateRepository(this._dio); + + // 1. 챌린지 생성 POST 요청 + Future createChallenge(Map data) async { + try { + final response = await _dio.post('/api/challenges/create', data: data); + debugPrint('📥 서버 생성 응답 원본: ${response.data}'); + + if (response.statusCode == 200 || response.statusCode == 201) { + // ✅ 서버 응답 원본을 그대로 CreatedResponse.fromJson에 전달 + // 모델 내부에서 super(id, title)와 고유 필드들을 알아서 매핑합니다. + return CreatedResponse.fromJson(response.data); + } else { + throw Exception('챌린지 생성 실패 (상태 코드: ${response.statusCode})'); + } + } on DioException catch (e) { + debugPrint('❌ 서버 상세 에러: ${e.response?.data}'); + throw Exception( + '서버 에러: ${e.response?.statusCode} - ${e.response?.data['message'] ?? '잘못된 요청'}', + ); + } catch (e) { + // 💡 여기서 모델 파싱 에러(타입 불일치 등)가 잡힙니다. + debugPrint('❌ 데이터 파싱 에러 발생: $e'); + throw Exception('데이터 처리 중 오류가 발생했습니다.'); + } + } + + // 2. 인증 사진 검증 (생성 과정에서 AI 검증 등이 필요한 경우 사용) + Future verifyImage(File imageFile, int challengeId) async { + try { + final formData = FormData.fromMap({ + "image": await MultipartFile.fromFile( + imageFile.path, + filename: imageFile.path.split('/').last, + contentType: MediaType('image', 'jpeg'), + ), + }); + + final response = await _dio.post( + '/api/image/verify', + data: formData, + queryParameters: {'challengeId': challengeId}, + ); + + if (response.statusCode == 200 || response.statusCode == 204) { + debugPrint('✅ 이미지 검증 및 임시 업로드 성공: ${response.data}'); + return response.data['tempImageId']; + } + return null; + } on DioException catch (e) { + debugPrint('❌ 이미지 검증 에러: ${e.response?.data}'); + return null; + } + } +} + +// Provider 설정 +@riverpod +ChallengeCreateRepository challengeCreateRepository( + ChallengeCreateRepositoryRef ref, +) { + final dio = ref.watch(dioProvider); + return ChallengeCreateRepository(dio); +} diff --git a/lib/features/challenge/create/data/challenge_create_repository.g.dart b/lib/features/challenge/create/data/challenge_create_repository.g.dart new file mode 100644 index 0000000..1d61c93 --- /dev/null +++ b/lib/features/challenge/create/data/challenge_create_repository.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'challenge_create_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$challengeCreateRepositoryHash() => + r'1d25f3888829a9d120dde7fbe087998c8601fcad'; + +/// See also [challengeCreateRepository]. +@ProviderFor(challengeCreateRepository) +final challengeCreateRepositoryProvider = + AutoDisposeProvider.internal( + challengeCreateRepository, + name: r'challengeCreateRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$challengeCreateRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef ChallengeCreateRepositoryRef = + AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/challenge/create/models/created_response.dart b/lib/features/challenge/create/models/created_response.dart new file mode 100644 index 0000000..f27153a --- /dev/null +++ b/lib/features/challenge/create/models/created_response.dart @@ -0,0 +1,23 @@ +import 'package:haenaem/shared/models/challenge_base.dart'; +import 'package:haenaem/shared/models/user.dart'; + +class CreatedResponse extends ChallengeBase { + final String challengeLink; + + const CreatedResponse({ + required super.id, + required super.title, + required this.challengeLink, + }); + + factory CreatedResponse.fromJson(Map json) { + return CreatedResponse( + // ChallengeBase 필드 (서버 원본 키 'id' 사용) + id: json['id'] as int? ?? 0, + title: json['title'] as String? ?? '', + + // CreatedResponse 전용 필드 + challengeLink: json['challengeLink'] as String? ?? '', + ); + } +} diff --git a/lib/features/challenge/create/provider/create_provider.dart b/lib/features/challenge/create/provider/create_provider.dart new file mode 100644 index 0000000..283a105 --- /dev/null +++ b/lib/features/challenge/create/provider/create_provider.dart @@ -0,0 +1,28 @@ +// 최초 작성자 : 강선욱 +import 'package:haenaem/features/challenge/create/models/created_response.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../data/challenge_create_repository.dart'; +import 'package:haenaem/shared/models/tag_model.dart'; + +part 'create_provider.g.dart'; // 파일명에 맞춰 변경 + +// 생성 상태(로딩/성공/에러)를 관리하는 Notifier +@riverpod +class ChallengeCreateNotifier extends _$ChallengeCreateNotifier { + @override + AsyncValue build() => const AsyncValue.data(null); + + Future create(Map data) async { + state = const AsyncValue.loading(); + + // 비동기 실행 결과 가드 + final result = await AsyncValue.guard( + () => ref.read(challengeCreateRepositoryProvider).createChallenge(data), + ); + + state = result; + + // 성공 시 ChallengeBase 객체를 반환하거나, 에러 시 null 반환 + return result.valueOrNull; + } +} diff --git a/lib/features/challenge/create/provider/create_provider.g.dart b/lib/features/challenge/create/provider/create_provider.g.dart new file mode 100644 index 0000000..fa8e7a6 --- /dev/null +++ b/lib/features/challenge/create/provider/create_provider.g.dart @@ -0,0 +1,31 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'create_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$challengeCreateNotifierHash() => + r'84ecfb76760f94967f2158d015979ff74f508361'; + +/// See also [ChallengeCreateNotifier]. +@ProviderFor(ChallengeCreateNotifier) +final challengeCreateNotifierProvider = + AutoDisposeNotifierProvider< + ChallengeCreateNotifier, + AsyncValue + >.internal( + ChallengeCreateNotifier.new, + name: r'challengeCreateNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$challengeCreateNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$ChallengeCreateNotifier = + AutoDisposeNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/challenge/create/screens/challenge_create_screen.dart b/lib/features/challenge/create/screens/challenge_create_screen.dart index f022e6d..3ac740c 100644 --- a/lib/features/challenge/create/screens/challenge_create_screen.dart +++ b/lib/features/challenge/create/screens/challenge_create_screen.dart @@ -4,16 +4,22 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:haenaem/core/theme/app_colors.dart'; import 'package:haenaem/core/theme/app_typography.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; -import 'package:haenaem/features/challenge/model/challenge_model.dart'; +import 'package:haenaem/shared/models/tag_model.dart'; +// import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; +// import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; +import 'package:haenaem/features/user/provider/my_challenge_provider.dart'; +import 'package:haenaem/shared/provider/home_provider.dart'; +import '../provider/create_provider.dart'; +// import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; +// import 'package:haenaem/features/challenge/models/challenge_model.dart'; import 'dart:convert'; import '../../../../shared/widgets/challenge_label.dart'; import '../../../../shared/widgets/challenge_input_box.dart'; import 'package:haenaem/shared/widgets/app_tag_chip.dart'; -import 'package:haenaem/features/challenge/create/widgets/ai_notice_box.dart'; +// import 'package:haenaem/features/challenge/create/widgets/ai_notice_box.dart'; import 'package:haenaem/features/challenge/create/widgets/plus_button.dart'; -import 'package:haenaem/features/challenge/create/widgets/challenge_select_button.dart'; +// import 'package:haenaem/features/challenge/create/widgets/challenge_select_button.dart'; import 'package:haenaem/features/challenge/create/widgets/challenge_calendar_bottom_sheet.dart'; import 'package:haenaem/features/challenge/create/widgets/challenge_duration_bottom_sheet.dart'; import 'package:haenaem/features/challenge/create/widgets/challenge_frequency_bottom_sheet.dart'; @@ -97,13 +103,10 @@ class _ChallengeCreateScreenState extends ConsumerState { super.dispose(); } - // 챌린지 생성 데이터 제출 준비 로직 - void _submitChallenge() async { - // 1. 데이터 가공 (Swagger 형식에 맞춤) + Map _buildRequestData() { final int duration = int.tryParse(_selectedDuration?.replaceAll('일', '') ?? '0') ?? 0; - // 인증 빈도 매핑 (예: "매일" -> 7, "주 3회" -> 3) int frequency = 7; if (_selectedFrequency != "매일") { frequency = @@ -113,48 +116,48 @@ class _ChallengeCreateScreenState extends ConsumerState { 7; } - // 서버 전송용 태그 데이터 가공 - final tagIds = _selectedTagModels.map((t) => t.id).toList(); - - final requestData = { + return { "title": _nameController.text.trim(), "startDate": _selectedDay != null ? "${_selectedDay!.year}-${_selectedDay!.month.toString().padLeft(2, '0')}-${_selectedDay!.day.toString().padLeft(2, '0')}" : "", "duration": duration, "frequency": frequency, - "tags": tagIds, + "tags": _selectedTagModels.map((t) => t.id).toList(), "description": _descriptionController.text.trim(), - "photoRequired": selectedType == 1, // 사진 필수 여부 (bool) + "photoRequired": selectedType == 1, "challengeVisibility": selectedVisibility == 1 ? "PRIVATE" : (selectedVisibility == 2 ? "PUBLIC" : "FRIENDS_ONLY"), "maxParticipantNumber": 50, }; + } + // 챌린지 생성 데이터 제출 준비 로직 + void _submitChallenge() async { + final requestData = _buildRequestData(); // ✅ 데이터 가공 분리 debugPrint('🚀 서버 전송 데이터: ${jsonEncode(requestData)}'); - // 2. API 호출 final notifier = ref.read(challengeCreateNotifierProvider.notifier); final response = await notifier.create(requestData); - // 3. 결과 처리 if (response != null && mounted) { - // 현황 페이지로 이동하며 데이터 전달 - // pushReplacement를 쓰면 '만들기' 화면이 스택에서 제거되어 뒤로가기를 눌러도 다시 나오지 않습니다. - await Future.delayed(const Duration(seconds: 5)); - debugPrint('✅ 5초 대기 후 이동 시도 - challengeId: ${response.id}'); - debugPrint('✅ 생성된 실제 ID: ${response.id}'); - if (!mounted) return; + ref.read(homeNotifierProvider.notifier).refresh(); + ref.invalidate(myInProgressChallengesProvider); + Navigator.pushReplacement( context, MaterialPageRoute( - builder: (context) => ChallengeMainScreen(challengeId: response.id), + builder: (context) => ChallengeMainScreen( + challengeId: response.id, + challengeTitle: response.title, + challengeLink: response.challengeLink, + isJustCreated: true, + ), ), ); } else if (mounted) { - // 에러 발생 시 처리 (notifier 내부에서 에러가 관리되지만 간단히 추가 가능) ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('챌린지 생성 중 오류가 발생했습니다.'))); diff --git a/lib/features/challenge/create/widgets/ai_notice_box.dart b/lib/features/challenge/create/widgets/ai_notice_box.dart index ab574a7..8ba4be5 100644 --- a/lib/features/challenge/create/widgets/ai_notice_box.dart +++ b/lib/features/challenge/create/widgets/ai_notice_box.dart @@ -12,7 +12,7 @@ class AiNoticeBox extends StatelessWidget { Widget build(BuildContext context) { return Container( width: double.infinity, - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), @@ -30,10 +30,10 @@ class AiNoticeBox extends StatelessWidget { BlendMode.srcIn, ), ), - const SizedBox(width: 7), + const SizedBox(width: 5), Expanded( child: Text( - '정확한 인증을 위해 AI 검증 단계를 거치게 됩니다. \n환경에 따라 인식이 지연되거나 재촬영이 필요할 수 있습니다.', + '정확한 인증을 위해 AI 검증 단계를 거치게 됩니다.\n환경에 따라 인식이 지연되거나 재촬영이 필요할 수 있습니다.', style: AppTypography.c1.copyWith(color: AppColors.gray1), ), ), diff --git a/lib/features/challenge/create/widgets/challenge_tag_bottom_sheet.dart b/lib/features/challenge/create/widgets/challenge_tag_bottom_sheet.dart index 9c4a75d..2d41674 100644 --- a/lib/features/challenge/create/widgets/challenge_tag_bottom_sheet.dart +++ b/lib/features/challenge/create/widgets/challenge_tag_bottom_sheet.dart @@ -3,13 +3,14 @@ import 'package:flutter/material.dart'; import 'package:haenaem/core/theme/app_colors.dart'; import 'package:haenaem/core/theme/app_typography.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; -import 'package:haenaem/features/challenge/model/challenge_model.dart'; +// import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; +import 'package:haenaem/shared/models/tag_model.dart'; +import 'package:haenaem/shared/provider/tag_provider.dart'; +// import 'package:haenaem/features/challenge/models/challenge_model.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:haenaem/features/challenge/create/widgets/plus_button.dart'; import 'package:haenaem/shared/widgets/custom_bottom_sheet.dart'; import 'package:haenaem/shared/widgets/app_tag_chip.dart'; -import 'package:haenaem/shared/models/tag_data.dart'; // 서버에서 태그 목록을 불러와 카테고리별로 표시하고 선택을 관리하는 바텀시트 class ChallengeTagBottomSheet extends ConsumerStatefulWidget { diff --git a/lib/features/challenge/data/challenge_repository.dart b/lib/features/challenge/data/challenge_repository.dart deleted file mode 100644 index a36e255..0000000 --- a/lib/features/challenge/data/challenge_repository.dart +++ /dev/null @@ -1,743 +0,0 @@ -// 최초 작성자: 강선욱 -import 'dart:io'; -import 'package:dio/dio.dart'; -import 'dart:convert'; -import 'package:http_parser/http_parser.dart'; -import 'package:flutter/material.dart'; -import 'package:image/image.dart' as img; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import 'package:haenaem/features/challenge/model/challenge_model.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter/foundation.dart'; -import 'package:haenaem/features/auth/services/auth_service.dart'; -import 'package:haenaem/features/user/model/user_model.dart'; -import 'package:haenaem/core/network/dio_provider.dart'; -part 'challenge_repository.g.dart'; - -// 서버로부터 사용자의 챌린지 데이터를 가져오는 클래스 -class ChallengeRepository { - final Dio _dio; - - ChallengeRepository(this._dio); - - Future getChallengeMainData(String date) async { - try { - // 1. 쿼리 파라미터에 날짜를 담아 호출합니다. - final response = await _dio.get( - '/api/mainHome', - queryParameters: {'date': date}, - ); - - if (response.statusCode == 200) { - // 성공 시 JSON 데이터를 모델로 변환 - // Swagger에 정의된 구조와 ChallengeMainModel.fromJson이 일치해야 합니다. - return ChallengeMainModel.fromJson(response.data); - } else { - throw Exception('데이터를 불러오는데 실패했습니다. (Status: ${response.statusCode})'); - } - } on DioException catch (e) { - // Dio 전용 에러 핸들링 - print('❌ Repository 네트워크 에러: ${e.message}'); - throw Exception('서버 연결 실패: ${e.response?.statusMessage}'); - } catch (e) { - print('❌ Repository 일반 에러: $e'); - throw Exception('알 수 없는 오류 발생'); - } - } - - // 챌린지 상세정보 조회 수정 - Future getChallengeDetail(int challengeId) async { - try { - final response = await _dio.get('api/challenge/$challengeId'); - print("서버 응답 데이터: ${response.data}"); // 데이터 확인 완료! - - // 상세 API는 content 없이 바로 객체가 오므로 response.data를 그대로 사용합니다. - return ChallengeDetailModel.fromJson(response.data); - } catch (e) { - print("상세 조회 에러: $e"); - rethrow; - } - } - - // 챌린지 생성 post 요청 보내기 - Future createChallenge( - Map data, - ) async { - try { - final response = await _dio.post('/api/challenges/create', data: data); - debugPrint('📥 서버 생성 응답 원본: ${response.data}'); - if (response.statusCode == 200 || response.statusCode == 201) { - return ChallengeCreateResponse.fromJson(response.data); - } else { - throw Exception('챌린지 생성 실패'); - } - } on DioException catch (e) { - // 서버가 보내준 상세 에러 본문 출력 - debugPrint('❌ 서버 상세 에러: ${e.response?.data}'); - throw Exception( - '서버 에러: ${e.response?.statusCode} - ${e.response?.data['message'] ?? '잘못된 요청'}', - ); - } - } - - // 챌린지 id를 받아서 서버에 요청을 보내는 함수 - Future getChallengeCalendarData( - int challengeId, - ) async { - try { - // GET /api/challenges/{challengeId}/calendar - final response = await _dio.get('/api/challenges/$challengeId/calendar'); - - if (response.statusCode == 200) { - return ChallengeCalendarModel.fromJson(response.data); - } else { - throw Exception('달력 요약 정보 조회 실패'); - } - } on DioException catch (e) { - debugPrint('❌ 달력 정보 API 에러: ${e.response?.data}'); - throw Exception('네트워크 에러'); - } - } - - //연도(year)와 월(month)을 파라미터로 받아 인증글 목록을 가져오는 함수 - Future> getChallengePosts({ - required int challengeId, - required int year, - required int month, - }) async { - try { - final response = await _dio.get( - '/api/challenges/$challengeId/calendar/posts', - queryParameters: { - 'year': year, - 'month': month, - 'page': 0, // 명세서에 따라 0으로 고정 - }, - ); - - if (response.statusCode == 200) { - // Page 객체의 'content' 리스트를 추출 - final List content = response.data['content'] ?? []; - return content - .map((json) => CertificationPostModel.fromJson(json)) - .toList(); - } else { - throw Exception('인증글 목록 조회 실패'); - } - } on DioException catch (e) { - debugPrint('❌ 인증글 API 에러: ${e.response?.data}'); - return []; // 에러 시 빈 리스트 반환 - } - } - - // 챌린지 달력 사진 가져오기 - Future> getChallengeCalendarPhotos({ - required int challengeId, - required int year, - required int month, - }) async { - try { - final response = await _dio.get( - '/api/challenges/$challengeId/calendar/photos', - queryParameters: {'year': year, 'month': month}, - ); - - if (response.statusCode == 200) { - final List data = response.data; - return data - .map((json) => ChallengeCalendarPhoto.fromJson(json)) - .toList(); - } else { - throw Exception('달력 사진 조회 실패'); - } - } on DioException catch (e) { - debugPrint('❌ 달력 사진 API 에러: ${e.response?.data}'); - return []; - } - } - - // 인증글 상세 정보 가져오기 - Future getArticleDetail(int postId) async { - try { - debugPrint('🚀 [GET Request] /api/articles/$postId'); - - final response = await _dio.get('/api/articles/$postId'); - - if (response.statusCode == 200) { - debugPrint('📥 상세조회 서버 응답 원본: ${response.data}'); - return CertificationPostModel.fromJson(response.data); - } else { - throw Exception('인증글 상세 조회 실패'); - } - } on DioException catch (e) { - debugPrint('❌ 상세 조회 에러: ${e.response?.data}'); - throw Exception('정보를 불러오지 못했습니다.'); - } - } - - // 인증글 생성 - Future createArticle({ - required int challengeId, - required String content, - required List tempImageIds, // 💡 파일 대신 ID 리스트를 받음 - }) async { - try { - // ✨ 이제 Multipart가 아닌 일반 JSON 전송입니다. - final response = await _dio.post( - '/api/articles', - data: { - "content": content, - "challengeId": challengeId, - "tempImageIds": tempImageIds, - }, - ); - - if (response.statusCode == 201) { - return CertificationPostModel.fromJson(response.data); - } else { - throw Exception('인증글 생성 실패: ${response.statusCode}'); - } - } on DioException catch (e) { - debugPrint('❌ 인증글 생성 에러: ${e.response?.data}'); - throw Exception(e.response?.data['message'] ?? '게시글 업로드 실패'); - } - } - - // 인증글 수정 - Future updateArticle({ - required int postId, - required String content, - required List deleteImageIds, - required List tempImageIds, - }) async { - try { - final response = await _dio.patch( - '/api/articles/$postId', - data: { - "content": content, - "deleteImageIds": deleteImageIds, - "tempImageIds": tempImageIds, - }, - ); - if (response.statusCode == 200) { - debugPrint('✅ 인증글 수정 성공: ${response.data}'); - return CertificationPostModel.fromJson(response.data); - } else { - throw Exception('수정 실패: ${response.statusCode}'); - } - } on DioException catch (e) { - debugPrint('❌ 수정 에러 상세: ${e.response?.data}'); - throw Exception(e.response?.data?['message'] ?? '수정 중 오류 발생'); - } - } - - // 인증글 삭제 - Future deleteArticle(int postId) async { - try { - debugPrint('🚀 [DELETE Request] /api/articles/$postId'); - - final response = await _dio.delete('/api/articles/$postId'); - - // 204는 성공을 의미하지만 응답 본문이 없는 상태입니다. - if (response.statusCode != 204 && response.statusCode != 200) { - throw Exception('삭제 실패 (Status: ${response.statusCode})'); - } - debugPrint('✅ 인증글 삭제 성공'); - } on DioException catch (e) { - debugPrint('❌ 인증글 삭제 에러: ${e.response?.data}'); - throw Exception(e.response?.data['message'] ?? '삭제 중 오류가 발생했습니다.'); - } - } - - // 인증 사진 검증 - Future verifyImage(File imageFile, int challengeId) async { - try { - final formData = FormData.fromMap({ - "image": await MultipartFile.fromFile( - imageFile.path, - filename: imageFile.path.split('/').last, - contentType: MediaType('image', 'jpeg'), - ), - }); - - // 💡 queryParameters에 challengeId 전달 - final response = await _dio.post( - '/api/image/verify', - data: formData, - queryParameters: {'challengeId': challengeId}, - ); - - // Swagger에는 204로 되어있으나 응답 바디가 있다면 tempImageId 추출 - if (response.statusCode == 200 || response.statusCode == 204) { - debugPrint('✅ 이미지 검증 및 임시 업로드 성공: ${response.data}'); - return response.data['tempImageId']; - } - return null; - } on DioException catch (e) { - debugPrint('❌ 이미지 검증 에러: ${e.response?.data}'); - return null; - } - } - - // 특정 게시글의 댓글 목록을 가져오는 함수 - Future> getComments({ - required int postId, - int page = 0, - }) async { - try { - final response = await _dio.get( - '/api/articles/$postId/comments', - queryParameters: {'page': page}, - ); - - if (response.statusCode == 200) { - // Page 객체의 'content' 배열을 가져옴 - final List content = response.data['content'] ?? []; - return content.map((json) => ChallengeComment.fromJson(json)).toList(); - } else { - throw Exception('댓글 목록 조회 실패'); - } - } on DioException catch (e) { - debugPrint('❌ 댓글 조회 에러: ${e.response?.data}'); - throw Exception('댓글을 불러오는 중 오류가 발생했습니다.'); - } - } - - // 댓글 생성 - Future createComment({ - required int postId, - required String contents, - }) async { - try { - final Map body = { - "contents": contents, // 명세서 기준 복수형 'contents' - }; - - final response = await _dio.post( - '/api/articles/$postId/comments', - data: body, - ); - - if (response.statusCode != 201) { - throw Exception('댓글 생성 실패 (Status: ${response.statusCode})'); - } - } on DioException catch (e) { - debugPrint('❌ 댓글 생성 에러: ${e.response?.data}'); - throw Exception(e.response?.data['message'] ?? '댓글 작성 중 오류가 발생했습니다.'); - } - } - - // 댓글 삭제 - Future deleteComment(int commentId) async { - try { - debugPrint('🚀 [DELETE Request] /api/comments/$commentId'); - - final response = await _dio.delete('/api/comments/$commentId'); - - // 204 No Content 성공 처리 - if (response.statusCode != 204 && response.statusCode != 200) { - throw Exception('댓글 삭제 실패 (Status: ${response.statusCode})'); - } - debugPrint('✅ 댓글 삭제 성공'); - } on DioException catch (e) { - debugPrint('❌ 댓글 삭제 에러: ${e.response?.data}'); - throw Exception(e.response?.data['message'] ?? '댓글 삭제 중 오류가 발생했습니다.'); - } - } - - // 댓글 수정 - Future updateComment({ - required int commentId, - required String contents, - }) async { - try { - final Map body = {"contents": contents}; - - debugPrint('🚀 [PATCH Request] /api/comments/$commentId'); - - final response = await _dio.patch('/api/comments/$commentId', data: body); - - if (response.statusCode != 204 && response.statusCode != 200) { - throw Exception('댓글 수정 실패 (Status: ${response.statusCode})'); - } - debugPrint('✅ 댓글 수정 성공'); - } on DioException catch (e) { - debugPrint('❌ 댓글 수정 에러: ${e.response?.data}'); - throw Exception(e.response?.data['message'] ?? '댓글 수정 중 오류가 발생했습니다.'); - } - } - - // 인증글 좋아요 토글 - Future toggleLike({ - required int postId, - required bool isCurrentlyLiked, - }) async { - try { - if (isCurrentlyLiked) { - // 이미 좋아요 상태라면 -> 취소 - debugPrint('🚀 [DELETE Request] /api/article/$postId/like'); - await _dio.delete('/api/article/$postId/like'); - } else { - // 좋아요가 아니라면 -> 등록 - debugPrint('🚀 [POST Request] /api/article/$postId/like'); - await _dio.post('/api/article/$postId/like'); - } - } on DioException catch (e) { - debugPrint('❌ 좋아요 토글 에러: ${e.response?.data}'); - throw Exception(e.response?.data?['message'] ?? '좋아요 처리 중 오류 발생'); - } - } - - // 챌린지 삭제 - Future deleteChallenge(int challengeId) async { - try { - debugPrint('🚀 [DELETE Request] /api/challenges/$challengeId'); - - final response = await _dio.delete('/api/challenges/$challengeId'); - - // 명세서 상 성공 시 204 반환 - if (response.statusCode != 204 && response.statusCode != 200) { - throw Exception('챌린지 삭제 실패 (Status: ${response.statusCode})'); - } - debugPrint('✅ 챌린지 삭제 성공'); - } on DioException catch (e) { - debugPrint('❌ 챌린지 삭제 API 에러: ${e.response?.data}'); - throw Exception(e.response?.data['message'] ?? '챌린지 삭제 중 오류가 발생했습니다.'); - } - } - - Future leaveChallenge(int challengeId) async { - try { - // 1. API 경로 설정: /api/challenges/{challengeId}/leaveChallenge - final response = await _dio.delete( - '/api/challenges/$challengeId/leaveChallenge', - ); - - // 2. 응답 상태 코드 확인 (서버 설계에 따라 200 또는 204) - if (response.statusCode == 200 || response.statusCode == 204) { - debugPrint("챌린지 퇴장 성공: $challengeId"); - } else { - // 서버에서 에러 메시지를 주는 경우 처리 - throw Exception(response.data['message'] ?? '챌린지 나가기에 실패했습니다.'); - } - } catch (e) { - debugPrint("챌린지 퇴장 API 에러: $e"); - rethrow; // Provider의 AsyncValue.guard에서 잡을 수 있도록 던져줍니다. - } - } - - // 내 페이지 - 나의 챌린지 - 진행 중인 챌린지 - Future> getInProgressChallenges({ - required bool onlyTwo, - }) async { - try { - final response = await _dio.get( - '/api/challenges/my/inProgress', - queryParameters: {'onlyTwo': onlyTwo}, - ); - if (response.statusCode == 200) { - return (response.data as List) - .map((e) => ChallengeInProgressModel.fromJson(e)) - .toList(); - } - throw Exception('챌린지 로드 실패'); - } on DioException catch (e) { - throw Exception('네트워크 에러: ${e.message}'); - } - } - - // 내 페이지 - 나의 챌린지 - 완료한 챌린지 - Future> getSuccessChallenges({ - required bool onlyTwo, - }) async { - try { - final response = await _dio.get( - '/api/challenges/my/success', - queryParameters: {'onlyTwo': onlyTwo}, - ); - if (response.statusCode == 200) { - return (response.data as List) - .map((e) => ChallengeInProgressModel.fromJson(e)) - .toList(); - } - throw Exception('완료된 챌린지 로드 실패'); - } on DioException catch (e) { - throw Exception('네트워크 에러: ${e.message}'); - } - } - - // 내페이지 - 나의 챌린지 - 실패한 챌린지 - Future> getFailedChallenges({ - required bool onlyTwo, - }) async { - try { - final response = await _dio.get( - '/api/challenges/my/fail', // 💡 실패 챌린지 엔드포인트 - queryParameters: {'onlyTwo': onlyTwo}, - ); - if (response.statusCode == 200) { - return (response.data as List) - .map((e) => ChallengeInProgressModel.fromJson(e)) - .toList(); - } - throw Exception('실패한 챌린지 로드 실패'); - } on DioException catch (e) { - throw Exception('네트워크 에러: ${e.message}'); - } - } - - // 챌린지 검색 API - Future> searchChallenges({ - required String keyword, - int page = 0, - }) async { - try { - final response = await _dio.get( - '/api/challenges/search', - queryParameters: {'keyword': keyword, 'page': page}, - ); - - if (response.statusCode == 200) { - // API 명세상 페이징 객체 내부의 'content' 리스트를 파싱합니다. - final List content = response.data['content'] ?? []; - return content - .map((json) => SearchChallengeModel.fromJson(json)) - .toList(); - } else { - throw Exception('검색 결과 조회 실패'); - } - } on DioException catch (e) { - debugPrint('❌ 검색 API 에러: ${e.response?.data}'); - throw Exception('검색 중 오류가 발생했습니다.'); - } - } - - // 챌린지 참여하기 api - Future participateChallenge(int challengeId) async { - try { - debugPrint('🚀 [POST Request] /api/challenges/$challengeId/participate'); - - final response = await _dio.post( - '/api/challenges/$challengeId/participate', - ); - - if (response.statusCode != 200 && response.statusCode != 201) { - throw Exception('챌린지 참여에 실패했습니다.'); - } - debugPrint('✅ 챌린지 참여 성공'); - } on DioException catch (e) { - debugPrint('❌ 챌린지 참여 API 에러: ${e.response?.data}'); - throw Exception( - e.response?.data?['message'] ?? '이미 참여 중이거나 참여할 수 없는 챌린지입니다.', - ); - } - } - - /// 챌린지 친구 초대 - /// API: POST /api/challenges/{challengeId}/invite/{friendNickname} - Future inviteFriend(int challengeId, String friendNickname) async { - try { - // POST 요청 전송 - await _dio.post('/api/challenges/$challengeId/invite/$friendNickname'); - } catch (e) { - // 에러 발생 시 호출한 곳(UI)으로 에러를 던짐 - rethrow; - } - } - - /* - // 챌린지 초대 탭 정보 조회 (링크 + 친구목록 + 초대여부) - // GET /api/challenges/{challengeId}/invite - Future getChallengeInviteInfo( - int challengeId, - ) async { - try { - final response = await _dio.get('/api/challenges/$challengeId/invite'); - - if (response.statusCode == 200) { - return ChallengeInviteResponse.fromJson(response.data); - } else { - throw Exception('초대 정보 조회 실패'); - } - } catch (e) { - rethrow; - } - } - */ - // 디버깅 모드 - Future getChallengeInviteInfo( - int challengeId, - ) async { - try { - debugPrint('🚀 [API 요청 시작] /api/challenges/$challengeId/invite'); - - final response = await _dio.get('/api/challenges/$challengeId/invite'); - - debugPrint('📥 [API 응답 코드] ${response.statusCode}'); - debugPrint('📦 [API 응답 데이터 원본] ${response.data}'); // ★ 여기가 핵심! - - if (response.statusCode == 200) { - try { - final result = ChallengeInviteResponse.fromJson(response.data); - debugPrint( - '✅ [파싱 성공] 친구 수: ${result.friends.length}명 / 링크: ${result.challengeLink}', - ); - return result; - } catch (e) { - debugPrint('⚠️ [파싱 에러] 모델 변환 실패: $e'); - debugPrint('🔍 [파싱 실패 데이터] ${response.data}'); - rethrow; - } - } else { - throw Exception('초대 정보 조회 실패 (Status: ${response.statusCode})'); - } - } catch (e) { - debugPrint('❌ [API 에러 발생] $e'); - rethrow; - } - } - - // 챌린지 멤버 조회 API - // page: 필수 (0부터 시작) - // nickname: 선택 (검색어) - Future> getChallengeMembers( - int challengeId, { - int page = 0, - String? nickname, - }) async { - print( - '🔥 [API Request] 챌린지($challengeId) 멤버 조회 요청 (Page: $page, Nickname: $nickname)', - ); - - try { - // 1. 쿼리 파라미터 구성 - final Map queryParams = { - 'page': page, - // 'size': 20, // 명세서에는 없지만, 보통 size도 같이 보냅니다. 필요시 주석 해제하세요. - }; - - // 닉네임이 있을 경우에만 파라미터에 추가 - if (nickname != null && nickname.isNotEmpty) { - queryParams['nickname'] = nickname; - } - - // 2. GET 요청 보내기 - final response = await _dio.get( - '/api/challenges/$challengeId/members', - queryParameters: queryParams, - ); - - print('✨ [API Response] Status: ${response.statusCode}'); - // 디버깅을 위해 서버가 주는 데이터 구조를 꼭 확인하세요! - print('📦 [API Response] Data: ${response.data}'); - - if (response.statusCode == 200) { - final data = response.data; - List list = []; - - // 3. 응답 데이터 파싱 (PageChallengeMemberResponse 대응) - if (data is Map && data.containsKey('content')) { - // Case A: Spring Boot Page 객체인 경우 ({ "content": [...], "totalPages": ... }) - list = data['content'] as List; - print('✅ [Parsing] Page 객체 감지됨. content 리스트 추출.'); - } else if (data is List) { - // Case B: 그냥 리스트인 경우 ([...]) - list = data; - print('✅ [Parsing] 단순 리스트 감지됨.'); - } else { - // Case C: 예상치 못한 구조 - print('⚠️ [Parsing Warning] 예상치 못한 데이터 구조입니다. 확인이 필요합니다.'); - // 일단 data 자체를 리스트로 시도하거나 빈 리스트 반환 - if (data is List) list = data; - } - - final members = list.map((e) => ChallengeMember.fromJson(e)).toList(); - print('✅ [Success] 총 ${members.length}명의 멤버 로드 완료'); - - return members; - } else { - throw Exception('멤버 조회 실패 (Status: ${response.statusCode})'); - } - } on DioException catch (e) { - print('🚨 [DioError] ${e.response?.statusCode} / ${e.response?.data}'); - throw Exception(e.response?.data['message'] ?? '서버 요청 실패'); - } catch (e) { - print('🚫 [Exception] $e'); - throw Exception('멤버 정보를 불러오는데 실패했습니다.'); - } - } - - // 챌린지장 위임 api - Future delegateChallengeOwner( - int challengeId, - int delegateMemberId, - ) async { - try { - debugPrint( - '🚀 [POST Request] /api/challenges/$challengeId/owner/delegate', - ); - - final response = await _dio.post( - '/api/challenges/$challengeId/owner/delegate', - data: {'delegateMemberId': delegateMemberId}, - ); - - if (response.statusCode != 204 && response.statusCode != 200) { - throw Exception('방장 위임 실패 (Status: ${response.statusCode})'); - } - debugPrint('✅ 방장 위임 성공'); - } on DioException catch (e) { - debugPrint('❌ 방장 위임 API 에러: ${e.response?.data}'); - throw Exception(e.response?.data['message'] ?? '위임 처리 중 오류가 발생했습니다.'); - } - } - - // 챌린지 멤버 추방 API - Future kickMember(int challengeId, int targetUserId) async { - print('🔥 [API Request] 멤버 추방 요청: 챌린지 $challengeId, 타겟 $targetUserId'); - - try { - final response = await _dio.post( - '/api/challenges/$challengeId/members/kick', - data: { - // [체크 필요] 백엔드 DTO의 필드명과 일치해야 합니다. (예: targetUserId, memberId, kickUserId 등) - 'targetUserId': targetUserId, - }, - ); - - if (response.statusCode == 204) { - print('✅ [Success] 멤버 추방 성공'); - return; - } else { - throw Exception('추방 실패 (Status: ${response.statusCode})'); - } - } on DioException catch (e) { - print('🚨 [DioError] ${e.response?.data}'); - throw Exception(e.response?.data['message'] ?? '서버 통신 오류'); - } catch (e) { - print('🚫 [Exception] $e'); - throw Exception('알 수 없는 오류가 발생했습니다.'); - } - } -} - -// Riverpod Provider 설정 -@riverpod -ChallengeRepository challengeRepository(ChallengeRepositoryRef ref) { - // 1. 공통 dioProvider를 감시(watch)하여 인스턴스를 가져옵니다. - final dio = ref.watch(dioProvider); - - // 2. 이미 모든 설정(BaseURL, 토큰 주입 등)이 끝난 dio를 넘겨줍니다. - return ChallengeRepository(dio); -} - -// 챌린지 초대 정보 Provider -// ChallengeInviteContent에서 ref.watch(challengeInviteInfoProvider(challengeId))로 사용 -// .autoDispose 추가 -// TODO: autoDispose 제거 고려 -final challengeInviteInfoProvider = FutureProvider.autoDispose - .family((ref, challengeId) async { - final repo = ref.watch(challengeRepositoryProvider); - return await repo.getChallengeInviteInfo(challengeId); - }); diff --git a/lib/features/challenge/detail/data/challenge_detail_repository.dart b/lib/features/challenge/detail/data/challenge_detail_repository.dart new file mode 100644 index 0000000..9d90b88 --- /dev/null +++ b/lib/features/challenge/detail/data/challenge_detail_repository.dart @@ -0,0 +1,38 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:haenaem/shared/models/challenge_detail.dart'; +import 'package:haenaem/core/network/dio_provider.dart'; + +part 'challenge_detail_repository.g.dart'; + +// 최초 작성자 : 강선욱 +class ChallengeDetailRepository { + final Dio _dio; + + ChallengeDetailRepository(this._dio); + + // 챌린지 상세 정보 조회 + Future getChallengeDetail(int challengeId) async { + try { + final response = await _dio.get('/api/challenge/$challengeId'); + + if (response.statusCode == 200) { + debugPrint('📦 챌린지 상세 응답: ${response.data}'); + return ChallengeDetail.fromJson(response.data as Map); + } else { + throw Exception('챌린지 상세 조회 실패'); + } + } on DioException catch (e) { + debugPrint('❌ 챌린지 상세 API 에러: ${e.response?.data}'); + throw Exception('챌린지 정보를 불러오지 못했습니다.'); + } + } +} + +@riverpod +ChallengeDetailRepository challengeDetailRepository(Ref ref) { + final dio = ref.watch(dioProvider); + return ChallengeDetailRepository(dio); +} diff --git a/lib/features/challenge/detail/data/challenge_detail_repository.g.dart b/lib/features/challenge/detail/data/challenge_detail_repository.g.dart new file mode 100644 index 0000000..fb94c20 --- /dev/null +++ b/lib/features/challenge/detail/data/challenge_detail_repository.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'challenge_detail_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$challengeDetailRepositoryHash() => + r'35839152b7ee466aa92d997b5a78dc80fe68f52d'; + +/// See also [challengeDetailRepository]. +@ProviderFor(challengeDetailRepository) +final challengeDetailRepositoryProvider = + AutoDisposeProvider.internal( + challengeDetailRepository, + name: r'challengeDetailRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$challengeDetailRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef ChallengeDetailRepositoryRef = + AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/challenge/detail/data/challenge_leave_repository.dart b/lib/features/challenge/detail/data/challenge_leave_repository.dart new file mode 100644 index 0000000..3de8cfc --- /dev/null +++ b/lib/features/challenge/detail/data/challenge_leave_repository.dart @@ -0,0 +1,40 @@ +// 최초 작성자: 정승빈 +import 'package:dio/dio.dart'; +import 'package:haenaem/core/network/dio_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'challenge_leave_repository.g.dart'; + +@riverpod +ChallengeLeaveRepository challengeLeaveRepository( + ChallengeLeaveRepositoryRef ref, +) { + final dio = ref.watch(dioProvider); + return ChallengeLeaveRepository(dio); +} + +class ChallengeLeaveRepository { + final Dio _dio; + + ChallengeLeaveRepository(this._dio); + + /// 챌린지 나가기 API + /// - [challengeId]: 나갈 챌린지 ID + Future leaveChallenge(int challengeId) async { + try { + final response = await _dio.delete( + '/api/challenges/$challengeId/leaveChallenge', + ); + + if (response.statusCode == 200 || response.statusCode == 204) { + debugPrint('✅ [Success] 챌린지 퇴장 성공: $challengeId'); + } else { + throw Exception(response.data['message'] ?? '챌린지 나가기에 실패했습니다.'); + } + } catch (e) { + debugPrint('🚫 [Exception] 챌린지 퇴장 API 에러: $e'); + rethrow; + } + } +} diff --git a/lib/features/challenge/detail/data/challenge_leave_repository.g.dart b/lib/features/challenge/detail/data/challenge_leave_repository.g.dart new file mode 100644 index 0000000..8ea77d9 --- /dev/null +++ b/lib/features/challenge/detail/data/challenge_leave_repository.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'challenge_leave_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$challengeLeaveRepositoryHash() => + r'5110fb577d6696fa4d27c2274ddc85a3f467081f'; + +/// See also [challengeLeaveRepository]. +@ProviderFor(challengeLeaveRepository) +final challengeLeaveRepositoryProvider = + AutoDisposeProvider.internal( + challengeLeaveRepository, + name: r'challengeLeaveRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$challengeLeaveRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef ChallengeLeaveRepositoryRef = + AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/challenge/detail/data/ranking_repository.dart b/lib/features/challenge/detail/data/ranking_repository.dart index 2bb13eb..442bace 100644 --- a/lib/features/challenge/detail/data/ranking_repository.dart +++ b/lib/features/challenge/detail/data/ranking_repository.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:haenaem/core/network/dio_provider.dart'; -import '../model/ranking_model.dart'; +import '../models/ranking_model.dart'; part 'ranking_repository.g.dart'; diff --git a/lib/features/challenge/detail/data/stats_repository.dart b/lib/features/challenge/detail/data/stats_repository.dart new file mode 100644 index 0000000..9f3a58f --- /dev/null +++ b/lib/features/challenge/detail/data/stats_repository.dart @@ -0,0 +1,25 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:haenaem/core/network/dio_provider.dart'; +import '../models/challenge_stats.dart'; + +part 'stats_repository.g.dart'; + +class StatsRepository { + final Dio _dio; + + StatsRepository(this._dio); + + // 실제 API 연동 로직 (Dio 사용 예시) + Future getChallengeStats(int challengeId) async { + final response = await _dio.get('/api/challenges/$challengeId/calendar'); + return ChallengeStats.fromJson(response.data); + } +} + +@riverpod +StatsRepository statsRepository(Ref ref) { + final dio = ref.watch(dioProvider); + return StatsRepository(dio); +} diff --git a/lib/features/challenge/detail/data/stats_repository.g.dart b/lib/features/challenge/detail/data/stats_repository.g.dart new file mode 100644 index 0000000..b2e519e --- /dev/null +++ b/lib/features/challenge/detail/data/stats_repository.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'stats_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$statsRepositoryHash() => r'0ddd170cf84c35353413a686df99dfdcd859a7c1'; + +/// See also [statsRepository]. +@ProviderFor(statsRepository) +final statsRepositoryProvider = AutoDisposeProvider.internal( + statsRepository, + name: r'statsRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$statsRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef StatsRepositoryRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/challenge/detail/models/calendar_post.dart b/lib/features/challenge/detail/models/calendar_post.dart new file mode 100644 index 0000000..b06b72f --- /dev/null +++ b/lib/features/challenge/detail/models/calendar_post.dart @@ -0,0 +1,26 @@ +// 최초 작성자 : 강선욱 +// 캘린더 달력 셀 및 인증글 목록 렌더링에 필요한 최소 데이터 모델 +// 상세 페이지 진입 시에는 postId만 넘기고 Post 모델을 별도로 조회 + +class CalendarPost { + final int postId; + final String postDate; + final String? imageUrl; + final String content; + + const CalendarPost({ + required this.postId, + required this.postDate, + this.imageUrl, + required this.content, + }); + + factory CalendarPost.fromJson(Map json) { + return CalendarPost( + postId: json['postId'] as int, + postDate: json['postDate'] as String, + imageUrl: json['imageUrl'] as String?, + content: json['content'] as String, + ); + } +} diff --git a/lib/features/challenge/detail/models/challenge_stats.dart b/lib/features/challenge/detail/models/challenge_stats.dart new file mode 100644 index 0000000..55c87a8 --- /dev/null +++ b/lib/features/challenge/detail/models/challenge_stats.dart @@ -0,0 +1,19 @@ +// 최초 작성자: 강선욱 +// 챌린지 총 인증 횟수, 연속 인증 횟수 관리 모델 + +class ChallengeStats { + final int totalSuccessDays; + final int currentStreakDays; + + ChallengeStats({ + required this.totalSuccessDays, + required this.currentStreakDays, + }); + + factory ChallengeStats.fromJson(Map json) { + return ChallengeStats( + totalSuccessDays: json['totalSuccessDays'] ?? 0, + currentStreakDays: json['currentStreakDays'] ?? 0, + ); + } +} diff --git a/lib/features/challenge/detail/model/ranking_model.dart b/lib/features/challenge/detail/models/ranking_model.dart similarity index 87% rename from lib/features/challenge/detail/model/ranking_model.dart rename to lib/features/challenge/detail/models/ranking_model.dart index eb98c93..dec0b85 100644 --- a/lib/features/challenge/detail/model/ranking_model.dart +++ b/lib/features/challenge/detail/models/ranking_model.dart @@ -1,3 +1,4 @@ +@Deprecated('challenge/models/rank_card.dart에 정의된 모델을 대신 사용') class RankingResponse { final List topRankings; final RankingUser myRanking; @@ -14,6 +15,7 @@ class RankingResponse { } } +@Deprecated('challenge/models/rank_card.dart에 정의된 모델을 대신 사용') class RankingUser { final int userId; final String nickname; diff --git a/lib/features/challenge/detail/provider/challenge_leave_provider.dart b/lib/features/challenge/detail/provider/challenge_leave_provider.dart new file mode 100644 index 0000000..aac8141 --- /dev/null +++ b/lib/features/challenge/detail/provider/challenge_leave_provider.dart @@ -0,0 +1,36 @@ +// 최초 작성자: 정승빈 +library; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../data/challenge_leave_repository.dart'; +import 'package:haenaem/shared/provider/home_provider.dart'; +import 'package:haenaem/features/user/provider/my_challenge_provider.dart'; + +part 'challenge_leave_provider.g.dart'; + +@riverpod +class ChallengeLeaveNotifier extends _$ChallengeLeaveNotifier { + @override + AsyncValue build() => const AsyncValue.data(null); + + // 일반 멤버용 + Future leaveChallenge(int challengeId) async { + state = const AsyncValue.loading(); + + final result = await AsyncValue.guard( + () => ref + .read(challengeLeaveRepositoryProvider) + .leaveChallenge(challengeId), + ); + + state = result; + + if (!result.hasError) { + ref.invalidate(homeNotifierProvider); // 홈 리스트 갱신 + ref.invalidate(myInProgressChallengesProvider); // 내 페이지 리스트 갱신 + return true; + } + + return false; + } +} diff --git a/lib/features/challenge/detail/provider/challenge_leave_provider.g.dart b/lib/features/challenge/detail/provider/challenge_leave_provider.g.dart new file mode 100644 index 0000000..5a441b5 --- /dev/null +++ b/lib/features/challenge/detail/provider/challenge_leave_provider.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'challenge_leave_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$challengeLeaveNotifierHash() => + r'458fc44e27dd35161833c775570cd0ba2bf317ea'; + +/// See also [ChallengeLeaveNotifier]. +@ProviderFor(ChallengeLeaveNotifier) +final challengeLeaveNotifierProvider = + AutoDisposeNotifierProvider< + ChallengeLeaveNotifier, + AsyncValue + >.internal( + ChallengeLeaveNotifier.new, + name: r'challengeLeaveNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$challengeLeaveNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$ChallengeLeaveNotifier = AutoDisposeNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/challenge/detail/provider/ranking_provider.dart b/lib/features/challenge/detail/provider/ranking_provider.dart index 96e25f1..2591ccd 100644 --- a/lib/features/challenge/detail/provider/ranking_provider.dart +++ b/lib/features/challenge/detail/provider/ranking_provider.dart @@ -3,7 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../data/ranking_repository.dart'; -import '../model/ranking_model.dart'; +import '../models/ranking_model.dart'; part 'ranking_provider.g.dart'; diff --git a/lib/features/challenge/detail/provider/stats_provider.dart b/lib/features/challenge/detail/provider/stats_provider.dart new file mode 100644 index 0000000..e1d0c51 --- /dev/null +++ b/lib/features/challenge/detail/provider/stats_provider.dart @@ -0,0 +1,12 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../data/stats_repository.dart'; +import '../models/challenge_stats.dart'; + +part 'stats_provider.g.dart'; + +@riverpod +Future challengeStats(Ref ref, int challengeId) async { + final repository = ref.watch(statsRepositoryProvider); + return repository.getChallengeStats(challengeId); +} diff --git a/lib/features/challenge/detail/provider/stats_provider.g.dart b/lib/features/challenge/detail/provider/stats_provider.g.dart new file mode 100644 index 0000000..fcf3be3 --- /dev/null +++ b/lib/features/challenge/detail/provider/stats_provider.g.dart @@ -0,0 +1,151 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'stats_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$challengeStatsHash() => r'ab18d5fc2d586a987b36fe8fec664ea5a49f175b'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [challengeStats]. +@ProviderFor(challengeStats) +const challengeStatsProvider = ChallengeStatsFamily(); + +/// See also [challengeStats]. +class ChallengeStatsFamily extends Family> { + /// See also [challengeStats]. + const ChallengeStatsFamily(); + + /// See also [challengeStats]. + ChallengeStatsProvider call(int challengeId) { + return ChallengeStatsProvider(challengeId); + } + + @override + ChallengeStatsProvider getProviderOverride( + covariant ChallengeStatsProvider provider, + ) { + return call(provider.challengeId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'challengeStatsProvider'; +} + +/// See also [challengeStats]. +class ChallengeStatsProvider extends AutoDisposeFutureProvider { + /// See also [challengeStats]. + ChallengeStatsProvider(int challengeId) + : this._internal( + (ref) => challengeStats(ref as ChallengeStatsRef, challengeId), + from: challengeStatsProvider, + name: r'challengeStatsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$challengeStatsHash, + dependencies: ChallengeStatsFamily._dependencies, + allTransitiveDependencies: + ChallengeStatsFamily._allTransitiveDependencies, + challengeId: challengeId, + ); + + ChallengeStatsProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.challengeId, + }) : super.internal(); + + final int challengeId; + + @override + Override overrideWith( + FutureOr Function(ChallengeStatsRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: ChallengeStatsProvider._internal( + (ref) => create(ref as ChallengeStatsRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + challengeId: challengeId, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _ChallengeStatsProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is ChallengeStatsProvider && other.challengeId == challengeId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, challengeId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin ChallengeStatsRef on AutoDisposeFutureProviderRef { + /// The parameter `challengeId` of this provider. + int get challengeId; +} + +class _ChallengeStatsProviderElement + extends AutoDisposeFutureProviderElement + with ChallengeStatsRef { + _ChallengeStatsProviderElement(super.provider); + + @override + int get challengeId => (origin as ChallengeStatsProvider).challengeId; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/challenge/detail/screens/challenge_main_screen.dart b/lib/features/challenge/detail/screens/challenge_main_screen.dart index d084976..b915edd 100644 --- a/lib/features/challenge/detail/screens/challenge_main_screen.dart +++ b/lib/features/challenge/detail/screens/challenge_main_screen.dart @@ -4,13 +4,16 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:haenaem/core/theme/app_colors.dart'; import 'package:haenaem/core/theme/app_typography.dart'; -import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; -import 'package:haenaem/features/challenge/widgets/challenge_popup_menu.dart'; -import 'package:haenaem/features/challenge/model/challenge_model.dart'; -import 'package:haenaem/features/challenge/widgets/challenge_create_success_dialog.dart'; +// import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; +import 'package:haenaem/features/challenge/detail/widgets/challenge_popup_menu.dart'; +// import 'package:haenaem/features/challenge/models/challenge_model.dart'; +import 'package:haenaem/features/challenge/detail/widgets/challenge_create_success_dialog.dart'; import 'package:haenaem/shared/widgets/bottom_action_button.dart'; +import 'package:haenaem/shared/widgets/custom_tab_bar.dart'; import 'package:haenaem/features/challenge/verification/screens/challenge_verification_screen.dart'; import 'package:haenaem/features/challenge/detail/screens/member_ranking_screen.dart'; +import 'package:haenaem/features/user/provider/user_provider.dart'; +import 'package:haenaem/shared/provider/challenge_detail_provider.dart'; // 분리된 뷰 파일들 (아래 2번 단계에서 생성/수정할 파일들) import 'package:haenaem/features/challenge/detail/views/calendar_view.dart'; @@ -20,15 +23,18 @@ import 'package:haenaem/features/challenge/detail/views/member_view.dart'; class ChallengeMainScreen extends ConsumerStatefulWidget { final int challengeId; final String? challengeTitle; + final int streakCount; final bool isJustCreated; - final ChallengeCreateResponse? createdData; + final String challengeLink; const ChallengeMainScreen({ super.key, required this.challengeId, this.challengeTitle, + // 새로 가입, 생성한 챌린지는 streakCount가 0 + this.streakCount = 0, this.isJustCreated = false, - this.createdData, + this.challengeLink = '', }); @override @@ -36,9 +42,8 @@ class ChallengeMainScreen extends ConsumerStatefulWidget { _ChallengeDetailScreenState(); } -class _ChallengeDetailScreenState extends ConsumerState - with SingleTickerProviderStateMixin { - late TabController _tabController; +class _ChallengeDetailScreenState extends ConsumerState { + int _currentTabIndex = 1; // 스크롤 컨트롤러들 final ScrollController _infoScrollController = ScrollController(); @@ -48,33 +53,17 @@ class _ChallengeDetailScreenState extends ConsumerState @override void initState() { super.initState(); - // 탭 3개: 소개(0), 내 현황(1), 멤버 현황(2) - _tabController = TabController(length: 3, vsync: this, initialIndex: 1); - - _tabController.addListener(() { - if (!_tabController.indexIsChanging) { - setState(() {}); // 인덱스 변경 완료 시 UI 업데이트 - } - }); // 챌린지 생성 직후라면 생성 성공 다이얼로그 실행 - if (widget.isJustCreated && widget.createdData != null) { + if (widget.isJustCreated) { // 프레임이 그려진 직후에 다이얼로그를 띄우기 위해 postFrameCallback 사용 WidgetsBinding.instance.addPostFrameCallback((_) { showDialog( context: context, barrierColor: const Color(0x7F1A1D1B), builder: (context) => ChallengeCreateSuccessDialog( - // [수정 1] friends 파라미터 삭제 (이제 필요 없음) - // friends: widget.createdData!.friends, - - // [수정 2] challengeId 추가 (필수) - // 주의: createdData 객체 안에 있는 ID 변수명을 정확히 적어주세요. (예: .id 또는 .challengeId) - // challengeId: widget.createdData!.id, - - // // 기존 유지 - // challengeLink: widget.createdData!.challengeLink, - createdData: widget.createdData!, + challengeId: widget.challengeId, + challengeLink: widget.challengeLink, ), ); }); @@ -83,28 +72,16 @@ class _ChallengeDetailScreenState extends ConsumerState @override void dispose() { - _tabController.dispose(); _infoScrollController.dispose(); _calendarScrollController.dispose(); _memberScrollController.dispose(); super.dispose(); } - void _scrollToTop(ScrollController controller) { - if (controller.hasClients) { - controller.animateTo( - 0, - duration: const Duration(milliseconds: 600), - curve: Curves.easeInOut, - ); - } - } - @override Widget build(BuildContext context) { - // 공통 데이터 로드 (방장 여부 등 확인용) - final summaryAsync = ref.watch( - challengeCalendarDataProvider(widget.challengeId), + final detailAsync = ref.watch( + challengeDetailProvider(challengeId: widget.challengeId), ); return Scaffold( @@ -120,47 +97,35 @@ class _ChallengeDetailScreenState extends ConsumerState width: 24, ), ), - title: summaryAsync.when( - // 1. 데이터가 로드되었을 때 (이미 widget에 있는 타이틀 사용) - data: (data) => - Text(widget.challengeTitle ?? "챌린지 상세", style: AppTypography.h3), - - // 2. 로딩 중일 때 - loading: () => - Text(widget.challengeTitle ?? "챌린지 상세", style: AppTypography.h3), - - // 3. 에러가 발생했을 때 - error: (_, __) => - Text(widget.challengeTitle ?? "챌린지 상세", style: AppTypography.h3), - ), + title: Text(widget.challengeTitle ?? "챌린지 상세", style: AppTypography.h3), centerTitle: true, actions: [ - summaryAsync.when( - data: (data) => ChallengePopupMenu( - isHost: data.challengeOwner, - challengeId: widget.challengeId, - ), + detailAsync.when( + data: (detail) { + final myInfo = ref.watch(currentUserProvider); + final bool isHost = detail.leader.id == myInfo?.id; + + return ChallengePopupMenu( + isHost: isHost, + challengeId: widget.challengeId, + ); + }, loading: () => const SizedBox(), error: (_, __) => const SizedBox(), ), ], - bottom: TabBar( - controller: _tabController, - labelColor: AppColors.primaryAble, - unselectedLabelColor: AppColors.gray2, - indicatorColor: AppColors.primaryAble, - indicatorWeight: 1, - indicatorSize: TabBarIndicatorSize.tab, - labelStyle: AppTypography.b1.copyWith(color: AppColors.primaryAble), - tabs: const [ - Tab(text: '소개'), - Tab(text: '내 현황'), - Tab(text: '멤버 현황'), - ], - ), ), - body: TabBarView( - controller: _tabController, + body: CustomTabBar( + initialIndex: 1, + tabs: const ['소개', '내 현황', '멤버 현황'], + scrollControllers: [ + _infoScrollController, + _calendarScrollController, + _memberScrollController, + ], + onTabChanged: (index) { + setState(() => _currentTabIndex = index); + }, children: [ InformationView( challengeId: widget.challengeId, @@ -168,6 +133,7 @@ class _ChallengeDetailScreenState extends ConsumerState ), CalendarView( challengeId: widget.challengeId, + streakCount: widget.streakCount, scrollController: _calendarScrollController, ), MemberView( @@ -181,26 +147,40 @@ class _ChallengeDetailScreenState extends ConsumerState } Widget _buildBottomButton() { - // 0: 소개, 1: 내 현황 -> '인증하기' - // 2: 멤버 현황 -> '내 순위 확인하기' - final bool isMemberTab = _tabController.index == 2; + final myInfo = ref.watch(currentUserProvider); + final detailAsync = ref.watch( + challengeDetailProvider(challengeId: widget.challengeId), + ); + + // .value 대신 AsyncValue 상태를 직접 확인 + final detail = detailAsync.value; + + final bool isMemberTab = _currentTabIndex == 2; + + // 디버깅용 로그 (콘솔에서 확인해 보세요) + if (detail != null) { + debugPrint('내 ID: ${myInfo?.id}'); + debugPrint( + '오늘 성공 유저 ID들: ${detail.todaySuccessUsers.map((e) => e.id).toList()}', + ); + } + + // 데이터가 로딩 중이면 detail이 null이므로 hasDoneToday는 false가 됨 + final bool hasDoneToday = + detail?.todaySuccessUsers.any((user) { + // 타입 이슈 방지를 위해 toString()으로 비교하거나 정확한 필드 확인 + return user.id.toString() == myInfo?.id.toString(); + }) ?? + false; return BottomActionButton( - // 1. 텍스트 분기 text: isMemberTab ? '내 순위 확인하기' : '인증하기', - - // 2. 배경색: 멤버 탭이면 흰색, 아니면 기본색(초록) backgroundColor: isMemberTab ? Colors.white : AppColors.primaryAble, - - // 3. 글자색: 멤버 탭이면 초록색, 아니면 흰색 textColor: isMemberTab ? AppColors.primaryAble : Colors.white, - - // 4. 테두리색: 멤버 탭일 때만 초록색 테두리 추가 borderColor: isMemberTab ? AppColors.primaryAble : null, - onPressed: () { if (isMemberTab) { - // 랭킹 페이지 이동 로직 + // 멤버 탭: 랭킹 페이지 이동 Navigator.push( context, MaterialPageRoute( @@ -209,26 +189,66 @@ class _ChallengeDetailScreenState extends ConsumerState ), ); } else { - // 인증 페이지 이동 로직 - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - ChallengeVerificationScreen(challengeId: widget.challengeId), - ), - ); + // 소개/내 현황 탭: 오늘 이미 인증했는지 확인 + if (hasDoneToday) { + // 💡 이미 인증한 경우 안내 문구 노출 + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('이미 오늘 인증글을 작성했습니다.'), + duration: Duration(seconds: 2), + behavior: SnackBarBehavior.floating, + ), + ); + } else { + // 아직 인증 전이면 인증 페이지로 이동 + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChallengeVerificationScreen( + challengeId: widget.challengeId, + ), + ), + ); + } } }, ); } - void _scrollToMyRank() { - // MemberView에서 내 순위를 찾는 로직을 구현하거나 - // scrollController를 통해 하단으로 이동시키는 로직 등을 수행합니다. - _memberScrollController.animateTo( - 500, // 예시 값 - duration: const Duration(milliseconds: 500), - curve: Curves.easeOut, - ); - } + // return BottomActionButton( + // // 1. 텍스트 분기 + // text: isMemberTab ? '내 순위 확인하기' : '인증하기', + + // // 2. 배경색: 멤버 탭이면 흰색, 아니면 기본색(초록) + // backgroundColor: isMemberTab ? Colors.white : AppColors.primaryAble, + + // // 3. 글자색: 멤버 탭이면 초록색, 아니면 흰색 + // textColor: isMemberTab ? AppColors.primaryAble : Colors.white, + + // // 4. 테두리색: 멤버 탭일 때만 초록색 테두리 추가 + // borderColor: isMemberTab ? AppColors.primaryAble : null, + + // onPressed: () { + // if (isMemberTab) { + // // 랭킹 페이지 이동 로직 + // Navigator.push( + // context, + // MaterialPageRoute( + // builder: (context) => + // MemberRankingScreen(challengeId: widget.challengeId), + // ), + // ); + // } else { + // // 인증 페이지 이동 로직 + // Navigator.push( + // context, + // MaterialPageRoute( + // builder: (context) => + // ChallengeVerificationScreen(challengeId: widget.challengeId), + // ), + // ); + // } + // }, + // ); + // } } diff --git a/lib/features/challenge/detail/screens/member_ranking_screen.dart b/lib/features/challenge/detail/screens/member_ranking_screen.dart index bc86580..8947eee 100644 --- a/lib/features/challenge/detail/screens/member_ranking_screen.dart +++ b/lib/features/challenge/detail/screens/member_ranking_screen.dart @@ -5,7 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:haenaem/core/theme/app_colors.dart'; import 'package:haenaem/core/theme/app_typography.dart'; import 'package:haenaem/features/challenge/detail/provider/ranking_provider.dart'; -import 'package:haenaem/features/challenge/detail/model/ranking_model.dart'; +import 'package:haenaem/features/challenge/detail/models/ranking_model.dart'; class MemberRankingScreen extends ConsumerWidget { final int challengeId; diff --git a/lib/features/challenge/detail/views/calendar_view.dart b/lib/features/challenge/detail/views/calendar_view.dart index c3091ca..1106fca 100644 --- a/lib/features/challenge/detail/views/calendar_view.dart +++ b/lib/features/challenge/detail/views/calendar_view.dart @@ -3,21 +3,21 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/svg.dart'; import 'package:haenaem/core/theme/app_colors.dart'; import 'package:haenaem/core/theme/app_typography.dart'; -import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; -import 'package:intl/intl.dart'; - -import 'package:haenaem/features/feed/screens/post_detail_screen.dart'; -import 'package:haenaem/features/challenge/model/challenge_model.dart'; +import '../../../../shared/provider/post_provider.dart'; +import '../provider/stats_provider.dart'; +import '../widgets/calendar_grid.dart'; +import '../widgets/calendar_post_card.dart'; class CalendarView extends ConsumerStatefulWidget { final int challengeId; + final int streakCount; final ScrollController scrollController; const CalendarView({ super.key, required this.challengeId, + required this.streakCount, required this.scrollController, }); @@ -26,103 +26,168 @@ class CalendarView extends ConsumerStatefulWidget { } class _CalendarViewState extends ConsumerState { + late PageController _pageController; DateTime _focusedDay = DateTime.now(); + final DateTime _firstDay = DateTime(2000, 1); + @override void initState() { super.initState(); + + int initialPage = + (_focusedDay.year - _firstDay.year) * 12 + + _focusedDay.month - + _firstDay.month; + _pageController = PageController(initialPage: initialPage); } // 달 이동 로직 - void _onPrevMonth() => setState( - () => _focusedDay = DateTime(_focusedDay.year, _focusedDay.month - 1), - ); - void _onNextMonth() => setState( - () => _focusedDay = DateTime(_focusedDay.year, _focusedDay.month + 1), - ); + void _onPrevMonth() { + _pageController.previousPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + void _onNextMonth() { + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } @override Widget build(BuildContext context) { // 1. 데이터 구독 - final calendarDataAsync = ref.watch( - challengeCalendarDataProvider(widget.challengeId), - ); + final statsAsync = ref.watch(challengeStatsProvider(widget.challengeId)); - final photosAsync = ref.watch( - challengeCalendarPhotosProvider( - challengeId: widget.challengeId, - year: _focusedDay.year, - month: _focusedDay.month, - ), + final postsProvider = monthlyChallengePostsProvider( + challengeId: widget.challengeId, + year: _focusedDay.year, + month: _focusedDay.month, ); - final postsAsync = ref.watch( - challengePostsProvider( - challengeId: widget.challengeId, - year: _focusedDay.year, - month: _focusedDay.month, - ), - ); + final postsAsync = ref.watch(postsProvider); - // 2. UI 구성 - return calendarDataAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (err, stack) => Center(child: Text('에러 발생: $err')), - data: (summaryData) => Scaffold( + // 2. 통합 로딩/에러/데이터 처리 + return statsAsync.when( + loading: () => const Scaffold( backgroundColor: Colors.white, - body: SingleChildScrollView( - controller: widget.scrollController, - // 하단 버튼 높이만큼 여유 공간을 주어 마지막 리스트가 가려지지 않게 합니다. - padding: const EdgeInsets.fromLTRB(20, 20, 20, 120), - child: Column( - children: [ - _buildStatCards(summaryData), - const SizedBox(height: 20), - _buildCalendarHeader(_focusedDay), - const SizedBox(height: 10), - _buildWeekdayHeader(), - const SizedBox(height: 10), - photosAsync.when( - data: (photos) { - final allPosts = postsAsync.value ?? []; - return _buildCalendarGrid(_focusedDay, photos, allPosts); - }, - loading: () => const SizedBox( - height: 200, - child: Center(child: CircularProgressIndicator()), + body: Center(child: CircularProgressIndicator()), + ), + error: (e, s) => Scaffold(body: Center(child: Text('통계 로드 실패: $e'))), + data: (stats) => postsAsync.when( + loading: () => const Scaffold( + backgroundColor: Colors.white, + body: Center(child: CircularProgressIndicator()), + ), + error: (e, s) => Scaffold(body: Center(child: Text('인증글 로드 실패: $e'))), + data: (posts) { + return Scaffold( + backgroundColor: Colors.white, + // [추가] 당겨서 새로고침 위젯 + body: RefreshIndicator( + onRefresh: () async { + ref.invalidate(challengeStatsProvider(widget.challengeId)); + ref.invalidate(postsProvider); + + // 두 데이터가 로드될 때까지 대기 + await Future.wait([ + ref.read(challengeStatsProvider(widget.challengeId).future), + ref.read(postsProvider.future), + ]); + }, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + controller: widget.scrollController, + padding: const EdgeInsets.fromLTRB(20, 20, 20, 120), + child: Column( + children: [ + _buildStatCards( + stats.totalSuccessDays, + stats.currentStreakDays, + ), + const SizedBox(height: 20), + _buildCalendarHeader(_focusedDay), + const SizedBox(height: 10), + _buildWeekdayHeader(), + const SizedBox(height: 10), + + SizedBox( + height: 320, // 달력 높이에 맞춰 조정 + child: PageView.builder( + controller: _pageController, + onPageChanged: (index) { + setState(() { + _focusedDay = DateTime( + _firstDay.year, + _firstDay.month + index, + ); + }); + }, + itemBuilder: (context, index) { + final monthDate = DateTime( + _firstDay.year, + _firstDay.month + index, + ); + + // 개별 달의 데이터를 Provider로 구독 + return Consumer( + builder: (context, ref, child) { + final postsAsync = ref.watch( + monthlyChallengePostsProvider( + challengeId: widget.challengeId, + year: monthDate.year, + month: monthDate.month, + ), + ); + + return postsAsync.when( + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (e, s) => + const Center(child: Text('에러')), + data: (posts) => CalendarGrid( + focusedDay: monthDate, + posts: posts, + ), + ); + }, + ); + }, + ), + ), + + const SizedBox(height: 20), + _buildPostsHeaderForData(posts.length), + const SizedBox(height: 16), + posts.isEmpty + ? _buildEmptyState() + : ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: posts.length, + itemBuilder: (context, index) => + CalendarPostCard(post: posts[index]), + ), + ], ), - error: (e, s) => const Text('달력을 불러오지 못했습니다.'), - ), - const SizedBox(height: 20), - _buildPostsHeader(postsAsync), - const SizedBox(height: 16), - postsAsync.when( - data: (posts) { - if (posts.isEmpty) return _buildEmptyState(); - return ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: posts.length, - itemBuilder: (context, index) => - _buildCertCard(context, post: posts[index]), - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (e, s) => const Text('인증글 로드 중 에러'), ), - ], - ), - ), + ), + ); + }, ), ); } - Widget _buildStatCards(ChallengeCalendarModel data) { + Widget _buildStatCards(int totalSuccessDays, int currentStreakDays) { return Row( children: [ - _buildStatCard(data.totalSuccessDays.toString(), '완료 일수'), + _buildStatCard(totalSuccessDays.toString(), '완료 일수'), const SizedBox(width: 12), - _buildStatCard(data.currentStreakDays.toString(), '연속 일수'), + _buildStatCard(currentStreakDays.toString(), '연속 일수'), ], ); } @@ -239,100 +304,14 @@ class _CalendarViewState extends ConsumerState { ); } - Widget _buildCalendarGrid( - DateTime date, - List photos, - List allPosts, - ) { - final int skipDays = DateTime(date.year, date.month, 1).weekday % 7; - final int lastDayOfMonth = DateTime(date.year, date.month + 1, 0).day; - final now = DateTime.now(); - - return GridView.builder( - padding: EdgeInsets.zero, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: skipDays + lastDayOfMonth, - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 7, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - ), - itemBuilder: (context, index) { - if (index < skipDays) return const SizedBox(); - int day = index - skipDays + 1; - final bool isToday = - now.year == date.year && now.month == date.month && now.day == day; - final String targetDateStr = - "${date.year}-${date.month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}"; - - final photoData = photos.firstWhereOrNull( - (p) => p.postDate == targetDateStr, - ); - bool isCertified = photoData != null && photoData.postId != -1; - - final CertificationPostModel? fullPost = isCertified - ? allPosts.firstWhereOrNull((p) => p.postId == photoData.postId) ?? - allPosts.firstWhereOrNull((p) => p.postDate == targetDateStr) - : null; - - return GestureDetector( - onTap: (isCertified && fullPost != null) - ? () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => PostDetailScreen( - postId: fullPost.postId, - post: fullPost, - ), - ), - ) - : null, - child: Container( - alignment: Alignment.center, - decoration: BoxDecoration( - color: isCertified - ? AppColors.primaryAble - : (isToday ? AppColors.gray5 : AppColors.gray5), - borderRadius: BorderRadius.circular(5), - border: (isToday && !isCertified) - ? Border.all(color: AppColors.gray2, width: 1) - : null, - image: (isCertified && photoData.imageUrl != null) - ? DecorationImage( - image: NetworkImage(photoData.imageUrl!), - fit: BoxFit.cover, - ) - : null, - ), - child: Text( - '$day', - style: TextStyle( - color: isCertified - ? Colors.white - : (isToday ? AppColors.gray2 : AppColors.gray2), - fontWeight: isToday ? FontWeight.bold : FontWeight.normal, - ), - ), - ), - ); - }, - ); - } - - Widget _buildPostsHeader( - AsyncValue> postsAsync, - ) { + Widget _buildPostsHeaderForData(int count) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text('내 인증글', style: AppTypography.b1), - postsAsync.maybeWhen( - data: (posts) => Text( - '총 ${posts.length}개', - style: AppTypography.c1.copyWith(color: AppColors.gray2), - ), - orElse: () => const SizedBox(), + Text( + '총 $count개', + style: AppTypography.c1.copyWith(color: AppColors.gray2), ), ], ); @@ -342,77 +321,4 @@ class _CalendarViewState extends ConsumerState { padding: EdgeInsets.symmetric(vertical: 40), child: Text('이번 달 인증글이 없습니다.', style: TextStyle(color: AppColors.gray2)), ); - - Widget _buildCertCard( - BuildContext context, { - required CertificationPostModel post, - }) { - final String formattedDate = (post.postDate.isNotEmpty) - ? DateFormat('M월 d일').format(DateTime.parse(post.postDate)) - : ""; - return GestureDetector( - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => PostDetailScreen(post: post, postId: post.postId), - ), - ), - child: Container( - margin: const EdgeInsets.only(bottom: 15), - padding: const EdgeInsets.all(13), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: AppColors.gray4), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (post.imageUrl != null) ...[ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.network( - post.imageUrl!, - width: 80, - height: 80, - fit: BoxFit.cover, - ), - ), - const SizedBox(width: 12), - ], - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - SvgPicture.asset( - 'assets/images/icons/green_calendar.svg', - width: 12, - height: 12, - ), - const SizedBox(width: 4), - Text( - formattedDate, - style: AppTypography.c1.copyWith( - color: AppColors.primaryAble, - ), - ), - ], - ), - const SizedBox(height: 4), - Text( - post.content, - style: AppTypography.b2.copyWith(color: AppColors.black), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - ), - ); - } } diff --git a/lib/features/challenge/detail/views/information_view.dart b/lib/features/challenge/detail/views/information_view.dart index 906d682..175852d 100644 --- a/lib/features/challenge/detail/views/information_view.dart +++ b/lib/features/challenge/detail/views/information_view.dart @@ -1,11 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:haenaem/core/theme/app_colors.dart'; import 'package:haenaem/core/theme/app_typography.dart'; -import 'package:flutter_svg/svg.dart'; -import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; -import 'package:haenaem/features/challenge/detail/widgets/challenge_detail_content.dart'; +import 'package:haenaem/shared/provider/challenge_detail_provider.dart'; +import 'package:haenaem/shared/widgets/challenge_detail_content.dart'; class InformationView extends ConsumerWidget { final int challengeId; diff --git a/lib/features/challenge/detail/widgets/calendar_grid.dart b/lib/features/challenge/detail/widgets/calendar_grid.dart new file mode 100644 index 0000000..b050209 --- /dev/null +++ b/lib/features/challenge/detail/widgets/calendar_grid.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; +import 'package:collection/collection.dart'; +import 'package:haenaem/core/theme/app_colors.dart'; +import '../models/calendar_post.dart'; +import 'package:haenaem/features/feed/screens/post_detail_screen.dart'; + +// 최초 작성자 : 강선욱 +// 챌린지 인증 달력 그리드 위젯 +// - 특정 연월의 날짜를 7열 그리드로 표시 +// - 인증한 날짜는 초록색으로 표시되며, 사진이 있으면 썸네일로 표시 +// - 인증한 날짜 셀을 탭하면 해당 포스트 상세 화면으로 이동 + +class CalendarGrid extends StatelessWidget { + /// 현재 표시 중인 연월 + final DateTime focusedDay; + + /// 해당 월의 포스트 목록 (post_provider에서 전달) + final List posts; + + const CalendarGrid({ + super.key, + required this.focusedDay, + required this.posts, + }); + + @override + Widget build(BuildContext context) { + // 해당 월 1일의 요일 (0: 일요일 기준으로 맞추기 위해 % 7 처리) + // ex) 1일이 화요일이면 skipDays = 2 → 앞에 빈 셀 2개 추가 + final int skipDays = + DateTime(focusedDay.year, focusedDay.month, 1).weekday % 7; + + // 해당 월의 마지막 날짜 + // ex) 3월이면 31, 4월이면 30 + final int lastDayOfMonth = DateTime( + focusedDay.year, + focusedDay.month + 1, + 0, + ).day; + + final now = DateTime.now(); + + return GridView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + // 빈 셀(skipDays) + 실제 날짜 셀(lastDayOfMonth) + itemCount: skipDays + lastDayOfMonth, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 7, // 일~토 7열 + mainAxisSpacing: 10, + crossAxisSpacing: 10, + ), + itemBuilder: (context, index) { + // 1일 이전 빈 셀 처리 + if (index < skipDays) return const SizedBox(); + + final int day = index - skipDays + 1; + + // 오늘 날짜 여부 확인 (테두리 표시용) + final bool isToday = + now.year == focusedDay.year && + now.month == focusedDay.month && + now.day == day; + + final String targetDateStr = + "${focusedDay.year}-${focusedDay.month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}"; + + // 해당 날짜에 인증 포스트가 있는지 확인 + final CalendarPost? post = posts.firstWhereOrNull( + (p) => p.postDate == targetDateStr, + ); + final bool isCertified = post != null; + + return _CalendarCell( + day: day, + isToday: isToday, + isCertified: isCertified, + imageUrl: post?.imageUrl, + // 인증한 날짜만 탭 가능, 포스트 상세 화면으로 이동 + onTap: isCertified + ? () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PostDetailScreen(postId: post.postId), + ), + ) + : null, + ); + }, + ); + } +} + +/// 달력 개별 셀 위젯 +/// +/// - 인증 여부에 따라 배경색 및 썸네일 표시 +/// - 오늘 날짜이고 미인증이면 회색 테두리 표시 +class _CalendarCell extends StatelessWidget { + final int day; + final bool isToday; + final bool isCertified; + + /// 인증 사진 URL (없으면 단색 배경으로 표시) + final String? imageUrl; + final VoidCallback? onTap; + + const _CalendarCell({ + required this.day, + required this.isToday, + required this.isCertified, + this.imageUrl, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + // 인증: 초록색 / 미인증: 회색 + color: isCertified ? AppColors.primaryAble : AppColors.gray5, + borderRadius: BorderRadius.circular(5), + // 오늘이면서 미인증인 경우 테두리 표시 + border: (isToday && !isCertified) + ? Border.all(color: AppColors.gray2, width: 1) + : null, + // 인증 사진이 있으면 썸네일로 표시 + image: (isCertified && imageUrl != null) + ? DecorationImage( + image: NetworkImage(imageUrl!), + fit: BoxFit.cover, + ) + : null, + ), + child: Text( + '$day', + style: TextStyle( + // 인증: 흰색 / 미인증: 회색 + color: isCertified ? Colors.white : AppColors.gray2, + fontWeight: isToday ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ); + } +} diff --git a/lib/features/challenge/detail/widgets/calendar_post_card.dart b/lib/features/challenge/detail/widgets/calendar_post_card.dart new file mode 100644 index 0000000..52f7143 --- /dev/null +++ b/lib/features/challenge/detail/widgets/calendar_post_card.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:haenaem/core/theme/app_colors.dart'; +import 'package:haenaem/core/theme/app_typography.dart'; +import 'package:haenaem/features/challenge/detail/models/calendar_post.dart'; +import 'package:haenaem/features/feed/screens/post_detail_screen.dart'; +import 'package:intl/intl.dart'; + +// 최초 작성자 : 강선욱 +// 캘린지 인증글 카드 위젯 +// - 인증글 사진, 날짜, 내용을 표시 +// - 탭 시 포스트 상세 화면으로 이동 +class CalendarPostCard extends StatelessWidget { + final CalendarPost post; + + const CalendarPostCard({super.key, required this.post}); + + @override + Widget build(BuildContext context) { + final String formattedDate = DateFormat( + 'M월 d일', + ).format(DateTime.parse(post.postDate)); + + return GestureDetector( + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PostDetailScreen(postId: post.postId), + ), + ), + child: Container( + margin: const EdgeInsets.only(bottom: 15), + padding: const EdgeInsets.all(13), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.gray4), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (post.imageUrl != null) ...[ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + post.imageUrl!, + width: 80, + height: 80, + fit: BoxFit.cover, + ), + ), + const SizedBox(width: 12), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + SvgPicture.asset( + 'assets/images/icons/green_calendar.svg', + width: 12, + height: 12, + ), + const SizedBox(width: 4), + Text( + formattedDate, + style: AppTypography.c1.copyWith( + color: AppColors.primaryAble, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + post.content, + style: AppTypography.b2.copyWith(color: AppColors.black), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/challenge/create/widgets/challenge_create_success_dialog.dart b/lib/features/challenge/detail/widgets/challenge_create_success_dialog.dart similarity index 99% rename from lib/features/challenge/create/widgets/challenge_create_success_dialog.dart rename to lib/features/challenge/detail/widgets/challenge_create_success_dialog.dart index cdaa7c9..c91b8ed 100644 --- a/lib/features/challenge/create/widgets/challenge_create_success_dialog.dart +++ b/lib/features/challenge/detail/widgets/challenge_create_success_dialog.dart @@ -13,7 +13,7 @@ class ChallengeCreateSuccessDialog extends StatelessWidget { const ChallengeCreateSuccessDialog({ super.key, required this.challengeId, // 필수 - required this.challengeLink, + this.challengeLink = '', }); @override diff --git a/lib/features/challenge/widgets/challenge_popup_menu.dart b/lib/features/challenge/detail/widgets/challenge_popup_menu.dart similarity index 90% rename from lib/features/challenge/widgets/challenge_popup_menu.dart rename to lib/features/challenge/detail/widgets/challenge_popup_menu.dart index d2a7554..f25eb46 100644 --- a/lib/features/challenge/widgets/challenge_popup_menu.dart +++ b/lib/features/challenge/detail/widgets/challenge_popup_menu.dart @@ -4,11 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; // 추가 import 'package:flutter_svg/flutter_svg.dart'; import 'package:haenaem/core/theme/app_colors.dart'; import 'package:haenaem/core/theme/app_typography.dart'; -import 'package:haenaem/features/challenge/invite/challengeInviteScreen.dart'; -import 'package:haenaem/features/challenge/widgets/exit_confirm_dialog.dart'; -import 'package:haenaem/features/challenge/widgets/NotificationSettingsDialog.dart'; -import 'package:haenaem/features/challenge/settings/challenge_settings_screen.dart'; -import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; // 추가 +import 'package:haenaem/features/challenge/invite/screens/challenge_invite_screen.dart'; +import 'package:haenaem/features/challenge/detail/widgets/exit_confirm_dialog.dart'; +import 'package:haenaem/features/challenge/detail/widgets/notification_settings_dialog.dart'; +import 'package:haenaem/features/challenge/settings/screens/challenge_settings_screen.dart'; +// import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; // 추가 +import '../provider/challenge_leave_provider.dart'; // 챌린지방 팝업 (방장일 경우/멤버일 경우) class ChallengePopupMenu extends ConsumerWidget { @@ -123,7 +124,8 @@ class ChallengePopupMenu extends ConsumerWidget { // 알림 설정 로직 호출 showDialog( context: context, - builder: (context) => const NotificationSettingsDialog(), + builder: (context) => + NotificationSettingsDialog(challengeId: challengeId), ); break; case 'invite': diff --git a/lib/features/challenge/widgets/exit_confirm_dialog.dart b/lib/features/challenge/detail/widgets/exit_confirm_dialog.dart similarity index 71% rename from lib/features/challenge/widgets/exit_confirm_dialog.dart rename to lib/features/challenge/detail/widgets/exit_confirm_dialog.dart index 7dc7124..e16ae8c 100644 --- a/lib/features/challenge/widgets/exit_confirm_dialog.dart +++ b/lib/features/challenge/detail/widgets/exit_confirm_dialog.dart @@ -1,12 +1,11 @@ // 최초 작성자 : 강선욱 import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:haenaem/core/theme/app_colors.dart'; -import 'package:haenaem/core/theme/app_typography.dart'; import 'package:haenaem/shared/widgets/challenge_exit_base_dialog.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../provider/challenge_provider.dart'; -import 'package:haenaem/features/challenge/data/challenge_repository.dart'; +// import '../provider/challenge_provider.dart'; +import 'package:haenaem/shared/provider/home_provider.dart'; +import '../data/challenge_leave_repository.dart'; +// import 'package:haenaem/features/challenge/data/challenge_repository.dart'; // 챌린지 나가기 다이얼로그 class ExitConfirmDialog extends ConsumerWidget { @@ -20,11 +19,11 @@ class ExitConfirmDialog extends ConsumerWidget { onConfirm: () async { try { await ref - .read(challengeRepositoryProvider) + .read(challengeLeaveRepositoryProvider) .leaveChallenge(challengeId); // 홈 화면 데이터 Provider를 무효화하여 새로고침 유도 - ref.invalidate(challengeHomeNotifierProvider); + ref.invalidate(homeNotifierProvider); if (context.mounted) { Navigator.of(context).popUntil((route) => route.isFirst); // 홈으로 이동 diff --git a/lib/features/challenge/detail/widgets/notification_settings_dialog.dart b/lib/features/challenge/detail/widgets/notification_settings_dialog.dart new file mode 100644 index 0000000..85cd219 --- /dev/null +++ b/lib/features/challenge/detail/widgets/notification_settings_dialog.dart @@ -0,0 +1,708 @@ +// 최초 작성자 : 강선욱 +// 수정: 김채영 (피그마 디자인 반영) +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:haenaem/core/theme/app_colors.dart'; +import 'package:haenaem/core/theme/app_typography.dart'; +import 'package:haenaem/features/notification/data/challenge_notification_repository.dart'; + +class NotificationSettingsDialog extends ConsumerStatefulWidget { + final int challengeId; + + const NotificationSettingsDialog({super.key, required this.challengeId}); + + @override + ConsumerState createState() => + _NotificationSettingsDialogState(); +} + +class _NotificationSettingsDialogState + extends ConsumerState { + // ── 스위치 상태 ─────────────────────────────────────── + bool allNotifications = false; + bool dailyReminder = false; + bool likesNotification = false; + bool commentsNotification = false; + bool mateVerification = false; + String selectedTime = '오후 9시'; + bool _isLoading = true; + + // ── 전역 설정에 의해 비활성화된 항목 ──────────────────── + bool _reminderDisabledByGlobal = false; + bool _likesDisabledByGlobal = false; + bool _commentsDisabledByGlobal = false; + bool _verificationDisabledByGlobal = false; + + @override + void initState() { + super.initState(); + _loadChallengeSettings(); + } + + // ── 초기 로드 ───────────────────────────────────────── + + Future _loadChallengeSettings() async { + try { + final dto = await ref + .read(challengeNotificationRepositoryProvider) + .getChallengeNotificationSettings(widget.challengeId); + + // 🔔 임시 로그 + debugPrint( + '===== 🔔 챌린지 알림 설정 조회 결과 (challengeId: ${widget.challengeId}) =====', + ); + debugPrint('🔔 전체 알림: ${dto.challengeAllPushEnabled}'); + debugPrint('🔔 일일 리마인더: ${dto.dailyReminderPushEnabled}'); + debugPrint('🔔 일일 리마인더 시간: ${dto.dailyReminderTime}'); + debugPrint( + '🔔 일일 리마인더 전역 차단: ${dto.dailyReminderDisabledByGlobalSetting}', + ); + debugPrint('🔔 좋아요: ${dto.likesPushEnabled}'); + debugPrint('🔔 좋아요 전역 차단: ${dto.likesDisabledByGlobalSetting}'); + debugPrint('🔔 댓글: ${dto.commentsPushEnabled}'); + debugPrint('🔔 댓글 전역 차단: ${dto.commentsDisabledByGlobalSetting}'); + debugPrint('🔔 멤버 인증: ${dto.memberCertificationPushEnabled}'); + debugPrint( + '🔔 멤버 인증 전역 차단: ${dto.memberCertificationDisabledByGlobalSetting}', + ); + debugPrint( + '=================================================================', + ); + + setState(() { + allNotifications = dto.challengeAllPushEnabled; + dailyReminder = dto.dailyReminderPushEnabled; + likesNotification = dto.likesPushEnabled; + commentsNotification = dto.commentsPushEnabled; + mateVerification = dto.memberCertificationPushEnabled; + selectedTime = _convertToDisplayTime(dto.dailyReminderTime); + + _reminderDisabledByGlobal = dto.dailyReminderDisabledByGlobalSetting; + _likesDisabledByGlobal = dto.likesDisabledByGlobalSetting; + _commentsDisabledByGlobal = dto.commentsDisabledByGlobalSetting; + _verificationDisabledByGlobal = + dto.memberCertificationDisabledByGlobalSetting; + + _isLoading = false; + }); + } catch (e) { + debugPrint('챌린지 알림 설정 로드 실패: $e'); + setState(() => _isLoading = false); + } + } + + // ── 시간 변환 헬퍼 ──────────────────────────────────── + + String _convertToDisplayTime(String serverTime) { + final hour = int.parse(serverTime.split(':')[0]); + if (hour == 0) return '오전 12시'; + if (hour < 12) return '오전 $hour시'; + if (hour == 12) return '오후 12시'; + return '오후 ${hour - 12}시'; + } + + String _convertToServerTime(String period, String hourStr) { + int hour = int.parse(hourStr.replaceAll('시', '')); + if (period == '오후' && hour != 12) hour += 12; + if (period == '오전' && hour == 12) hour = 0; + return '${hour.toString().padLeft(2, '0')}:00'; + } + + // ── 전체 알림 동기화 헬퍼 ──────────────────────────── + + void _syncAllNotifications() { + allNotifications = + dailyReminder && + likesNotification && + commentsNotification && + mateVerification; + } + + // ── 토글 핸들러 ─────────────────────────────────────── + + Future _toggleAll(bool val) async { + if (_isLoading) return; + setState(() => _isLoading = true); + + final success = await ref + .read(challengeNotificationRepositoryProvider) + .setChallengeAllNotification(widget.challengeId, val); + + if (success) { + setState(() { + allNotifications = val; + dailyReminder = val; + likesNotification = val; + commentsNotification = val; + mateVerification = val; + }); + } + setState(() => _isLoading = false); + } + + Future _toggleReminder(bool val) async { + if (_isLoading) return; + setState(() => _isLoading = true); + + final success = await ref + .read(challengeNotificationRepositoryProvider) + .setChallengeReminderNotification(widget.challengeId, val); + + if (success) { + setState(() { + dailyReminder = val; + _syncAllNotifications(); + }); + } + setState(() => _isLoading = false); + } + + Future _toggleLikes(bool val) async { + if (_isLoading) return; + setState(() => _isLoading = true); + + final success = await ref + .read(challengeNotificationRepositoryProvider) + .setChallengeLikesNotification(widget.challengeId, val); + + if (success) { + setState(() { + likesNotification = val; + _syncAllNotifications(); + }); + } + setState(() => _isLoading = false); + } + + Future _toggleComments(bool val) async { + if (_isLoading) return; + setState(() => _isLoading = true); + + final success = await ref + .read(challengeNotificationRepositoryProvider) + .setChallengeCommentsNotification(widget.challengeId, val); + + if (success) { + setState(() { + commentsNotification = val; + _syncAllNotifications(); + }); + } + setState(() => _isLoading = false); + } + + Future _toggleVerification(bool val) async { + if (_isLoading) return; + setState(() => _isLoading = true); + + final success = await ref + .read(challengeNotificationRepositoryProvider) + .setChallengeVerificationNotification(widget.challengeId, val); + + if (success) { + setState(() { + mateVerification = val; + _syncAllNotifications(); + }); + } + setState(() => _isLoading = false); + } + + // ── UI ──────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + return Dialog( + insetPadding: const EdgeInsets.symmetric(horizontal: 20), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + backgroundColor: Colors.white, + elevation: 0, + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ── 헤더 ───────────────────────────────────── + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: const BoxDecoration( + color: Colors.white, + border: Border( + bottom: BorderSide(width: 1, color: AppColors.gray4), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '챌린지 알림 설정', + style: AppTypography.h3.copyWith(color: AppColors.black), + ), + GestureDetector( + onTap: () => Navigator.pop(context), + child: SizedBox( + width: 24, + height: 24, + child: SvgPicture.asset( + 'assets/images/icons/close_icon.svg', + ), + ), + ), + ], + ), + ), + + // ── 본문 ───────────────────────────────────── + _isLoading + ? const Padding( + padding: EdgeInsets.symmetric(vertical: 40), + child: CircularProgressIndicator( + color: AppColors.primaryAble, + ), + ) + : Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, + ), + child: Column( + spacing: 20, + children: [ + _buildSwitchRow( + '전체 알림', + '모든 알림 받기', + allNotifications, + _toggleAll, + ), + _buildDailyReminderSection(), + _buildSwitchRowWithGlobalWarning( + '내 글 좋아요', + "내 게시물에 '좋아요' 반응이 올 때 알림", + likesNotification, + _likesDisabledByGlobal ? null : _toggleLikes, + disabledByGlobal: _likesDisabledByGlobal, + ), + _buildSwitchRowWithGlobalWarning( + '댓글', + '내 글에 새로운 댓글이 달릴 때 알림', + commentsNotification, + _commentsDisabledByGlobal ? null : _toggleComments, + disabledByGlobal: _commentsDisabledByGlobal, + ), + _buildSwitchRowWithGlobalWarning( + '멤버 인증 소식', + '다른 참여자들이 인증 완료 시 알림', + mateVerification, + _verificationDisabledByGlobal + ? null + : _toggleVerification, + disabledByGlobal: _verificationDisabledByGlobal, + ), + ], + ), + ), + + // ── 완료 버튼 ───────────────────────────────── + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: const BoxDecoration( + border: Border(top: BorderSide(width: 1, color: AppColors.gray4)), + ), + child: SizedBox( + width: double.infinity, + height: 48, + child: ElevatedButton( + onPressed: () => Navigator.pop(context), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryAble, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + elevation: 0, + ), + child: const Text( + '완료', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ], + ), + ); + } + + // ── 기본 스위치 행 ────────────────────────────────────── + + Widget _buildSwitchRow( + String title, + String subtitle, + bool value, + ValueChanged? onChanged, + ) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: AppTypography.b1.copyWith(color: AppColors.black), + ), + Text( + subtitle, + style: AppTypography.b2.copyWith(color: AppColors.gray2), + ), + ], + ), + ), + _buildSwitch(value, onChanged), + ], + ); + } + + // ── 전역 비활성화 안내 문구 포함 스위치 행 ────────────── + + Widget _buildSwitchRowWithGlobalWarning( + String title, + String subtitle, + bool value, + ValueChanged? onChanged, { + bool disabledByGlobal = false, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: AppTypography.b1.copyWith( + color: disabledByGlobal + ? AppColors.gray2 + : AppColors.black, + ), + ), + Text( + subtitle, + style: AppTypography.b2.copyWith(color: AppColors.gray2), + ), + ], + ), + ), + _buildSwitch( + value, + (_isLoading || disabledByGlobal) ? null : onChanged, + ), + ], + ), + if (disabledByGlobal) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + '전체 푸시 알림 설정에서 해당 알림이 꺼져 있습니다.', + style: AppTypography.c1.copyWith(color: AppColors.primaryAble), + ), + ), + ], + ); + } + + // ── 공통 스위치 위젯 ──────────────────────────────────── + + Widget _buildSwitch(bool value, ValueChanged? onChanged) { + return Transform.scale( + scale: 0.8, + alignment: Alignment.centerRight, + child: Switch( + value: value, + onChanged: onChanged, + activeTrackColor: AppColors.primaryAble, + activeThumbColor: Colors.white, + inactiveTrackColor: AppColors.disable, + inactiveThumbColor: Colors.white, + trackOutlineColor: const WidgetStatePropertyAll(Colors.transparent), + thumbIcon: WidgetStateProperty.all(const Icon(null)), + thumbColor: const WidgetStatePropertyAll(Colors.white), + ), + ); + } + + // ── 일일 리마인더 섹션 ────────────────────────────────── + + Widget _buildDailyReminderSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '일일 리마인더', + style: AppTypography.b1.copyWith( + color: _reminderDisabledByGlobal + ? AppColors.gray2 + : AppColors.black, + ), + ), + Text( + '매일 $selectedTime 알림', + style: AppTypography.b2.copyWith(color: AppColors.gray2), + ), + ], + ), + ), + _buildSwitch( + dailyReminder, + (_isLoading || _reminderDisabledByGlobal) + ? null + : _toggleReminder, + ), + ], + ), + if (dailyReminder && !_reminderDisabledByGlobal) ...[ + const SizedBox(height: 4), + GestureDetector( + onTap: () => _showTimePicker(context), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.gray5, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + selectedTime, + style: AppTypography.b2.copyWith(color: AppColors.gray3), + ), + Opacity( + opacity: 0.5, + child: SvgPicture.asset( + 'assets/images/icons/big_down_arrow.svg', + width: 16, + height: 16, + colorFilter: const ColorFilter.mode( + AppColors.gray2, + BlendMode.srcIn, + ), + ), + ), + ], + ), + ), + ), + ], + if (_reminderDisabledByGlobal) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + '전체 푸시 알림 설정에서 해당 알림이 꺼져 있습니다.', + style: AppTypography.c1.copyWith(color: AppColors.primaryAble), + ), + ), + ], + ); + } + + // ── 시간 피커 ─────────────────────────────────────────── + + void _showTimePicker(BuildContext context) { + final List hours = List.generate(12, (i) => '${i + 1}시'); + String currentPeriod = selectedTime.contains('오후') ? '오후' : '오전'; + String currentHour = selectedTime.split(' ').last; + int initialHourIndex = hours.indexOf(currentHour); + if (initialHourIndex == -1) initialHourIndex = 8; + + showDialog( + context: context, + barrierColor: Colors.black.withAlpha(100), + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + backgroundColor: Colors.white, + contentPadding: const EdgeInsets.fromLTRB(20, 20, 20, 0), + content: SizedBox( + width: MediaQuery.of(context).size.width * 0.8, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: _buildAnimatedPeriodSelector( + currentPeriod, + (newPeriod) => + setDialogState(() => currentPeriod = newPeriod), + ), + ), + const SizedBox(height: 24), + SizedBox( + height: 150, + child: Stack( + children: [ + Center( + child: Container( + height: 40, + margin: const EdgeInsets.symmetric(horizontal: 24), + decoration: BoxDecoration( + color: AppColors.gray4.withAlpha(100), + borderRadius: BorderRadius.circular(10), + ), + ), + ), + CupertinoPicker( + itemExtent: 40, + scrollController: FixedExtentScrollController( + initialItem: initialHourIndex, + ), + onSelectedItemChanged: (index) => + setDialogState(() => currentHour = hours[index]), + selectionOverlay: const SizedBox.shrink(), + children: hours + .map( + (h) => Center( + child: Text(h, style: AppTypography.b1), + ), + ) + .toList(), + ), + ], + ), + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.only( + bottom: 16, + left: 16, + right: 16, + ), + child: TextButton( + onPressed: () { + final newTime = '$currentPeriod $currentHour'; + final serverTime = _convertToServerTime( + currentPeriod, + currentHour, + ); + setState(() => selectedTime = newTime); + // TODO: 챌린지별 리마인더 시간 변경 API 생기면 연동 + Navigator.pop(context); + }, + style: TextButton.styleFrom( + backgroundColor: Colors.transparent, + foregroundColor: AppColors.primaryAble, + minimumSize: const Size(double.infinity, 52), + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + '완료', + style: AppTypography.b1.copyWith( + color: AppColors.primaryAble, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildAnimatedPeriodSelector( + String currentPeriod, + Function(String) onPeriodChanged, + ) { + return Container( + height: 48, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: AppColors.gray4, + borderRadius: BorderRadius.circular(12), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final double width = constraints.maxWidth / 2; + return Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + alignment: currentPeriod == '오전' + ? Alignment.centerLeft + : Alignment.centerRight, + child: Container( + width: width - 4, + height: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(20), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + ), + ), + Row( + children: ['오전', '오후'].map((p) { + final bool isSelected = currentPeriod == p; + return Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onPeriodChanged(p), + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 250), + style: AppTypography.b2.copyWith( + color: isSelected ? Colors.black : AppColors.gray2, + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + ), + child: Text(p), + ), + ), + ), + ); + }).toList(), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/features/challenge/invite/data/challenge_invite_repository.dart b/lib/features/challenge/invite/data/challenge_invite_repository.dart new file mode 100644 index 0000000..36ad079 --- /dev/null +++ b/lib/features/challenge/invite/data/challenge_invite_repository.dart @@ -0,0 +1,70 @@ +// 최초 작성자: 정승빈 + +import 'package:dio/dio.dart'; +import 'package:haenaem/core/network/dio_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../models/invite_response.dart'; + +part 'challenge_invite_repository.g.dart'; + +@riverpod +ChallengeInviteRepository challengeInviteRepository( + ChallengeInviteRepositoryRef ref, +) { + final dio = ref.watch(dioProvider); + return ChallengeInviteRepository(dio); +} + +class ChallengeInviteRepository { + final Dio _dio; + + ChallengeInviteRepository(this._dio); + + // 챌린지 초대 탭 정보 조회 API (링크 + 친구목록 + 초대여부) + // - [challengeId]: 조회할 챌린지 ID + Future getChallengeInviteInfo( + int challengeId, + ) async { + try { + debugPrint('🚀 [API 요청 시작] /api/challenges/$challengeId/invite'); + + final response = await _dio.get('/api/challenges/$challengeId/invite'); + + debugPrint('📥 [API 응답 코드] ${response.statusCode}'); + debugPrint('📦 [API 응답 데이터 원본] ${response.data}'); + + if (response.statusCode == 200) { + try { + final result = ChallengeInviteResponse.fromJson(response.data); + debugPrint( + '친구: ${result.friends.map((f) => '(${f.id}) ${f.nickname}').toList()}', + ); + debugPrint('링크: ${result.challengeLink}'); + return result; + } catch (e) { + debugPrint('⚠️ [파싱 에러] 모델 변환 실패: $e'); + debugPrint('🔍 [파싱 실패 데이터] ${response.data}'); + rethrow; + } + } else { + throw Exception('초대 정보 조회 실패 (Status: ${response.statusCode})'); + } + } catch (e) { + debugPrint('❌ [API 에러 발생] $e'); + rethrow; + } + } + + // 챌린지 친구 초대 + // API: POST /api/challenges/{challengeId}/invite/{friendNickname} + Future inviteFriend(int challengeId, String friendNickname) async { + try { + // POST 요청 전송 + await _dio.post('/api/challenges/$challengeId/invite/$friendNickname'); + } catch (e) { + // 에러 발생 시 호출한 곳(UI)으로 에러를 던짐 + rethrow; + } + } +} diff --git a/lib/features/challenge/invite/data/challenge_invite_repository.g.dart b/lib/features/challenge/invite/data/challenge_invite_repository.g.dart new file mode 100644 index 0000000..826b269 --- /dev/null +++ b/lib/features/challenge/invite/data/challenge_invite_repository.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'challenge_invite_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$challengeInviteRepositoryHash() => + r'0bf9f2c645c965a6c29c951969e875f5048b96b2'; + +/// See also [challengeInviteRepository]. +@ProviderFor(challengeInviteRepository) +final challengeInviteRepositoryProvider = + AutoDisposeProvider.internal( + challengeInviteRepository, + name: r'challengeInviteRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$challengeInviteRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef ChallengeInviteRepositoryRef = + AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/challenge/invite/models/invite_friend.dart b/lib/features/challenge/invite/models/invite_friend.dart new file mode 100644 index 0000000..5512316 --- /dev/null +++ b/lib/features/challenge/invite/models/invite_friend.dart @@ -0,0 +1,39 @@ +// 최초 작성자: 강선욱 +import 'package:haenaem/shared/models/user.dart'; + +class InviteFriend extends User { + final bool isInvited; + + const InviteFriend({ + required super.id, + super.profileUrl, + required super.nickname, + required this.isInvited, + }); + + factory InviteFriend.fromJson(Map json) { + final user = User.fromJson(json); + return InviteFriend( + id: user.id, + profileUrl: user.profileUrl, + nickname: user.nickname, + // API 명세: 초대 상태 체크 ('INVITED' 문자열일 경우) + isInvited: json['inviteStatus'] == 'INVITED', + ); + } + + @override + InviteFriend copyWith({ + int? id, + String? profileUrl, + String? nickname, + bool? isInvited, + }) { + return InviteFriend( + id: id ?? this.id, + profileUrl: profileUrl ?? this.profileUrl, + nickname: nickname ?? this.nickname, + isInvited: isInvited ?? this.isInvited, + ); + } +} diff --git a/lib/features/challenge/invite/models/invite_response.dart b/lib/features/challenge/invite/models/invite_response.dart new file mode 100644 index 0000000..59515c5 --- /dev/null +++ b/lib/features/challenge/invite/models/invite_response.dart @@ -0,0 +1,21 @@ +import './invite_friend.dart'; + +// 최초 작성자: 강선욱 +// 챌린지 초대 탭 응답 모델 (GET /api/challenges/{challengeId}/invite) +class ChallengeInviteResponse { + final String challengeLink; // 초대 링크 + final List friends; // 친구 목록 (초대 상태 포함) + + ChallengeInviteResponse({required this.challengeLink, required this.friends}); + + factory ChallengeInviteResponse.fromJson(Map json) { + return ChallengeInviteResponse( + // 초대 링크 매핑 + challengeLink: json['inviteLink'] ?? '', + + friends: ((json['responseList'] ?? []) as List) + .map((e) => InviteFriend.fromJson(e)) + .toList(), + ); + } +} diff --git a/lib/features/challenge/invite/provider/challenge_invite_provider.dart b/lib/features/challenge/invite/provider/challenge_invite_provider.dart new file mode 100644 index 0000000..c66b92c --- /dev/null +++ b/lib/features/challenge/invite/provider/challenge_invite_provider.dart @@ -0,0 +1,18 @@ +// 최초 작성자: 정승빈 +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../data/challenge_invite_repository.dart'; +import '../models/invite_response.dart'; + +part 'challenge_invite_provider.g.dart'; + +/// 챌린지 초대 정보 Provider +/// - ChallengeInviteContent에서 ref.watch(challengeInviteInfoProvider(challengeId))로 사용 +@riverpod +Future challengeInvite( + Ref ref, + int challengeId, +) async { + final repository = ref.watch(challengeInviteRepositoryProvider); + return repository.getChallengeInviteInfo(challengeId); +} diff --git a/lib/features/challenge/invite/provider/challenge_invite_provider.g.dart b/lib/features/challenge/invite/provider/challenge_invite_provider.g.dart new file mode 100644 index 0000000..1ad94b3 --- /dev/null +++ b/lib/features/challenge/invite/provider/challenge_invite_provider.g.dart @@ -0,0 +1,173 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'challenge_invite_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$challengeInviteHash() => r'df75e09f6a485ad010ee00e52251f1247a94a5c3'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// 챌린지 초대 정보 Provider +/// - ChallengeInviteContent에서 ref.watch(challengeInviteInfoProvider(challengeId))로 사용 +/// +/// Copied from [challengeInvite]. +@ProviderFor(challengeInvite) +const challengeInviteProvider = ChallengeInviteFamily(); + +/// 챌린지 초대 정보 Provider +/// - ChallengeInviteContent에서 ref.watch(challengeInviteInfoProvider(challengeId))로 사용 +/// +/// Copied from [challengeInvite]. +class ChallengeInviteFamily + extends Family> { + /// 챌린지 초대 정보 Provider + /// - ChallengeInviteContent에서 ref.watch(challengeInviteInfoProvider(challengeId))로 사용 + /// + /// Copied from [challengeInvite]. + const ChallengeInviteFamily(); + + /// 챌린지 초대 정보 Provider + /// - ChallengeInviteContent에서 ref.watch(challengeInviteInfoProvider(challengeId))로 사용 + /// + /// Copied from [challengeInvite]. + ChallengeInviteProvider call(int challengeId) { + return ChallengeInviteProvider(challengeId); + } + + @override + ChallengeInviteProvider getProviderOverride( + covariant ChallengeInviteProvider provider, + ) { + return call(provider.challengeId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'challengeInviteProvider'; +} + +/// 챌린지 초대 정보 Provider +/// - ChallengeInviteContent에서 ref.watch(challengeInviteInfoProvider(challengeId))로 사용 +/// +/// Copied from [challengeInvite]. +class ChallengeInviteProvider + extends AutoDisposeFutureProvider { + /// 챌린지 초대 정보 Provider + /// - ChallengeInviteContent에서 ref.watch(challengeInviteInfoProvider(challengeId))로 사용 + /// + /// Copied from [challengeInvite]. + ChallengeInviteProvider(int challengeId) + : this._internal( + (ref) => challengeInvite(ref as ChallengeInviteRef, challengeId), + from: challengeInviteProvider, + name: r'challengeInviteProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$challengeInviteHash, + dependencies: ChallengeInviteFamily._dependencies, + allTransitiveDependencies: + ChallengeInviteFamily._allTransitiveDependencies, + challengeId: challengeId, + ); + + ChallengeInviteProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.challengeId, + }) : super.internal(); + + final int challengeId; + + @override + Override overrideWith( + FutureOr Function(ChallengeInviteRef provider) + create, + ) { + return ProviderOverride( + origin: this, + override: ChallengeInviteProvider._internal( + (ref) => create(ref as ChallengeInviteRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + challengeId: challengeId, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _ChallengeInviteProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is ChallengeInviteProvider && other.challengeId == challengeId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, challengeId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin ChallengeInviteRef + on AutoDisposeFutureProviderRef { + /// The parameter `challengeId` of this provider. + int get challengeId; +} + +class _ChallengeInviteProviderElement + extends AutoDisposeFutureProviderElement + with ChallengeInviteRef { + _ChallengeInviteProviderElement(super.provider); + + @override + int get challengeId => (origin as ChallengeInviteProvider).challengeId; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/challenge/invite/ChallengeInviteScreen.dart b/lib/features/challenge/invite/screens/challenge_invite_screen.dart similarity index 100% rename from lib/features/challenge/invite/ChallengeInviteScreen.dart rename to lib/features/challenge/invite/screens/challenge_invite_screen.dart diff --git a/lib/features/challenge/invite/widgets/challenge_invite_content.dart b/lib/features/challenge/invite/widgets/challenge_invite_content.dart index c708a73..b8e8670 100644 --- a/lib/features/challenge/invite/widgets/challenge_invite_content.dart +++ b/lib/features/challenge/invite/widgets/challenge_invite_content.dart @@ -7,8 +7,12 @@ import 'package:flutter/services.dart'; import 'package:haenaem/core/theme/app_colors.dart'; import 'package:haenaem/core/theme/app_typography.dart'; import 'package:haenaem/core/utils/korean_string_utils.dart'; -import 'package:haenaem/features/challenge/data/challenge_repository.dart'; -import 'package:haenaem/features/challenge/model/challenge_model.dart'; +import 'package:haenaem/features/challenge/invite/data/challenge_invite_repository.dart'; +import '../provider/challenge_invite_provider.dart'; +// import 'package:haenaem/features/notification/provider/notification_provider.dart'; +import '../models/invite_friend.dart'; +// import 'package:haenaem/features/challenge/data/challenge_repository.dart'; +// import 'package:haenaem/features/challenge/models/challenge_model.dart'; // import 'package:share_plus/share_plus.dart'; // [공통 위젯] 챌린지 초대 본문 (링크 공유 + 친구 검색 + 리스트) @@ -105,7 +109,7 @@ class _ChallengeInviteContentState Widget build(BuildContext context) { // 챌린지 전용 초대 정보 Provider 사용 final inviteInfoAsync = ref.watch( - challengeInviteInfoProvider(widget.challengeId), + challengeInviteProvider(widget.challengeId), ); // [디버깅 로그] 현재 상태 찍어보기 @@ -156,7 +160,7 @@ class _ChallengeInviteContentState } // 친구 리스트 빌더 메서드 분리 - Widget _buildFriendList(List friends) { + Widget _buildFriendList(List friends) { debugPrint('🔍 [검색어] "$_searchQuery"'); // 검색 필터링 final filteredFriends = friends.where((friend) { @@ -330,11 +334,11 @@ class _ChallengeInviteContentState } // 친구 리스트 아이템 + 실제 API 로직 - Widget _buildFriendInviteItem(ChallengeInviteFriend friend) { + Widget _buildFriendInviteItem(InviteFriend friend) { // 1. 서버에서 온 상태(friend.isInvited)이거나 // 2. 방금 내가 버튼 눌러서 초대한 상태(_newlyInvitedFriends 포함)인지 확인 bool isInvited = - friend.isInvited || _newlyInvitedFriends.contains(friend.userId); + friend.isInvited || _newlyInvitedFriends.contains(friend.id); return Padding( padding: const EdgeInsets.symmetric(vertical: 12), @@ -347,18 +351,14 @@ class _ChallengeInviteContentState decoration: BoxDecoration( shape: BoxShape.circle, color: const Color(0x7FDFE1DC), - image: - friend.profileImageUrl != null && - friend.profileImageUrl!.isNotEmpty + image: friend.profileUrl != null && friend.profileUrl!.isNotEmpty ? DecorationImage( - image: NetworkImage(friend.profileImageUrl!), + image: NetworkImage(friend.profileUrl!), fit: BoxFit.cover, ) : null, ), - child: - friend.profileImageUrl == null || - friend.profileImageUrl!.isEmpty + child: friend.profileUrl == null || friend.profileUrl!.isEmpty ? Center( child: SvgPicture.asset( 'assets/images/icons/default_profile_icon.svg', @@ -381,13 +381,13 @@ class _ChallengeInviteContentState try { // ★ API 호출 복구 완료 await ref - .read(challengeRepositoryProvider) + .read(challengeInviteRepositoryProvider) .inviteFriend(widget.challengeId, friend.nickname); if (!mounted) return; // 성공 시 '새로 초대된 목록'에 추가하여 버튼 비활성화 (낙관적 업데이트) - setState(() => _newlyInvitedFriends.add(friend.userId)); + setState(() => _newlyInvitedFriends.add(friend.id)); // 커스텀 Toast 사용 _showToast( diff --git a/lib/features/challenge/model/challenge_model.dart b/lib/features/challenge/model/challenge_model.dart deleted file mode 100644 index 1bf9885..0000000 --- a/lib/features/challenge/model/challenge_model.dart +++ /dev/null @@ -1,593 +0,0 @@ -// 최초 작성자: 강선욱 -// 챌린지 관련 데이터 관리 모델 -import 'package:intl/intl.dart'; -import 'package:flutter/foundation.dart'; -import 'package:haenaem/features/user/model/user_model.dart'; -import 'package:haenaem/features/challenge/model/image_model.dart'; - -// 챌린지의 상태(완료, 실패 위기, 일반)를 정의하는 열거형 -enum ChallengeStatus { - completed, // 초록색 카드 - urgent, // 빨간색 카드 - normal, // 회색 카드 -} - -// 메인화면에서 관리하는 챌린지 모델 -class ChallengeMainModel { - final int notificationNumber; - final List> myChallenges; - - ChallengeMainModel({ - required this.notificationNumber, - required this.myChallenges, - }); - - factory ChallengeMainModel.fromJson(Map json) { - return ChallengeMainModel( - notificationNumber: json['notificationNumber'] ?? 0, - myChallenges: List>.from(json['myChallenges'] ?? []), - ); - } - - /// 특정 인덱스의 챌린지 상태를 가져오는 함수 - ChallengeStatus getStatus(int index) { - final challenge = myChallenges[index]; - if (challenge['doIt'] == true) return ChallengeStatus.completed; - if (challenge['warning'] == true) return ChallengeStatus.urgent; - return ChallengeStatus.normal; - } - - /// 특정 인덱스의 참여 인원 정보를 반환하는 함수 - String getParticipantInfo(int index) { - final challenge = myChallenges[index]; - return "${challenge['todaySuccessCount']}/${challenge['participantNumber']}명"; - } -} - -// class ChallengeModel { -// final int challengeId; // 챌린지 id -// final String title; // 챌린지 제목 -// final String content; // 챌린지 소개 -// final int maxParticipantNumber; // 최대 인원수 -// final int participantNumber; // 참여 인원수 -// final int duringDate; // 시작 경과일 -// final bool isDoneToday; // API의 'doIt' 매핑 -// final bool isUrgent; // API의 'warning' 매핑 - -// ChallengeModel({ -// required this.challengeId, -// required this.title, -// required this.content, -// required this.maxParticipantNumber, -// required this.participantNumber, -// required this.duringDate, -// required this.isDoneToday, -// required this.isUrgent, -// }); - -// // API(Map) 데이터를 모델 객체로 변환하는 생성자 -// factory ChallengeModel.fromJson(Map json) { -// return ChallengeModel( -// challengeId: json['challengeId'] ?? 0, -// title: json['title'] ?? '', -// content: json['content'] ?? '', -// maxParticipantNumber: json['maxParticipantNumber'] ?? 0, -// participantNumber: json['participantNumber'] ?? 0, -// duringDate: json['duringDate'] ?? 0, -// isDoneToday: json['doIt'] ?? false, -// isUrgent: json['warning'] ?? false, -// ); -// } - -// // 함수의 용도: 모델의 데이터를 기반으로 UI에 표시할 상태값을 계산함 -// ChallengeStatus getStatus() { -// if (isDoneToday) { -// return ChallengeStatus.completed; // 완료 상태 (초록색) -// } else if (isUrgent) { -// return ChallengeStatus.urgent; // 긴급 상태 (빨간색) -// } -// return ChallengeStatus.normal; // 일반 상태 (회색) -// } -// } - -// 챌린지 상세정보에서 사용하는 챌린지 모델 -class ChallengeDetailModel { - final String title; - final String startDate; - final String endDate; - final int requiredWeeklyCount; // 인증 빈도수 - final bool photoRequired; - final List tags; - final String description; - final HostModel host; - final int participantCount; - final List todaySuccessUsers; - - ChallengeDetailModel({ - required this.title, - required this.startDate, - required this.endDate, - required this.requiredWeeklyCount, - required this.photoRequired, - required this.tags, - required this.description, - required this.host, - required this.participantCount, - required this.todaySuccessUsers, - }); - - factory ChallengeDetailModel.fromJson(Map json) { - return ChallengeDetailModel( - title: json['title'] ?? '', - startDate: json['startDate'] ?? '', - endDate: json['endDate'] ?? '', - requiredWeeklyCount: json['requiredWeeklyCount'] ?? 0, - photoRequired: json['photoRequired'] ?? false, - tags: (json['tags'] as List? ?? []).map((t) { - if (t is String) { - return ChallengeTagModel(id: 0, tag: t, tagCategory: 'ETC'); - } else if (t is Map) { - return ChallengeTagModel.fromJson(t); - } else { - return ChallengeTagModel(id: 0, tag: '', tagCategory: 'ETC'); - } - }).toList(), - description: json['description'] ?? '', - host: HostModel.fromJson(json['host'] ?? {}), - participantCount: json['participantCount'] ?? 0, - todaySuccessUsers: - (json['todaySuccessUsers'] as List?) - ?.map((e) => ParticipantModel.fromJson(e)) - .toList() ?? - [], - ); - } -} - -// 챌린지 생성 데이터 관리 클래스 -class ChallengeCreateResponse { - final int id; - final String challengeLink; - final List friends; - - ChallengeCreateResponse({ - required this.id, - required this.challengeLink, - required this.friends, - }); - - factory ChallengeCreateResponse.fromJson(Map json) { - final String link = json['challengeLink'] ?? ''; - int finalId = 0; - - // 💡 서버가 'id'를 주면 그걸 쓰고, 없거나 null이면 링크에서 숫자를 추출합니다. - if (json['id'] != null && json['id'] != 0) { - finalId = json['id']; - } else if (link.isNotEmpty) { - // 링크 예시: http://localhost:3000/challenges/14 - try { - finalId = int.parse(Uri.parse(link).pathSegments.last); - print( - '🎯 링크에서 ID 추출 성공: $finalId', - ); // TODO: 링크에서 id 추출하지 않고 백엔드한테 받아야 함. - } catch (e) { - // Uri 파싱이 안 될 경우를 대비한 split 백업 로직 - finalId = int.tryParse(link.split('/').last) ?? 0; - } - } - - return ChallengeCreateResponse( - id: finalId, - challengeLink: link, - friends: (json['friends'] as List? ?? []) - .map((f) => FriendModel.fromJson(f)) - .toList(), - ); - } -} - -// 챌린지 내 현황 탭 데이터 관리 클래스 -class ChallengeCalendarModel { - final int totalSuccessDays; - final int currentStreakDays; - final bool challengeOwner; - - ChallengeCalendarModel({ - required this.totalSuccessDays, - required this.currentStreakDays, - required this.challengeOwner, - }); - - factory ChallengeCalendarModel.fromJson(Map json) { - return ChallengeCalendarModel( - totalSuccessDays: json['totalSuccessDays'] ?? 0, - currentStreakDays: json['currentStreakDays'] ?? 0, - challengeOwner: json['challengeOwner'] ?? false, - ); - } -} - -// 챌린지 내 현황 달력 그리드 모델 -class ChallengeCalendarPhoto { - final int postId; - final String postDate; - final String? imageUrl; - - ChallengeCalendarPhoto({ - required this.postId, - required this.postDate, - this.imageUrl, - }); - - factory ChallengeCalendarPhoto.fromJson(Map json) { - return ChallengeCalendarPhoto( - postId: json['postId'] ?? 0, - postDate: json['postDate'] ?? '', - imageUrl: json['imageUrl'], - ); - } -} - -// 댓글 데이터 관리 -class ChallengeComment { - final int commentId; - final String userNickname; - final String? userPicture; - final String contents; - final DateTime? createdAt; - final DateTime? updatedAt; - final bool mine; - - ChallengeComment({ - required this.commentId, - required this.userNickname, - this.userPicture, - required this.contents, - required this.createdAt, - required this.updatedAt, - required this.mine, - }); - - factory ChallengeComment.fromJson(Map json) { - return ChallengeComment( - commentId: json['commentId'] ?? 0, - userNickname: json['userNickname'] ?? '익명', - userPicture: json['userPicture'], - contents: json['contents'] ?? '', - updatedAt: json['updatedAt'] != null - ? DateTime.parse(json['updatedAt']) - .toLocal() // 로컬 시간대 변환 추가 - : null, - createdAt: json['createdAt'] != null - ? DateTime.parse(json['createdAt']).toLocal() - : null, - mine: json['mine'] ?? false, - ); - } - - // 업데이트 시 상태 유지를 위한 copyWith - ChallengeComment copyWith({ - int? commentId, - String? userNickname, - String? userPicture, - String? contents, - DateTime? createdAt, - DateTime? updatedAt, - bool? mine, - }) { - return ChallengeComment( - commentId: commentId ?? this.commentId, - userNickname: userNickname ?? this.userNickname, - userPicture: userPicture ?? this.userPicture, - contents: contents ?? this.contents, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - mine: mine ?? this.mine, - ); - } -} - -// 개별 인증글 모델 -class CertificationPostModel { - final int postId; - final String postDate; - final int challengeId; - final String challengeTitle; - final int totalSuccessDays; - final String content; - final String? userNickname; - final String? userImageUrl; - final List images; - final DateTime? createdAt; - final DateTime? updatedAt; - final int likeNumber; - final int commentNumber; - final bool liked; - final bool author; - final List comments; - - String? get imageUrl => images.isNotEmpty ? images.first.imageUrl : null; - String? get userName => userNickname; - int get likeCount => likeNumber; - bool get hasImage => images.isNotEmpty; - - CertificationPostModel({ - required this.postId, - required this.postDate, - required this.challengeId, - required this.challengeTitle, - required this.totalSuccessDays, - required this.content, - required this.images, - this.userNickname, - this.userImageUrl, - this.createdAt, - this.updatedAt, - this.likeNumber = 0, - this.commentNumber = 0, - this.liked = false, - this.author = false, - this.comments = const [], - }); - - // 피드 탭에서 좋아요 수 업데이트를 위해 필요한 copyWith 메서드 - CertificationPostModel copyWith({ - int? postId, - String? postDate, - String? challengeTitle, - int? totalSuccessDays, - String? content, - String? userNickname, - String? userImageUrl, - List? images, - DateTime? createdAt, - DateTime? updatedAt, - int? likeNumber, - int? commentNumber, - bool? liked, - bool? author, - List? comments, - }) { - return CertificationPostModel( - postId: postId ?? this.postId, - challengeId: challengeId ?? challengeId, - postDate: postDate ?? this.postDate, - challengeTitle: challengeTitle ?? this.challengeTitle, - totalSuccessDays: totalSuccessDays ?? this.totalSuccessDays, - content: content ?? this.content, - userNickname: userNickname ?? this.userNickname, - userImageUrl: userImageUrl ?? this.userImageUrl, - images: images ?? this.images, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - likeNumber: likeNumber ?? this.likeNumber, - commentNumber: commentNumber ?? this.commentNumber, - liked: liked ?? this.liked, - author: author ?? this.author, - comments: comments ?? this.comments, - ); - } - - factory CertificationPostModel.fromJson(Map json) { - // 1. 상세 조회용 'images' 리스트 처리 (객체 형태) - List extractedImages = []; - - String dateStr = json['postDate'] ?? ""; - if (dateStr.isEmpty && json['createdAt'] != null) { - dateStr = json['createdAt'].toString().split('T').first; - } - - // 1. 신규 규격 (객체 리스트: images) 처리 - if (json['images'] != null && json['images'] is List) { - extractedImages = (json['images'] as List) - .map((item) => PostImage.fromJson(item)) - .toList(); - } - // 2. 구 규격 대응 (문자열 리스트 혹은 단일 URL일 경우 ID 0으로 생성) - else if (json['imageUrl'] != null) { - extractedImages.add(PostImage(imageId: 0, imageUrl: json['imageUrl'])); - } else if (json['articleImageUrl'] != null && - json['articleImageUrl'] is List) { - extractedImages = (json['articleImageUrl'] as List) - .map((url) => PostImage(imageId: 0, imageUrl: url.toString())) - .toList(); - } - - return CertificationPostModel( - postId: json['postId'] ?? 0, - postDate: json['postDate'] ?? dateStr, - challengeId: json['challengeId'] ?? 0, - challengeTitle: json['challengeTitle'] ?? '제목 없음', - totalSuccessDays: json['totalSuccessDays'] ?? 0, - content: json['content'] ?? '', - images: extractedImages, // 💡 사진이 없으면 빈 리스트 [] 가 됩니다. - userNickname: json['userNickname'] ?? '익명', - userImageUrl: json['userImageUrl'], - updatedAt: json['updatedAt'] != null - ? DateTime.parse(json['updatedAt'].toString()).toLocal() - : null, - createdAt: json['createdAt'] != null - ? DateTime.parse(json['createdAt'].toString()).toLocal() - : null, - likeNumber: json['likeNumber'] ?? 0, - commentNumber: json['commentNumber'] ?? 0, - liked: json['liked'] ?? false, - author: json['author'] ?? false, - comments: (json['comments'] as List? ?? []) - .map((c) => ChallengeComment.fromJson(c)) - .toList(), - ); - } -} - -// 마이페이지 탭 구분을 위한 전용 이름 -enum MyPageTab { inProgress, success, fail } - -// 내 페이지 - 나의 챌린지 - 진행중인 챌린지 -class ChallengeInProgressModel { - final int challengeId; - final String title; - final int requiredWeeklyCount; // 필수는 유지하되 - final int todaySuccessCount; - final int participantNumber; - final int duringDate; - final String endDate; - final double achievementRate; - final String status; - - ChallengeInProgressModel({ - required this.challengeId, - required this.title, - required this.requiredWeeklyCount, - required this.todaySuccessCount, - required this.participantNumber, - required this.duringDate, - required this.endDate, - required this.achievementRate, - required this.status, - }); - - factory ChallengeInProgressModel.fromJson(Map json) { - double rate = (json['achievementRate'] ?? 0).toDouble(); - - // 💡 방어 로직: 0%일 때 직접 계산하는 로직에서도 null 체크 강화 - final int today = json['todaySuccessCount'] ?? 0; - final int weekly = json['requiredWeeklyCount'] ?? 0; - - if (rate == 0 && weekly > 0) { - rate = today / weekly; - } else if (rate > 1.0) { - rate = rate / 100.0; - } - - return ChallengeInProgressModel( - challengeId: json['challengeId'] ?? 0, - title: json['title'] ?? '', - requiredWeeklyCount: json['requiredWeeklyCount'] ?? 0, - todaySuccessCount: json['todaySuccessCount'] ?? 0, - participantNumber: json['participantNumber'] ?? 0, - duringDate: json['duringDate'] ?? 0, - endDate: json['endDate'] ?? '', - achievementRate: rate, - status: json['status'] ?? 'IN_PROGRESS', - ); - } - - // 기존 UI 위젯 수정을 최소화하기 위한 Getter - String get dateInfo => "완료일까지 D-${_calculateDDay()}"; - String get countInfo => "$todaySuccessCount/$participantNumber명"; - double get progress => achievementRate; - - int _calculateDDay() { - try { - final end = DateTime.parse(endDate); - final dDay = end.difference(DateTime.now()).inDays; - return dDay < 0 ? 0 : dDay; - } catch (_) { - return 0; - } - } -} - -// 챌린지 검색 -// TODO: 챌린지 아이디 부분 수정 -class SearchChallengeModel { - final int challengeId; - final String title; - final int participantNumber; - final int requiredWeeklyCount; - final bool photoRequired; - final List tags; - - SearchChallengeModel({ - required this.challengeId, - required this.title, - required this.participantNumber, - required this.requiredWeeklyCount, - required this.photoRequired, - required this.tags, - }); - - factory SearchChallengeModel.fromJson(Map json) { - return SearchChallengeModel( - challengeId: json['id'] ?? 0, - title: json['title'] ?? '', - participantNumber: json['participantNumber'] ?? 0, - requiredWeeklyCount: json['requiredWeeklyCount'] ?? 0, - photoRequired: json['photoRequired'] ?? true, - tags: (json['tags'] as List? ?? []) - .map((t) => ChallengeTagModel.fromJson(t)) - .toList(), - ); - } -} - -// 태그 모델 -class ChallengeTagModel { - final int id; - final String tag; - final String tagCategory; - - int get tagId => id; - - ChallengeTagModel({ - required this.id, - required this.tag, - required this.tagCategory, - }); - - factory ChallengeTagModel.fromJson(Map json) { - return ChallengeTagModel( - id: json['tagId'] ?? 0, - tag: json['tag'] ?? '', - tagCategory: json['tagCategory'] ?? 'AGE', - ); - } -} - -// 챌린지 초대 탭 응답 모델 (GET /api/challenges/{challengeId}/invite) -class ChallengeInviteResponse { - final String challengeLink; // 초대 링크 - final List friends; // 친구 목록 (초대 상태 포함) - - ChallengeInviteResponse({required this.challengeLink, required this.friends}); - - factory ChallengeInviteResponse.fromJson(Map json) { - return ChallengeInviteResponse( - // 초대 링크 매핑 - challengeLink: json['inviteLink'] ?? '', - - friends: ((json['responseList'] ?? []) as List) - .map((e) => ChallengeInviteFriend.fromJson(e)) - .toList(), - ); - } -} - -class ChallengeInviteFriend { - final int userId; - final String nickname; - final String? profileImageUrl; - final bool isInvited; // 이미 초대되었는지 여부 - - ChallengeInviteFriend({ - required this.userId, - required this.nickname, - this.profileImageUrl, - required this.isInvited, - }); - - factory ChallengeInviteFriend.fromJson(Map json) { - return ChallengeInviteFriend( - // API 명세: a. 유저id - userId: json['userId'] ?? 0, - // API 명세: b. 유저 닉네임 - nickname: json['nickname'] ?? '', - // API 명세: b. 유저 프로필 이미지 url - profileImageUrl: json['profileImageUrl'], - // API 명세: c. 이미 해당 챌린지에 초대되었는지에 대한 여부 - // 초대 상태 체크 ('INVITED' 문자열이거나 true일 경우) - isInvited: json['inviteStatus'] == 'INVITED', - ); - } -} diff --git a/lib/features/challenge/model/image_model.dart b/lib/features/challenge/model/image_model.dart deleted file mode 100644 index 780dd22..0000000 --- a/lib/features/challenge/model/image_model.dart +++ /dev/null @@ -1,17 +0,0 @@ -// 리팩토링: 강선욱 -// 이미지 관련 정보 관리 클래스 - -// 인증글 사진 정보를 관리하는 클래스 -class PostImage { - final int imageId; - final String imageUrl; - - PostImage({required this.imageId, required this.imageUrl}); - - factory PostImage.fromJson(Map json) { - return PostImage( - imageId: json['imageId'] ?? 0, - imageUrl: json['imageUrl'] ?? '', - ); - } -} diff --git a/lib/features/challenge/models/challenge_model.dart b/lib/features/challenge/models/challenge_model.dart new file mode 100644 index 0000000..d413a6f --- /dev/null +++ b/lib/features/challenge/models/challenge_model.dart @@ -0,0 +1,613 @@ +// // 최초 작성자: 강선욱 +// // 챌린지 관련 데이터 관리 모델 +// import 'package:intl/intl.dart'; +// import 'package:flutter/foundation.dart'; +// import 'package:haenaem/features/user/models/user_model.dart'; +// import 'package:haenaem/features/challenge/models/image_model.dart'; + +// // 챌린지의 상태(완료, 실패 위기, 일반)를 정의하는 열거형 +// enum ChallengeStatus { +// completed, // 초록색 카드 +// urgent, // 빨간색 카드 +// normal, // 회색 카드 +// } + +// // 메인화면에서 관리하는 챌린지 모델 +// @Deprecated( +// 'challenge/models/home_challenge_card.dart에 정의된 모델 대신 사용 / notificationNumber 변수는 따로 관리', +// ) +// class ChallengeMainModel { +// final int notificationNumber; +// final List> myChallenges; + +// ChallengeMainModel({ +// required this.notificationNumber, +// required this.myChallenges, +// }); + +// factory ChallengeMainModel.fromJson(Map json) { +// return ChallengeMainModel( +// notificationNumber: json['notificationNumber'] ?? 0, +// myChallenges: List>.from(json['myChallenges'] ?? []), +// ); +// } + +// /// 특정 인덱스의 챌린지 상태를 가져오는 함수 +// ChallengeStatus getStatus(int index) { +// final challenge = myChallenges[index]; +// if (challenge['doIt'] == true) return ChallengeStatus.completed; +// if (challenge['warning'] == true) return ChallengeStatus.urgent; +// return ChallengeStatus.normal; +// } + +// /// 특정 인덱스의 참여 인원 정보를 반환하는 함수 +// String getParticipantInfo(int index) { +// final challenge = myChallenges[index]; +// return "${challenge['todaySuccessCount']}/${challenge['participantNumber']}명"; +// } +// } + +// // class ChallengeModel { +// // final int challengeId; // 챌린지 id +// // final String title; // 챌린지 제목 +// // final String content; // 챌린지 소개 +// // final int maxParticipantNumber; // 최대 인원수 +// // final int participantNumber; // 참여 인원수 +// // final int duringDate; // 시작 경과일 +// // final bool isDoneToday; // API의 'doIt' 매핑 +// // final bool isUrgent; // API의 'warning' 매핑 + +// // ChallengeModel({ +// // required this.challengeId, +// // required this.title, +// // required this.content, +// // required this.maxParticipantNumber, +// // required this.participantNumber, +// // required this.duringDate, +// // required this.isDoneToday, +// // required this.isUrgent, +// // }); + +// // // API(Map) 데이터를 모델 객체로 변환하는 생성자 +// // factory ChallengeModel.fromJson(Map json) { +// // return ChallengeModel( +// // challengeId: json['challengeId'] ?? 0, +// // title: json['title'] ?? '', +// // content: json['content'] ?? '', +// // maxParticipantNumber: json['maxParticipantNumber'] ?? 0, +// // participantNumber: json['participantNumber'] ?? 0, +// // duringDate: json['duringDate'] ?? 0, +// // isDoneToday: json['doIt'] ?? false, +// // isUrgent: json['warning'] ?? false, +// // ); +// // } + +// // // 함수의 용도: 모델의 데이터를 기반으로 UI에 표시할 상태값을 계산함 +// // ChallengeStatus getStatus() { +// // if (isDoneToday) { +// // return ChallengeStatus.completed; // 완료 상태 (초록색) +// // } else if (isUrgent) { +// // return ChallengeStatus.urgent; // 긴급 상태 (빨간색) +// // } +// // return ChallengeStatus.normal; // 일반 상태 (회색) +// // } +// // } + +// // 챌린지 상세정보에서 사용하는 챌린지 모델 +// @Deprecated('shared/models/challenge_detail.dart에 있는 ChallengeDetail 모델 대신 사용') +// class ChallengeDetailModel { +// final String title; +// final String startDate; +// final String endDate; +// final int requiredWeeklyCount; // 인증 빈도수 +// final bool photoRequired; +// final List tags; +// final String description; +// final HostModel host; +// final int participantCount; +// final List todaySuccessUsers; + +// ChallengeDetailModel({ +// required this.title, +// required this.startDate, +// required this.endDate, +// required this.requiredWeeklyCount, +// required this.photoRequired, +// required this.tags, +// required this.description, +// required this.host, +// required this.participantCount, +// required this.todaySuccessUsers, +// }); + +// factory ChallengeDetailModel.fromJson(Map json) { +// return ChallengeDetailModel( +// title: json['title'] ?? '', +// startDate: json['startDate'] ?? '', +// endDate: json['endDate'] ?? '', +// requiredWeeklyCount: json['requiredWeeklyCount'] ?? 0, +// photoRequired: json['photoRequired'] ?? false, +// tags: (json['tags'] as List? ?? []).map((t) { +// if (t is String) { +// return ChallengeTagModel(id: 0, tag: t, tagCategory: 'ETC'); +// } else if (t is Map) { +// return ChallengeTagModel.fromJson(t); +// } else { +// return ChallengeTagModel(id: 0, tag: '', tagCategory: 'ETC'); +// } +// }).toList(), +// description: json['description'] ?? '', +// host: HostModel.fromJson(json['host'] ?? {}), +// participantCount: json['participantCount'] ?? 0, +// todaySuccessUsers: +// (json['todaySuccessUsers'] as List?) +// ?.map((e) => ParticipantModel.fromJson(e)) +// .toList() ?? +// [], +// ); +// } +// } + +// // 챌린지 생성 데이터 관리 클래스 +// // @Deprecated('사용 X') +// // class ChallengeCreateResponse { +// // final int id; +// // final String challengeLink; +// // final List friends; + +// // ChallengeCreateResponse({ +// // required this.id, +// // required this.challengeLink, +// // required this.friends, +// // }); + +// // factory ChallengeCreateResponse.fromJson(Map json) { +// // final String link = json['challengeLink'] ?? ''; +// // int finalId = 0; + +// // // 💡 서버가 'id'를 주면 그걸 쓰고, 없거나 null이면 링크에서 숫자를 추출합니다. +// // if (json['id'] != null && json['id'] != 0) { +// // finalId = json['id']; +// // } else if (link.isNotEmpty) { +// // // 링크 예시: http://localhost:3000/challenges/14 +// // try { +// // finalId = int.parse(Uri.parse(link).pathSegments.last); +// // print( +// // '🎯 링크에서 ID 추출 성공: $finalId', +// // ); // TODO: 링크에서 id 추출하지 않고 백엔드한테 받아야 함. +// // } catch (e) { +// // // Uri 파싱이 안 될 경우를 대비한 split 백업 로직 +// // finalId = int.tryParse(link.split('/').last) ?? 0; +// // } +// // } + +// // return ChallengeCreateResponse( +// // id: finalId, +// // challengeLink: link, +// // friends: (json['friends'] as List? ?? []) +// // .map((f) => FriendModel.fromJson(f)) +// // .toList(), +// // ); +// // } +// // } + +// // 챌린지 내 현황 탭 데이터 관리 클래스 +// @Deprecated('사용 X') +// class ChallengeCalendarModel { +// final int totalSuccessDays; +// final int currentStreakDays; +// final bool challengeOwner; + +// ChallengeCalendarModel({ +// required this.totalSuccessDays, +// required this.currentStreakDays, +// required this.challengeOwner, +// }); + +// factory ChallengeCalendarModel.fromJson(Map json) { +// return ChallengeCalendarModel( +// totalSuccessDays: json['totalSuccessDays'] ?? 0, +// currentStreakDays: json['currentStreakDays'] ?? 0, +// challengeOwner: json['challengeOwner'] ?? false, +// ); +// } +// } + +// // 챌린지 내 현황 달력 그리드 모델 +// @Deprecated('feed/models/post.dart에 있는 모델 대신 사용') +// class ChallengeCalendarPhoto { +// final int postId; +// final String postDate; +// final String? imageUrl; + +// ChallengeCalendarPhoto({ +// required this.postId, +// required this.postDate, +// this.imageUrl, +// }); + +// factory ChallengeCalendarPhoto.fromJson(Map json) { +// return ChallengeCalendarPhoto( +// postId: json['postId'] ?? 0, +// postDate: json['postDate'] ?? '', +// imageUrl: json['imageUrl'], +// ); +// } +// } + +// // 댓글 데이터 관리 +// @Deprecated('feed/models/comment.dart 내부에 정의된 Comment 모델 대신 사용') +// class ChallengeComment { +// final int commentId; +// final String userNickname; +// final String? userPicture; +// final String contents; +// final DateTime? createdAt; +// final DateTime? updatedAt; +// final bool mine; + +// ChallengeComment({ +// required this.commentId, +// required this.userNickname, +// this.userPicture, +// required this.contents, +// required this.createdAt, +// required this.updatedAt, +// required this.mine, +// }); + +// factory ChallengeComment.fromJson(Map json) { +// return ChallengeComment( +// commentId: json['commentId'] ?? 0, +// userNickname: json['userNickname'] ?? '익명', +// userPicture: json['userPicture'], +// contents: json['contents'] ?? '', +// updatedAt: json['updatedAt'] != null +// ? DateTime.parse(json['updatedAt']) +// .toLocal() // 로컬 시간대 변환 추가 +// : null, +// createdAt: json['createdAt'] != null +// ? DateTime.parse(json['createdAt']).toLocal() +// : null, +// mine: json['mine'] ?? false, +// ); +// } + +// // 업데이트 시 상태 유지를 위한 copyWith +// ChallengeComment copyWith({ +// int? commentId, +// String? userNickname, +// String? userPicture, +// String? contents, +// DateTime? createdAt, +// DateTime? updatedAt, +// bool? mine, +// }) { +// return ChallengeComment( +// commentId: commentId ?? this.commentId, +// userNickname: userNickname ?? this.userNickname, +// userPicture: userPicture ?? this.userPicture, +// contents: contents ?? this.contents, +// createdAt: createdAt ?? this.createdAt, +// updatedAt: updatedAt ?? this.updatedAt, +// mine: mine ?? this.mine, +// ); +// } +// } + +// // 개별 인증글 모델 +// @Deprecated('feed/models/post.dart 내부에 정의된 Post 모델을 대신 사용') +// class CertificationPostModel { +// final int postId; +// final String postDate; +// final int challengeId; +// final String challengeTitle; +// final int totalSuccessDays; +// final String content; +// final String? userNickname; +// final String? userImageUrl; +// final List images; +// final DateTime? createdAt; +// final DateTime? updatedAt; +// final int likeNumber; +// final int commentNumber; +// final bool liked; +// final bool author; +// final List comments; + +// String? get imageUrl => images.isNotEmpty ? images.first.imageUrl : null; +// String? get userName => userNickname; +// int get likeCount => likeNumber; +// bool get hasImage => images.isNotEmpty; + +// CertificationPostModel({ +// required this.postId, +// required this.postDate, +// required this.challengeId, +// required this.challengeTitle, +// required this.totalSuccessDays, +// required this.content, +// required this.images, +// this.userNickname, +// this.userImageUrl, +// this.createdAt, +// this.updatedAt, +// this.likeNumber = 0, +// this.commentNumber = 0, +// this.liked = false, +// this.author = false, +// this.comments = const [], +// }); + +// // 피드 탭에서 좋아요 수 업데이트를 위해 필요한 copyWith 메서드 +// CertificationPostModel copyWith({ +// int? postId, +// String? postDate, +// String? challengeTitle, +// int? totalSuccessDays, +// String? content, +// String? userNickname, +// String? userImageUrl, +// List? images, +// DateTime? createdAt, +// DateTime? updatedAt, +// int? likeNumber, +// int? commentNumber, +// bool? liked, +// bool? author, +// List? comments, +// }) { +// return CertificationPostModel( +// postId: postId ?? this.postId, +// challengeId: challengeId ?? challengeId, +// postDate: postDate ?? this.postDate, +// challengeTitle: challengeTitle ?? this.challengeTitle, +// totalSuccessDays: totalSuccessDays ?? this.totalSuccessDays, +// content: content ?? this.content, +// userNickname: userNickname ?? this.userNickname, +// userImageUrl: userImageUrl ?? this.userImageUrl, +// images: images ?? this.images, +// createdAt: createdAt ?? this.createdAt, +// updatedAt: updatedAt ?? this.updatedAt, +// likeNumber: likeNumber ?? this.likeNumber, +// commentNumber: commentNumber ?? this.commentNumber, +// liked: liked ?? this.liked, +// author: author ?? this.author, +// comments: comments ?? this.comments, +// ); +// } + +// factory CertificationPostModel.fromJson(Map json) { +// // 1. 상세 조회용 'images' 리스트 처리 (객체 형태) +// List extractedImages = []; + +// String dateStr = json['postDate'] ?? ""; + +// if ((json['postDate'] == null || json['postDate'] == "") && +// json['createdAt'] != null) { +// dateStr = json['createdAt'].toString().split('T').first; +// } + +// // 1. 신규 규격 (객체 리스트: images) 처리 +// if (json['images'] != null && json['images'] is List) { +// extractedImages = (json['images'] as List) +// .map((item) => PostImage.fromJson(item)) +// .toList(); +// } +// // 2. 구 규격 대응 (문자열 리스트 혹은 단일 URL일 경우 ID 0으로 생성) +// else if (json['imageUrl'] != null) { +// extractedImages.add(PostImage(imageId: 0, imageUrl: json['imageUrl'])); +// } else if (json['articleImageUrl'] != null && +// json['articleImageUrl'] is List) { +// extractedImages = (json['articleImageUrl'] as List) +// .map((url) => PostImage(imageId: 0, imageUrl: url.toString())) +// .toList(); +// } + +// return CertificationPostModel( +// postId: json['postId'] ?? 0, +// postDate: (json['postDate'] != null && json['postDate'] != "") +// ? json['postDate'] +// : dateStr, +// challengeId: json['challengeId'] ?? 0, +// challengeTitle: json['challengeTitle'] ?? '제목 없음', +// totalSuccessDays: json['totalSuccessDays'] ?? 0, +// content: json['content'] ?? '', +// images: extractedImages, // 💡 사진이 없으면 빈 리스트 [] 가 됩니다. +// userNickname: json['userNickname'] ?? '익명', +// userImageUrl: json['userImageUrl'], +// updatedAt: json['updatedAt'] != null +// ? DateTime.parse(json['updatedAt'].toString()).toLocal() +// : null, +// createdAt: json['createdAt'] != null +// ? DateTime.parse(json['createdAt'].toString()).toLocal() +// : null, +// likeNumber: json['likeNumber'] ?? 0, +// commentNumber: json['commentNumber'] ?? 0, +// liked: json['liked'] ?? false, +// author: json['author'] ?? false, +// comments: (json['comments'] as List? ?? []) +// .map((c) => ChallengeComment.fromJson(c)) +// .toList(), +// ); +// } +// } + +// // 마이페이지 탭 구분을 위한 전용 이름 +// // enum MyPageTab { inProgress, success, fail } + +// // 내 페이지 - 나의 챌린지 - 진행중인 챌린지 +// // // 리팩토링 완료 +// // @Deprecated( +// // 'user/models/my_page_challenge_card.dart내에 MyPageChallengeCard 모델 대신 사용', +// // ) +// // class ChallengeInProgressModel { +// // final int challengeId; +// // final String title; +// // final int requiredWeeklyCount; // 필수는 유지하되 +// // final int todaySuccessCount; +// // final int participantNumber; +// // final int duringDate; +// // final String endDate; +// // final double achievementRate; +// // final String status; + +// // ChallengeInProgressModel({ +// // required this.challengeId, +// // required this.title, +// // required this.requiredWeeklyCount, +// // required this.todaySuccessCount, +// // required this.participantNumber, +// // required this.duringDate, +// // required this.endDate, +// // required this.achievementRate, +// // required this.status, +// // }); + +// // factory ChallengeInProgressModel.fromJson(Map json) { +// // double rate = (json['achievementRate'] ?? 0).toDouble(); + +// // // 💡 방어 로직: 0%일 때 직접 계산하는 로직에서도 null 체크 강화 +// // final int today = json['todaySuccessCount'] ?? 0; +// // final int weekly = json['requiredWeeklyCount'] ?? 0; + +// // if (rate == 0 && weekly > 0) { +// // rate = today / weekly; +// // } else if (rate > 1.0) { +// // rate = rate / 100.0; +// // } + +// // return ChallengeInProgressModel( +// // challengeId: json['challengeId'] ?? 0, +// // title: json['title'] ?? '', +// // requiredWeeklyCount: json['requiredWeeklyCount'] ?? 0, +// // todaySuccessCount: json['todaySuccessCount'] ?? 0, +// // participantNumber: json['participantNumber'] ?? 0, +// // duringDate: json['duringDate'] ?? 0, +// // endDate: json['endDate'] ?? '', +// // achievementRate: rate, +// // status: json['status'] ?? 'IN_PROGRESS', +// // ); +// // } + +// // // 기존 UI 위젯 수정을 최소화하기 위한 Getter +// // String get dateInfo => "완료일까지 D-${_calculateDDay()}"; +// // String get countInfo => "$todaySuccessCount/$participantNumber명"; +// // double get progress => achievementRate; + +// // int _calculateDDay() { +// // try { +// // final end = DateTime.parse(endDate); +// // final dDay = end.difference(DateTime.now()).inDays; +// // return dDay < 0 ? 0 : dDay; +// // } catch (_) { +// // return 0; +// // } +// // } +// // } + +// // 챌린지 검색 +// // TODO: 챌린지 아이디 부분 수정 +// @Deprecated( +// 'shared/models/search_challenge_card.dart에 있는 SearchChallengeCard 모델 대신 사용', +// ) +// class SearchChallengeModel { +// final int challengeId; +// final String title; +// final int participantNumber; +// final int requiredWeeklyCount; +// final bool photoRequired; +// final List tags; + +// SearchChallengeModel({ +// required this.challengeId, +// required this.title, +// required this.participantNumber, +// required this.requiredWeeklyCount, +// required this.photoRequired, +// required this.tags, +// }); + +// factory SearchChallengeModel.fromJson(Map json) { +// return SearchChallengeModel( +// challengeId: json['id'] ?? 0, +// title: json['title'] ?? '', +// participantNumber: json['participantNumber'] ?? 0, +// requiredWeeklyCount: json['requiredWeeklyCount'] ?? 0, +// photoRequired: json['photoRequired'] ?? true, +// tags: (json['tags'] as List? ?? []) +// .map((t) => ChallengeTagModel.fromJson(t)) +// .toList(), +// ); +// } +// } + +// // 태그 모델 +// class ChallengeTagModel { +// final int id; +// final String tag; +// final String tagCategory; + +// int get tagId => id; + +// ChallengeTagModel({ +// required this.id, +// required this.tag, +// required this.tagCategory, +// }); + +// factory ChallengeTagModel.fromJson(Map json) { +// return ChallengeTagModel( +// id: json['tagId'] ?? 0, +// tag: json['tag'] ?? '', +// tagCategory: json['tagCategory'] ?? 'AGE', +// ); +// } +// } + +// // 챌린지 초대 탭 응답 모델 (GET /api/challenges/{challengeId}/invite) +// class ChallengeInviteResponse { +// final String challengeLink; // 초대 링크 +// final List friends; // 친구 목록 (초대 상태 포함) + +// ChallengeInviteResponse({required this.challengeLink, required this.friends}); + +// factory ChallengeInviteResponse.fromJson(Map json) { +// return ChallengeInviteResponse( +// // 초대 링크 매핑 +// challengeLink: json['inviteLink'] ?? '', + +// friends: ((json['responseList'] ?? []) as List) +// .map((e) => ChallengeInviteFriend.fromJson(e)) +// .toList(), +// ); +// } +// } + +// class ChallengeInviteFriend { +// final int userId; +// final String nickname; +// final String? profileImageUrl; +// final bool isInvited; // 이미 초대되었는지 여부 + +// ChallengeInviteFriend({ +// required this.userId, +// required this.nickname, +// this.profileImageUrl, +// required this.isInvited, +// }); + +// factory ChallengeInviteFriend.fromJson(Map json) { +// return ChallengeInviteFriend( +// // API 명세: a. 유저id +// userId: json['userId'] ?? 0, +// // API 명세: b. 유저 닉네임 +// nickname: json['nickname'] ?? '', +// // API 명세: b. 유저 프로필 이미지 url +// profileImageUrl: json['profileImageUrl'], +// // API 명세: c. 이미 해당 챌린지에 초대되었는지에 대한 여부 +// // 초대 상태 체크 ('INVITED' 문자열이거나 true일 경우) +// isInvited: json['inviteStatus'] == 'INVITED', +// ); +// } +// } diff --git a/lib/features/challenge/models/image_model.dart b/lib/features/challenge/models/image_model.dart new file mode 100644 index 0000000..5d13c7e --- /dev/null +++ b/lib/features/challenge/models/image_model.dart @@ -0,0 +1,18 @@ +// // 리팩토링: 강선욱 +// // 이미지 관련 정보 관리 클래스 + +// // 인증글 사진 정보를 관리하는 클래스 +// @Deprecated('shared/models/post.dart에 함께 정의. 이 모델은 사용 X') +// class PostImage { +// final int imageId; +// final String imageUrl; + +// PostImage({required this.imageId, required this.imageUrl}); + +// factory PostImage.fromJson(Map json) { +// return PostImage( +// imageId: json['imageId'] ?? 0, +// imageUrl: json['imageUrl'] ?? '', +// ); +// } +// } diff --git a/lib/features/challenge/models/rank_card.dart b/lib/features/challenge/models/rank_card.dart new file mode 100644 index 0000000..aa79c20 --- /dev/null +++ b/lib/features/challenge/models/rank_card.dart @@ -0,0 +1,41 @@ +// import 'package:haenaem/shared/models/user.dart'; + +// // 최초 작성자: 강선욱 +// // 랭킹 카드 모델 클래스 +// // User에 정의된 필드(id, profileUrl, nickname)를 토대로 랭킹 화면에서 필요한 데이터로 구성 +// class RankCard { +// final User user; // id, profileUrl, nickname +// final int rank; // 사용자 순위 +// final int totalSuccessCount; // 총 인증 완료 횟수 +// final int streakCount; // 최근 인증 연속 횟수 + +// const RankCard({ +// required this.user, +// required this.rank, +// required this.totalSuccessCount, +// required this.streakCount, +// }); + +// factory RankCard.fromJson(Map json) { +// return RankCard( +// user: User.fromJson(json), +// rank: json['rank'] as int, +// totalSuccessCount: json['total_success_count'] as int, +// streakCount: json['streak_count'] as int, +// ); +// } + +// RankCard copyWith({ +// User? user, +// int? rank, +// int? totalSuccessCount, +// int? streakCount, +// }) { +// return RankCard( +// user: user ?? this.user, +// rank: rank ?? this.rank, +// totalSuccessCount: totalSuccessCount ?? this.totalSuccessCount, +// streakCount: streakCount ?? this.streakCount, +// ); +// } +// } diff --git a/lib/features/challenge/provider/challenge_provider.dart b/lib/features/challenge/provider/challenge_provider.dart deleted file mode 100644 index e2849d7..0000000 --- a/lib/features/challenge/provider/challenge_provider.dart +++ /dev/null @@ -1,536 +0,0 @@ -// 최초 작성자 : 강선욱 -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../data/challenge_repository.dart'; -import 'package:haenaem/features/user/data/user_repository.dart'; -import '../model/challenge_model.dart'; -import 'package:haenaem/features/user/model/user_model.dart'; -import 'dart:io'; - -part 'challenge_provider.g.dart'; - -// 전체 데이터를 가져오는 비동기 Provider -@riverpod -class ChallengeHomeNotifier extends _$ChallengeHomeNotifier { - @override - FutureOr build() async { - // Repository를 통해 데이터를 가져옵니다. - final repository = ref.watch(challengeRepositoryProvider); - final String todayDate = _getFormattedDate(DateTime.now()); - // print(repository.getChallengeMainData(todayDate)); - return repository.getChallengeMainData(todayDate); - } - - // 새로고침 기능 - Future refresh() async { - state = const AsyncValue.loading(); - state = await AsyncValue.guard(() { - final String todayDate = _getFormattedDate(DateTime.now()); - return ref - .read(challengeRepositoryProvider) - .getChallengeMainData(todayDate); - }); - } - - // 내부 날짜 포맷팅 유틸리티 - String _getFormattedDate(DateTime dateTime) { - return "${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')}"; - } -} - -// 챌린지 나가기 로직 (일반 멤버용) -@riverpod -class ChallengeLeaveNotifier extends _$ChallengeLeaveNotifier { - @override - AsyncValue build() => const AsyncValue.data(null); - - Future leaveChallenge(int challengeId) async { - state = const AsyncValue.loading(); - - final result = await AsyncValue.guard( - () => ref.read(challengeRepositoryProvider).leaveChallenge(challengeId), - ); - - state = result; - - if (!result.hasError) { - // 💡 나가기 성공 시 관련 데이터들을 무효화하여 UI를 갱신합니다. - ref.invalidate(challengeHomeNotifierProvider); // 홈 리스트 갱신 - // 나갔을 때 실패한 챌린지로 넣을거면 로직을 추가해야함 - ref.invalidate(myInProgressChallengesProvider); // 내 페이지 리스트 갱신 - return true; - } - - return false; - } -} - -// 특정 챌린지 상세 정보를 가져오는 Provider -@riverpod -Future challengeDetail( - Ref ref, { - required int challengeId, -}) async { - final repository = ref.watch(challengeRepositoryProvider); - return repository.getChallengeDetail(challengeId); // 레포지토리 리턴 타입도 맞춰주세요. -} - -// 오늘의 종합 상태만 따로 계산하는 파생 Provider -@riverpod -ChallengeStatus todayTotalStatus(TodayTotalStatusRef ref) { - final homeDataAsync = ref.watch(challengeHomeNotifierProvider); - - return homeDataAsync.maybeWhen( - data: (data) { - final challenges = data.myChallenges; - if (challenges.isEmpty) return ChallengeStatus.normal; - - // 하나라도 긴급(warning)이 있으면 Urgent - if (challenges.any((c) => c['warning'] == true)) - return ChallengeStatus.urgent; - - // 모든 챌린지가 오늘 완료(doIt)되었으면 Completed - if (challenges.every((c) => c['doIt'] == true)) - return ChallengeStatus.completed; - - return ChallengeStatus.normal; - }, - orElse: () => ChallengeStatus.normal, - ); -} - -// 서버 태그 목록 불러오기 -@riverpod -Future> allTags(AllTagsRef ref) { - return ref.watch(userRepositoryProvider).getAllTags(); -} - -// 생성 상태(로딩/성공/에러)를 관리할 notifier 추가 -@riverpod -class ChallengeCreateNotifier extends _$ChallengeCreateNotifier { - @override - AsyncValue build() => const AsyncValue.data(null); - - // Future를 반환하도록 수정 - Future create(Map data) async { - state = const AsyncValue.loading(); - - // AsyncValue.guard의 결과를 변수에 담습니다. - state = await AsyncValue.guard( - () => ref.read(challengeRepositoryProvider).createChallenge(data), - ); - - // 💡 UI에서 결과를 기다릴 수 있게 value(성공 시 데이터)를 반환합니다. - return state.valueOrNull; - } -} - -// 특정 챌린지의 ID를 기반으로 데이터를 가져옴 -@riverpod -Future challengeCalendarData( - ChallengeCalendarDataRef ref, - int challengeId, -) async { - final repository = ref.watch(challengeRepositoryProvider); - return repository.getChallengeCalendarData(challengeId); -} - -// 특정 챌린지 ID, 연도, 월에 따라 데이터를 캐싱 -@riverpod -Future> challengePosts( - ChallengePostsRef ref, { - required int challengeId, - required int year, - required int month, -}) async { - final repository = ref.watch(challengeRepositoryProvider); - return repository.getChallengePosts( - challengeId: challengeId, - year: year, - month: month, - ); -} - -// 챌린지 캘린더 사진 -@riverpod -Future> challengeCalendarPhotos( - ChallengeCalendarPhotosRef ref, { - required int challengeId, - required int year, - required int month, -}) async { - final repository = ref.watch(challengeRepositoryProvider); - return repository.getChallengeCalendarPhotos( - challengeId: challengeId, - year: year, - month: month, - ); -} - -// 챌린지 인증글 생성 로직 -@riverpod -class ArticleCreateNotifier extends _$ArticleCreateNotifier { - @override - AsyncValue build() => const AsyncValue.data(null); - - Future submitArticle({ - required int challengeId, - required String content, - required List tempImageIds, // 💡 File 리스트에서 int(ID) 리스트로 변경 - }) async { - state = const AsyncValue.loading(); - - final result = await AsyncValue.guard( - () => ref - .read(challengeRepositoryProvider) - .createArticle( - challengeId: challengeId, - content: content, - tempImageIds: tempImageIds, - ), - ); - - state = result; - return !result.hasError; - } -} - -// 이미지 검증 -@riverpod -class ImageVerifyNotifier extends _$ImageVerifyNotifier { - @override - AsyncValue build() => const AsyncValue.data(null); - - Future verify(File file, int challengeId) async { - state = const AsyncValue.loading(); - - final result = await AsyncValue.guard( - () => - ref.read(challengeRepositoryProvider).verifyImage(file, challengeId), - ); - - state = result; - return result.valueOrNull; // 성공 시 tempImageId 반환 - } -} - -// 인증글 상세 정보 가져오기 로직 -@riverpod -Future articleDetail( - ArticleDetailRef ref, { - required int postId, -}) async { - final repository = ref.watch(challengeRepositoryProvider); - return repository.getArticleDetail(postId); -} - -// 인증글 수정 로직 -@riverpod -class ArticleUpdateNotifier extends _$ArticleUpdateNotifier { - @override - AsyncValue build() => const AsyncValue.data(null); - - Future editArticle({ - required int postId, - required String content, - List deleteImageIds = const [], - List tempImageIds = const [], // 💡 새 이미지 파일 대신 검증된 ID 리스트를 받음 - }) async { - state = const AsyncValue.loading(); - - final result = await AsyncValue.guard( - () => ref - .read(challengeRepositoryProvider) - .updateArticle( - postId: postId, - content: content, - deleteImageIds: deleteImageIds, - tempImageIds: tempImageIds, - ), - ); - - if (!result.hasError) { - ref.invalidate(articleDetailProvider(postId: postId)); - ref.invalidate(challengePostsProvider); - } - - state = result; - return !result.hasError; - } -} - -// 인증글 삭제 로직 -@riverpod -class ArticleDeleteNotifier extends _$ArticleDeleteNotifier { - @override - AsyncValue build() => const AsyncValue.data(null); - - Future removeArticle(int postId) async { - state = const AsyncValue.loading(); - - final result = await AsyncValue.guard( - () => ref.read(challengeRepositoryProvider).deleteArticle(postId), - ); - - state = result; - return !result.hasError; - } -} - -// 댓글 목록 불러오기 로직 -@riverpod -Future> articleComments( - ArticleCommentsRef ref, { - required int postId, - int page = 0, -}) async { - final repository = ref.watch(challengeRepositoryProvider); - return repository.getComments(postId: postId, page: page); -} - -// 댓글 생성 로직 -@riverpod -class ArticleCommentCreateNotifier extends _$ArticleCommentCreateNotifier { - @override - AsyncValue build() => const AsyncValue.data(null); - - Future addComment({ - required int postId, - required String contents, - }) async { - state = const AsyncValue.loading(); - - final result = await AsyncValue.guard( - () => ref - .read(challengeRepositoryProvider) - .createComment(postId: postId, contents: contents), - ); - - if (!result.hasError) { - // 댓글 목록 새로고침 - ref.invalidate(articleCommentsProvider(postId: postId)); - // 게시글 상세 정보 새로고침 - ref.invalidate(articleDetailProvider(postId: postId)); - } - - state = result; - return !result.hasError; - } -} - -// 댓글 삭제 로직 -@riverpod -class ArticleCommentDeleteNotifier extends _$ArticleCommentDeleteNotifier { - @override - AsyncValue build() => const AsyncValue.data(null); - - Future removeComment({ - required int postId, - required int commentId, - }) async { - state = const AsyncValue.loading(); - - final result = await AsyncValue.guard( - () => ref.read(challengeRepositoryProvider).deleteComment(commentId), - ); - - if (!result.hasError) { - // 댓글 목록 새로고침 - ref.invalidate(articleCommentsProvider(postId: postId)); - - // 게시글 상세 정보 새로고침 - ref.invalidate(articleDetailProvider(postId: postId)); - } - - state = result; - return !result.hasError; - } -} - -// 댓글 수정 로직 -@riverpod -class ArticleCommentUpdateNotifier extends _$ArticleCommentUpdateNotifier { - @override - AsyncValue build() => const AsyncValue.data(null); - - Future editComment({ - required int postId, - required int commentId, - required String contents, - }) async { - state = const AsyncValue.loading(); - - final result = await AsyncValue.guard( - () => ref - .read(challengeRepositoryProvider) - .updateComment(commentId: commentId, contents: contents), - ); - - if (!result.hasError) { - // 💡 댓글 수정 성공 시 해당 게시글의 댓글 목록을 새로고침합니다. - ref.invalidate(articleCommentsProvider(postId: postId)); - } - - state = result; - return !result.hasError; - } -} - -// 좋아요 로직 -@riverpod -class ArticleLikeNotifier extends _$ArticleLikeNotifier { - @override - AsyncValue build() => const AsyncValue.data(null); - - Future toggleLike({ - required int postId, - required bool isCurrentlyLiked, - }) async { - // 💡 별도의 로딩 상태 없이 즉시 실행 (사용자 체감 속도 향상) - final result = await AsyncValue.guard( - () => ref - .read(challengeRepositoryProvider) - .toggleLike(postId: postId, isCurrentlyLiked: isCurrentlyLiked), - ); - - if (!result.hasError) { - // 💡 성공 시 해당 게시글 상세 데이터 무효화 -> 화면 자동 갱신 - ref.invalidate(articleDetailProvider(postId: postId)); - // 필요 시 리스트 화면도 무효화 - // ref.invalidate(challengePostsProvider); - } - } -} - -// 챌린지장 위임 로직 -@riverpod -class ChallengeDelegateNotifier extends _$ChallengeDelegateNotifier { - @override - AsyncValue build() => const AsyncValue.data(null); - - Future delegateAndLeave({ - required int challengeId, - required int delegateMemberId, - }) async { - state = const AsyncValue.loading(); - - final result = await AsyncValue.guard( - () => ref - .read(challengeRepositoryProvider) - .delegateChallengeOwner(challengeId, delegateMemberId), - ); - - state = result; - - if (!result.hasError) { - // 위임 성공 시 홈 데이터 등을 갱신 - ref.invalidate(challengeHomeNotifierProvider); - ref.invalidate(challengeCalendarDataProvider(challengeId)); - return true; - } - return false; - } -} - -// 챌린지 삭제 로직 -@riverpod -class ChallengeDeleteNotifier extends _$ChallengeDeleteNotifier { - @override - AsyncValue build() => const AsyncValue.data(null); - - Future removeChallenge(int challengeId) async { - state = const AsyncValue.loading(); - - final result = await AsyncValue.guard( - () => ref.read(challengeRepositoryProvider).deleteChallenge(challengeId), - ); - - state = result; - - if (!result.hasError) { - // 💡 삭제 성공 시 홈 화면 데이터를 무효화하여 리스트를 새로고침합니다. - ref.invalidate(challengeHomeNotifierProvider); - return true; - } - return false; - } -} - -// 내페이지 사용자 프로필 정보 -@riverpod -Future myProfile(MyProfileRef ref) async { - final repository = ref.watch(userRepositoryProvider); - return repository.getMyProfile(); -} - -// 내 페이지 - 나의 챌린지 - 진행중인 챌린지 -@riverpod -Future> myInProgressChallenges( - MyInProgressChallengesRef ref, { - bool onlyTwo = false, -}) { - return ref - .watch(challengeRepositoryProvider) - .getInProgressChallenges(onlyTwo: onlyTwo); -} - -// 내 페이지 - 나의 챌린지 - 완료한 챌린지 -@riverpod -Future> mySuccessChallenges( - MySuccessChallengesRef ref, { - bool onlyTwo = false, -}) { - return ref - .watch(challengeRepositoryProvider) - .getSuccessChallenges(onlyTwo: onlyTwo); -} - -// 내 페이지 - 나의 챌린지 - 실패한 챌린지 -@riverpod -Future> myFailedChallenges( - MyFailedChallengesRef ref, { - bool onlyTwo = false, -}) { - return ref - .watch(challengeRepositoryProvider) - .getFailedChallenges(onlyTwo: onlyTwo); -} - -// 챌린지 검색 -@riverpod -Future> searchChallenges( - SearchChallengesRef ref, { - required String keyword, - int page = 0, -}) { - return ref - .watch(challengeRepositoryProvider) - .searchChallenges(keyword: keyword, page: page); -} - -// 챌린지 참여 -@riverpod -class ChallengeParticipateNotifier extends _$ChallengeParticipateNotifier { - @override - AsyncValue build() => const AsyncValue.data(null); - - Future participate(int challengeId) async { - state = const AsyncValue.loading(); - - final result = await AsyncValue.guard( - () => ref - .read(challengeRepositoryProvider) - .participateChallenge(challengeId), - ); - - state = result; - - if (!result.hasError) { - // 참여 성공 시, 홈 화면이나 내페이지의 진행 중인 챌린지 목록 갱신 - ref.invalidate(challengeHomeNotifierProvider); - ref.invalidate(myInProgressChallengesProvider); - return true; - } - return false; - } -} diff --git a/lib/features/challenge/provider/challenge_provider.g.dart b/lib/features/challenge/provider/challenge_provider.g.dart deleted file mode 100644 index db7d391..0000000 --- a/lib/features/challenge/provider/challenge_provider.g.dart +++ /dev/null @@ -1,1742 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'challenge_provider.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -String _$challengeDetailHash() => r'74cf37d6cd49e4aab0f276a03f7471edd65c1c0b'; - -/// Copied from Dart SDK -class _SystemHash { - _SystemHash._(); - - static int combine(int hash, int value) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + value); - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); - return hash ^ (hash >> 6); - } - - static int finish(int hash) { - // ignore: parameter_assignments - hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); - // ignore: parameter_assignments - hash = hash ^ (hash >> 11); - return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); - } -} - -/// See also [challengeDetail]. -@ProviderFor(challengeDetail) -const challengeDetailProvider = ChallengeDetailFamily(); - -/// See also [challengeDetail]. -class ChallengeDetailFamily extends Family> { - /// See also [challengeDetail]. - const ChallengeDetailFamily(); - - /// See also [challengeDetail]. - ChallengeDetailProvider call({required int challengeId}) { - return ChallengeDetailProvider(challengeId: challengeId); - } - - @override - ChallengeDetailProvider getProviderOverride( - covariant ChallengeDetailProvider provider, - ) { - return call(challengeId: provider.challengeId); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'challengeDetailProvider'; -} - -/// See also [challengeDetail]. -class ChallengeDetailProvider - extends AutoDisposeFutureProvider { - /// See also [challengeDetail]. - ChallengeDetailProvider({required int challengeId}) - : this._internal( - (ref) => challengeDetail( - ref as ChallengeDetailRef, - challengeId: challengeId, - ), - from: challengeDetailProvider, - name: r'challengeDetailProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$challengeDetailHash, - dependencies: ChallengeDetailFamily._dependencies, - allTransitiveDependencies: - ChallengeDetailFamily._allTransitiveDependencies, - challengeId: challengeId, - ); - - ChallengeDetailProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.challengeId, - }) : super.internal(); - - final int challengeId; - - @override - Override overrideWith( - FutureOr Function(ChallengeDetailRef provider) create, - ) { - return ProviderOverride( - origin: this, - override: ChallengeDetailProvider._internal( - (ref) => create(ref as ChallengeDetailRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - challengeId: challengeId, - ), - ); - } - - @override - AutoDisposeFutureProviderElement createElement() { - return _ChallengeDetailProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is ChallengeDetailProvider && other.challengeId == challengeId; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, challengeId.hashCode); - - return _SystemHash.finish(hash); - } -} - -@Deprecated('Will be removed in 3.0. Use Ref instead') -// ignore: unused_element -mixin ChallengeDetailRef on AutoDisposeFutureProviderRef { - /// The parameter `challengeId` of this provider. - int get challengeId; -} - -class _ChallengeDetailProviderElement - extends AutoDisposeFutureProviderElement - with ChallengeDetailRef { - _ChallengeDetailProviderElement(super.provider); - - @override - int get challengeId => (origin as ChallengeDetailProvider).challengeId; -} - -String _$todayTotalStatusHash() => r'538019eb21a25b4148687cc999fca4c729a86b93'; - -/// See also [todayTotalStatus]. -@ProviderFor(todayTotalStatus) -final todayTotalStatusProvider = AutoDisposeProvider.internal( - todayTotalStatus, - name: r'todayTotalStatusProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$todayTotalStatusHash, - dependencies: null, - allTransitiveDependencies: null, -); - -@Deprecated('Will be removed in 3.0. Use Ref instead') -// ignore: unused_element -typedef TodayTotalStatusRef = AutoDisposeProviderRef; -String _$allTagsHash() => r'509a8857edbc465e553a55ab145a6a56fd3ac1f8'; - -/// See also [allTags]. -@ProviderFor(allTags) -final allTagsProvider = - AutoDisposeFutureProvider>.internal( - allTags, - name: r'allTagsProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$allTagsHash, - dependencies: null, - allTransitiveDependencies: null, - ); - -@Deprecated('Will be removed in 3.0. Use Ref instead') -// ignore: unused_element -typedef AllTagsRef = AutoDisposeFutureProviderRef>; -String _$challengeCalendarDataHash() => - r'f98f24f51387e5a39a38ec6c484831fdb14d30e0'; - -/// See also [challengeCalendarData]. -@ProviderFor(challengeCalendarData) -const challengeCalendarDataProvider = ChallengeCalendarDataFamily(); - -/// See also [challengeCalendarData]. -class ChallengeCalendarDataFamily - extends Family> { - /// See also [challengeCalendarData]. - const ChallengeCalendarDataFamily(); - - /// See also [challengeCalendarData]. - ChallengeCalendarDataProvider call(int challengeId) { - return ChallengeCalendarDataProvider(challengeId); - } - - @override - ChallengeCalendarDataProvider getProviderOverride( - covariant ChallengeCalendarDataProvider provider, - ) { - return call(provider.challengeId); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'challengeCalendarDataProvider'; -} - -/// See also [challengeCalendarData]. -class ChallengeCalendarDataProvider - extends AutoDisposeFutureProvider { - /// See also [challengeCalendarData]. - ChallengeCalendarDataProvider(int challengeId) - : this._internal( - (ref) => - challengeCalendarData(ref as ChallengeCalendarDataRef, challengeId), - from: challengeCalendarDataProvider, - name: r'challengeCalendarDataProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$challengeCalendarDataHash, - dependencies: ChallengeCalendarDataFamily._dependencies, - allTransitiveDependencies: - ChallengeCalendarDataFamily._allTransitiveDependencies, - challengeId: challengeId, - ); - - ChallengeCalendarDataProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.challengeId, - }) : super.internal(); - - final int challengeId; - - @override - Override overrideWith( - FutureOr Function(ChallengeCalendarDataRef provider) - create, - ) { - return ProviderOverride( - origin: this, - override: ChallengeCalendarDataProvider._internal( - (ref) => create(ref as ChallengeCalendarDataRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - challengeId: challengeId, - ), - ); - } - - @override - AutoDisposeFutureProviderElement createElement() { - return _ChallengeCalendarDataProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is ChallengeCalendarDataProvider && - other.challengeId == challengeId; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, challengeId.hashCode); - - return _SystemHash.finish(hash); - } -} - -@Deprecated('Will be removed in 3.0. Use Ref instead') -// ignore: unused_element -mixin ChallengeCalendarDataRef - on AutoDisposeFutureProviderRef { - /// The parameter `challengeId` of this provider. - int get challengeId; -} - -class _ChallengeCalendarDataProviderElement - extends AutoDisposeFutureProviderElement - with ChallengeCalendarDataRef { - _ChallengeCalendarDataProviderElement(super.provider); - - @override - int get challengeId => (origin as ChallengeCalendarDataProvider).challengeId; -} - -String _$challengePostsHash() => r'08fedb7bb5a647c8fc3744997c2179902fb31fcd'; - -/// See also [challengePosts]. -@ProviderFor(challengePosts) -const challengePostsProvider = ChallengePostsFamily(); - -/// See also [challengePosts]. -class ChallengePostsFamily - extends Family>> { - /// See also [challengePosts]. - const ChallengePostsFamily(); - - /// See also [challengePosts]. - ChallengePostsProvider call({ - required int challengeId, - required int year, - required int month, - }) { - return ChallengePostsProvider( - challengeId: challengeId, - year: year, - month: month, - ); - } - - @override - ChallengePostsProvider getProviderOverride( - covariant ChallengePostsProvider provider, - ) { - return call( - challengeId: provider.challengeId, - year: provider.year, - month: provider.month, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'challengePostsProvider'; -} - -/// See also [challengePosts]. -class ChallengePostsProvider - extends AutoDisposeFutureProvider> { - /// See also [challengePosts]. - ChallengePostsProvider({ - required int challengeId, - required int year, - required int month, - }) : this._internal( - (ref) => challengePosts( - ref as ChallengePostsRef, - challengeId: challengeId, - year: year, - month: month, - ), - from: challengePostsProvider, - name: r'challengePostsProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$challengePostsHash, - dependencies: ChallengePostsFamily._dependencies, - allTransitiveDependencies: - ChallengePostsFamily._allTransitiveDependencies, - challengeId: challengeId, - year: year, - month: month, - ); - - ChallengePostsProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.challengeId, - required this.year, - required this.month, - }) : super.internal(); - - final int challengeId; - final int year; - final int month; - - @override - Override overrideWith( - FutureOr> Function(ChallengePostsRef provider) - create, - ) { - return ProviderOverride( - origin: this, - override: ChallengePostsProvider._internal( - (ref) => create(ref as ChallengePostsRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - challengeId: challengeId, - year: year, - month: month, - ), - ); - } - - @override - AutoDisposeFutureProviderElement> - createElement() { - return _ChallengePostsProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is ChallengePostsProvider && - other.challengeId == challengeId && - other.year == year && - other.month == month; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, challengeId.hashCode); - hash = _SystemHash.combine(hash, year.hashCode); - hash = _SystemHash.combine(hash, month.hashCode); - - return _SystemHash.finish(hash); - } -} - -@Deprecated('Will be removed in 3.0. Use Ref instead') -// ignore: unused_element -mixin ChallengePostsRef - on AutoDisposeFutureProviderRef> { - /// The parameter `challengeId` of this provider. - int get challengeId; - - /// The parameter `year` of this provider. - int get year; - - /// The parameter `month` of this provider. - int get month; -} - -class _ChallengePostsProviderElement - extends AutoDisposeFutureProviderElement> - with ChallengePostsRef { - _ChallengePostsProviderElement(super.provider); - - @override - int get challengeId => (origin as ChallengePostsProvider).challengeId; - @override - int get year => (origin as ChallengePostsProvider).year; - @override - int get month => (origin as ChallengePostsProvider).month; -} - -String _$challengeCalendarPhotosHash() => - r'132312c2d59424752d6cc544a272c21ef578c4d3'; - -/// See also [challengeCalendarPhotos]. -@ProviderFor(challengeCalendarPhotos) -const challengeCalendarPhotosProvider = ChallengeCalendarPhotosFamily(); - -/// See also [challengeCalendarPhotos]. -class ChallengeCalendarPhotosFamily - extends Family>> { - /// See also [challengeCalendarPhotos]. - const ChallengeCalendarPhotosFamily(); - - /// See also [challengeCalendarPhotos]. - ChallengeCalendarPhotosProvider call({ - required int challengeId, - required int year, - required int month, - }) { - return ChallengeCalendarPhotosProvider( - challengeId: challengeId, - year: year, - month: month, - ); - } - - @override - ChallengeCalendarPhotosProvider getProviderOverride( - covariant ChallengeCalendarPhotosProvider provider, - ) { - return call( - challengeId: provider.challengeId, - year: provider.year, - month: provider.month, - ); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'challengeCalendarPhotosProvider'; -} - -/// See also [challengeCalendarPhotos]. -class ChallengeCalendarPhotosProvider - extends AutoDisposeFutureProvider> { - /// See also [challengeCalendarPhotos]. - ChallengeCalendarPhotosProvider({ - required int challengeId, - required int year, - required int month, - }) : this._internal( - (ref) => challengeCalendarPhotos( - ref as ChallengeCalendarPhotosRef, - challengeId: challengeId, - year: year, - month: month, - ), - from: challengeCalendarPhotosProvider, - name: r'challengeCalendarPhotosProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$challengeCalendarPhotosHash, - dependencies: ChallengeCalendarPhotosFamily._dependencies, - allTransitiveDependencies: - ChallengeCalendarPhotosFamily._allTransitiveDependencies, - challengeId: challengeId, - year: year, - month: month, - ); - - ChallengeCalendarPhotosProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.challengeId, - required this.year, - required this.month, - }) : super.internal(); - - final int challengeId; - final int year; - final int month; - - @override - Override overrideWith( - FutureOr> Function( - ChallengeCalendarPhotosRef provider, - ) - create, - ) { - return ProviderOverride( - origin: this, - override: ChallengeCalendarPhotosProvider._internal( - (ref) => create(ref as ChallengeCalendarPhotosRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - challengeId: challengeId, - year: year, - month: month, - ), - ); - } - - @override - AutoDisposeFutureProviderElement> - createElement() { - return _ChallengeCalendarPhotosProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is ChallengeCalendarPhotosProvider && - other.challengeId == challengeId && - other.year == year && - other.month == month; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, challengeId.hashCode); - hash = _SystemHash.combine(hash, year.hashCode); - hash = _SystemHash.combine(hash, month.hashCode); - - return _SystemHash.finish(hash); - } -} - -@Deprecated('Will be removed in 3.0. Use Ref instead') -// ignore: unused_element -mixin ChallengeCalendarPhotosRef - on AutoDisposeFutureProviderRef> { - /// The parameter `challengeId` of this provider. - int get challengeId; - - /// The parameter `year` of this provider. - int get year; - - /// The parameter `month` of this provider. - int get month; -} - -class _ChallengeCalendarPhotosProviderElement - extends AutoDisposeFutureProviderElement> - with ChallengeCalendarPhotosRef { - _ChallengeCalendarPhotosProviderElement(super.provider); - - @override - int get challengeId => - (origin as ChallengeCalendarPhotosProvider).challengeId; - @override - int get year => (origin as ChallengeCalendarPhotosProvider).year; - @override - int get month => (origin as ChallengeCalendarPhotosProvider).month; -} - -String _$articleDetailHash() => r'42e7dfa2cfb3dbbb4b9ef61f740651aa6ecd5e67'; - -/// See also [articleDetail]. -@ProviderFor(articleDetail) -const articleDetailProvider = ArticleDetailFamily(); - -/// See also [articleDetail]. -class ArticleDetailFamily extends Family> { - /// See also [articleDetail]. - const ArticleDetailFamily(); - - /// See also [articleDetail]. - ArticleDetailProvider call({required int postId}) { - return ArticleDetailProvider(postId: postId); - } - - @override - ArticleDetailProvider getProviderOverride( - covariant ArticleDetailProvider provider, - ) { - return call(postId: provider.postId); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'articleDetailProvider'; -} - -/// See also [articleDetail]. -class ArticleDetailProvider - extends AutoDisposeFutureProvider { - /// See also [articleDetail]. - ArticleDetailProvider({required int postId}) - : this._internal( - (ref) => articleDetail(ref as ArticleDetailRef, postId: postId), - from: articleDetailProvider, - name: r'articleDetailProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$articleDetailHash, - dependencies: ArticleDetailFamily._dependencies, - allTransitiveDependencies: - ArticleDetailFamily._allTransitiveDependencies, - postId: postId, - ); - - ArticleDetailProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.postId, - }) : super.internal(); - - final int postId; - - @override - Override overrideWith( - FutureOr Function(ArticleDetailRef provider) create, - ) { - return ProviderOverride( - origin: this, - override: ArticleDetailProvider._internal( - (ref) => create(ref as ArticleDetailRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - postId: postId, - ), - ); - } - - @override - AutoDisposeFutureProviderElement createElement() { - return _ArticleDetailProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is ArticleDetailProvider && other.postId == postId; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, postId.hashCode); - - return _SystemHash.finish(hash); - } -} - -@Deprecated('Will be removed in 3.0. Use Ref instead') -// ignore: unused_element -mixin ArticleDetailRef on AutoDisposeFutureProviderRef { - /// The parameter `postId` of this provider. - int get postId; -} - -class _ArticleDetailProviderElement - extends AutoDisposeFutureProviderElement - with ArticleDetailRef { - _ArticleDetailProviderElement(super.provider); - - @override - int get postId => (origin as ArticleDetailProvider).postId; -} - -String _$articleCommentsHash() => r'00b8c1f43a3b172a21fe298e17ea96bbccb54bba'; - -/// See also [articleComments]. -@ProviderFor(articleComments) -const articleCommentsProvider = ArticleCommentsFamily(); - -/// See also [articleComments]. -class ArticleCommentsFamily extends Family>> { - /// See also [articleComments]. - const ArticleCommentsFamily(); - - /// See also [articleComments]. - ArticleCommentsProvider call({required int postId, int page = 0}) { - return ArticleCommentsProvider(postId: postId, page: page); - } - - @override - ArticleCommentsProvider getProviderOverride( - covariant ArticleCommentsProvider provider, - ) { - return call(postId: provider.postId, page: provider.page); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'articleCommentsProvider'; -} - -/// See also [articleComments]. -class ArticleCommentsProvider - extends AutoDisposeFutureProvider> { - /// See also [articleComments]. - ArticleCommentsProvider({required int postId, int page = 0}) - : this._internal( - (ref) => articleComments( - ref as ArticleCommentsRef, - postId: postId, - page: page, - ), - from: articleCommentsProvider, - name: r'articleCommentsProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$articleCommentsHash, - dependencies: ArticleCommentsFamily._dependencies, - allTransitiveDependencies: - ArticleCommentsFamily._allTransitiveDependencies, - postId: postId, - page: page, - ); - - ArticleCommentsProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.postId, - required this.page, - }) : super.internal(); - - final int postId; - final int page; - - @override - Override overrideWith( - FutureOr> Function(ArticleCommentsRef provider) - create, - ) { - return ProviderOverride( - origin: this, - override: ArticleCommentsProvider._internal( - (ref) => create(ref as ArticleCommentsRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - postId: postId, - page: page, - ), - ); - } - - @override - AutoDisposeFutureProviderElement> createElement() { - return _ArticleCommentsProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is ArticleCommentsProvider && - other.postId == postId && - other.page == page; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, postId.hashCode); - hash = _SystemHash.combine(hash, page.hashCode); - - return _SystemHash.finish(hash); - } -} - -@Deprecated('Will be removed in 3.0. Use Ref instead') -// ignore: unused_element -mixin ArticleCommentsRef - on AutoDisposeFutureProviderRef> { - /// The parameter `postId` of this provider. - int get postId; - - /// The parameter `page` of this provider. - int get page; -} - -class _ArticleCommentsProviderElement - extends AutoDisposeFutureProviderElement> - with ArticleCommentsRef { - _ArticleCommentsProviderElement(super.provider); - - @override - int get postId => (origin as ArticleCommentsProvider).postId; - @override - int get page => (origin as ArticleCommentsProvider).page; -} - -String _$myProfileHash() => r'0536f34717b179e7bf7ba33d770c66e3781de904'; - -/// See also [myProfile]. -@ProviderFor(myProfile) -final myProfileProvider = AutoDisposeFutureProvider.internal( - myProfile, - name: r'myProfileProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$myProfileHash, - dependencies: null, - allTransitiveDependencies: null, -); - -@Deprecated('Will be removed in 3.0. Use Ref instead') -// ignore: unused_element -typedef MyProfileRef = AutoDisposeFutureProviderRef; -String _$myInProgressChallengesHash() => - r'4f53f796e087c3f28343d446396e079ac6ab81dc'; - -/// See also [myInProgressChallenges]. -@ProviderFor(myInProgressChallenges) -const myInProgressChallengesProvider = MyInProgressChallengesFamily(); - -/// See also [myInProgressChallenges]. -class MyInProgressChallengesFamily - extends Family>> { - /// See also [myInProgressChallenges]. - const MyInProgressChallengesFamily(); - - /// See also [myInProgressChallenges]. - MyInProgressChallengesProvider call({bool onlyTwo = false}) { - return MyInProgressChallengesProvider(onlyTwo: onlyTwo); - } - - @override - MyInProgressChallengesProvider getProviderOverride( - covariant MyInProgressChallengesProvider provider, - ) { - return call(onlyTwo: provider.onlyTwo); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'myInProgressChallengesProvider'; -} - -/// See also [myInProgressChallenges]. -class MyInProgressChallengesProvider - extends AutoDisposeFutureProvider> { - /// See also [myInProgressChallenges]. - MyInProgressChallengesProvider({bool onlyTwo = false}) - : this._internal( - (ref) => myInProgressChallenges( - ref as MyInProgressChallengesRef, - onlyTwo: onlyTwo, - ), - from: myInProgressChallengesProvider, - name: r'myInProgressChallengesProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$myInProgressChallengesHash, - dependencies: MyInProgressChallengesFamily._dependencies, - allTransitiveDependencies: - MyInProgressChallengesFamily._allTransitiveDependencies, - onlyTwo: onlyTwo, - ); - - MyInProgressChallengesProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.onlyTwo, - }) : super.internal(); - - final bool onlyTwo; - - @override - Override overrideWith( - FutureOr> Function( - MyInProgressChallengesRef provider, - ) - create, - ) { - return ProviderOverride( - origin: this, - override: MyInProgressChallengesProvider._internal( - (ref) => create(ref as MyInProgressChallengesRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - onlyTwo: onlyTwo, - ), - ); - } - - @override - AutoDisposeFutureProviderElement> - createElement() { - return _MyInProgressChallengesProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is MyInProgressChallengesProvider && other.onlyTwo == onlyTwo; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, onlyTwo.hashCode); - - return _SystemHash.finish(hash); - } -} - -@Deprecated('Will be removed in 3.0. Use Ref instead') -// ignore: unused_element -mixin MyInProgressChallengesRef - on AutoDisposeFutureProviderRef> { - /// The parameter `onlyTwo` of this provider. - bool get onlyTwo; -} - -class _MyInProgressChallengesProviderElement - extends AutoDisposeFutureProviderElement> - with MyInProgressChallengesRef { - _MyInProgressChallengesProviderElement(super.provider); - - @override - bool get onlyTwo => (origin as MyInProgressChallengesProvider).onlyTwo; -} - -String _$mySuccessChallengesHash() => - r'95d005eec80994a28c20765d27b9a28ab78d01cf'; - -/// See also [mySuccessChallenges]. -@ProviderFor(mySuccessChallenges) -const mySuccessChallengesProvider = MySuccessChallengesFamily(); - -/// See also [mySuccessChallenges]. -class MySuccessChallengesFamily - extends Family>> { - /// See also [mySuccessChallenges]. - const MySuccessChallengesFamily(); - - /// See also [mySuccessChallenges]. - MySuccessChallengesProvider call({bool onlyTwo = false}) { - return MySuccessChallengesProvider(onlyTwo: onlyTwo); - } - - @override - MySuccessChallengesProvider getProviderOverride( - covariant MySuccessChallengesProvider provider, - ) { - return call(onlyTwo: provider.onlyTwo); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'mySuccessChallengesProvider'; -} - -/// See also [mySuccessChallenges]. -class MySuccessChallengesProvider - extends AutoDisposeFutureProvider> { - /// See also [mySuccessChallenges]. - MySuccessChallengesProvider({bool onlyTwo = false}) - : this._internal( - (ref) => mySuccessChallenges( - ref as MySuccessChallengesRef, - onlyTwo: onlyTwo, - ), - from: mySuccessChallengesProvider, - name: r'mySuccessChallengesProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$mySuccessChallengesHash, - dependencies: MySuccessChallengesFamily._dependencies, - allTransitiveDependencies: - MySuccessChallengesFamily._allTransitiveDependencies, - onlyTwo: onlyTwo, - ); - - MySuccessChallengesProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.onlyTwo, - }) : super.internal(); - - final bool onlyTwo; - - @override - Override overrideWith( - FutureOr> Function( - MySuccessChallengesRef provider, - ) - create, - ) { - return ProviderOverride( - origin: this, - override: MySuccessChallengesProvider._internal( - (ref) => create(ref as MySuccessChallengesRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - onlyTwo: onlyTwo, - ), - ); - } - - @override - AutoDisposeFutureProviderElement> - createElement() { - return _MySuccessChallengesProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is MySuccessChallengesProvider && other.onlyTwo == onlyTwo; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, onlyTwo.hashCode); - - return _SystemHash.finish(hash); - } -} - -@Deprecated('Will be removed in 3.0. Use Ref instead') -// ignore: unused_element -mixin MySuccessChallengesRef - on AutoDisposeFutureProviderRef> { - /// The parameter `onlyTwo` of this provider. - bool get onlyTwo; -} - -class _MySuccessChallengesProviderElement - extends AutoDisposeFutureProviderElement> - with MySuccessChallengesRef { - _MySuccessChallengesProviderElement(super.provider); - - @override - bool get onlyTwo => (origin as MySuccessChallengesProvider).onlyTwo; -} - -String _$myFailedChallengesHash() => - r'332d3fb3a12b34ae0912d92300578649335a35e8'; - -/// See also [myFailedChallenges]. -@ProviderFor(myFailedChallenges) -const myFailedChallengesProvider = MyFailedChallengesFamily(); - -/// See also [myFailedChallenges]. -class MyFailedChallengesFamily - extends Family>> { - /// See also [myFailedChallenges]. - const MyFailedChallengesFamily(); - - /// See also [myFailedChallenges]. - MyFailedChallengesProvider call({bool onlyTwo = false}) { - return MyFailedChallengesProvider(onlyTwo: onlyTwo); - } - - @override - MyFailedChallengesProvider getProviderOverride( - covariant MyFailedChallengesProvider provider, - ) { - return call(onlyTwo: provider.onlyTwo); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'myFailedChallengesProvider'; -} - -/// See also [myFailedChallenges]. -class MyFailedChallengesProvider - extends AutoDisposeFutureProvider> { - /// See also [myFailedChallenges]. - MyFailedChallengesProvider({bool onlyTwo = false}) - : this._internal( - (ref) => - myFailedChallenges(ref as MyFailedChallengesRef, onlyTwo: onlyTwo), - from: myFailedChallengesProvider, - name: r'myFailedChallengesProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$myFailedChallengesHash, - dependencies: MyFailedChallengesFamily._dependencies, - allTransitiveDependencies: - MyFailedChallengesFamily._allTransitiveDependencies, - onlyTwo: onlyTwo, - ); - - MyFailedChallengesProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.onlyTwo, - }) : super.internal(); - - final bool onlyTwo; - - @override - Override overrideWith( - FutureOr> Function( - MyFailedChallengesRef provider, - ) - create, - ) { - return ProviderOverride( - origin: this, - override: MyFailedChallengesProvider._internal( - (ref) => create(ref as MyFailedChallengesRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - onlyTwo: onlyTwo, - ), - ); - } - - @override - AutoDisposeFutureProviderElement> - createElement() { - return _MyFailedChallengesProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is MyFailedChallengesProvider && other.onlyTwo == onlyTwo; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, onlyTwo.hashCode); - - return _SystemHash.finish(hash); - } -} - -@Deprecated('Will be removed in 3.0. Use Ref instead') -// ignore: unused_element -mixin MyFailedChallengesRef - on AutoDisposeFutureProviderRef> { - /// The parameter `onlyTwo` of this provider. - bool get onlyTwo; -} - -class _MyFailedChallengesProviderElement - extends AutoDisposeFutureProviderElement> - with MyFailedChallengesRef { - _MyFailedChallengesProviderElement(super.provider); - - @override - bool get onlyTwo => (origin as MyFailedChallengesProvider).onlyTwo; -} - -String _$searchChallengesHash() => r'065450eb2d191ef0657fd876e50e7a03d5063c38'; - -/// See also [searchChallenges]. -@ProviderFor(searchChallenges) -const searchChallengesProvider = SearchChallengesFamily(); - -/// See also [searchChallenges]. -class SearchChallengesFamily - extends Family>> { - /// See also [searchChallenges]. - const SearchChallengesFamily(); - - /// See also [searchChallenges]. - SearchChallengesProvider call({required String keyword, int page = 0}) { - return SearchChallengesProvider(keyword: keyword, page: page); - } - - @override - SearchChallengesProvider getProviderOverride( - covariant SearchChallengesProvider provider, - ) { - return call(keyword: provider.keyword, page: provider.page); - } - - static const Iterable? _dependencies = null; - - @override - Iterable? get dependencies => _dependencies; - - static const Iterable? _allTransitiveDependencies = null; - - @override - Iterable? get allTransitiveDependencies => - _allTransitiveDependencies; - - @override - String? get name => r'searchChallengesProvider'; -} - -/// See also [searchChallenges]. -class SearchChallengesProvider - extends AutoDisposeFutureProvider> { - /// See also [searchChallenges]. - SearchChallengesProvider({required String keyword, int page = 0}) - : this._internal( - (ref) => searchChallenges( - ref as SearchChallengesRef, - keyword: keyword, - page: page, - ), - from: searchChallengesProvider, - name: r'searchChallengesProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$searchChallengesHash, - dependencies: SearchChallengesFamily._dependencies, - allTransitiveDependencies: - SearchChallengesFamily._allTransitiveDependencies, - keyword: keyword, - page: page, - ); - - SearchChallengesProvider._internal( - super._createNotifier, { - required super.name, - required super.dependencies, - required super.allTransitiveDependencies, - required super.debugGetCreateSourceHash, - required super.from, - required this.keyword, - required this.page, - }) : super.internal(); - - final String keyword; - final int page; - - @override - Override overrideWith( - FutureOr> Function(SearchChallengesRef provider) - create, - ) { - return ProviderOverride( - origin: this, - override: SearchChallengesProvider._internal( - (ref) => create(ref as SearchChallengesRef), - from: from, - name: null, - dependencies: null, - allTransitiveDependencies: null, - debugGetCreateSourceHash: null, - keyword: keyword, - page: page, - ), - ); - } - - @override - AutoDisposeFutureProviderElement> createElement() { - return _SearchChallengesProviderElement(this); - } - - @override - bool operator ==(Object other) { - return other is SearchChallengesProvider && - other.keyword == keyword && - other.page == page; - } - - @override - int get hashCode { - var hash = _SystemHash.combine(0, runtimeType.hashCode); - hash = _SystemHash.combine(hash, keyword.hashCode); - hash = _SystemHash.combine(hash, page.hashCode); - - return _SystemHash.finish(hash); - } -} - -@Deprecated('Will be removed in 3.0. Use Ref instead') -// ignore: unused_element -mixin SearchChallengesRef - on AutoDisposeFutureProviderRef> { - /// The parameter `keyword` of this provider. - String get keyword; - - /// The parameter `page` of this provider. - int get page; -} - -class _SearchChallengesProviderElement - extends AutoDisposeFutureProviderElement> - with SearchChallengesRef { - _SearchChallengesProviderElement(super.provider); - - @override - String get keyword => (origin as SearchChallengesProvider).keyword; - @override - int get page => (origin as SearchChallengesProvider).page; -} - -String _$challengeHomeNotifierHash() => - r'ce230999a0c31c320d048d283d07a5fd954c1df3'; - -/// See also [ChallengeHomeNotifier]. -@ProviderFor(ChallengeHomeNotifier) -final challengeHomeNotifierProvider = - AutoDisposeAsyncNotifierProvider< - ChallengeHomeNotifier, - ChallengeMainModel - >.internal( - ChallengeHomeNotifier.new, - name: r'challengeHomeNotifierProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$challengeHomeNotifierHash, - dependencies: null, - allTransitiveDependencies: null, - ); - -typedef _$ChallengeHomeNotifier = AutoDisposeAsyncNotifier; -String _$challengeLeaveNotifierHash() => - r'b3730af90553c42993450f3f38cb24c564a3ff8a'; - -/// See also [ChallengeLeaveNotifier]. -@ProviderFor(ChallengeLeaveNotifier) -final challengeLeaveNotifierProvider = - AutoDisposeNotifierProvider< - ChallengeLeaveNotifier, - AsyncValue - >.internal( - ChallengeLeaveNotifier.new, - name: r'challengeLeaveNotifierProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$challengeLeaveNotifierHash, - dependencies: null, - allTransitiveDependencies: null, - ); - -typedef _$ChallengeLeaveNotifier = AutoDisposeNotifier>; -String _$challengeCreateNotifierHash() => - r'cbc4cd944ef88513ff25539f16dbe448af4446e6'; - -/// See also [ChallengeCreateNotifier]. -@ProviderFor(ChallengeCreateNotifier) -final challengeCreateNotifierProvider = - AutoDisposeNotifierProvider< - ChallengeCreateNotifier, - AsyncValue - >.internal( - ChallengeCreateNotifier.new, - name: r'challengeCreateNotifierProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$challengeCreateNotifierHash, - dependencies: null, - allTransitiveDependencies: null, - ); - -typedef _$ChallengeCreateNotifier = - AutoDisposeNotifier>; -String _$articleCreateNotifierHash() => - r'558fb00ff45dc4f321919d42368c1a94c415f602'; - -/// See also [ArticleCreateNotifier]. -@ProviderFor(ArticleCreateNotifier) -final articleCreateNotifierProvider = - AutoDisposeNotifierProvider< - ArticleCreateNotifier, - AsyncValue - >.internal( - ArticleCreateNotifier.new, - name: r'articleCreateNotifierProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$articleCreateNotifierHash, - dependencies: null, - allTransitiveDependencies: null, - ); - -typedef _$ArticleCreateNotifier = - AutoDisposeNotifier>; -String _$imageVerifyNotifierHash() => - r'91d07f6c3d486d31cdeba206cb43812ca95edd7f'; - -/// See also [ImageVerifyNotifier]. -@ProviderFor(ImageVerifyNotifier) -final imageVerifyNotifierProvider = - AutoDisposeNotifierProvider>.internal( - ImageVerifyNotifier.new, - name: r'imageVerifyNotifierProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$imageVerifyNotifierHash, - dependencies: null, - allTransitiveDependencies: null, - ); - -typedef _$ImageVerifyNotifier = AutoDisposeNotifier>; -String _$articleUpdateNotifierHash() => - r'0b6c9f85f15de5b114787f2332ca7eae0668c6a2'; - -/// See also [ArticleUpdateNotifier]. -@ProviderFor(ArticleUpdateNotifier) -final articleUpdateNotifierProvider = - AutoDisposeNotifierProvider< - ArticleUpdateNotifier, - AsyncValue - >.internal( - ArticleUpdateNotifier.new, - name: r'articleUpdateNotifierProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$articleUpdateNotifierHash, - dependencies: null, - allTransitiveDependencies: null, - ); - -typedef _$ArticleUpdateNotifier = - AutoDisposeNotifier>; -String _$articleDeleteNotifierHash() => - r'b11a8e3236a84b509fe694fdc747c45e450a211c'; - -/// See also [ArticleDeleteNotifier]. -@ProviderFor(ArticleDeleteNotifier) -final articleDeleteNotifierProvider = - AutoDisposeNotifierProvider< - ArticleDeleteNotifier, - AsyncValue - >.internal( - ArticleDeleteNotifier.new, - name: r'articleDeleteNotifierProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$articleDeleteNotifierHash, - dependencies: null, - allTransitiveDependencies: null, - ); - -typedef _$ArticleDeleteNotifier = AutoDisposeNotifier>; -String _$articleCommentCreateNotifierHash() => - r'd84b900117d6f6fd84773769cae051987ce6bfcd'; - -/// See also [ArticleCommentCreateNotifier]. -@ProviderFor(ArticleCommentCreateNotifier) -final articleCommentCreateNotifierProvider = - AutoDisposeNotifierProvider< - ArticleCommentCreateNotifier, - AsyncValue - >.internal( - ArticleCommentCreateNotifier.new, - name: r'articleCommentCreateNotifierProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$articleCommentCreateNotifierHash, - dependencies: null, - allTransitiveDependencies: null, - ); - -typedef _$ArticleCommentCreateNotifier = AutoDisposeNotifier>; -String _$articleCommentDeleteNotifierHash() => - r'b62243e175469fc7f81c2b36944aea57994438e8'; - -/// See also [ArticleCommentDeleteNotifier]. -@ProviderFor(ArticleCommentDeleteNotifier) -final articleCommentDeleteNotifierProvider = - AutoDisposeNotifierProvider< - ArticleCommentDeleteNotifier, - AsyncValue - >.internal( - ArticleCommentDeleteNotifier.new, - name: r'articleCommentDeleteNotifierProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$articleCommentDeleteNotifierHash, - dependencies: null, - allTransitiveDependencies: null, - ); - -typedef _$ArticleCommentDeleteNotifier = AutoDisposeNotifier>; -String _$articleCommentUpdateNotifierHash() => - r'3c4af46b57926f9a695dedf7c01c7922c2d4ba5f'; - -/// See also [ArticleCommentUpdateNotifier]. -@ProviderFor(ArticleCommentUpdateNotifier) -final articleCommentUpdateNotifierProvider = - AutoDisposeNotifierProvider< - ArticleCommentUpdateNotifier, - AsyncValue - >.internal( - ArticleCommentUpdateNotifier.new, - name: r'articleCommentUpdateNotifierProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$articleCommentUpdateNotifierHash, - dependencies: null, - allTransitiveDependencies: null, - ); - -typedef _$ArticleCommentUpdateNotifier = AutoDisposeNotifier>; -String _$articleLikeNotifierHash() => - r'edca8172ac97f7d19f7b645c5f0f928672a9ed1f'; - -/// See also [ArticleLikeNotifier]. -@ProviderFor(ArticleLikeNotifier) -final articleLikeNotifierProvider = - AutoDisposeNotifierProvider>.internal( - ArticleLikeNotifier.new, - name: r'articleLikeNotifierProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$articleLikeNotifierHash, - dependencies: null, - allTransitiveDependencies: null, - ); - -typedef _$ArticleLikeNotifier = AutoDisposeNotifier>; -String _$challengeDelegateNotifierHash() => - r'9ca847752fbd5dbe57f004bd395d80b69215d5eb'; - -/// See also [ChallengeDelegateNotifier]. -@ProviderFor(ChallengeDelegateNotifier) -final challengeDelegateNotifierProvider = - AutoDisposeNotifierProvider< - ChallengeDelegateNotifier, - AsyncValue - >.internal( - ChallengeDelegateNotifier.new, - name: r'challengeDelegateNotifierProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$challengeDelegateNotifierHash, - dependencies: null, - allTransitiveDependencies: null, - ); - -typedef _$ChallengeDelegateNotifier = AutoDisposeNotifier>; -String _$challengeDeleteNotifierHash() => - r'fae50ca4d3884c1de44ceafd6bc2e89c775b2c54'; - -/// See also [ChallengeDeleteNotifier]. -@ProviderFor(ChallengeDeleteNotifier) -final challengeDeleteNotifierProvider = - AutoDisposeNotifierProvider< - ChallengeDeleteNotifier, - AsyncValue - >.internal( - ChallengeDeleteNotifier.new, - name: r'challengeDeleteNotifierProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$challengeDeleteNotifierHash, - dependencies: null, - allTransitiveDependencies: null, - ); - -typedef _$ChallengeDeleteNotifier = AutoDisposeNotifier>; -String _$challengeParticipateNotifierHash() => - r'f6af3d6d99c942b164911cb504c4c57825eeb4d2'; - -/// See also [ChallengeParticipateNotifier]. -@ProviderFor(ChallengeParticipateNotifier) -final challengeParticipateNotifierProvider = - AutoDisposeNotifierProvider< - ChallengeParticipateNotifier, - AsyncValue - >.internal( - ChallengeParticipateNotifier.new, - name: r'challengeParticipateNotifierProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$challengeParticipateNotifierHash, - dependencies: null, - allTransitiveDependencies: null, - ); - -typedef _$ChallengeParticipateNotifier = AutoDisposeNotifier>; -// ignore_for_file: type=lint -// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/challenge/settings/data/challenge_delete_repository.dart b/lib/features/challenge/settings/data/challenge_delete_repository.dart new file mode 100644 index 0000000..de59be4 --- /dev/null +++ b/lib/features/challenge/settings/data/challenge_delete_repository.dart @@ -0,0 +1,39 @@ +// 최초 작성자: 정승빈 +import 'package:dio/dio.dart'; +import 'package:haenaem/core/network/dio_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'challenge_delete_repository.g.dart'; + +@riverpod +ChallengeDeleteRepository challengeDeleteRepository( + ChallengeDeleteRepositoryRef ref, +) { + final dio = ref.watch(dioProvider); // ← 공통 Dio 주입 + return ChallengeDeleteRepository(dio); +} + +class ChallengeDeleteRepository { + final Dio _dio; + + ChallengeDeleteRepository(this._dio); + + /// 챌린지 삭제 API + /// - [challengeId]: 삭제할 챌린지 ID + Future deleteChallenge(int challengeId) async { + try { + debugPrint('🚀 [DELETE Request] /api/challenges/$challengeId'); + + final response = await _dio.delete('/api/challenges/$challengeId'); + + if (response.statusCode != 204 && response.statusCode != 200) { + throw Exception('챌린지 삭제 실패 (Status: ${response.statusCode})'); + } + debugPrint('✅ 챌린지 삭제 성공'); + } on DioException catch (e) { + debugPrint('❌ 챌린지 삭제 API 에러: ${e.response?.data}'); + throw Exception(e.response?.data['message'] ?? '챌린지 삭제 중 오류가 발생했습니다.'); + } + } +} diff --git a/lib/features/challenge/settings/data/challenge_delete_repository.g.dart b/lib/features/challenge/settings/data/challenge_delete_repository.g.dart new file mode 100644 index 0000000..e0f0861 --- /dev/null +++ b/lib/features/challenge/settings/data/challenge_delete_repository.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'challenge_delete_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$challengeDeleteRepositoryHash() => + r'0e7cdcf127ac14d2652bb35ff4257b021ebde9da'; + +/// See also [challengeDeleteRepository]. +@ProviderFor(challengeDeleteRepository) +final challengeDeleteRepositoryProvider = + AutoDisposeProvider.internal( + challengeDeleteRepository, + name: r'challengeDeleteRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$challengeDeleteRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef ChallengeDeleteRepositoryRef = + AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/challenge/settings/data/challenge_member_repository.dart b/lib/features/challenge/settings/data/challenge_member_repository.dart new file mode 100644 index 0000000..b39a0e5 --- /dev/null +++ b/lib/features/challenge/settings/data/challenge_member_repository.dart @@ -0,0 +1,142 @@ +/// 최초 작성자: 정승빈 +library; + +import 'package:dio/dio.dart'; +import 'package:haenaem/core/network/dio_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:haenaem/shared/models/user.dart'; + +part 'challenge_member_repository.g.dart'; + +@riverpod +ChallengeMemberRepository challengeMemberRepository( + ChallengeMemberRepositoryRef ref, +) { + final dio = ref.watch(dioProvider); // ← 공통 Dio 주입 + return ChallengeMemberRepository(dio); +} + +class ChallengeMemberRepository { + final Dio _dio; + + ChallengeMemberRepository(this._dio); + + /// 챌린지 멤버 조회 API + /// - [challengeId]: 조회할 챌린지 ID + /// - [page]: 페이지 번호 (0부터 시작, 기본값 0) + /// - [nickname]: 닉네임 검색어 (선택) + Future> getChallengeMembers( + int challengeId, { + int page = 0, + String? nickname, + }) async { + print( + '🔥 [API Request] 챌린지($challengeId) 멤버 조회 요청 (Page: $page, Nickname: $nickname)', + ); + + try { + // 1. 쿼리 파라미터 구성 + final Map queryParams = {'page': page}; + + if (nickname != null && nickname.isNotEmpty) { + queryParams['nickname'] = nickname; + } + + // 2. GET 요청 + final response = await _dio.get( + '/api/challenges/$challengeId/members', + queryParameters: queryParams, + ); + + print('✨ [API Response] Status: ${response.statusCode}'); + print('📦 [API Response] Data: ${response.data}'); + + if (response.statusCode == 200) { + final data = response.data; + List list = []; + + // 3. 응답 데이터 파싱 (Spring Boot Page 객체 또는 단순 리스트 대응) + if (data is Map && data.containsKey('content')) { + list = data['content'] as List; + print('✅ [Parsing] Page 객체 감지됨. content 리스트 추출.'); + } else if (data is List) { + list = data; + print('✅ [Parsing] 단순 리스트 감지됨.'); + } else { + print('⚠️ [Parsing Warning] 예상치 못한 데이터 구조입니다.'); + } + + final members = list.map((e) => User.fromJson(e)).toList(); + print('✅ [Success] 총 ${members.length}명의 멤버 로드 완료'); + + return members; + } else { + throw Exception('멤버 조회 실패 (Status: ${response.statusCode})'); + } + } on DioException catch (e) { + print('🚨 [DioError] ${e.response?.statusCode} / ${e.response?.data}'); + throw Exception(e.response?.data['message'] ?? '서버 요청 실패'); + } catch (e) { + print('🚫 [Exception] $e'); + throw Exception('멤버 정보를 불러오는데 실패했습니다.'); + } + } + + /// 챌린지 멤버 추방 API + /// - [challengeId]: 챌린지 ID + /// - [targetUserId]: 추방할 멤버의 유저 ID + Future kickMember(int challengeId, int targetUserId) async { + print('🔥 [API Request] 멤버 추방 요청: 챌린지 $challengeId, 타겟 $targetUserId'); + + try { + final response = await _dio.post( + '/api/challenges/$challengeId/members/kick', + data: { + // [체크 필요] 백엔드 DTO의 필드명과 일치해야 합니다. (예: targetUserId, memberId, kickUserId 등) + 'targetUserId': targetUserId, + }, + ); + + if (response.statusCode == 204) { + print('✅ [Success] 멤버 추방 성공'); + return; + } else { + throw Exception('추방 실패 (Status: ${response.statusCode})'); + } + } on DioException catch (e) { + print('🚨 [DioError] ${e.response?.data}'); + throw Exception(e.response?.data['message'] ?? '서버 통신 오류'); + } catch (e) { + print('🚫 [Exception] $e'); + throw Exception('알 수 없는 오류가 발생했습니다.'); + } + } + + /// 챌린지장 위임 API + /// - [challengeId]: 챌린지 ID + /// - [delegateMemberId]: 위임할 멤버의 유저 ID + Future delegateChallengeOwner( + int challengeId, + int delegateMemberId, + ) async { + try { + debugPrint( + '🚀 [POST Request] /api/challenges/$challengeId/owner/delegate', + ); + + final response = await _dio.post( + '/api/challenges/$challengeId/owner/delegate', + data: {'delegateMemberId': delegateMemberId}, + ); + + if (response.statusCode != 204 && response.statusCode != 200) { + throw Exception('방장 위임 실패 (Status: ${response.statusCode})'); + } + debugPrint('✅ 방장 위임 성공'); + } on DioException catch (e) { + debugPrint('❌ 방장 위임 API 에러: ${e.response?.data}'); + throw Exception(e.response?.data['message'] ?? '위임 처리 중 오류가 발생했습니다.'); + } + } +} diff --git a/lib/features/challenge/settings/data/challenge_member_repository.g.dart b/lib/features/challenge/settings/data/challenge_member_repository.g.dart new file mode 100644 index 0000000..d1f946a --- /dev/null +++ b/lib/features/challenge/settings/data/challenge_member_repository.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'challenge_member_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$challengeMemberRepositoryHash() => + r'ee8c267dabd2716b94e76b90b53de9403a630b13'; + +/// See also [challengeMemberRepository]. +@ProviderFor(challengeMemberRepository) +final challengeMemberRepositoryProvider = + AutoDisposeProvider.internal( + challengeMemberRepository, + name: r'challengeMemberRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$challengeMemberRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef ChallengeMemberRepositoryRef = + AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/challenge/settings/provider/challenge_delete_provider.dart b/lib/features/challenge/settings/provider/challenge_delete_provider.dart new file mode 100644 index 0000000..27320ab --- /dev/null +++ b/lib/features/challenge/settings/provider/challenge_delete_provider.dart @@ -0,0 +1,32 @@ +/// 최초 작성자: 정승빈 +library; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../data/challenge_delete_repository.dart'; +import 'package:haenaem/shared/provider/home_provider.dart'; // challengeHomeNotifierProvider 위치에 맞게 수정 + +part 'challenge_delete_provider.g.dart'; + +@riverpod +class ChallengeDeleteNotifier extends _$ChallengeDeleteNotifier { + @override + AsyncValue build() => const AsyncValue.data(null); + + Future removeChallenge(int challengeId) async { + state = const AsyncValue.loading(); + + final result = await AsyncValue.guard( + () => ref + .read(challengeDeleteRepositoryProvider) + .deleteChallenge(challengeId), + ); + + state = result; + + if (!result.hasError) { + ref.invalidate(homeNotifierProvider); // 홈 리스트 갱신 + return true; + } + return false; + } +} diff --git a/lib/features/challenge/settings/provider/challenge_delete_provider.g.dart b/lib/features/challenge/settings/provider/challenge_delete_provider.g.dart new file mode 100644 index 0000000..d745066 --- /dev/null +++ b/lib/features/challenge/settings/provider/challenge_delete_provider.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'challenge_delete_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$challengeDeleteNotifierHash() => + r'd5932b5175afd252a1375e79ca996bb62a905247'; + +/// See also [ChallengeDeleteNotifier]. +@ProviderFor(ChallengeDeleteNotifier) +final challengeDeleteNotifierProvider = + AutoDisposeNotifierProvider< + ChallengeDeleteNotifier, + AsyncValue + >.internal( + ChallengeDeleteNotifier.new, + name: r'challengeDeleteNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$challengeDeleteNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$ChallengeDeleteNotifier = AutoDisposeNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/challenge/provider/challenge_member_provider.dart b/lib/features/challenge/settings/provider/challenge_member_provider.dart similarity index 79% rename from lib/features/challenge/provider/challenge_member_provider.dart rename to lib/features/challenge/settings/provider/challenge_member_provider.dart index db79c31..1059ed8 100644 --- a/lib/features/challenge/provider/challenge_member_provider.dart +++ b/lib/features/challenge/settings/provider/challenge_member_provider.dart @@ -2,8 +2,9 @@ library; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../data/challenge_repository.dart'; -import '../../user/model/user_model.dart'; +// import '../../data/challenge_repository.dart'; +import '../data/challenge_member_repository.dart'; +import 'package:haenaem/shared/models/user.dart'; part 'challenge_member_provider.g.dart'; @@ -30,11 +31,11 @@ class MemberFilter { } @riverpod -Future> challengeMembers( +Future> challengeMembers( ChallengeMembersRef ref, - MemberFilter filter, // int challengeId 대신 Filter 객체를 받음 + MemberFilter filter, ) async { - final repository = ref.watch(challengeRepositoryProvider); + final repository = ref.watch(challengeMemberRepositoryProvider); return repository.getChallengeMembers( filter.challengeId, diff --git a/lib/features/challenge/provider/challenge_member_provider.g.dart b/lib/features/challenge/settings/provider/challenge_member_provider.g.dart similarity index 87% rename from lib/features/challenge/provider/challenge_member_provider.g.dart rename to lib/features/challenge/settings/provider/challenge_member_provider.g.dart index 06e6334..4f3c7c9 100644 --- a/lib/features/challenge/provider/challenge_member_provider.g.dart +++ b/lib/features/challenge/settings/provider/challenge_member_provider.g.dart @@ -6,7 +6,7 @@ part of 'challenge_member_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$challengeMembersHash() => r'64f129d20a062a2443e2fac5867db217ffcb1054'; +String _$challengeMembersHash() => r'8d51eb07d492fdeba9dc138d34a2984b483c2cbb'; /// Copied from Dart SDK class _SystemHash { @@ -34,7 +34,7 @@ class _SystemHash { const challengeMembersProvider = ChallengeMembersFamily(); /// See also [challengeMembers]. -class ChallengeMembersFamily extends Family>> { +class ChallengeMembersFamily extends Family>> { /// See also [challengeMembers]. const ChallengeMembersFamily(); @@ -66,8 +66,7 @@ class ChallengeMembersFamily extends Family>> { } /// See also [challengeMembers]. -class ChallengeMembersProvider - extends AutoDisposeFutureProvider> { +class ChallengeMembersProvider extends AutoDisposeFutureProvider> { /// See also [challengeMembers]. ChallengeMembersProvider(MemberFilter filter) : this._internal( @@ -97,8 +96,7 @@ class ChallengeMembersProvider @override Override overrideWith( - FutureOr> Function(ChallengeMembersRef provider) - create, + FutureOr> Function(ChallengeMembersRef provider) create, ) { return ProviderOverride( origin: this, @@ -115,7 +113,7 @@ class ChallengeMembersProvider } @override - AutoDisposeFutureProviderElement> createElement() { + AutoDisposeFutureProviderElement> createElement() { return _ChallengeMembersProviderElement(this); } @@ -135,14 +133,13 @@ class ChallengeMembersProvider @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -mixin ChallengeMembersRef - on AutoDisposeFutureProviderRef> { +mixin ChallengeMembersRef on AutoDisposeFutureProviderRef> { /// The parameter `filter` of this provider. MemberFilter get filter; } class _ChallengeMembersProviderElement - extends AutoDisposeFutureProviderElement> + extends AutoDisposeFutureProviderElement> with ChallengeMembersRef { _ChallengeMembersProviderElement(super.provider); diff --git a/lib/features/challenge/settings/challenge_members_screen.dart b/lib/features/challenge/settings/screens/challenge_members_screen.dart similarity index 91% rename from lib/features/challenge/settings/challenge_members_screen.dart rename to lib/features/challenge/settings/screens/challenge_members_screen.dart index ff0739f..f2a9721 100644 --- a/lib/features/challenge/settings/challenge_members_screen.dart +++ b/lib/features/challenge/settings/screens/challenge_members_screen.dart @@ -2,16 +2,19 @@ library; import 'package:flutter/material.dart'; -import 'package:haenaem/features/challenge/data/challenge_repository.dart'; -import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_typography.dart'; +// import 'package:haenaem/features/challenge/data/challenge_repository.dart'; +import '../data/challenge_member_repository.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/theme/app_typography.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:dio/dio.dart'; import 'package:haenaem/core/utils/korean_string_utils.dart'; -import 'package:haenaem/features/user/model/user_model.dart'; -import 'package:haenaem/features/challenge/provider/challenge_member_provider.dart'; -import 'widgets/kick_confirm_dialog.dart'; +// import 'package:haenaem/features/user/models/user_model.dart'; +import 'package:haenaem/shared/models/user.dart'; +import '../provider/challenge_member_provider.dart'; +import 'package:haenaem/features/user/provider/user_provider.dart'; +import '../widgets/kick_confirm_dialog.dart'; // 1. StatefulWidget으로 변경 (검색어 상태 관리를 위해) class ChallengeMemberManagementScreen extends ConsumerStatefulWidget { @@ -40,7 +43,7 @@ class _ScreenState extends ConsumerState { try { // 1. Repository 메서드 호출 (강퇴 요청) await ref - .read(challengeRepositoryProvider) + .read(challengeMemberRepositoryProvider) .kickMember(widget.challengeId, targetUserId); // 2. 성공 시 목록 새로고침 (Provider 초기화 -> 다시 로딩됨) @@ -118,12 +121,7 @@ class _ScreenState extends ConsumerState { nickname: null, ); final membersAsyncValue = ref.watch(challengeMembersProvider(filter)); - - // TODO: 실제 앱의 UserProvider 등을 통해 현재 로그인한 유저의 ID를 가져오기. - // 현재는 테스트용 임시 ID가 '나' 역할로 사용되고 있습니다. - // 그렇기에 '승빈'은 항상 '챌린지장' 배지가 표시되며 강퇴 버튼이 나타나지 않습니다. - // final currentUserId = ref.watch(userProvider).id; - const int currentUserId = 14; // 테스트용 임시 ID (로그상의 '승빈'님 ID) + final currentUserId = ref.watch(currentUserProvider)?.id; return Scaffold( backgroundColor: Colors.white, @@ -263,17 +261,15 @@ class _ScreenState extends ConsumerState { itemBuilder: (context, index) { final member = filteredMembers[index]; // 현재 렌더링 중인 멤버가 '나'인지 확인 - final isMe = member.memberId == currentUserId; + final isMe = member.id == currentUserId; return _MemberTile( member: member, isMe: isMe, // 상태 전달 notificationRed: AppColors.notification, // 콜백 함수 전달 - onKick: () => _handleKickMember( - member.memberId, - member.nickname, - ), + onKick: () => + _handleKickMember(member.id, member.nickname), ); }, ), @@ -297,7 +293,7 @@ class _MemberTile extends StatelessWidget { required this.onKick, }); - final ChallengeMember member; + final User member; final bool isMe; final Color notificationRed; final VoidCallback onKick; @@ -309,7 +305,7 @@ class _MemberTile extends StatelessWidget { child: Row( children: [ // 프로필 이미지 (임시 플레이스홀더) - buildProfileCircle(member.profileImageUrl, 44), + buildProfileCircle(member.profileUrl, 44), const SizedBox(width: 10), // 이름 및 칭호 Column( diff --git a/lib/features/challenge/settings/challenge_settings_screen.dart b/lib/features/challenge/settings/screens/challenge_settings_screen.dart similarity index 100% rename from lib/features/challenge/settings/challenge_settings_screen.dart rename to lib/features/challenge/settings/screens/challenge_settings_screen.dart diff --git a/lib/features/challenge/widgets/delegate_dialog.dart b/lib/features/challenge/settings/widgets/delegate_dialog.dart similarity index 89% rename from lib/features/challenge/widgets/delegate_dialog.dart rename to lib/features/challenge/settings/widgets/delegate_dialog.dart index 4a00e1e..97fe3fc 100644 --- a/lib/features/challenge/widgets/delegate_dialog.dart +++ b/lib/features/challenge/settings/widgets/delegate_dialog.dart @@ -4,11 +4,13 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:haenaem/core/theme/app_colors.dart'; import 'package:haenaem/core/theme/app_typography.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:haenaem/features/user/model/user_model.dart'; -import '../provider/challenge_provider.dart'; -import 'package:haenaem/features/challenge/provider/challenge_member_provider.dart'; +import 'package:haenaem/features/challenge/settings/data/challenge_member_repository.dart'; +import 'package:haenaem/shared/models/user.dart'; +// import '../provider/challenge_provider.dart'; +import 'package:haenaem/shared/provider/home_provider.dart'; +import 'package:haenaem/features/challenge/settings/provider/challenge_member_provider.dart'; import 'package:haenaem/shared/widgets/challenge_exit_base_dialog.dart'; -import 'package:haenaem/features/challenge/data/challenge_repository.dart'; +// import 'package:haenaem/features/challenge/data/challenge_repository.dart'; // 챌린지장 위임 다이얼로그 class DelegateDialog extends ConsumerStatefulWidget { @@ -20,7 +22,7 @@ class DelegateDialog extends ConsumerStatefulWidget { } class _DelegateDialogState extends ConsumerState { - ChallengeMember? selectedMember; + User? selectedMember; bool isExpanded = false; // 위임할 멤버 리스트 확장 여부 @override @@ -32,14 +34,11 @@ class _DelegateDialogState extends ConsumerState { if (selectedMember == null) return; try { await ref - .read(challengeRepositoryProvider) - .delegateChallengeOwner( - widget.challengeId, - selectedMember!.memberId, - ); + .read(challengeMemberRepositoryProvider) + .delegateChallengeOwner(widget.challengeId, selectedMember!.id); // 홈 화면 데이터 새로고침 - ref.invalidate(challengeHomeNotifierProvider); + ref.invalidate(homeNotifierProvider); if (context.mounted) { Navigator.of(context).popUntil((route) => route.isFirst); // 홈으로 이동 @@ -170,8 +169,8 @@ class _DelegateDialogState extends ConsumerState { leading: CircleAvatar( radius: 14, backgroundColor: AppColors.gray5, - backgroundImage: member.profileImageUrl != null - ? NetworkImage(member.profileImageUrl!) + backgroundImage: member.profileUrl != null + ? NetworkImage(member.profileUrl!) : null, ), title: Text(member.nickname, style: AppTypography.b2), diff --git a/lib/features/challenge/widgets/delete_challenge_dialog.dart b/lib/features/challenge/settings/widgets/delete_challenge_dialog.dart similarity index 89% rename from lib/features/challenge/widgets/delete_challenge_dialog.dart rename to lib/features/challenge/settings/widgets/delete_challenge_dialog.dart index d15ee50..fa8c6f8 100644 --- a/lib/features/challenge/widgets/delete_challenge_dialog.dart +++ b/lib/features/challenge/settings/widgets/delete_challenge_dialog.dart @@ -4,7 +4,10 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:haenaem/core/theme/app_colors.dart'; import 'package:haenaem/core/theme/app_typography.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../provider/challenge_provider.dart'; +// import '../../provider/challenge_provider.dart'; +import '../provider/challenge_delete_provider.dart'; +import 'package:haenaem/shared/provider/home_provider.dart'; +import 'package:haenaem/features/user/provider/my_challenge_provider.dart'; // 방장용 : 챌린지 삭제 다이얼로그 class DeleteChallengeDialog extends ConsumerWidget { @@ -31,9 +34,9 @@ class DeleteChallengeDialog extends ConsumerWidget { Container( width: 80, height: 80, - decoration: ShapeDecoration( + decoration: const ShapeDecoration( color: AppColors.warning, - shape: const CircleBorder(), + shape: CircleBorder(), ), child: Center( child: SvgPicture.asset( @@ -81,6 +84,9 @@ class DeleteChallengeDialog extends ConsumerWidget { if (success && context.mounted) { // 성공 시 모든 창을 닫고 홈으로 이동 + ref.read(homeNotifierProvider.notifier).refresh(); + // 내 페이지 진행중인 챌린지 상태 업데이트 + ref.invalidate(myInProgressChallengesProvider); Navigator.of( context, ).popUntil((route) => route.isFirst); diff --git a/lib/features/challenge/verification/data/verification_repository.dart b/lib/features/challenge/verification/data/verification_repository.dart new file mode 100644 index 0000000..07222a7 --- /dev/null +++ b/lib/features/challenge/verification/data/verification_repository.dart @@ -0,0 +1,112 @@ +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:haenaem/core/network/dio_provider.dart'; +import 'package:haenaem/shared/models/post.dart'; // [추가] 통합된 Post 모델 임포트 + +part 'verification_repository.g.dart'; + +class VerificationRepository { + final Dio _dio; + + VerificationRepository(this._dio); + + // 이미지를 서버에 임시 업로드하고 AI 검증을 요청 + Future verifyImage(File imageFile, int challengeId) async { + try { + final formData = FormData.fromMap({ + "image": await MultipartFile.fromFile( + imageFile.path, + filename: imageFile.path.split('/').last, + contentType: MediaType('image', 'jpeg'), + ), + }); + + final response = await _dio.post( + '/api/image/verify', + data: formData, + queryParameters: {'challengeId': challengeId}, + ); + + if (response.statusCode == 200 || response.statusCode == 204) { + debugPrint('✅ 이미지 검증 및 임시 업로드 성공: ${response.data}'); + return response.data?['tempImageId']; + } + return null; + } on DioException catch (e) { + debugPrint('❌ 이미지 검증 에러: ${e.response?.data}'); + return null; + } catch (e) { + debugPrint('❌ 알 수 없는 에러 발생: $e'); + return null; + } + } + + // 검증된 이미지 ID들과 함께 인증글을 최종 생성 + Future createArticle({ + required int challengeId, + required String content, + required List tempImageIds, + }) async { + try { + final response = await _dio.post( + '/api/articles', + data: { + "content": content, + "challengeId": challengeId, + "tempImageIds": tempImageIds, + }, + ); + + if (response.statusCode == 201 || response.statusCode == 200) { + debugPrint('📦 서버 응답 데이터: ${response.data}'); + + // [변경] CertificationPostModel -> Post + return Post.fromJson(response.data); + } else { + throw Exception('인증글 생성 실패: ${response.statusCode}'); + } + } on DioException catch (e) { + debugPrint('❌ 인증글 생성 에러: ${e.response?.data}'); + throw Exception(e.response?.data?['message'] ?? '게시글 업로드 실패'); + } + } + + // 기존 인증글을 수정합니다. + Future updateArticle({ + required int postId, + required String content, + required List deleteImageIds, + required List tempImageIds, + }) async { + try { + final response = await _dio.patch( + '/api/articles/$postId', + data: { + "content": content, + "deleteImageIds": deleteImageIds, + "tempImageIds": tempImageIds, + }, + ); + + if (response.statusCode == 200) { + debugPrint('✅ 인증글 수정 성공: ${response.data}'); + // [변경] CertificationPostModel -> Post + return Post.fromJson(response.data); + } else { + throw Exception('수정 실패: ${response.statusCode}'); + } + } on DioException catch (e) { + debugPrint('❌ 수정 에러 상세: ${e.response?.data}'); + throw Exception(e.response?.data?['message'] ?? '수정 중 오류 발생'); + } + } +} + +@riverpod +VerificationRepository verificationRepository(VerificationRepositoryRef ref) { + final dio = ref.watch(dioProvider); + return VerificationRepository(dio); +} diff --git a/lib/features/challenge/verification/data/verification_repository.g.dart b/lib/features/challenge/verification/data/verification_repository.g.dart new file mode 100644 index 0000000..dc1bb99 --- /dev/null +++ b/lib/features/challenge/verification/data/verification_repository.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'verification_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$verificationRepositoryHash() => + r'0275cb8f6d58c04031052100873fe37c04c8edc3'; + +/// See also [verificationRepository]. +@ProviderFor(verificationRepository) +final verificationRepositoryProvider = + AutoDisposeProvider.internal( + verificationRepository, + name: r'verificationRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$verificationRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef VerificationRepositoryRef = + AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/challenge/verification/provider/verification_provider.dart b/lib/features/challenge/verification/provider/verification_provider.dart new file mode 100644 index 0000000..b9066d5 --- /dev/null +++ b/lib/features/challenge/verification/provider/verification_provider.dart @@ -0,0 +1,122 @@ +import 'dart:io'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:haenaem/features/feed/provider/feed_provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../data/verification_repository.dart'; +import 'package:haenaem/shared/models/post.dart'; +import 'package:haenaem/shared/provider/post_provider.dart'; +import 'package:haenaem/shared/provider/home_provider.dart'; +import 'package:haenaem/features/challenge/detail/provider/stats_provider.dart'; + +part 'verification_provider.g.dart'; + +// 인증글 생성, 수정 시 관련된 캐시를 한번에 갱신하는 함수 +void _refreshRelatedProviders(Ref ref, int challengeId) { + final now = DateTime.now(); + + // 1. 해당 챌린지의 월간 포스트 리스트 갱신 + ref.invalidate( + monthlyChallengePostsProvider( + challengeId: challengeId, + year: now.year, + month: now.month, + ), + ); + + // 2. 챌린지 상세 통계(총 인증 횟수, 연속 인증 횟수) 갱신 + ref.invalidate(challengeStatsProvider(challengeId)); + + // 3. 홈 화면(진행 중인 챌린지 현황 등) 갱신 + ref.invalidate(homeNotifierProvider); + + // 4. 멤버 현황 갱신 + ref.invalidate(feedNotifierProvider); +} + +// 인증 이미지 검증 +@riverpod +class ImageVerifyNotifier extends _$ImageVerifyNotifier { + @override + AsyncValue build() => const AsyncValue.data(null); + + Future verify(File file, int challengeId) async { + state = const AsyncValue.loading(); + + // [변경] 기존 challengeRepository 대신 신규 verificationRepository 사용 + final result = await AsyncValue.guard( + () => ref + .read(verificationRepositoryProvider) + .verifyImage(file, challengeId), + ); + + state = result; + return result.valueOrNull; + } +} + +// 인증글 생성 +@riverpod +class ArticleCreateNotifier extends _$ArticleCreateNotifier { + @override + AsyncValue build() => const AsyncValue.data(null); + + Future submitArticle({ + required int challengeId, + required String content, + required List tempImageIds, // 검증 완료된 ID 리스트 + }) async { + state = const AsyncValue.loading(); + + final result = await AsyncValue.guard( + () => ref + .read(verificationRepositoryProvider) + .createArticle( + challengeId: challengeId, + content: content, + tempImageIds: tempImageIds, + ), + ); + + if (!result.hasError && result.value != null) { + _refreshRelatedProviders(ref, challengeId); + } + + state = result; + return !result.hasError; + } +} + +// 인증글 수정 +@riverpod +class ArticleUpdateNotifier extends _$ArticleUpdateNotifier { + @override + AsyncValue build() => const AsyncValue.data(null); + + Future editArticle({ + required int postId, + required int challengeId, + required String content, + List deleteImageIds = const [], + List tempImageIds = const [], // 새 이미지의 임시 ID 리스트 + }) async { + state = const AsyncValue.loading(); + + final result = await AsyncValue.guard( + () => ref + .read(verificationRepositoryProvider) + .updateArticle( + postId: postId, + content: content, + deleteImageIds: deleteImageIds, + tempImageIds: tempImageIds, + ), + ); + + if (!result.hasError && result.value != null) { + _refreshRelatedProviders(ref, challengeId); + } + + state = result; + return !result.hasError; + } +} diff --git a/lib/features/challenge/verification/provider/verification_provider.g.dart b/lib/features/challenge/verification/provider/verification_provider.g.dart new file mode 100644 index 0000000..ac2848e --- /dev/null +++ b/lib/features/challenge/verification/provider/verification_provider.g.dart @@ -0,0 +1,67 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'verification_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$imageVerifyNotifierHash() => + r'4dffeb198fa09f19bfe619e052f6d84b73e4c845'; + +/// See also [ImageVerifyNotifier]. +@ProviderFor(ImageVerifyNotifier) +final imageVerifyNotifierProvider = + AutoDisposeNotifierProvider>.internal( + ImageVerifyNotifier.new, + name: r'imageVerifyNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$imageVerifyNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$ImageVerifyNotifier = AutoDisposeNotifier>; +String _$articleCreateNotifierHash() => + r'ea2a88bead8bf1b6b370bab02fb8ea49bcf1aff6'; + +/// See also [ArticleCreateNotifier]. +@ProviderFor(ArticleCreateNotifier) +final articleCreateNotifierProvider = + AutoDisposeNotifierProvider< + ArticleCreateNotifier, + AsyncValue + >.internal( + ArticleCreateNotifier.new, + name: r'articleCreateNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$articleCreateNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$ArticleCreateNotifier = AutoDisposeNotifier>; +String _$articleUpdateNotifierHash() => + r'8519a707005f6e3485d92788ee073278ba2149db'; + +/// See also [ArticleUpdateNotifier]. +@ProviderFor(ArticleUpdateNotifier) +final articleUpdateNotifierProvider = + AutoDisposeNotifierProvider< + ArticleUpdateNotifier, + AsyncValue + >.internal( + ArticleUpdateNotifier.new, + name: r'articleUpdateNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$articleUpdateNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$ArticleUpdateNotifier = AutoDisposeNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/challenge/verification/screens/camera_edit_screen.dart b/lib/features/challenge/verification/screens/camera_edit_screen.dart index 06ed48c..17691b5 100644 --- a/lib/features/challenge/verification/screens/camera_edit_screen.dart +++ b/lib/features/challenge/verification/screens/camera_edit_screen.dart @@ -1,6 +1,8 @@ // 최초 작성자 : 김채영 import 'dart:io'; import 'dart:typed_data'; +import 'dart:ui' as ui; +import 'package:flutter/rendering.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:intl/intl.dart'; @@ -10,6 +12,8 @@ import 'package:haenaem/core/theme/app_colors.dart'; import 'package:haenaem/core/theme/app_typography.dart'; import 'package:image/image.dart' as img; +final GlobalKey _repaintKey = GlobalKey(); + // 카메라로 촬영했을 때의 편집 화면 class CameraEditScreen extends StatefulWidget { final File imageFile; @@ -159,31 +163,35 @@ class _CameraEditScreenState extends State { // 현재 기기의 화면 너비 가져오기 final double screenWidth = MediaQuery.of(context).size.width; - return Container( - width: screenWidth, // 너비: 화면 가득 - height: screenWidth, - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)), - child: Stack( - children: [ - // 배경 이미지 (회전 적용) - Positioned.fill( - child: RotatedBox( - quarterTurns: _rotationTurns, - child: Image.memory(_imageData!, fit: BoxFit.cover), + return RepaintBoundary( + // ✅ 추가 + key: _repaintKey, // ✅ 추가 + child: Container( + width: screenWidth, // 너비: 화면 가득 + height: screenWidth, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)), + child: Stack( + children: [ + // 배경 이미지 (회전 적용) + Positioned.fill( + child: RotatedBox( + quarterTurns: _rotationTurns, + child: Image.memory(_imageData!, fit: BoxFit.cover), + ), ), - ), - // 타임스탬프 (자르기 모드가 아닐 때만 보임) - Positioned( - right: 16, - bottom: 16, - child: Text( - _timestamp, - textAlign: TextAlign.right, - style: AppTypography.h1.copyWith(color: Colors.white), + // 타임스탬프 (자르기 모드가 아닐 때만 보임) + Positioned( + right: 40, + bottom: 16, + child: Text( + _timestamp, + textAlign: TextAlign.right, + style: AppTypography.h1.copyWith(color: Colors.white), + ), ), - ), - ], + ], + ), ), ); } @@ -266,19 +274,31 @@ class _CameraEditScreenState extends State { Future _saveAndReturn() async { if (_imageData == null) return; - // 회전이 적용된 경우 물리적으로 이미지 회전 처리 - Uint8List finalData = _imageData!; - if (_rotationTurns != 0) { - finalData = rotateImageBytes(finalData, _rotationTurns); - } + try { + // RepaintBoundary로 화면에 보이는 그대로 캡처 (타임스탬프 포함) + final RenderRepaintBoundary boundary = + _repaintKey.currentContext!.findRenderObject() + as RenderRepaintBoundary; - final tempDir = await getTemporaryDirectory(); - final file = File( - '${tempDir.path}/camera_edited_${DateTime.now().millisecondsSinceEpoch}.png', - ); - await file.writeAsBytes(finalData); + // pixelRatio를 높이면 캡처 해상도가 올라감 (3.0 권장) + final ui.Image image = await boundary.toImage(pixelRatio: 3.0); + final ByteData? byteData = await image.toByteData( + format: ui.ImageByteFormat.png, + ); + + if (byteData == null) return; + final Uint8List finalData = byteData.buffer.asUint8List(); - if (mounted) Navigator.pop(context, file); + final tempDir = await getTemporaryDirectory(); + final file = File( + '${tempDir.path}/camera_edited_${DateTime.now().millisecondsSinceEpoch}.png', + ); + await file.writeAsBytes(finalData); + + if (mounted) Navigator.pop(context, file); + } catch (e) { + debugPrint('이미지 캡처 에러: $e'); + } } } diff --git a/lib/features/challenge/verification/screens/challenge_verification_screen.dart b/lib/features/challenge/verification/screens/challenge_verification_screen.dart index 811e064..13dfa6e 100644 --- a/lib/features/challenge/verification/screens/challenge_verification_screen.dart +++ b/lib/features/challenge/verification/screens/challenge_verification_screen.dart @@ -1,16 +1,17 @@ // 최초 작성자 : 김채영 import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:haenaem/core/utils/image_utils.dart'; import 'package:image_picker/image_picker.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:haenaem/core/theme/app_colors.dart'; import 'package:haenaem/core/theme/app_typography.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; -import 'package:haenaem/features/challenge/model/challenge_model.dart'; -import 'package:haenaem/features/challenge/model/image_model.dart'; -import 'package:intl/intl.dart'; + +import 'package:haenaem/shared/models/post.dart'; +import '../provider/verification_provider.dart'; +import 'package:haenaem/shared/provider/challenge_detail_provider.dart'; import '../../../../shared/widgets/challenge_label.dart'; import '../../../../shared/widgets/challenge_input_box.dart'; @@ -24,13 +25,12 @@ import '../widgets/ai_success_box.dart'; import '../widgets/ai_fail_box.dart'; import 'package:haenaem/features/challenge/verification/widgets/reverification_guide_box.dart'; import '../widgets/verification_submit_button.dart'; -import 'package:haenaem/features/challenge/widgets/verification_cancel_dialog.dart'; -import 'package:haenaem/features/feed/model/feed_model.dart'; +import 'package:haenaem/features/challenge/verification/widgets/verification_cancel_dialog.dart'; // 챌린지 인증하기 화면 class ChallengeVerificationScreen extends ConsumerStatefulWidget { final int challengeId; - final CertificationPostModel? existingPost; // 데이터가 있으면 수정 모드 + final Post? existingPost; // 데이터가 있으면 수정 모드 const ChallengeVerificationScreen({ super.key, @@ -55,7 +55,7 @@ class _ChallengeVerificationScreenState // 현재 "살아있는" 모든 사진의 총 합 (기존 사진 - 삭제할 것 + 새 사진) int get _currentTotalPhotoCount { - final int existingCount = widget.existingPost?.images.length ?? 0; + final int existingCount = widget.existingPost?.pictureUrl.length ?? 0; final int activeExisting = existingCount - _imageIdsToDelete.length; return activeExisting + _newImages.length; } @@ -71,7 +71,7 @@ class _ChallengeVerificationScreenState // 현재 화면에 보이는 총 사진 수 (기존 유지분 + 새로 추가분) int get _totalActivePhotoCount { - final int initialCount = widget.existingPost?.images.length ?? 0; + final int initialCount = widget.existingPost?.pictureUrl.length ?? 0; final int remainingExisting = initialCount - _imageIdsToDelete.length; return remainingExisting + _newImages.length; } @@ -116,7 +116,9 @@ class _ChallengeVerificationScreenState text: isEditMode ? widget.existingPost!.content : '', ); // 기존 이미지 데이터 초기화 - _existingImages = isEditMode ? List.from(widget.existingPost!.images) : []; + _existingImages = isEditMode + ? List.from(widget.existingPost!.pictureUrl) + : []; _scrollController.addListener(_onScroll); _contentController.addListener(() => setState(() {})); // 글자수 실시간 반영 @@ -133,7 +135,20 @@ class _ChallengeVerificationScreenState } // 사진 추가 시트 띄우기 - void _showImageSourceSheet() { + void _showImageSourceSheet() async { + // ✅ 갤러리/카메라 접근 전 권한 먼저 요청 + final PermissionState ps = await PhotoManager.requestPermissionExtend(); + + if (!ps.hasAccess) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('사진 접근 권한이 필요합니다.'))); + PhotoManager.openSetting(); // 설정 화면으로 유도 + } + return; + } + showModalBottomSheet( context: context, isScrollControlled: true, @@ -353,10 +368,13 @@ class _ChallengeVerificationScreenState Future _runImageVerification(File file) async { setState(() => _verifyStatus = ImageVerificationStatus.loading); + // 먼저 압축 + final File compressed = await compressImageFile(file); + // 1. 서버에 사진 검증 및 임시 업로드 요청 (Notifier 호출) final int? tempId = await ref .read(imageVerifyNotifierProvider.notifier) - .verify(file, widget.challengeId); + .verify(compressed, widget.challengeId); if (mounted) { setState(() { @@ -600,66 +618,45 @@ class _ChallengeVerificationScreenState } Future _onSave() async { - final now = DateTime.now(); - bool success = false; final content = _contentController.text.trim(); + bool success = false; - try { - if (isEditMode) { - // ✨ editArticle의 파라미터명을 tempImageIds로 맞춤 - success = await ref - .read(articleUpdateNotifierProvider.notifier) - .editArticle( - postId: widget.existingPost!.postId, - content: content, - deleteImageIds: _imageIdsToDelete, - tempImageIds: _tempImageIds, // 💡 File 대신 ID 리스트 전달 - ); - } else { - // ✨ submitArticle의 파라미터명을 tempImageIds로 맞춤 - success = await ref - .read(articleCreateNotifierProvider.notifier) - .submitArticle( - challengeId: widget.challengeId, - content: content, - tempImageIds: _tempImageIds, // 💡 File 대신 ID 리스트 전달 - ); - } - } catch (e) { - success = false; + if (isEditMode) { + success = await ref + .read(articleUpdateNotifierProvider.notifier) + .editArticle( + postId: widget.existingPost!.id, + challengeId: widget.challengeId, + content: content, + deleteImageIds: _imageIdsToDelete, + tempImageIds: _tempImageIds, + ); + } else { + success = await ref + .read(articleCreateNotifierProvider.notifier) + .submitArticle( + challengeId: widget.challengeId, + content: content, + tempImageIds: _tempImageIds, + ); } - if (success && mounted) { - // 💡 [에러 해결] 이 프로바이더만 이름 없이 숫자만 넣습니다 (Positional) - ref.invalidate(challengeCalendarDataProvider(widget.challengeId)); - - // 💡 아래 프로바이더들은 정의된 대로 이름을 명시합니다 (Named) - ref.invalidate( - challengeCalendarPhotosProvider( - challengeId: widget.challengeId, - year: now.year, - month: now.month, - ), - ); - ref.invalidate( - challengePostsProvider( - challengeId: widget.challengeId, - year: now.year, - month: now.month, - ), - ); - - ref.invalidate(challengeHomeNotifierProvider); + if (!mounted) return; + if (success) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(isEditMode ? '수정 완료!' : '인증 완료!'))); - Navigator.pop(context); - } else if (mounted) { + } else { + // 에러 발생 시 처리 (예: 스낵바 노출) + final error = isEditMode + ? ref.read(articleUpdateNotifierProvider).error + : ref.read(articleCreateNotifierProvider).error; + ScaffoldMessenger.of( context, - ).showSnackBar(const SnackBar(content: Text('인증에 실패했습니다. 다시 시도해주세요.'))); + ).showSnackBar(SnackBar(content: Text('오류가 발생했습니다: $error'))); } } } diff --git a/lib/features/challenge/widgets/verification_cancel_dialog.dart b/lib/features/challenge/verification/widgets/verification_cancel_dialog.dart similarity index 100% rename from lib/features/challenge/widgets/verification_cancel_dialog.dart rename to lib/features/challenge/verification/widgets/verification_cancel_dialog.dart diff --git a/lib/features/challenge/widgets/MockData.dart b/lib/features/challenge/widgets/MockData.dart deleted file mode 100644 index 2dacc01..0000000 --- a/lib/features/challenge/widgets/MockData.dart +++ /dev/null @@ -1,190 +0,0 @@ -// 최초 작성자 : 강선욱 -import 'package:haenaem/features/challenge/widgets/UserChallengeData.dart'; - -String imgUrl = "assets/images/testImage.jpg"; - -class MockData { - /// 특정 챌린지 이름을 기반으로 데이터를 찾아주는 함수 - static UserChallengeData getChallengeByName(String name) { - return getAllChallenges().firstWhere( - (element) => element.challengeName == name, - orElse: () => getAllChallenges().first, - ); - } - - /// 모든 챌린지 데이터를 반환 - static List getAllChallenges() { - return [_getRunningData(), _getStudyData(), _getCodingData()]; - } - - // --- 1. 매일 10분 러닝 (김해냄: 12회 인증, 5일 연속) --- - static UserChallengeData _getRunningData() { - return UserChallengeData( - challengeName: "졸업 프로젝트 코딩", - isHost: false, - totalCertCount: 12, - continuousCertCount: 5, - posts: [ - CertificationPost( - userName: "김해냄", - content: "sprint2가 끝났네요. 확실히 초반보다 코딩 속도가 붙는 것 같아요.", - date: DateTime(2026, 1, 20, 07, 30), - hasImage: true, - imageUrl: imgUrl, - likeCount: 24, // 최신글 좋아요 24개 - comments: [ - ChallengeComment( - id: "rc_1", - userName: "러닝메이트", - userBadge: "열정러너", - content: "팀원들 모두 끝까지 포기하지 마세요! 💻", - createdAt: DateTime(2026, 1, 20, 08, 05), - ), - ChallengeComment( - id: "rc_2", - userName: "비타민", - userBadge: "응원대장", - content: "연속 5일 인증 축하드려요! 불꽃 아이콘 너무 멋져요🔥", - createdAt: DateTime(2026, 1, 20, 08, 30), - ), - ChallengeComment( - id: "rc_3", - userName: "새벽반", - userBadge: "얼리버드", - content: "오늘 날씨 꽤 쌀쌀한데 고생하셨습니다!", - createdAt: DateTime(2026, 1, 20, 09, 10), - ), - ], - ), - CertificationPost( - content: "11일차 인증. 월요일 아침 성공!", - date: DateTime(2026, 1, 19, 07, 10), - hasImage: true, - imageUrl: imgUrl, - likeCount: 15, - ), - CertificationPost( - content: "10일차 인증. 인증하기 기능 구현 완료.", - date: DateTime(2026, 1, 18, 08, 45), - hasImage: true, - imageUrl: imgUrl, - likeCount: 12, - ), - CertificationPost( - content: "9일차 인증. 페이스 조절 중입니다.", - date: DateTime(2026, 1, 17, 07, 30), - hasImage: false, - likeCount: 8, - ), - CertificationPost( - content: "8일차 인증. 새로운 버그 발견!", - date: DateTime(2026, 1, 16, 07, 00), - hasImage: true, - imageUrl: imgUrl, - likeCount: 11, - ), - CertificationPost( - content: "7일차 인증. 일주일 달성 뿌듯하네요.", - date: DateTime(2026, 1, 15, 07, 20), - hasImage: true, - imageUrl: imgUrl, - likeCount: 20, - ), - CertificationPost( - content: "6일차 인증. 오늘은 가볍게 2시간만 코딩.", - date: DateTime(2026, 1, 14, 07, 50), - hasImage: false, - likeCount: 6, - ), - CertificationPost( - content: "5일차 인증. 습관이 되어가네요.", - date: DateTime(2026, 1, 13, 06, 30), - hasImage: true, - imageUrl: imgUrl, - likeCount: 14, - ), - CertificationPost( - content: "4일차 인증. 추위을 뚫고 카페에서 코딩!", - date: DateTime(2026, 1, 12, 07, 15), - hasImage: true, - imageUrl: imgUrl, - likeCount: 9, - ), - CertificationPost( - content: "3일차 인증. 작심삼일 고비 완료.", - date: DateTime(2026, 1, 11, 08, 00), - hasImage: false, - likeCount: 7, - ), - CertificationPost( - content: "2일차 인증. 상쾌한 아침 공기.", - date: DateTime(2026, 1, 10, 07, 30), - hasImage: true, - imageUrl: imgUrl, - likeCount: 13, - ), - CertificationPost( - content: "1일차 인증. 오늘부터 시작합니다!", - date: DateTime(2026, 1, 9, 07, 40), - hasImage: true, - imageUrl: imgUrl, - likeCount: 18, - ), - ], - ); - } - - // --- 2. 모각공 (김해냄: 8회 인증) --- - static UserChallengeData _getStudyData() { - return UserChallengeData( - challengeName: "모각공", - isHost: false, - totalCertCount: 8, - continuousCertCount: 2, - posts: List.generate(8, (index) { - int day = 19 - index; - return CertificationPost( - userName: "김해냄", - content: "${8 - index}회차 공부 인증입니다. 오늘도 집중 성공!", - date: DateTime(2026, 1, day, 15, 00), - hasImage: index % 2 == 0, - imageUrl: index % 2 == 0 ? imgUrl : null, - likeCount: 5 + (index * 3), // 좋아요 수 데이터 추가 - comments: index == 0 - ? [ - ChallengeComment( - id: "sc_1", - userName: "공부벌레", - userBadge: "독서실지기", - content: "열공하시네요! 화이팅입니다.", - createdAt: DateTime(2026, 1, 19, 16, 20), - ), - ] - : [], - ); - }), - ); - } - - // --- 3. 모각코 (김해냄 : 20회 인증, 방장) --- - static UserChallengeData _getCodingData() { - return UserChallengeData( - challengeName: "모각코", - isHost: true, - totalCertCount: 20, - continuousCertCount: 10, - posts: List.generate(20, (index) { - int day = 20 - index; - return CertificationPost( - userName: "김해냄", - content: "${20 - index}회차 코딩 인증. 데이터 모델링 작업 중입니다.", - date: DateTime(2026, 1, day, 23, 00), - hasImage: index < 5, // 최근 5개만 이미지 있음 - imageUrl: index < 5 ? imgUrl : null, - likeCount: (index == 0) ? 32 : (20 - index) * 2, // 최신글은 32개, 나머지는 계산식 - comments: [], - ); - }), - ); - } -} diff --git a/lib/features/challenge/widgets/NotificationSettingsDialog.dart b/lib/features/challenge/widgets/NotificationSettingsDialog.dart deleted file mode 100644 index 868c624..0000000 --- a/lib/features/challenge/widgets/NotificationSettingsDialog.dart +++ /dev/null @@ -1,422 +0,0 @@ -// 최초 작성자 : 강선욱 -import 'package:flutter/material.dart'; -import 'package:flutter_svg/svg.dart'; -import 'package:haenaem/core/theme/app_colors.dart'; -import 'package:haenaem/core/theme/app_typography.dart'; -import 'package:flutter/cupertino.dart'; - -// 챌린지 알림 설정 다이얼로그 -class NotificationSettingsDialog extends StatefulWidget { - const NotificationSettingsDialog({super.key}); - - @override - State createState() => - _NotificationSettingsDialogState(); -} - -class _NotificationSettingsDialogState - extends State { - // 스위치 상태 변수들 - bool allNotifications = true; - bool dailyReminder = true; - bool mateReaction = true; - bool mateVerification = true; - String selectedTime = "오후 9시"; - - @override - Widget build(BuildContext context) { - return Dialog( - insetPadding: const EdgeInsets.symmetric(horizontal: 20), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - backgroundColor: Colors.white, - elevation: 0, - clipBehavior: Clip.antiAlias, - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // 1. 헤더 영역 - Container( - width: double.infinity, - padding: const EdgeInsets.all(20), - decoration: const BoxDecoration( - color: Colors.white, - border: Border( - bottom: BorderSide(width: 1, color: AppColors.gray4), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - '알림 설정', - style: AppTypography.h3.copyWith(color: AppColors.black), - ), - GestureDetector( - onTap: () => Navigator.pop(context), - child: SizedBox( - width: 24, - height: 24, - child: SvgPicture.asset( - 'assets/images/icons/close_icon.svg', - ), - ), - ), - ], - ), - ), - - // 3. 설정 리스트 및 버튼 영역 - Padding( - padding: const EdgeInsets.fromLTRB(16, 10, 16, 12), - child: Column( - children: [ - _buildSwitchRow( - "전체 알림", - "모든 알림 받기", - allNotifications, - (val) => setState(() => allNotifications = val), - ), - _buildDailyReminderSection(), - _buildSwitchRow( - "메이트 반응 소식", - "다른 참여자들이 내 인증글에 반응 시 알림", - mateReaction, - (val) => setState(() => mateReaction = val), - ), - _buildSwitchRow( - "메이트 인증 소식", - "다른 참여자들이 인증 완료 시 알림", - mateVerification, - (val) => setState(() => mateVerification = val), - ), - const SizedBox(height: 24), - - // 완료 버튼 - SizedBox( - width: double.infinity, - height: 50, - child: ElevatedButton( - onPressed: () => Navigator.pop(context), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primaryAble, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - elevation: 0, - ), - child: const Text( - '완료', - style: TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildSwitchRow( - String title, - String subtitle, - bool value, - ValueChanged onChanged, - ) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - // 텍스트 영역 - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: AppTypography.b1.copyWith(color: AppColors.black), - ), - Text( - subtitle, - style: AppTypography.b2.copyWith(color: AppColors.gray2), - ), - ], - ), - ), - - // 스위치 영역: 이미지와 동일한 초록색 테마 적용 - Transform.scale( - scale: 0.8, - alignment: Alignment.centerRight, - child: Switch( - value: value, - onChanged: onChanged, - activeTrackColor: AppColors.primaryAble, - activeThumbColor: Colors.white, - inactiveTrackColor: AppColors.disable, // 이미지와 유사한 연회색 - inactiveThumbColor: Colors.white, - - // 테두리 제거 - trackOutlineColor: const WidgetStatePropertyAll( - Colors.transparent, - ), - - // 비활성화 시에도 버튼 크기가 작아지지 않도록 설정 - thumbIcon: WidgetStateProperty.all( - const Icon(null), - ), // 아이콘 공간 강제 확보 - thumbColor: const WidgetStatePropertyAll(Colors.white), - ), - ), - ], - ), - ); - } - - Widget _buildDailyReminderSection() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 1. 상단 스위치 행 - _buildSwitchRow( - "일일 리마인더", - "매일 $selectedTime 알림", - dailyReminder, - (val) => setState(() => dailyReminder = val), - ), - - // 2. 리마인더가 활성화되었을 때만 드롭다운 표시 - if (dailyReminder) - Padding( - padding: const EdgeInsets.only(bottom: 20), - child: GestureDetector( - onTap: () => _showTimePicker(context), // 터치 시 피커 호출 - child: Container( - width: double.infinity, - height: 48, - padding: const EdgeInsets.symmetric(horizontal: 20), - decoration: BoxDecoration( - color: AppColors.gray5, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(selectedTime, style: AppTypography.b2), - SvgPicture.asset( - 'assets/images/icons/big_down_arrow.svg', - colorFilter: const ColorFilter.mode( - AppColors.gray2, - BlendMode.srcIn, - ), - ), - ], - ), - ), - ), - ), - ], - ); - } - - void _showTimePicker(BuildContext context) { - final List hours = List.generate(12, (i) => "${i + 1}시"); - String currentPeriod = selectedTime.contains("오후") ? "오후" : "오전"; - String currentHour = selectedTime.split(' ').last; - int initialHourIndex = hours.indexOf(currentHour); - if (initialHourIndex == -1) initialHourIndex = 8; - - showDialog( - context: context, - barrierColor: Colors.black.withAlpha(100), // 기존 투명도 유지 - builder: (context) => StatefulBuilder( - builder: (context, setDialogState) => AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), // 다이얼로그 전체 곡률 유지 - ), - actionsAlignment: MainAxisAlignment.center, - backgroundColor: Colors.white, - contentPadding: const EdgeInsets.fromLTRB( - 20, - 20, - 20, - 0, - ), // 하단 여백 제거하여 버튼 밀착 - content: SizedBox( - width: MediaQuery.of(context).size.width * 0.8, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // 1. 오전/오후 선택용 애니메이션 버튼 - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: _buildAnimatedPeriodSelector( - currentPeriod, - (newPeriod) => - setDialogState(() => currentPeriod = newPeriod), - ), - ), - - const SizedBox(height: 24), - - // 2. 시간 선택 휠 - SizedBox( - height: 150, // 휠의 높이를 원하는 만큼 고정 (너무 늘어나지 않음) - child: Stack( - children: [ - // 중앙 하이라이트 바 - Center( - child: Container( - height: 40, - margin: const EdgeInsets.symmetric(horizontal: 24), - decoration: BoxDecoration( - color: AppColors.gray4.withAlpha(100), - borderRadius: BorderRadius.circular(10), - ), - ), - ), - CupertinoPicker( - itemExtent: 40, - scrollController: FixedExtentScrollController( - initialItem: initialHourIndex, - ), - onSelectedItemChanged: (index) => - setDialogState(() => currentHour = hours[index]), - selectionOverlay: const SizedBox.shrink(), - children: hours - .map( - (h) => Center( - child: Text(h, style: AppTypography.b1), - ), - ) - .toList(), - ), - ], - ), - ), - - const SizedBox(height: 20), - - Padding( - padding: const EdgeInsets.only( - bottom: 16, - left: 16, - right: 16, - ), // 하단과 좌우 여백 설정 - child: TextButton( - onPressed: () { - setState( - () => selectedTime = "$currentPeriod $currentHour", - ); - Navigator.pop(context); - }, - style: TextButton.styleFrom( - backgroundColor: Colors.transparent, - foregroundColor: AppColors.primaryAble, - minimumSize: const Size(double.infinity, 52), - padding: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: Text( - "완료", - style: AppTypography.b1.copyWith( - color: AppColors.primaryAble, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ], - ), - ), - ), - ), - ); - } - - Widget _buildAnimatedPeriodSelector( - String currentPeriod, - Function(String) onPeriodChanged, - ) { - return Container( - height: 48, - padding: const EdgeInsets.all(4), // 테두리와 내부 버튼 사이의 여백 - decoration: BoxDecoration( - color: AppColors.gray4, // 배경색 (이미지 b77188의 연회색) - borderRadius: BorderRadius.circular(12), - ), - child: LayoutBuilder( - builder: (context, constraints) { - // 전체 너비의 절반에서 패딩(4)을 뺀 값이 움직이는 배경의 너비가 됩니다. - double width = constraints.maxWidth / 2; - - return Stack( - children: [ - // 1. 배경에서 움직이는 흰색 하이라이트 박스 - AnimatedAlign( - duration: const Duration(milliseconds: 250), // 애니메이션 속도 - curve: Curves.easeInOut, // 부드러운 가속도 곡선 - alignment: currentPeriod == "오전" - ? Alignment.centerLeft - : Alignment.centerRight, - child: Container( - width: width - 4, // 좌우 여백을 고려한 너비 - height: double.infinity, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - boxShadow: [ - BoxShadow( - color: Colors.black.withAlpha(20), // 미세한 그림자 효과 - blurRadius: 4, - offset: const Offset(0, 2), - ), - ], - ), - ), - ), - - // 2. 상단 텍스트 레이어 (오전, 오후) - Row( - children: ["오전", "오후"].map((p) { - bool isSelected = currentPeriod == p; - return Expanded( - child: GestureDetector( - // 투명한 영역을 클릭해도 인식되도록 설정 - behavior: HitTestBehavior.opaque, - onTap: () => onPeriodChanged(p), - child: Center( - child: AnimatedDefaultTextStyle( - duration: const Duration(milliseconds: 250), - style: AppTypography.b2.copyWith( - color: isSelected ? Colors.black : AppColors.gray2, - fontWeight: isSelected - ? FontWeight.bold - : FontWeight.normal, - ), - child: Text(p), - ), - ), - ), - ); - }).toList(), - ), - ], - ); - }, - ), - ); - } -} diff --git a/lib/features/challenge/widgets/UserChallengeData.dart b/lib/features/challenge/widgets/UserChallengeData.dart deleted file mode 100644 index d6db355..0000000 --- a/lib/features/challenge/widgets/UserChallengeData.dart +++ /dev/null @@ -1,85 +0,0 @@ -// 최초 작성자 : 강선욱 -/// 사용자의 챌린지 전체 현황 및 인증글 데이터를 담는 모델 -class UserChallengeData { - final String challengeName; // 챌린지 이름 - final bool isHost; - final int totalCertCount; // 인증 완료 일수 - final int continuousCertCount; // 인증 연속 일수 - final List posts; // 인증글 리스트 - - UserChallengeData({ - required this.challengeName, - required this.isHost, - required this.totalCertCount, - required this.continuousCertCount, - required this.posts, - }); - - /// 1. 최신순으로 정렬된 인증글 리스트 반환 - List get sortedPosts { - // 원본 리스트를 보존하기 위해 복사본을 정렬하여 반환합니다. - return List.from(posts)..sort((a, b) => b.date.compareTo(a.date)); - } - - /// 2. 특정 연도와 월에 해당하는 인증글만 필터링 (캘린더용) - List getPostsByMonth(int year, int month) { - return posts.where((post) { - return post.date.year == year && post.date.month == month; - }).toList(); - } - - /// 3. 특정 날짜(일)에 딱 맞는 인증글 찾기 (캘린더 클릭용) - /// 해당 날짜에 글이 없으면 null을 반환합니다. - CertificationPost? getPostByDay(DateTime day) { - final filtered = posts.where( - (post) => - post.date.year == day.year && - post.date.month == day.month && - post.date.day == day.day, - ); - - return filtered.isEmpty ? null : filtered.first; - } -} - -/// 개별 인증글 상세 정보 모델 -class CertificationPost { - final String? userName; // 작성자 이름 (피드 화면에서 필요) - final String content; // 인증글 내용 - final DateTime date; // 인증글 날짜 - final bool hasImage; // 인증글 사진 유무 - final String? imageUrl; // (선택) 사진이 있다면 이미지 경로 - final int likeCount; // 인증글 좋아요 수 - final List comments; // 해당 글에 달린 댓글 리스트 - - CertificationPost({ - this.userName = "김해냄", // 기본값 설정 가능 - required this.content, - required this.date, - required this.hasImage, - this.imageUrl, - required this.likeCount, - this.comments = const [], // 초기값은 빈 리스트 - }); -} - -/// 댓글 정보 모델 -class ChallengeComment { - final String id; // 댓글 고유 ID - final String userName; // 작성자 이름 (예: 김코딩) - final String userBadge; // 작성자 칭호/배지 (예: 올빼미) - final String? profileUrl; // 프로필 이미지 경로 (Asset) - final String content; // 댓글 내용 - final DateTime createdAt; // 작성일시 - final bool isMyComment; // 본인 댓글 여부 (수정/삭제 권한 분기용) - - ChallengeComment({ - required this.id, - required this.userName, - required this.userBadge, - this.profileUrl, - required this.content, - required this.createdAt, - this.isMyComment = false, - }); -} diff --git a/lib/features/challenge/widgets/challenge_create_success_dialog.dart b/lib/features/challenge/widgets/challenge_create_success_dialog.dart deleted file mode 100644 index 5990baf..0000000 --- a/lib/features/challenge/widgets/challenge_create_success_dialog.dart +++ /dev/null @@ -1,560 +0,0 @@ -// // 최초 작성자 : 김채영 -// import 'package:flutter/material.dart'; -// import 'package:haenaem/core/theme/app_colors.dart'; -// import 'package:haenaem/core/theme/app_typography.dart'; -// import 'package:flutter_svg/flutter_svg.dart'; -// import 'package:flutter/services.dart'; // 클립보드 복사 -// import 'package:share_plus/share_plus.dart'; // 공유 -// import 'package:haenaem/features/challenge/model/challenge_model.dart'; // 💡 Response 및 Friend 모델 임포트 - -// // 챌린지 생성 성공했을 경우 띄우는 작은 화면 -// class ChallengeCreateSuccessDialog extends StatefulWidget { -// // 💡 response 모델 전체를 넘겨받습니다. -// final ChallengeCreateResponse createdData; - -// const ChallengeCreateSuccessDialog({super.key, required this.createdData}); - -// @override -// State createState() => -// _ChallengeCreateSuccessDialogState(); -// } - -// // 챌린지 생성 성공 화면의 로직 및 상태 관리 클래스 -// class _ChallengeCreateSuccessDialogState -// extends State { -// // 💡 초대한 친구들의 ID를 저장 (FriendModel의 id 타입에 맞춰 dynamic 또는 int/String 설정) -// final Set _invitedFriends = {}; - -// // 💡 모델의 친구 데이터를 기반으로 필터링 리스트 관리 -// List _filteredFriends = []; - -// // 검색창 제어를 위한 컨트롤러 -// final TextEditingController _searchController = TextEditingController(); - -// @override -// void initState() { -// super.initState(); -// // 💡 초기에는 모델에서 받은 실제 친구 목록을 보여주기 -// _filteredFriends = widget.createdData.friends; - -// // 검색창 입력 감지 리스너 추가 -// _searchController.addListener(onSearchChanged); -// } - -// @override -// void dispose() { -// _searchController.dispose(); -// super.dispose(); -// } - -// // 초대 버튼 누를 경우 뜨는 토스트 메시지 -// void _showToast(BuildContext context, String name) { -// OverlayEntry overlayEntry = OverlayEntry( -// builder: (context) => Positioned( -// bottom: 100, -// left: 20, -// right: 20, -// child: Material( -// color: Colors.transparent, -// child: Center( -// child: Container( -// padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), -// decoration: ShapeDecoration( -// color: const Color(0xff1B1D1B).withAlpha(200), -// shape: RoundedRectangleBorder( -// borderRadius: BorderRadius.circular(8), -// ), -// ), -// child: Text( -// '$name 님에게 챌린지 초대를 보냈습니다!', -// textAlign: TextAlign.center, -// style: AppTypography.b1.copyWith(color: Colors.white), -// ), -// ), -// ), -// ), -// ), -// ); - -// Overlay.of(context).insert(overlayEntry); - -// Future.delayed(const Duration(seconds: 2), () { -// overlayEntry.remove(); -// }); -// } - -// // 클립보드 복사 로직: 실제 응답받은 링크 사용 -// void copyToClipboard(BuildContext context) { -// Clipboard.setData( -// ClipboardData(text: widget.createdData.challengeLink), -// ).then((_) { -// ScaffoldMessenger.of(context).showSnackBar( -// SnackBar( -// content: const Text('링크가 클립보드에 복사되었습니다.'), -// behavior: SnackBarBehavior.floating, -// shape: RoundedRectangleBorder( -// borderRadius: BorderRadius.circular(10), -// ), -// duration: const Duration(seconds: 2), -// ), -// ); -// }); -// } - -// // 공유창 로직: 실제 응답받은 링크 사용 -// void shareChallenge(BuildContext context) async { -// final box = context.findRenderObject() as RenderBox?; - -// await Share.share( -// '[해냄] 새로운 챌린지에 초대받았어요!\n지금 바로 확인해보세요: ${widget.createdData.challengeLink}', -// subject: '해냄 챌린지 초대', -// sharePositionOrigin: box != null -// ? box.localToGlobal(Offset.zero) & box.size -// : null, -// ); -// } - -// // 검색 로직: 입력값이 바뀔 때마다 FriendModel 리스트를 필터링 -// void onSearchChanged() { -// String query = _searchController.text.toLowerCase(); -// setState(() { -// if (query.isEmpty) { -// _filteredFriends = widget.createdData.friends; -// } else { -// _filteredFriends = widget.createdData.friends -// .where((friend) => friend.nickname.toLowerCase().contains(query)) -// .toList(); -// } -// }); -// } - -// @override -// Widget build(BuildContext context) { -// return Dialog( -// alignment: const Alignment(0, -0.3), -// insetPadding: const EdgeInsets.symmetric(horizontal: 20), -// shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), -// child: Container( -// width: double.infinity, -// height: 600, -// clipBehavior: Clip.antiAlias, -// decoration: BoxDecoration( -// color: Colors.white, -// borderRadius: BorderRadius.circular(16), -// ), -// child: Column( -// children: [ -// buildGradientHeader(), -// Expanded( -// child: SingleChildScrollView( -// padding: const EdgeInsets.all(20), -// child: Column( -// crossAxisAlignment: CrossAxisAlignment.start, -// children: [ -// buildLinkShareSection(), -// const SizedBox(height: 10), -// buildFriendSearchBar(), -// const SizedBox(height: 10), -// if (_filteredFriends.isEmpty) -// Center( -// child: Padding( -// padding: const EdgeInsets.symmetric(vertical: 20), -// child: Text( -// '검색 결과가 없습니다.', -// style: AppTypography.b2.copyWith( -// color: AppColors.gray3, -// ), -// ), -// ), -// ) -// else -// ..._filteredFriends.map( -// (friend) => buildInviteItem(friend), -// ), -// ], -// ), -// ), -// ), -// buildLaterButton(context), -// ], -// ), -// ), -// ); -// } - -// Widget buildGradientHeader() { -// return Container( -// width: double.infinity, -// padding: const EdgeInsets.symmetric(vertical: 24), -// decoration: const BoxDecoration( -// gradient: LinearGradient( -// begin: Alignment.topCenter, -// end: Alignment.bottomCenter, -// colors: [Color(0xFF009951), Color(0xFF00C94D)], -// ), -// ), -// child: Column( -// children: [ -// SvgPicture.asset( -// 'assets/images/icons/challenge_create_success_check.svg', -// width: 44, -// height: 44, -// ), -// const SizedBox(height: 12), -// Text( -// '챌린지 생성 완료!', -// style: AppTypography.h2.copyWith(color: Colors.white), -// ), -// const SizedBox(height: 4), -// Text( -// '친구들을 초대해서 함께 도전해보세요', -// style: AppTypography.b1.copyWith( -// color: Colors.white.withAlpha(200), -// ), -// ), -// ], -// ), -// ); -// } - -// Widget buildLinkShareSection() { -// return Column( -// crossAxisAlignment: CrossAxisAlignment.start, -// children: [ -// Text( -// '챌린지 링크 공유', -// style: AppTypography.b2.copyWith(color: AppColors.gray1), -// ), -// const SizedBox(height: 8), -// Container( -// width: double.infinity, -// padding: const EdgeInsets.all(10), -// decoration: ShapeDecoration( -// color: Colors.white, -// shape: RoundedRectangleBorder( -// side: const BorderSide(width: 1, color: AppColors.gray4), -// borderRadius: BorderRadius.circular(10), -// ), -// ), -// child: Text( -// // 💡 실제 응답받은 링크 텍스트 표시 -// widget.createdData.challengeLink, -// style: AppTypography.c1.copyWith(color: const Color(0xFF3E7E60)), -// maxLines: 1, -// overflow: TextOverflow.ellipsis, -// ), -// ), -// const SizedBox(height: 8), -// Row( -// children: [ -// Expanded( -// child: buildActionButton( -// label: '복사', -// color: AppColors.gray5, -// iconPath: 'assets/images/icons/link_copy.svg', -// onTap: (ctx) => copyToClipboard(ctx), -// ), -// ), -// const SizedBox(width: 10), -// Expanded( -// child: buildActionButton( -// label: '공유', -// color: AppColors.gray5, -// iconPath: 'assets/images/icons/link_share.svg', -// onTap: (ctx) => shareChallenge(ctx), -// ), -// ), -// ], -// ), -// ], -// ); -// } - -// Widget buildActionButton({ -// required String label, -// required Color color, -// required String iconPath, -// required Function(BuildContext) onTap, -// }) { -// return Builder( -// builder: (context) { -// return GestureDetector( -// onTap: () => onTap(context), -// child: Container( -// padding: const EdgeInsets.symmetric(horizontal: 44, vertical: 8), -// decoration: ShapeDecoration( -// color: AppColors.gray5, -// shape: RoundedRectangleBorder( -// borderRadius: BorderRadius.circular(10), -// ), -// ), -// child: Row( -// mainAxisSize: MainAxisSize.min, -// children: [ -// SvgPicture.asset( -// iconPath, -// width: 16, -// height: 16, -// colorFilter: const ColorFilter.mode( -// AppColors.gray2, -// BlendMode.srcIn, -// ), -// ), -// const SizedBox(width: 4), -// Text( -// label, -// textAlign: TextAlign.center, -// style: AppTypography.b2.copyWith(color: AppColors.gray2), -// ), -// ], -// ), -// ), -// ); -// }, -// ); -// } - -// Widget buildFriendSearchBar() { -// return Container( -// width: double.infinity, -// height: 37.98, -// padding: const EdgeInsets.symmetric(horizontal: 16), -// decoration: ShapeDecoration( -// shape: RoundedRectangleBorder( -// side: const BorderSide(width: 1, color: AppColors.gray3), -// borderRadius: BorderRadius.circular(9.50), -// ), -// ), -// child: Row( -// mainAxisAlignment: MainAxisAlignment.start, -// crossAxisAlignment: CrossAxisAlignment.center, -// children: [ -// SvgPicture.asset( -// 'assets/images/icons/friend_search.svg', -// width: 18.99, -// height: 18.99, -// ), -// const SizedBox(width: 8), -// Expanded( -// child: TextField( -// controller: _searchController, -// style: AppTypography.b2.copyWith(color: AppColors.black), -// decoration: InputDecoration( -// hintText: '친구 검색', -// hintStyle: AppTypography.b2.copyWith(color: AppColors.gray3), -// border: InputBorder.none, -// isDense: true, -// contentPadding: EdgeInsets.zero, -// ), -// ), -// ), -// ], -// ), -// ); -// } - -// // 💡 FriendModel 객체를 받아 스타일대로 렌더링 -// Widget buildInviteItem(FriendModel friend) { -// bool isInvited = _invitedFriends.contains(friend.id); - -// return Container( -// margin: const EdgeInsets.only(bottom: 0), -// padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 11), -// decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)), -// child: Row( -// children: [ -// // 💡 프로필 이미지 연동 (null일 경우 기본 색상) -// CircleAvatar( -// radius: 20, -// backgroundColor: const Color(0xFFD9D9D9), -// backgroundImage: friend.profileImageUrl != null -// ? NetworkImage(friend.profileImageUrl!) -// : null, -// ), -// const SizedBox(width: 10), -// Text( -// friend.nickname, -// style: AppTypography.b2.copyWith(color: AppColors.black), -// ), -// const Spacer(), -// GestureDetector( -// onTap: isInvited -// ? null -// : () { -// setState(() { -// _invitedFriends.add(friend.id); -// }); -// _showToast(context, friend.nickname); -// }, -// child: isInvited ? buildInvitedButton() : buildActiveInviteButton(), -// ), -// ], -// ), -// ); -// } - -// Widget buildInvitedButton() { -// return Container( -// width: 54.08, -// height: 33.99, -// decoration: ShapeDecoration( -// color: AppColors.disable, -// shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), -// ), -// child: Center( -// child: Text( -// '초대함', -// textAlign: TextAlign.center, -// style: AppTypography.c1.copyWith(color: AppColors.gray2), -// ), -// ), -// ); -// } - -// Widget buildActiveInviteButton() { -// return Container( -// width: 54.08, -// height: 33.99, -// alignment: Alignment.center, -// decoration: BoxDecoration( -// color: AppColors.primaryAble, -// borderRadius: BorderRadius.circular(8), -// ), -// child: Text('초대', style: AppTypography.c1.copyWith(color: Colors.white)), -// ); -// } - -// Widget buildLaterButton(BuildContext context) { -// return Container( -// width: double.infinity, -// padding: const EdgeInsets.all(16), -// child: GestureDetector( -// onTap: () => Navigator.pop(context), -// child: Container( -// width: double.infinity, -// height: 44, -// alignment: Alignment.center, -// decoration: BoxDecoration( -// border: Border.all(color: AppColors.gray2), -// borderRadius: BorderRadius.circular(10), -// ), -// child: Text( -// '나중에 초대하기', -// style: AppTypography.b1.copyWith(color: AppColors.gray2), -// ), -// ), -// ), -// ); -// } -// } - -import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:haenaem/core/theme/app_colors.dart'; -import 'package:haenaem/core/theme/app_typography.dart'; -import 'package:haenaem/features/challenge/model/challenge_model.dart'; -import 'package:haenaem/features/challenge/invite/widgets/challenge_invite_content.dart'; - -class ChallengeCreateSuccessDialog extends StatelessWidget { - final ChallengeCreateResponse createdData; - - const ChallengeCreateSuccessDialog({super.key, required this.createdData}); - - @override - Widget build(BuildContext context) { - return Dialog( - alignment: const Alignment(0, -0.3), - insetPadding: const EdgeInsets.symmetric(horizontal: 20), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: Container( - width: double.infinity, - height: 600, // 전체 높이 유지 - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - ), - child: Column( - children: [ - // 1. 상단 그라데이션 헤더 (김채영님 스타일 유지) - _buildGradientHeader(), - - // 2. 본문 (정승빈님의 InviteContent 위젯 사용) - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: ChallengeInviteContent( - challengeId: createdData.id, - challengeUrl: createdData.challengeLink, - ), - ), - ), - - // 3. 하단 닫기 버튼 (김채영님 스타일 유지) - _buildLaterButton(context), - ], - ), - ), - ); - } - - // 상단 그라데이션 헤더 - Widget _buildGradientHeader() { - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 24), - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFF009951), Color(0xFF00C94D)], - ), - ), - child: Column( - children: [ - SvgPicture.asset( - 'assets/images/icons/challenge_create_success_check.svg', - width: 44, - height: 44, - ), - const SizedBox(height: 12), - Text( - '챌린지 생성 완료!', - style: AppTypography.h2.copyWith(color: Colors.white), - ), - const SizedBox(height: 4), - Text( - '친구들을 초대해서 함께 도전해보세요', - style: AppTypography.b1.copyWith( - color: Colors.white.withAlpha(200), - ), - ), - ], - ), - ); - } - - // 나중에 초대하기 버튼 - Widget _buildLaterButton(BuildContext context) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - child: GestureDetector( - onTap: () => Navigator.pop(context), - child: Container( - width: double.infinity, - height: 44, - alignment: Alignment.center, - decoration: BoxDecoration( - border: Border.all(color: AppColors.gray2), - borderRadius: BorderRadius.circular(10), - ), - child: Text( - '나중에 초대하기', - style: AppTypography.b1.copyWith(color: AppColors.gray2), - ), - ), - ), - ); - } -} diff --git a/lib/features/challenge/widgets/challenge_feed_popup_menu.dart b/lib/features/challenge/widgets/challenge_feed_popup_menu.dart new file mode 100644 index 0000000..70f2795 --- /dev/null +++ b/lib/features/challenge/widgets/challenge_feed_popup_menu.dart @@ -0,0 +1,229 @@ +// // 최초 작성자 : 강선욱 +// import 'package:intl/intl.dart'; +// import 'package:flutter/material.dart'; +// import 'package:flutter_svg/flutter_svg.dart'; +// import 'package:haenaem/core/theme/app_colors.dart'; +// import 'package:haenaem/core/theme/app_typography.dart'; +// import 'package:haenaem/features/challenge/widgets/DeleteConfirmDialog.dart'; +// import 'package:flutter_riverpod/flutter_riverpod.dart'; +// import 'package:haenaem/features/challenge/models/challenge_model.dart'; +// import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; +// import 'edit_article_dialog.dart'; +// import 'package:haenaem/features/challenge/verification/screens/challenge_verification_screen.dart'; + +// // 인증글 다이얼로그 (내 인증글일 경우와 타인의 인증글일 경우) +// class ChallengeFeedPopupMenu extends ConsumerWidget { +// final CertificationPostModel post; // 인증글 데이터 +// const ChallengeFeedPopupMenu({super.key, required this.post}); + +// @override +// Widget build(BuildContext context, WidgetRef ref) { +// // 1. 현재 로그인한 내 프로필 정보를 가져옵니다. +// final myProfileAsync = ref.watch(myProfileProvider); + +// // 2. 내 닉네임과 게시글 작성자 닉네임을 비교하여 '내 글' 여부 판단 +// final bool isMine = post.author; + +// // 2. [날짜 체크] 오늘 날짜 문자열(yyyy-MM-dd) 생성 +// final String todayStr = DateFormat('yyyy-MM-dd').format(DateTime.now()); + +// // 3. 게시글의 postDate와 오늘 날짜 비교 +// final bool isToday = post.postDate.trim().startsWith(todayStr); + +// // 💡 디버깅을 위해 로그를 한 번 찍어보세요. (문제 확인용) +// debugPrint('🔍 비교 날짜 - 서버 데이터: "${post.postDate}", 오늘 날짜: "$todayStr"'); +// debugPrint('🔍 판정 결과 - isMine: $isMine, isToday: $isToday'); + +// return PopupMenuButton( +// //popUpAnimationStyle: AnimationStyle.none, // 애니메이션 없이 즉시 노출 +// padding: EdgeInsets.zero, +// constraints: const BoxConstraints(maxWidth: 206), +// offset: const Offset(0, 40), // 버튼 아래로 띄우기 +// shape: RoundedRectangleBorder( +// side: const BorderSide(width: 1, color: AppColors.gray4), +// borderRadius: BorderRadius.circular(10), +// ), +// color: Colors.white, +// elevation: 4, // 그림자 효과 + +// icon: SvgPicture.asset( +// 'assets/images/icons/dots_vert_icon.svg', +// width: 24, +// height: 24, +// ), + +// // 내 글이냐 아니냐에 따라 아이템 리스트만 교체 +// itemBuilder: (context) { +// if (isMine) { +// if (isToday) { +// // 📅 오늘 올린 글: 수정/삭제 가능 +// return [ +// _buildPopupItem( +// '수정하기', +// 'assets/images/icons/edit_icon.svg', +// 'edit', +// ), +// _buildDivider(), +// _buildPopupItem( +// '삭제하기', +// 'assets/images/icons/small_trash_icon.svg', +// 'delete', +// isDanger: true, +// ), +// ]; +// } else { +// // 🕰️ 지난 날짜 글: 내 글이라도 수정/삭제 불가 (대신 챌린지 보기만 노출) +// // 자신이 쓴 글을 신고할 순 없으니 '챌린지 보기'만 넣어주는 게 자연스러워요! +// return [ +// _buildPopupItem( +// '챌린지 보기', +// 'assets/images/icons/eye.svg', +// 'view_challenge', +// ), +// ]; +// } +// } else { +// // ✨ 타인의 글: 기존과 동일 (보기/신고) +// return [ +// _buildPopupItem( +// '챌린지 보기', +// 'assets/images/icons/eye.svg', +// 'view_challenge', +// ), +// _buildDivider(), +// _buildPopupItem( +// '신고하기', +// 'assets/images/icons/complaint.svg', +// 'report', +// isDanger: true, +// ), +// ]; +// } +// }, +// onSelected: (value) => _handleMenuSelection(context, ref, value), +// ); +// } + +// PopupMenuItem _buildPopupItem( +// String title, +// String iconPath, +// String value, { +// bool isDanger = false, +// }) { +// final Color textColor = isDanger ? AppColors.notification : AppColors.black; + +// return PopupMenuItem( +// value: value, +// height: 40, // 높이 조절 +// padding: EdgeInsets.zero, +// child: Container( +// width: 200, +// padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), +// child: Row( +// children: [ +// SizedBox( +// width: 16, +// height: 16, +// child: SvgPicture.asset( +// iconPath, +// colorFilter: ColorFilter.mode(textColor, BlendMode.srcIn), +// ), +// ), +// const SizedBox(width: 8), +// Text( +// title, +// style: AppTypography.b2.copyWith(color: textColor, height: 1.5), +// ), +// ], +// ), +// ), +// ); +// } + +// // 구분선 위젯 +// PopupMenuEntry _buildDivider() { +// return const PopupMenuDivider(height: 1); +// } + +// // 메뉴 선택 핸들러 +// void _handleMenuSelection( +// BuildContext context, +// WidgetRef ref, +// String value, +// ) async { +// FocusManager.instance.primaryFocus?.unfocus(); + +// switch (value) { +// case 'edit': +// // 다이얼로그 대신 '인증하기' 화면으로 데이터와 함께 이동 +// Navigator.push( +// context, +// MaterialPageRoute( +// builder: (context) => ChallengeVerificationScreen( +// challengeId: post +// .challengeId, // 챌린지 ID가 필요하다면 post 모델에 추가하거나 context에서 가져와야 함 +// existingPost: post, // 현재 게시글 데이터를 전달하여 '수정 모드'로 진입 +// ), +// ), +// ); +// break; +// case 'delete': +// final bool? confirmed = await showDialog( +// context: context, +// barrierDismissible: false, +// builder: (context) => const DeleteConfirmDialog( +// title: '인증글 삭제', +// message: '정말로 이 게시물을 삭제하시겠습니까?\n삭제된 게시물은 복구할 수 없습니다.', +// ), +// ); + +// if (confirmed == true) { +// try { +// // 💡 삭제 시도 +// final success = await ref +// .read(articleDeleteNotifierProvider.notifier) +// .removeArticle(post.postId); + +// if (success && context.mounted) { +// ScaffoldMessenger.of( +// context, +// ).showSnackBar(const SnackBar(content: Text("인증글이 삭제되었습니다."))); + +// // 갱신 로직 +// ref.invalidate(challengePostsProvider); +// ref.invalidate(challengeCalendarPhotosProvider); +// ref.invalidate(challengeCalendarDataProvider); + +// // 만약 상세페이지라면 뒤로가기 +// Navigator.pop(context); +// } +// } catch (e) { +// // 💡 [핵심] 서버의 에러 메시지(PAST_POST_CANNOT_DELETE) 처리 +// String errorMessage = "삭제에 실패했습니다."; + +// if (e.toString().contains("PAST_POST_CANNOT_DELETE")) { +// errorMessage = "지나간 날짜의 인증글은 삭제할 수 없습니다. ✊"; +// } + +// if (context.mounted) { +// ScaffoldMessenger.of( +// context, +// ).showSnackBar(SnackBar(content: Text(errorMessage))); +// } +// } +// } +// break; + +// case 'view_challenge': +// // TODO: 챌린지 상세(소개) 페이지로 이동하거나 탭을 전환하는 로직 +// debugPrint('🚀 [Action] 챌린지 보기 클릭'); +// // TODO: Navigator.push(...) 혹은 현재 탭 전환 로직 추가 +// break; +// case 'complain': +// ScaffoldMessenger.of( +// context, +// ).showSnackBar(const SnackBar(content: Text("신고가 접수되었습니다."))); +// break; +// } +// } +// } diff --git a/lib/features/challenge/widgets/comment_popup_menu.dart b/lib/features/challenge/widgets/comment_popup_menu.dart index 64131b6..1ec8786 100644 --- a/lib/features/challenge/widgets/comment_popup_menu.dart +++ b/lib/features/challenge/widgets/comment_popup_menu.dart @@ -4,15 +4,19 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:haenaem/core/theme/app_colors.dart'; import 'package:haenaem/core/theme/app_typography.dart'; -import 'package:haenaem/features/challenge/model/challenge_model.dart'; -import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; +//import 'package:haenaem/features/challenge/models/challenge_model.dart'; +//import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; +import 'package:haenaem/features/feed/models/comment.dart'; +import 'package:haenaem/features/feed/provider/comment_provider.dart'; import 'package:haenaem/features/challenge/widgets/DeleteConfirmDialog.dart'; import 'edit_article_dialog.dart'; +import 'package:haenaem/features/report/screens/report_screen.dart'; +import 'package:haenaem/features/report/provider/report_provider.dart'; // 내 댓글이면 삭제/수정 + 다른 사람 댓글이면 신고 다이얼로그 class CommentPopupMenu extends ConsumerWidget { final int postId; - final ChallengeComment comment; + final Comment comment; final dynamic feedProvider; const CommentPopupMenu({ @@ -38,7 +42,7 @@ class CommentPopupMenu extends ConsumerWidget { color: Colors.white, itemBuilder: (context) { // 내 댓글인 경우 : 수정/삭제 - if (comment.mine) { + if (comment.isMine) { return [ _buildPopupItem( '수정하기', @@ -60,7 +64,7 @@ class CommentPopupMenu extends ConsumerWidget { _buildPopupItem( '신고하기', 'assets/images/icons/complaint.svg', - 'complain', + 'report', isDanger: true, ), ]; @@ -107,17 +111,17 @@ class CommentPopupMenu extends ConsumerWidget { final String? newContents = await showDialog( context: context, builder: (context) => - EditArticleDialog(initialContent: comment.contents), + EditArticleDialog(initialContent: comment.content), ); // 수정을 완료하고 텍스트를 입력했을 경우 API 호출 if (newContents != null && newContents.trim().isNotEmpty) { //FocusManager.instance.primaryFocus?.unfocus(); // 키보드 닫기! final success = await ref - .read(articleCommentUpdateNotifierProvider.notifier) + .read(commentUpdateNotifierProvider.notifier) .editComment( postId: postId, - commentId: comment.commentId, + commentId: comment.id, contents: newContents, ); @@ -142,8 +146,8 @@ class CommentPopupMenu extends ConsumerWidget { if (confirmed == true) { // 댓글 삭제 API 호출 final success = await ref - .read(articleCommentDeleteNotifierProvider.notifier) - .removeComment(postId: postId, commentId: comment.commentId); + .read(commentDeleteNotifierProvider.notifier) + .removeComment(postId: postId, commentId: comment.id); if (success && context.mounted) { // 피드 화면 댓글 수 감소를 위해 필요한 코드 @@ -161,40 +165,48 @@ class CommentPopupMenu extends ConsumerWidget { } } break; - case 'complain': - _showComplainDialog(context); + case 'report': + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ReportScreen( + targetType: ReportTargetType.comment, // 댓글 타입 + targetId: comment.id, // 댓글 ID + ), + ), + ); break; } } - // TODO: 나중에 따로 뺄까여 - // 신고 확인 다이얼로그 - void _showComplainDialog(BuildContext context) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('신고하기'), - content: const Text('이 댓글을 신고하시겠습니까?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('취소', style: TextStyle(color: AppColors.gray2)), - ), - TextButton( - onPressed: () { - // TODO: 신고 API 연결 (현재는 스낵바만 표시) - Navigator.pop(context); - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('신고가 접수되었습니다.'))); - }, - child: const Text( - '신고', - style: TextStyle(color: AppColors.notification), - ), - ), - ], - ), - ); - } + // // TODO: 나중에 따로 뺄까여 + // // 신고 확인 다이얼로그 + // void _showComplainDialog(BuildContext context) { + // showDialog( + // context: context, + // builder: (context) => AlertDialog( + // title: const Text('신고하기'), + // content: const Text('이 댓글을 신고하시겠습니까?'), + // actions: [ + // TextButton( + // onPressed: () => Navigator.pop(context), + // child: const Text('취소', style: TextStyle(color: AppColors.gray2)), + // ), + // TextButton( + // onPressed: () { + // // TODO: 신고 API 연결 (현재는 스낵바만 표시) + // Navigator.pop(context); + // ScaffoldMessenger.of( + // context, + // ).showSnackBar(const SnackBar(content: Text('신고가 접수되었습니다.'))); + // }, + // child: const Text( + // '신고', + // style: TextStyle(color: AppColors.notification), + // ), + // ), + // ], + // ), + // ); + // } } diff --git a/lib/features/feed/data/challenge_participate_repository.dart b/lib/features/feed/data/challenge_participate_repository.dart new file mode 100644 index 0000000..73f637d --- /dev/null +++ b/lib/features/feed/data/challenge_participate_repository.dart @@ -0,0 +1,43 @@ +import 'package:dio/dio.dart'; +import 'package:haenaem/core/network/dio_provider.dart'; +import 'package:flutter/foundation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'challenge_participate_repository.g.dart'; + +@riverpod +ChallengeParticipateRepository challengeParticipateRepository( + ChallengeParticipateRepositoryRef ref, +) { + final dio = ref.watch(dioProvider); // ← 공통 Dio 주입 + return ChallengeParticipateRepository(dio); +} + +// 최초 작성자 : 강선욱 +class ChallengeParticipateRepository { + final Dio _dio; + + ChallengeParticipateRepository(this._dio); + + /// 챌린지 참여하기 API + Future participateChallenge(int challengeId) async { + try { + debugPrint('🚀 [POST Request] /api/challenges/$challengeId/participate'); + + final response = await _dio.post( + '/api/challenges/$challengeId/participate', + ); + + // 성공 조건 체크 (200 또는 201) + if (response.statusCode != 200 && response.statusCode != 201) { + throw Exception('챌린지 참여에 실패했습니다.'); + } + debugPrint('✅ 챌린지 참여 성공'); + } on DioException catch (e) { + debugPrint('❌ 챌린지 참여 API 에러: ${e.response?.data}'); + throw Exception( + e.response?.data?['message'] ?? '이미 참여 중이거나 참여할 수 없는 챌린지입니다.', + ); + } + } +} diff --git a/lib/features/feed/data/challenge_participate_repository.g.dart b/lib/features/feed/data/challenge_participate_repository.g.dart new file mode 100644 index 0000000..3bb1e60 --- /dev/null +++ b/lib/features/feed/data/challenge_participate_repository.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'challenge_participate_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$challengeParticipateRepositoryHash() => + r'6ce4e1f08dee7bad8012056a541cf71fc9c2beb5'; + +/// See also [challengeParticipateRepository]. +@ProviderFor(challengeParticipateRepository) +final challengeParticipateRepositoryProvider = + AutoDisposeProvider.internal( + challengeParticipateRepository, + name: r'challengeParticipateRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$challengeParticipateRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef ChallengeParticipateRepositoryRef = + AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/feed/data/challenge_search_repository.dart b/lib/features/feed/data/challenge_search_repository.dart new file mode 100644 index 0000000..32fc05a --- /dev/null +++ b/lib/features/feed/data/challenge_search_repository.dart @@ -0,0 +1,47 @@ +import 'package:dio/dio.dart'; +import 'package:haenaem/core/network/dio_provider.dart'; +import 'package:flutter/foundation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:haenaem/shared/models/search_challenge_card.dart'; + +part 'challenge_search_repository.g.dart'; + +@riverpod +ChallengeSearchRepository challengeSearchRepository( + ChallengeSearchRepositoryRef ref, +) { + final dio = ref.watch(dioProvider); // ← 공통 Dio 주입 + return ChallengeSearchRepository(dio); +} + +// 최초 작성자: 강선욱 +class ChallengeSearchRepository { + final Dio _dio; + ChallengeSearchRepository(this._dio); + + /// 챌린지 검색 API + Future> searchChallenges({ + required String keyword, + int page = 0, + }) async { + try { + final response = await _dio.get( + '/api/challenges/search', + queryParameters: {'keyword': keyword, 'page': page}, + ); + + if (response.statusCode == 200) { + // API 응답 구조에 따라 'content' 리스트 파싱 + final List content = response.data['content'] ?? []; + return content + .map((json) => SearchChallengeCard.fromJson(json)) + .toList(); + } else { + throw Exception('검색 결과 조회 실패'); + } + } on DioException catch (e) { + debugPrint('❌ 검색 API 에러: ${e.response?.data}'); + throw Exception('검색 중 오류가 발생했습니다.'); + } + } +} diff --git a/lib/features/feed/data/challenge_search_repository.g.dart b/lib/features/feed/data/challenge_search_repository.g.dart new file mode 100644 index 0000000..9a8cd68 --- /dev/null +++ b/lib/features/feed/data/challenge_search_repository.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'challenge_search_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$challengeSearchRepositoryHash() => + r'35a38eb68a7fa2e4ca1a5e7c331bfbbd2520d58c'; + +/// See also [challengeSearchRepository]. +@ProviderFor(challengeSearchRepository) +final challengeSearchRepositoryProvider = + AutoDisposeProvider.internal( + challengeSearchRepository, + name: r'challengeSearchRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$challengeSearchRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef ChallengeSearchRepositoryRef = + AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/feed/data/feed_repository.dart b/lib/features/feed/data/feed_repository.dart index f865aff..58ad7c5 100644 --- a/lib/features/feed/data/feed_repository.dart +++ b/lib/features/feed/data/feed_repository.dart @@ -1,13 +1,56 @@ import 'package:dio/dio.dart'; -import 'package:haenaem/features/challenge/model/challenge_model.dart'; +import 'package:haenaem/core/network/dio_provider.dart'; +import 'package:flutter/foundation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +//import 'package:haenaem/features/challenge/models/challenge_model.dart'; +import 'package:haenaem/shared/models/post.dart'; +import 'package:haenaem/features/feed/models/comment.dart'; +import 'package:haenaem/shared/models/search_challenge_card.dart'; +import 'package:haenaem/shared/models/challenge_base.dart'; + +part 'feed_repository.g.dart'; + +@riverpod +FeedRepository feedRepository(FeedRepositoryRef ref) { + final dio = ref.watch(dioProvider); + return FeedRepository(dio); +} class FeedRepository { final Dio _dio; - FeedRepository(this._dio); + // ── AI 챌린지 추천 데이터 가져오기 ──────────────────────────── + Future> getAiRecommendations() async { + try { + // 명세서에 따른 POST 요청 + final response = await _dio.post('/api/v1/rag/recommend/discovery'); + + if (response.statusCode == 200) { + final List data = response.data; + return data.map((item) { + return SearchChallengeCard( + // 1. ChallengeBase 매핑 (리팩토링된 구조: id, title) + base: ChallengeBase(id: item['id'], title: item['title']), + // 2. 참여자 수 + participantCount: item['participantNumber'] ?? 0, + // 3. D-Day (명세서에 없으므로 기본값 0 처리) + dDay: 0, + // 4. 태그 (API의 객체 리스트를 String 리스트로 변환) + tags: (item['tags'] as List) + .map((tagObj) => tagObj['tag'] as String) + .toList(), + ); + }).toList(); + } + throw Exception('AI 추천 로드 실패'); + } catch (e) { + debugPrint('❌ AI 추천 에러: $e'); + throw Exception('네트워크 에러가 발생했습니다.'); + } + } + /// 공통 피드 조회 메서드 - /// [apiPath]를 파라미터로 받아 친구 피드나 둘러보기 피드 모두 처리 가능합니다. Future> getFeeds(String apiPath, int page) async { try { final response = await _dio.get( @@ -18,10 +61,10 @@ class FeedRepository { if (response.statusCode == 200) { final List content = response.data['content'] ?? []; - final List posts = []; + final List posts = []; for (var item in content) { try { - posts.add(CertificationPostModel.fromJson(item)); + posts.add(Post.fromJson(item)); } catch (e) { print("⚠️ 특정 포스트 파싱 실패 (ID: ${item['postId']}): $e"); // 에러가 난 포스트는 건너뜁니다. @@ -61,4 +104,171 @@ class FeedRepository { rethrow; } } + + // 인증글 상세 정보 가져오기 + Future getArticleDetail(int postId) async { + try { + debugPrint('🚀 [GET Request] /api/articles/$postId'); + final response = await _dio.get('/api/articles/$postId'); + + if (response.statusCode == 200) { + debugPrint('📥 상세조회 서버 응답 원본: ${response.data}'); + return Post.fromJson(response.data); + } else { + throw Exception('인증글 상세 조회 실패'); + } + } on DioException catch (e) { + debugPrint('❌ 상세 조회 에러: ${e.response?.data}'); + throw Exception('정보를 불러오지 못했습니다.'); + } + } + + // 인증글 생성 + Future createArticle({ + required int challengeId, + required String content, + required List tempImageIds, + }) async { + try { + final response = await _dio.post( + '/api/articles', + data: { + "content": content, + "challengeId": challengeId, + "tempImageIds": tempImageIds, + }, + ); + + if (response.statusCode == 201) { + return Post.fromJson(response.data); + } else { + throw Exception('인증글 생성 실패: ${response.statusCode}'); + } + } on DioException catch (e) { + debugPrint('❌ 인증글 생성 에러: ${e.response?.data}'); + throw Exception(e.response?.data['message'] ?? '게시글 업로드 실패'); + } + } + + // 인증글 수정 + Future updateArticle({ + required int postId, + required String content, + required List deleteImageIds, + required List tempImageIds, + }) async { + try { + final response = await _dio.patch( + '/api/articles/$postId', + data: { + "content": content, + "deleteImageIds": deleteImageIds, + "tempImageIds": tempImageIds, + }, + ); + if (response.statusCode == 200) { + debugPrint('✅ 인증글 수정 성공: ${response.data}'); + return Post.fromJson(response.data); + } else { + throw Exception('수정 실패: ${response.statusCode}'); + } + } on DioException catch (e) { + debugPrint('❌ 수정 에러 상세: ${e.response?.data}'); + throw Exception(e.response?.data?['message'] ?? '수정 중 오류 발생'); + } + } + + // 인증글 삭제 + Future deleteArticle(int postId) async { + try { + debugPrint('🚀 [DELETE Request] /api/articles/$postId'); + final response = await _dio.delete('/api/articles/$postId'); + + if (response.statusCode != 204 && response.statusCode != 200) { + throw Exception('삭제 실패 (Status: ${response.statusCode})'); + } + debugPrint('✅ 인증글 삭제 성공'); + } on DioException catch (e) { + debugPrint('❌ 인증글 삭제 에러: ${e.response?.data}'); + throw Exception(e.response?.data['message'] ?? '삭제 중 오류가 발생했습니다.'); + } + } + + // 댓글 목록 조회 + Future> getComments({required int postId, int page = 0}) async { + try { + final response = await _dio.get( + '/api/articles/$postId/comments', + queryParameters: {'page': page}, + ); + + if (response.statusCode == 200) { + final List content = response.data['content'] ?? []; + return content.map((json) => Comment.fromJson(json)).toList(); + } else { + throw Exception('댓글 목록 조회 실패'); + } + } on DioException catch (e) { + debugPrint('❌ 댓글 조회 에러: ${e.response?.data}'); + throw Exception('댓글을 불러오는 중 오류가 발생했습니다.'); + } + } + + // 댓글 생성 + Future createComment({ + required int postId, + required String contents, + }) async { + try { + final response = await _dio.post( + '/api/articles/$postId/comments', + data: {"contents": contents}, + ); + + if (response.statusCode != 201) { + throw Exception('댓글 생성 실패 (Status: ${response.statusCode})'); + } + } on DioException catch (e) { + debugPrint('❌ 댓글 생성 에러: ${e.response?.data}'); + throw Exception(e.response?.data['message'] ?? '댓글 작성 중 오류가 발생했습니다.'); + } + } + + // 댓글 수정 + Future updateComment({ + required int commentId, + required String contents, + }) async { + try { + debugPrint('🚀 [PATCH Request] /api/comments/$commentId'); + final response = await _dio.patch( + '/api/comments/$commentId', + data: {"contents": contents}, + ); + + if (response.statusCode != 204 && response.statusCode != 200) { + throw Exception('댓글 수정 실패 (Status: ${response.statusCode})'); + } + debugPrint('✅ 댓글 수정 성공'); + } on DioException catch (e) { + debugPrint('❌ 댓글 수정 에러: ${e.response?.data}'); + throw Exception(e.response?.data['message'] ?? '댓글 수정 중 오류가 발생했습니다.'); + } + } + + // 댓글 삭제 + Future deleteComment(int commentId) async { + try { + debugPrint('🚀 [DELETE Request] /api/comments/$commentId'); + final response = await _dio.delete('/api/comments/$commentId'); + + if (response.statusCode != 204 && response.statusCode != 200) { + throw Exception('댓글 삭제 실패 (Status: ${response.statusCode})'); + } + debugPrint('✅ 댓글 삭제 성공'); + } on DioException catch (e) { + debugPrint('❌ 댓글 삭제 에러: ${e.response?.data}'); + throw Exception(e.response?.data['message'] ?? '댓글 삭제 중 오류가 발생했습니다.'); + } + } } diff --git a/lib/features/feed/data/feed_repository.g.dart b/lib/features/feed/data/feed_repository.g.dart new file mode 100644 index 0000000..13c33b7 --- /dev/null +++ b/lib/features/feed/data/feed_repository.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'feed_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$feedRepositoryHash() => r'61ca4e0768668846bda032f6cd41b82ecf951d0b'; + +/// See also [feedRepository]. +@ProviderFor(feedRepository) +final feedRepositoryProvider = AutoDisposeProvider.internal( + feedRepository, + name: r'feedRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$feedRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef FeedRepositoryRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/feed/models/comment.dart b/lib/features/feed/models/comment.dart new file mode 100644 index 0000000..93d9d21 --- /dev/null +++ b/lib/features/feed/models/comment.dart @@ -0,0 +1,66 @@ +import 'package:haenaem/shared/models/user.dart'; + +// 최초 작성자: 강선욱 +// 댓글 모델 클래스 +// User에 정의된 필드(id, profileUrl, nickname)를 작성자 정보로 재사용 +class Comment { + final int id; // 댓글 id + final String content; // 댓글 내용 + final DateTime date; // 댓글 작성(또는 수정) 날짜 + final bool isEdited; // 댓글 수정 여부 + final User writer; // 작성자 정보 (id, profileUrl, nickname) + final bool isMine; // 현재 로그인 유저의 댓글 여부 + + const Comment({ + required this.id, + required this.content, + required this.date, + required this.isEdited, + required this.writer, + required this.isMine, + }); + + factory Comment.fromJson(Map json) { + // 1. 날짜 파싱: 기존 모델의 로직을 참고하여 updatedAt 우선, 없으면 createdAt 사용 + DateTime parsedDate = DateTime.now(); + if (json['updatedAt'] != null) { + parsedDate = DateTime.parse(json['updatedAt'].toString()).toLocal(); + } else if (json['createdAt'] != null) { + parsedDate = DateTime.parse(json['createdAt'].toString()).toLocal(); + } + + // 2. 작성자 정보 조립: 서버의 평면적 데이터를 Nested User 객체로 매핑 + final writer = User( + id: json['userId'] ?? 0, + nickname: json['userNickname'] ?? '익명', // 기존 로직 참고하여 기본값 '익명' 부여 + profileUrl: json['userPicture'], + ); + + return Comment( + id: json['commentId'] ?? 0, // id -> commentId + content: json['contents'] ?? '', // content -> contents + date: parsedDate, + isEdited: json['edited'] ?? false, // is_edited -> edited + writer: writer, + isMine: json['mine'] ?? false, // is_mine -> mine + ); + } + + Comment copyWith({ + int? id, + String? content, + DateTime? date, + bool? isEdited, + User? writer, + bool? isMine, + }) { + return Comment( + id: id ?? this.id, + content: content ?? this.content, + date: date ?? this.date, + isEdited: isEdited ?? this.isEdited, + writer: writer ?? this.writer, + isMine: isMine ?? this.isMine, + ); + } +} diff --git a/lib/features/feed/model/feed_model.dart b/lib/features/feed/models/feed_model.dart similarity index 82% rename from lib/features/feed/model/feed_model.dart rename to lib/features/feed/models/feed_model.dart index 4ae85f0..f4a2e27 100644 --- a/lib/features/feed/model/feed_model.dart +++ b/lib/features/feed/models/feed_model.dart @@ -1,9 +1,10 @@ -import 'package:haenaem/features/challenge/model/challenge_model.dart'; +//import 'package:haenaem/features/challenge/models/challenge_model.dart'; +import 'package:haenaem/shared/models/post.dart'; // 피드 상태를 관리하는 클래스 // 로딩 상태, 에러 상태 관리 class FeedState { - final List posts; + final List posts; final bool isLoading; final String? errorMessage; final int currentPage; // 현재 페이지 번호 @@ -18,7 +19,7 @@ class FeedState { }); FeedState copyWith({ - List? posts, + List? posts, bool? isLoading, String? errorMessage, int? currentPage, diff --git a/lib/features/feed/provider/challenge_participate_provider.dart b/lib/features/feed/provider/challenge_participate_provider.dart new file mode 100644 index 0000000..38ca2f9 --- /dev/null +++ b/lib/features/feed/provider/challenge_participate_provider.dart @@ -0,0 +1,40 @@ +// 최초 작성자 : 강선욱 +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../data/challenge_participate_repository.dart'; +import 'package:haenaem/features/user/provider/my_challenge_provider.dart'; + +part 'challenge_participate_provider.g.dart'; + +@riverpod +class ChallengeParticipateNotifier extends _$ChallengeParticipateNotifier { + @override + // 초기 상태는 아무 작업도 하지 않은 'data(null)' 상태로 둡니다. + AsyncValue build() => const AsyncValue.data(null); + + Future participate(int challengeId) async { + // 1. 상태를 로딩 중으로 변경 (UI에서 로딩바를 보여줄 수 있게 합니다) + state = const AsyncValue.loading(); + + // 2. 리포지토리에 실제 API 요청을 보냅니다. + final result = await AsyncValue.guard( + () => ref + .read( + challengeParticipateRepositoryProvider, + ) // [수정] Notifier가 아닌 Repository를 읽어야 합니다! + .participateChallenge(challengeId), + ); + + // 3. 결과(성공 또는 에러)를 상태에 저장합니다. + state = result; + + if (!result.hasError) { + // 4. 참여 성공 시, 내 진행 중인 챌린지 목록을 새로고침합니다. + // (기존 데이터가 무효화되어 다시 서버에서 받아오게 됩니다) + ref.invalidate(myInProgressChallengesProvider); + return true; + } + + // 에러 발생 시 false 반환 + return false; + } +} diff --git a/lib/features/feed/provider/challenge_participate_provider.g.dart b/lib/features/feed/provider/challenge_participate_provider.g.dart new file mode 100644 index 0000000..7f55b05 --- /dev/null +++ b/lib/features/feed/provider/challenge_participate_provider.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'challenge_participate_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$challengeParticipateNotifierHash() => + r'81cb8b5a9d6d65a601fbf2b2958b7df205f9c4c4'; + +/// See also [ChallengeParticipateNotifier]. +@ProviderFor(ChallengeParticipateNotifier) +final challengeParticipateNotifierProvider = + AutoDisposeNotifierProvider< + ChallengeParticipateNotifier, + AsyncValue + >.internal( + ChallengeParticipateNotifier.new, + name: r'challengeParticipateNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$challengeParticipateNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$ChallengeParticipateNotifier = AutoDisposeNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/feed/provider/challenge_search_provider.dart b/lib/features/feed/provider/challenge_search_provider.dart new file mode 100644 index 0000000..d1a342b --- /dev/null +++ b/lib/features/feed/provider/challenge_search_provider.dart @@ -0,0 +1,18 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:haenaem/shared/models/search_challenge_card.dart'; +import '../data/challenge_search_repository.dart'; + +part 'challenge_search_provider.g.dart'; + +// 최초 작성자: 강선욱 +// 피드 화면에서 챌린지 검색을 위한 Provider +@riverpod +Future> searchChallenges( + SearchChallengesRef ref, { + required String keyword, + int page = 0, +}) { + return ref + .watch(challengeSearchRepositoryProvider) + .searchChallenges(keyword: keyword, page: page); +} diff --git a/lib/features/feed/provider/challenge_search_provider.g.dart b/lib/features/feed/provider/challenge_search_provider.g.dart new file mode 100644 index 0000000..9295d5d --- /dev/null +++ b/lib/features/feed/provider/challenge_search_provider.g.dart @@ -0,0 +1,171 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'challenge_search_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$searchChallengesHash() => r'7c82fae9d8e6c30f7697025ff0a3a4174a14508c'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [searchChallenges]. +@ProviderFor(searchChallenges) +const searchChallengesProvider = SearchChallengesFamily(); + +/// See also [searchChallenges]. +class SearchChallengesFamily + extends Family>> { + /// See also [searchChallenges]. + const SearchChallengesFamily(); + + /// See also [searchChallenges]. + SearchChallengesProvider call({required String keyword, int page = 0}) { + return SearchChallengesProvider(keyword: keyword, page: page); + } + + @override + SearchChallengesProvider getProviderOverride( + covariant SearchChallengesProvider provider, + ) { + return call(keyword: provider.keyword, page: provider.page); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'searchChallengesProvider'; +} + +/// See also [searchChallenges]. +class SearchChallengesProvider + extends AutoDisposeFutureProvider> { + /// See also [searchChallenges]. + SearchChallengesProvider({required String keyword, int page = 0}) + : this._internal( + (ref) => searchChallenges( + ref as SearchChallengesRef, + keyword: keyword, + page: page, + ), + from: searchChallengesProvider, + name: r'searchChallengesProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$searchChallengesHash, + dependencies: SearchChallengesFamily._dependencies, + allTransitiveDependencies: + SearchChallengesFamily._allTransitiveDependencies, + keyword: keyword, + page: page, + ); + + SearchChallengesProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.keyword, + required this.page, + }) : super.internal(); + + final String keyword; + final int page; + + @override + Override overrideWith( + FutureOr> Function(SearchChallengesRef provider) + create, + ) { + return ProviderOverride( + origin: this, + override: SearchChallengesProvider._internal( + (ref) => create(ref as SearchChallengesRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + keyword: keyword, + page: page, + ), + ); + } + + @override + AutoDisposeFutureProviderElement> createElement() { + return _SearchChallengesProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is SearchChallengesProvider && + other.keyword == keyword && + other.page == page; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, keyword.hashCode); + hash = _SystemHash.combine(hash, page.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin SearchChallengesRef + on AutoDisposeFutureProviderRef> { + /// The parameter `keyword` of this provider. + String get keyword; + + /// The parameter `page` of this provider. + int get page; +} + +class _SearchChallengesProviderElement + extends AutoDisposeFutureProviderElement> + with SearchChallengesRef { + _SearchChallengesProviderElement(super.provider); + + @override + String get keyword => (origin as SearchChallengesProvider).keyword; + @override + int get page => (origin as SearchChallengesProvider).page; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/feed/provider/comment_provider.dart b/lib/features/feed/provider/comment_provider.dart new file mode 100644 index 0000000..1314e0b --- /dev/null +++ b/lib/features/feed/provider/comment_provider.dart @@ -0,0 +1,104 @@ +// 최초 작성자: 정승빈 (분리 및 리팩토링) +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:haenaem/features/feed/models/comment.dart'; +import '../data/feed_repository.dart'; +// 💡 상세 정보 새로고침을 위해 post_detail_provider 임포트 필요 +import 'package:haenaem/features/feed/provider/post_detail_provider.dart'; + +part 'comment_provider.g.dart'; + +// 1. 댓글 목록 불러오기 로직 +@riverpod +Future> postComments( + // 💡 articleComments -> postComments, 타입 Comment로 변경 + PostCommentsRef ref, { + required int postId, + int page = 0, +}) async { + final repository = ref.watch(feedRepositoryProvider); + return repository.getComments(postId: postId, page: page); +} + +// 2. 댓글 생성 로직 +@riverpod +class CommentCreateNotifier extends _$CommentCreateNotifier { + @override + AsyncValue build() => const AsyncValue.data(null); + + Future addComment({ + required int postId, + required String contents, + }) async { + state = const AsyncValue.loading(); + + final result = await AsyncValue.guard( + () => ref + .read(feedRepositoryProvider) + .createComment(postId: postId, contents: contents), + ); + + if (!result.hasError) { + // 💡 댓글 목록 및 게시글 상세 정보 새로고침 + ref.invalidate(postCommentsProvider(postId: postId)); + ref.invalidate(postDetailProvider(postId: postId)); + } + + state = result; + return !result.hasError; + } +} + +// 3. 댓글 수정 로직 +@riverpod +class CommentUpdateNotifier extends _$CommentUpdateNotifier { + @override + AsyncValue build() => const AsyncValue.data(null); + + Future editComment({ + required int postId, + required int commentId, + required String contents, + }) async { + state = const AsyncValue.loading(); + + final result = await AsyncValue.guard( + () => ref + .read(feedRepositoryProvider) + .updateComment(commentId: commentId, contents: contents), + ); + + if (!result.hasError) { + ref.invalidate(postCommentsProvider(postId: postId)); + } + + state = result; + return !result.hasError; + } +} + +// 4. 댓글 삭제 로직 +@riverpod +class CommentDeleteNotifier extends _$CommentDeleteNotifier { + @override + AsyncValue build() => const AsyncValue.data(null); + + Future removeComment({ + required int postId, + required int commentId, + }) async { + state = const AsyncValue.loading(); + + final result = await AsyncValue.guard( + () => ref.read(feedRepositoryProvider).deleteComment(commentId), + ); + + if (!result.hasError) { + // 💡 댓글 목록 및 게시글 상세 정보 새로고침 + ref.invalidate(postCommentsProvider(postId: postId)); + ref.invalidate(postDetailProvider(postId: postId)); + } + + state = result; + return !result.hasError; + } +} diff --git a/lib/features/feed/provider/comment_provider.g.dart b/lib/features/feed/provider/comment_provider.g.dart new file mode 100644 index 0000000..1105e56 --- /dev/null +++ b/lib/features/feed/provider/comment_provider.g.dart @@ -0,0 +1,224 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'comment_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$postCommentsHash() => r'078468e7edde7672d4d1e91472df38af619eb8b7'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [postComments]. +@ProviderFor(postComments) +const postCommentsProvider = PostCommentsFamily(); + +/// See also [postComments]. +class PostCommentsFamily extends Family>> { + /// See also [postComments]. + const PostCommentsFamily(); + + /// See also [postComments]. + PostCommentsProvider call({required int postId, int page = 0}) { + return PostCommentsProvider(postId: postId, page: page); + } + + @override + PostCommentsProvider getProviderOverride( + covariant PostCommentsProvider provider, + ) { + return call(postId: provider.postId, page: provider.page); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'postCommentsProvider'; +} + +/// See also [postComments]. +class PostCommentsProvider extends AutoDisposeFutureProvider> { + /// See also [postComments]. + PostCommentsProvider({required int postId, int page = 0}) + : this._internal( + (ref) => + postComments(ref as PostCommentsRef, postId: postId, page: page), + from: postCommentsProvider, + name: r'postCommentsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$postCommentsHash, + dependencies: PostCommentsFamily._dependencies, + allTransitiveDependencies: + PostCommentsFamily._allTransitiveDependencies, + postId: postId, + page: page, + ); + + PostCommentsProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.postId, + required this.page, + }) : super.internal(); + + final int postId; + final int page; + + @override + Override overrideWith( + FutureOr> Function(PostCommentsRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: PostCommentsProvider._internal( + (ref) => create(ref as PostCommentsRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + postId: postId, + page: page, + ), + ); + } + + @override + AutoDisposeFutureProviderElement> createElement() { + return _PostCommentsProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is PostCommentsProvider && + other.postId == postId && + other.page == page; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, postId.hashCode); + hash = _SystemHash.combine(hash, page.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin PostCommentsRef on AutoDisposeFutureProviderRef> { + /// The parameter `postId` of this provider. + int get postId; + + /// The parameter `page` of this provider. + int get page; +} + +class _PostCommentsProviderElement + extends AutoDisposeFutureProviderElement> + with PostCommentsRef { + _PostCommentsProviderElement(super.provider); + + @override + int get postId => (origin as PostCommentsProvider).postId; + @override + int get page => (origin as PostCommentsProvider).page; +} + +String _$commentCreateNotifierHash() => + r'c125a85e3dd166344703c77d2e7992c53fb9154a'; + +/// See also [CommentCreateNotifier]. +@ProviderFor(CommentCreateNotifier) +final commentCreateNotifierProvider = + AutoDisposeNotifierProvider< + CommentCreateNotifier, + AsyncValue + >.internal( + CommentCreateNotifier.new, + name: r'commentCreateNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$commentCreateNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$CommentCreateNotifier = AutoDisposeNotifier>; +String _$commentUpdateNotifierHash() => + r'24745c04e7d1a4e433fd6df4ef4a55c70158f6cd'; + +/// See also [CommentUpdateNotifier]. +@ProviderFor(CommentUpdateNotifier) +final commentUpdateNotifierProvider = + AutoDisposeNotifierProvider< + CommentUpdateNotifier, + AsyncValue + >.internal( + CommentUpdateNotifier.new, + name: r'commentUpdateNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$commentUpdateNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$CommentUpdateNotifier = AutoDisposeNotifier>; +String _$commentDeleteNotifierHash() => + r'25843072cf5f2a8d4461ca80c8746909cfe46261'; + +/// See also [CommentDeleteNotifier]. +@ProviderFor(CommentDeleteNotifier) +final commentDeleteNotifierProvider = + AutoDisposeNotifierProvider< + CommentDeleteNotifier, + AsyncValue + >.internal( + CommentDeleteNotifier.new, + name: r'commentDeleteNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$commentDeleteNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$CommentDeleteNotifier = AutoDisposeNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/feed/provider/feed_provider.dart b/lib/features/feed/provider/feed_provider.dart index 80f6ecd..3d03586 100644 --- a/lib/features/feed/provider/feed_provider.dart +++ b/lib/features/feed/provider/feed_provider.dart @@ -1,100 +1,57 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:dio/dio.dart'; +// 최초 작성자: 강선욱 +import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:haenaem/features/feed/data/feed_repository.dart'; -import 'package:haenaem/features/feed/model/feed_model.dart'; -import 'package:haenaem/features/auth/services/auth_service.dart'; - -// 1. Repository Provider 추가 (Dio 객체는 별도의 공통 Provider에서 가져오는 것이 좋습니다) -final feedRepositoryProvider = Provider((ref) { - final dio = Dio( - BaseOptions( - // Render.com 서버 주소를 베이스로 넣어두면 편리합니다 - baseUrl: 'https://hanaem.onrender.com', - //baseUrl: 'https://ungenially-undebatable-sindy.ngrok-free.dev', - connectTimeout: const Duration(seconds: 45), - receiveTimeout: const Duration(seconds: 45), - //headers: {'ngrok-skip-browser-warning': 'true'}, - ), - ); - - // 💡 모든 API 요청에 자동으로 토큰을 가로채서(Intercept) 넣어줍니다. - dio.interceptors.add( - InterceptorsWrapper( - onRequest: (options, handler) async { - // AuthService에 만들어두신 메서드로 토큰을 읽어옵니다. - final token = await AuthService.getAccessToken(); - - if (token != null) { - // 헤더에 Bearer 토큰 삽입 - options.headers['Authorization'] = 'Bearer $token'; - print("🔑 [Dio Interceptor] 토큰 삽입 완료"); - } else { - print("⚠️ [Dio Interceptor] 토큰을 찾을 수 없습니다."); - } - - return handler.next(options); - }, - onError: (DioException e, handler) async { - // 만약 401 에러(토큰 만료)가 나면 여기서 토큰 재발급 로직을 연결할 수도 있습니다. - if (e.response?.statusCode == 401) { - print("🚨 [Dio Interceptor] 401 에러 발생: 토큰이 만료되었을 수 있습니다."); - } - return handler.next(e); - }, - ), - ); - - return FeedRepository(dio); -}); - -// 2. Notifier 수정 (Repository 주입) -class FeedNotifier extends StateNotifier { - final String apiPath; - final FeedRepository _repository; // 추가 - - FeedNotifier({ - required this.apiPath, - required FeedRepository repository, // 추가 - }) : _repository = repository, - super(FeedState()); +import 'package:haenaem/features/feed/models/feed_model.dart'; +import 'package:haenaem/shared/models/search_challenge_card.dart'; + +part 'feed_provider.g.dart'; + +// ── AI 추천 챌린지 리스트를 관리하는 Provider 추가 ───────────────────── +@riverpod +Future> aiRecommendation(AiRecommendationRef ref) { + final repository = ref.watch(feedRepositoryProvider); + return repository.getAiRecommendations(); +} + +@riverpod +class FeedNotifier extends _$FeedNotifier { + @override + FeedState build(String apiPath) => FeedState(); + + // feed_repository.dart의 @riverpod 어노테이션으로 생성된 Provider를 사용 + FeedRepository get _repository => ref.read(feedRepositoryProvider); + + // ── 피드 최초 로드 ──────────────────────────── Future fetchFeeds() async { - // [수정] 1. 중복 호출 방지 가드: 이미 로딩 중이거나 데이터가 있으면 중단 - // (새로고침이 필요한 경우를 대비해 posts.isNotEmpty 조건은 상황에 따라 조절하세요) if (state.isLoading) return; - // [수정] 2. 호출 시작 즉시 로딩 상태로 변경 state = state.copyWith( isLoading: true, errorMessage: null, currentPage: 0, isLastPage: false, - // posts: [], // 필요하다면 초기화 ); try { - print("📡 [FeedNotifier] Repository 데이터 요청 중..."); + print("📡 [FeedNotifier] 요청 → $apiPath"); final result = await _repository.getFeeds(apiPath, 0); - - print("✅ [FeedNotifier] 데이터 수신 성공: ${result['posts'].length}개의 포스트"); + print("✅ [FeedNotifier] ${result['posts'].length}개 수신"); state = state.copyWith( posts: result['posts'], isLastPage: result['isLast'], - isLoading: false, // 로딩 완료 - ); - } catch (e, stacktrace) { - print("❌ [FeedNotifier] 에러 발생: $e"); - state = state.copyWith( - isLoading: false, // 에러 발생 시에도 로딩은 꺼줘야 함 - errorMessage: e.toString(), + isLoading: false, ); + } catch (e, st) { + print("❌ [FeedNotifier] fetchFeeds 에러: $e\n$st"); + state = state.copyWith(isLoading: false, errorMessage: e.toString()); } } - // 다음 페이지 추가 로드 (무한 스크롤) + // ── 무한 스크롤 추가 로드 ───────────────────── + Future loadMore() async { - // 이미 로딩 중이거나 마지막 페이지면 중단 if (state.isLoading || state.isLastPage) return; state = state.copyWith(isLoading: true); @@ -104,100 +61,77 @@ class FeedNotifier extends StateNotifier { final result = await _repository.getFeeds(apiPath, nextPage); state = state.copyWith( - posts: [...state.posts, ...result['posts']], // 기존 데이터 + 새 데이터 합치기 + posts: [...state.posts, ...result['posts']], currentPage: nextPage, isLastPage: result['isLast'], isLoading: false, ); } catch (e) { + print("❌ [FeedNotifier] loadMore 에러: $e"); state = state.copyWith(isLoading: false, errorMessage: e.toString()); } } - // 좋아요 상태 변경 메서드 + // ── 좋아요 토글 (Optimistic Update + Rollback) ─ + Future toggleLike(int postId) async { - // 1. 상태 변경 전, 현재 해당 포스트의 좋아요 여부를 확인합니다. - final post = state.posts.firstWhere((p) => p.postId == postId); - final wasLiked = post.liked; + final post = state.posts.firstWhere((p) => p.id == postId); + final wasLiked = post.isLiked; - // 2. 로컬 UI 즉시 변경 (Optimistic Update) - toggleLikeLocally(postId); + toggleLikeLocally(postId); // 즉시 UI 반영 try { - // 3. 확인한 'wasLiked' 상태를 리포지토리에 전달합니다. await _repository.toggleLike(postId, wasLiked); } catch (e) { - // 4. 서버 실패 시 다시 원래대로 롤백 - toggleLikeLocally(postId); - print("좋아요 요청 실패로 롤백: $e"); + toggleLikeLocally(postId); // 실패 시 롤백 + print("⚠️ [FeedNotifier] 좋아요 실패 → 롤백: $e"); } } void toggleLikeLocally(int postId) { state = state.copyWith( posts: state.posts.map((post) { - if (post.postId == postId) { - final isLiked = post.liked; - return post.copyWith( - liked: !isLiked, - likeNumber: isLiked ? post.likeNumber - 1 : post.likeNumber + 1, - ); - } - return post; + if (post.id != postId) return post; + return post.copyWith( + isLiked: !post.isLiked, + likeCount: post.isLiked ? post.likeCount - 1 : post.likeCount + 1, + ); }).toList(), ); } - void incrementCommentCountLocally(int postId) { - state = state.copyWith( - posts: state.posts.map((post) { - if (post.postId == postId) { - // 기존 post를 복사하면서 commentNumber만 1 증가시킴 - return post.copyWith(commentNumber: post.commentNumber + 1); - } - return post; - }).toList(), - ); - } + // ── 댓글 수 로컬 업데이트 ───────────────────── - void decrementCommentCountLocally(int postId) { + void incrementCommentCountLocally(int postId) => + _updateCommentCount(postId, 1); + + void decrementCommentCountLocally(int postId) => + _updateCommentCount(postId, -1); + + void _updateCommentCount(int postId, int delta) { state = state.copyWith( posts: state.posts.map((post) { - if (post.postId == postId) { - return post.copyWith( - // 💡 0보다 작아지지 않도록 처리하면서 -1 - commentNumber: post.commentNumber > 0 ? post.commentNumber - 1 : 0, - ); - } - return post; + if (post.id != postId) return post; + final updated = post.commentCount + delta; + return post.copyWith(commentCount: updated < 0 ? 0 : updated); }).toList(), ); } } -// 3. Provider 정의 부분 수정 -final friendFeedProvider = StateNotifierProvider(( - ref, // -) { - final repository = ref.watch(feedRepositoryProvider); // 리포지토리 구독 - return FeedNotifier(apiPath: '/api/feed/friends', repository: repository); -}); - -final exploreFeedProvider = StateNotifierProvider(( - ref, // -) { - final repository = ref.watch(feedRepositoryProvider); // 리포지토리 구독 - return FeedNotifier(apiPath: '/api/feed/public', repository: repository); -}); - -final memberFeedProvider = - StateNotifierProvider.family(( - ref, - challengeId, - ) { - final repository = ref.watch(feedRepositoryProvider); - return FeedNotifier( - apiPath: '/api/feed/challengeMember/$challengeId', - repository: repository, - ); - }); +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +// 각 탭·화면 전용 Provider alias +// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +/// 친구 피드 (FeedScreen 친구 탭) +/// 사용: ref.watch(friendFeedProvider) +final friendFeedProvider = feedNotifierProvider('/api/feed/friends'); + +/// 둘러보기 피드 (FeedScreen 둘러보기 탭) +/// 사용: ref.watch(exploreFeedProvider) +final exploreFeedProvider = feedNotifierProvider('/api/feed/public'); + +/// 챌린지 멤버 피드 +/// 사용: ref.watch(memberFeedProvider(42)) +memberFeedProvider(int challengeId) => + feedNotifierProvider('/api/feed/challengeMember/$challengeId'); diff --git a/lib/features/feed/provider/feed_provider.g.dart b/lib/features/feed/provider/feed_provider.g.dart new file mode 100644 index 0000000..97728e9 --- /dev/null +++ b/lib/features/feed/provider/feed_provider.g.dart @@ -0,0 +1,180 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'feed_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$aiRecommendationHash() => r'90ca6a9c155eaf19f39c8ef6ebc589e287b93686'; + +/// See also [aiRecommendation]. +@ProviderFor(aiRecommendation) +final aiRecommendationProvider = + AutoDisposeFutureProvider>.internal( + aiRecommendation, + name: r'aiRecommendationProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$aiRecommendationHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef AiRecommendationRef = + AutoDisposeFutureProviderRef>; +String _$feedNotifierHash() => r'58f6bfea36959e5344b16506c194b07fbbcce5f5'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$FeedNotifier extends BuildlessAutoDisposeNotifier { + late final String apiPath; + + FeedState build(String apiPath); +} + +/// See also [FeedNotifier]. +@ProviderFor(FeedNotifier) +const feedNotifierProvider = FeedNotifierFamily(); + +/// See also [FeedNotifier]. +class FeedNotifierFamily extends Family { + /// See also [FeedNotifier]. + const FeedNotifierFamily(); + + /// See also [FeedNotifier]. + FeedNotifierProvider call(String apiPath) { + return FeedNotifierProvider(apiPath); + } + + @override + FeedNotifierProvider getProviderOverride( + covariant FeedNotifierProvider provider, + ) { + return call(provider.apiPath); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'feedNotifierProvider'; +} + +/// See also [FeedNotifier]. +class FeedNotifierProvider + extends AutoDisposeNotifierProviderImpl { + /// See also [FeedNotifier]. + FeedNotifierProvider(String apiPath) + : this._internal( + () => FeedNotifier()..apiPath = apiPath, + from: feedNotifierProvider, + name: r'feedNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$feedNotifierHash, + dependencies: FeedNotifierFamily._dependencies, + allTransitiveDependencies: + FeedNotifierFamily._allTransitiveDependencies, + apiPath: apiPath, + ); + + FeedNotifierProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.apiPath, + }) : super.internal(); + + final String apiPath; + + @override + FeedState runNotifierBuild(covariant FeedNotifier notifier) { + return notifier.build(apiPath); + } + + @override + Override overrideWith(FeedNotifier Function() create) { + return ProviderOverride( + origin: this, + override: FeedNotifierProvider._internal( + () => create()..apiPath = apiPath, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + apiPath: apiPath, + ), + ); + } + + @override + AutoDisposeNotifierProviderElement createElement() { + return _FeedNotifierProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is FeedNotifierProvider && other.apiPath == apiPath; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, apiPath.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin FeedNotifierRef on AutoDisposeNotifierProviderRef { + /// The parameter `apiPath` of this provider. + String get apiPath; +} + +class _FeedNotifierProviderElement + extends AutoDisposeNotifierProviderElement + with FeedNotifierRef { + _FeedNotifierProviderElement(super.provider); + + @override + String get apiPath => (origin as FeedNotifierProvider).apiPath; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/feed/provider/post_detail_provider.dart b/lib/features/feed/provider/post_detail_provider.dart new file mode 100644 index 0000000..9679d14 --- /dev/null +++ b/lib/features/feed/provider/post_detail_provider.dart @@ -0,0 +1,142 @@ +// 최초 작성자: 정승빈 (분리 및 리팩토링) +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:haenaem/shared/models/post.dart'; +// 💡 FeedRepository가 있는 경로를 임포트해주세요. (feed_provider.dart 내부에 있다면 해당 파일 임포트) +import '../data/feed_repository.dart'; +import '../../../../shared/provider/post_provider.dart'; // monthlyChallengePostsProvider +import 'package:haenaem/shared/provider/challenge_detail_provider.dart'; + +part 'post_detail_provider.g.dart'; + +// 1. 인증글 상세 정보 가져오기 로직 +@riverpod +Future postDetail( + // 💡 articleDetail -> postDetail로 변경, 타입 Post로 변경 + PostDetailRef ref, { + required int postId, +}) async { + final repository = ref.watch( + feedRepositoryProvider, + ); // 💡 challenge -> feed 레포지토리로 변경 + return repository.getArticleDetail(postId); +} + +// 2. 인증글 생성 로직 +@riverpod +class PostCreateNotifier extends _$PostCreateNotifier { + @override + AsyncValue build() => const AsyncValue.data(null); // 💡 타입 Post로 변경 + + Future submitArticle({ + required int challengeId, + required String content, + required List tempImageIds, + }) async { + state = const AsyncValue.loading(); + + final result = await AsyncValue.guard( + () => ref + .read(feedRepositoryProvider) + .createArticle( + challengeId: challengeId, + content: content, + tempImageIds: tempImageIds, + ), + ); + + state = result; + return !result.hasError; + } +} + +// 3. 인증글 수정 로직 +@riverpod +class PostUpdateNotifier extends _$PostUpdateNotifier { + @override + AsyncValue build() => const AsyncValue.data(null); // 💡 타입 Post로 변경 + + Future editArticle({ + required int postId, + required String content, + List deleteImageIds = const [], + List tempImageIds = const [], + }) async { + state = const AsyncValue.loading(); + + final result = await AsyncValue.guard( + () => ref + .read(feedRepositoryProvider) + .updateArticle( + postId: postId, + content: content, + deleteImageIds: deleteImageIds, + tempImageIds: tempImageIds, + ), + ); + + if (!result.hasError) { + // 💡 수정 완료 후 상세 페이지 무효화(새로고침) + ref.invalidate(postDetailProvider(postId: postId)); + //ref.invalidate(challengePostsProvider); // 필요하다면 챌린지 피드도 무효화하여 목록 갱신 + } + + state = result; + return !result.hasError; + } +} + +// 4. 인증글 삭제 로직 +@riverpod +class PostDeleteNotifier extends _$PostDeleteNotifier { + @override + AsyncValue build() => const AsyncValue.data(null); + + Future removeArticle(int postId, int challengeId) async { + state = const AsyncValue.loading(); + + final result = await AsyncValue.guard( + () => ref.read(feedRepositoryProvider).deleteArticle(postId), + ); + + if (!result.hasError) { + final now = DateTime.now(); + // ✅ 캘린더 목록 갱신 + ref.invalidate( + monthlyChallengePostsProvider( + challengeId: challengeId, + year: now.year, + month: now.month, + ), + ); + // ✅ 상세 캐시 제거 + ref.invalidate(postDetailProvider(postId: postId)); + // ✅ 인증 여부 상태도 갱신 (인증하기 버튼 활성화) + ref.invalidate(challengeDetailProvider(challengeId: challengeId)); + } + + state = result; + return !result.hasError; + } +} + +// 5. 좋아요 로직 +@riverpod +class PostLikeNotifier extends _$PostLikeNotifier { + @override + AsyncValue build() => const AsyncValue.data(null); + + Future toggleLike({ + required int postId, + required bool isCurrentlyLiked, + }) async { + final result = await AsyncValue.guard( + () => + ref.read(feedRepositoryProvider).toggleLike(postId, isCurrentlyLiked), + ); + + if (!result.hasError) { + // 💡 성공 시 해당 게시글 상세 데이터 무효화 -> 화면 자동 갱신 + ref.invalidate(postDetailProvider(postId: postId)); + } + } +} diff --git a/lib/features/feed/provider/post_detail_provider.g.dart b/lib/features/feed/provider/post_detail_provider.g.dart new file mode 100644 index 0000000..6366bc1 --- /dev/null +++ b/lib/features/feed/provider/post_detail_provider.g.dart @@ -0,0 +1,216 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'post_detail_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$postDetailHash() => r'4c7ecb10d1517df7f808b4645bd6cc6047aab5c6'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [postDetail]. +@ProviderFor(postDetail) +const postDetailProvider = PostDetailFamily(); + +/// See also [postDetail]. +class PostDetailFamily extends Family> { + /// See also [postDetail]. + const PostDetailFamily(); + + /// See also [postDetail]. + PostDetailProvider call({required int postId}) { + return PostDetailProvider(postId: postId); + } + + @override + PostDetailProvider getProviderOverride( + covariant PostDetailProvider provider, + ) { + return call(postId: provider.postId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'postDetailProvider'; +} + +/// See also [postDetail]. +class PostDetailProvider extends AutoDisposeFutureProvider { + /// See also [postDetail]. + PostDetailProvider({required int postId}) + : this._internal( + (ref) => postDetail(ref as PostDetailRef, postId: postId), + from: postDetailProvider, + name: r'postDetailProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$postDetailHash, + dependencies: PostDetailFamily._dependencies, + allTransitiveDependencies: PostDetailFamily._allTransitiveDependencies, + postId: postId, + ); + + PostDetailProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.postId, + }) : super.internal(); + + final int postId; + + @override + Override overrideWith( + FutureOr Function(PostDetailRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: PostDetailProvider._internal( + (ref) => create(ref as PostDetailRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + postId: postId, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _PostDetailProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is PostDetailProvider && other.postId == postId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, postId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin PostDetailRef on AutoDisposeFutureProviderRef { + /// The parameter `postId` of this provider. + int get postId; +} + +class _PostDetailProviderElement extends AutoDisposeFutureProviderElement + with PostDetailRef { + _PostDetailProviderElement(super.provider); + + @override + int get postId => (origin as PostDetailProvider).postId; +} + +String _$postCreateNotifierHash() => + r'2d9392f118e628a9ab3f16ecd2d524fbf17e99bd'; + +/// See also [PostCreateNotifier]. +@ProviderFor(PostCreateNotifier) +final postCreateNotifierProvider = + AutoDisposeNotifierProvider>.internal( + PostCreateNotifier.new, + name: r'postCreateNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$postCreateNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$PostCreateNotifier = AutoDisposeNotifier>; +String _$postUpdateNotifierHash() => + r'ffadccd22bb2faec7b7f4c8b11493fb4c8695351'; + +/// See also [PostUpdateNotifier]. +@ProviderFor(PostUpdateNotifier) +final postUpdateNotifierProvider = + AutoDisposeNotifierProvider>.internal( + PostUpdateNotifier.new, + name: r'postUpdateNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$postUpdateNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$PostUpdateNotifier = AutoDisposeNotifier>; +String _$postDeleteNotifierHash() => + r'07b47c44d9e04acef1f1ede70e94a8534f8a4285'; + +/// See also [PostDeleteNotifier]. +@ProviderFor(PostDeleteNotifier) +final postDeleteNotifierProvider = + AutoDisposeNotifierProvider>.internal( + PostDeleteNotifier.new, + name: r'postDeleteNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$postDeleteNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$PostDeleteNotifier = AutoDisposeNotifier>; +String _$postLikeNotifierHash() => r'babbf64cb6d1c62edec7a8ee5635fa76b29bf63c'; + +/// See also [PostLikeNotifier]. +@ProviderFor(PostLikeNotifier) +final postLikeNotifierProvider = + AutoDisposeNotifierProvider>.internal( + PostLikeNotifier.new, + name: r'postLikeNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$postLikeNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$PostLikeNotifier = AutoDisposeNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/feed/screens/challenge_search_screen.dart b/lib/features/feed/screens/challenge_search_screen.dart index 1c37ee1..8bfb095 100644 --- a/lib/features/feed/screens/challenge_search_screen.dart +++ b/lib/features/feed/screens/challenge_search_screen.dart @@ -3,11 +3,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:haenaem/core/theme/app_colors.dart'; import 'package:haenaem/core/theme/app_typography.dart'; -import 'package:haenaem/features/feed/screens/challenge_detail_screen.dart'; // 챌린지 소개 화면 뷰 재활용 import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; -import 'package:haenaem/features/challenge/model/challenge_model.dart'; -import 'package:haenaem/features/user/model/user_model.dart'; +import '../provider/challenge_search_provider.dart'; +import '../widgets/challenge_search_card.dart'; +import 'package:haenaem/shared/models/user.dart'; +import 'package:haenaem/features/user/provider/user_provider.dart'; +import 'package:haenaem/features/feed/provider/feed_provider.dart'; +import 'package:haenaem/shared/models/search_challenge_card.dart'; class ChallengeSearchScreen extends ConsumerStatefulWidget { const ChallengeSearchScreen({super.key}); @@ -23,7 +25,7 @@ class _ChallengeSearchScreenState extends ConsumerState { @override Widget build(BuildContext context) { // 사용자 프로필 정보 가져오기 (헤더 이름용) - final profileAsync = ref.watch(myProfileProvider); + final currentUser = ref.watch(currentUserProvider); // 검색어가 있을 때만 api 호출 final searchResults = ref.watch( @@ -58,95 +60,32 @@ class _ChallengeSearchScreenState extends ConsumerState { children: [ const Divider(height: 1, color: AppColors.gray4), // 검색창 영역 - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), - child: TextField( - controller: _searchController, - // 검색 액션 추가: 엔터를 누르면 상태 업데이트 - onSubmitted: (value) { - setState(() { - _currentKeyword = value; - }); - }, - decoration: InputDecoration( - hintText: '이름으로 탐색하기', - hintStyle: AppTypography.b2.copyWith(color: AppColors.gray3), - prefixIcon: Padding( - padding: const EdgeInsets.all(14.0), - child: SvgPicture.asset( - 'assets/images/icons/search_icon.svg', - ), - ), - // UX 개선: 검색어 삭제 버튼 추가 - suffixIcon: _searchController.text.isNotEmpty - ? IconButton( - icon: const Icon( - Icons.clear, - size: 20, - color: AppColors.gray3, - ), - onPressed: () { - _searchController.clear(); - setState(() => _currentKeyword = ""); - }, - ) - : null, - fillColor: Colors.white, - contentPadding: const EdgeInsets.symmetric(vertical: 10), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(9.5), - borderSide: const BorderSide(color: AppColors.gray4), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(9.5), - borderSide: BorderSide(color: Colors.grey.shade300), - ), - ), - ), - ), - // 챌린지 카드 리스트 + _buildSearchBar(), + + // 챌린지 카드 리스트 영역 Expanded( child: _currentKeyword.isEmpty - ? _buildRecommendationSection(profileAsync) // 검색어 없을 때 - : searchResults.when( - data: (list) => list.isEmpty - ? const Center(child: Text("검색 결과가 없습니다.")) - : ListView.builder( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - itemCount: list.length, - itemBuilder: (context, index) { - final SearchChallengeModel challengeItem = - list[index]; - return ChallengeCard(challenge: challengeItem); - }, - ), - loading: () => - const Center(child: CircularProgressIndicator()), - error: (err, stack) => - Center(child: Text("검색 중 오류 발생: $err")), - ), + ? _buildRecommendationSection( + currentUser, + ) // 💡 검색어 없을 때 추천 섹션 노출 + : _buildSearchResults(searchResults), // 검색어 있을 때 결과 노출 ), ], ), ); } - // 추천 섹션 (기존 코드의 그라데이션 박스 부분 분리) - Widget _buildRecommendationSection( - AsyncValue profileAsync, - ) { - final userName = profileAsync.when( - data: (user) => user.nickname, - loading: () => "해냄", - error: (_, __) => "해냄", - ); + // ── AI 추천 섹션 구현 ──────────────────────────── + Widget _buildRecommendationSection(User? currentUser) { + final userName = currentUser?.nickname ?? "해냄"; + + // 💡 AI 추천 데이터 구독 (feed_provider에 정의한 FutureProvider) + final aiAsync = ref.watch(aiRecommendationProvider); return SingleChildScrollView( child: Column( children: [ + // 1. 상단 그라데이션 배너 Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), child: Container( @@ -179,157 +118,129 @@ class _ChallengeSearchScreenState extends ConsumerState { ), ), const SizedBox(width: 8), - Text( - '$userName 님의 관심 태그 기반 추천', - style: AppTypography.h3.copyWith(color: Colors.white), + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, // 공간이 부족하면 비율을 맞춰 줄임 + alignment: Alignment.centerLeft, + child: Text( + '$userName 님의 관심 태그 기반 추천', + style: AppTypography.h3.copyWith(color: Colors.white), + ), + ), ), ], ), ), ), - const SizedBox(height: 100), // 적절한 여백 + + // 2. AI 추천 카드 리스트 + aiAsync.when( + data: (list) { + if (list.isEmpty) return const SizedBox.shrink(); + return ListView.builder( + shrinkWrap: true, // 💡 SingleChildScrollView 내 중첩을 위해 필수 + physics: const NeverScrollableScrollPhysics(), // 💡 부모 스크롤 사용 + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + itemCount: list.length, + itemBuilder: (context, index) { + return ChallengeSearchCard(challenge: list[index]); + }, + ); + }, + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 60), + child: CircularProgressIndicator(color: AppColors.primaryAble), + ), + error: (err, _) => const Padding( + padding: EdgeInsets.symmetric(vertical: 40), + child: Text("추천 정보를 불러올 수 없습니다."), + ), + ), + + const SizedBox(height: 20), + + // 3. 하단 안내 문구 const Center( child: Text( "원하시는 챌린지를 검색해보세요!", style: TextStyle(color: AppColors.gray2), ), ), + const SizedBox(height: 60), // 하단 여백 확보 ], ), ); } -} -class ChallengeCard extends StatelessWidget { - final SearchChallengeModel challenge; - const ChallengeCard({super.key, required this.challenge}); + // 검색창 위젯 분리 (가독성용) + Widget _buildSearchBar() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.only(bottom: 10), - padding: const EdgeInsets.fromLTRB(16, 10, 0, 10), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withAlpha(40), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: IntrinsicHeight( - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 챌린지 정보로 채우기 - Text( - challenge.title, - style: AppTypography.b3.copyWith(color: AppColors.black), - ), - const SizedBox(height: 10), // 이름과 정보 사이 간격 확대 - // 인원수 및 완료일 정보 - Row( - children: [ - // 인원수 - Row( - children: [ - SvgPicture.asset( - 'assets/images/icons/person_icon.svg', - width: 18, - height: 18, - colorFilter: const ColorFilter.mode( - AppColors.gray2, - BlendMode.srcIn, - ), - ), - const SizedBox(width: 2), - Text( - '${challenge.participantNumber}명', - style: AppTypography.b2.copyWith( - color: AppColors.gray2, - ), - ), - ], - ), - const SizedBox(width: 15), // 인원수와 완료일 사이 간격 - // 완료일 - Text( - '완료까지 D-000', - style: AppTypography.b2.copyWith( - color: AppColors.gray2, - ), - ), - ], - ), + child: TextField( + controller: _searchController, - const SizedBox(height: 10), + // 검색 액션 추가: 엔터를 누르면 상태 업데이트 + onSubmitted: (value) { + setState(() { + _currentKeyword = value; + }); + }, - // 챌린지 태그 정보 - Row( - children: challenge.tags.isEmpty - ? [const SizedBox.shrink()] - : challenge.tags - .take(2) // 최대 2개만 표시 - .map( - (tagObj) => Padding( - padding: const EdgeInsets.only(right: 10), - child: buildTag(tagObj.tag), - ), - ) - .toList(), + decoration: InputDecoration( + hintText: '이름으로 탐색하기', + hintStyle: AppTypography.b2.copyWith(color: AppColors.gray3), + prefixIcon: Padding( + padding: const EdgeInsets.all(14.0), + child: SvgPicture.asset('assets/images/icons/search_icon.svg'), + ), + // UX 개선: 검색어 삭제 버튼 추가 + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon( + Icons.clear, + size: 20, + color: AppColors.gray3, ), - ], - ), - ), + onPressed: () { + _searchController.clear(); + setState(() => _currentKeyword = ""); + }, + ) + : null, - // 오른쪽 화살표 아이콘 - Align( - alignment: Alignment.center, - child: IconButton( - onPressed: () { - debugPrint("====> 이동하려는 챌린지 ID: ${challenge.challengeId}"); + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric(vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(9.5), + borderSide: const BorderSide(color: AppColors.gray4), + ), - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ChallengeDetailScreen( - challengeId: challenge.challengeId, - ), - ), - ); - }, - icon: SvgPicture.asset( - 'assets/images/icons/thick_right_arrow_icon.svg', - colorFilter: const ColorFilter.mode( - AppColors.gray2, - BlendMode.srcIn, - ), - ), - ), - ), - ], + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(9.5), + borderSide: BorderSide(color: Colors.grey.shade300), + ), ), ), ); } - Widget buildTag(String text) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), - decoration: BoxDecoration( - color: AppColors.selected, - borderRadius: BorderRadius.circular(15), - ), - child: Text( - text, - style: AppTypography.b2.copyWith(color: AppColors.primaryAble), - ), + // 검색 결과 리스트 위젯 분리 + Widget _buildSearchResults(AsyncValue> results) { + return results.when( + data: (list) => list.isEmpty + ? const Center(child: Text("검색 결과가 없습니다.")) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: list.length, + itemBuilder: (context, index) => + ChallengeSearchCard(challenge: list[index]), + ), + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, _) => Center(child: Text("검색 중 오류 발생: $err")), ); } } diff --git a/lib/features/feed/screens/feed_screen.dart b/lib/features/feed/screens/feed_screen.dart index a01bb70..5e10248 100644 --- a/lib/features/feed/screens/feed_screen.dart +++ b/lib/features/feed/screens/feed_screen.dart @@ -5,7 +5,7 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:haenaem/core/theme/app_colors.dart'; import 'package:haenaem/core/theme/app_typography.dart'; import 'package:haenaem/features/feed/screens/challenge_search_screen.dart'; -import 'package:haenaem/features/feed/provider/feed_provider.dart'; +import '../provider/feed_provider.dart'; import 'package:haenaem/features/feed/views/share_feed_view.dart'; class FeedScreen extends StatefulWidget { diff --git a/lib/features/feed/screens/post_detail_screen.dart b/lib/features/feed/screens/post_detail_screen.dart index d95be25..c49d268 100644 --- a/lib/features/feed/screens/post_detail_screen.dart +++ b/lib/features/feed/screens/post_detail_screen.dart @@ -1,19 +1,22 @@ // 최초 작성자 : 강선욱 import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; +//import 'package:intl/intl.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; +// 💡 기존 challenge_provider 대신 분리된 프로바이더들을 임포트합니다. +import 'package:haenaem/features/feed/provider/post_detail_provider.dart'; +import 'package:haenaem/features/feed/provider/comment_provider.dart'; import 'package:haenaem/core/theme/app_colors.dart'; import 'package:haenaem/core/theme/app_typography.dart'; -import 'package:haenaem/features/challenge/model/challenge_model.dart'; -import 'package:haenaem/features/challenge/widgets/comment_popup_menu.dart'; +import 'package:haenaem/features/feed/widgets/comment_item.dart'; import 'package:haenaem/features/feed/widgets/feed_post_card.dart'; // FeedPostCard 임포트 +import 'package:haenaem/features/feed/widgets/comment_input_field.dart'; +import 'package:haenaem/shared/models/post.dart'; -class PostDetailScreen extends ConsumerStatefulWidget { +class PostDetailScreen extends ConsumerWidget { final int postId; - final CertificationPostModel? post; + final Post? post; final dynamic feedProvider; const PostDetailScreen({ @@ -24,36 +27,10 @@ class PostDetailScreen extends ConsumerStatefulWidget { }); @override - ConsumerState createState() => _PostDetailScreenState(); -} - -class _PostDetailScreenState extends ConsumerState { - final TextEditingController _commentController = TextEditingController(); - bool _isButtonActive = false; - - @override - void initState() { - super.initState(); - _commentController.addListener(() { - setState(() { - _isButtonActive = _commentController.text.trim().isNotEmpty; - }); - }); - } - - @override - void dispose() { - _commentController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - // 상세 정보 및 댓글 데이터 구독 - final detailAsync = ref.watch(articleDetailProvider(postId: widget.postId)); - final commentsAsync = ref.watch( - articleCommentsProvider(postId: widget.postId), - ); + Widget build(BuildContext context, WidgetRef ref) { + // 상세 정보 및 댓글 구독 + final detailAsync = ref.watch(postDetailProvider(postId: postId)); + final commentsAsync = ref.watch(postCommentsProvider(postId: postId)); return Scaffold( backgroundColor: Colors.white, @@ -90,7 +67,7 @@ class _PostDetailScreenState extends ConsumerState { // --- 리팩토링 포인트: FeedPostCard 재사용 --- FeedPostCard( post: latestPost, - provider: widget.feedProvider, + provider: feedProvider, onTap: () {}, // 상세 페이지 내에서는 클릭 시 아무 동작 안 함 ), @@ -113,7 +90,12 @@ class _PostDetailScreenState extends ConsumerState { physics: const NeverScrollableScrollPhysics(), itemCount: comments.length, itemBuilder: (context, index) => - _buildCommentItem(comments[index]), + // 💡 분리한 위젯을 여기서 간편하게 호출합니다. + CommentItem( + comment: comments[index], + postId: postId, + feedProvider: feedProvider, + ), ); }, ), @@ -122,8 +104,8 @@ class _PostDetailScreenState extends ConsumerState { ), ), ), - // 키보드에 가려지지 않도록 처리된 댓글 입력창 - _buildCommentInputField(), + // 💡 분리된 입력창 위젯 호출! + CommentInputField(postId: postId, feedProvider: feedProvider), ], ); }, @@ -131,168 +113,6 @@ class _PostDetailScreenState extends ConsumerState { ); } - // 댓글 입력창 (기존 로직 유지) - Widget _buildCommentInputField() { - final double systemBottomPadding = - MediaQuery.of(context).viewInsets.bottom > 0 - ? 10 - : MediaQuery.of(context).padding.bottom + 10; - - return Container( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 10, - bottom: systemBottomPadding, - ), - decoration: BoxDecoration( - color: Colors.white, - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.05), - offset: const Offset(0, -2), - blurRadius: 4, - ), - ], - border: Border(top: BorderSide(color: Colors.grey.shade200)), - ), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _commentController, - decoration: InputDecoration( - hintText: '댓글을 입력하세요...', - hintStyle: AppTypography.b2.copyWith(color: AppColors.gray3), - filled: true, - fillColor: AppColors.gray5, // 연한 회색 배경 - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 10, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(20), - borderSide: BorderSide.none, - ), - ), - ), - ), - const SizedBox(width: 10), - GestureDetector( - onTap: _isButtonActive - ? () async { - // 💡 댓글 작성 API 호출 - final contents = _commentController.text.trim(); - final success = await ref - .read(articleCommentCreateNotifierProvider.notifier) - .addComment(postId: widget.postId, contents: contents); - - if (success && mounted) { - // 피드 화면에서 댓글 수 업데이트를 위해 필요한 코드 - if (widget.feedProvider != null) { - // 목록의 댓글 수를 로컬에서 +1 시켜서 UI를 즉시 갱신 - ref - .read(widget.feedProvider.notifier) - .incrementCommentCountLocally(widget.postId); - } - - // 성공 시 입력창 초기화 및 키보드 내리기 - _commentController.clear(); - FocusScope.of(context).unfocus(); - - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('댓글이 작성되었습니다.')), - ); - } - } - : null, - child: CircleAvatar( - radius: 22, - backgroundColor: _isButtonActive - ? AppColors.primaryAble - : AppColors.disable, - child: SvgPicture.asset( - 'assets/images/icons/comment_upload_icon.svg', - ), - ), - ), - ], - ), - ); - } - - Future _handleCommentSubmit() async { - final contents = _commentController.text.trim(); - final success = await ref - .read(articleCommentCreateNotifierProvider.notifier) - .addComment(postId: widget.postId, contents: contents); - - if (success && mounted) { - if (widget.feedProvider != null) { - ref - .read(widget.feedProvider.notifier) - .incrementCommentCountLocally(widget.postId); - } - _commentController.clear(); - FocusScope.of(context).unfocus(); - } - } - - Widget _buildCommentItem(ChallengeComment comment) { - final DateTime? displayDate = comment.updatedAt ?? comment.createdAt; - String commentDate = ""; - if (displayDate != null) { - commentDate = DateFormat('yyyy.MM.dd HH:mm').format(displayDate); - } - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CircleAvatar( - radius: 18, - backgroundImage: - (comment.userPicture != null && comment.userPicture!.isNotEmpty) - ? NetworkImage(comment.userPicture!) - : null, - child: (comment.userPicture == null || comment.userPicture!.isEmpty) - ? SvgPicture.asset( - 'assets/images/icons/default_profile_icon.svg', - ) - : null, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(comment.userNickname, style: AppTypography.b1), - CommentPopupMenu( - postId: widget.postId, - comment: comment, - feedProvider: widget.feedProvider, - ), - ], - ), - const SizedBox(height: 2), - Text(comment.contents, style: AppTypography.b2), - const SizedBox(height: 4), - Text( - commentDate, - style: AppTypography.c1.copyWith(color: AppColors.gray2), - ), - ], - ), - ), - ], - ), - ); - } - Widget _buildEmptyComments() { return const Padding( padding: EdgeInsets.symmetric(vertical: 60), diff --git a/lib/features/feed/views/share_feed_view.dart b/lib/features/feed/views/share_feed_view.dart index 0832324..1ffb1fc 100644 --- a/lib/features/feed/views/share_feed_view.dart +++ b/lib/features/feed/views/share_feed_view.dart @@ -5,13 +5,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:haenaem/core/theme/app_colors.dart'; import 'package:haenaem/core/theme/app_typography.dart'; -import 'package:haenaem/features/feed/provider/feed_provider.dart'; +import '../provider/feed_provider.dart'; import 'package:haenaem/features/feed/widgets/feed_post_card.dart'; -import 'package:haenaem/features/feed/model/feed_model.dart'; +// import 'package:haenaem/features/feed/models/feed_model.dart'; class ShareFeedView extends ConsumerStatefulWidget { final ScrollController scrollController; - final StateNotifierProvider provider; + final FeedNotifierProvider provider; final String emptyMessage; const ShareFeedView({ @@ -108,7 +108,7 @@ class _ShareFeedViewState extends ConsumerState return FeedPostCard( key: ValueKey( - '${feedState.posts[index].postId}_${feedState.posts[index].liked}_${feedState.posts[index].likeNumber}_${feedState.posts[index].commentNumber}', + '${feedState.posts[index].id}_${feedState.posts[index].isLiked}_${feedState.posts[index].likeCount}_${feedState.posts[index].commentCount}', ), post: feedState.posts[index], provider: widget.provider, diff --git a/lib/features/feed/widgets/challenge_search_card.dart b/lib/features/feed/widgets/challenge_search_card.dart new file mode 100644 index 0000000..729fb66 --- /dev/null +++ b/lib/features/feed/widgets/challenge_search_card.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:haenaem/core/theme/app_colors.dart'; +import 'package:haenaem/core/theme/app_typography.dart'; +import 'package:haenaem/shared/screens/challenge_detail_screen.dart'; +import 'package:haenaem/shared/models/search_challenge_card.dart'; // 모델 임포트 + +class ChallengeSearchCard extends StatelessWidget { + final SearchChallengeCard challenge; + + const ChallengeSearchCard({super.key, required this.challenge}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.fromLTRB(16, 10, 0, 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(40), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: IntrinsicHeight( + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 챌린지 제목 + Text( + challenge.base.title, + style: AppTypography.b3.copyWith(color: AppColors.black), + ), + const SizedBox(height: 10), + + // 인원수 및 완료일 정보 + Row( + children: [ + _buildInfoItem( + iconPath: 'assets/images/icons/person_icon.svg', + text: '${challenge.participantCount}명', + ), + const SizedBox(width: 15), + // 하드코딩된 D-000을 모델의 dDay 데이터로 변경 + Text( + '완료까지 D-${challenge.dDay}', + style: AppTypography.b2.copyWith( + color: AppColors.gray2, + ), + ), + ], + ), + + const SizedBox(height: 10), + + // 챌린지 태그 정보 (List 처리) + Row( + children: challenge.tags.isEmpty + ? [const SizedBox.shrink()] + : challenge.tags + .take(2) + .map( + (tag) => Padding( + padding: const EdgeInsets.only(right: 10), + child: _buildTag(tag), + ), + ) + .toList(), + ), + ], + ), + ), + + // 오른쪽 이동 화살표 아이콘 + IconButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChallengeDetailScreen( + challengeId: challenge.base.id, + challengeTitle: challenge.base.title, + ), + ), + ); + }, + icon: SvgPicture.asset( + 'assets/images/icons/thick_right_arrow_icon.svg', + colorFilter: const ColorFilter.mode( + AppColors.gray2, + BlendMode.srcIn, + ), + ), + ), + ], + ), + ), + ); + } + + // 내부 태그 위젯 + Widget _buildTag(String text) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + decoration: BoxDecoration( + color: AppColors.selected, + borderRadius: BorderRadius.circular(15), + ), + child: Text( + text.startsWith('#') ? text : text, + style: AppTypography.b2.copyWith(color: AppColors.primaryAble), + ), + ); + } + + // 정보 아이템(인원수 등) 위젯 + Widget _buildInfoItem({required String iconPath, required String text}) { + return Row( + children: [ + SvgPicture.asset( + iconPath, + width: 18, + height: 18, + colorFilter: const ColorFilter.mode(AppColors.gray2, BlendMode.srcIn), + ), + const SizedBox(width: 2), + Text(text, style: AppTypography.b2.copyWith(color: AppColors.gray2)), + ], + ); + } +} diff --git a/lib/features/feed/widgets/comment_input_field.dart b/lib/features/feed/widgets/comment_input_field.dart new file mode 100644 index 0000000..b2b23c0 --- /dev/null +++ b/lib/features/feed/widgets/comment_input_field.dart @@ -0,0 +1,128 @@ +// 최초 작성자 : 정승빈 (분리 및 리팩토링) +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:haenaem/core/theme/app_colors.dart'; +import 'package:haenaem/core/theme/app_typography.dart'; +import 'package:haenaem/features/feed/provider/comment_provider.dart'; + +class CommentInputField extends ConsumerStatefulWidget { + final int postId; + final dynamic feedProvider; + + const CommentInputField({super.key, required this.postId, this.feedProvider}); + + @override + ConsumerState createState() => _CommentInputFieldState(); +} + +class _CommentInputFieldState extends ConsumerState { + final TextEditingController _commentController = TextEditingController(); + bool _isButtonActive = false; + + @override + void initState() { + super.initState(); + _commentController.addListener(() { + setState(() { + _isButtonActive = _commentController.text.trim().isNotEmpty; + }); + }); + } + + @override + void dispose() { + _commentController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // 키보드가 올라왔을 때 하단 여백 자동 조절 + final double systemBottomPadding = + MediaQuery.of(context).viewInsets.bottom > 0 + ? 10 + : MediaQuery.of(context).padding.bottom + 10; + + return Container( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 10, + bottom: systemBottomPadding, + ), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + offset: const Offset(0, -2), + blurRadius: 4, + ), + ], + border: Border(top: BorderSide(color: Colors.grey.shade200)), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _commentController, + decoration: InputDecoration( + hintText: '댓글을 입력하세요...', + hintStyle: AppTypography.b2.copyWith(color: AppColors.gray3), + filled: true, + fillColor: AppColors.gray5, + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none, + ), + ), + ), + ), + const SizedBox(width: 10), + GestureDetector( + onTap: _isButtonActive + ? () async { + final contents = _commentController.text.trim(); + // 💡 API 호출 + final success = await ref + .read(commentCreateNotifierProvider.notifier) + .addComment(postId: widget.postId, contents: contents); + + if (success && mounted) { + // 리스트 업데이트 + if (widget.feedProvider != null) { + // 피드 화면에서 댓글 수 업데이트를 위해 필요한 코드 + ref + .read(widget.feedProvider.notifier) + .incrementCommentCountLocally(widget.postId); + } + // 입력창 초기화 + _commentController.clear(); + FocusScope.of(context).unfocus(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('댓글이 작성되었습니다.')), + ); + } + } + : null, + child: CircleAvatar( + radius: 22, + backgroundColor: _isButtonActive + ? AppColors.primaryAble + : AppColors.disable, + child: SvgPicture.asset( + 'assets/images/icons/comment_upload_icon.svg', + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/feed/widgets/comment_item.dart b/lib/features/feed/widgets/comment_item.dart new file mode 100644 index 0000000..8f34e7b --- /dev/null +++ b/lib/features/feed/widgets/comment_item.dart @@ -0,0 +1,80 @@ +// 최초 작성자 : 정승빈 (분리 및 리팩토링) +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import 'package:haenaem/core/theme/app_colors.dart'; +import 'package:haenaem/core/theme/app_typography.dart'; +import 'package:haenaem/features/challenge/widgets/comment_popup_menu.dart'; +import 'package:haenaem/features/feed/models/comment.dart'; + +class CommentItem extends StatelessWidget { + final Comment comment; + final int postId; + final dynamic feedProvider; + + const CommentItem({ + super.key, + required this.comment, + required this.postId, + this.feedProvider, + }); + + @override + Widget build(BuildContext context) { + String commentDate = DateFormat('yyyy.MM.dd HH:mm').format(comment.date); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 프로필 이미지 + CircleAvatar( + radius: 18, + // 타입 안정성을 위해 as ImageProvider 추가 + backgroundImage: + (comment.writer.profileUrl != null && + comment.writer.profileUrl!.isNotEmpty) + ? NetworkImage(comment.writer.profileUrl!) as ImageProvider + : null, + child: + (comment.writer.profileUrl == null || + comment.writer.profileUrl!.isEmpty) + ? SvgPicture.asset( + 'assets/images/icons/default_profile_icon.svg', + ) + : null, + ), + const SizedBox(width: 12), + // 댓글 내용 및 팝업 메뉴 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(comment.writer.nickname, style: AppTypography.b1), + CommentPopupMenu( + postId: postId, + comment: comment, + feedProvider: feedProvider, + ), + ], + ), + const SizedBox(height: 2), + Text(comment.content, style: AppTypography.b2), + const SizedBox(height: 4), + Text( + commentDate, + style: AppTypography.c1.copyWith(color: AppColors.gray2), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/feed/widgets/enter_confirm_dialog.dart b/lib/features/feed/widgets/enter_confirm_dialog.dart index b525404..6bfd9fa 100644 --- a/lib/features/feed/widgets/enter_confirm_dialog.dart +++ b/lib/features/feed/widgets/enter_confirm_dialog.dart @@ -4,7 +4,8 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:haenaem/core/theme/app_colors.dart'; import 'package:haenaem/core/theme/app_typography.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; +// import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; +import '../provider/challenge_participate_provider.dart'; import 'package:haenaem/features/challenge/detail/screens/challenge_main_screen.dart'; class EnterConfirmDialog extends ConsumerWidget { diff --git a/lib/features/feed/widgets/feed_post_card.dart b/lib/features/feed/widgets/feed_post_card.dart index 4a31ac4..5b538ae 100644 --- a/lib/features/feed/widgets/feed_post_card.dart +++ b/lib/features/feed/widgets/feed_post_card.dart @@ -7,12 +7,13 @@ import 'package:haenaem/features/feed/screens/post_detail_screen.dart'; // 인 import 'package:intl/intl.dart'; import 'package:haenaem/core/theme/app_colors.dart'; import 'package:haenaem/core/theme/app_typography.dart'; -import 'package:haenaem/features/challenge/model/challenge_model.dart'; -import 'package:haenaem/features/challenge/widgets/ChallengeFeedPopupMenu.dart'; -import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; +//import 'package:haenaem/features/challenge/models/challenge_model.dart'; +import '../provider/post_detail_provider.dart'; +import 'package:haenaem/shared/models/post.dart'; +import 'package:haenaem/features/feed/widgets/post_popup_menu.dart'; class FeedPostCard extends ConsumerWidget { - final CertificationPostModel post; + final Post post; final VoidCallback? onTap; final dynamic provider; // 어떤 Provider(친구/둘러보기)인지 받음 @@ -25,11 +26,15 @@ class FeedPostCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + /* final displayDate = post.updatedAt ?? post.createdAt; String formattedDate = displayDate != null ? DateFormat('yyyy년 MM월 dd일 HH:mm').format(displayDate) : ""; + */ + // 💡 날짜 처리: post.date 하나로 통합 + String formattedDate = DateFormat('yyyy년 MM월 dd일 HH:mm').format(post.date); return InkWell( onTap: @@ -40,7 +45,7 @@ class FeedPostCard extends ConsumerWidget { context, MaterialPageRoute( builder: (context) => PostDetailScreen( - postId: post.postId, + postId: post.id, post: post, feedProvider: provider, // 기존에 넘겨주던 프로바이더 ), @@ -52,18 +57,19 @@ class FeedPostCard extends ConsumerWidget { children: [ // 1. 헤더 Padding( - padding: const EdgeInsets.fromLTRB(15, 12, 5, 10), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( children: [ CircleAvatar( - radius: 18, + radius: 20, // 반지름 backgroundImage: - (post.userImageUrl != null && - post.userImageUrl!.isNotEmpty) - ? NetworkImage(post.userImageUrl!) as ImageProvider + (post.writer.profileUrl != null && + post.writer.profileUrl!.isNotEmpty) + ? NetworkImage(post.writer.profileUrl!) as ImageProvider : null, child: - (post.userImageUrl == null || post.userImageUrl!.isEmpty) + (post.writer.profileUrl == null || + post.writer.profileUrl!.isEmpty) ? SvgPicture.asset( 'assets/images/icons/default_profile_icon.svg', ) @@ -74,22 +80,25 @@ class FeedPostCard extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(post.userName ?? '해냄', style: AppTypography.b1), + Text(post.writer.nickname, style: AppTypography.b1), ], ), ), - ChallengeFeedPopupMenu(post: post), + PostPopupMenu(post: post), ], ), ), // 2. 텍스트 본문 Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '${post.challengeTitle} ${post.totalSuccessDays}일차', + post.title, style: AppTypography.b3.copyWith(color: AppColors.black), ), const SizedBox(height: 4), // 간격 추가 @@ -106,7 +115,7 @@ class FeedPostCard extends ConsumerWidget { ), ), // 3. 이미지 - if (post.hasImage && post.images.isNotEmpty) + if (post.hasImage && post.pictureUrl.isNotEmpty) _PostImageSlider(post: post), // 4. 하단 아이콘 정보 Padding( @@ -117,29 +126,29 @@ class FeedPostCard extends ConsumerWidget { onTap: () async { // [로컬 업데이트] 서버 응답 기다리지 않고 즉시 UI 변경 if (provider != null) { - ref - .read(provider.notifier) - .toggleLikeLocally(post.postId); + ref.read(provider.notifier).toggleLikeLocally(post.id); } // [서버 통신] 백그라운드에서 조용히 처리 ref - .read(articleLikeNotifierProvider.notifier) + .read(postLikeNotifierProvider.notifier) .toggleLike( - postId: post.postId, - isCurrentlyLiked: post.liked, + postId: post.id, + isCurrentlyLiked: post.isLiked, ); }, child: Row( children: [ SvgPicture.asset( - post.liked + post.isLiked ? 'assets/images/icons/like_filled_icon.svg' : 'assets/images/icons/like_icon.svg', width: 20, height: 20, colorFilter: ColorFilter.mode( - post.liked ? AppColors.notification : AppColors.gray2, + post.isLiked + ? AppColors.notification + : AppColors.gray2, BlendMode.srcIn, ), ), @@ -147,7 +156,7 @@ class FeedPostCard extends ConsumerWidget { Text( post.likeCount.toString(), style: AppTypography.b2.copyWith( - color: post.liked + color: post.isLiked ? AppColors.notification : AppColors.gray2, ), @@ -158,7 +167,7 @@ class FeedPostCard extends ConsumerWidget { const SizedBox(width: 16), _buildIconInfo( 'assets/images/icons/comment_icon.svg', - post.commentNumber.toString(), + post.commentCount.toString(), ), const Spacer(), Text( @@ -195,7 +204,7 @@ class FeedPostCard extends ConsumerWidget { // ✅ 이미지 슬라이더와 인디케이터 상태를 관리하기 위한 내부 위젯 class _PostImageSlider extends StatefulWidget { - final CertificationPostModel post; + final Post post; const _PostImageSlider({required this.post}); @@ -214,14 +223,14 @@ class _PostImageSliderState extends State<_PostImageSlider> { height: 375, // 기존 FeedPostCard 이미지 높이와 통일 width: double.infinity, child: PageView.builder( - itemCount: widget.post.images.length, + itemCount: widget.post.pictureUrl.length, onPageChanged: (index) { setState(() { _currentImagePage = index; }); }, itemBuilder: (context, index) { - final String path = widget.post.images[index].imageUrl; + final String path = widget.post.pictureUrl[index].imageUrl; // 💡 경로 처리: http로 시작하지 않으면 서버 주소 붙여주기 final String fullUrl = path.startsWith('http') ? path @@ -251,12 +260,12 @@ class _PostImageSliderState extends State<_PostImageSlider> { ), ), // 💡 이미지가 2장 이상일 때만 하단에 페이지 점(Indicator) 표시 - if (widget.post.images.length > 1) + if (widget.post.pictureUrl.length > 1) Padding( padding: const EdgeInsets.only(top: 10, bottom: 5), child: Row( mainAxisAlignment: MainAxisAlignment.center, - children: List.generate(widget.post.images.length, (index) { + children: List.generate(widget.post.pictureUrl.length, (index) { return Container( width: 6, height: 6, diff --git a/lib/features/challenge/widgets/ChallengeFeedPopupMenu.dart b/lib/features/feed/widgets/post_popup_menu.dart similarity index 71% rename from lib/features/challenge/widgets/ChallengeFeedPopupMenu.dart rename to lib/features/feed/widgets/post_popup_menu.dart index 9af2a05..9188bb6 100644 --- a/lib/features/challenge/widgets/ChallengeFeedPopupMenu.dart +++ b/lib/features/feed/widgets/post_popup_menu.dart @@ -1,4 +1,5 @@ // 최초 작성자 : 강선욱 +import 'package:haenaem/features/user/data/user_repository.dart'; import 'package:intl/intl.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -6,32 +7,50 @@ import 'package:haenaem/core/theme/app_colors.dart'; import 'package:haenaem/core/theme/app_typography.dart'; import 'package:haenaem/features/challenge/widgets/DeleteConfirmDialog.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:haenaem/features/challenge/model/challenge_model.dart'; -import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; -import 'edit_article_dialog.dart'; +import 'package:haenaem/shared/models/post.dart'; +//import 'package:haenaem/features/challenge/models/challenge_model.dart'; +// 리스트 갱신용으로 유지 +// import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; +//import '../../challenge/widgets/edit_article_dialog.dart'; +import '../provider/post_detail_provider.dart'; import 'package:haenaem/features/challenge/verification/screens/challenge_verification_screen.dart'; +import 'package:haenaem/features/report/screens/report_screen.dart'; +import 'package:haenaem/features/report/provider/report_provider.dart'; +import 'package:haenaem/features/user/provider/user_provider.dart'; +import 'package:haenaem/shared/screens/challenge_detail_screen.dart'; // 인증글 다이얼로그 (내 인증글일 경우와 타인의 인증글일 경우) -class ChallengeFeedPopupMenu extends ConsumerWidget { - final CertificationPostModel post; // 인증글 데이터 - const ChallengeFeedPopupMenu({super.key, required this.post}); +class PostPopupMenu extends ConsumerWidget { + final Post post; // 인증글 데이터 + const PostPopupMenu({super.key, required this.post}); @override Widget build(BuildContext context, WidgetRef ref) { - // 1. 현재 로그인한 내 프로필 정보를 가져옵니다. - final myProfileAsync = ref.watch(myProfileProvider); + // 💡 Post 객체 내부 데이터 상태 확인용 로그 + debugPrint('--- [Post Data Check] ---'); + debugPrint('Post ID: ${post.id}'); + debugPrint('Challenge ID: ${post.challengeId}'); // 0이 나온다면 매핑 오류 확률 99% + debugPrint('Challenge Title: ${post.challengeTitle}'); + debugPrint('Writer ID: ${post.writer.id}'); + debugPrint('Post Date: ${post.date}'); + debugPrint('--------------------------'); + + // 1. user 전역값 가져오기 + final currentUser = ref.watch(currentUserProvider); // 2. 내 닉네임과 게시글 작성자 닉네임을 비교하여 '내 글' 여부 판단 - final bool isMine = post.author; + final bool isMine = (post.writer.id == currentUser?.id); // 2. [날짜 체크] 오늘 날짜 문자열(yyyy-MM-dd) 생성 final String todayStr = DateFormat('yyyy-MM-dd').format(DateTime.now()); // 3. 게시글의 postDate와 오늘 날짜 비교 - final bool isToday = post.postDate.trim().startsWith(todayStr); + // 💡 날짜 타입이 DateTime으로 바뀌었으므로 String으로 포맷 변환 후 비교 + final String postDateStr = DateFormat('yyyy-MM-dd').format(post.date); + final bool isToday = postDateStr.startsWith(todayStr); // 💡 디버깅을 위해 로그를 한 번 찍어보세요. (문제 확인용) - debugPrint('🔍 비교 날짜 - 서버 데이터: "${post.postDate}", 오늘 날짜: "$todayStr"'); + debugPrint('🔍 비교 날짜 - 서버 데이터: "$postDateStr", 오늘 날짜: "$todayStr"'); debugPrint('🔍 판정 결과 - isMine: $isMine, isToday: $isToday'); return PopupMenuButton( @@ -181,19 +200,14 @@ class ChallengeFeedPopupMenu extends ConsumerWidget { try { // 💡 삭제 시도 final success = await ref - .read(articleDeleteNotifierProvider.notifier) - .removeArticle(post.postId); + .read(postDeleteNotifierProvider.notifier) + .removeArticle(post.id, post.challengeId); if (success && context.mounted) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text("인증글이 삭제되었습니다."))); - // 갱신 로직 - ref.invalidate(challengePostsProvider); - ref.invalidate(challengeCalendarPhotosProvider); - ref.invalidate(challengeCalendarDataProvider); - // 만약 상세페이지라면 뒤로가기 Navigator.pop(context); } @@ -215,14 +229,32 @@ class ChallengeFeedPopupMenu extends ConsumerWidget { break; case 'view_challenge': - // TODO: 챌린지 상세(소개) 페이지로 이동하거나 탭을 전환하는 로직 - debugPrint('🚀 [Action] 챌린지 보기 클릭'); - // TODO: Navigator.push(...) 혹은 현재 탭 전환 로직 추가 + debugPrint('보내는 ID: ${post.challengeId}'); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChallengeDetailScreen( + // post 객체 내에 저장된 challenge 관련 정보를 전달합니다. + challengeId: post.challengeId, + challengeTitle: post.challengeTitle, + ), + ), + ); + + debugPrint( + '🚀 [Action] ChallengeDetailScreen으로 이동 (ID: ${post.challengeId})', + ); break; - case 'complain': - ScaffoldMessenger.of( + case 'report': + Navigator.push( context, - ).showSnackBar(const SnackBar(content: Text("신고가 접수되었습니다."))); + MaterialPageRoute( + builder: (context) => ReportScreen( + targetType: ReportTargetType.article, // 인증글 타입 + targetId: post.id, // 인증글 ID + ), + ), + ); break; } } diff --git a/lib/features/home/data/home_repository.dart b/lib/features/home/data/home_repository.dart new file mode 100644 index 0000000..2e89da1 --- /dev/null +++ b/lib/features/home/data/home_repository.dart @@ -0,0 +1,75 @@ +import 'package:flutter/foundation.dart'; +import 'package:dio/dio.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:haenaem/core/network/dio_provider.dart'; +import 'package:haenaem/features/home/models/home_response.dart'; + +part 'home_repository.g.dart'; + +class HomeRepository { + final Dio _dio; + + HomeRepository(this._dio); + + // Future getHomeData(String date) async { + // try { + // final response = await _dio.get( + // '/api/mainHome', + // queryParameters: {'date': date}, + // ); + + // if (response.statusCode == 200) { + // debugPrint('📥 [HomeRepository] 서버 응답 원본: ${response.data}'); + + // // HomeResponse.fromJson 안에서 myChallenges, notificationNumber 분리 + // return HomeResponse.fromJson(response.data); + // } else { + // throw Exception( + // '홈 데이터를 불러오는데 실패했습니다. (Status: ${response.statusCode})', + // ); + // } + // } on DioException catch (e) { + // throw Exception('서버 연결 실패: ${e.response?.statusMessage}'); + // } + // } + Future getHomeData(String date) async { + // 💡 [디버깅용] 호출 직전 헤더 상태를 확인합니다. + debugPrint( + '🚀 [Auth Check] Header: ${_dio.options.headers['Authorization']}', + ); + debugPrint( + '🚀 [ngrok Check] Header: ${_dio.options.headers['ngrok-skip-browser-warning']}', + ); + try { + final response = await _dio.get( + '/api/mainHome', + queryParameters: {'date': date}, + ); + + if (response.statusCode == 200) { + final data = response.data; + + // ✅ 인증 후 각 챌린지의 doIt, currentStreak 값 확인 + debugPrint('📥 [HomeRepository] 원본 응답: $data'); + final challenges = data['myChallenges'] as List? ?? []; + for (final c in challenges) { + debugPrint( + '🔥 challengeId=${c['challengeId']} | doIt=${c['doIt']} | currentStreak=${c['currentStreak']} | warning=${c['warning']}', + ); + } + + return HomeResponse.fromJson(data); + } else { + throw Exception('홈 데이터 로드 실패 (Status: ${response.statusCode})'); + } + } on DioException catch (e) { + throw Exception('서버 연결 실패: ${e.response?.statusMessage}'); + } + } +} + +@riverpod +HomeRepository homeRepository(HomeRepositoryRef ref) { + final dio = ref.watch(dioProvider); + return HomeRepository(dio); +} diff --git a/lib/features/home/data/home_repository.g.dart b/lib/features/home/data/home_repository.g.dart new file mode 100644 index 0000000..2353bde --- /dev/null +++ b/lib/features/home/data/home_repository.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'home_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$homeRepositoryHash() => r'096aef80ffcb164251e7870b577c8951cdba9c43'; + +/// See also [homeRepository]. +@ProviderFor(homeRepository) +final homeRepositoryProvider = AutoDisposeProvider.internal( + homeRepository, + name: r'homeRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$homeRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef HomeRepositoryRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/home/home_screen.dart b/lib/features/home/home_screen.dart deleted file mode 100644 index d154597..0000000 --- a/lib/features/home/home_screen.dart +++ /dev/null @@ -1,478 +0,0 @@ -// // 최초 작성자 : 강선욱 -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; // 추가 -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:haenaem/core/theme/app_colors.dart'; -import 'package:haenaem/core/theme/app_typography.dart'; -import 'package:haenaem/features/challenge/create/screens/challenge_create_screen.dart'; -import 'package:haenaem/features/challenge/model/challenge_model.dart'; -import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; -import 'package:haenaem/features/challenge/detail/screens/challenge_main_screen.dart'; -import 'package:haenaem/features/notification/screens/notification_main_screen.dart'; -import 'package:haenaem/features/notification/provider/notification_provider.dart'; - -class HomeScreen extends ConsumerWidget { - const HomeScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final homeDataAsync = ref.watch(challengeHomeNotifierProvider); - final todayStatus = ref.watch(todayTotalStatusProvider); - - DateTime now = DateTime.now(); - DateTime firstDayOfWeek = now.subtract(Duration(days: now.weekday % 7)); - List weekDays = List.generate( - 7, - (index) => firstDayOfWeek.add(Duration(days: index)), - ); - - return Scaffold( - backgroundColor: Colors.white, - body: SafeArea( - child: homeDataAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (err, stack) => Center(child: Text('데이터 에러: $err')), - data: (data) => Stack( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // 상단 바 - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 12, - ), - child: Row( - children: [ - Text(getFormattedDate(), style: AppTypography.h2), - const Spacer(), - // 알림 배지 (data.notificationNumber 사용) - _buildNotificationIcon( - context, - ref, - data.notificationNumber, - ), - ], - ), - ), - // 주간 캘린더 - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 4, - ), - child: Row( - children: weekDays.map((date) { - final isToday = - date.year == now.year && - date.month == now.month && - date.day == now.day; - return _DayChip( - date: date, - isSelected: isToday, - status: isToday - ? todayStatus - : ChallengeStatus.normal, - ); - }).toList(), - ), - ), - const SizedBox(height: 24), - // 챌린지 리스트 (data.myChallenges 전달) - Expanded( - child: RefreshIndicator( - onRefresh: () => ref - .read(challengeHomeNotifierProvider.notifier) - .refresh(), - child: ChallengeListView( - challenges: - data.myChallenges, // List> - model: data, // getStatus 호출을 위해 모델 전달 - ), - ), - ), - ], - ), - // Floating Action Button - _buildFAB(context), - ], - ), - ), - ), - ); - } - - // 알림 아이콘 빌더 - Widget _buildNotificationIcon( - BuildContext context, - WidgetRef ref, - int count, - ) { - return Stack( - alignment: Alignment.center, - children: [ - IconButton( - onPressed: () async { - // 💡 1. 들어가기 전에 뱃지 숫자를 확인합니다. - // 0보다 크다면, 서버에서 '읽음' 처리될 것이 100% 확실하므로 새로고침을 예약해 둡니다. - final hasUnreadInitially = count > 0; - - // 💡 2. await를 붙여서 알림 페이지(NotificationMainScreen)가 닫힐 때까지 기다립니다. - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const NotificationMainScreen(), - ), - ); - - // 💡 3. 알림 페이지에서 뒤로가기(pop)를 눌러서 홈으로 돌아온 직후 실행됩니다! - // 홈 화면 전체 데이터를 새로고침(refresh) 하여 뱃지 개수와 챌린지 목록을 최신화합니다. - if (context.mounted) { - // 💡 알림 페이지 안에서 '수락'이나 '거절'을 눌렀는지 확인 - final hasActionOccurred = ref.read(needsHomeRefreshProvider); - - // 처음 들어갈 때 안 읽은 알림이 있었거나 OR 안에서 챌린지 수락/거절을 했다면 새로고침! - if (hasUnreadInitially || hasActionOccurred) { - ref.read(challengeHomeNotifierProvider.notifier).refresh(); - - // 스위치는 다시 꺼줍니다 - ref.read(needsHomeRefreshProvider.notifier).state = false; - } - } - }, - icon: SvgPicture.asset('assets/images/icons/home_notice_icon.svg'), - ), - if (count > 0) - Positioned( - right: 8, - top: 3, - child: Container( - padding: const EdgeInsets.all(4), - decoration: const BoxDecoration( - color: AppColors.notification, - shape: BoxShape.circle, - ), - child: Text( - '$count', - style: AppTypography.c1.copyWith(color: Colors.white), - ), - ), - ), - ], - ); - } - - // FAB 빌더 - Widget _buildFAB(BuildContext context) { - return Positioned( - right: 20, - bottom: 30, - child: FloatingActionButton( - onPressed: () => Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const ChallengeCreateScreen(), - ), - ), - backgroundColor: Colors.white, - shape: const CircleBorder(), - child: const Icon(Icons.add, size: 32, color: Colors.green), - ), - ); - } - - String getFormattedDate() { - DateTime now = DateTime.now(); - return '${now.year}. ${now.month.toString().padLeft(2, '0')}. ${now.day.toString().padLeft(2, '0')}'; - } -} - -// 챌린지 리스트 뷰 -class ChallengeListView extends StatelessWidget { - final List> challenges; - final ChallengeMainModel model; // 상태 계산 로직을 쓰기 위해 추가 - - const ChallengeListView({ - super.key, - required this.challenges, - required this.model, - }); - - @override - Widget build(BuildContext context) { - if (challenges.isEmpty) return _buildEmptyState(); - - return ListView.builder( - padding: const EdgeInsets.only(bottom: 100), - itemCount: challenges.length, - itemBuilder: (context, index) { - return ChallengeCard( - challenge: challenges[index], - status: model.getStatus(index), // 모델의 헬퍼 함수 활용 - ); - }, - ); - } - - Widget _buildEmptyState() { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), - decoration: BoxDecoration( - color: AppColors.gray5, - borderRadius: BorderRadius.circular(12), - ), - child: const Text( - '+ 아이콘을 눌러 챌린지를 추가하거나\n' - '피드에서 도전할 챌린지를 찾아보세요!', - style: AppTypography.b1, - ), - ), - ); - } -} - -// 챌린지 카드 -class ChallengeCard extends StatelessWidget { - final Map challenge; - final ChallengeStatus status; - - const ChallengeCard({ - super.key, - required this.challenge, - required this.status, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ChallengeMainScreen( - challengeId: challenge['challengeId'] ?? 0, - challengeTitle: challenge['title'], - ), - ), - ); - }, - child: Padding( - padding: const EdgeInsets.only(left: 20, right: 20, bottom: 12), - child: Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: _getCardColor(status), - borderRadius: BorderRadius.circular(12), - ), - child: IntrinsicHeight( - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - challenge['title'] ?? '', - style: AppTypography.b1.copyWith( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - const SizedBox(height: 4), - _buildSuccessDays(), - const SizedBox(height: 4), - _buildBottomInfo(), - ], - ), - ), - _buildDivider(), - SizedBox(width: 44, child: Center(child: _buildStatusIcon())), - ], - ), - ), - ), - ), - ); - } - - Widget _buildSuccessDays() { - final during = challenge['duringDate'] ?? 0; - final isDone = challenge['doIt'] ?? false; - return Row( - children: [ - if (during >= 2 && isDone) - Padding( - padding: const EdgeInsets.only(right: 4), - child: SvgPicture.asset( - 'assets/images/icons/small_fire_icon.svg', - width: 16, - height: 16, - ), - ), - Text('$during일째', style: AppTypography.b2.copyWith(fontSize: 14)), - ], - ); - } - - Widget _buildBottomInfo() { - if (status == ChallengeStatus.urgent) { - return const Text( - '오늘 챌린지를 하지 않으면 실패해요!', - style: TextStyle(color: AppColors.notification, fontSize: 12), - ); - } - return Row( - children: [ - SvgPicture.asset( - 'assets/images/icons/mini_success_icon.svg', - width: 16, - height: 16, - ), - const SizedBox(width: 4), - Text( - '인증인원 ${challenge['todaySuccessCount']}/${challenge['participantNumber']}', - style: AppTypography.b2.copyWith(fontSize: 14), - ), - ], - ); - } - - Widget _buildDivider() { - return SizedBox( - width: 40, - child: Center( - child: CustomPaint( - size: const Size(1, double.infinity), - painter: VerticalDashPainter(), - ), - ), - ); - } - - Widget _buildStatusIcon() { - if (status == ChallengeStatus.completed) - return SvgPicture.asset('assets/images/icons/success_icon.svg'); - if (status == ChallengeStatus.urgent) - return SvgPicture.asset('assets/images/icons/warning_icon.svg'); - return const SizedBox(width: 24); - } - - Color _getCardColor(ChallengeStatus status) { - if (status == ChallengeStatus.completed) return AppColors.success; - if (status == ChallengeStatus.urgent) return AppColors.warning; - return AppColors.gray5; - } -} - -class VerticalDashPainter extends CustomPainter { - @override - void paint(Canvas canvas, Size size) { - double dashHeight = 5, dashSpace = 3, startY = 0; - // 캔버스의 중앙 X축 계산 - final double centerX = size.width / 2; - final paint = Paint() - ..color = AppColors - .gray3 // 점선 색상 농도 조절 - ..strokeWidth = 1; - - while (startY < size.height) { - // Offset의 X좌표를 centerX로 고정하여 직선도 유지 - canvas.drawLine( - Offset(centerX, startY), - Offset(centerX, startY + dashHeight), - paint, - ); - startY += dashHeight + dashSpace; - } - } - - @override - bool shouldRepaint(CustomPainter oldDelegate) => false; -} - -class _DayChip extends StatelessWidget { - final DateTime date; - final bool isSelected; - final ChallengeStatus status; // 추가됨 - - const _DayChip({ - required this.date, - this.isSelected = false, - this.status = ChallengeStatus.normal, - }); - - @override - Widget build(BuildContext context) { - const Color black = AppColors.black; - const Color gray2 = AppColors.gray2; - - // 요일 레이블 매핑 - const weekdayLabels = ['일', '월', '화', '수', '목', '금', '토']; - String label = weekdayLabels[date.weekday % 7]; - String day = date.day.toString(); - - // 테두리 결정 로직 - final bool showBorder = isSelected && status == ChallengeStatus.normal; - - return Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(label, style: AppTypography.b1.copyWith(color: AppColors.black)), - const SizedBox(height: 8), - Container( - width: 40, - height: 40, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 9), - decoration: ShapeDecoration( - color: getBackgroundColor(), - shape: RoundedRectangleBorder( - // 💡 오늘 날짜일 때만 테두리(Outside) 적용 - side: showBorder - ? const BorderSide( - width: 1, - strokeAlign: BorderSide.strokeAlignOutside, - color: gray2, - ) - : BorderSide.none, - borderRadius: BorderRadius.circular(5), - ), - ), - alignment: Alignment.center, - child: Text( - day, - style: TextStyle( - color: isSelected && status != ChallengeStatus.normal - ? Colors.white - : gray2, - fontSize: 14, - fontFamily: 'Pretendard', - fontWeight: isSelected ? FontWeight.w700 : FontWeight.w400, - height: 1.50, - ), - ), - ), - ], - ), - ); - } - - Color getBackgroundColor() { - if (!isSelected) return AppColors.gray5; - - // 💡 오늘일 경우: 상태에 따른 강조색 - switch (status) { - case ChallengeStatus.urgent: - return AppColors.notification; // 실패 위기 (빨강) - case ChallengeStatus.completed: - return AppColors.primaryAble; // 완료 (초록) - case ChallengeStatus.normal: - default: - return AppColors.gray5; // 미완료 상태인 오늘 (회색) - } - } -} diff --git a/lib/features/home/models/home_response.dart b/lib/features/home/models/home_response.dart new file mode 100644 index 0000000..4613e83 --- /dev/null +++ b/lib/features/home/models/home_response.dart @@ -0,0 +1,25 @@ +import 'package:haenaem/shared/models/home_challenge_card.dart'; + +// 최초 작성자: 강선욱 +// API 응답을 담는 홈 전용 모델 +// myChallenges → List로 파싱 +// notificationNumber → 별도 관리 +class HomeResponse { + final List myChallenges; + final int notificationNumber; + + const HomeResponse({ + required this.myChallenges, + required this.notificationNumber, + }); + + factory HomeResponse.fromJson(Map json) { + final List challenges = json['myChallenges'] ?? []; + return HomeResponse( + myChallenges: challenges + .map((e) => HomeChallengeCard.fromJson(e)) + .toList(), + notificationNumber: json['notificationNumber'] as int, + ); + } +} diff --git a/lib/features/home/screens/home_screen.dart b/lib/features/home/screens/home_screen.dart new file mode 100644 index 0000000..d51e073 --- /dev/null +++ b/lib/features/home/screens/home_screen.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; // 추가 +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:haenaem/core/theme/app_colors.dart'; +import 'package:haenaem/core/theme/app_typography.dart'; +import 'package:haenaem/features/challenge/create/screens/challenge_create_screen.dart'; +import '../../../shared/provider/home_provider.dart'; +import 'package:haenaem/features/notification/screens/notification_main_screen.dart'; +import 'package:haenaem/features/notification/provider/notification_provider.dart'; +import 'package:haenaem/shared/models/home_challenge_card.dart'; +import '../widgets/challenge_card.dart'; +import '../widgets/day_chip.dart'; + +// 최초 작성자 : 강선욱 +// 홈 화면 빌드 클래스 +class HomeScreen extends ConsumerWidget { + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final homeDataAsync = ref.watch(homeNotifierProvider); + + DateTime now = DateTime.now(); + DateTime firstDayOfWeek = now.subtract(Duration(days: now.weekday % 7)); + List weekDays = List.generate( + 7, + (index) => firstDayOfWeek.add(Duration(days: index)), + ); + + return Scaffold( + backgroundColor: Colors.white, + body: SafeArea( + child: homeDataAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, stack) => Center(child: Text('데이터 에러: $err')), + data: (data) { + // 모든 챌린지가 isDone이면 오늘 완료로 판단 + final todayIsDone = + data.myChallenges.isNotEmpty && + data.myChallenges.every((c) => c.isDone); + // 하나라도 warning이면 오늘 위험으로 판단 + final todayIsWarning = data.myChallenges.any((c) => c.warning); + + return Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 상단 바 + Padding( + padding: const EdgeInsets.only( + left: 32, + right: 20, + top: 12, + bottom: 12, + ), + child: Row( + children: [ + Text(getFormattedDate(), style: AppTypography.h2), + const Spacer(), + // 알림 배지 (data.notificationNumber 사용) + _buildNotificationIcon( + context, + ref, + data.notificationNumber, + ), + ], + ), + ), + // 주간 캘린더 + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 4, + ), + child: Row( + children: weekDays.map((date) { + final isToday = + date.year == now.year && + date.month == now.month && + date.day == now.day; + return DayChip( + date: date, + isSelected: isToday, + isDone: isToday ? todayIsDone : false, + isWarning: isToday ? todayIsWarning : false, + ); + }).toList(), + ), + ), + const SizedBox(height: 24), + // 챌린지 리스트 (data.myChallenges 전달) + Expanded( + child: RefreshIndicator( + onRefresh: () => + ref.read(homeNotifierProvider.notifier).refresh(), + child: ChallengeListView( + challenges: + data.myChallenges, // getStatus 호출을 위해 모델 전달 + ), + ), + ), + ], + ), + // Floating Action Button + _buildFAB(context), + ], + ); + }, + ), + ), + ); + } + + // 알림 아이콘 빌더 + Widget _buildNotificationIcon( + BuildContext context, + WidgetRef ref, + int count, + ) { + return Stack( + alignment: Alignment.center, + children: [ + IconButton( + onPressed: () async { + // 💡 1. 들어가기 전에 뱃지 숫자를 확인합니다. + // 0보다 크다면, 서버에서 '읽음' 처리될 것이 100% 확실하므로 새로고침을 예약해 둡니다. + final hasUnreadInitially = count > 0; + + // 💡 2. await를 붙여서 알림 페이지(NotificationMainScreen)가 닫힐 때까지 기다립니다. + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const NotificationMainScreen(), + ), + ); + + // 💡 3. 알림 페이지에서 뒤로가기(pop)를 눌러서 홈으로 돌아온 직후 실행됩니다! + // 홈 화면 전체 데이터를 새로고침(refresh) 하여 뱃지 개수와 챌린지 목록을 최신화합니다. + if (context.mounted) { + // 💡 알림 페이지 안에서 '수락'이나 '거절'을 눌렀는지 확인 + final hasActionOccurred = ref.read(needsHomeRefreshProvider); + + // 처음 들어갈 때 안 읽은 알림이 있었거나 OR 안에서 챌린지 수락/거절을 했다면 새로고침! + if (hasUnreadInitially || hasActionOccurred) { + ref.read(homeNotifierProvider.notifier).refresh(); + + // 스위치는 다시 꺼줍니다 + ref.read(needsHomeRefreshProvider.notifier).state = false; + } + } + }, + icon: SvgPicture.asset('assets/images/icons/home_notice_icon.svg'), + ), + if (count > 0) + Positioned( + right: 8, + top: 3, + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: AppColors.notification, + shape: BoxShape.circle, + ), + child: Text( + '$count', + style: AppTypography.c1.copyWith(color: Colors.white), + ), + ), + ), + ], + ); + } + + // FAB 빌더 + Widget _buildFAB(BuildContext context) { + return Positioned( + right: 20, + bottom: 30, + child: FloatingActionButton( + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const ChallengeCreateScreen(), + ), + ), + backgroundColor: Colors.white, + shape: const CircleBorder(), + child: const Icon(Icons.add, size: 32, color: Colors.green), + ), + ); + } + + String getFormattedDate() { + DateTime now = DateTime.now(); + return '${now.year}. ${now.month.toString().padLeft(2, '0')}. ${now.day.toString().padLeft(2, '0')}'; + } +} + +// 챌린지 리스트 뷰 +class ChallengeListView extends StatelessWidget { + final List challenges; + + const ChallengeListView({super.key, required this.challenges}); + + @override + Widget build(BuildContext context) { + if (challenges.isEmpty) return _buildEmptyState(); + + return ListView.builder( + padding: const EdgeInsets.only(bottom: 100), + itemCount: challenges.length, + itemBuilder: (context, index) { + return ChallengeCard(challenge: challenges[index]); + }, + ); + } + + Widget _buildEmptyState() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + decoration: BoxDecoration( + color: AppColors.gray5, + borderRadius: BorderRadius.circular(12), + ), + child: const Text( + '+ 아이콘을 눌러 챌린지를 추가하거나\n' + '피드에서 도전할 챌린지를 찾아보세요!', + style: AppTypography.b1, + ), + ), + ); + } +} diff --git a/lib/features/home/widgets/challenge_card.dart b/lib/features/home/widgets/challenge_card.dart new file mode 100644 index 0000000..1f36216 --- /dev/null +++ b/lib/features/home/widgets/challenge_card.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:haenaem/core/theme/app_colors.dart'; +import 'package:haenaem/core/theme/app_typography.dart'; + +import 'package:haenaem/shared/models/home_challenge_card.dart'; +import 'package:haenaem/features/challenge/detail/screens/challenge_main_screen.dart'; + +// 홈탭의 챌린지 카드 +class ChallengeCard extends StatelessWidget { + final HomeChallengeCard challenge; + + const ChallengeCard({super.key, required this.challenge}); + + Color get _cardColor { + if (challenge.isDone) return AppColors.success; + if (challenge.warning) return AppColors.warning; + return AppColors.gray5; + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChallengeMainScreen( + challengeId: challenge.challengeBase.id, + challengeTitle: challenge.challengeBase.title, + streakCount: challenge.streakCount, + ), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.only(left: 20, right: 20, bottom: 12), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: _cardColor, + borderRadius: BorderRadius.circular(12), + ), + child: IntrinsicHeight( + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + challenge.challengeBase.title, + style: AppTypography.b1.copyWith( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + _buildFrequencyAndDDayInfo(), + const SizedBox(height: 4), + _buildStreakAndParticipantInfo(), + + if (challenge.warning) ...[ + const SizedBox(height: 4), + _buildWarningText(), + ], + ], + ), + ), + _buildDivider(), + SizedBox(width: 44, child: Center(child: _buildStatusIcon())), + ], + ), + ), + ), + ), + ); + } + + Widget _buildFrequencyAndDDayInfo() { + final frequencyText = challenge.weeklyFrequency == 7 + ? '매일' + : '주 ${challenge.weeklyFrequency}회'; + final dDayText = challenge.dDay == 0 ? '오늘 종료' : '완료까지 D-${challenge.dDay}'; + return Text( + '$frequencyText, $dDayText', + style: AppTypography.b2.copyWith(fontSize: 14), + ); + } + + Widget _buildStreakAndParticipantInfo() { + return Row( + children: [ + // 스트릭 정보: streakCount > 0 && isDone일 때 불꽃 아이콘 표시 + if (challenge.streakCount > 0 && challenge.isDone) + Padding( + padding: const EdgeInsets.only(right: 4), + child: SvgPicture.asset( + 'assets/images/icons/small_fire_icon.svg', + width: 16, + height: 16, + ), + ), + Text( + '${challenge.streakCount}일째', + style: AppTypography.b2.copyWith(fontSize: 14), + ), + const SizedBox(width: 12), + // 인증인원 정보 + SvgPicture.asset( + 'assets/images/icons/mini_success_icon.svg', + width: 16, + height: 16, + ), + const SizedBox(width: 4), + Text( + '${challenge.successParticipantCount}/${challenge.participantCount}명', + style: AppTypography.b2.copyWith(fontSize: 14), + ), + ], + ); + } + + Widget _buildWarningText() { + return const Text( + '오늘 챌린지를 하지 않으면 실패해요!', + style: TextStyle(color: AppColors.notification, fontSize: 12), + ); + } + + Widget _buildDivider() { + return SizedBox( + width: 40, + child: Center( + child: CustomPaint( + size: const Size(1, double.infinity), + painter: VerticalDashPainter(), + ), + ), + ); + } + + Widget _buildStatusIcon() { + if (challenge.isDone) + return SvgPicture.asset('assets/images/icons/success_icon.svg'); + if (challenge.warning) + return SvgPicture.asset('assets/images/icons/warning_icon.svg'); + return const SizedBox(width: 24); + } +} + +class VerticalDashPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + double dashHeight = 5, dashSpace = 3, startY = 0; + // 캔버스의 중앙 X축 계산 + final double centerX = size.width / 2; + final paint = Paint() + ..color = AppColors + .gray3 // 점선 색상 농도 조절 + ..strokeWidth = 1; + + while (startY < size.height) { + // Offset의 X좌표를 centerX로 고정하여 직선도 유지 + canvas.drawLine( + Offset(centerX, startY), + Offset(centerX, startY + dashHeight), + paint, + ); + startY += dashHeight + dashSpace; + } + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => false; +} diff --git a/lib/features/home/widgets/day_chip.dart b/lib/features/home/widgets/day_chip.dart new file mode 100644 index 0000000..7017992 --- /dev/null +++ b/lib/features/home/widgets/day_chip.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:haenaem/core/theme/app_colors.dart'; +import 'package:haenaem/core/theme/app_typography.dart'; +import 'package:haenaem/features/challenge/models/challenge_model.dart'; + +class DayChip extends StatelessWidget { + final DateTime date; + final bool isSelected; + final bool isDone; + final bool isWarning; + + const DayChip({ + super.key, + required this.date, + this.isSelected = false, + this.isDone = false, + this.isWarning = false, + }); + + @override + Widget build(BuildContext context) { + const Color gray2 = AppColors.gray2; + + // 요일 레이블 매핑 + const weekdayLabels = ['일', '월', '화', '수', '목', '금', '토']; + String label = weekdayLabels[date.weekday % 7]; + String day = date.day.toString(); + + // 테두리 결정 로직 + final bool showBorder = isSelected && !isDone && !isWarning; + + return Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, style: AppTypography.b1.copyWith(color: AppColors.black)), + const SizedBox(height: 8), + Container( + width: 40, + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 9), + decoration: ShapeDecoration( + color: getBackgroundColor(), + shape: RoundedRectangleBorder( + // 💡 오늘 날짜일 때만 테두리(Outside) 적용 + side: showBorder + ? const BorderSide( + width: 1, + strokeAlign: BorderSide.strokeAlignOutside, + color: gray2, + ) + : BorderSide.none, + borderRadius: BorderRadius.circular(5), + ), + ), + alignment: Alignment.center, + child: Text( + day, + style: TextStyle( + color: isSelected && (isDone || isWarning) + ? Colors.white + : gray2, + fontSize: 14, + fontFamily: 'Pretendard', + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w400, + height: 1.50, + ), + ), + ), + ], + ), + ); + } + + Color getBackgroundColor() { + if (!isSelected) return AppColors.gray5; + + if (isWarning) return AppColors.notification; + if (isDone) return AppColors.primaryAble; + return AppColors.gray5; + } +} diff --git a/lib/features/main/screens/main_screen.dart b/lib/features/main/screens/main_screen.dart index e958544..2a5d334 100644 --- a/lib/features/main/screens/main_screen.dart +++ b/lib/features/main/screens/main_screen.dart @@ -1,10 +1,11 @@ // 최초 작성자 : 김채영 import 'package:flutter/material.dart'; -import 'package:haenaem/features/home/home_screen.dart'; -import 'package:haenaem/features/social/screens/social_screen.dart'; +import 'package:haenaem/features/home/screens/home_screen.dart'; +import 'package:haenaem/features/social/screens/social_main_screen.dart'; import '../widgets/bottom_nav_bar.dart'; -import 'package:haenaem/features/user/screens/my_page_screen.dart'; +import 'package:haenaem/features/user/screens/my_page_main_screen.dart'; import 'package:haenaem/features/feed/screens/feed_screen.dart'; +import 'package:haenaem/features/statistics/screens/statistics_screen.dart'; // 내비게이션 바를 넣은 화면 class MainScreen extends StatefulWidget { @@ -20,10 +21,10 @@ class _MainScreenState extends State { // 하단 바를 통해 전환될 화면 리스트 final List _pages = [ const HomeScreen(), - const Center(child: Text("통계 화면")), + const StatisticsScreen(), const FeedScreen(), - const SocialScreen(), - const MyPageScreen(), + const SocialMainScreen(), + const MyPageMainScreen(), ]; @override diff --git a/lib/features/notification/data/challenge_notification_repository.dart b/lib/features/notification/data/challenge_notification_repository.dart new file mode 100644 index 0000000..2edc3e8 --- /dev/null +++ b/lib/features/notification/data/challenge_notification_repository.dart @@ -0,0 +1,124 @@ +// 최초 작성자: 김채영 +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:haenaem/core/network/dio_provider.dart'; +import 'package:haenaem/features/notification/models/challenge_notification_settings_dto.dart'; + +part 'challenge_notification_repository.g.dart'; + +@riverpod +ChallengeNotificationRepository challengeNotificationRepository( + ChallengeNotificationRepositoryRef ref, +) { + final dio = ref.watch(dioProvider); + return ChallengeNotificationRepository(dio); +} + +class ChallengeNotificationRepository { + final Dio _dio; + ChallengeNotificationRepository(this._dio); + + // ── 설정 조회 ─────────────────────────────────────────── + + /// GET /api/fcm/notification/challenge/{challengeId}/settings + Future getChallengeNotificationSettings( + int challengeId, + ) async { + try { + final response = await _dio.get( + '/api/fcm/notification/challenge/$challengeId/settings', + ); + return ChallengeNotificationSettingsDto.fromJson(response.data); + } on DioException catch (e) { + throw Exception('챌린지 알림 설정 조회 실패: ${e.response?.statusCode}'); + } + } + + // ── 설정 변경 ─────────────────────────────────────────── + + /// PUT /api/fcm/notification/challenge/{challengeId}/all + Future setChallengeAllNotification( + int challengeId, + bool enabled, + ) async { + try { + await _dio.put( + '/api/fcm/notification/challenge/$challengeId/all', + data: {'enabled': enabled}, + ); + return true; + } on DioException catch (e) { + debugPrint('챌린지 전체 알림 설정 실패: ${e.response?.data}'); + return false; + } + } + + /// PUT /api/fcm/notification/challenge/{challengeId}/daily-reminder + Future setChallengeReminderNotification( + int challengeId, + bool enabled, + ) async { + try { + await _dio.put( + '/api/fcm/notification/challenge/$challengeId/daily-reminder', + data: {'enabled': enabled}, + ); + return true; + } on DioException catch (e) { + debugPrint('챌린지 리마인더 알림 설정 실패: ${e.response?.data}'); + return false; + } + } + + /// PUT /api/fcm/notification/challenge/{challengeId}/likes + Future setChallengeLikesNotification( + int challengeId, + bool enabled, + ) async { + try { + await _dio.put( + '/api/fcm/notification/challenge/$challengeId/likes', + data: {'enabled': enabled}, + ); + return true; + } on DioException catch (e) { + debugPrint('챌린지 좋아요 알림 설정 실패: ${e.response?.data}'); + return false; + } + } + + /// PUT /api/fcm/notification/challenge/{challengeId}/comments + Future setChallengeCommentsNotification( + int challengeId, + bool enabled, + ) async { + try { + await _dio.put( + '/api/fcm/notification/challenge/$challengeId/comments', + data: {'enabled': enabled}, + ); + return true; + } on DioException catch (e) { + debugPrint('챌린지 댓글 알림 설정 실패: ${e.response?.data}'); + return false; + } + } + + /// PUT /api/fcm/notification/challenge/{challengeId}/member-certification + Future setChallengeVerificationNotification( + int challengeId, + bool enabled, + ) async { + try { + await _dio.put( + '/api/fcm/notification/challenge/$challengeId/member-certification', + data: {'enabled': enabled}, + ); + return true; + } on DioException catch (e) { + debugPrint('챌린지 멤버 인증 알림 설정 실패: ${e.response?.data}'); + return false; + } + } +} diff --git a/lib/features/notification/data/challenge_notification_repository.g.dart b/lib/features/notification/data/challenge_notification_repository.g.dart new file mode 100644 index 0000000..04c1dc8 --- /dev/null +++ b/lib/features/notification/data/challenge_notification_repository.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'challenge_notification_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$challengeNotificationRepositoryHash() => + r'41c3aeea4df09e6c7268ca48038900de3b0f227d'; + +/// See also [challengeNotificationRepository]. +@ProviderFor(challengeNotificationRepository) +final challengeNotificationRepositoryProvider = + AutoDisposeProvider.internal( + challengeNotificationRepository, + name: r'challengeNotificationRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$challengeNotificationRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef ChallengeNotificationRepositoryRef = + AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/notification/data/notification_repository.dart b/lib/features/notification/data/notification_repository.dart index e0a7b14..289f212 100644 --- a/lib/features/notification/data/notification_repository.dart +++ b/lib/features/notification/data/notification_repository.dart @@ -1,139 +1,80 @@ // 최초 작성자: 정승빈 -// 알림 조회, 읽음 처리, 수락/거절 API import 'package:dio/dio.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import '../model/notification_model.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:haenaem/core/network/dio_provider.dart'; +import 'package:haenaem/features/notification/models/invite_challenge_card.dart'; -// 1. SecureStorage 프로바이더 생성 -final secureStorageProvider = Provider((ref) { - return const FlutterSecureStorage(); -}); +part 'notification_repository.g.dart'; -// 2. 알림 전용 Dio 프로바이더 (토큰 주입 로직 포함) -final notiDioProvider = Provider((ref) { - final dio = Dio( - BaseOptions( - baseUrl: 'https://hanaem.onrender.com', // 확인된 백엔드 주소 - connectTimeout: const Duration(seconds: 5), - receiveTimeout: const Duration(seconds: 3), - ), - ); - - // 토큰을 읽어오기 위해 storage 객체 가져오기 - final storage = ref.read(secureStorageProvider); - - dio.interceptors.add( - InterceptorsWrapper( - onRequest: (options, handler) async { - // [중요] storage에서 토큰 꺼내기 - // 주의: 'accessToken' 이라는 키 이름은 auth_service.dart에서 저장할 때 쓴 이름과 일치해야 합니다. - // 혹시 토큰 키가 'token'이나 'jwt'라면 그에 맞게 수정해 주세요. - final String? token = await storage.read(key: 'accessToken'); - - if (token != null && token.isNotEmpty) { - options.headers['Authorization'] = 'Bearer $token'; - } - - print('🌐 [Dio Request] ${options.method} ${options.uri}'); - print('🌐 [Dio Request Headers] ${options.headers}'); - return handler.next(options); - }, - onResponse: (response, handler) { - print( - '✅ [Dio Response] ${response.statusCode} ${response.requestOptions.path}', - ); - print('📦 [Dio Response Data]: ${response.data}'); - return handler.next(response); - }, - onError: (DioException e, handler) { - print('❌ [Dio Error] ${e.response?.statusCode} - ${e.message}'); - print('❌ [Dio Error URL] ${e.requestOptions.uri}'); - return handler.next(e); - }, - ), - ); - - return dio; -}); - -// 3. 알림 레포지토리 프로바이더 -final notificationRepositoryProvider = Provider((ref) { - final dio = ref.watch(notiDioProvider); - return NotificationRepository(dio: dio); -}); +@riverpod +NotificationRepository notificationRepository(NotificationRepositoryRef ref) { + final dio = ref.watch(dioProvider); + return NotificationRepository(dio); +} -// 4. 알림 레포지토리 클래스 class NotificationRepository { - final Dio dio; + final Dio _dio; + NotificationRepository(this._dio); - NotificationRepository({required this.dio}); + // ── 알림 목록 ────────────────────────────────────────── + /// 알림 목록 페이징 조회 Future> getNotifications({required int page}) async { try { - final response = await dio.get( + final response = await _dio.get( '/api/notification', queryParameters: {'page': page}, ); - return response.data; } on DioException catch (e) { - print('❌ [Noti Repo Error]: ${e.response?.data}'); - throw Exception('알림 목록을 불러오는데 실패했습니다: ${e.response?.statusCode}'); - } catch (e) { - throw Exception('알 수 없는 오류 발생: $e'); + throw Exception('알림 목록 조회 실패: ${e.response?.statusCode}'); } } - // 챌린지 초대 목록 조회 - Future> getChallengeInvites() async { + // ── 챌린지 초대 ───────────────────────────────────────── + + /// 챌린지 초대 목록 조회 + Future> getChallengeInvites() async { try { - final response = await dio.get('/api/challenges/invites'); - // 응답이 배열(List) 형태 + final response = await _dio.get('/api/challenges/invites'); final List data = response.data; - return data.map((e) => ChallengeInviteModel.fromJson(e)).toList(); + return data + .map( + (e) => InviteChallengecard.fromResponse( + InviteResponse.fromJson(e as Map), + ), + ) + .toList(); } on DioException catch (e) { - print('❌ [초대 조회 에러]: ${e.response?.data}'); - throw Exception('초대 목록을 불러오는데 실패했습니다.'); - } catch (e) { - throw Exception('초대 조회 중 알 수 없는 오류 발생: $e'); + throw Exception('초대 목록 조회 실패: ${e.response?.statusCode}'); } } - // 챌린지 초대 수락 + /// 챌린지 초대 수락 Future acceptChallengeInvite(int challengeId) async { try { - await dio.post('/api/challenges/$challengeId/invites/accept'); + await _dio.post('/api/challenges/$challengeId/invites/accept'); } on DioException catch (e) { - // 💡 1. 백엔드 에러 응답을 안전하게 가져옵니다. final data = e.response?.data; String errorMessage = '초대 수락에 실패했습니다.'; - - // 💡 2. 데이터가 Map(JSON) 형태인지 확인 후 안전하게 파싱합니다. (앱 터짐 방지) if (data != null && data is Map) { final reason = data['reason']; - - // 💡 3. 백엔드 에러 메시지에 맞게 한글로 변환해 줍니다. if (reason == 'CHALLENGE_INVITE_NOT_FOUND') { errorMessage = '이미 취소되거나 존재하지 않는 초대입니다.'; } else if (reason != null) { - errorMessage = reason; // 예: "이미 처리된 초대입니다." 그대로 노출 + errorMessage = reason; } } - // UI 단으로 에러 메시지를 던집니다. throw Exception(errorMessage); - } catch (e) { - throw Exception('알 수 없는 오류가 발생했습니다.'); } } - // 챌린지 초대 거절 + /// 챌린지 초대 거절 Future rejectChallengeInvite(int challengeId) async { try { - await dio.post('/api/challenges/$challengeId/invites/reject'); + await _dio.post('/api/challenges/$challengeId/invites/reject'); } on DioException catch (e) { - print('❌ [거절 에러]: ${e.response?.data}'); - throw Exception('초대 거절 실패'); + throw Exception('초대 거절 실패: ${e.response?.statusCode}'); } } } diff --git a/lib/features/notification/data/notification_repository.g.dart b/lib/features/notification/data/notification_repository.g.dart new file mode 100644 index 0000000..1714776 --- /dev/null +++ b/lib/features/notification/data/notification_repository.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'notification_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$notificationRepositoryHash() => + r'bd26d838ec2fdb33dbdf0637b10770900a0324f5'; + +/// See also [notificationRepository]. +@ProviderFor(notificationRepository) +final notificationRepositoryProvider = + AutoDisposeProvider.internal( + notificationRepository, + name: r'notificationRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$notificationRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef NotificationRepositoryRef = + AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/notification/data/push_notification_repository.dart b/lib/features/notification/data/push_notification_repository.dart new file mode 100644 index 0000000..dcc1086 --- /dev/null +++ b/lib/features/notification/data/push_notification_repository.dart @@ -0,0 +1,187 @@ +// 최초 작성자: 김채영 +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:haenaem/core/network/dio_provider.dart'; +import 'package:haenaem/features/notification/models/push_notification_settings_dto.dart'; + +part 'push_notification_repository.g.dart'; + +@riverpod +PushNotificationRepository pushNotificationRepository( + PushNotificationRepositoryRef ref, +) { + final dio = ref.watch(dioProvider); + return PushNotificationRepository(dio); +} + +class PushNotificationRepository { + final Dio _dio; + PushNotificationRepository(this._dio); + + // ── 설정 조회 ─────────────────────────────────────────── + + /// GET /api/fcm/notification/settings + Future getNotificationSettings() async { + try { + final response = await _dio.get('/api/fcm/notification/settings'); + return PushNotificationSettingsDto.fromJson(response.data); + } on DioException catch (e) { + throw Exception('알림 설정 조회 실패: ${e.response?.statusCode}'); + } + } + + // ── 전체 알림 ──────────────────────────────────────────── + + /// PUT /api/fcm/notification/all + Future setAllNotification(bool enabled) async { + try { + await _dio.put('/api/fcm/notification/all', data: {'enabled': enabled}); + return true; + } on DioException catch (e) { + debugPrint('전체 알림 설정 실패: ${e.response?.data}'); + return false; + } + } + + // ── 소셜 알림 ──────────────────────────────────────────── + + /// PUT /api/fcm/notification/all-likes + Future setAllLikesNotification(bool enabled) async { + try { + await _dio.put( + '/api/fcm/notification/all-likes', + data: {'enabled': enabled}, + ); + return true; + } on DioException catch (e) { + debugPrint('전체 좋아요 알림 설정 실패: ${e.response?.data}'); + return false; + } + } + + /// PUT /api/fcm/notification/all-comments + Future setAllCommentsNotification(bool enabled) async { + try { + await _dio.put( + '/api/fcm/notification/all-comments', + data: {'enabled': enabled}, + ); + return true; + } on DioException catch (e) { + debugPrint('전체 댓글 알림 설정 실패: ${e.response?.data}'); + return false; + } + } + + /// PUT /api/fcm/notification/friend + Future setFriendNotification(bool enabled) async { + try { + await _dio.put( + '/api/fcm/notification/friend', + data: {'enabled': enabled}, + ); + return true; + } on DioException catch (e) { + debugPrint('친구 알림 설정 실패: ${e.response?.data}'); + return false; + } + } + + /// PUT /api/fcm/notification/all-member-certification + Future setAllMemberCertificationNotification(bool enabled) async { + try { + await _dio.put( + '/api/fcm/notification/all-member-certification', + data: {'enabled': enabled}, + ); + return true; + } on DioException catch (e) { + debugPrint('전체 멤버 인증 알림 설정 실패: ${e.response?.data}'); + return false; + } + } + + // ── 챌린지 섹션 알림 ───────────────────────────────────── + + /// PUT /api/fcm/notification/challenge-invite + Future setChallengeInviteNotification(bool enabled) async { + try { + await _dio.put( + '/api/fcm/notification/challenge-invite', + data: {'enabled': enabled}, + ); + return true; + } on DioException catch (e) { + debugPrint('챌린지 초대 알림 설정 실패: ${e.response?.data}'); + return false; + } + } + + /// PUT /api/fcm/notification/motivation-message + Future setMotivationNotification(bool enabled) async { + try { + await _dio.put( + '/api/fcm/notification/motivation-message', + data: {'enabled': enabled}, + ); + return true; + } on DioException catch (e) { + debugPrint('동기부여 알림 설정 실패: ${e.response?.data}'); + return false; + } + } + + /// PUT /api/fcm/notification/daily-reminder + Future setDailyReminderNotification(bool enabled) async { + try { + await _dio.put( + '/api/fcm/notification/daily-reminder', + data: {'enabled': enabled}, + ); + return true; + } on DioException catch (e) { + debugPrint('일일 리마인더 알림 설정 실패: ${e.response?.data}'); + return false; + } + } + + // ── 실패 방지 리마인더 시간 ────────────────────────────── + + /// GET /api/notification/reminder/time + Future getWeeklyReminderTime() async { + try { + final response = await _dio.get('/api/notification/reminder/time'); + final time = response.data['notificationTime']; + if (time == null) return null; + + if (time is String) { + return time.substring(0, 5); // "21:00:00" → "21:00" + } else if (time is Map) { + final hour = time['hour'] as int; + final minute = time['minute'] as int; + return '${hour.toString().padLeft(2, '0')}:${minute.toString().padLeft(2, '0')}'; + } + return null; + } on DioException catch (e) { + throw Exception('실패 방지 리마인더 시간 조회 실패: ${e.response?.statusCode}'); + } + } + + /// PUT /api/notification/reminder/time + Future setWeeklyReminderTime(String timeString) async { + try { + debugPrint('📤 실패 방지 리마인더 시간 전송: $timeString'); // ✅ 추가 + await _dio.put( + '/api/notification/reminder/time', + data: {'notificationTime': timeString}, + ); + debugPrint('✅ 실패 방지 리마인더 시간 설정 성공'); // ✅ 추가 + return true; + } on DioException catch (e) { + debugPrint('❌ 실패 방지 리마인더 시간 설정 실패: ${e.response?.data}'); + debugPrint('❌ 상태 코드: ${e.response?.statusCode}'); + return false; + } + } +} diff --git a/lib/features/notification/data/push_notification_repository.g.dart b/lib/features/notification/data/push_notification_repository.g.dart new file mode 100644 index 0000000..305c011 --- /dev/null +++ b/lib/features/notification/data/push_notification_repository.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'push_notification_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$pushNotificationRepositoryHash() => + r'051900f137067448f5b1a7ac5eddf38003190f51'; + +/// See also [pushNotificationRepository]. +@ProviderFor(pushNotificationRepository) +final pushNotificationRepositoryProvider = + AutoDisposeProvider.internal( + pushNotificationRepository, + name: r'pushNotificationRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$pushNotificationRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef PushNotificationRepositoryRef = + AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/notification/models/challenge_notification_settings_dto.dart b/lib/features/notification/models/challenge_notification_settings_dto.dart new file mode 100644 index 0000000..be75bd8 --- /dev/null +++ b/lib/features/notification/models/challenge_notification_settings_dto.dart @@ -0,0 +1,57 @@ +// 최초 작성자: 김채영 +class ChallengeNotificationSettingsDto { + final bool challengeAllPushEnabled; + final bool dailyReminderPushEnabled; + final String dailyReminderTime; // "HH:mm" 형식 + final bool dailyReminderDisabledByGlobalSetting; + final bool likesPushEnabled; + final bool likesDisabledByGlobalSetting; + final bool commentsPushEnabled; + final bool commentsDisabledByGlobalSetting; + final bool memberCertificationPushEnabled; + final bool memberCertificationDisabledByGlobalSetting; + + const ChallengeNotificationSettingsDto({ + required this.challengeAllPushEnabled, + required this.dailyReminderPushEnabled, + required this.dailyReminderTime, + required this.dailyReminderDisabledByGlobalSetting, + required this.likesPushEnabled, + required this.likesDisabledByGlobalSetting, + required this.commentsPushEnabled, + required this.commentsDisabledByGlobalSetting, + required this.memberCertificationPushEnabled, + required this.memberCertificationDisabledByGlobalSetting, + }); + + factory ChallengeNotificationSettingsDto.fromJson(Map json) { + // String("21:00:00") 또는 Map({ hour: 21, ... }) 둘 다 처리 + String timeStr = '21:00'; + final time = json['dailyReminderTime']; + if (time != null) { + if (time is String) { + timeStr = time.substring(0, 5); + } else if (time is Map) { + timeStr = '${(time['hour'] as int).toString().padLeft(2, '0')}:00'; + } + } + + return ChallengeNotificationSettingsDto( + challengeAllPushEnabled: json['challengeAllPushEnabled'] ?? false, + dailyReminderPushEnabled: json['dailyReminderPushEnabled'] ?? false, + dailyReminderTime: timeStr, + dailyReminderDisabledByGlobalSetting: + json['dailyReminderDisabledByGlobalSetting'] ?? false, + likesPushEnabled: json['likesPushEnabled'] ?? false, + likesDisabledByGlobalSetting: + json['likesDisabledByGlobalSetting'] ?? false, + commentsPushEnabled: json['commentsPushEnabled'] ?? false, + commentsDisabledByGlobalSetting: + json['commentsDisabledByGlobalSetting'] ?? false, + memberCertificationPushEnabled: + json['memberCertificationPushEnabled'] ?? false, + memberCertificationDisabledByGlobalSetting: + json['memberCertificationDisabledByGlobalSetting'] ?? false, + ); + } +} diff --git a/lib/features/notification/models/invite_challenge_card.dart b/lib/features/notification/models/invite_challenge_card.dart new file mode 100644 index 0000000..bb8f2a1 --- /dev/null +++ b/lib/features/notification/models/invite_challenge_card.dart @@ -0,0 +1,95 @@ +// 최초작성자: 정승빈 +// 챌린지 초대 목록 아이템 모델 +import 'package:haenaem/shared/models/user.dart'; +import 'package:haenaem/shared/models/search_challenge_card.dart'; +import 'package:haenaem/shared/models/challenge_base.dart'; + +// API 응답 원본 데이터를 담는 모델 +class InviteResponse { + final int inviterId; + final String inviterNickname; + final String? profileImageUrl; + final int challengeId; + final String challengeTitle; + final int participantCount; + final int remainingDays; + final List tags; + + InviteResponse({ + required this.inviterId, + required this.inviterNickname, + this.profileImageUrl, + required this.challengeId, + required this.challengeTitle, + required this.participantCount, + required this.remainingDays, + required this.tags, + }); + + factory InviteResponse.fromJson(Map json) { + String? rawProfileUrl = json['profileImageUrl'] as String?; + // URL이 null이 아니고, 공백을 제거했을 때 빈 문자열("")이라면 null로 간주 + // (서버에서 잘못된 빈 값이 넘어올 경우를 대비한 방어 코드) + if (rawProfileUrl != null && rawProfileUrl.trim().isEmpty) { + rawProfileUrl = null; + } + + return InviteResponse( + inviterId: json['inviterId'] as int, + inviterNickname: json['inviterNickname'] as String, + profileImageUrl: rawProfileUrl, + challengeId: json['challengeId'] as int, + challengeTitle: json['challengeTitle'] as String, + participantCount: json['participantCount'] as int, + remainingDays: json['remainingDays'] as int, + tags: List.from(json['tags'] as List), + ); + } + + // InviteResponse → User 변환 + // User.fromJson() 대신 직접 생성: 초대 API 필드명이 User.fromJson()의 기대 키('id', 'nickname')와 다르기 때문 + User toUser() { + return User( + id: inviterId, // 'inviterId' → User.id + nickname: inviterNickname, // 'inviterNickname' → User.nickname + profileUrl: profileImageUrl, // 'profileImageUrl' → User.profileUrl + ); + } + + // InviteResponse → SearchChallengeCard 변환 + // SearchChallengeCard.fromJson() 대신 직접 생성: 초대 API 필드명이 + // SearchChallengeCard.fromJson()의 기대 키('participant_count', 'end_date', 'tag')와 다르기 때문 + SearchChallengeCard toChallengeCard() { + return SearchChallengeCard( + base: ChallengeBase( + id: challengeId, // 'challengeId' → ChallengeBase.id + title: challengeTitle, // 'challengeTitle' → ChallengeBase.title + ), + participantCount: + participantCount, // 'participantCount' → SearchChallengeCard.participantCount + dDay: remainingDays, + tags: tags, // 'tags' → SearchChallengeCard.tags + ); + } +} + +// 챌린지 초대 카드 모델 (User + SearchChallengeCard) +class InviteChallengecard { + final SearchChallengeCard challengeInfo; // 초대된 챌린지의 상세정보 + final User inviterUser; // 초대한 유저의 정보 (ID, 프로필 URL, 닉네임 포함) + + InviteChallengecard({required this.challengeInfo, required this.inviterUser}); + + /// InviteResponse를 InviteChallengecard로 변환하는 팩토리 생성자 + factory InviteChallengecard.fromResponse(InviteResponse response) { + return InviteChallengecard( + inviterUser: response.toUser(), + challengeInfo: response.toChallengeCard(), + ); + } + + // JSON에서 바로 변환하는 팩토리 생성자 (InviteResponse를 거쳐 변환) + factory InviteChallengecard.fromJson(Map json) { + return InviteChallengecard.fromResponse(InviteResponse.fromJson(json)); + } +} diff --git a/lib/features/notification/model/notification_model.dart b/lib/features/notification/models/notification_model.dart similarity index 95% rename from lib/features/notification/model/notification_model.dart rename to lib/features/notification/models/notification_model.dart index 1d3c5d1..6222f21 100644 --- a/lib/features/notification/model/notification_model.dart +++ b/lib/features/notification/models/notification_model.dart @@ -1,5 +1,5 @@ // 최초 작성자: 정승빈 -// 알림 데이터 모델 (enum으로 알림 타입 구분) +// 알림 데이터 모델 class NotificationModel { final String message; final String type; @@ -53,6 +53,10 @@ class NotificationModel { } } +/* +@Deprecated( + 'notification/models/challenge_invite_card내에 ChallengeInviteCard 대신 사용', +) // 챌린지 초대 목록 아이템 모델 class ChallengeInviteModel { final int challengeId; @@ -90,3 +94,4 @@ class ChallengeInviteModel { ); } } +*/ diff --git a/lib/features/notification/models/notification_state.dart b/lib/features/notification/models/notification_state.dart new file mode 100644 index 0000000..ca0815d --- /dev/null +++ b/lib/features/notification/models/notification_state.dart @@ -0,0 +1,37 @@ +import './notification_model.dart'; + +// 최초 작성자: 정승빈 +// 리팩토링: 강선욱 + +// 상태 클래스 정의 +class NotificationState { + final List notifications; + final bool isLoading; + final bool isFetchingMore; // 추가 페이징 로딩 중 + final bool hasMore; // 다음 페이지 존재 여부 + final int currentPage; + + NotificationState({ + required this.notifications, + this.isLoading = false, + this.isFetchingMore = false, + this.hasMore = true, + this.currentPage = 0, + }); + + NotificationState copyWith({ + List? notifications, + bool? isLoading, + bool? isFetchingMore, + bool? hasMore, + int? currentPage, + }) { + return NotificationState( + notifications: notifications ?? this.notifications, + isLoading: isLoading ?? this.isLoading, + isFetchingMore: isFetchingMore ?? this.isFetchingMore, + hasMore: hasMore ?? this.hasMore, + currentPage: currentPage ?? this.currentPage, + ); + } +} diff --git a/lib/features/notification/models/push_notification_settings_dto.dart b/lib/features/notification/models/push_notification_settings_dto.dart new file mode 100644 index 0000000..2b475b7 --- /dev/null +++ b/lib/features/notification/models/push_notification_settings_dto.dart @@ -0,0 +1,43 @@ +// 최초 작성자: 김채영 + +class PushNotificationSettingsDto { + final bool allPushNotificationEnabled; + final bool friendPushNotificationEnabled; + final bool allLikesPushNotificationEnabled; + final bool allCommentsPushNotificationEnabled; + final bool allMemberCertificationPushNotificationEnabled; + final bool challengeInvitePushNotificationEnabled; + final bool motivationPushNotificationEnabled; + final bool dailyReminderPushNotificationEnabled; + + const PushNotificationSettingsDto({ + required this.allPushNotificationEnabled, + required this.friendPushNotificationEnabled, + required this.allLikesPushNotificationEnabled, + required this.allCommentsPushNotificationEnabled, + required this.allMemberCertificationPushNotificationEnabled, + required this.challengeInvitePushNotificationEnabled, + required this.motivationPushNotificationEnabled, + required this.dailyReminderPushNotificationEnabled, + }); + + factory PushNotificationSettingsDto.fromJson(Map json) { + return PushNotificationSettingsDto( + allPushNotificationEnabled: json['allPushNotificationEnabled'] ?? false, + friendPushNotificationEnabled: + json['friendPushNotificationEnabled'] ?? false, + allLikesPushNotificationEnabled: + json['allLikesPushNotificationEnabled'] ?? false, + allCommentsPushNotificationEnabled: + json['allCommentsPushNotificationEnabled'] ?? false, + allMemberCertificationPushNotificationEnabled: + json['allMemberCertificationPushNotificationEnabled'] ?? false, + challengeInvitePushNotificationEnabled: + json['challengeInvitePushNotificationEnabled'] ?? false, + motivationPushNotificationEnabled: + json['motivationPushNotificationEnabled'] ?? false, + dailyReminderPushNotificationEnabled: + json['dailyReminderPushNotificationEnabled'] ?? false, + ); + } +} diff --git a/lib/features/notification/provider/notification_provider.dart b/lib/features/notification/provider/notification_provider.dart index 197872a..674a277 100644 --- a/lib/features/notification/provider/notification_provider.dart +++ b/lib/features/notification/provider/notification_provider.dart @@ -2,44 +2,13 @@ // 알림 목록 상태 및 탭 상태 관리 import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../data/notification_repository.dart'; -import '../model/notification_model.dart'; +import '../models/notification_model.dart'; +import '../models/notification_state.dart'; +import 'package:haenaem/features/notification/models/invite_challenge_card.dart'; // 홈 화면 새로고침이 필요한지 여부를 저장하는 스위치 (초기값: false) final needsHomeRefreshProvider = StateProvider((ref) => false); -// 상태 클래스 정의 -class NotificationState { - final List notifications; - final bool isLoading; - final bool isFetchingMore; // 추가 페이징 로딩 중 - final bool hasMore; // 다음 페이지 존재 여부 - final int currentPage; - - NotificationState({ - required this.notifications, - this.isLoading = false, - this.isFetchingMore = false, - this.hasMore = true, - this.currentPage = 0, - }); - - NotificationState copyWith({ - List? notifications, - bool? isLoading, - bool? isFetchingMore, - bool? hasMore, - int? currentPage, - }) { - return NotificationState( - notifications: notifications ?? this.notifications, - isLoading: isLoading ?? this.isLoading, - isFetchingMore: isFetchingMore ?? this.isFetchingMore, - hasMore: hasMore ?? this.hasMore, - currentPage: currentPage ?? this.currentPage, - ); - } -} - final notificationProvider = StateNotifierProvider((ref) { final repository = ref.watch(notificationRepositoryProvider); @@ -143,13 +112,13 @@ class NotificationNotifier extends StateNotifier { } class ChallengeInviteState { - final List invites; + final List invites; final bool isLoading; ChallengeInviteState({required this.invites, this.isLoading = false}); ChallengeInviteState copyWith({ - List? invites, + List? invites, bool? isLoading, }) { return ChallengeInviteState( @@ -191,7 +160,7 @@ class ChallengeInviteNotifier extends StateNotifier { // 성공하면 UI 목록에서 해당 카드 즉시 제거 state = state.copyWith( invites: state.invites - .where((i) => i.challengeId != challengeId) + .where((i) => i.challengeInfo.base.id != challengeId) .toList(), ); } catch (e) { @@ -207,7 +176,7 @@ class ChallengeInviteNotifier extends StateNotifier { // 성공하면 UI 목록에서 해당 카드 즉시 제거 state = state.copyWith( invites: state.invites - .where((i) => i.challengeId != challengeId) + .where((i) => i.challengeInfo.base.id != challengeId) .toList(), ); } catch (e) { diff --git a/lib/features/notification/provider/push_notification_provider.dart b/lib/features/notification/provider/push_notification_provider.dart index 3e510d0..d118066 100644 --- a/lib/features/notification/provider/push_notification_provider.dart +++ b/lib/features/notification/provider/push_notification_provider.dart @@ -1,124 +1,257 @@ -// 최초 작성자 : 김채영 -import 'package:flutter_riverpod/flutter_riverpod.dart'; +// 최초 작성자: 강선욱 import 'package:flutter/foundation.dart'; -import 'package:haenaem/features/notification/services/fcm_service.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:haenaem/features/notification/data/push_notification_repository.dart'; +import 'package:haenaem/features/notification/models/push_notification_settings_dto.dart'; + +// ── 상태 모델 ───────────────────────────────────────────── class PushNotificationSettings { final bool allNotifications; + final bool friendRequestNotifications; final bool likeNotifications; final bool commentNotifications; - final bool friendRequestNotifications; + final bool memberAuthNotifications; final bool challengeInviteNotifications; final bool motivationNotifications; - final bool dailyReminder; // 일일 리마인더 필드 추가 - final bool memberAuthNotifications; + final bool dailyReminder; + final String weeklyReminderTime; - PushNotificationSettings({ - this.allNotifications = true, - this.likeNotifications = true, - this.commentNotifications = true, + const PushNotificationSettings({ + this.allNotifications = false, this.friendRequestNotifications = false, - this.challengeInviteNotifications = true, - this.motivationNotifications = true, - this.dailyReminder = true, // 기본값 true + this.likeNotifications = false, + this.commentNotifications = false, this.memberAuthNotifications = false, + this.challengeInviteNotifications = false, + this.motivationNotifications = false, + this.dailyReminder = false, + this.weeklyReminderTime = '21:00', }); PushNotificationSettings copyWith({ bool? allNotifications, + bool? friendRequestNotifications, bool? likeNotifications, bool? commentNotifications, - bool? friendRequestNotifications, + bool? memberAuthNotifications, bool? challengeInviteNotifications, bool? motivationNotifications, bool? dailyReminder, - bool? memberAuthNotifications, + String? reminderTime, + bool? weeklyReminder, + String? weeklyReminderTime, }) { return PushNotificationSettings( allNotifications: allNotifications ?? this.allNotifications, - likeNotifications: likeNotifications ?? this.likeNotifications, - commentNotifications: commentNotifications ?? this.commentNotifications, friendRequestNotifications: friendRequestNotifications ?? this.friendRequestNotifications, + likeNotifications: likeNotifications ?? this.likeNotifications, + commentNotifications: commentNotifications ?? this.commentNotifications, + memberAuthNotifications: + memberAuthNotifications ?? this.memberAuthNotifications, challengeInviteNotifications: challengeInviteNotifications ?? this.challengeInviteNotifications, motivationNotifications: motivationNotifications ?? this.motivationNotifications, dailyReminder: dailyReminder ?? this.dailyReminder, - memberAuthNotifications: - memberAuthNotifications ?? this.memberAuthNotifications, + weeklyReminderTime: weeklyReminderTime ?? this.weeklyReminderTime, ); } } +// ── Notifier ────────────────────────────────────────────── + class PushNotificationSettingsNotifier extends StateNotifier { - final Ref ref; // 1. Ref 추가 (서비스 호출을 위해) - - PushNotificationSettingsNotifier(this.ref) - : super(PushNotificationSettings()); - - void toggle(String key, bool value) async { - if (key == 'all') { - // ✅ 서버 API 호출 - final success = await ref - .read(fcmServiceProvider) - .updateAllNotificationStatus(value); - - if (success) { - // 성공 시 모든 스위치 상태 변경 - state = PushNotificationSettings( - allNotifications: value, - likeNotifications: value, - commentNotifications: value, - friendRequestNotifications: value, - challengeInviteNotifications: value, - motivationNotifications: value, - dailyReminder: value, - memberAuthNotifications: value, - ); - } else { - // 실패 시 에러 처리 (필요시 토스트 메시지 등 추가) - debugPrint("전체 알림 설정 변경에 실패했습니다."); - } - } else { - // 개별 알림 로직 (나중에 개별 API 생기면 여기에 추가) - switch (key) { - case 'like': - state = state.copyWith(likeNotifications: value); - break; - case 'comment': - state = state.copyWith(commentNotifications: value); - break; - case 'friend': - state = state.copyWith(friendRequestNotifications: value); - break; - case 'invite': - state = state.copyWith(challengeInviteNotifications: value); - break; - case 'motivation': - state = state.copyWith(motivationNotifications: value); - break; - case 'reminder': - state = state.copyWith(dailyReminder: value); - break; - case 'memberAuth': - state = state.copyWith(memberAuthNotifications: value); - break; - } - - if (value == false) { - state = state.copyWith(allNotifications: false); - } + final Ref _ref; + + PushNotificationSettingsNotifier(this._ref) + : super(const PushNotificationSettings()) { + _loadInitialSettings(); + } + + PushNotificationRepository get _repo => + _ref.read(pushNotificationRepositoryProvider); + + // ── 초기 로드 ───────────────────────────────────────── + + Future _loadInitialSettings() async { + try { + final results = await Future.wait([ + _repo.getNotificationSettings(), + _repo.getWeeklyReminderTime(), // 실패 방지 리마인더 시간 + ]); + + final dto = results[0] as PushNotificationSettingsDto; + final weeklyReminderTimeStr = results[1] as String?; + + // ✅ 임시 로그 + debugPrint('===== 🔔 푸시 알림 설정 조회 결과 ====='); + debugPrint('🔔 전체 알림: ${dto.allPushNotificationEnabled}'); + debugPrint('🔔 친구 알림: ${dto.friendPushNotificationEnabled}'); + debugPrint('🔔 좋아요: ${dto.allLikesPushNotificationEnabled}'); + debugPrint('🔔 댓글: ${dto.allCommentsPushNotificationEnabled}'); + debugPrint( + '🔔 멤버 인증: ${dto.allMemberCertificationPushNotificationEnabled}', + ); + debugPrint('🔔 챌린지 초대: ${dto.challengeInvitePushNotificationEnabled}'); + debugPrint('🔔 동기부여: ${dto.motivationPushNotificationEnabled}'); + debugPrint('🔔 일일 리마인더: ${dto.dailyReminderPushNotificationEnabled}'); + debugPrint('🔔 실패 방지 리마인더 시간: $weeklyReminderTimeStr'); + debugPrint('======================================='); + + state = PushNotificationSettings( + allNotifications: dto.allPushNotificationEnabled, + friendRequestNotifications: dto.friendPushNotificationEnabled, + likeNotifications: dto.allLikesPushNotificationEnabled, + commentNotifications: dto.allCommentsPushNotificationEnabled, + memberAuthNotifications: + dto.allMemberCertificationPushNotificationEnabled, + challengeInviteNotifications: + dto.challengeInvitePushNotificationEnabled, + motivationNotifications: dto.motivationPushNotificationEnabled, + dailyReminder: dto.dailyReminderPushNotificationEnabled, + weeklyReminderTime: weeklyReminderTimeStr ?? '21:00', + ); + } catch (e) { + debugPrint('알림 설정 초기 로드 실패: $e'); + } + } + + /// DTO → 상태 반영 (전체 알림 토글 시에도 재사용) + void _applyDto(PushNotificationSettingsDto dto) { + state = state.copyWith( + allNotifications: dto.allPushNotificationEnabled, + friendRequestNotifications: dto.friendPushNotificationEnabled, + likeNotifications: dto.allLikesPushNotificationEnabled, + commentNotifications: dto.allCommentsPushNotificationEnabled, + memberAuthNotifications: + dto.allMemberCertificationPushNotificationEnabled, + challengeInviteNotifications: dto.challengeInvitePushNotificationEnabled, + motivationNotifications: dto.motivationPushNotificationEnabled, + dailyReminder: dto.dailyReminderPushNotificationEnabled, + ); + } + + // ── 전체 알림 토글 ──────────────────────────────────── + + Future toggleAll(bool value) async { + final success = await _repo.setAllNotification(value); + if (!success) return; + + // 서버가 모든 항목을 일괄 변경하므로 재조회해서 상태 동기화 + try { + final dto = await _repo.getNotificationSettings(); + _applyDto(dto); + } catch (e) { + // 재조회 실패 시 로컬에서 일괄 적용 + debugPrint('전체 알림 설정 후 재조회 실패, 로컬 반영: $e'); + state = state.copyWith( + allNotifications: value, + friendRequestNotifications: value, + likeNotifications: value, + commentNotifications: value, + memberAuthNotifications: value, + challengeInviteNotifications: value, + motivationNotifications: value, + dailyReminder: value, + ); + } + } + + // ────────────── 소셜 알림 토글 ─────────────── + /// 좋아요 알림 토글 + Future toggleLikes(bool value) async { + final success = await _repo.setAllLikesNotification(value); + if (success) { + state = state.copyWith( + likeNotifications: value, + allNotifications: value ? state.allNotifications : false, + ); + } + } + + /// 댓글 알림 토글 + Future toggleComments(bool value) async { + final success = await _repo.setAllCommentsNotification(value); + if (success) { + state = state.copyWith( + commentNotifications: value, + allNotifications: value ? state.allNotifications : false, + ); } } + + /// 친구 알림 토글 + Future toggleFriend(bool value) async { + final success = await _repo.setFriendNotification(value); + if (success) { + state = state.copyWith( + friendRequestNotifications: value, + allNotifications: value ? state.allNotifications : false, + ); + } + } + + /// 멤버 인증 알림 토글 + Future toggleMemberAuth(bool value) async { + final success = await _repo.setAllMemberCertificationNotification(value); + if (success) { + state = state.copyWith( + memberAuthNotifications: value, + allNotifications: value ? state.allNotifications : false, + ); + } + } + + // ────────────── 챌린지 알림 토글 ─────────────── + + /// 챌린지 초대 알림 토글 + Future toggleChallengeInvite(bool value) async { + final success = await _repo.setChallengeInviteNotification(value); + if (success) { + state = state.copyWith( + challengeInviteNotifications: value, + allNotifications: value ? state.allNotifications : false, + ); + } + } + + /// 동기부여 메시지 알림 토글 + Future toggleMotivation(bool value) async { + final success = await _repo.setMotivationNotification(value); + if (success) { + state = state.copyWith( + motivationNotifications: value, + allNotifications: value ? state.allNotifications : false, + ); + } + } + + /// 일일 리마인더 토글 (시간 설정 API 없음) + Future toggleDailyReminder(bool value) async { + final success = await _repo.setDailyReminderNotification(value); + if (success) { + state = state.copyWith( + dailyReminder: value, + allNotifications: value ? state.allNotifications : false, + ); + } + } + + /// 실패 방지 리마인더 시간 변경 + Future updateWeeklyReminderTime(String timeString) async { + state = state.copyWith(weeklyReminderTime: timeString); + final success = await _repo.setWeeklyReminderTime(timeString); + if (!success) debugPrint('실패 방지 리마인더 시간 서버 저장 실패'); + } } -// 2. 프로바이더 정의 수정 +// ── Provider ────────────────────────────────────────────── + final pushNotificationProvider = StateNotifierProvider< PushNotificationSettingsNotifier, PushNotificationSettings - >((ref) { - return PushNotificationSettingsNotifier(ref); // ref 전달 - }); + >((ref) => PushNotificationSettingsNotifier(ref)); diff --git a/lib/features/notification/screens/challenge_invite_detail_screen.dart b/lib/features/notification/screens/challenge_invite_detail_screen.dart index 4234353..acdbe1f 100644 --- a/lib/features/notification/screens/challenge_invite_detail_screen.dart +++ b/lib/features/notification/screens/challenge_invite_detail_screen.dart @@ -3,23 +3,25 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; - import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_typography.dart'; -import '../../challenge/provider/challenge_provider.dart'; -import '../../challenge/detail/widgets/challenge_detail_content.dart'; +// import '../../challenge/provider/challenge_provider.dart'; +import 'package:haenaem/shared/widgets/challenge_detail_content.dart'; +import 'package:haenaem/shared/provider/challenge_detail_provider.dart'; import '../../../shared/widgets/bottom_action_button.dart'; import '../provider/notification_provider.dart'; import 'package:haenaem/features/feed/widgets/enter_confirm_dialog.dart'; class ChallengeInviteDetailScreen extends ConsumerWidget { final int challengeId; + final String challengeTitle; final String inviterName; final String? inviterProfileImageUrl; const ChallengeInviteDetailScreen({ super.key, required this.challengeId, + required this.challengeTitle, required this.inviterName, this.inviterProfileImageUrl, }); @@ -129,7 +131,7 @@ class ChallengeInviteDetailScreen extends ConsumerWidget { context: context, builder: (context) => EnterConfirmDialog( challengeId: challengeId, - challengeTitle: challenge?.title ?? '챌린지', // 제목 전달 + challengeTitle: challengeTitle, ), ); } diff --git a/lib/features/notification/views/all_notifications_view.dart b/lib/features/notification/views/all_notifications_view.dart index fdfce5e..ffae915 100644 --- a/lib/features/notification/views/all_notifications_view.dart +++ b/lib/features/notification/views/all_notifications_view.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../provider/notification_provider.dart'; -import '../model/notification_model.dart'; +import '../models/notification_model.dart'; import '../widgets/notification_date_header.dart'; import '../widgets/notification_list_tile.dart'; import '../../../core/theme/app_colors.dart'; @@ -152,17 +152,20 @@ class _AllNotificationsViewState extends ConsumerState { inviterName = noti.message.split('님이').first.trim(); } - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => ChallengeInviteDetailScreen( - challengeId: noti.targetId!, - inviterName: inviterName, - inviterProfileImageUrl: noti.profileImageUrl, - ), - ), - ); - break; + // ChallengeInviteDetailScreen에서는 challengeTitle값이 필요한데 이 화면에는 존재하지 않아서 일단 주석 처리 + + // Navigator.push( + // context, + // MaterialPageRoute( + // builder: (context) => ChallengeInviteDetailScreen( + // challengeId: noti.targetId!, + // challengeTitle: , + // inviterName: inviterName, + // inviterProfileImageUrl: noti.profileImageUrl, + // ), + // ), + // ); + // break; // TODO: 챌린지 성공/실패 알림 타입 추가 시 여기에 케이스 추가 /* diff --git a/lib/features/notification/views/challenge_invites_view.dart b/lib/features/notification/views/challenge_invites_view.dart index a93d3f3..b9a11a2 100644 --- a/lib/features/notification/views/challenge_invites_view.dart +++ b/lib/features/notification/views/challenge_invites_view.dart @@ -36,17 +36,11 @@ class ChallengeInvitesView extends ConsumerWidget { final invite = state.invites[index]; return ChallengeInviteCard( - challengeId: invite.challengeId, - inviterName: invite.inviterNickname, - inviterProfileImageUrl: invite.inviterProfileImageUrl, - challengeName: invite.challengeTitle, - participantCount: invite.participantCount, - dDay: 'D-${invite.remainingDays}', - labels: invite.tags, + inviteChallenge: invite, // 수락 콜백 연결 onAccept: () async { try { - await notifier.acceptInvite(invite.challengeId); + await notifier.acceptInvite(invite.challengeInfo.base.id); ref.read(needsHomeRefreshProvider.notifier).state = true; // 홈 화면 새로고침 필요 플래그 켜기 @@ -58,7 +52,9 @@ class ChallengeInvitesView extends ConsumerWidget { // 다이얼로그(showDialog) 대신 스낵바를 띄웁니다. ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('${invite.challengeTitle} 초대를 수락했습니다.'), + content: Text( + '${invite.challengeInfo.base.title} 초대를 수락했습니다.', + ), behavior: SnackBarBehavior.floating, // 화면 아래에 살짝 떠 있는 스타일 duration: const Duration(seconds: 3), // ✨ 꿀팁: 스낵바 우측에 '이동' 버튼 추가 @@ -70,7 +66,7 @@ class ChallengeInvitesView extends ConsumerWidget { safeNavigator.push( MaterialPageRoute( builder: (context) => ChallengeMainScreen( - challengeId: invite.challengeId, + challengeId: invite.challengeInfo.base.id, ), ), ); @@ -95,7 +91,7 @@ class ChallengeInvitesView extends ConsumerWidget { // 거절 콜백 연결 onReject: () async { try { - await notifier.rejectInvite(invite.challengeId); + await notifier.rejectInvite(invite.challengeInfo.base.id); ref.read(needsHomeRefreshProvider.notifier).state = true; // 홈 화면 새로고침 필요 플래그 켜기 @@ -103,7 +99,9 @@ class ChallengeInvitesView extends ConsumerWidget { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('${invite.challengeTitle} 초대를 거절했습니다.'), + content: Text( + '${invite.challengeInfo.base.title} 초대를 거절했습니다.', + ), behavior: SnackBarBehavior.floating, // 화면 아래에 살짝 떠 있는 스타일 duration: const Duration(seconds: 3), ), diff --git a/lib/features/notification/widgets/challenge_invite_card.dart b/lib/features/notification/widgets/challenge_invite_card.dart index 7f0769c..6169414 100644 --- a/lib/features/notification/widgets/challenge_invite_card.dart +++ b/lib/features/notification/widgets/challenge_invite_card.dart @@ -4,34 +4,26 @@ import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_typography.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import '../models/invite_challenge_card.dart'; import '../screens/challenge_invite_detail_screen.dart'; class ChallengeInviteCard extends StatelessWidget { - final int challengeId; - final String inviterName; - final String? inviterProfileImageUrl; - final String challengeName; - final int participantCount; - final String dDay; - final List labels; + final InviteChallengecard inviteChallenge; final VoidCallback onAccept; // 수락 함수 final VoidCallback onReject; // 거절 함수 const ChallengeInviteCard({ super.key, - required this.challengeId, - required this.inviterName, - this.inviterProfileImageUrl, - required this.challengeName, - required this.participantCount, - required this.dDay, - required this.labels, + required this.inviteChallenge, required this.onAccept, required this.onReject, }); @override Widget build(BuildContext context) { + final challengeInfo = inviteChallenge.challengeInfo; + final inviterUser = inviteChallenge.inviterUser; + return Container( margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), // 디자인 가이드에 맞춘 패딩 적용 @@ -67,7 +59,7 @@ class ChallengeInviteCard extends StatelessWidget { _buildIconBox(), const SizedBox(width: 6), Text( - '$inviterName님의 초대', + '${inviterUser.nickname}님의 초대', style: AppTypography.b2.copyWith( color: AppColors.gray1, // 디자인 코드 참조 ), @@ -76,7 +68,7 @@ class ChallengeInviteCard extends StatelessWidget { ), ), Text( - challengeName, + challengeInfo.base.title, style: AppTypography.b3.copyWith(color: AppColors.black), overflow: TextOverflow.ellipsis, ), @@ -95,14 +87,16 @@ class ChallengeInviteCard extends StatelessWidget { ), ), Text( - '${participantCount.toString()}명', + '${challengeInfo.participantCount.toString()}명', style: AppTypography.b2.copyWith( color: AppColors.gray2, ), ), const SizedBox(width: 16), Text( - '완료까지 $dDay', + challengeInfo.dDay == 0 + ? '오늘 완료' + : '완료까지 D-${challengeInfo.dDay}', style: AppTypography.b2.copyWith( color: AppColors.gray2, ), @@ -114,7 +108,7 @@ class ChallengeInviteCard extends StatelessWidget { Wrap( spacing: 6, // 태그 사이 간격 // runSpacing: 8, // 줄 바꿈 시 간격 (필요 시 활성화) - children: labels.map((label) { + children: challengeInfo.tags.map((label) { return Container( padding: const EdgeInsets.symmetric( horizontal: 10, @@ -142,15 +136,16 @@ class ChallengeInviteCard extends StatelessWidget { alignment: Alignment.center, child: IconButton( onPressed: () { - print("====> 이동하려는 챌린지 ID: $challengeId"); + print("====> 이동하려는 챌린지 ID: ${challengeInfo.base.id}"); Navigator.push( context, MaterialPageRoute( builder: (context) => ChallengeInviteDetailScreen( - challengeId: challengeId, - inviterName: inviterName, - inviterProfileImageUrl: inviterProfileImageUrl, + challengeId: challengeInfo.base.id, + challengeTitle: challengeInfo.base.title, + inviterName: inviterUser.nickname, + inviterProfileImageUrl: inviterUser.profileUrl, ), ), ); @@ -231,20 +226,22 @@ class ChallengeInviteCard extends StatelessWidget { color: AppColors.gray5, shape: BoxShape.circle, image: - inviterProfileImageUrl != null && - inviterProfileImageUrl!.startsWith('http') + inviteChallenge.inviterUser.profileUrl != null && + inviteChallenge.inviterUser.profileUrl!.startsWith('http') ? DecorationImage( - image: NetworkImage(inviterProfileImageUrl!), + image: NetworkImage(inviteChallenge.inviterUser.profileUrl!), fit: BoxFit.cover, ) - : (inviterProfileImageUrl != null + : (inviteChallenge.inviterUser.profileUrl != null ? DecorationImage( - image: AssetImage(inviterProfileImageUrl!), + image: AssetImage( + inviteChallenge.inviterUser.profileUrl!, + ), fit: BoxFit.cover, ) : null), ), - child: inviterProfileImageUrl == null + child: inviteChallenge.inviterUser.profileUrl == null ? Center( child: SvgPicture.asset( 'assets/images/icons/default_profile_icon.svg', diff --git a/lib/features/report/data/report_repository.dart b/lib/features/report/data/report_repository.dart new file mode 100644 index 0000000..7819eb5 --- /dev/null +++ b/lib/features/report/data/report_repository.dart @@ -0,0 +1,59 @@ +// 최초 작성자: 정승빈 + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:haenaem/core/network/dio_provider.dart'; + +// Repository Provider +final reportRepositoryProvider = Provider((ref) { + return ReportRepository(ref.watch(dioProvider)); +}); + +class ReportRepository { + final Dio _dio; + + ReportRepository(this._dio); + + // 인증글 신고 API + Future reportArticle({ + required int articleId, + required String reportReason, + required String detailReason, + }) async { + try { + await _dio.post( + '/api/articles/$articleId/report', + data: {'reportReason': reportReason, 'detailReason': detailReason}, + ); + } on DioException catch (e) { + debugPrint('---------- [인증글 신고 실패] ----------'); + debugPrint('대상 ID: $articleId'); + debugPrint('상태 코드: ${e.response?.statusCode}'); + debugPrint('에러 데이터: ${e.response?.data}'); + debugPrint('------------------------------------'); + rethrow; + } + } + + // 댓글 신고 API + Future reportComment({ + required int commentId, + required String reportReason, + required String detailReason, + }) async { + try { + await _dio.post( + '/api/comments/$commentId/report', + data: {'reportReason': reportReason, 'detailReason': detailReason}, + ); + } on DioException catch (e) { + debugPrint('---------- [댓글 신고 실패] ----------'); + debugPrint('대상 ID: $commentId'); + debugPrint('상태 코드: ${e.response?.statusCode}'); + debugPrint('에러 데이터: ${e.response?.data}'); + debugPrint('------------------------------------'); + rethrow; + } + } +} diff --git a/lib/features/report/provider/report_provider.dart b/lib/features/report/provider/report_provider.dart new file mode 100644 index 0000000..3eff236 --- /dev/null +++ b/lib/features/report/provider/report_provider.dart @@ -0,0 +1,60 @@ +// 최초 작성자: 정승빈 + +import 'package:flutter/foundation.dart'; +import 'package:dio/dio.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../data/report_repository.dart'; + +part 'report_provider.g.dart'; + +enum ReportTargetType { article, comment } + +@riverpod +class ReportController extends _$ReportController { + @override + FutureOr build() {} + + Future submitReport({ + required ReportTargetType targetType, + required int targetId, + required String reportReason, + required String detailReason, + }) async { + // state = const AsyncValue.loading(); + + try { + if (targetType == ReportTargetType.article) { + await ref + .read(reportRepositoryProvider) + .reportArticle( + articleId: targetId, + reportReason: reportReason, + detailReason: detailReason, + ); + } else { + await ref + .read(reportRepositoryProvider) + .reportComment( + commentId: targetId, + reportReason: reportReason, + detailReason: detailReason, + ); + } + + // state = const AsyncValue.data(null); + return true; + } catch (e) { + if (e is DioException) { + debugPrint('---------- [신고 컨트롤러 오류] ----------'); + debugPrint('타입: $targetType, ID: $targetId'); + debugPrint('서버 응답: ${e.response?.data}'); + debugPrint('---------------------------------------'); + } else { + debugPrint('신고 처리 중 알 수 없는 에러: $e'); + } + + // state = AsyncValue.error(e, stack); + return false; + } + } +} diff --git a/lib/features/report/provider/report_provider.g.dart b/lib/features/report/provider/report_provider.g.dart new file mode 100644 index 0000000..c9932e0 --- /dev/null +++ b/lib/features/report/provider/report_provider.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'report_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$reportControllerHash() => r'08f5ab8a47d854b8d27160fa05ebd773d887910e'; + +/// See also [ReportController]. +@ProviderFor(ReportController) +final reportControllerProvider = + AutoDisposeAsyncNotifierProvider.internal( + ReportController.new, + name: r'reportControllerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$reportControllerHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$ReportController = AutoDisposeAsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/report/screens/report_screen.dart b/lib/features/report/screens/report_screen.dart new file mode 100644 index 0000000..85311d2 --- /dev/null +++ b/lib/features/report/screens/report_screen.dart @@ -0,0 +1,150 @@ +// 최초 작성자: 정승빈 + +import 'package:flutter/material.dart'; +import 'package:haenaem/core/theme/app_colors.dart'; +import 'package:haenaem/core/theme/app_typography.dart'; +import 'package:haenaem/shared/widgets/bottom_action_button.dart'; +import '../widgets/report_reason_tile.dart'; +import 'report_success_screen.dart'; +import 'package:haenaem/features/report/provider/report_provider.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class ReportScreen extends ConsumerStatefulWidget { + final ReportTargetType targetType; + final int targetId; + + const ReportScreen({ + super.key, + required this.targetType, + required this.targetId, + }); + @override + ConsumerState createState() => _ReportScreenState(); +} + +class _ReportScreenState extends ConsumerState { + int? _selectedReasonIndex; + final TextEditingController _otherReasonController = TextEditingController(); + + // 백엔드 API 명세에 맞게 keys 값을 꼭 수정해주세요! + final List> _reasons = [ + {'key': 'SPAM', 'title': '영리목적/홍보성', 'desc': '상업적 광고, 도배성 게시글, 링크 유도 등'}, + { + 'key': 'ABUSE', + 'title': '욕설/비하 발언', + 'desc': '특정 개인이나 집단에 대한 혐오, 비하, 욕설 포함', + }, + { + 'key': 'INAPPROPRIATE', + 'title': '부적절한 콘텐츠', + 'desc': '음란물, 폭력적 내용, 불법 정보 포함', + }, + {'key': 'PRIVACY', 'title': '개인정보 노출', 'desc': '타인의 연락처, 주소 등 민감한 정보 공유'}, + { + 'key': 'IMPERSONATION', + 'title': '명의 도용/사칭', + 'desc': '타인을 사칭하거나 저작권을 침해하는 이미지 사용', + }, + {'key': 'ETC', 'title': '기타 (직접 입력)', 'desc': '위 항목에 해당하지 않는 구체적인 사유'}, + ]; + + @override + void dispose() { + _otherReasonController.dispose(); + super.dispose(); + } + + Future _submitReport() async { + if (_selectedReasonIndex == null) return; + FocusManager.instance.primaryFocus?.unfocus(); + + final selectedKey = _reasons[_selectedReasonIndex!]['key']!; + final detailReason = _selectedReasonIndex == 5 + ? _otherReasonController.text + : ''; + + // 바뀐 부분: 모델 객체 생성 없이 파라미터로 바로 전달 + final success = await ref + .read(reportControllerProvider.notifier) + .submitReport( + targetType: widget.targetType, + targetId: widget.targetId, + reportReason: selectedKey, + detailReason: detailReason, + ); + + if (success && mounted) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (context) => const ReportSuccessScreen()), + ); + } else if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('신고 접수에 실패했습니다. 다시 시도해주세요.')), + ); + } + } + + @override + Widget build(BuildContext context) { + // 항목이 하나라도 선택되었는지 여부로 하단 버튼 활성화 상태 결정 + final bool isButtonActive = _selectedReasonIndex != null; + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: AppColors.black), + onPressed: () => Navigator.of(context).pop(), + ), + title: const Text('신고하기', style: AppTypography.h3), + ), + body: Column( + children: [ + // 상단 안내 문구 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), + child: Text( + '더 깨끗한 \'해냄\'을 위해 부적절한 콘텐츠를 알려주세요.\n신고하신 내용은 운영 정책에 따라 검토 후 조치됩니다.', + textAlign: TextAlign.center, + style: AppTypography.b2.copyWith(color: AppColors.gray2), + ), + ), + + // 신고 사유 리스트 + Expanded( + child: ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + itemCount: _reasons.length, + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final isSelected = _selectedReasonIndex == index; + final isOtherOption = index == _reasons.length - 1; + + return ReportReasonTile( + title: _reasons[index]['title']!, + subtitle: _reasons[index]['desc']!, + isSelected: isSelected, + isOtherOption: isOtherOption, + textController: _otherReasonController, + onTap: () { + setState(() { + _selectedReasonIndex = index; + }); + }, + ); + }, + ), + ), + ], + ), + + // 하단 고정 버튼 + bottomNavigationBar: BottomActionButton( + text: '신고하기', + backgroundColor: isButtonActive + ? AppColors.primaryAble + : AppColors.disable, + onPressed: isButtonActive ? _submitReport : () {}, + ), + ); + } +} diff --git a/lib/features/report/screens/report_success_screen.dart b/lib/features/report/screens/report_success_screen.dart new file mode 100644 index 0000000..2778e33 --- /dev/null +++ b/lib/features/report/screens/report_success_screen.dart @@ -0,0 +1,72 @@ +// 최초 작성자: 정승빈 + +import 'package:flutter/material.dart'; +import 'package:haenaem/core/theme/app_colors.dart'; +import 'package:haenaem/core/theme/app_typography.dart'; +import 'package:haenaem/shared/widgets/bottom_action_button.dart'; + +class ReportSuccessScreen extends StatelessWidget { + const ReportSuccessScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Column( + children: [ + Expanded( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 초록색 체크 원형 아이콘 + Container( + width: 84, + height: 84, + decoration: const BoxDecoration( + color: AppColors.primaryAble, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check_rounded, + color: Colors.white, + size: 48, + ), + ), + const SizedBox(height: 24), + + // 완료 안내 제목 + const Text( + '신고가 접수되었습니다.', + textAlign: TextAlign.center, + style: AppTypography.h1, // 24px, Bold + ), + const SizedBox(height: 12), + + // 완료 안내 부가 설명 + Text( + '검토 결과에 따라 적절한 조치가 취해질 예정이며,\n깨끗한 \'해냄\'을 위해 항상 노력하겠습니다.', + textAlign: TextAlign.center, + style: AppTypography.b3.copyWith( + color: AppColors.gray1, + ), // 16px, SemiBold + ), + ], + ), + ), + ), + ], + ), + ), + // 하단 '확인' 버튼 + bottomNavigationBar: BottomActionButton( + text: '확인', + onPressed: () { + // '확인' 버튼을 누르면 이 화면을 닫고 피드(또는 댓글) 화면으로 돌아갑니다. + Navigator.of(context).pop(); + }, + ), + ); + } +} diff --git a/lib/features/report/widgets/report_reason_tile.dart b/lib/features/report/widgets/report_reason_tile.dart new file mode 100644 index 0000000..2a7e062 --- /dev/null +++ b/lib/features/report/widgets/report_reason_tile.dart @@ -0,0 +1,109 @@ +// 최초 작성자: 정승빈 + +import 'package:flutter/material.dart'; +import 'package:haenaem/core/theme/app_colors.dart'; +import 'package:haenaem/core/theme/app_typography.dart'; + +class ReportReasonTile extends StatelessWidget { + final String title; + final String subtitle; + final bool isSelected; + final bool isOtherOption; + final VoidCallback onTap; + final TextEditingController textController; + + const ReportReasonTile({ + super.key, + required this.title, + required this.subtitle, + required this.isSelected, + required this.isOtherOption, + required this.onTap, + required this.textController, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + // color: isSelected ? AppColors.selected : AppColors.gray5, + color: AppColors.gray5, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: AppTypography.b3.copyWith( + color: AppColors.gray1, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: AppTypography.b2.copyWith( + color: AppColors.gray3, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + // 커스텀 라디오 버튼 아이콘 처리 + Icon( + isSelected + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + color: isSelected ? AppColors.primaryAble : AppColors.gray4, + size: 24, + ), + ], + ), + + // '기타 (직접 입력)' 항목이 선택되었을 때만 노출되는 텍스트 입력창 + if (isOtherOption && isSelected) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + // color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.gray4), + ), + child: TextField( + controller: textController, + maxLines: 3, + style: AppTypography.b2.copyWith(color: AppColors.black), + decoration: InputDecoration( + hintText: '예: 챌린지 주제와 상관없는 사진을 반복적으로 올립니다.', + hintStyle: AppTypography.b2.copyWith( + color: AppColors.gray3, + ), + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/social/data/social_repository.dart b/lib/features/social/data/social_repository.dart index 9bbf312..8d818a2 100644 --- a/lib/features/social/data/social_repository.dart +++ b/lib/features/social/data/social_repository.dart @@ -1,52 +1,19 @@ -/// 최초 작성자: 정승빈 -library; - +// 최초 작성자: 정승빈 import 'package:dio/dio.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../model/social_model.dart'; -import '../../auth/services/auth_service.dart'; import 'package:flutter/material.dart'; +import 'package:haenaem/core/network/dio_provider.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; -// Dio Provider with Interceptor for adding Authorization header -final dioProvider = Provider((ref) { - final dio = Dio( - BaseOptions( - baseUrl: 'https://hanaem.onrender.com', // 서버 주소 - //baseUrl: 'https://ungenially-undebatable-sindy.ngrok-free.dev', - connectTimeout: const Duration(seconds: 5), - //headers: {'ngrok-skip-browser-warning': 'true'}, - ), - ); - - // 요청 인터셉터 추가 - dio.interceptors.add( - InterceptorsWrapper( - onRequest: (options, handler) async { - // 1. 저장소에서 액세스 토큰 읽기 - final String? accessToken = await AuthService.getAccessToken(); - debugPrint("🔍 저장소에서 꺼낸 토큰: $accessToken"); // 이 값이 null인지 확인! - - // 2. 토큰이 있다면 헤더에 Bearer 토큰 주입 - if (accessToken != null) { - options.headers['Authorization'] = 'Bearer $accessToken'; - debugPrint("🔑 API 요청에 토큰 주입 완료"); - } - - return handler.next(options); // 다음 단계로 진행 - }, - onError: (DioException e, handler) async { - // 만약 401 에러가 나면 여기서 refreshTokens()를 호출하는 로직을 추가할 수도 있습니다. - return handler.next(e); - }, - ), - ); - - return dio; -}); +// 새 모델들 Import +import '../../../shared/models/user.dart'; +import '../models/user_search_card.dart'; +import '../models/friend_request_card.dart'; -// Repository Provider +// Dio Provider with Interceptor for adding Authorization header final socialRepositoryProvider = Provider((ref) { - return SocialRepository(ref.watch(dioProvider)); + // [리팩토링] 자체 정의한 dioProvider 대신 공통 dioProvider를 watch 합니다. + final dio = ref.watch(dioProvider); + return SocialRepository(dio); }); class SocialRepository { @@ -54,22 +21,38 @@ class SocialRepository { SocialRepository(this._dio); // 1. 친구 목록 조회 (GET /api/users/friend/list) - Future> getFriendList() async { + Future> getFriendList() async { final response = await _dio.get('/api/users/friend/list'); // 응답 데이터가 null일 경우를 대비해 빈 리스트 처리를 추가합니다. - return (response.data as List?)?.map((e) => Friend.fromJson(e)).toList() ?? + return (response.data as List?)?.map((e) => User.fromJson(e)).toList() ?? []; } // 2. 유저 검색 (GET /api/users/search) - Future> searchUsers(String nickname) async { + Future> searchUsers(String nickname) async { final response = await _dio.get( '/api/users/search', queryParameters: {'nickname': nickname}, ); - return (response.data as List?) - ?.map((e) => SearchResultUser.fromSearchJson(e)) - .toList() ?? + debugPrint('검색 유저 응답: ${response.data.toString()}'); + return (response.data as List?)?.map((e) { + // 서버의 relationshipStatus를 FriendState enum으로 변환 + FriendState state = FriendState.stranger; + if (e['relationshipStatus'] == 'FRIEND') { + state = FriendState.friend; + } else if (e['relationshipStatus'] == 'PENDING_SENT') { + state = FriendState.pending; + } + + return UserSearchCard( + user: User.fromJson({ + 'id': e['userId'], + 'nickname': e['nickname'], + 'profileImageUrl': e['profileImageUrl'], + }), + state: state, + ); + }).toList() ?? []; } @@ -80,12 +63,24 @@ class SocialRepository { } // 4. 보낸 신청 목록 조회 (GET /api/users/friend/request/sent) - Future> getSentRequests() async { + Future> getSentRequests() async { // Swagger operationId: getSentRequests final response = await _dio.get('/api/users/friend/request/sent'); - return (response.data as List?) - ?.map((e) => SearchResultUser.fromSentJson(e)) - .toList() ?? + debugPrint(response.data.toString()); + + return (response.data as List?)?.map((e) { + return FriendRequestCard( + user: User.fromJson({ + 'id': e['userId'] ?? 0, // 보낸 대상의 id (서버 응답 확인 필요) + 'nickname': e['nickname'], + 'profileImageUrl': e['profileImageUrl'], + }), + requestId: e['requestId'], + requestDate: e['createdAt'] != null + ? DateTime.parse(e['createdAt']) + : DateTime.now(), + ); + }).toList() ?? []; } @@ -96,12 +91,24 @@ class SocialRepository { } // 6. 받은 신청 목록 조회 (GET /api/users/friend/request/received) - Future> getReceivedRequests() async { + Future> getReceivedRequests() async { // Swagger operationId: getReceivedRequests final response = await _dio.get('/api/users/friend/request/received'); - return (response.data as List?) - ?.map((e) => ReceivedRequest.fromJson(e)) - .toList() ?? + debugPrint(response.data.toString()); + + return (response.data as List?)?.map((e) { + return FriendRequestCard( + user: User.fromJson({ + 'id': e['fromUserId'], + 'nickname': e['nickname'], + 'profileImageUrl': e['profileImageUrl'], + }), + requestId: e['requestId'], + requestDate: e['createdAt'] != null + ? DateTime.parse(e['createdAt']) + : DateTime.now(), + ); + }).toList() ?? []; } @@ -123,17 +130,3 @@ class SocialRepository { await _dio.delete('/api/users/friend/delete/$nickname'); } } - -// 1. 친구 목록을 서버에서 가져오는 FutureProvider -// SocialScreen에서 ref.watch(friendListProvider)로 사용합니다. -final friendListProvider = FutureProvider>((ref) async { - final repo = ref.watch(socialRepositoryProvider); - return await repo.getFriendList(); // GET /api/users/friend/list 호출 -}); - -// 2. (옵션) 보낸/받은 요청 목록도 Provider로 관리하면 화면 갱신이 더 편해집니다. -final receivedRequestsProvider = FutureProvider>(( - ref, -) async { - return await ref.watch(socialRepositoryProvider).getReceivedRequests(); -}); diff --git a/lib/features/social/models/friend_request_card.dart b/lib/features/social/models/friend_request_card.dart new file mode 100644 index 0000000..773b4c7 --- /dev/null +++ b/lib/features/social/models/friend_request_card.dart @@ -0,0 +1,36 @@ +import 'package:haenaem/shared/models/user.dart'; + +// 최초 작성자: 강선욱 +// 친구 요청 카드 모델 +// User에 정의된 필드(id, profileUrl, nickname)를 재사용 +class FriendRequestCard { + final User user; // 요청자 정보 (id, profileUrl, nickname) + final int requestId; // 친구 요청 id + final DateTime requestDate; // 친구 요청 날짜 + + const FriendRequestCard({ + required this.user, + required this.requestId, + required this.requestDate, + }); + + factory FriendRequestCard.fromJson(Map json) { + return FriendRequestCard( + user: User.fromJson(json['user'] as Map), + requestId: json['request_id'] as int, + requestDate: DateTime.parse(json['request_date'] as String), + ); + } + + FriendRequestCard copyWith({ + User? user, + int? requestId, + DateTime? requestDate, + }) { + return FriendRequestCard( + user: user ?? this.user, + requestId: requestId ?? this.requestId, + requestDate: requestDate ?? this.requestDate, + ); + } +} diff --git a/lib/features/social/model/social_model.dart b/lib/features/social/models/social_model.dart similarity index 92% rename from lib/features/social/model/social_model.dart rename to lib/features/social/models/social_model.dart index 953f796..3fd0a0c 100644 --- a/lib/features/social/model/social_model.dart +++ b/lib/features/social/models/social_model.dart @@ -1,9 +1,11 @@ /// 최초 작성자: 정승빈 +/* library; import 'package:flutter/material.dart'; /// 클래스의 용도: 친구 정보를 관리하는 데이터 모델 +@Deprecated('shared/models/user.dart에 User 모델 대신 사용') class Friend { final int id; final String nickname; @@ -29,6 +31,7 @@ class Friend { } /// 클래스의 용도: 검색 결과 유저 정보 및 요청 상태를 저장하는 모델 +@Deprecated('social/models/user_search_card.dart에 UserSearchCard 모델 대신 사용') class SearchResultUser { final int? userId; // 검색 시 사용 final int? requestId; // 요청 취소 시 사용 @@ -81,6 +84,9 @@ class SearchResultUser { } /// 클래스의 용도: 받은 친구 요청의 상세 정보를 관리하는 데이터 모델 +@Deprecated( + 'social/models/friend_request_card.dart에 FriendRequestCard 모델 대신 사용', +) class ReceivedRequest { final int requestId; final int fromUserId; @@ -110,3 +116,4 @@ class ReceivedRequest { ); } } +*/ diff --git a/lib/features/social/models/user_search_card.dart b/lib/features/social/models/user_search_card.dart new file mode 100644 index 0000000..fc7140c --- /dev/null +++ b/lib/features/social/models/user_search_card.dart @@ -0,0 +1,30 @@ +import 'package:haenaem/shared/models/user.dart'; + +// 최초 작성자: 강선욱 +// 친구 상태 관리 +enum FriendState { + friend, // 친구 + pending, // 대기중 + stranger, // 비친구 +} + +// 최초 작성자: 강선욱 +// 소셜 화면 사용자 검색 카드 모델 +// User에 정의된 필드(id, profileUrl, nickname)를 재사용 +class UserSearchCard { + final User user; // 사용자 정보 (id, profileUrl, nickname) + final FriendState state; // 현재 로그인 유저와의 상태 + + const UserSearchCard({required this.user, required this.state}); + + factory UserSearchCard.fromJson(Map json) { + return UserSearchCard( + user: User.fromJson(json['user'] as Map), + state: FriendState.values.byName(json['state'] as String), + ); + } + + UserSearchCard copyWith({User? user, FriendState? state}) { + return UserSearchCard(user: user ?? this.user, state: state ?? this.state); + } +} diff --git a/lib/features/social/provider/friend_list_provider.dart b/lib/features/social/provider/friend_list_provider.dart new file mode 100644 index 0000000..5b70236 --- /dev/null +++ b/lib/features/social/provider/friend_list_provider.dart @@ -0,0 +1,28 @@ +// 최초 작성자: 정승빈 +// 친구 목록 상태 및 편집 로직 +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../data/social_repository.dart'; +import '../../../shared/models/user.dart'; + +part 'friend_list_provider.g.dart'; + +@riverpod +class FriendList extends _$FriendList { + @override + Future> build() async { + // 초기 친구 목록 불러오기 + return ref.watch(socialRepositoryProvider).getFriendList(); + } + + // 친구 삭제 로직 + Future removeFriend(String nickname) async { + try { + await ref.read(socialRepositoryProvider).deleteFriend(nickname); + // 삭제 성공 시, 서버에서 목록을 다시 불러와서 UI 갱신 + ref.invalidateSelf(); + } catch (e) { + // 에러 처리는 여기서 하거나, UI 단에서 잡을 수 있도록 던짐 + rethrow; + } + } +} diff --git a/lib/features/social/provider/friend_list_provider.g.dart b/lib/features/social/provider/friend_list_provider.g.dart new file mode 100644 index 0000000..06a3796 --- /dev/null +++ b/lib/features/social/provider/friend_list_provider.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'friend_list_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$friendListHash() => r'5753be3a1b415d7c3b8ee674c269380e252eda94'; + +/// See also [FriendList]. +@ProviderFor(FriendList) +final friendListProvider = + AutoDisposeAsyncNotifierProvider>.internal( + FriendList.new, + name: r'friendListProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$friendListHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$FriendList = AutoDisposeAsyncNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/social/provider/friend_request_provider.dart b/lib/features/social/provider/friend_request_provider.dart new file mode 100644 index 0000000..20d1278 --- /dev/null +++ b/lib/features/social/provider/friend_request_provider.dart @@ -0,0 +1,90 @@ +// 최초 작성자: 정승빈 +// 받은/보낸 요청 상태 관리 +import 'package:flutter/foundation.dart'; +import 'package:dio/dio.dart'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../data/social_repository.dart'; +import '../models/friend_request_card.dart'; +import 'friend_list_provider.dart'; + +part 'friend_request_provider.g.dart'; + +// 받은 요청 상태 관리 +@riverpod +class ReceivedRequests extends _$ReceivedRequests { + @override + Future> build() async { + return ref.watch(socialRepositoryProvider).getReceivedRequests(); + } + + // 친구 요청 수락 로직 + Future acceptRequest(int requestId) async { + try { + await ref.read(socialRepositoryProvider).acceptRequest(requestId); + ref.invalidateSelf(); // 받은 요청 목록 새로고침 + ref.invalidate(friendListProvider); // 친구가 추가되었으니 친구 목록도 새로고침 + } catch (e) { + if (e is DioException) { + debugPrint('---------- [친구 수락 실패] ----------'); + debugPrint('대상 Request ID: $requestId'); + debugPrint('상태 코드: ${e.response?.statusCode}'); + debugPrint('에러 메시지: ${e.response?.data}'); + debugPrint('요청 경로: ${e.requestOptions.path}'); + debugPrint('------------------------------------'); + } else { + debugPrint('수락 중 알 수 없는 에러: $e'); + } + rethrow; + } + } + + // 친구 요청 거절 로직 + Future rejectRequest(int requestId) async { + try { + await ref.read(socialRepositoryProvider).rejectRequest(requestId); + ref.invalidateSelf(); + } catch (e) { + if (e is DioException) { + debugPrint('---------- [친구 거절 실패] ----------'); + debugPrint('거절할 ID: $requestId'); + debugPrint('상태 코드: ${e.response?.statusCode}'); + debugPrint('에러 메시지: ${e.response?.data}'); + debugPrint('요청 경로: ${e.requestOptions.path}'); + debugPrint('------------------------------------'); + } else { + debugPrint('거절 중 알 수 없는 에러: $e'); + } + rethrow; + } + } +} + +// 보낸 요청 상태 관리 +@riverpod +class SentRequests extends _$SentRequests { + @override + Future> build() async { + return ref.watch(socialRepositoryProvider).getSentRequests(); + } + + // 친구 요청 취소 로직 + Future cancelRequest(int requestId) async { + try { + await ref.read(socialRepositoryProvider).cancelRequest(requestId); + ref.invalidateSelf(); + } catch (e) { + if (e is DioException) { + debugPrint('---------- [친구 요청 취소 실패] ----------'); + debugPrint('취소할 Request ID: $requestId'); + debugPrint('상태 코드: ${e.response?.statusCode}'); + debugPrint('에러 메시지: ${e.response?.data}'); + debugPrint('요청 경로: ${e.requestOptions.path}'); + debugPrint('---------------------------------------'); + } else { + debugPrint('요청 취소 중 알 수 없는 에러: $e'); + } + rethrow; + } + } +} diff --git a/lib/features/social/provider/friend_request_provider.g.dart b/lib/features/social/provider/friend_request_provider.g.dart new file mode 100644 index 0000000..d000f52 --- /dev/null +++ b/lib/features/social/provider/friend_request_provider.g.dart @@ -0,0 +1,48 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'friend_request_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$receivedRequestsHash() => r'4b5c706f34051662afd50c7b5f97fd3ea4258799'; + +/// See also [ReceivedRequests]. +@ProviderFor(ReceivedRequests) +final receivedRequestsProvider = + AutoDisposeAsyncNotifierProvider< + ReceivedRequests, + List + >.internal( + ReceivedRequests.new, + name: r'receivedRequestsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$receivedRequestsHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$ReceivedRequests = AutoDisposeAsyncNotifier>; +String _$sentRequestsHash() => r'8416bec6bc2187d5ccfeb4aacc1dc6c248b552a6'; + +/// See also [SentRequests]. +@ProviderFor(SentRequests) +final sentRequestsProvider = + AutoDisposeAsyncNotifierProvider< + SentRequests, + List + >.internal( + SentRequests.new, + name: r'sentRequestsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$sentRequestsHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$SentRequests = AutoDisposeAsyncNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/social/provider/user_search_provider.dart b/lib/features/social/provider/user_search_provider.dart new file mode 100644 index 0000000..c6fe6b2 --- /dev/null +++ b/lib/features/social/provider/user_search_provider.dart @@ -0,0 +1,120 @@ +// 최초 작성자: 정승빈 +// 유저 검색 상태 관리 +import 'package:flutter/foundation.dart'; +import 'package:dio/dio.dart'; +import 'package:image/image.dart'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../data/social_repository.dart'; +import '../models/user_search_card.dart'; +import '../../../core/utils/korean_string_utils.dart'; + +part 'user_search_provider.g.dart'; + +@riverpod +class UserSearch extends _$UserSearch { + @override + FutureOr> build() { + // 초기 상태는 검색 결과가 없는 빈 리스트 + return []; + } + + // 사용자 검색 로직 (필터링 및 정렬 포함) + Future searchUsers(String query) async { + final trimmedQuery = query.trim(); + if (trimmedQuery.isEmpty) { + state = const AsyncValue.data([]); + return; + } + + // 로딩 상태 시작 + state = const AsyncValue.loading(); + + try { + // 1. 서버로부터 검색 결과 리스트를 받아옴 + // 서버 API가 초성 검색을 지원하지 않더라도, 결과 목록을 받아온 뒤 + // 클라이언트에서 2차 필터링을 수행할 수 있도록 raw 데이터를 받습니다. + final rawResults = await ref + .read(socialRepositoryProvider) + .searchUsers(trimmedQuery); + // TODO: [성능 최적화 필요] 현재 서버 API가 초성 검색을 지원하지 않아, + // 임시로 전체 유저 목록을 받아와 클라이언트에서 필터링하고 있습니다. + // 유저 수가 늘어나면 앱 속도가 느려질 수 있으므로, + // 추후 백엔드에 초성 검색 기능(DB 쿼리 수정 등)을 요청하여 + // 서버 사이드 필터링으로 교체해야 합니다. + + // 클라이언트 사이드 초성 및 닉네임 필터링 + final filtered = rawResults.where((card) { + final name = card.user.nickname.toLowerCase(); + final searchLower = trimmedQuery.toLowerCase(); + + // 닉네임 전체 또는 초성 문자열에 검색어가 포함되는지 체크 + return name.contains(searchLower) || + KoreanStringUtils.getChoseongString(name).contains(searchLower); + }).toList(); + + // 가나다순 정렬 + // 순서: 한글 > 영문 대문자 > 영문 소문자 > 숫자 > 특수문자 + filtered.sort( + (a, b) => KoreanStringUtils.compareKoreanFirst( + a.user.nickname, + b.user.nickname, + ), + ); + + // 결과 상태 반영 + state = AsyncValue.data(filtered); + } catch (e, stack) { + // 🐛 디버깅 로그 추가: DioException인지 확인하여 상세 에러 출력 + if (e is DioException) { + debugPrint('---------- [검색 오류 발생] ----------'); + debugPrint('상태 코드: ${e.response?.statusCode}'); + debugPrint('에러 데이터: ${e.response?.data}'); + debugPrint('에러 메시지: ${e.message}'); + debugPrint('------------------------------------'); + } else { + debugPrint('시스템 오류: $e'); + } + + // 에러 상태 반영 + state = AsyncValue.error(e, stack); + } + } + + // 친구 신청 및 결과 리스트 내 상태 즉시 갱신 + Future sendFriendRequest(UserSearchCard card) async { + // 이미 친구 신청이 진행 중인 경우 중복 요청 방지 + if (card.state == FriendState.pending) return; + + final previousState = state.value ?? []; // 현재 검색 결과 리스트 + + try { + await ref + .read(socialRepositoryProvider) + .sendFriendRequest(card.user.nickname); + + // 성공 시 해당 카드의 상태만 FriendState.pending으로 변경하여 UI 즉각 갱신 + state = AsyncValue.data([ + for (final c in previousState) + if (c.user.nickname == card.user.nickname) + c.copyWith(state: FriendState.pending) + else + c, + ]); + } catch (e) { + // 🐛 디버깅 로그 추가: 친구 신청 실패 시 상세 원인 파악 + if (e is DioException) { + debugPrint('---------- [친구 신청 실패] ----------'); + debugPrint('대상 닉네임: ${card.user.nickname}'); + debugPrint('상태 코드: ${e.response?.statusCode}'); + debugPrint('서버 응답: ${e.response?.data}'); + debugPrint('------------------------------------'); + } else { + debugPrint('친구 신청 중 알 수 없는 에러: $e'); + } + + // 에러는 UI에서 토스트를 띄우도록 rethrow + rethrow; + } + } +} diff --git a/lib/features/social/provider/user_search_provider.g.dart b/lib/features/social/provider/user_search_provider.g.dart new file mode 100644 index 0000000..7cf53a3 --- /dev/null +++ b/lib/features/social/provider/user_search_provider.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_search_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$userSearchHash() => r'cc645e5cd1e99dbbcb399d9af68296db8fb445f0'; + +/// See also [UserSearch]. +@ProviderFor(UserSearch) +final userSearchProvider = + AutoDisposeAsyncNotifierProvider>.internal( + UserSearch.new, + name: r'userSearchProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$userSearchHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$UserSearch = AutoDisposeAsyncNotifier>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/social/screens/friend_add_screen.dart b/lib/features/social/screens/friend_add_screen.dart index ffa6e0c..7845ab3 100644 --- a/lib/features/social/screens/friend_add_screen.dart +++ b/lib/features/social/screens/friend_add_screen.dart @@ -1,795 +1,58 @@ -/// 최초 작성자: 정승빈 -library; - +// 최초 작성자: 정승빈 import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:intl/intl.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_typography.dart'; -import '../data/social_repository.dart'; -import '../model/social_model.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:dio/dio.dart'; -import '../../../core/utils/korean_string_utils.dart'; -// --- 메인 화면 --- +import '../views/user_search_view.dart'; +import '../views/received_request_view.dart'; +import '../views/sent_request_view.dart'; -/// 클래스의 용도: 친구 검색, 받은 요청, 보낸 요청을 관리하는 친구 추가 메인 화면 -class FriendAddScreen extends ConsumerStatefulWidget { +class FriendAddScreen extends StatelessWidget { final int initialTabIndex; - const FriendAddScreen({ - super.key, - this.initialTabIndex = 0, // 기본값은 0 (친구 신청 탭) - }); - - @override - ConsumerState createState() => FriendAddScreenState(); -} - -class FriendAddScreenState extends ConsumerState - with SingleTickerProviderStateMixin { - late TabController tabController; - final TextEditingController searchController = TextEditingController(); - - // 서버로부터 받아올 데이터 리스트 - List filteredResults = []; - List receivedRequests = []; - List sentRequests = []; - bool isSearchPerformed = false; - bool isLoading = false; - - /// 함수의 용도: 컨트롤러 및 리포지토리 데이터 상태 복구 - /// 매개 변수: 없음 - /// 반환 값: 없음 - @override - void initState() { - super.initState(); - // 탭 컨트롤러를 만들 때 initialIndex를 외부에서 받은 값으로 설정 - tabController = TabController( - length: 3, - vsync: this, - initialIndex: widget.initialTabIndex, - ); - // 화면 진입 시 받은 요청과 보낸 요청 목록을 먼저 불러옵니다. - _fetchInitialData(); - } - - /// 초기 데이터(받은/보낸 요청) 로드 - Future _fetchInitialData() async { - final repo = ref.read(socialRepositoryProvider); - try { - final received = await repo - .getReceivedRequests(); // GET /api/users/friend/request/received - final sent = await repo - .getSentRequests(); // GET /api/users/friend/request/sent - setState(() { - receivedRequests = received; - sentRequests = sent; - }); - } on DioException catch (e) { - if (!mounted) return; - - // 개발자를 위한 상세 로그 - debugPrint('---------- [초기 데이터 로드 오류] ----------'); - debugPrint('상태 코드: ${e.response?.statusCode}'); - debugPrint('에러 경로: ${e.requestOptions.path}'); - debugPrint('에러 내용: ${e.response?.data}'); - debugPrint('-----------------------------------------'); - - displayToast('데이터를 불러오는데 실패했습니다.'); - } catch (e) { - if (!mounted) return; - debugPrint('초기 데이터 로드 중 알 수 없는 에러: $e'); - displayToast('데이터 로딩 중 오류가 발생했습니다.'); - } - } + const FriendAddScreen({super.key, this.initialTabIndex = 0}); - /// 함수의 용도: 사용된 리소스 해제 - /// 매개 변수: 없음 - /// 반환 값: 없음 - @override - void dispose() { - tabController.dispose(); - searchController.dispose(); - super.dispose(); - } - - /// 닉네임 유저 검색 수행 - Future performSearch() async { - final query = searchController.text.trim(); - if (query.isEmpty) return; - - setState(() { - isLoading = true; - isSearchPerformed = true; - }); - - try { - // 1. 서버로부터 검색 결과 리스트를 받아옴 - // 서버 API가 초성 검색을 지원하지 않더라도, 결과 목록을 받아온 뒤 - // 클라이언트에서 2차 필터링을 수행할 수 있도록 raw 데이터를 받습니다. - final rawResults = await ref - .read(socialRepositoryProvider) - .searchUsers(""); - // TODO: [성능 최적화 필요] 현재 서버 API가 초성 검색을 지원하지 않아, - // 임시로 전체 유저 목록을 받아와 클라이언트에서 필터링하고 있습니다. - // 유저 수가 늘어나면 앱 속도가 느려질 수 있으므로, - // 추후 백엔드에 초성 검색 기능(DB 쿼리 수정 등)을 요청하여 - // 서버 사이드 필터링으로 교체해야 합니다. - - // 2. 클라이언트 사이드 필터링 (social_screen.dart와 동일 로직 적용) - // 서버에서 'ㅎㄴ'으로 검색 시 결과가 없더라도, 만약 서버가 전체 유저나 - // 유사 유저를 반환한다면 이 로직이 '해냄'을 찾아냅니다. - final filtered = rawResults.where((user) { - final name = user.nickname.toLowerCase(); - final searchLower = query.toLowerCase(); - - // 닉네임 포함 여부 OR 초성 포함 여부 확인 - return name.contains(searchLower) || - KoreanStringUtils.getChoseongString(name).contains(searchLower); - }).toList(); - - // 3. 정렬 (social_screen.dart와 동일 로직 적용) - // 순서: 한글 > 영문 대문자 > 영문 소문자 > 숫자 > 특수문자 - filtered.sort( - (a, b) => KoreanStringUtils.compareKoreanFirst(a.nickname, b.nickname), - ); - - if (!mounted) return; - setState(() { - filteredResults = filtered; - isLoading = false; - }); - } on DioException catch (e) { - // DioException을 직접 잡아 구체적인 원인 파악 - if (!mounted) return; - - // 디버그 콘솔에 상세 오류 출력 - debugPrint('---------- [검색 오류 발생] ----------'); - debugPrint('상태 코드: ${e.response?.statusCode}'); - debugPrint('에러 데이터: ${e.response?.data}'); - debugPrint('에러 메시지: ${e.message}'); - debugPrint('------------------------------------'); - - setState(() => isLoading = false); - - // 사용자에게는 최소한의 정보만 전달 - displayToast('검색 결과가 없거나 오류가 발생했습니다.'); - } catch (e) { - if (!mounted) return; - debugPrint('시스템 오류: $e'); - setState(() => isLoading = false); - displayToast('잠시 후 다시 시도해 주세요.'); - } - } - - /// 친구 신청 보내기 - Future sendFriendRequestAction(SearchResultUser user) async { - // 1. 이미 신청된 상태면 아무 작업도 하지 않음 (중복 방지) - if (user.isRequested) return; - - try { - // POST /api/users/friend/request/{toUserNickName} - await ref.read(socialRepositoryProvider).sendFriendRequest(user.nickname); - - // 2. 비동기 작업 후 위젯이 여전히 화면에 있는지 확인 (unmounted 에러 방지) - if (!mounted) return; - - setState(() { - user.isRequested = true; // 로컬 상태 즉시 반영 - }); - - _fetchInitialData(); // 보낸 요청 목록 갱신 - displayToast('${user.nickname} 님에게 친구 신청을 보냈습니다!'); - } on DioException catch (e) { - if (!mounted) return; - - // 🔥 [디버깅용 로그] 정확한 에러 원인 확인 - debugPrint('---------- [친구 신청 실패] ----------'); - debugPrint('대상 닉네임: ${user.nickname}'); - debugPrint('상태 코드: ${e.response?.statusCode}'); - debugPrint('서버 응답: ${e.response?.data}'); // 에러 메시지나 코드 확인 - debugPrint('------------------------------------'); - - // 사용자에게는 기존과 동일하게 안내 - displayToast('이미 신청되었거나 신청에 실패했습니다.'); - } catch (e) { - if (!mounted) return; - debugPrint('친구 신청 중 알 수 없는 에러: $e'); - displayToast('신청 중 오류가 발생했습니다.'); - } - } - - /// 신청 취소하기 - Future cancelFriendRequestAction(SearchResultUser user) async { - if (user.requestId == null) return; - try { - // PATCH /api/users/friend/request/sent/cancel/{requestId} - await ref.read(socialRepositoryProvider).cancelRequest(user.requestId!); - _fetchInitialData(); - displayToast('친구 신청을 취소했습니다.'); - } catch (e) { - displayToast('취소에 실패했습니다.'); - } - } - - /// 친구 수락하기 - Future acceptFriendRequestAction(ReceivedRequest req) async { - try { - // PATCH /api/users/friend/request/accept/{requestId} - await ref.read(socialRepositoryProvider).acceptRequest(req.requestId); - if (!mounted) return; - - // 보낸/받은 요청 목록 갱신 - _fetchInitialData(); - // 실제 친구 목록(SocialScreen용)도 새로고침하여 데이터 일치화 - ref.invalidate(friendListProvider); - - displayToast('${req.nickname} 님과 친구가 되었습니다!'); - } on DioException catch (e) { - if (!mounted) return; - - // 🔥 [수락 실패 디버그] - debugPrint('---------- [친구 수락 실패] ----------'); - debugPrint('대상 닉네임: ${req.nickname}'); - debugPrint('대상 Request ID: ${req.requestId}'); - debugPrint('상태 코드: ${e.response?.statusCode}'); - debugPrint('에러 메시지: ${e.response?.data}'); - debugPrint('요청 경로: ${e.requestOptions.path}'); - debugPrint('------------------------------------'); - - displayToast('수락 처리에 실패했습니다. (코드: ${e.response?.statusCode})'); - } catch (e) { - if (!mounted) return; - debugPrint('수락 중 알 수 없는 에러: $e'); - displayToast('수락 중 오류가 발생했습니다.'); - } - } - - /// 친구 거절하기 - Future rejectFriendRequestAction(ReceivedRequest req) async { - try { - // PATCH /api/users/friend/request/reject/{rejectId} - // 주의: requestId가 null인지 확인이 필요할 수 있습니다. - await ref.read(socialRepositoryProvider).rejectRequest(req.requestId); - - if (!mounted) return; - _fetchInitialData(); // 목록 갱신 - displayToast('요청을 거절했습니다.'); - } on DioException catch (e) { - if (!mounted) return; - - // 🔥 [거절 실패 디버그] - debugPrint('---------- [친구 거절 실패] ----------'); - debugPrint('거절할 ID: ${req.requestId}'); - debugPrint('상태 코드: ${e.response?.statusCode}'); - debugPrint('에러 메시지: ${e.response?.data}'); - debugPrint('요청 경로: ${e.requestOptions.path}'); - debugPrint('------------------------------------'); - - displayToast('거절 처리에 실패했습니다. (코드: ${e.response?.statusCode})'); - } catch (e) { - if (!mounted) return; - debugPrint('거절 중 알 수 없는 에러: $e'); - displayToast('거절 중 오류가 발생했습니다.'); - } - } - - /// 함수의 용도: 커스텀 Overlay 애니메이션 토스트 표시 - /// 매개 변수: String message (출력 문구) - /// 반환 값: 없음 - void displayToast(String message) { - final overlay = Overlay.of(context); - late OverlayEntry overlayEntry; - - overlayEntry = OverlayEntry( - builder: (context) => AnimatedToast( - message: message, - onDismissed: () { - overlayEntry.remove(); - }, - ), - ); - - overlay.insert(overlayEntry); - } - - /// 함수의 용도: 메인 빌드 메서드 - /// 매개 변수: BuildContext context (빌드 컨텍스트) - /// 반환 값: Widget (완성된 화면 위젯) @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.white, - appBar: AppBar( + return DefaultTabController( + length: 3, + initialIndex: initialTabIndex, // 초기 탭 인덱스 설정 + child: Scaffold( backgroundColor: Colors.white, - elevation: 0, - leading: IconButton( - icon: const Icon(Icons.arrow_back, color: AppColors.black), - onPressed: () => Navigator.pop(context), - ), - centerTitle: true, - title: const Text('친구 추가', style: AppTypography.h2), - ), - body: Column( - children: [ - TabBar( - controller: tabController, - indicatorColor: AppColors.primaryAble, - labelColor: AppColors.primaryAble, - unselectedLabelColor: AppColors.gray2, - labelStyle: AppTypography.b1.copyWith(fontWeight: FontWeight.w500), - tabs: const [ - Tab(text: '친구 신청'), - Tab(text: '받은 요청'), - Tab(text: '보낸 요청'), - ], - ), - Expanded( - child: TabBarView( - controller: tabController, - children: [buildSearchTab(), buildReceivedTab(), buildSentTab()], + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: AppColors.black), + onPressed: () => Navigator.pop(context), + ), + centerTitle: true, + title: const Text('친구 추가', style: AppTypography.h2), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(48), + child: TabBar( + indicatorColor: AppColors.primaryAble, + labelColor: AppColors.primaryAble, + unselectedLabelColor: AppColors.gray2, + labelStyle: AppTypography.b1.copyWith( + fontWeight: FontWeight.w500, + ), + tabs: const [ + Tab(text: '친구 신청'), + Tab(text: '받은 요청'), + Tab(text: '보낸 요청'), + ], ), ), - ], - ), - ); - } - - /// 함수의 용도: 검색창과 검색 결과를 포함하는 탭 빌드 - Widget buildSearchTab() { - return Column( - children: [ - buildSearchInputSection(), - if (isSearchPerformed) ...[ - buildResultCountHeader(filteredResults.length), - Expanded(child: buildSearchResultList()), - ] else - const Expanded(child: SizedBox.expand()), - ], - ); - } - - /// 함수의 용도: 받은 요청 목록을 포함하는 탭 빌드 - Widget buildReceivedTab() { - return Container( - color: const Color(0x7FDFE1DC), - child: receivedRequests.isEmpty - ? const Center(child: Text('받은 요청이 없습니다.', style: AppTypography.b2)) - : ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: receivedRequests.length, - itemBuilder: (context, index) { - final req = receivedRequests[index]; - return buildReceivedCard( - req, - onAccept: () => acceptFriendRequestAction(req), - onReject: () => rejectFriendRequestAction(req), - ); - }, - ), - ); - } - - /// 함수의 용도: 보낸 요청 목록을 포함하는 탭 빌드 - Widget buildSentTab() { - return Container( - color: const Color(0x7FDFE1DC), - child: sentRequests.isEmpty - ? const Center(child: Text('보낸 요청이 없습니다.', style: AppTypography.b2)) - : ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: sentRequests.length, - itemBuilder: (context, index) => - buildSentCard(sentRequests[index]), - ), - ); - } - - /// 함수의 용도: 검색창 입력 필드 영역 생성 - Widget buildSearchInputSection() { - return Padding( - padding: const EdgeInsets.all(20), - child: Container( - height: 40, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppColors.gray4), ), - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( + body: const TabBarView( children: [ - SvgPicture.asset( - 'assets/images/icons/search_icon.svg', - width: 18, - colorFilter: const ColorFilter.mode( - AppColors.gray3, - BlendMode.srcIn, - ), - ), - const SizedBox(width: 8), - Expanded( - child: TextField( - controller: searchController, - onSubmitted: (query) => performSearch(), - textInputAction: TextInputAction.search, - decoration: const InputDecoration( - hintText: '닉네임을 검색하세요', - hintStyle: AppTypography.b2, - border: InputBorder.none, - isDense: true, - ), - style: AppTypography.b1, - ), - ), + UserSearchView(), + ReceivedRequestView(), + SentRequestView(), ], ), ), ); } - - /// 함수의 용도: 검색 결과 수 헤더 생성 - Widget buildResultCountHeader(int count) { - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), - child: Text('검색 결과 $count명', style: AppTypography.b2), - ); - } - - /// 함수의 용도: 검색된 유저 리스트 생성 - Widget buildSearchResultList() { - return ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 20), - itemCount: filteredResults.length, - itemBuilder: (context, index) { - final user = filteredResults[index]; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Row( - children: [ - buildProfileCircle(user.profileImageUrl, 44), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - user.nickname, - style: AppTypography.h3.copyWith(fontSize: 15), - ), - Text( - "해냄 메이트", // api에 title이 없으므로 기본값 설정 - //TODO: 추후 title 필드가 추가되면 반영 - style: AppTypography.c1.copyWith(color: AppColors.gray2), - ), - ], - ), - ), - buildRequestButton(user), - ], - ), - ); - }, - ); - } - - /// 함수의 용도: 받은 요청 카드 위젯 생성 - Widget buildReceivedCard( - ReceivedRequest req, { - required VoidCallback onAccept, - required VoidCallback onReject, - }) { - return Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - children: [ - Row( - children: [ - buildProfileCircle(req.profileImageUrl, 48), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(req.nickname, style: AppTypography.h3), - Text( - "함께 아는 친구 0명", - //"함께 아는 친구 ${req.mutualFriends}명", - //TODO: 추후 mutualFriends 필드가 추가되면 반영 - style: AppTypography.c1.copyWith(color: AppColors.gray2), - ), - Text( - DateFormat( - 'yyyy년 MM월 dd일', - ).format(DateTime.parse(req.createdAt)), - style: AppTypography.c2.copyWith(color: AppColors.gray3), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: buildActionButton( - '거절', - const Color(0x7FDFE1DC), - AppColors.gray2, - onReject, // 전달받은 거절 액션 연결 - ), - ), - const SizedBox(width: 10), - Expanded( - child: buildActionButton( - '수락', - AppColors.primaryAble, - Colors.white, - onAccept, // 전달받은 수락 액션 연결 - ), - ), - ], - ), - ], - ), - ); - } - - /// 함수의 용도: 보낸 요청 카드 위젯 생성 - /// 매개 변수: SearchResultUser user (대상 유저) - Widget buildSentCard(SearchResultUser user) { - return Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - buildProfileCircle(user.profileImageUrl, 48), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(user.nickname, style: AppTypography.h3), - Text( - user.createdAt != null - ? DateFormat( - 'yyyy년 MM월 dd일', - ).format(DateTime.parse(user.createdAt!)) - : '', - style: AppTypography.c2.copyWith( - color: AppColors.gray3, - ), - ), - ], - ), - ], - ), - buildBadge('대기 중'), - ], - ), - const SizedBox(height: 16), - buildActionButton( - '요청 취소', - const Color(0x7FDFE1DC), - AppColors.gray2, - () => cancelFriendRequestAction(user), // 위에서 만든 취소 액션 연결 - ), - ], - ), - ); - } - - /// 함수의 용도: 상태 표시를 위한 배지 위젯 생성 - /// 매개 변수: String text (배지 문구) - Widget buildBadge(String text) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xFFE8F5E9), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - text, - style: const TextStyle(color: Color(0xFF444444), fontSize: 12), - ), - ); - } - - /// 함수의 용도: 공통 버튼 위젯 생성 - /// 매개 변수: String label, Color bg, Color text, VoidCallback onTap - Widget buildActionButton( - String label, - Color bg, - Color text, - VoidCallback onTap, - ) { - return GestureDetector( - onTap: onTap, - child: Container( - width: double.infinity, - height: 48, - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(10), - ), - alignment: Alignment.center, - child: Text( - label, - style: AppTypography.b1.copyWith( - color: text, - fontWeight: FontWeight.w500, - ), - ), - ), - ); - } - - /// 함수의 용도: 유저 프로필 이미지 원형 위젯 생성 (Asset -> Network 이미지 대응) - /// 매개 변수: String? imagePath, double size - Widget buildProfileCircle(String? imageUrl, double size) { - return Container( - width: size, - height: size, - decoration: BoxDecoration( - color: const Color(0x7FDFE1DC), - shape: BoxShape.circle, - // 서버에서 오는 이미지는 NetworkImage로 처리해야 합니다. - image: imageUrl != null && imageUrl.startsWith('http') - ? DecorationImage(image: NetworkImage(imageUrl), fit: BoxFit.cover) - : (imageUrl != null - ? DecorationImage( - image: AssetImage(imageUrl), - fit: BoxFit.cover, - ) - : null), - ), - child: imageUrl == null - ? Center( - child: SvgPicture.asset( - 'assets/images/icons/default_profile_icon.svg', - width: size, - ), - ) - : null, - ); - } - - /// 함수의 용도: 검색 결과의 친구 신청/신청됨 버튼 생성 - /// 매개 변수: SearchResultUser user (대상 유저) - Widget buildRequestButton(SearchResultUser user) { - // 1. 이미 친구인 유저는 버튼 자체를 노출하지 않음 [추가] - if (user.isFriend) { - return const SizedBox.shrink(); - } - - return GestureDetector( - // 2. 이미 신청된 상태(isRequested)면 클릭 방지 - onTap: user.isRequested ? null : () => sendFriendRequestAction(user), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: user.isRequested ? AppColors.disable : AppColors.primaryAble, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - user.isRequested ? '신청됨' : '친구 신청', - style: AppTypography.c1.copyWith( - color: user.isRequested ? AppColors.gray2 : Colors.white, - fontWeight: FontWeight.w500, - ), - ), - ), - ); - } -} - -// --- 애니메이션 컴포넌트 --- - -/// 클래스의 용도: 화면 하단에 메시지를 띄우는 애니메이션 토스트 위젯 -class AnimatedToast extends StatefulWidget { - final String message; - final VoidCallback onDismissed; - - const AnimatedToast({ - super.key, - required this.message, - required this.onDismissed, - }); - - @override - State createState() => AnimatedToastState(); -} - -class AnimatedToastState extends State - with SingleTickerProviderStateMixin { - late AnimationController controller; - late Animation slideAnimation; - late Animation opacityAnimation; - - /// 함수의 용도: 애니메이션 컨트롤러 및 애니메이션 초기화 - @override - void initState() { - super.initState(); - controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 500), - ); - - slideAnimation = Tween( - begin: const Offset(0, 0.5), - end: Offset.zero, - ).animate(CurvedAnimation(parent: controller, curve: Curves.easeOutQuart)); - - opacityAnimation = Tween( - begin: 0.0, - end: 1.0, - ).animate(CurvedAnimation(parent: controller, curve: Curves.easeIn)); - - controller.forward().then((_) async { - await Future.delayed(const Duration(seconds: 2)); - if (mounted) { - await controller.reverse(); - widget.onDismissed(); - } - }); - } - - /// 함수의 용도: 애니메이션 컨트롤러 해제 - @override - void dispose() { - controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Positioned( - bottom: 60, - left: 20, - right: 20, - child: IgnorePointer( - child: Material( - color: Colors.transparent, - child: FadeTransition( - opacity: opacityAnimation, - child: SlideTransition( - position: slideAnimation, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 16, - ), - decoration: BoxDecoration( - color: const Color(0xCC1A1D1B), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - widget.message, - textAlign: TextAlign.center, - style: AppTypography.b1.copyWith(color: Colors.white), - ), - ), - ), - ), - ), - ), - ); - } } diff --git a/lib/features/social/screens/friend_edit_screen.dart b/lib/features/social/screens/friend_edit_screen.dart index c29f9a1..217660b 100644 --- a/lib/features/social/screens/friend_edit_screen.dart +++ b/lib/features/social/screens/friend_edit_screen.dart @@ -1,35 +1,31 @@ -/// 최초 작성자: 정승빈 - -library; - +// 최초 작성자: 정승빈 import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_typography.dart'; -import '../model/social_model.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../data/social_repository.dart'; import '../../../core/utils/korean_string_utils.dart'; +import '../../../shared/models/user.dart'; +import '../../../shared/widgets/animated_toast.dart'; +import '../provider/friend_list_provider.dart'; +import '../widgets/delete_confirm_dialog.dart'; +import '../widgets/friend_edit_tile.dart'; +import 'package:haenaem/shared/widgets/custom_search_bar.dart'; -/// 클래스의 용도: 기존 친구 목록을 검색하고 삭제할 수 있는 편집 화면 class FriendEditScreen extends ConsumerStatefulWidget { - // ConsumerStatefulWidget으로 변경 - final List initialFriends; + final List initialFriends; // 초기 친구 목록을 전달받는 매개변수 const FriendEditScreen({super.key, required this.initialFriends}); @override - ConsumerState createState() => FriendEditScreenState(); + ConsumerState createState() => _FriendEditScreenState(); } -class FriendEditScreenState extends ConsumerState { +class _FriendEditScreenState extends ConsumerState { final TextEditingController searchController = TextEditingController(); - late List totalList; - List filteredList = []; + late List totalList; + List filteredList = []; - /// 함수의 용도: 초기 상태 설정 및 원본 리스트 복사 - /// 매개 변수: 없음 - /// 반환 값: 없음 @override void initState() { super.initState(); @@ -38,17 +34,21 @@ class FriendEditScreenState extends ConsumerState { _applySortAndState(); } - /// 함수의 용도: 입력 쿼리에 따라 리스트 필터링 - /// 매개 변수: String query (검색어) - /// 반환 값: 없음 + @override + void dispose() { + searchController.dispose(); + super.dispose(); + } + + // 입력 쿼리에 따라 리스트 필터링 void filterList(String query) { setState(() { String trimmedQuery = query.trim().toLowerCase(); if (trimmedQuery.isEmpty) { filteredList = List.from(totalList); } else { - filteredList = totalList.where((friend) { - String nickname = friend.nickname.toLowerCase(); + filteredList = totalList.where((user) { + String nickname = user.nickname.toLowerCase(); return nickname.contains(trimmedQuery) || KoreanStringUtils.getChoseongString( nickname, @@ -59,72 +59,48 @@ class FriendEditScreenState extends ConsumerState { }); } - /// 정렬 로직 호출 및 상태 반영 + // 정렬 로직 호출 및 상태 반영 void _applySortAndState() { filteredList.sort( (a, b) => KoreanStringUtils.compareKoreanFirst(a.nickname, b.nickname), ); } - /// 함수의 용도: 커스텀 Overlay 애니메이션 토스트 표시 - /// 매개 변수: String message (출력 문구) - /// 반환 값: 없음 - void displayToast(String message) { - final overlay = Overlay.of(context); - late OverlayEntry overlayEntry; - - overlayEntry = OverlayEntry( - builder: (context) => AnimatedToast( - message: message, - onDismissed: () { - overlayEntry.remove(); - }, - ), - ); - - overlay.insert(overlayEntry); - } - - /// 함수의 용도: 삭제 확인 다이얼로그 노출 - /// 매개 변수: Friend friend (삭제 대상 친구) - /// 반환 값: 없음 - void showDeleteDialog(Friend friend) { + // 삭제 확인 다이얼로그 표시 + void showDeleteDialog(User user) { showDialog( context: context, - // 1. 여기서 context 이름을 dialogContext로 변경하여 혼동 방지 builder: (dialogContext) => DeleteConfirmDialog( - usernickName: friend.nickname, + userNickname: user.nickname, onDelete: () async { try { + // 새로 생성한 Provider의 Notifier를 통한 삭제 로직 호출 await ref - .read(socialRepositoryProvider) - .deleteFriend(friend.nickname); + .read(friendListProvider.notifier) + .removeFriend(user.nickname); - // 2. 화면(FriendEditScreen)이 살아있는지 확인 (setState용) if (!mounted) return; + // 삭제 성공 시 로컬 리스트에서 제거 및 UI 업데이트 setState(() { - totalList.removeWhere((f) => f.nickname == friend.nickname); + totalList.removeWhere((u) => u.nickname == user.nickname); filterList(searchController.text); }); - // 3. 다이얼로그가 아직 열려있는지 확인 후 닫기 (Navigator용) - // 'context' 대신 'dialogContext'를 사용하세요. + // 다이얼로그가 아직 열려있는지 확인 후 닫기 if (dialogContext.mounted) { Navigator.pop(dialogContext); } - displayToast('${friend.nickname} 님이 삭제되었습니다.'); + displayToast(context, '${user.nickname} 님이 삭제되었습니다.'); } catch (e) { if (!mounted) return; - - // 여기서도 dialogContext가 살아있는지 확인하면 더 안전합니다. + // 다이얼로그가 아직 열려있는지 확인 후 닫기 if (dialogContext.mounted) { Navigator.pop(dialogContext); } - - displayToast('삭제에 실패했습니다. 다시 시도해 주세요.'); - debugPrint('친구 삭제 실패: $e'); + displayToast(context, '삭제에 실패했습니다. 다시 시도해 주세요.'); + debugPrint('친구 삭제 실패: $e'); // 디버그 로그 } }, ), @@ -147,305 +123,41 @@ class FriendEditScreenState extends ConsumerState { ), body: Column( children: [ - buildSearchHeader(), - Expanded(child: buildEditListView()), + _buildSearchHeader(), + Expanded(child: _buildEditListView()), ], ), ); } - /// 함수의 용도: 검색창 영역 빌드 - /// 매개 변수: 없음 - /// 반환 값: Widget - Widget buildSearchHeader() { + // 검색창 영역 빌드 + // 검색창 영역 빌드 + Widget _buildSearchHeader() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - child: Container( - height: 40, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppColors.gray4), - ), - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - SvgPicture.asset( - 'assets/images/icons/search_icon.svg', - width: 18, - colorFilter: const ColorFilter.mode( - AppColors.gray3, - BlendMode.srcIn, - ), - ), - const SizedBox(width: 8), - Expanded( - child: TextField( - controller: searchController, - onChanged: filterList, - decoration: const InputDecoration( - hintText: '친구 검색', - hintStyle: AppTypography.b2, - border: InputBorder.none, - isDense: true, - ), - style: AppTypography.b2, - ), - ), - ], - ), + child: CustomSearchBar( + controller: searchController, + hintText: '친구 검색', + onChanged: filterList, ), ); } - /// 함수의 용도: 필터링된 편집 리스트뷰 빌드 - /// 매개 변수: 없음 - /// 반환 값: Widget - Widget buildEditListView() { + // 편집 리스트 빌드 + Widget _buildEditListView() { if (filteredList.isEmpty) { return const Center(child: Text('검색 결과가 없습니다.', style: AppTypography.b2)); } return ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 20), itemCount: filteredList.length, - itemBuilder: (context, index) => buildEditTile(filteredList[index]), - ); - } - - /// 함수의 용도: 개별 친구 편집 항목 타일 생성 - /// 매개 변수: Friend friend (대상 친구 데이터) - /// 반환 값: Widget - Widget buildEditTile(Friend friend) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: const Color(0x7FDFE1DC), - shape: BoxShape.circle, - image: friend.profileImageUrl != null - ? DecorationImage( - image: NetworkImage(friend.profileImageUrl!), - fit: BoxFit.cover, - ) - : null, - ), - child: friend.profileImageUrl == null - ? Center( - child: SvgPicture.asset( - 'assets/images/icons/default_profile_icon.svg', - width: 24, - ), - ) - : null, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - friend.nickname, - style: AppTypography.h3.copyWith(fontSize: 15), - ), - Text( - friend.title, - style: AppTypography.c1.copyWith(color: AppColors.gray2), - ), - ], - ), - ), - IconButton( - onPressed: () => showDeleteDialog(friend), - icon: SvgPicture.asset( - 'assets/images/icons/big_trash_icon.svg', - width: 24, - colorFilter: const ColorFilter.mode( - AppColors.notification, - BlendMode.srcIn, - ), - ), - ), - ], - ), - ); - } -} - -/// 클래스의 용도: 친구 삭제 여부를 묻는 팝업 다이얼로그 -class DeleteConfirmDialog extends StatelessWidget { - final String usernickName; - final VoidCallback onDelete; - - const DeleteConfirmDialog({ - super.key, - required this.usernickName, - required this.onDelete, - }); - - @override - Widget build(BuildContext context) { - return Dialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - backgroundColor: Colors.white, - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text('친구 삭제', style: AppTypography.h2), - const SizedBox(height: 12), - Text( - '$usernickName 님을 삭제하시겠습니까?', - style: AppTypography.b2.copyWith(color: AppColors.gray2), - textAlign: TextAlign.center, - ), - const SizedBox(height: 24), - Row( - children: [ - Expanded( - child: GestureDetector( - onTap: () => Navigator.pop(context), - child: Container( - height: 48, - decoration: BoxDecoration( - color: const Color(0x7FDFE1DC), - borderRadius: BorderRadius.circular(10), - ), - child: const Center( - child: Text('취소', style: AppTypography.b1), - ), - ), - ), - ), - const SizedBox(width: 10), - Expanded( - child: GestureDetector( - onTap: onDelete, - child: Container( - height: 48, - decoration: BoxDecoration( - color: const Color(0x7FDFE1DC), - borderRadius: BorderRadius.circular(10), - ), - child: Center( - child: Text( - '삭제하기', - style: AppTypography.b1.copyWith( - color: AppColors.notification, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ), - ), - ], - ), - ], - ), - ), - ); - } -} - -/// 클래스의 용도: 화면 하단에 메시지를 띄우는 애니메이션 토스트 위젯 -class AnimatedToast extends StatefulWidget { - final String message; - final VoidCallback onDismissed; - - const AnimatedToast({ - super.key, - required this.message, - required this.onDismissed, - }); - - @override - State createState() => AnimatedToastState(); -} - -class AnimatedToastState extends State - with SingleTickerProviderStateMixin { - late AnimationController animationController; - late Animation slideAnimation; - late Animation opacityAnimation; - - /// 함수의 용도: 애니메이션 초기화 및 자동 소멸 로직 - /// 매개 변수: 없음 - /// 반환 값: 없음 - @override - void initState() { - super.initState(); - animationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 500), - ); - - slideAnimation = - Tween(begin: const Offset(0, 0.5), end: Offset.zero).animate( - CurvedAnimation( - parent: animationController, - curve: Curves.easeOutQuart, - ), + itemBuilder: (context, index) { + final user = filteredList[index]; + return FriendEditTile( + user: user, + onDeleteTap: () => showDeleteDialog(user), ); - - opacityAnimation = Tween(begin: 0.0, end: 1.0).animate( - CurvedAnimation(parent: animationController, curve: Curves.easeIn), - ); - - animationController.forward().then((_) async { - await Future.delayed(const Duration(seconds: 2)); - if (mounted) { - await animationController.reverse(); - widget.onDismissed(); - } - }); - } - - /// 함수의 용도: 애니메이션 컨트롤러 해제 - /// 매개 변수: 없음 - /// 반환 값: 없음 - @override - void dispose() { - animationController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Positioned( - bottom: 60, - left: 20, - right: 20, - child: IgnorePointer( - child: Material( - color: Colors.transparent, - child: FadeTransition( - opacity: opacityAnimation, - child: SlideTransition( - position: slideAnimation, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 16, - ), - decoration: BoxDecoration( - color: const Color(0xCC1A1D1B), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - widget.message, - textAlign: TextAlign.center, - style: AppTypography.b1.copyWith(color: Colors.white), - ), - ), - ), - ), - ), - ), + }, ); } } diff --git a/lib/features/social/screens/social_main_screen.dart b/lib/features/social/screens/social_main_screen.dart new file mode 100644 index 0000000..1c69b8d --- /dev/null +++ b/lib/features/social/screens/social_main_screen.dart @@ -0,0 +1,188 @@ +// 최초 작성자: 정승빈 +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/theme/app_typography.dart'; +import '../../../core/utils/korean_string_utils.dart'; +import '../../../shared/models/user.dart'; +import '../provider/friend_list_provider.dart'; +import '../widgets/friend_list_tile.dart'; +import 'friend_add_screen.dart'; +import 'friend_edit_screen.dart'; +import 'package:haenaem/shared/widgets/custom_search_bar.dart'; + +class SocialMainScreen extends ConsumerStatefulWidget { + const SocialMainScreen({super.key}); + + @override + ConsumerState createState() => _SocialMainScreenState(); +} + +class _SocialMainScreenState extends ConsumerState { + final TextEditingController searchController = TextEditingController(); + String searchQuery = ''; + + @override + void dispose() { + searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // 새로운 Provider 구독 + final friendListAsync = ref.watch(friendListProvider); + + return Scaffold( + backgroundColor: Colors.white, + appBar: _buildAppBar(context), + body: Column( + children: [ + _buildSearchBar(), + // 친구 목록 섹션 + Expanded( + child: friendListAsync.when( + data: (totalFriends) { + // 1. 검색 필터링 + final filteredFriends = totalFriends.where((user) { + final name = user.nickname.toLowerCase(); + final query = searchQuery.toLowerCase(); + return name.contains(query) || + KoreanStringUtils.getChoseongString(name).contains(query); + }).toList(); + + // 2. 가나다순 정렬 + filteredFriends.sort( + (a, b) => KoreanStringUtils.compareKoreanFirst( + a.nickname, + b.nickname, + ), + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildListHeader(filteredFriends.length, totalFriends), + Expanded( + child: RefreshIndicator( + color: AppColors.primaryAble, + onRefresh: () async { + // 스크롤을 당기면 데이터를 강제로 다시 불러옵니다. + return await ref.refresh(friendListProvider.future); + }, + // 친구 목록이 비어있을 때 빈 상태 표시, 그렇지 않으면 친구 목록 표시 + child: filteredFriends.isEmpty + ? _buildEmptyState() + : _buildFriendList(filteredFriends), + ), + ), + ], + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, stack) => + const Center(child: Text('친구 목록을 불러오지 못했습니다.')), + ), + ), + ], + ), + ); + } + + // AppBar 위젯을 별도의 메서드로 분리 + PreferredSizeWidget _buildAppBar(BuildContext context) { + return AppBar( + backgroundColor: Colors.white, + elevation: 0, + centerTitle: true, + title: const Text('친구', style: AppTypography.h3), + actions: [ + IconButton( + icon: SvgPicture.asset('assets/images/icons/friend_add_icon.svg'), + onPressed: () async { + // await를 사용하여 FriendAddScreen이 닫힐 때까지 기다림 + await Navigator.push( + context, + MaterialPageRoute(builder: (context) => const FriendAddScreen()), + ); + + if (!mounted) return; + + // 화면이 닫히고 돌아오면 친구 목록 Provider를 강제로 새로고침 + ref.invalidate(friendListProvider); + }, + ), + ], + ); + } + + // 검색 바 위젯 + Widget _buildSearchBar() { + return Padding( + padding: const EdgeInsets.all(20), + child: CustomSearchBar( + controller: searchController, + hintText: '친구 검색', + onChanged: (value) => setState(() => searchQuery = value), + ), + ); + } + + // 친구 목록 헤더 위젯 + Widget _buildListHeader(int count, List totalFriends) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('친구 $count', style: AppTypography.b2), + GestureDetector( + onTap: () async { + // await로 편집 화면이 닫힐 때까지 대기 + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + FriendEditScreen(initialFriends: totalFriends), + ), + ); + + if (!mounted) return; + + // 편집 화면에서 돌아오면 친구 목록 새로고침 (삭제 반영) + ref.invalidate(friendListProvider); + }, + child: Text( + '편집', + style: AppTypography.c1.copyWith(color: AppColors.gray2), + ), + ), + ], + ), + ); + } + + // 친구가 없을 때 보여줄 빈 상태 위젯 + Widget _buildEmptyState() { + return SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: SizedBox( + height: MediaQuery.of(context).size.height * 0.5, + child: const Center(child: Text('친구가 없습니다.')), + ), + ); + } + + // 친구 목록을 보여주는 위젯 + Widget _buildFriendList(List filteredFriends) { + return ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), // 스크롤이 가능하도록 설정 + padding: const EdgeInsets.symmetric(horizontal: 20), + itemCount: filteredFriends.length, + itemBuilder: (context, index) => + FriendListTile(user: filteredFriends[index]), + ); + } +} diff --git a/lib/features/social/screens/social_screen.dart b/lib/features/social/screens/social_screen.dart deleted file mode 100644 index 33edb27..0000000 --- a/lib/features/social/screens/social_screen.dart +++ /dev/null @@ -1,260 +0,0 @@ -/// 최초 작성자: 정승빈 (수정: Gemini) -library; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; // 추가 -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:haenaem/features/social/screens/friend_edit_screen.dart'; -import '../../../../core/theme/app_colors.dart'; -import '../../../../core/theme/app_typography.dart'; -import 'friend_add_screen.dart'; -import '../data/social_repository.dart'; -import '../model/social_model.dart'; -import '../../../core/utils/korean_string_utils.dart'; - -class SocialScreen extends ConsumerStatefulWidget { - const SocialScreen({super.key}); - - @override - ConsumerState createState() => _SocialScreenState(); -} - -class _SocialScreenState extends ConsumerState { - final TextEditingController searchController = TextEditingController(); - String searchQuery = ''; - - @override - void dispose() { - searchController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - // Riverpod을 통해 친구 목록 프로바이더 구독 - final friendListAsync = ref.watch(friendListProvider); - - return Scaffold( - backgroundColor: Colors.white, - appBar: AppBar( - backgroundColor: Colors.white, - elevation: 0, - centerTitle: true, - title: const Text('친구', style: AppTypography.h3), - actions: [ - IconButton( - icon: SvgPicture.asset('assets/images/icons/friend_add_icon.svg'), - onPressed: () async { - // 1. async 키워드 추가 - // 2. await를 사용하여 FriendAddScreen이 닫힐 때까지 기다림 - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const FriendAddScreen(), - ), - ); - - // 3. 화면이 닫히고 돌아오면 친구 목록 Provider를 강제로 새로고침 - ref.invalidate(friendListProvider); - }, - ), - ], - ), - body: Column( - children: [ - // 검색창 (friend_add_screen.dart 스타일로 수정됨) - Padding( - padding: const EdgeInsets.all(20), - child: Container( - height: 40, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppColors.gray4), // 연한 회색 테두리 - ), - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - SvgPicture.asset( - 'assets/images/icons/search_icon.svg', - width: 18, - colorFilter: const ColorFilter.mode( - AppColors.gray3, - BlendMode.srcIn, - ), - ), - const SizedBox(width: 8), - Expanded( - child: TextField( - controller: searchController, - onChanged: (value) => setState(() => searchQuery = value), - decoration: const InputDecoration( - hintText: '친구 검색', - hintStyle: AppTypography.b2, - border: InputBorder.none, // 기본 TextField 테두리 제거 - isDense: true, // 텍스트 필드 내부 여백 최소화 (중앙 정렬 도움) - ), - style: AppTypography.b1, - ), - ), - ], - ), - ), - ), - - // 친구 목록 섹션 - Expanded( - child: friendListAsync.when( - data: (totalFriends) { - // 1. 필터링 및 2. 정렬 로직 적용 - final filteredFriends = totalFriends.where((friend) { - final name = friend.nickname.toLowerCase(); - final query = searchQuery.toLowerCase(); - return name.contains(query) || - KoreanStringUtils.getChoseongString(name).contains(query); - }).toList(); - - // 가나다순 정렬 추가 - filteredFriends.sort( - (a, b) => KoreanStringUtils.compareKoreanFirst( - a.nickname, - b.nickname, - ), - ); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 8, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '친구 ${filteredFriends.length}', - style: AppTypography.b2, - ), - GestureDetector( - onTap: () async { - // 1. async 추가 - // 2. await로 편집 화면이 닫힐 때까지 대기 - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => FriendEditScreen( - initialFriends: totalFriends, - ), - ), - ); - - // 3. 편집 화면에서 돌아오면 친구 목록 새로고침 (삭제 반영) - ref.invalidate(friendListProvider); - }, - child: Text( - '편집', - style: AppTypography.c1.copyWith( - color: AppColors.gray2, - ), - ), - ), - ], - ), - ), - Expanded( - child: RefreshIndicator( - color: AppColors.primaryAble, - onRefresh: () async { - // 스크롤을 당기면 데이터를 강제로 다시 불러옵니다. - return await ref.refresh(friendListProvider.future); - }, - child: filteredFriends.isEmpty - ? SingleChildScrollView( - physics: - const AlwaysScrollableScrollPhysics(), // 친구가 없어도 스크롤을 당길 수 있게 설정 - child: SizedBox( - height: - MediaQuery.of(context).size.height * 0.5, - child: const Center(child: Text('친구가 없습니다.')), - ), - ) - : ListView.builder( - physics: - const AlwaysScrollableScrollPhysics(), // 스크롤이 가능하도록 설정 - padding: const EdgeInsets.symmetric( - horizontal: 20, - ), - itemCount: filteredFriends.length, - itemBuilder: (context, index) => - buildFriendTile(filteredFriends[index]), - ), - ), - ), - ], - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (err, stack) => - const Center(child: Text('친구 목록을 불러오지 못했습니다.')), - ), - ), - ], - ), - ); - } - - Widget buildFriendTile(Friend friend) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - // 프로필 이미지 (NetworkImage 대응) - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: const Color(0x7FDFE1DC), - shape: BoxShape.circle, - image: - friend.profileImageUrl != null && - friend.profileImageUrl!.startsWith('http') - ? DecorationImage( - image: NetworkImage(friend.profileImageUrl!), - fit: BoxFit.cover, - ) - : (friend.profileImageUrl != null - ? DecorationImage( - image: AssetImage(friend.profileImageUrl!), - fit: BoxFit.cover, - ) - : null), - ), - child: friend.profileImageUrl == null - ? Center( - child: SvgPicture.asset( - 'assets/images/icons/default_profile_icon.svg', - width: 24, - ), - ) - : null, - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - friend.nickname, - style: AppTypography.h3.copyWith(fontSize: 15), - ), - Text( - "칭호 없음", // Swagger 모델에 맞게 수정 필요 시 변경 - style: AppTypography.c1.copyWith(color: AppColors.gray2), - ), - ], - ), - ], - ), - ); - } -} diff --git a/lib/features/social/views/received_request_view.dart b/lib/features/social/views/received_request_view.dart new file mode 100644 index 0000000..9a13cf2 --- /dev/null +++ b/lib/features/social/views/received_request_view.dart @@ -0,0 +1,81 @@ +// 최초 작성자: 정승빈 +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:haenaem/core/theme/app_colors.dart'; +import '../../../../core/theme/app_typography.dart'; +import '../provider/friend_request_provider.dart'; +import '../../../shared/widgets/animated_toast.dart'; +import '../widgets/received_request_card.dart'; + +class ReceivedRequestView extends ConsumerWidget { + const ReceivedRequestView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // 1. 상태 구독 + final receivedRequestsAsync = ref.watch(receivedRequestsProvider); + + return Container( + color: const Color(0x7FDFE1DC), + child: receivedRequestsAsync.when( + data: (requests) { + if (requests.isEmpty) { + return const Center( + child: Text('받은 요청이 없습니다.', style: AppTypography.b2), + ); + } + return RefreshIndicator( + color: AppColors.primaryAble, + onRefresh: () async { + // 새로고침 시 Provider의 Future를 다시 호출하여 최신 데이터 가져오기 + return await ref.refresh(receivedRequestsProvider.future); + }, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: requests.length, + itemBuilder: (context, index) { + final req = requests[index]; + return ReceivedRequestCard( + request: req, + onAccept: () async { + try { + await ref + .read(receivedRequestsProvider.notifier) + .acceptRequest(req.requestId); + if (context.mounted) { + displayToast( + context, + '${req.user.nickname} 님과 친구가 되었습니다!', + ); + } + } catch (e) { + if (context.mounted) { + displayToast(context, '수락 처리에 실패했습니다.'); + } + } + }, + onReject: () async { + try { + await ref + .read(receivedRequestsProvider.notifier) + .rejectRequest(req.requestId); + if (context.mounted) displayToast(context, '요청을 거절했습니다.'); + } catch (e) { + if (context.mounted) { + displayToast(context, '거절 처리에 실패했습니다.'); + } + } + }, + ); + }, + // 항상 스크롤 가능하도록 설정 (요청이 1개 이하일 때도 당겨서 새로고침 가능) + physics: const AlwaysScrollableScrollPhysics(), + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, stack) => const Center(child: Text('데이터를 불러오는데 실패했습니다.')), + ), + ); + } +} diff --git a/lib/features/social/views/sent_request_view.dart b/lib/features/social/views/sent_request_view.dart new file mode 100644 index 0000000..b06c751 --- /dev/null +++ b/lib/features/social/views/sent_request_view.dart @@ -0,0 +1,63 @@ +// 최초 작성자: 정승빈 +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/theme/app_typography.dart'; +import 'package:haenaem/core/theme/app_colors.dart'; +import '../provider/friend_request_provider.dart'; +import '../../../shared/widgets/animated_toast.dart'; +import '../widgets/sent_request_card.dart'; + +class SentRequestView extends ConsumerWidget { + const SentRequestView({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sentRequestsAsync = ref.watch(sentRequestsProvider); + + return Container( + color: const Color(0x7FDFE1DC), + child: sentRequestsAsync.when( + data: (requests) { + if (requests.isEmpty) { + return const Center( + child: Text('보낸 요청이 없습니다.', style: AppTypography.b2), + ); + } + return RefreshIndicator( + color: AppColors.primaryAble, + onRefresh: () async { + // 새로고침 시 Provider의 Future를 다시 호출하여 최신 데이터 가져오기 + return await ref.refresh(sentRequestsProvider.future); + }, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: requests.length, + itemBuilder: (context, index) { + final req = requests[index]; + return SentRequestCard( + request: req, + onCancel: () async { + try { + await ref + .read(sentRequestsProvider.notifier) + .cancelRequest(req.requestId); + if (context.mounted) { + displayToast(context, '친구 신청을 취소했습니다.'); + } + } catch (e) { + if (context.mounted) displayToast(context, '취소에 실패했습니다.'); + } + }, + ); + }, + // 항상 스크롤 가능하도록 설정 (요청이 1개 이하일 때도 당겨서 새로고침 가능) + physics: const AlwaysScrollableScrollPhysics(), + ), + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, stack) => const Center(child: Text('데이터를 불러오는데 실패했습니다.')), + ), + ); + } +} diff --git a/lib/features/social/views/user_search_view.dart b/lib/features/social/views/user_search_view.dart new file mode 100644 index 0000000..f1f4d9f --- /dev/null +++ b/lib/features/social/views/user_search_view.dart @@ -0,0 +1,122 @@ +// 최초 작성자: 정승빈 +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../../core/theme/app_typography.dart'; +import '../models/user_search_card.dart'; +import '../provider/user_search_provider.dart'; +import '../../../shared/widgets/animated_toast.dart'; +import '../widgets/user_search_tile.dart'; +import 'package:haenaem/shared/widgets/custom_search_bar.dart'; + +class UserSearchView extends ConsumerStatefulWidget { + const UserSearchView({super.key}); + + @override + ConsumerState createState() => _UserSearchViewState(); +} + +class _UserSearchViewState extends ConsumerState { + final TextEditingController searchController = TextEditingController(); + bool isSearchPerformed = false; // 검색창에 입력 후 엔터를 쳤는지 여부만 관리 + + @override + void dispose() { + searchController.dispose(); + super.dispose(); + } + + Future performSearch() async { + if (searchController.text.trim().isEmpty) return; + + setState(() => isSearchPerformed = true); + + // 비즈니스 로직은 Provider에게 위임 + await ref + .read(userSearchProvider.notifier) + .searchUsers(searchController.text); + } + + Future sendFriendRequestAction(UserSearchCard card) async { + try { + await ref.read(userSearchProvider.notifier).sendFriendRequest(card); + if (mounted) { + displayToast(context, '${card.user.nickname} 님에게 친구 신청을 보냈습니다!'); + } + } catch (e) { + if (mounted) { + displayToast(context, '이미 신청되었거나 신청에 실패했습니다.'); + } + } + } + + @override + Widget build(BuildContext context) { + // 1. 검색 상태 구독 + final searchState = ref.watch(userSearchProvider); + + return Column( + children: [ + _buildSearchInputSection(), + + // 2. 검색 상태에 따른 UI 렌더링 + Expanded( + child: searchState.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) { + // 검색 에러 시 토스트는 유지하되 화면에는 에러 문구 표시 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) displayToast(context, '검색 중 오류가 발생했습니다.'); + }); + return const Center(child: Text('검색 결과가 없거나 오류가 발생했습니다.')); + }, + data: (results) { + if (!isSearchPerformed) { + return const SizedBox.expand(); + } + return Column( + children: [ + _buildResultCountHeader(results.length), + Expanded(child: _buildSearchResultList(results)), + ], + ); + }, + ), + ), + ], + ); + } + + Widget _buildSearchInputSection() { + return Padding( + padding: const EdgeInsets.all(20), + child: CustomSearchBar( + controller: searchController, + hintText: '닉네임을 검색하세요', + onSubmitted: (_) => performSearch(), // onChanged 대신 엔터를 쳤을 때 작동 + ), + ); + } + + Widget _buildResultCountHeader(int count) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: Text('검색 결과 $count명', style: AppTypography.b2), + ); + } + + Widget _buildSearchResultList(List results) { + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 20), + itemCount: results.length, + itemBuilder: (context, index) { + final card = results[index]; + return UserSearchTile( + searchCard: card, + onRequest: () => sendFriendRequestAction(card), + ); + }, + ); + } +} diff --git a/lib/features/social/widgets/delete_confirm_dialog.dart b/lib/features/social/widgets/delete_confirm_dialog.dart new file mode 100644 index 0000000..acf282e --- /dev/null +++ b/lib/features/social/widgets/delete_confirm_dialog.dart @@ -0,0 +1,81 @@ +// 최초 작성자: 정승빈 + +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/theme/app_typography.dart'; + +class DeleteConfirmDialog extends StatelessWidget { + final String userNickname; + final VoidCallback onDelete; + + const DeleteConfirmDialog({ + super.key, + required this.userNickname, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + backgroundColor: Colors.white, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('친구 삭제', style: AppTypography.h2), + const SizedBox(height: 12), + Text( + '$userNickname 님을 삭제하시겠습니까?', + style: AppTypography.b2.copyWith(color: AppColors.gray2), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + height: 48, + decoration: BoxDecoration( + color: const Color(0x7FDFE1DC), + borderRadius: BorderRadius.circular(10), + ), + child: const Center( + child: Text('취소', style: AppTypography.b1), + ), + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: GestureDetector( + onTap: onDelete, + child: Container( + height: 48, + decoration: BoxDecoration( + color: const Color(0x7FDFE1DC), + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: Text( + '삭제하기', + style: AppTypography.b1.copyWith( + color: AppColors.notification, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/social/widgets/friend_edit_tile.dart b/lib/features/social/widgets/friend_edit_tile.dart new file mode 100644 index 0000000..b4996a3 --- /dev/null +++ b/lib/features/social/widgets/friend_edit_tile.dart @@ -0,0 +1,38 @@ +// 최초 작성자: 정승빈 + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../../../core/theme/app_colors.dart'; + +import '../../../shared/models/user.dart'; +import 'package:haenaem/shared/widgets/user_list_tile.dart'; + +class FriendEditTile extends StatelessWidget { + final User user; + final VoidCallback onDeleteTap; + + const FriendEditTile({ + super.key, + required this.user, + required this.onDeleteTap, + }); + + @override + Widget build(BuildContext context) { + return UserListTile( + user: user, + trailing: IconButton( + onPressed: onDeleteTap, + icon: SvgPicture.asset( + 'assets/images/icons/big_trash_icon.svg', + width: 24, + colorFilter: const ColorFilter.mode( + AppColors.notification, + BlendMode.srcIn, + ), + ), + ), + ); + } +} diff --git a/lib/features/social/widgets/friend_list_tile.dart b/lib/features/social/widgets/friend_list_tile.dart new file mode 100644 index 0000000..d26d311 --- /dev/null +++ b/lib/features/social/widgets/friend_list_tile.dart @@ -0,0 +1,17 @@ +// 최초 작성자: 정승빈 + +import 'package:flutter/material.dart'; + +import '../../../shared/models/user.dart'; +import 'package:haenaem/shared/widgets/user_list_tile.dart'; + +class FriendListTile extends StatelessWidget { + final User user; + + const FriendListTile({super.key, required this.user}); + + @override + Widget build(BuildContext context) { + return UserListTile(user: user); + } +} diff --git a/lib/features/social/widgets/received_request_card.dart b/lib/features/social/widgets/received_request_card.dart new file mode 100644 index 0000000..0152278 --- /dev/null +++ b/lib/features/social/widgets/received_request_card.dart @@ -0,0 +1,109 @@ +// 최초 작성자: 정승빈 + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import 'package:haenaem/core/theme/app_colors.dart'; +import 'package:haenaem/core/theme/app_typography.dart'; +import 'package:haenaem/shared/widgets/user_profile_circle.dart'; + +import 'package:haenaem/features/social/models/friend_request_card.dart'; + +class ReceivedRequestCard extends StatelessWidget { + final FriendRequestCard request; + final VoidCallback onAccept; + final VoidCallback onReject; + + const ReceivedRequestCard({ + super.key, + required this.request, + required this.onAccept, + required this.onReject, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Row( + children: [ + UserProfileCircle(imageUrl: request.user.profileUrl, size: 48), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(request.user.nickname, style: AppTypography.h3), + Text( + "함께 아는 친구 0명", // 추후 데이터 연동 필요 + style: AppTypography.c1.copyWith(color: AppColors.gray2), + ), + Text( + DateFormat('yyyy년 MM월 dd일').format(request.requestDate), + style: AppTypography.c2.copyWith(color: AppColors.gray3), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _buildActionButton( + label: '거절', + bg: const Color(0x7FDFE1DC), + text: AppColors.gray2, + onTap: onReject, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _buildActionButton( + label: '수락', + bg: AppColors.primaryAble, + text: Colors.white, + onTap: onAccept, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildActionButton({ + required String label, + required Color bg, + required Color text, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + height: 48, + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(10), + ), + alignment: Alignment.center, + child: Text( + label, + style: AppTypography.b1.copyWith( + color: text, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } +} diff --git a/lib/features/social/widgets/sent_request_card.dart b/lib/features/social/widgets/sent_request_card.dart new file mode 100644 index 0000000..d160f18 --- /dev/null +++ b/lib/features/social/widgets/sent_request_card.dart @@ -0,0 +1,84 @@ +// 최초 작성자: 정승빈 + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import 'package:haenaem/core/theme/app_colors.dart'; +import 'package:haenaem/core/theme/app_typography.dart'; + +import 'package:haenaem/shared/widgets/user_profile_circle.dart'; +import '../models/friend_request_card.dart'; + +class SentRequestCard extends StatelessWidget { + final FriendRequestCard request; + final VoidCallback onCancel; + + const SentRequestCard({ + super.key, + required this.request, + required this.onCancel, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + UserProfileCircle( + imageUrl: request.user.profileUrl, + size: 48, + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(request.user.nickname, style: AppTypography.h3), + Text( + DateFormat('yyyy년 MM월 dd일').format(request.requestDate), + style: AppTypography.c2.copyWith( + color: AppColors.gray3, + ), + ), + ], + ), + ], + ), + ], + ), + const SizedBox(height: 16), + GestureDetector( + onTap: onCancel, + child: Container( + width: double.infinity, + height: 48, + decoration: BoxDecoration( + color: const Color(0x7FDFE1DC), + borderRadius: BorderRadius.circular(10), + ), + alignment: Alignment.center, + child: Text( + '요청 취소', + style: AppTypography.b1.copyWith( + color: AppColors.gray2, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/social/widgets/user_search_tile.dart b/lib/features/social/widgets/user_search_tile.dart new file mode 100644 index 0000000..6f04148 --- /dev/null +++ b/lib/features/social/widgets/user_search_tile.dart @@ -0,0 +1,57 @@ +// 최초 작성자: 정승빈 + +import 'package:flutter/material.dart'; + +import 'package:haenaem/core/theme/app_colors.dart'; +import 'package:haenaem/core/theme/app_typography.dart'; + +import 'package:haenaem/features/social/models/user_search_card.dart'; +import 'package:haenaem/shared/widgets/user_list_tile.dart'; + +class UserSearchTile extends StatelessWidget { + final UserSearchCard searchCard; + final VoidCallback onRequest; + + const UserSearchTile({ + super.key, + required this.searchCard, + required this.onRequest, + }); + + @override + Widget build(BuildContext context) { + return UserListTile( + user: searchCard.user, + padding: const EdgeInsets.symmetric(vertical: 12), // 여기만 12 + trailing: _buildRequestButton(), + ); + } + + Widget _buildRequestButton() { + // 1. 이미 친구인 경우 버튼을 숨김 + if (searchCard.state == FriendState.friend) { + return const SizedBox.shrink(); + } + + // 2. 신청 대기 상태 확인 + final isRequested = searchCard.state == FriendState.pending; + + return GestureDetector( + onTap: isRequested ? null : onRequest, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: isRequested ? AppColors.disable : AppColors.primaryAble, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + isRequested ? '신청됨' : '친구 신청', + style: AppTypography.c1.copyWith( + color: isRequested ? AppColors.gray2 : Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } +} diff --git a/lib/features/statistics/data/activity_repository.dart b/lib/features/statistics/data/activity_repository.dart new file mode 100644 index 0000000..1cccadf --- /dev/null +++ b/lib/features/statistics/data/activity_repository.dart @@ -0,0 +1,58 @@ +// 최초 작성자: 김채영 +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:haenaem/core/network/dio_provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'activity_repository.g.dart'; + +// 해냄 잔디 레포지토리 +class ActivityData { + final int successDays; + final int currentStreak; + final List activity; + + ActivityData({ + required this.successDays, + required this.currentStreak, + required this.activity, + }); + + factory ActivityData.fromJson(Map json) { + return ActivityData( + successDays: json['successDays'], + currentStreak: json['currentStreak'], + activity: List.from(json['activity']), + ); + } +} + +@riverpod +class ActivityRepository extends _$ActivityRepository { + @override + Future build() async { + final dio = ref.watch(dioProvider); + return _fetchActivity(dio); + } + + Future _fetchActivity(Dio dio) async { + final year = DateTime.now().year; + final response = await dio.get( + '/api/users/activity', + queryParameters: {'year': year}, + ); + // ✅ API 응답 원본 확인 + debugPrint('🌐 [ActivityRepository] status: ${response.statusCode}'); + debugPrint('🌐 [ActivityRepository] raw data: ${response.data}'); + + return ActivityData.fromJson(response.data); + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + final dio = ref.read(dioProvider); + return _fetchActivity(dio); + }); + } +} diff --git a/lib/features/statistics/data/activity_repository.g.dart b/lib/features/statistics/data/activity_repository.g.dart new file mode 100644 index 0000000..4041ef3 --- /dev/null +++ b/lib/features/statistics/data/activity_repository.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'activity_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$activityRepositoryHash() => + r'deac898ee453d5c15e95786c15a00557263bffc9'; + +/// See also [ActivityRepository]. +@ProviderFor(ActivityRepository) +final activityRepositoryProvider = + AutoDisposeAsyncNotifierProvider.internal( + ActivityRepository.new, + name: r'activityRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$activityRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$ActivityRepository = AutoDisposeAsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/statistics/data/distribution_repository.dart b/lib/features/statistics/data/distribution_repository.dart new file mode 100644 index 0000000..c3cecea --- /dev/null +++ b/lib/features/statistics/data/distribution_repository.dart @@ -0,0 +1,128 @@ +// 최초 작성자: 김채영 +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:haenaem/core/network/dio_provider.dart'; +import 'package:haenaem/core/theme/app_colors.dart'; +import 'package:haenaem/features/statistics/widgets/pie_graph.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'distribution_repository.g.dart'; + +// 나의 해냄 분포 데이터 매핑 +class DistributionData { + final int totalCount; + final List top3; // 1~3위: 각각 색상 + final List rest; // 4위~: 각각 따로 (이름, 횟수 표시용) + final int restCount; // 4위~ 합산 (파이 차트용) + + DistributionData({ + required this.totalCount, + required this.top3, + required this.rest, + required this.restCount, + }); +} + +@riverpod +class DistributionRepository extends _$DistributionRepository { + @override + Future build() async { + final dio = ref.watch(dioProvider); + return _fetchDistribution(dio); + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + final dio = ref.read(dioProvider); + return _fetchDistribution(dio); + }); + } + + Future _fetchDistribution(Dio dio) async { + final response = await dio.get('/api/users/graph/tag'); + + debugPrint('🥧 [Distribution] ── 1. API 원본 응답 ──────────────────'); + debugPrint('🥧 [Distribution] status: ${response.statusCode}'); + debugPrint('🥧 [Distribution] raw data: ${response.data}'); + + final List raw = response.data; + + debugPrint('🥧 [Distribution] ── 2. 정렬 전 ──────────────────────'); + for (int i = 0; i < raw.length; i++) { + debugPrint( + '🥧 [Distribution] [$i] ${raw[i]['tagName']} : ${raw[i]['count']}번', + ); + } + + raw.sort((a, b) => (b['count'] as int).compareTo(a['count'] as int)); + + debugPrint('🥧 [Distribution] ── 3. 정렬 후 ──────────────────────'); + for (int i = 0; i < raw.length; i++) { + debugPrint( + '🥧 [Distribution] [$i] ${raw[i]['tagName']} : ${raw[i]['count']}번', + ); + } + + const top3Colors = [ + Color(0xFF8979FF), // 1위 보라 + Color(0xFFFF928A), // 2위 핑크 + Color(0xFF3CC3DF), // 3위 파랑 + ]; + + // 1~3위: 각각 색상 유지 + final top3 = raw.take(3).toList().asMap().entries.map((entry) { + return TagCount( + tag: entry.value['tagName'], + count: entry.value['count'], + color: top3Colors[entry.key], + ); + }).toList(); + + // 4위~: 이름/횟수 각각 유지 (UI 표시용), 색상은 gray4 + final restList = raw.skip(3).toList(); + final rest = restList + .take(3) + .map( + (item) => TagCount( + tag: item['tagName'], + count: item['count'], + color: AppColors.gray4, + ), + ) + .toList(); + + // 4위~ 합산 (파이 차트에서 gray4 하나로 표시용) + final restCount = restList.fold( + 0, + (sum, item) => sum + (item['count'] as int), + ); + + final totalCount = raw.fold( + 0, + (sum, item) => sum + (item['count'] as int), + ); + + debugPrint('🥧 [Distribution] ── 4. 위젯 전달 데이터 ───────────────'); + debugPrint('🥧 [Distribution] totalCount: $totalCount'); + for (int i = 0; i < top3.length; i++) { + final medal = ['🥇', '🥈', '🥉'][i]; + debugPrint( + '🥧 [Distribution] $medal ${i + 1}위 | ${top3[i].tag} : ${top3[i].count}번', + ); + } + for (int i = 0; i < rest.length; i++) { + debugPrint( + '🥧 [Distribution] 🔘 ${i + 4}위 | ${rest[i].tag} : ${rest[i].count}번', + ); + } + debugPrint('🥧 [Distribution] 🔘 기타 합산: $restCount번 (파이 차트용)'); + + return DistributionData( + totalCount: totalCount, + top3: top3, + rest: rest, + restCount: restCount, + ); + } +} diff --git a/lib/features/statistics/data/distribution_repository.g.dart b/lib/features/statistics/data/distribution_repository.g.dart new file mode 100644 index 0000000..f831d67 --- /dev/null +++ b/lib/features/statistics/data/distribution_repository.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'distribution_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$distributionRepositoryHash() => + r'dc3366618b82dc5934300051f6b59ba877a536f7'; + +/// See also [DistributionRepository]. +@ProviderFor(DistributionRepository) +final distributionRepositoryProvider = + AutoDisposeAsyncNotifierProvider< + DistributionRepository, + DistributionData + >.internal( + DistributionRepository.new, + name: r'distributionRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$distributionRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$DistributionRepository = AutoDisposeAsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/statistics/data/monthly_weekly_repository.dart b/lib/features/statistics/data/monthly_weekly_repository.dart new file mode 100644 index 0000000..006ead1 --- /dev/null +++ b/lib/features/statistics/data/monthly_weekly_repository.dart @@ -0,0 +1,63 @@ +// 최초 작성자: 김채영 +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:haenaem/core/network/dio_provider.dart'; +import 'package:haenaem/features/statistics/widgets/line_graph/monthly_line_graph.dart'; +import 'package:haenaem/features/statistics/widgets/line_graph/weekly_line_graph.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'monthly_weekly_repository.g.dart'; + +// 나의 해냄 추이 레포지토리 +class MonthlyWeeklyData { + final WeeklyGraphData monthly; + final DailyGraphData weekly; + + MonthlyWeeklyData({required this.monthly, required this.weekly}); +} + +@riverpod +class MonthlyWeeklyRepository extends _$MonthlyWeeklyRepository { + // ✅ _$TrendRepository → _$MonthlyWeeklyRepository + @override + Future build() async { + final dio = ref.watch(dioProvider); + return _fetch(dio); + } + + Future _fetch(Dio dio) async { + final responses = await Future.wait([ + dio.get('/api/users/graph/4weeks'), + dio.get('/api/users/graph/weekly'), // ✅ daily → weekly + ]); + + debugPrint('📈 [MonthlyWeekly] ── 월간 API 응답 ──────────────────────'); + debugPrint('📈 [MonthlyWeekly] status: ${responses[0].statusCode}'); + debugPrint('📈 [MonthlyWeekly] raw data: ${responses[0].data}'); + + debugPrint('📈 [MonthlyWeekly] ── 주간 API 응답 ──────────────────────'); + debugPrint('📈 [MonthlyWeekly] status: ${responses[1].statusCode}'); + debugPrint('📈 [MonthlyWeekly] raw data: ${responses[1].data}'); + + final monthly = WeeklyGraphData.fromJson(responses[0].data); + final weekly = DailyGraphData.fromJson(responses[1].data); + + debugPrint('📈 [MonthlyWeekly] ── 월간 파싱 결과 ─────────────────────'); + debugPrint('📈 [MonthlyWeekly] thisMonth: ${monthly.thisMonth}'); + debugPrint('📈 [MonthlyWeekly] lastMonth: ${monthly.lastMonth}'); + + debugPrint('📈 [MonthlyWeekly] ── 주간 파싱 결과 ─────────────────────'); + debugPrint('📈 [MonthlyWeekly] thisWeek: ${weekly.thisWeek}'); + debugPrint('📈 [MonthlyWeekly] lastWeek: ${weekly.lastWeek}'); + + return MonthlyWeeklyData(monthly: monthly, weekly: weekly); + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + final dio = ref.read(dioProvider); + return _fetch(dio); + }); + } +} diff --git a/lib/features/statistics/data/monthly_weekly_repository.g.dart b/lib/features/statistics/data/monthly_weekly_repository.g.dart new file mode 100644 index 0000000..66a5f18 --- /dev/null +++ b/lib/features/statistics/data/monthly_weekly_repository.g.dart @@ -0,0 +1,30 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'monthly_weekly_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$monthlyWeeklyRepositoryHash() => + r'a1b957980c63fa664de422f1081d12f2ffda867f'; + +/// See also [MonthlyWeeklyRepository]. +@ProviderFor(MonthlyWeeklyRepository) +final monthlyWeeklyRepositoryProvider = + AutoDisposeAsyncNotifierProvider< + MonthlyWeeklyRepository, + MonthlyWeeklyData + >.internal( + MonthlyWeeklyRepository.new, + name: r'monthlyWeeklyRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$monthlyWeeklyRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$MonthlyWeeklyRepository = AutoDisposeAsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/statistics/provider/line_graph_provider.dart b/lib/features/statistics/provider/line_graph_provider.dart new file mode 100644 index 0000000..d834c0a --- /dev/null +++ b/lib/features/statistics/provider/line_graph_provider.dart @@ -0,0 +1,5 @@ +// 최초 작성자: 김채영 +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +// true면 월간, false면 주간 +final graphTypeProvider = StateProvider((ref) => true); diff --git a/lib/features/statistics/screens/statistics_screen.dart b/lib/features/statistics/screens/statistics_screen.dart new file mode 100644 index 0000000..7120e7f --- /dev/null +++ b/lib/features/statistics/screens/statistics_screen.dart @@ -0,0 +1,150 @@ +// 최초 작성자: 김채영 +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:haenaem/core/theme/app_colors.dart'; +import 'package:haenaem/core/theme/app_typography.dart'; +import '../data/activity_repository.dart'; +import '../data/distribution_repository.dart'; +import '../data/monthly_weekly_repository.dart'; +import '../widgets/haenaem_grass.dart'; +import '../widgets/pie_graph.dart'; +import '../widgets/line_graph/line_graph.dart'; + +// 통계화면 프레임 (안에다 위젯을 넣는 구조) +class StatisticsScreen extends ConsumerWidget { + const StatisticsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final activityAsync = ref.watch(activityRepositoryProvider); + final distributionAsync = ref.watch(distributionRepositoryProvider); + final monthlyWeeklyAsync = ref.watch(monthlyWeeklyRepositoryProvider); + + return Scaffold( + backgroundColor: AppColors.gray5, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + scrolledUnderElevation: 0, + title: Text( + '통계', + style: AppTypography.h3.copyWith(color: AppColors.black), + ), + centerTitle: true, + ), + body: SingleChildScrollView( + child: Column( + children: [ + const SizedBox(height: 10), + // 해냄 잔디 + activityAsync.when( + loading: () => const SizedBox( + height: 200, + child: Center(child: CircularProgressIndicator()), + ), + error: (e, _) => SizedBox( + height: 200, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '데이터를 불러오지 못했어요', + style: AppTypography.b2.copyWith( + color: AppColors.gray4, + ), + ), + const SizedBox(height: 8), + TextButton( + onPressed: () => ref + .read(activityRepositoryProvider.notifier) + .refresh(), + child: const Text('다시 시도'), + ), + ], + ), + ), + ), + data: (data) => HaenaemGrass( + successDays: data.successDays, + currentStreak: data.currentStreak, + activity: data.activity, + ), + ), + const SizedBox(height: 20), + // 나의 해냄 분포 + distributionAsync.when( + loading: () => const SizedBox( + height: 200, + child: Center(child: CircularProgressIndicator()), + ), + error: (e, _) => SizedBox( + height: 200, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '데이터를 불러오지 못했어요', + style: AppTypography.b2.copyWith( + color: AppColors.gray4, + ), + ), + const SizedBox(height: 8), + TextButton( + onPressed: () => ref + .read(distributionRepositoryProvider.notifier) + .refresh(), + child: const Text('다시 시도'), + ), + ], + ), + ), + ), + data: (data) => PieGraph( + top3: data.top3, + rest: data.rest, + restCount: data.restCount, + totalCount: data.totalCount, + ), + ), + const SizedBox(height: 20), + // 나의 해냄 추이 + monthlyWeeklyAsync.when( + loading: () => const SizedBox( + height: 200, + child: Center(child: CircularProgressIndicator()), + ), + error: (e, _) => SizedBox( + height: 200, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '데이터를 불러오지 못했어요', + style: AppTypography.b2.copyWith( + color: AppColors.gray4, + ), + ), + const SizedBox(height: 8), + TextButton( + onPressed: () => ref + .read(monthlyWeeklyRepositoryProvider.notifier) + .refresh(), + child: const Text('다시 시도'), + ), + ], + ), + ), + ), + data: (data) => + LineGraph(monthlyData: data.monthly, weeklyData: data.weekly), + ), + const SizedBox(height: 20), + ], + ), + ), + ); + } +} diff --git a/lib/features/statistics/widgets/haenaem_grass.dart b/lib/features/statistics/widgets/haenaem_grass.dart new file mode 100644 index 0000000..2bf27dc --- /dev/null +++ b/lib/features/statistics/widgets/haenaem_grass.dart @@ -0,0 +1,138 @@ +// 최초 작성자: 김채영 +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:haenaem/core/theme/app_colors.dart'; +import 'package:haenaem/core/theme/app_typography.dart'; +import 'statistics_card.dart'; + +// 해냄 잔디 위젯 +class HaenaemGrass extends StatelessWidget { + final int successDays; + final int currentStreak; + final List activity; + + const HaenaemGrass({ + super.key, + required this.successDays, + required this.currentStreak, + required this.activity, + }); + + int _getLevel(int count) { + if (count <= 0) return 0; + if (count == 1) return 1; + if (count == 2) return 2; + if (count == 3) return 3; + return 4; + } + + @override + Widget build(BuildContext context) { + debugPrint('🌿 [HaenaemGrass] successDays: $successDays'); + debugPrint('🔥 [HaenaemGrass] currentStreak: $currentStreak'); + debugPrint('📊 [HaenaemGrass] activity length: ${activity.length}'); + debugPrint( + '📊 [HaenaemGrass] activity (first 10): ${activity.take(10).toList()}', + ); + + final int year = DateTime.now().year; + final List daysInMonths = List.generate(12, (monthIndex) { + // 다음 달 1일에서 하루를 빼면 이번 달 마지막 날 = 해당 월의 일수 + return DateTime(year, monthIndex + 2, 0).day; + }); + debugPrint('📅 [HaenaemGrass] year: $year, daysInMonths: $daysInMonths'); + + return StatisticsCard( + title: "해냄 잔디", + headerAction: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildSummaryItem( + 'assets/images/icons/mini_success_icon.svg', + '$successDays일', + AppColors.primaryAble, + ), + const SizedBox(width: 10), + _buildSummaryItem( + 'assets/images/icons/small_fire_icon.svg', + '$currentStreak일', + AppColors.fire, + ), + ], + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.5), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 6, + // ✅ 월 루프 (12개) + children: List.generate(12, (monthIndex) { + int dayCount = daysInMonths[monthIndex]; + + // ✅ startIndex: 이 월 이전까지의 누적 일수 + int startIndex = daysInMonths + .sublist(0, monthIndex) + .fold(0, (sum, d) => sum + d); + + // ✅ 일 루프 (각 월의 일수만큼) + return Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 2, + children: List.generate(dayCount, (dayIndex) { + int activityIndex = startIndex + dayIndex; + int count = activityIndex < activity.length + ? activity[activityIndex] + : 0; + return _buildGrassNode(_getLevel(count)); + }), + ); + }), + ), + ), + ); + } + + Widget _buildSummaryItem(String iconPath, String text, Color textColor) { + return Row( + children: [ + SvgPicture.asset( + iconPath, + width: 20, + height: 20, + colorFilter: ColorFilter.mode(textColor, BlendMode.srcIn), + ), + const SizedBox(width: 4), + Text(text, style: AppTypography.b2.copyWith(color: textColor)), + ], + ); + } + + Widget _buildGrassNode(int level) { + Color nodeColor; + switch (level) { + case 1: + nodeColor = AppColors.primaryAble.withValues(alpha: 0.30); + break; + case 2: + nodeColor = AppColors.primaryAble.withValues(alpha: 0.55); + break; + case 3: + nodeColor = AppColors.primaryAble.withValues(alpha: 0.80); + break; + case 4: + nodeColor = AppColors.primaryAble; + break; + default: + nodeColor = AppColors.gray5; + } + + return Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: nodeColor, + borderRadius: BorderRadius.circular(1), + ), + ); + } +} diff --git a/lib/features/statistics/widgets/line_graph/line_chart_grid.dart b/lib/features/statistics/widgets/line_graph/line_chart_grid.dart new file mode 100644 index 0000000..4b56fa8 --- /dev/null +++ b/lib/features/statistics/widgets/line_graph/line_chart_grid.dart @@ -0,0 +1,290 @@ +// 최초 작성자: 김채영 +import 'package:flutter/material.dart'; +import 'package:haenaem/core/theme/app_colors.dart'; +import 'package:haenaem/core/theme/app_typography.dart'; + +// ─────────────────────────────────────────────── +// 공통 라인 차트 위젯 +// ─────────────────────────────────────────────── + +/// 월간/주간 공통 꺾은선 그래프 위젯 +/// +/// - [columnCount] : 데이터 포인트 수 (월간: 4, 주간: 7) +/// - [rowCount] : 가로 구간 수 (월간: 3, 주간: 5) → 가로선은 rowCount+1개 +/// - [xLabels] : X축 레이블 목록 (columnCount와 길이 일치) +/// - [yMax] : Y축 최댓값 (데이터 기반으로 계산된 값) +/// - [thisData] : 이번 달/주 데이터 +/// - [lastData] : 저번 달/주 데이터 +/// - [labelColor] : 축 레이블 색상 (기본: AppColors.gray1) +class LineChartGrid extends StatelessWidget { + final int columnCount; + final int rowCount; + final List xLabels; + final double yMax; + final List thisData; + final List lastData; + final Color? labelColor; + + const LineChartGrid({ + super.key, + required this.columnCount, + this.rowCount = 3, + required this.xLabels, + required this.yMax, + required this.thisData, + required this.lastData, + this.labelColor, + }) : assert( + xLabels.length == columnCount, + 'xLabels 길이는 columnCount와 같아야 합니다.', + ); + + @override + Widget build(BuildContext context) { + final axisColor = labelColor ?? AppColors.gray1; + + // Y축 레이블: yMax에서 0까지 rowCount+1개 균등 분할 + final yLabels = List.generate( + rowCount + 1, + (i) => (yMax * (rowCount - i) / rowCount).round(), + ); + + return SizedBox( + width: double.infinity, + height: 164, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── 그래프 본체 (Y축 레이블 + 캔버스) ── + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Y축 레이블 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.end, + children: yLabels + .map( + (label) => Text( + '$label', + style: AppTypography.c1.copyWith(color: axisColor), + ), + ) + .toList(), + ), + ), + + // 그래프 캔버스 + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: CustomPaint( + painter: _LineChartPainter( + columnCount: columnCount, + rowCount: rowCount, + yMax: yMax, + thisData: thisData, + lastData: lastData, + ), + ), + ), + ), + ], + ), + ), + + // ── X축 레이블 ── + // 그래프 본체와 동일한 Row 구조 사용: + // [Y축 더미 공간] + [캔버스 영역을 columnCount로 균등 분할] + // → Y축 너비를 하드코딩 없이 정확히 맞춤 + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Y축 레이블과 동일한 패딩/텍스트 스타일로 더미 공간 확보 + IntrinsicWidth( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + '${yMax.toInt()}', + style: AppTypography.c1.copyWith( + color: Colors.transparent, + ), + ), + ), + ), + + // 캔버스 영역: columnCount 구간으로 균등 분할 + // 각 Expanded가 세로 점선 사이 한 구간에 대응 + Expanded( + child: Row( + children: List.generate( + columnCount, + (i) => Expanded( + child: Text( + xLabels[i], + textAlign: TextAlign.center, + style: AppTypography.c1.copyWith(color: axisColor), + ), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +// ─────────────────────────────────────────────── +// CustomPainter +// ─────────────────────────────────────────────── + +class _LineChartPainter extends CustomPainter { + final int columnCount; + final int rowCount; + final double yMax; + final List thisData; + final List lastData; + + const _LineChartPainter({ + required this.columnCount, + required this.rowCount, + required this.yMax, + required this.thisData, + required this.lastData, + }); + + @override + void paint(Canvas canvas, Size size) { + _drawGrid(canvas, size); + if (lastData.length >= columnCount) { + _drawLine(canvas, size, lastData, AppColors.gray4); + _drawDots(canvas, size, lastData, AppColors.gray4); + } + if (thisData.length >= columnCount) { + _drawLine(canvas, size, thisData, AppColors.primaryAble); + _drawDots(canvas, size, thisData, AppColors.primaryAble); + } + } + + /// 격자선 + /// - 가로: rowCount+1개, 맨 아래만 실선 나머지는 점선 + /// - 세로: columnCount+1개 점선 (양끝 포함) + void _drawGrid(Canvas canvas, Size size) { + final solidPaint = Paint() + ..color = AppColors.gray4 + ..strokeWidth = 1; + + final dashedPaint = Paint() + ..color = AppColors.gray4 + ..strokeWidth = 1; + + // 가로선: rowCount+1개 (i=0: top, i=rowCount: bottom) + for (int i = 0; i <= rowCount; i++) { + final y = size.height * i / rowCount; + if (i == rowCount) { + // 맨 아래만 실선 + canvas.drawLine(Offset(0, y), Offset(size.width, y), solidPaint); + } else { + // 나머지 점선 + _drawDashedHorizontalLine(canvas, y, 0, size.width, dashedPaint); + } + } + + // 세로 점선: columnCount+1개 (양끝 포함) + for (int i = 0; i <= columnCount; i++) { + final x = size.width * i / columnCount; + _drawDashedVerticalLine(canvas, x, 0, size.height, dashedPaint); + } + } + + // 점선 가로선 헬퍼 + void _drawDashedHorizontalLine( + Canvas canvas, + double y, + double startX, + double endX, + Paint paint, + ) { + const dashWidth = 4.0; + const dashGap = 3.0; + double currentX = startX; + while (currentX < endX) { + final nextX = (currentX + dashWidth).clamp(0.0, endX); + canvas.drawLine(Offset(currentX, y), Offset(nextX, y), paint); + currentX += dashWidth + dashGap; + } + } + + // 점선 세로선 헬퍼 + void _drawDashedVerticalLine( + Canvas canvas, + double x, + double startY, + double endY, + Paint paint, + ) { + const dashHeight = 4.0; + const dashGap = 3.0; + double currentY = startY; + while (currentY < endY) { + final nextY = (currentY + dashHeight).clamp(0.0, endY); + canvas.drawLine(Offset(x, currentY), Offset(x, nextY), paint); + currentY += dashHeight + dashGap; + } + } + + /// 꺾은선 그리기 + /// x 위치: 각 구간 중앙 → (2i + 1) / (2 * columnCount) + void _drawLine(Canvas canvas, Size size, List values, Color color) { + final paint = Paint() + ..color = color + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + + final path = Path(); + for (int i = 0; i < columnCount; i++) { + final x = size.width * (2 * i + 1) / (2 * columnCount); + final y = size.height - (values[i] / yMax) * size.height; + i == 0 ? path.moveTo(x, y) : path.lineTo(x, y); + } + canvas.drawPath(path, paint); + } + + /// 데이터 포인트 점 그리기 (흰색 채우기 + 컬러 테두리) + void _drawDots(Canvas canvas, Size size, List values, Color color) { + final fillPaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + final borderPaint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + + for (int i = 0; i < columnCount; i++) { + final x = size.width * (2 * i + 1) / (2 * columnCount); + final y = size.height - (values[i] / yMax) * size.height; + canvas.drawCircle(Offset(x, y), 4, fillPaint); + canvas.drawCircle(Offset(x, y), 4, borderPaint); + } + } + + @override + bool shouldRepaint(covariant _LineChartPainter old) => + old.columnCount != columnCount || + old.rowCount != rowCount || + old.yMax != yMax || + old.thisData != thisData || + old.lastData != lastData; +} diff --git a/lib/features/statistics/widgets/line_graph/line_graph.dart b/lib/features/statistics/widgets/line_graph/line_graph.dart new file mode 100644 index 0000000..aa03195 --- /dev/null +++ b/lib/features/statistics/widgets/line_graph/line_graph.dart @@ -0,0 +1,137 @@ +// 최초 작성자: 김채영 +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../provider/line_graph_provider.dart'; +import 'package:haenaem/core/theme/app_colors.dart'; +import 'package:haenaem/core/theme/app_typography.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import '../statistics_card.dart'; +import 'monthly_line_graph.dart'; +import 'weekly_line_graph.dart'; + +// 나의 해냄 추이 위젯 틀 +class LineGraph extends ConsumerWidget { + final WeeklyGraphData monthlyData; + final DailyGraphData weeklyData; + + const LineGraph({ + super.key, + required this.monthlyData, + required this.weeklyData, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isMonthly = ref.watch(graphTypeProvider); + + return StatisticsCard( + title: "나의 해냄 추이", + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Column( + children: [ + _buildToggleButtons(ref, isMonthly), + const SizedBox(height: 20), + _buildLegend(isMonthly), + const SizedBox(height: 12), + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: KeyedSubtree( + key: ValueKey(isMonthly), + child: isMonthly + ? MonthlyLineGraph(data: monthlyData) + : WeeklyLineGraph(data: weeklyData), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildLegend(bool isMonthly) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 8, + children: [ + _buildLegendItem( + iconPath: 'assets/images/icons/green_line_graph_icon.svg', + label: isMonthly ? '이번 달' : '이번 주', + ), + _buildLegendItem( + iconPath: 'assets/images/icons/gray_line_graph_icon.svg', + label: isMonthly ? '저번 달' : '저번 주', + ), + ], + ); + } + + Widget _buildLegendItem({required String iconPath, required String label}) { + return Row( + mainAxisSize: MainAxisSize.min, + spacing: 4, + children: [ + SvgPicture.asset(iconPath, width: 16, height: 16), + Text(label, style: AppTypography.c1.copyWith(color: AppColors.gray1)), + ], + ); + } + + Widget _buildToggleButtons(WidgetRef ref, bool isMonthly) { + return Container( + width: double.infinity, + height: 35.99, + decoration: ShapeDecoration( + color: AppColors.gray5, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(100)), + ), + child: Row( + children: [ + _buildTab( + ref: ref, + label: '월간', + isSelected: isMonthly, + onTap: () => ref.read(graphTypeProvider.notifier).state = true, + ), + _buildTab( + ref: ref, + label: '주간', + isSelected: !isMonthly, + onTap: () => ref.read(graphTypeProvider.notifier).state = false, + ), + ], + ), + ); + } + + Widget _buildTab({ + required WidgetRef ref, + required String label, + required bool isSelected, + required VoidCallback onTap, + }) { + return Expanded( + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Container( + height: double.infinity, + alignment: Alignment.center, + decoration: ShapeDecoration( + color: isSelected ? AppColors.primaryAble : Colors.transparent, + shape: const StadiumBorder(), + ), + child: Text( + label, + textAlign: TextAlign.center, + style: AppTypography.b2.copyWith( + color: isSelected ? Colors.white : AppColors.black, + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/statistics/widgets/line_graph/monthly_line_graph.dart b/lib/features/statistics/widgets/line_graph/monthly_line_graph.dart new file mode 100644 index 0000000..e2b10bb --- /dev/null +++ b/lib/features/statistics/widgets/line_graph/monthly_line_graph.dart @@ -0,0 +1,54 @@ +// 최초 작성자: 김채영 +import 'package:flutter/material.dart'; +import 'line_chart_grid.dart'; + +// ─────────────────────────────────────────────── +// 데이터 모델 +// ─────────────────────────────────────────────── + +/// 월간 그래프 데이터 모델 +/// - [thisMonth] : 이번 달 4주 데이터 (전전전주 → 이번주) +/// - [lastMonth] : 저번 달 4주 데이터 (전전전주 → 이번주) +class WeeklyGraphData { + final List thisMonth; + final List lastMonth; + + const WeeklyGraphData({required this.thisMonth, required this.lastMonth}); + + factory WeeklyGraphData.fromJson(Map json) { + return WeeklyGraphData( + thisMonth: List.from(json['thisMonth'] ?? [0, 0, 0, 0]), + lastMonth: List.from(json['lastMonth'] ?? [0, 0, 0, 0]), + ); + } +} + +// ─────────────────────────────────────────────── +// 월간 꺾은선 그래프 위젯 +// ─────────────────────────────────────────────── + +/// 이번 달 vs 저번 달 4주 인증 추이를 꺾은선 그래프로 표시 +class MonthlyLineGraph extends StatelessWidget { + final WeeklyGraphData data; + + const MonthlyLineGraph({super.key, required this.data}); + + static const _xLabels = ['첫째 주', '둘째 주', '셋째 주', '넷째 주']; + + @override + Widget build(BuildContext context) { + final allValues = [...data.thisMonth, ...data.lastMonth]; + final rawMax = allValues.isEmpty + ? 30 + : allValues.reduce((a, b) => a > b ? a : b); + final yMax = ((rawMax / 10).ceil() * 10).clamp(10, 999).toDouble(); + + return LineChartGrid( + columnCount: 4, + xLabels: _xLabels, + yMax: yMax, + thisData: data.thisMonth, + lastData: data.lastMonth, + ); + } +} diff --git a/lib/features/statistics/widgets/line_graph/weekly_line_graph.dart b/lib/features/statistics/widgets/line_graph/weekly_line_graph.dart new file mode 100644 index 0000000..8927567 --- /dev/null +++ b/lib/features/statistics/widgets/line_graph/weekly_line_graph.dart @@ -0,0 +1,58 @@ +// 최초 작성자: 김채영 +import 'package:flutter/material.dart'; +import 'package:haenaem/core/theme/app_colors.dart'; +import 'line_chart_grid.dart'; + +// ─────────────────────────────────────────────── +// 데이터 모델 +// ─────────────────────────────────────────────── + +/// 주간 그래프 데이터 모델 +/// - [thisWeek] : 이번 주 요일별 데이터 (월~일, 7개) +/// - [lastWeek] : 저번 주 요일별 데이터 (월~일, 7개) +class DailyGraphData { + final List thisWeek; + final List lastWeek; + + const DailyGraphData({required this.thisWeek, required this.lastWeek}); + + factory DailyGraphData.fromJson(Map json) { + return DailyGraphData( + thisWeek: List.from(json['thisWeek'] ?? List.filled(7, 0)), + lastWeek: List.from(json['lastWeek'] ?? List.filled(7, 0)), + ); + } +} + +// ─────────────────────────────────────────────── +// 주간 꺾은선 그래프 위젯 +// ─────────────────────────────────────────────── + +/// 이번 주 vs 저번 주 요일별 인증 추이를 꺾은선 그래프로 표시 +class WeeklyLineGraph extends StatelessWidget { + final DailyGraphData data; + + const WeeklyLineGraph({super.key, required this.data}); + + static const _xLabels = ['월', '화', '수', '목', '금', '토', '일']; + + @override + Widget build(BuildContext context) { + final allValues = [...data.thisWeek, ...data.lastWeek]; + final rawMax = allValues.isEmpty + ? 5 + : allValues.reduce((a, b) => a > b ? a : b); + // 주간은 하루 최대 인증 수가 소수이므로 1 단위로 올림 + final yMax = ((rawMax / 5).ceil() * 5).clamp(5, 999).toDouble(); + + return LineChartGrid( + columnCount: 7, + rowCount: 5, + xLabels: _xLabels, + yMax: yMax, + thisData: data.thisWeek, + lastData: data.lastWeek, + labelColor: AppColors.gray1, + ); + } +} diff --git a/lib/features/statistics/widgets/pie_graph.dart b/lib/features/statistics/widgets/pie_graph.dart new file mode 100644 index 0000000..8cb0875 --- /dev/null +++ b/lib/features/statistics/widgets/pie_graph.dart @@ -0,0 +1,195 @@ +// 최초 작성자: 김채영 +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:haenaem/core/theme/app_colors.dart'; +import 'package:haenaem/core/theme/app_typography.dart'; +import 'statistics_card.dart'; + +// 태그별 해냄 횟수 데이터 모델 +class TagCount { + final String tag; + final int count; + final Color color; + + const TagCount({required this.tag, required this.count, required this.color}); +} + +// 나의 해냄 분포 원형 그래프 +class PieGraph extends StatelessWidget { + final List top3; // 1~3위: 각각 색상으로 표시 + final List rest; // 4위~: 이름/횟수 각각 표시 (gray4 텍스트) + final int restCount; // 4위~ 합산 횟수 (파이 차트 gray4 조각용) + final int totalCount; // 총 해냄 횟수 + + const PieGraph({ + super.key, + required this.top3, + required this.rest, + required this.restCount, + required this.totalCount, + }); + + @override + Widget build(BuildContext context) { + // ✅ 데이터 없을 때 빈 상태 UI + if (top3.isEmpty) { + return StatisticsCard( + title: '나의 해냄 분포', + child: SizedBox( + height: 120, + child: Center( + child: Text( + '아직 완료한 챌린지가 없어요', + style: AppTypography.b2.copyWith(color: AppColors.gray3), + ), + ), + ), + ); + } + + // 파이 차트용: top3 각각 + 나머지 합산 gray4 하나 + final pieData = [ + ...top3, + if (restCount > 0) + TagCount(tag: '기타', count: restCount, color: AppColors.gray4), + ]; + + return StatisticsCard( + title: '나의 해냄 분포', + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 10, + children: [ + // 원형 파이 차트 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 60), + child: AspectRatio( + aspectRatio: 1, + child: CustomPaint( + // ✅ 파이 차트는 pieData (top3 + 기타 합산) 사용 + painter: _PieChartPainter(tagCounts: pieData), + ), + ), + ), + + // 총 N번 해냄! + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + spacing: 2, + children: [ + Text( + '총', + style: AppTypography.b3.copyWith(color: AppColors.black), + ), + Text( + '$totalCount번', + style: AppTypography.h3.copyWith(color: AppColors.primaryAble), + ), + Text( + '해냄!', + style: AppTypography.b3.copyWith(color: AppColors.black), + ), + ], + ), + + // 1~3위: 큰 폰트 + 각각의 색상 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 60), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + // ✅ 텍스트는 top3 그대로 사용 + children: top3.map((tag) => _buildTopTagItem(tag)).toList(), + ), + ), + + // 4위~: 작은 회색 폰트 + 이름/횟수 각각 표시 + if (rest.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 50), + child: Column( + spacing: 1, + // ✅ rest는 각각 따로 표시 + children: rest.map((tag) => _buildRestTagRow(tag)).toList(), + ), + ), + ], + ), + ); + } + + // 1~3위 태그 아이템 + Widget _buildTopTagItem(TagCount tag) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, // ✅ start → center + spacing: 2, + children: [ + Text( + tag.tag, + style: AppTypography.b1.copyWith(color: AppColors.gray2), + textAlign: TextAlign.center, // ✅ 추가 + ), + Text( + '${tag.count}번', + style: AppTypography.h3.copyWith(color: tag.color), + textAlign: TextAlign.center, // ✅ 추가 + ), + ], + ); + } + + // 4위~ 태그 행 + Widget _buildRestTagRow(TagCount tag) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(tag.tag, style: AppTypography.b2.copyWith(color: AppColors.gray2)), + Text( + '${tag.count}번', + style: AppTypography.b2.copyWith(color: AppColors.gray2), + ), + ], + ); + } +} + +// 파이 차트 CustomPainter +class _PieChartPainter extends CustomPainter { + final List tagCounts; + + const _PieChartPainter({required this.tagCounts}); + + @override + void paint(Canvas canvas, Size size) { + if (tagCounts.isEmpty) return; + + final total = tagCounts.fold(0, (sum, t) => sum + t.count); + if (total == 0) return; + + final center = Offset(size.width / 2, size.height / 2); + const radius = 85.33; + final rect = Rect.fromCircle(center: center, radius: radius); + + double startAngle = -pi / 2; + final paint = Paint()..style = PaintingStyle.fill; + + for (final tag in tagCounts) { + final sweepAngle = (tag.count / total) * (2 * pi); + paint.color = tag.color; + canvas.drawArc(rect, startAngle, sweepAngle, true, paint); + startAngle += sweepAngle; + } + + // 중앙 흰색 원 (도넛 효과) + final innerPaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + canvas.drawCircle(center, radius * 0.50, innerPaint); + } + + @override + bool shouldRepaint(covariant _PieChartPainter oldDelegate) => + oldDelegate.tagCounts != tagCounts; +} diff --git a/lib/features/statistics/widgets/statistics_card.dart b/lib/features/statistics/widgets/statistics_card.dart new file mode 100644 index 0000000..aaed8f6 --- /dev/null +++ b/lib/features/statistics/widgets/statistics_card.dart @@ -0,0 +1,62 @@ +// 최초 작성자: 김채영 +import 'package:flutter/material.dart'; +import 'package:haenaem/core/theme/app_colors.dart'; +import 'package:haenaem/core/theme/app_typography.dart'; + +// 통계 화면 위젯들의 공통 흰색 카드 +class StatisticsCard extends StatelessWidget { + final String title; + final Widget child; + final Widget? headerAction; // 오른쪽 상단에 들어갈 인증 횟수 등 + + const StatisticsCard({ + super.key, + required this.title, + required this.child, + this.headerAction, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), // 화면 양끝과 20 차이 + child: Container( + padding: const EdgeInsets.all(20), // 카드 내부 기본 패딩 (상단 20 포함) + decoration: ShapeDecoration( + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + shadows: const [ + BoxShadow( + color: Color(0x3F000000), + blurRadius: 16, + offset: Offset(0, 4), + spreadRadius: 0, + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 헤더 영역 (제목 + 선택적 액션) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + title, + style: AppTypography.h3.copyWith(color: AppColors.black), + ), + if (headerAction != null) headerAction!, + ], + ), + const SizedBox(height: 20), // 헤더와 콘텐츠 사이 20 차이 + child, + ], + ), + ), + ); + } +} diff --git a/lib/features/user/data/my_challenge_repository.dart b/lib/features/user/data/my_challenge_repository.dart new file mode 100644 index 0000000..1db6a7e --- /dev/null +++ b/lib/features/user/data/my_challenge_repository.dart @@ -0,0 +1,80 @@ +// 최초 작성자 : 강선욱 +import 'package:dio/dio.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:haenaem/core/network/dio_provider.dart'; +import 'package:haenaem/features/user/models/my_page_challenge_card.dart'; + +part 'my_challenge_repository.g.dart'; + +class MyChallengeRepository { + final Dio _dio; + + MyChallengeRepository(this._dio); + + /// 내 페이지 - 나의 챌린지 - 진행 중인 챌린지 + Future> getInProgressChallenges({ + required bool onlyTwo, + }) async { + try { + final response = await _dio.get( + '/api/challenges/my/inProgress', + queryParameters: {'onlyTwo': onlyTwo}, + ); + if (response.statusCode == 200) { + return (response.data as List) + .map((e) => MyPageChallengeCard.fromJson(e)) + .toList(); + } + throw Exception('챌린지 로드 실패'); + } on DioException catch (e) { + throw Exception('네트워크 에러: ${e.message}'); + } + } + + /// 내 페이지 - 나의 챌린지 - 완료한 챌린지 + Future> getSuccessChallenges({ + required bool onlyTwo, + }) async { + try { + final response = await _dio.get( + '/api/challenges/my/success', + queryParameters: {'onlyTwo': onlyTwo}, + ); + if (response.statusCode == 200) { + return (response.data as List) + .map((e) => MyPageChallengeCard.fromJson(e)) + .toList(); + } + throw Exception('완료된 챌린지 로드 실패'); + } on DioException catch (e) { + throw Exception('네트워크 에러: ${e.message}'); + } + } + + /// 내 페이지 - 나의 챌린지 - 실패한 챌린지 + Future> getFailedChallenges({ + required bool onlyTwo, + }) async { + try { + final response = await _dio.get( + '/api/challenges/my/fail', + queryParameters: {'onlyTwo': onlyTwo}, + ); + if (response.statusCode == 200) { + return (response.data as List) + .map((e) => MyPageChallengeCard.fromJson(e)) + .toList(); + } + throw Exception('실패한 챌린지 로드 실패'); + } on DioException catch (e) { + throw Exception('네트워크 에러: ${e.message}'); + } + } +} + +/// MyChallengeRepository 인스턴스를 제공하는 프로바이더 +@riverpod +MyChallengeRepository myChallengeRepository(MyChallengeRepositoryRef ref) { + final dio = ref.watch(dioProvider); + return MyChallengeRepository(dio); +} diff --git a/lib/features/user/data/my_challenge_repository.g.dart b/lib/features/user/data/my_challenge_repository.g.dart new file mode 100644 index 0000000..a2e3d46 --- /dev/null +++ b/lib/features/user/data/my_challenge_repository.g.dart @@ -0,0 +1,32 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'my_challenge_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$myChallengeRepositoryHash() => + r'9943aeec2a88e659f450e8d0941487ca6b1a4dda'; + +/// MyChallengeRepository 인스턴스를 제공하는 프로바이더 +/// +/// Copied from [myChallengeRepository]. +@ProviderFor(myChallengeRepository) +final myChallengeRepositoryProvider = + AutoDisposeProvider.internal( + myChallengeRepository, + name: r'myChallengeRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$myChallengeRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef MyChallengeRepositoryRef = + AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/user/data/user_repository.dart b/lib/features/user/data/user_repository.dart index 92b0c38..79fc764 100644 --- a/lib/features/user/data/user_repository.dart +++ b/lib/features/user/data/user_repository.dart @@ -5,8 +5,11 @@ import 'package:http_parser/http_parser.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:haenaem/core/network/dio_provider.dart'; -import 'package:haenaem/features/challenge/model/challenge_model.dart'; -import 'package:haenaem/features/user/model/user_model.dart'; +import 'package:haenaem/shared/models/tag_model.dart'; +// import 'package:haenaem/features/challenge/models/challenge_model.dart'; +import 'package:haenaem/shared/models/user.dart'; +import 'package:haenaem/shared/models/user_detail.dart'; + part 'user_repository.g.dart'; // 회원가입 + 내페이지 @@ -15,11 +18,11 @@ class UserRepository { UserRepository(this._dio); // 내 프로필 정보 조회 - Future getMyProfile() async { + Future getMyProfile() async { try { final response = await _dio.get('/api/users/me/profile'); if (response.statusCode == 200) { - return UserProfileModel.fromJson(response.data); + return UserDetail.fromJson(response.data); } else { throw Exception('프로필 정보를 불러오지 못했습니다.'); } diff --git a/lib/features/user/models/my_page_challenge_card.dart b/lib/features/user/models/my_page_challenge_card.dart new file mode 100644 index 0000000..2f17c6c --- /dev/null +++ b/lib/features/user/models/my_page_challenge_card.dart @@ -0,0 +1,75 @@ +import 'package:haenaem/shared/models/home_challenge_card.dart'; + +// 최초 작성자: 강선욱 +// 마이페이지 챌린지 카드 모델 +// HomeChallengeCard에 정의된 필드를 challengeInfo로 재사용 + +enum ChallengeStatus { + inProgress, // 진행중 + success, // 완료 + fail, // 실패 +} + +class MyPageChallengeCard { + final HomeChallengeCard challengeInfo; // 챌린지 기본 정보 + final double rate; // 챌린지 달성률 + final ChallengeStatus status; // 챌린지 상태 + final DateTime? failedDate; // 챌린지 실패 날짜 + final int maxStreakCount; // 최고 연속 일수 + final bool isParticipated; // 참여중 여부 + + const MyPageChallengeCard({ + required this.challengeInfo, + required this.rate, + required this.status, + this.failedDate, + required this.maxStreakCount, + required this.isParticipated, + }); + + factory MyPageChallengeCard.fromJson(Map json) { + return MyPageChallengeCard( + // 홈탭 모델의 팩토리 메서드를 그대로 호출 + challengeInfo: HomeChallengeCard.fromJson(json), + + // 마이페이지 전용 필드들만 추가로 매핑 + rate: (json['achievementRate'] as num? ?? 0).toDouble(), + status: _mapStatus(json['status'] as String? ?? ''), + failedDate: json['endDate'] != null + ? DateTime.tryParse(json['endDate']) + : null, + maxStreakCount: json['currentStreak'] as int? ?? 0, // max를 현재 스트리크로 우선 매핑 + isParticipated: true, + ); + } + + // 상태 값(SUCCESS, FAIL 등)을 Enum으로 안전하게 변환하는 헬퍼 + static ChallengeStatus _mapStatus(String raw) { + switch (raw.toUpperCase()) { + case 'SUCCESS': + return ChallengeStatus.success; + case 'FAIL': + return ChallengeStatus.fail; + default: + return ChallengeStatus.inProgress; + } + } + + MyPageChallengeCard copyWith({ + HomeChallengeCard? challengeInfo, + double? rate, + ChallengeStatus? status, + DateTime? failedDate, + int? maxStreakCount, + bool? isParticipated, + }) { + return MyPageChallengeCard( + challengeInfo: challengeInfo ?? this.challengeInfo, + rate: rate ?? this.rate, + status: status ?? this.status, + failedDate: failedDate ?? this.failedDate, + maxStreakCount: maxStreakCount ?? this.maxStreakCount, + isParticipated: isParticipated ?? this.isParticipated, + ); + } +} diff --git a/lib/features/user/model/user_model.dart b/lib/features/user/models/user_model.dart similarity index 87% rename from lib/features/user/model/user_model.dart rename to lib/features/user/models/user_model.dart index 0a294cd..2fb31c8 100644 --- a/lib/features/user/model/user_model.dart +++ b/lib/features/user/models/user_model.dart @@ -2,6 +2,7 @@ // 사용자 객체와 관련된 정보를 관리 모델 // 마이페이지 사용자 프로필 부분 +@Deprecated('shared/models/user.dart에 정의된 model을 대신 사용') class UserProfileModel { final String nickname; final String introduction; @@ -26,6 +27,7 @@ class UserProfileModel { } // 방장 정보를 관리하는 클래스 +@Deprecated('shared/models/user.dart에 정의된 model을 대신 사용') class HostModel { final String name; final String profileImageUrl; @@ -41,6 +43,7 @@ class HostModel { } // 챌린지 참여 멤버 정보를 관리하는 클래스 +@Deprecated('shared/models/user.dart에 정의된 model을 대신 사용') class ParticipantModel { final String name; final String profileImageUrl; @@ -56,6 +59,7 @@ class ParticipantModel { } // 친구 정보를 관리하는 클래스 +@Deprecated('shared/models/user.dart에 정의된 model을 대신 사용') class FriendModel { final int id; final String email; @@ -84,6 +88,7 @@ class FriendModel { // challenge_member_provider.dart // challenge_repository.dart // challenge_members_screen.dart +@Deprecated('shared/models/user.dart에 정의된 model을 대신 사용') class ChallengeMember { final int memberId; final String nickname; diff --git a/lib/features/user/provider/my_challenge_provider.dart b/lib/features/user/provider/my_challenge_provider.dart new file mode 100644 index 0000000..6a30673 --- /dev/null +++ b/lib/features/user/provider/my_challenge_provider.dart @@ -0,0 +1,39 @@ +// 최초 작성자 : 강선욱 +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:haenaem/features/user/models/my_page_challenge_card.dart'; +import '../data/my_challenge_repository.dart'; + +part 'my_challenge_provider.g.dart'; + +// 1. 내 페이지 - 나의 챌린지 - 진행중인 챌린지 +@riverpod +Future> myInProgressChallenges( + MyInProgressChallengesRef ref, { + bool onlyTwo = false, +}) { + return ref + .watch(myChallengeRepositoryProvider) + .getInProgressChallenges(onlyTwo: onlyTwo); +} + +// 2. 내 페이지 - 나의 챌린지 - 완료한 챌린지 +@riverpod +Future> mySuccessChallenges( + MySuccessChallengesRef ref, { + bool onlyTwo = false, +}) { + return ref + .watch(myChallengeRepositoryProvider) + .getSuccessChallenges(onlyTwo: onlyTwo); +} + +// 3. 내 페이지 - 나의 챌린지 - 실패한 챌린지 +@riverpod +Future> myFailedChallenges( + MyFailedChallengesRef ref, { + bool onlyTwo = false, +}) { + return ref + .watch(myChallengeRepositoryProvider) + .getFailedChallenges(onlyTwo: onlyTwo); +} diff --git a/lib/features/user/provider/my_challenge_provider.g.dart b/lib/features/user/provider/my_challenge_provider.g.dart new file mode 100644 index 0000000..381ee27 --- /dev/null +++ b/lib/features/user/provider/my_challenge_provider.g.dart @@ -0,0 +1,417 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'my_challenge_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$myInProgressChallengesHash() => + r'97c9400ea42582ca7c6e7e2390905d47704bbf5b'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [myInProgressChallenges]. +@ProviderFor(myInProgressChallenges) +const myInProgressChallengesProvider = MyInProgressChallengesFamily(); + +/// See also [myInProgressChallenges]. +class MyInProgressChallengesFamily + extends Family>> { + /// See also [myInProgressChallenges]. + const MyInProgressChallengesFamily(); + + /// See also [myInProgressChallenges]. + MyInProgressChallengesProvider call({bool onlyTwo = false}) { + return MyInProgressChallengesProvider(onlyTwo: onlyTwo); + } + + @override + MyInProgressChallengesProvider getProviderOverride( + covariant MyInProgressChallengesProvider provider, + ) { + return call(onlyTwo: provider.onlyTwo); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'myInProgressChallengesProvider'; +} + +/// See also [myInProgressChallenges]. +class MyInProgressChallengesProvider + extends AutoDisposeFutureProvider> { + /// See also [myInProgressChallenges]. + MyInProgressChallengesProvider({bool onlyTwo = false}) + : this._internal( + (ref) => myInProgressChallenges( + ref as MyInProgressChallengesRef, + onlyTwo: onlyTwo, + ), + from: myInProgressChallengesProvider, + name: r'myInProgressChallengesProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$myInProgressChallengesHash, + dependencies: MyInProgressChallengesFamily._dependencies, + allTransitiveDependencies: + MyInProgressChallengesFamily._allTransitiveDependencies, + onlyTwo: onlyTwo, + ); + + MyInProgressChallengesProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.onlyTwo, + }) : super.internal(); + + final bool onlyTwo; + + @override + Override overrideWith( + FutureOr> Function( + MyInProgressChallengesRef provider, + ) + create, + ) { + return ProviderOverride( + origin: this, + override: MyInProgressChallengesProvider._internal( + (ref) => create(ref as MyInProgressChallengesRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + onlyTwo: onlyTwo, + ), + ); + } + + @override + AutoDisposeFutureProviderElement> createElement() { + return _MyInProgressChallengesProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is MyInProgressChallengesProvider && other.onlyTwo == onlyTwo; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, onlyTwo.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin MyInProgressChallengesRef + on AutoDisposeFutureProviderRef> { + /// The parameter `onlyTwo` of this provider. + bool get onlyTwo; +} + +class _MyInProgressChallengesProviderElement + extends AutoDisposeFutureProviderElement> + with MyInProgressChallengesRef { + _MyInProgressChallengesProviderElement(super.provider); + + @override + bool get onlyTwo => (origin as MyInProgressChallengesProvider).onlyTwo; +} + +String _$mySuccessChallengesHash() => + r'4c71c6dd9942b1bee097455700a66099eb08da0c'; + +/// See also [mySuccessChallenges]. +@ProviderFor(mySuccessChallenges) +const mySuccessChallengesProvider = MySuccessChallengesFamily(); + +/// See also [mySuccessChallenges]. +class MySuccessChallengesFamily + extends Family>> { + /// See also [mySuccessChallenges]. + const MySuccessChallengesFamily(); + + /// See also [mySuccessChallenges]. + MySuccessChallengesProvider call({bool onlyTwo = false}) { + return MySuccessChallengesProvider(onlyTwo: onlyTwo); + } + + @override + MySuccessChallengesProvider getProviderOverride( + covariant MySuccessChallengesProvider provider, + ) { + return call(onlyTwo: provider.onlyTwo); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'mySuccessChallengesProvider'; +} + +/// See also [mySuccessChallenges]. +class MySuccessChallengesProvider + extends AutoDisposeFutureProvider> { + /// See also [mySuccessChallenges]. + MySuccessChallengesProvider({bool onlyTwo = false}) + : this._internal( + (ref) => mySuccessChallenges( + ref as MySuccessChallengesRef, + onlyTwo: onlyTwo, + ), + from: mySuccessChallengesProvider, + name: r'mySuccessChallengesProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$mySuccessChallengesHash, + dependencies: MySuccessChallengesFamily._dependencies, + allTransitiveDependencies: + MySuccessChallengesFamily._allTransitiveDependencies, + onlyTwo: onlyTwo, + ); + + MySuccessChallengesProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.onlyTwo, + }) : super.internal(); + + final bool onlyTwo; + + @override + Override overrideWith( + FutureOr> Function( + MySuccessChallengesRef provider, + ) + create, + ) { + return ProviderOverride( + origin: this, + override: MySuccessChallengesProvider._internal( + (ref) => create(ref as MySuccessChallengesRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + onlyTwo: onlyTwo, + ), + ); + } + + @override + AutoDisposeFutureProviderElement> createElement() { + return _MySuccessChallengesProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is MySuccessChallengesProvider && other.onlyTwo == onlyTwo; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, onlyTwo.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin MySuccessChallengesRef + on AutoDisposeFutureProviderRef> { + /// The parameter `onlyTwo` of this provider. + bool get onlyTwo; +} + +class _MySuccessChallengesProviderElement + extends AutoDisposeFutureProviderElement> + with MySuccessChallengesRef { + _MySuccessChallengesProviderElement(super.provider); + + @override + bool get onlyTwo => (origin as MySuccessChallengesProvider).onlyTwo; +} + +String _$myFailedChallengesHash() => + r'dcff0a445e60508d54100b481c6584282761f31e'; + +/// See also [myFailedChallenges]. +@ProviderFor(myFailedChallenges) +const myFailedChallengesProvider = MyFailedChallengesFamily(); + +/// See also [myFailedChallenges]. +class MyFailedChallengesFamily + extends Family>> { + /// See also [myFailedChallenges]. + const MyFailedChallengesFamily(); + + /// See also [myFailedChallenges]. + MyFailedChallengesProvider call({bool onlyTwo = false}) { + return MyFailedChallengesProvider(onlyTwo: onlyTwo); + } + + @override + MyFailedChallengesProvider getProviderOverride( + covariant MyFailedChallengesProvider provider, + ) { + return call(onlyTwo: provider.onlyTwo); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'myFailedChallengesProvider'; +} + +/// See also [myFailedChallenges]. +class MyFailedChallengesProvider + extends AutoDisposeFutureProvider> { + /// See also [myFailedChallenges]. + MyFailedChallengesProvider({bool onlyTwo = false}) + : this._internal( + (ref) => + myFailedChallenges(ref as MyFailedChallengesRef, onlyTwo: onlyTwo), + from: myFailedChallengesProvider, + name: r'myFailedChallengesProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$myFailedChallengesHash, + dependencies: MyFailedChallengesFamily._dependencies, + allTransitiveDependencies: + MyFailedChallengesFamily._allTransitiveDependencies, + onlyTwo: onlyTwo, + ); + + MyFailedChallengesProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.onlyTwo, + }) : super.internal(); + + final bool onlyTwo; + + @override + Override overrideWith( + FutureOr> Function(MyFailedChallengesRef provider) + create, + ) { + return ProviderOverride( + origin: this, + override: MyFailedChallengesProvider._internal( + (ref) => create(ref as MyFailedChallengesRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + onlyTwo: onlyTwo, + ), + ); + } + + @override + AutoDisposeFutureProviderElement> createElement() { + return _MyFailedChallengesProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is MyFailedChallengesProvider && other.onlyTwo == onlyTwo; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, onlyTwo.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin MyFailedChallengesRef + on AutoDisposeFutureProviderRef> { + /// The parameter `onlyTwo` of this provider. + bool get onlyTwo; +} + +class _MyFailedChallengesProviderElement + extends AutoDisposeFutureProviderElement> + with MyFailedChallengesRef { + _MyFailedChallengesProviderElement(super.provider); + + @override + bool get onlyTwo => (origin as MyFailedChallengesProvider).onlyTwo; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/user/provider/user_profile_provider.dart b/lib/features/user/provider/user_profile_provider.dart new file mode 100644 index 0000000..be97005 --- /dev/null +++ b/lib/features/user/provider/user_profile_provider.dart @@ -0,0 +1,98 @@ +// 최초 작성자: 정승빈 +// 프로필 조회, 수정, 이미지 업로드/삭제 로직 전담 (Fat UI 해결) +// 최초 작성자: 정승빈 +import 'dart:io'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../data/user_repository.dart'; +import 'package:haenaem/features/user/provider/user_provider.dart'; +import '../../../shared/provider/tag_provider.dart'; + +part 'user_profile_provider.g.dart'; + +@riverpod +class UserProfile extends _$UserProfile { + @override + FutureOr build() { + // 초기 상태는 아무 작업도 하지 않은 상태 + } + + // 1. 프로필 이미지 즉시 삭제 + Future deleteProfileImage() async { + state = const AsyncValue.loading(); + try { + await ref.read(userRepositoryProvider).deleteProfileImage(); + ref + .read(currentUserProvider.notifier) + .updateProfileImage(null); // 전역 상태 업데이트 + ref + .read(myProfileProvider.notifier) + .updateLocalDetail(profileUrl: null); // 로컬 업데이트 + + state = const AsyncValue.data(null); + } catch (e, stack) { + state = AsyncValue.error(e, stack); + rethrow; + } + } + + // 2. 프로필 최종 저장 (닉네임, 한줄소개, 새 이미지, 태그) + Future updateProfile({ + required String currentNickname, + required String newNickname, + required String currentIntro, + required String newIntro, + File? newImageFile, + }) async { + state = const AsyncValue.loading(); + try { + final userRepo = ref.read(userRepositoryProvider); + final tagNotifier = ref.read(tagProvider.notifier); + + // 1) 닉네임 변경 + if (newNickname != currentNickname) { + await userRepo.updateNickname(newNickname); + ref + .read(currentUserProvider.notifier) + .updateNickname(newNickname); // 전역 상태 업데이트 + } + + // 2) 한 줄 소개 변경 + if (newIntro != currentIntro) { + await userRepo.updateIntroduction(newIntro); + } + + // 3) 프로필 이미지 변경 + String? finalProfileUrl; + if (newImageFile != null) { + await userRepo.uploadProfileImage(newImageFile); + + // 업로드 후 새 URL 가져와서 전역 상태 업데이트 + final updatedUserDetail = await userRepo.getMyProfile(); + finalProfileUrl = updatedUserDetail.user.profileUrl; + ref + .read(currentUserProvider.notifier) + .updateProfileImage(updatedUserDetail.user.profileUrl); + } + + // 4) 태그 업데이트 + final tagSuccess = await tagNotifier.updateInterestTags(); + if (!tagSuccess) { + throw Exception('태그 수정 중 오류가 발생했습니다.'); + } + + // 내페이지 전역 상태 로컬 업데이트 + ref + .read(myProfileProvider.notifier) + .updateLocalDetail( + nickname: newNickname, + introduction: newIntro, + tags: ref.read(tagProvider).tags, + profileUrl: finalProfileUrl, // 이미지가 바뀌었다면 새 URL, 아니면 기존 유지 + ); + state = const AsyncValue.data(null); + } catch (e, stack) { + state = AsyncValue.error(e, stack); + rethrow; // UI에서 예외 메시지를 띄우기 위해 에러를 위로 던짐 + } + } +} diff --git a/lib/features/user/provider/user_profile_provider.g.dart b/lib/features/user/provider/user_profile_provider.g.dart new file mode 100644 index 0000000..d8e3b6a --- /dev/null +++ b/lib/features/user/provider/user_profile_provider.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_profile_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$userProfileHash() => r'8d8ac63bfedecf90ac1d25e08d8d740cb55398de'; + +/// See also [UserProfile]. +@ProviderFor(UserProfile) +final userProfileProvider = + AutoDisposeAsyncNotifierProvider.internal( + UserProfile.new, + name: r'userProfileProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$userProfileHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$UserProfile = AutoDisposeAsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/user/provider/user_provider.dart b/lib/features/user/provider/user_provider.dart new file mode 100644 index 0000000..ab9f69f --- /dev/null +++ b/lib/features/user/provider/user_provider.dart @@ -0,0 +1,75 @@ +// 최초 작성자 : 김채영 +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:haenaem/shared/models/user.dart'; +import 'package:haenaem/shared/models/user_detail.dart'; +import 'package:haenaem/features/user/data/user_repository.dart'; + +part 'user_provider.g.dart'; +// 앱 전체에서 현재 로그인한 사용자의 정보를 관리하는 전역 상태 클래스 + +@Riverpod(keepAlive: true) // 앱이 켜져 있는 동안 데이터를 계속 유지 +class CurrentUser extends _$CurrentUser { + @override + User? build() { + return null; + } + + // 로그인 시 호출 + void setUser(User user) { + state = user; + } + + // 로그아웃 시 호출 + void clearUser() { + state = null; + } + + // 닉네임만 업데이트 + void updateNickname(String nickname) { + state = state?.copyWith(nickname: nickname); + } + + // 프로필 이미지만 업데이트 + void updateProfileImage(String? profileUrl) { + state = state?.copyWith(profileUrl: profileUrl); + } +} + +@riverpod +class MyProfile extends _$MyProfile { + @override + FutureOr build() async { + final userRepo = ref.read(userRepositoryProvider); + final detail = await userRepo.getMyProfile(); + + // ✨ [1. 초기 로드 시 자동 업데이트] + // 프로필 상세를 가져오자마자 기본 정보(User)를 전역 상태에 꽂아줍니다. + ref.read(currentUserProvider.notifier).setUser(detail.user); + + return detail; + } + + // API 호출 없이 로컬 상세 정보만 업데이트 + void updateLocalDetail({ + String? introduction, + List? tags, + String? nickname, + String? profileUrl, + }) { + state.whenData((current) { + // 새로운 유저 객체 생성 + final updatedUser = current.user.copyWith( + nickname: nickname ?? current.user.nickname, + profileUrl: profileUrl ?? current.user.profileUrl, + ); + // 2. 업데이트된 User를 포함하여 UserDetail 전체 상태를 갱신합니다. + state = AsyncData( + current.copyWith( + user: updatedUser, + introduction: introduction ?? current.introduction, + tags: tags ?? current.tags, + ), + ); + }); + } +} diff --git a/lib/features/user/provider/user_provider.g.dart b/lib/features/user/provider/user_provider.g.dart new file mode 100644 index 0000000..63acd16 --- /dev/null +++ b/lib/features/user/provider/user_provider.g.dart @@ -0,0 +1,41 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$currentUserHash() => r'1cd77fb5bb48fa22fbd7a130413d96bac74fdc3d'; + +/// See also [CurrentUser]. +@ProviderFor(CurrentUser) +final currentUserProvider = NotifierProvider.internal( + CurrentUser.new, + name: r'currentUserProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$currentUserHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$CurrentUser = Notifier; +String _$myProfileHash() => r'a2ae41d2429d95cb9cbdecd38b5825067b6b9ebc'; + +/// See also [MyProfile]. +@ProviderFor(MyProfile) +final myProfileProvider = + AutoDisposeAsyncNotifierProvider.internal( + MyProfile.new, + name: r'myProfileProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$myProfileHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$MyProfile = AutoDisposeAsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/user/screens/challenge/challenge_list_screen.dart b/lib/features/user/screens/challenge/challenge_list_screen.dart new file mode 100644 index 0000000..5b2e467 --- /dev/null +++ b/lib/features/user/screens/challenge/challenge_list_screen.dart @@ -0,0 +1,117 @@ +// 최초 작성자: 정승빈 +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import '../../../../../core/theme/app_colors.dart'; +import '../../../../../core/theme/app_typography.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +// import 'package:haenaem/features/challenge/models/challenge_model.dart'; +// import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; +import 'package:haenaem/features/user/provider/my_challenge_provider.dart'; +import '../../widgets/my_challenge_card.dart'; // 💡 공통 카드 위젯 추가 +import 'package:haenaem/features/user/models/my_page_challenge_card.dart'; + +// 클래스의 용도: 챌린지 목록을 진행중, 완료, 실패 탭으로 구분하여 보여주는 화면 +class ChallengeListScreen extends ConsumerWidget { + const ChallengeListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final inProgressAsync = ref.watch( + myInProgressChallengesProvider(onlyTwo: false), + ); + final successAsync = ref.watch(mySuccessChallengesProvider(onlyTwo: false)); + final failedAsync = ref.watch(myFailedChallengesProvider(onlyTwo: false)); + + return DefaultTabController( + length: 3, + child: Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + centerTitle: true, + leading: IconButton( + onPressed: () => Navigator.pop(context), + icon: SvgPicture.asset( + 'assets/images/icons/arrow_left.svg', + width: 24, + height: 24, + colorFilter: const ColorFilter.mode( + AppColors.black, + BlendMode.srcIn, + ), + ), + ), + title: Text( + '나의 챌린지', + style: AppTypography.h3.copyWith(color: AppColors.black), + ), + ), + body: Column( + children: [ + _buildTabBar(), + Expanded( + child: Container( + color: AppColors.gray5, + child: TabBarView( + children: [ + inProgressAsync.when( + data: (list) => _buildFilteredListView(list), + loading: () => + const Center(child: CircularProgressIndicator()), + error: (err, __) => Center(child: Text('로드 실패: $err')), + ), + successAsync.when( + data: (list) => _buildFilteredListView(list), + loading: () => + const Center(child: CircularProgressIndicator()), + error: (err, __) => Center(child: Text('로드 실패: $err')), + ), + failedAsync.when( + data: (list) => _buildFilteredListView(list), + loading: () => + const Center(child: CircularProgressIndicator()), + error: (err, __) => Center(child: Text('로드 실패: $err')), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildTabBar() { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + border: Border(bottom: BorderSide(color: AppColors.gray4, width: 1)), + ), + child: const TabBar( + indicatorColor: AppColors.primaryAble, + labelColor: AppColors.primaryAble, + unselectedLabelColor: AppColors.gray2, + tabs: [ + Tab(text: '진행중'), + Tab(text: '완료'), + Tab(text: '실패'), + ], + ), + ); + } + + Widget _buildFilteredListView(List list) { + if (list.isEmpty) { + return const Center(child: Text('해당하는 챌린지가 없습니다.')); + } + + return ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + itemCount: list.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) => MyChallengeCard(item: list[index]), + ); + } +} diff --git a/lib/features/user/screens/challenge_list_screen.dart b/lib/features/user/screens/challenge_list_screen.dart deleted file mode 100644 index 78a2d8c..0000000 --- a/lib/features/user/screens/challenge_list_screen.dart +++ /dev/null @@ -1,411 +0,0 @@ -/// 최초 작성자: 정승빈 -/// 작성일: 2026-02-03 -library; - -import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import '../../../../core/theme/app_theme.dart'; -import '../../../../core/theme/app_colors.dart'; -import '../../../../core/theme/app_typography.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:haenaem/features/challenge/model/challenge_model.dart'; -import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; - -/// 클래스의 용도: 챌린지 목록을 진행중, 완료, 실패 탭으로 구분하여 보여주는 화면 -class ChallengeListScreen extends ConsumerWidget { - const ChallengeListScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - // 세 가지 API 모두 구독 (전체 목록을 위해 onlyTwo: false) - final inProgressAsync = ref.watch( - myInProgressChallengesProvider(onlyTwo: false), - ); - final successAsync = ref.watch(mySuccessChallengesProvider(onlyTwo: false)); - final failedAsync = ref.watch(myFailedChallengesProvider(onlyTwo: false)); - - return DefaultTabController( - length: 3, - child: Scaffold( - backgroundColor: Colors.white, - appBar: AppBar( - backgroundColor: Colors.white, - elevation: 0, - centerTitle: true, - leading: IconButton( - onPressed: () => Navigator.pop(context), - icon: SvgPicture.asset( - 'assets/images/icons/arrow_left.svg', - width: 24, - height: 24, - colorFilter: const ColorFilter.mode( - AppColors.black, - BlendMode.srcIn, - ), - ), - ), - title: Text( - '나의 챌린지', - style: AppTypography.h3.copyWith(color: AppColors.black), - ), - ), - body: Column( - children: [ - _buildTabBar(), - Expanded( - child: Container( - color: AppColors.gray5, - child: TabBarView( - children: [ - // 1. 진행중 탭 - inProgressAsync.when( - data: (list) => - _buildFilteredListView(list, "IN_PROGRESS"), - loading: () => - const Center(child: CircularProgressIndicator()), - error: (err, __) => Center(child: Text('로드 실패: $err')), - ), - // 2. 완료 탭 - successAsync.when( - data: (list) => _buildFilteredListView(list, "SUCCESS"), - loading: () => - const Center(child: CircularProgressIndicator()), - error: (err, __) => Center(child: Text('로드 실패: $err')), - ), - // 3. 실패 탭 - failedAsync.when( - data: (list) => _buildFilteredListView(list, "FAIL"), - loading: () => - const Center(child: CircularProgressIndicator()), - error: (err, __) => Center(child: Text('로드 실패: $err')), - ), - ], - ), - ), - ), - ], - ), - ), - ); - } - - Widget _buildTabBar() { - return Container( - decoration: const BoxDecoration( - color: Colors.white, - border: Border(bottom: BorderSide(color: AppColors.gray4, width: 1)), - ), - child: const TabBar( - indicatorColor: AppColors.primaryAble, - labelColor: AppColors.primaryAble, - unselectedLabelColor: AppColors.gray2, - tabs: [ - Tab(text: '진행중'), - Tab(text: '완료'), - Tab(text: '실패'), - ], - ), - ); - } - - // 상태별 리스트 필터링 및 출력 - Widget _buildFilteredListView( - List list, - String tabStatus, - ) { - final filtered = list.where((item) { - final serverStatus = item.status.toUpperCase(); - return serverStatus == tabStatus || - (tabStatus == "FAIL" && serverStatus == "FAILED"); - }).toList(); - - if (filtered.isEmpty) { - return const Center(child: Text('해당하는 챌린지가 없습니다.')); - } - - return ListView.separated( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - itemCount: filtered.length, - separatorBuilder: (_, __) => const SizedBox(height: 12), - itemBuilder: (context, index) => _buildFullChallengeCard(filtered[index]), - ); - } - - // 마이페이지와 디자인을 통일한 챌린지 카드 - Widget _buildFullChallengeCard(ChallengeInProgressModel item) { - final serverStatus = item.status.toUpperCase(); - - // 1. 실패 상태일 때 (빨간 테마 + 실패일 + 최대 일수 + 불 아이콘) - if (serverStatus == "FAIL" || serverStatus == "FAILED") { - return _buildSyncFailedCard(item); - } - - // 2. 완료 상태일 때 (초록 테마 + 완료일 + 총 진행일 + 불 아이콘) - if (serverStatus == "SUCCESS") { - return _buildSyncSuccessCard(item); - } - - // 3. 진행 중일 때 (기본 디자인) - return _buildSyncInProgressCard(item); - } - - Widget _buildSyncFailedCard(ChallengeInProgressModel item) { - const Color failColor = AppColors.notification; - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.gray5, width: 0.69), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - item.title, - style: AppTypography.b3.copyWith( - color: AppColors.black, - ), - overflow: TextOverflow.ellipsis, // 길면 생략 - maxLines: 1, - ), - ), - const SizedBox(width: 7.35), - _buildStatusBadge('실패', failColor), - ], - ), - const SizedBox(height: 2), - Text( - '실패일 ${item.endDate.replaceAll('-', '/')}', - style: AppTypography.b2.copyWith(color: AppColors.gray2), - ), - const SizedBox(height: 2), - Row( - children: [ - SvgPicture.asset( - 'assets/images/icons/small_fire_icon.svg', - width: 16, - ), - const SizedBox(width: 2), - Text( - '최대 ${item.duringDate}일', - style: AppTypography.b2.copyWith( - color: AppColors.black, - ), - ), - ], - ), - ], - ), - ), - _buildProgressText(item.progress, failColor), - ], - ), - const SizedBox(height: 8), - _buildGaugeBar(item.progress, failColor), - ], - ), - ); - } - - Widget _buildSyncSuccessCard(ChallengeInProgressModel item) { - const Color successColor = AppColors.primaryAble; - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.gray5, width: 0.69), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - item.title, - style: AppTypography.b3.copyWith( - color: AppColors.black, - ), - overflow: TextOverflow.ellipsis, // 길면 생략 - maxLines: 1, - ), - ), - const SizedBox(width: 7.35), - _buildStatusBadge('완료', successColor), - ], - ), - const SizedBox(height: 2), - Text( - '완료일 ${item.endDate.replaceAll('-', '/')}', - style: AppTypography.b2.copyWith(color: AppColors.gray2), - ), - const SizedBox(height: 2), - Row( - children: [ - SvgPicture.asset( - 'assets/images/icons/small_fire_icon.svg', - width: 16, - ), - const SizedBox(width: 2), - Text( - '총 ${item.duringDate}일', - style: AppTypography.b2.copyWith( - color: AppColors.black, - ), - ), - ], - ), - ], - ), - ), - _buildProgressText(item.progress, successColor), - ], - ), - const SizedBox(height: 8), - _buildGaugeBar(item.progress, successColor), - ], - ), - ); - } - - Widget _buildSyncInProgressCard(ChallengeInProgressModel item) { - const Color inProgressColor = AppColors.blue; - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - item.title, - style: AppTypography.b3.copyWith( - color: AppColors.black, - ), - overflow: TextOverflow.ellipsis, // 길면 생략 - maxLines: 1, - ), - ), - const SizedBox(width: 7.35), - _buildStatusBadge('진행중', inProgressColor), - ], - ), - const SizedBox(height: 3), - Text( - item.dateInfo, - style: AppTypography.b2.copyWith(color: AppColors.gray2), - ), - ], - ), - ), - _buildProgressText(item.progress, inProgressColor), - ], - ), - const SizedBox(height: 8), - _buildInProgressInfoRow(item), - const SizedBox(height: 8), - _buildGaugeBar(item.progress, inProgressColor), - ], - ), - ); - } - // --- 헬퍼 위젯들 --- - - Widget _buildStatusBadge(String text, Color color) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: ShapeDecoration( - color: color.withOpacity(0.1), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - child: Text(text, style: AppTypography.c1.copyWith(color: color)), - ); - } - - Widget _buildProgressText(double progress, Color color) { - return Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '${(progress * 100).toInt()}%', - style: AppTypography.h2.copyWith(color: color), - ), - Text('달성률', style: AppTypography.c1.copyWith(color: AppColors.gray2)), - ], - ); - } - - Widget _buildInProgressInfoRow(ChallengeInProgressModel item) { - return Row( - children: [ - SvgPicture.asset('assets/images/icons/small_fire_icon.svg', width: 16), - const SizedBox(width: 4), - Text( - '${item.duringDate}일째', - style: AppTypography.b2.copyWith(color: AppColors.black), - ), - const SizedBox(width: 12), - SvgPicture.asset( - 'assets/images/icons/mini_success_icon.svg', - width: 16, - ), - const SizedBox(width: 4), - Text( - item.countInfo, - style: AppTypography.b2.copyWith(color: AppColors.black), - ), - ], - ); - } - - Widget _buildGaugeBar(double progress, Color color) { - return ClipRRect( - borderRadius: BorderRadius.circular(23), - child: LinearProgressIndicator( - value: progress, - minHeight: 4, - backgroundColor: AppColors.gray5, - valueColor: AlwaysStoppedAnimation(color), - ), - ); - } -} diff --git a/lib/features/user/screens/my_page_main_screen.dart b/lib/features/user/screens/my_page_main_screen.dart new file mode 100644 index 0000000..9055da4 --- /dev/null +++ b/lib/features/user/screens/my_page_main_screen.dart @@ -0,0 +1,134 @@ +// 최초 작성자: 정승빈 + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/theme/app_typography.dart'; +import 'package:haenaem/features/notification/services/fcm_service.dart'; + +import 'package:haenaem/features/user/provider/user_provider.dart'; +import 'package:haenaem/shared/models/user_detail.dart'; + +import 'profile/profile_edit_screen.dart'; +import '../views/profile_header_view.dart'; +import '../views/my_challenge_section_view.dart'; +import '../views/my_page_menu_view.dart'; + +class MyPageMainScreen extends ConsumerStatefulWidget { + const MyPageMainScreen({super.key}); + + @override + ConsumerState createState() => _MyPageMainScreenState(); +} + +class _MyPageMainScreenState extends ConsumerState { + @override + Widget build(BuildContext context) { + // myProfileProvider가 로딩중일 때 + // 전역 관리하는 currentUserProvider가 닉네임/이미지라도 먼저 가져옴 + final currentUser = ref.watch(currentUserProvider); + final profileAsync = ref.watch(myProfileProvider); + + return Scaffold( + backgroundColor: Colors.white, + appBar: _buildAppBar(profileAsync.value), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Column( + children: [ + const SizedBox(height: 20), + + if (currentUser != null) + profileAsync.when( + loading: () => ProfileHeaderView( + nickname: currentUser.nickname, + profileImageUrl: currentUser.profileUrl ?? '', + introduction: '', + tags: const [], + ), + error: (_, __) => ProfileHeaderView( + nickname: currentUser.nickname, + profileImageUrl: currentUser.profileUrl ?? '', + introduction: '', + tags: const [], + ), + data: (UserDetail detail) => ProfileHeaderView( + // ✅ UserDetail 사용 + nickname: detail.user.nickname, + profileImageUrl: detail.user.profileUrl ?? '', + introduction: detail.introduction, + tags: detail.tags, + ), + ) + else + const Center(child: CircularProgressIndicator()), + + const SizedBox(height: 40), + + // 2. 나의 챌린지 뷰 + const MyChallengeSectionView(), + + const SizedBox(height: 24), + + // 3. 하단 설정 메뉴 뷰 + const MyPageMenuView(), + + const SizedBox(height: 30), + ], + ), + ), + ), + ); + } + + PreferredSizeWidget _buildAppBar(UserDetail? detail) { + return AppBar( + backgroundColor: Colors.white, + elevation: 0, + automaticallyImplyLeading: false, + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SizedBox(width: 24), + Text( + '내 페이지', + style: AppTypography.h3.copyWith(color: AppColors.black), + ), + InkWell( + onTap: () { + // 1. 현재 로드된 프로필 데이터를 가져옵니다. + //final profileData = ref.read(myProfileProvider).value; + if (detail != null) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ProfileEditScreen( + //user: currentUser, + detail: detail, + ), + ), + ); + } else { + // 데이터 로딩 중이거나 에러 시 알림 + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('프로필 정보를 불러오는 중입니다.')), + ); + } + }, + child: SvgPicture.asset( + 'assets/images/icons/my_page_edit.svg', + width: 24, + height: 24, + colorFilter: const ColorFilter.mode( + AppColors.black, + BlendMode.srcIn, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/user/screens/my_page_screen.dart b/lib/features/user/screens/my_page_screen.dart deleted file mode 100644 index ff38ae0..0000000 --- a/lib/features/user/screens/my_page_screen.dart +++ /dev/null @@ -1,159 +0,0 @@ -/// 최초 작성자: 정승빈 -/// 작성일: 2026-01-18 -library; - -import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:haenaem/features/user/screens/push_notification_settings_screen.dart'; -import '../../../../core/theme/app_colors.dart'; -import '../../../../core/theme/app_theme.dart'; -import '../../../../core/theme/app_typography.dart'; -import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; -import 'package:haenaem/features/notification/services/fcm_service.dart'; - -import 'package:haenaem/shared/models/tag_data.dart'; -import 'withdrawal_screen.dart'; -import 'challenge_list_screen.dart'; -import 'profile_edit_screen.dart'; -import '../widgets/profile_header.dart'; -import '../widgets/my_page_menu_item.dart'; -import '../widgets/challenge_section.dart'; -import '../widgets/logout_dialog.dart'; - -class MyPageScreen extends ConsumerStatefulWidget { - const MyPageScreen({super.key}); - - @override - ConsumerState createState() => _MyPageScreenState(); -} - -class _MyPageScreenState extends ConsumerState { - @override - Widget build(BuildContext context) { - final profileAsync = ref.watch(myProfileProvider); - - return Scaffold( - backgroundColor: Colors.white, - appBar: _buildAppBar(), - body: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20.0), - child: Column( - children: [ - const SizedBox(height: 20), - - profileAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (err, stack) => const Center(child: Text('데이터 로드 실패')), - data: (profile) => ProfileHeader( - nickname: profile.nickname, - introduction: profile.introduction, - profileImageUrl: profile.profileImageUrl, - tags: profile.tags, - ), - ), - - const SizedBox(height: 40), - const ChallengeSection(), // 챌린지 섹션 위젯 - - const SizedBox(height: 24), - _buildMenuSection(context), - const SizedBox(height: 30), - ], - ), - ), - ), - ); - } - - PreferredSizeWidget _buildAppBar() { - return AppBar( - backgroundColor: Colors.white, - elevation: 0, - automaticallyImplyLeading: false, - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const SizedBox(width: 24), - Text( - '내 페이지', - style: AppTypography.h3.copyWith(color: AppColors.black), - ), - InkWell( - onTap: () { - // 1. 현재 로드된 프로필 데이터를 가져옵니다. - final profileData = ref.read(myProfileProvider).value; - - // 2. 데이터가 있을 때만 화면 이동 (null 체크) - if (profileData != null) { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - ProfileEditScreen(profile: profileData), - ), - ); - } else { - // 데이터 로딩 중이거나 에러 시 알림 (선택 사항) - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('프로필 정보를 불러오는 중입니다.')), - ); - } - }, - child: SvgPicture.asset( - 'assets/images/icons/my_page_edit.svg', - width: 24, - height: 24, - colorFilter: const ColorFilter.mode( - AppColors.black, - BlendMode.srcIn, - ), - ), - ), - ], - ), - ); - } - - Widget _buildMenuSection(BuildContext context) { - return Column( - children: [ - MyPageMenuItem( - title: '푸시 알림 설정', - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const PushNotificationSettingsScreen(), - ), - ); - }, - ), - const SizedBox(height: 10), - MyPageMenuItem( - title: '로그아웃', - onTap: () { - // 분리된 LogoutDialog 호출 - showDialog( - context: context, - builder: (context) => const LogoutDialog(), - ); - }, - ), - const SizedBox(height: 10), - MyPageMenuItem( - title: '회원 탈퇴', - textColor: AppColors.notification, - showArrow: true, - onTap: () { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const WithdrawalScreen()), - ); - }, - ), - ], - ); - } -} diff --git a/lib/features/user/screens/profile_edit_screen.dart b/lib/features/user/screens/profile/profile_edit_screen.dart similarity index 83% rename from lib/features/user/screens/profile_edit_screen.dart rename to lib/features/user/screens/profile/profile_edit_screen.dart index 050d186..9e213e7 100644 --- a/lib/features/user/screens/profile_edit_screen.dart +++ b/lib/features/user/screens/profile/profile_edit_screen.dart @@ -1,4 +1,4 @@ -// 최초 작성자 : 김채영 +// 최초 작성자 : 김채영, 리팩토링: 정승빈 import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -6,21 +6,23 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:image_picker/image_picker.dart'; import '../../../../core/theme/app_colors.dart'; import '../../../../core/theme/app_typography.dart'; -import 'package:haenaem/features/user/model/user_model.dart'; -import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; -import 'package:haenaem/shared/models/tag_data.dart'; +import 'package:haenaem/shared/models/tag_model.dart'; + import 'package:haenaem/shared/widgets/app_tag_chip.dart'; import 'package:haenaem/shared/widgets/image_source_sheet.dart'; -import '../../../../shared/widgets/bottom_action_button.dart'; +import '../../../../../shared/widgets/bottom_action_button.dart'; import 'package:haenaem/features/auth/signup/screens/profile_image_edit_screen.dart'; -import '../widgets/profile_image_menu.dart'; -import '../data/user_repository.dart'; -import '../provider/tag_provider.dart'; +import 'package:haenaem/shared/models/user_detail.dart'; +import 'package:haenaem/features/user/provider/user_provider.dart'; + +import '../../widgets/profile_image_menu.dart'; +import '../../../../shared/provider/tag_provider.dart'; +import '../../provider/user_profile_provider.dart'; // 프로필 편집 화면 class ProfileEditScreen extends ConsumerStatefulWidget { - final UserProfileModel profile; - const ProfileEditScreen({super.key, required this.profile}); + final UserDetail detail; + const ProfileEditScreen({super.key, required this.detail}); @override ConsumerState createState() => _ProfileEditScreenState(); @@ -31,7 +33,7 @@ class _ProfileEditScreenState extends ConsumerState { late TextEditingController _introController; bool _showImageMenu = false; // 이미지 관리 메뉴 노출 상태 File? _selectedImageFile; - bool _isImageDeleted = false; // 이미지 삭제 여부를 추적하는 상태 추가 + bool _isImageDeleted = false; // 이미지 삭제 여부를 추적하는 상태 final ImagePicker _picker = ImagePicker(); bool _isDuplicate = false; bool _isInvalidFormat = false; @@ -39,13 +41,15 @@ class _ProfileEditScreenState extends ConsumerState { @override void initState() { super.initState(); - _nicknameController = TextEditingController(text: widget.profile.nickname); - _introController = TextEditingController(text: widget.profile.introduction); + _nicknameController = TextEditingController( + text: widget.detail.user.nickname, + ); + _introController = TextEditingController(text: widget.detail.introduction); _nicknameController.addListener(_validateNickname); _introController.addListener(() => setState(() {})); - // 2. 💡 추가: 화면 진입 시 tagProvider 초기화 호출 + // 화면 진입 시 tagProvider 초기화 호출 Future.microtask(() => ref.read(tagProvider.notifier).initialize()); } @@ -56,7 +60,7 @@ class _ProfileEditScreenState extends ConsumerState { super.dispose(); } - // --- 💡 이미지 소스 선택 (카메라/갤러리) --- + // --- 이미지 소스 선택 (카메라/갤러리) --- void _showImageSourceSheet() { setState(() => _showImageMenu = false); // 팝업 메뉴 닫기 showModalBottomSheet( @@ -68,7 +72,7 @@ class _ProfileEditScreenState extends ConsumerState { ); } - // --- 💡 이미지 가져오기 및 편집 화면 이동 --- + // --- 이미지 가져오기 및 편집 화면 이동 --- Future _getImage(ImageSource source) async { final XFile? pickedFile = await _picker.pickImage(source: source); @@ -93,22 +97,21 @@ class _ProfileEditScreenState extends ConsumerState { } } - // --- 💡 이미지 삭제 API 연동 --- + // --- 💡 이미지 삭제: 로직을 Provider로 위임 --- Future _handleDeleteImage() async { try { - await ref.read(userRepositoryProvider).deleteProfileImage(); + await ref.read(userProfileProvider.notifier).deleteProfileImage(); setState(() { _selectedImageFile = null; _isImageDeleted = true; _showImageMenu = false; }); - ref.invalidate(myProfileProvider); } catch (e) { debugPrint('삭제 실패: $e'); } } - // 💡 닉네임 실시간 유효성 검사 (회원가입 로직 이식) + // 닉네임 실시간 유효성 검사 void _validateNickname() { final text = _nicknameController.text; setState(() { @@ -123,38 +126,22 @@ class _ProfileEditScreenState extends ConsumerState { }); } - // --- 💡 최종 저장 로직 (POST 이미지 포함) --- + // --- 💡 최종 저장: 복잡한 API 호출을 모두 Provider로 위임 --- Future _handleSave() async { try { - final userRepo = ref.read(userRepositoryProvider); - final tagNotifier = ref.read(tagProvider.notifier); - - // 로딩바 등을 보여줄 수 있는 로직 추가 가능 (예: 다이얼로그) - - // 1. 닉네임 변경 체크 및 실행 - if (_nicknameController.text != widget.profile.nickname) { - await userRepo.updateNickname(_nicknameController.text); - } - - // 2. 💡 한 줄 소개 변경 체크 및 실행 - if (_introController.text != widget.profile.introduction) { - await userRepo.updateIntroduction(_introController.text); - } - - // 3. 이미지 변경 체크 및 실행 - if (_selectedImageFile != null) { - await userRepo.uploadProfileImage(_selectedImageFile!); - } - - final tagSuccess = await tagNotifier.updateInterestTags(); - - if (!tagSuccess) { - throw Exception('태그 수정 중 오류가 발생했습니다.'); - } + await ref + .read(userProfileProvider.notifier) + .updateProfile( + currentNickname: + widget.detail.user.nickname, // ✅ widget.profile → widget.user + newNickname: _nicknameController.text, + currentIntro: + widget.detail.introduction, // ✅ widget.profile → widget.detail + newIntro: _introController.text, + newImageFile: _selectedImageFile, + ); - // 4. 모든 작업 성공 시 처리 if (mounted) { - ref.invalidate(myProfileProvider); // 마이페이지 정보 갱신 Navigator.pop(context); ScaffoldMessenger.of( context, @@ -162,7 +149,6 @@ class _ProfileEditScreenState extends ConsumerState { } } catch (e) { final errorMsg = e.toString(); - // 닉네임 중복 등의 에러는 기존처럼 setState로 에러 메시지 노출 if (errorMsg.contains('DUPLICATE')) { setState(() => _isDuplicate = true); } else { @@ -178,14 +164,19 @@ class _ProfileEditScreenState extends ConsumerState { @override Widget build(BuildContext context) { final tagState = ref.watch(tagProvider); + final profileEditState = ref.watch(userProfileProvider); // 💡 프로필 저장 상태 구독 + final selectedTagsFromProvider = tagState.tags; final bool isNicknameValid = _nicknameController.text.isNotEmpty && !_isInvalidFormat; + + // 💡 Provider가 작업 중(loading)일 때는 저장 버튼을 비활성화하여 중복 터치 방지 final bool isEnabled = isNicknameValid && selectedTagsFromProvider.length >= 2 && selectedTagsFromProvider.length <= 6 && - !tagState.isLoading; + !tagState.isLoading && + !profileEditState.isLoading; return Scaffold( backgroundColor: Colors.white, @@ -221,7 +212,6 @@ class _ProfileEditScreenState extends ConsumerState { ), ), - // 💡 닫기 레이어와 팝업 메뉴를 본문(Layer 1) 안의 Stack으로 옮겼습니다. if (_showImageMenu) ...[ Positioned.fill( child: GestureDetector( @@ -262,7 +252,6 @@ class _ProfileEditScreenState extends ConsumerState { ); } - // --- 프로필 이미지 섹션 (Positioned 포함) --- Widget _buildProfileImageSection() { return Center( child: SizedBox( @@ -287,9 +276,13 @@ class _ProfileEditScreenState extends ConsumerState { _selectedImageFile!, fit: BoxFit.cover, ) // 2순위: 새로 고름 - : widget.profile.profileImageUrl.isNotEmpty + : (widget.detail.user.profileUrl ?? '') + .isNotEmpty // ✅ widget.profile → widget.user ? Image.network( - widget.profile.profileImageUrl, + widget + .detail + .user + .profileUrl!, // ✅ widget.profile → widget.user fit: BoxFit.cover, ) // 3순위: 기존 이미지 : SvgPicture.asset( @@ -317,9 +310,6 @@ class _ProfileEditScreenState extends ConsumerState { ); } - // --- 입력 필드 및 태그 섹션 로직 --- - - // --- 💡 닉네임 입력 필드 (에러 메시지 포함하도록 수정) --- Widget _buildNicknameSection() { String? errorMessage; if (_isInvalidFormat) { diff --git a/lib/features/user/screens/push_notification_settings_screen.dart b/lib/features/user/screens/push_notification_settings_screen.dart deleted file mode 100644 index 0c61216..0000000 --- a/lib/features/user/screens/push_notification_settings_screen.dart +++ /dev/null @@ -1,167 +0,0 @@ -// 최초 작성자 : 김채영 -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_typography.dart'; -import '../../../shared/widgets/custom_switch.dart'; -import '../../notification/provider/push_notification_provider.dart'; - -// 푸시 알림 설정 화면 -class PushNotificationSettingsScreen extends ConsumerWidget { - const PushNotificationSettingsScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final settings = ref.watch(pushNotificationProvider); - final notifier = ref.read(pushNotificationProvider.notifier); - - return Scaffold( - backgroundColor: Colors.white, - appBar: AppBar( - backgroundColor: Colors.white, - elevation: 0, - leading: IconButton( - icon: SvgPicture.asset( - 'assets/images/icons/arrow_left.svg', - width: 24, - height: 24, - colorFilter: const ColorFilter.mode( - AppColors.black, - BlendMode.srcIn, - ), - ), - onPressed: () => Navigator.pop(context), - ), - title: Text( - '푸시 알림 설정', - style: AppTypography.h3.copyWith(color: AppColors.black), - ), - centerTitle: true, - ), - body: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 10), - _buildSwitchRow( - title: '전체 알림', - subtitle: '모든 알림 받기', - value: settings.allNotifications, - onChanged: (val) => notifier.toggle('all', val), - isMain: true, - ), - const Padding( - padding: EdgeInsets.only(left: 20, right: 20, top: 20, bottom: 0), - child: Divider(color: AppColors.gray4, height: 1), - ), - - _buildSectionHeader('소셜'), - _buildSwitchRow( - title: '내 글 좋아요', - subtitle: "내 게시물에 '좋아요' 반응이 올 때 알림", - value: settings.likeNotifications, - onChanged: (val) => notifier.toggle('like', val), - ), - _buildSwitchRow( - title: '댓글', - subtitle: '내 글에 새로운 댓글이 달릴 때 알림', - value: settings.commentNotifications, - onChanged: (val) => notifier.toggle('comment', val), - ), - _buildSwitchRow( - title: '친구 신청', - subtitle: '나에게 새로운 친구 요청이 도착할 때 알림', - value: settings.friendRequestNotifications, - onChanged: (val) => notifier.toggle('friend', val), - ), - - _buildSectionHeader('챌린지'), - _buildSwitchRow( - title: '챌린지 초대', - subtitle: '새로운 챌린지 참여 제안을 받았을 때 알림', - value: settings.challengeInviteNotifications, - onChanged: (val) => notifier.toggle('invite', val), - ), - _buildSwitchRow( - title: '동기부여 메시지', - subtitle: '꾸준한 챌린지 참여를 돕는 응원 푸시 알림', - value: settings.motivationNotifications, - onChanged: (val) => notifier.toggle('motivation', val), - ), - _buildSwitchRow( - title: '일일 리마인더 (전체)', - subtitle: '리마인더 알림 통합 관리', - value: settings.dailyReminder, // 전용 필드로 수정 - onChanged: (val) => notifier.toggle('reminder', val), - ), - - _buildSectionHeader('커뮤니티 활동'), - _buildSwitchRow( - title: '멤버 반응 소식 (전체)', - subtitle: '멤버 반응 알림 통합 관리', - value: settings.likeNotifications, // 좋아요/댓글 등 소식 연결 - onChanged: (val) => notifier.toggle('like', val), - ), - _buildSwitchRow( - title: '멤버 인증 소식 (전체)', - subtitle: '멤버 인증 알림 통합 관리', - value: settings.memberAuthNotifications, - onChanged: (val) => notifier.toggle('memberAuth', val), - ), - const SizedBox(height: 40), - ], - ), - ), - ); - } - - // 섹션 제목 위젯 - Widget _buildSectionHeader(String title) { - return Padding( - padding: const EdgeInsets.only(left: 20, top: 20, bottom: 10), - child: Text( - title, - style: AppTypography.b1.copyWith(color: AppColors.gray1), - ), - ); - } - - // 토글 타일 위젯 - Widget _buildSwitchRow({ - required String title, - required String subtitle, - required bool value, - required ValueChanged onChanged, - bool isMain = false, - }) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: AppTypography.b1.copyWith( - color: AppColors.black, - fontWeight: isMain ? FontWeight.w600 : FontWeight.w500, - ), - ), - Text( - subtitle, - style: AppTypography.b2.copyWith(color: AppColors.gray2), - ), - ], - ), - ), - // 2. 커스텀 스위치 적용 - CustomSwitch(value: value, onChanged: onChanged), - ], - ), - ); - } -} diff --git a/lib/features/user/screens/settings/push_notification_settings_screen.dart b/lib/features/user/screens/settings/push_notification_settings_screen.dart new file mode 100644 index 0000000..c855bd6 --- /dev/null +++ b/lib/features/user/screens/settings/push_notification_settings_screen.dart @@ -0,0 +1,450 @@ +// 최초 작성자: 김채영 +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/theme/app_typography.dart'; +import '../../../../shared/widgets/custom_switch.dart'; +import '../../../notification/provider/push_notification_provider.dart'; + +// 푸시 알림 설정 화면 +class PushNotificationSettingsScreen extends ConsumerStatefulWidget { + const PushNotificationSettingsScreen({super.key}); + + @override + ConsumerState createState() => + _PushNotificationSettingsScreenState(); +} + +class _PushNotificationSettingsScreenState + extends ConsumerState { + // ── 시간 변환 헬퍼 ──────────────────────────────────────── + String _convertToDisplayTime(String serverTime) { + final hour = int.parse(serverTime.split(':')[0]); + if (hour == 0) return '오전 12시'; + if (hour < 12) return '오전 $hour시'; + if (hour == 12) return '오후 12시'; + return '오후 ${hour - 12}시'; + } + + String _convertToServerTime(String period, String hourStr) { + int hour = int.parse(hourStr.replaceAll('시', '')); + if (period == '오후' && hour != 12) hour += 12; + if (period == '오전' && hour == 12) hour = 0; + return '${hour.toString().padLeft(2, '0')}:00'; + } + + // ── 공통 시간 피커 ──────────────────────────────────────── + void _showTimePicker( + BuildContext context, + String currentDisplayTime, + void Function(String serverTime) onConfirm, + ) { + final List hours = List.generate(12, (i) => '${i + 1}시'); + String currentPeriod = currentDisplayTime.contains('오후') ? '오후' : '오전'; + String currentHour = currentDisplayTime.split(' ').last; + int initialHourIndex = hours.indexOf(currentHour); + if (initialHourIndex == -1) initialHourIndex = 8; + + showDialog( + context: context, + barrierColor: Colors.black.withAlpha(100), + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + backgroundColor: Colors.white, + contentPadding: const EdgeInsets.fromLTRB(20, 20, 20, 0), + content: SizedBox( + width: MediaQuery.of(context).size.width * 0.8, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: _buildAnimatedPeriodSelector( + currentPeriod, + (newPeriod) => + setDialogState(() => currentPeriod = newPeriod), + ), + ), + const SizedBox(height: 24), + SizedBox( + height: 150, + child: Stack( + children: [ + Center( + child: Container( + height: 40, + margin: const EdgeInsets.symmetric(horizontal: 24), + decoration: BoxDecoration( + color: AppColors.gray4.withAlpha(100), + borderRadius: BorderRadius.circular(10), + ), + ), + ), + CupertinoPicker( + itemExtent: 40, + scrollController: FixedExtentScrollController( + initialItem: initialHourIndex, + ), + onSelectedItemChanged: (index) => + setDialogState(() => currentHour = hours[index]), + selectionOverlay: const SizedBox.shrink(), + children: hours + .map( + (h) => Center( + child: Text(h, style: AppTypography.b1), + ), + ) + .toList(), + ), + ], + ), + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.only( + bottom: 16, + left: 16, + right: 16, + ), + child: TextButton( + onPressed: () { + final serverTime = _convertToServerTime( + currentPeriod, + currentHour, + ); + onConfirm(serverTime); // ✅ 콜백으로 처리 + Navigator.pop(context); + }, + style: TextButton.styleFrom( + backgroundColor: Colors.transparent, + foregroundColor: AppColors.primaryAble, + minimumSize: const Size(double.infinity, 52), + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + '완료', + style: AppTypography.b1.copyWith( + color: AppColors.primaryAble, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildAnimatedPeriodSelector( + String currentPeriod, + Function(String) onPeriodChanged, + ) { + return Container( + height: 48, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: AppColors.gray4, + borderRadius: BorderRadius.circular(12), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final double width = constraints.maxWidth / 2; + return Stack( + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + alignment: currentPeriod == '오전' + ? Alignment.centerLeft + : Alignment.centerRight, + child: Container( + width: width - 4, + height: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(20), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + ), + ), + Row( + children: ['오전', '오후'].map((p) { + final bool isSelected = currentPeriod == p; + return Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onPeriodChanged(p), + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 250), + style: AppTypography.b2.copyWith( + color: isSelected ? Colors.black : AppColors.gray2, + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + ), + child: Text(p), + ), + ), + ), + ); + }).toList(), + ), + ], + ); + }, + ), + ); + } + + Widget _buildWeeklyReminderSection( + PushNotificationSettings settings, + PushNotificationSettingsNotifier notifier, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 타이틀 + 설명 (스위치 없음) + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '실패 방지 리마인더', + style: AppTypography.b1.copyWith( + color: AppColors.black, + fontWeight: FontWeight.w500, + ), + ), + Text( + '챌린지 실패를 방지하는 주간 리마인더 알림', + style: AppTypography.b2.copyWith(color: AppColors.gray2), + ), + ], + ), + ), + ], + ), + // 시간 피커 드롭다운 (항상 표시) + const SizedBox(height: 4), + GestureDetector( + onTap: () => _showTimePicker( + context, + _convertToDisplayTime(settings.weeklyReminderTime), + (serverTime) => notifier.updateWeeklyReminderTime(serverTime), + ), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.gray5, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _convertToDisplayTime(settings.weeklyReminderTime), + style: AppTypography.b2.copyWith(color: AppColors.gray3), + ), + Opacity( + opacity: 0.5, + child: SvgPicture.asset( + 'assets/images/icons/big_down_arrow.svg', + width: 16, + height: 16, + colorFilter: const ColorFilter.mode( + AppColors.gray2, + BlendMode.srcIn, + ), + ), + ), + ], + ), + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final settings = ref.watch(pushNotificationProvider); + final notifier = ref.read(pushNotificationProvider.notifier); + + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + surfaceTintColor: Colors.white, + leading: IconButton( + icon: SvgPicture.asset( + 'assets/images/icons/arrow_left.svg', + width: 24, + height: 24, + colorFilter: const ColorFilter.mode( + AppColors.black, + BlendMode.srcIn, + ), + ), + onPressed: () => Navigator.pop(context), + ), + title: Text( + '푸시 알림 설정', + style: AppTypography.h3.copyWith(color: AppColors.black), + ), + centerTitle: true, + ), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── 전체 알림 ─────────────────────────────── + const SizedBox(height: 16), + _buildSwitchRow( + title: '전체 알림', + subtitle: '모든 알림 받기', + value: settings.allNotifications, + onChanged: (val) => notifier.toggleAll(val), + isMain: true, + ), + const SizedBox(height: 20), + const Divider(color: AppColors.gray4, height: 1), + const SizedBox(height: 20), + + // ── 소셜 섹션 ──────────────────────────────── + _buildSectionHeader('소셜'), + const SizedBox(height: 10), + Column( + spacing: 20, + children: [ + _buildSwitchRow( + title: '내 글 좋아요', + subtitle: "내 게시물에 '좋아요' 반응이 올 때 알림", + value: settings.likeNotifications, + onChanged: (val) => notifier.toggleLikes(val), + ), + _buildSwitchRow( + title: '댓글', + subtitle: '내 글에 새로운 댓글이 달릴 때 알림', + value: settings.commentNotifications, + onChanged: (val) => notifier.toggleComments(val), + ), + _buildSwitchRow( + title: '친구 신청', + subtitle: '나에게 새로운 친구 요청이 도착할 때 알림', + value: settings.friendRequestNotifications, + onChanged: (val) => notifier.toggleFriend(val), + ), + _buildSwitchRow( + title: '멤버 인증 소식 (전체)', + subtitle: '멤버 인증 알림 통합 관리', + value: settings.memberAuthNotifications, + onChanged: (val) => notifier.toggleMemberAuth(val), + ), + ], + ), + const SizedBox(height: 42), + + // ── 챌린지 섹션 ────────────────────────────── + _buildSectionHeader('챌린지'), + const SizedBox(height: 10), + Column( + spacing: 20, + children: [ + _buildSwitchRow( + title: '챌린지 초대', + subtitle: '새로운 챌린지 참여 제안을 받았을 때 알림', + value: settings.challengeInviteNotifications, + onChanged: (val) => notifier.toggleChallengeInvite(val), + ), + _buildSwitchRow( + title: '동기부여 메시지', + subtitle: '꾸준한 챌린지 참여를 돕는 응원 푸시 알림', + value: settings.motivationNotifications, + onChanged: (val) => notifier.toggleMotivation(val), + ), + // ── 일일 리마인더 (시간 피커 없음) ────────────── + _buildSwitchRow( + title: '일일 리마인더 (전체)', + subtitle: '리마인더 알림 통합 관리', + value: settings.dailyReminder, + onChanged: (val) => notifier.toggleDailyReminder(val), + ), + // ── 실패 방지 리마인더 ───────────────────── + _buildWeeklyReminderSection(settings, notifier), + ], + ), + const SizedBox(height: 100), + ], + ), + ), + ), + ); + } + + // ── 섹션 헤더 ──────────────────────────────────────────── + Widget _buildSectionHeader(String title) { + return Text( + title, + style: AppTypography.b1.copyWith(color: AppColors.gray1), + ); + } + + // ── 스위치 행 ──────────────────────────────────────────── + Widget _buildSwitchRow({ + required String title, + required String subtitle, + required bool value, + required ValueChanged onChanged, + bool isMain = false, + }) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: AppTypography.b1.copyWith( + color: AppColors.black, + fontWeight: isMain ? FontWeight.w600 : FontWeight.w500, + ), + ), + Text( + subtitle, + style: AppTypography.b2.copyWith(color: AppColors.gray2), + ), + ], + ), + ), + CustomSwitch(value: value, onChanged: onChanged), + ], + ); + } +} diff --git a/lib/features/user/screens/withdrawal_screen.dart b/lib/features/user/screens/settings/withdrawal_screen.dart similarity index 98% rename from lib/features/user/screens/withdrawal_screen.dart rename to lib/features/user/screens/settings/withdrawal_screen.dart index 6c7b4a4..8dee71e 100644 --- a/lib/features/user/screens/withdrawal_screen.dart +++ b/lib/features/user/screens/settings/withdrawal_screen.dart @@ -3,9 +3,9 @@ library; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import '../../../../core/theme/app_colors.dart'; -import '../../../../core/theme/app_typography.dart'; -import '../../auth/services/auth_service.dart'; +import '../../../../../core/theme/app_colors.dart'; +import '../../../../../core/theme/app_typography.dart'; +import '../../../auth/services/auth_service.dart'; import 'package:haenaem/features/auth/signup/screens/auth_gate.dart'; /// 클래스의 용도: 회원 탈퇴 안내 및 동의 확인, 탈퇴 처리를 수행하는 화면 diff --git a/lib/features/user/views/my_challenge_section_view.dart b/lib/features/user/views/my_challenge_section_view.dart new file mode 100644 index 0000000..15e6e0c --- /dev/null +++ b/lib/features/user/views/my_challenge_section_view.dart @@ -0,0 +1,222 @@ +// 최초 작성자 : 김채영 +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_typography.dart'; +//import '../../challenge/models/challenge_model.dart'; +// import '../../challenge/provider/challenge_provider.dart'; +import 'package:haenaem/features/user/provider/my_challenge_provider.dart'; +import '../screens/challenge/challenge_list_screen.dart'; +import '../widgets/my_challenge_card.dart'; // 💡 공통 카드 위젯 추가 (위치가 widgets 내부일 경우 경로 주의) +import 'package:haenaem/features/user/models/my_page_challenge_card.dart'; + +// 내페이지 나의 챌린지 영역 +class MyChallengeSectionView extends ConsumerStatefulWidget { + const MyChallengeSectionView({super.key}); + + @override + ConsumerState createState() => + _MyChallengeSectionViewState(); +} + +enum MyPageTab { inProgress, success, fail } + +class _MyChallengeSectionViewState + extends ConsumerState { + // 섹션 내부에서 탭 상태 관리 + MyPageTab selectedTab = MyPageTab.inProgress; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.gray4, width: 1), + ), + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildHeader(), + const Divider(height: 1, color: AppColors.gray4), + _buildChallengeListArea(), + ], + ), + ); + } + + // --- 헤더 영역 (제목 + 더보기 + 탭) --- + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + color: AppColors.gray5, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '나의 챌린지', + style: AppTypography.h3.copyWith(color: Colors.black), + ), + _buildMoreButton(), + ], + ), + const SizedBox(height: 8), + _buildStatusTabs(), + ], + ), + ); + } + + Widget _buildMoreButton() { + return InkWell( + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (context) => const ChallengeListScreen()), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('더보기', style: AppTypography.b1.copyWith(color: AppColors.gray3)), + SvgPicture.asset( + 'assets/images/icons/right_arrow_icon.svg', + width: 20, + height: 20, + ), + ], + ), + ); + } + + // --- 탭 버튼 영역 --- + Widget _buildStatusTabs() { + return Row( + children: [ + _buildTabButton( + '진행중', + MyPageTab.inProgress, + 'assets/images/icons/inprogress.svg', + ), + const SizedBox(width: 8), + _buildTabButton( + '완료', + MyPageTab.success, + 'assets/images/icons/success_check.svg', + ), + const SizedBox(width: 8), + _buildTabButton( + '실패', + MyPageTab.fail, + 'assets/images/icons/fail_circle.svg', + ), + ], + ); + } + + Widget _buildTabButton(String label, MyPageTab tab, String svgPath) { + final bool isSelected = selectedTab == tab; + final Color activeColor = _getTabColor(tab); + + return Expanded( + child: InkWell( + onTap: () => setState(() => selectedTab = tab), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 6), + decoration: BoxDecoration( + color: isSelected ? activeColor : Colors.white, + borderRadius: BorderRadius.circular(9), + border: isSelected ? null : Border.all(color: AppColors.gray4), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset( + svgPath, + width: 16, + height: 16, + colorFilter: ColorFilter.mode( + isSelected ? Colors.white : AppColors.gray3, + BlendMode.srcIn, + ), + ), + const SizedBox(width: 4), + Text( + label, + style: AppTypography.b2.copyWith( + color: isSelected ? Colors.white : AppColors.gray3, + ), + ), + ], + ), + ), + ), + ); + } + + Color _getTabColor(MyPageTab tab) { + switch (tab) { + case MyPageTab.inProgress: + return AppColors.blue; + case MyPageTab.success: + return AppColors.primaryAble; + case MyPageTab.fail: + return AppColors.notification; + } + } + + // --- 챌린지 리스트 데이터 처리 영역 --- + Widget _buildChallengeListArea() { + final challengesAsync = _getChallengesProvider(); + + return challengesAsync.when( + loading: () => const Padding( + padding: EdgeInsets.all(30), + child: Center(child: CircularProgressIndicator()), + ), + error: (err, stack) => const Padding( + padding: EdgeInsets.all(20), + child: Center(child: Text('데이터 로드 실패')), + ), + data: (list) { + if (list.isEmpty) { + return Container( + height: 100, + alignment: Alignment.center, + child: const Text( + '해당하는 챌린지가 없습니다.', + style: TextStyle(color: AppColors.gray2), + ), + ); + } + return Column( + children: list.asMap().entries.map((entry) { + final isLast = entry.key == (list.length - 1); + final challenge = entry.value; + return Column( + children: [ + MyChallengeCard(item: challenge), + if (!isLast) const Divider(height: 1, color: AppColors.gray5), + ], + ); + }).toList(), + ); + }, + ); + } + + AsyncValue> _getChallengesProvider() { + switch (selectedTab) { + case MyPageTab.inProgress: + // 💡 주의: 프로바이더 자체의 정의(challenge_provider.dart)도 + // MyPageChallengeCard를 반환하도록 수정되어 있어야 합니다. + return ref.watch(myInProgressChallengesProvider(onlyTwo: true)); + case MyPageTab.success: + return ref.watch(mySuccessChallengesProvider(onlyTwo: true)); + case MyPageTab.fail: + return ref.watch(myFailedChallengesProvider(onlyTwo: true)); + } + } +} diff --git a/lib/features/user/views/my_page_menu_view.dart b/lib/features/user/views/my_page_menu_view.dart new file mode 100644 index 0000000..8ffe451 --- /dev/null +++ b/lib/features/user/views/my_page_menu_view.dart @@ -0,0 +1,52 @@ +// 최초 작성자: 정승빈 +import 'package:flutter/material.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../screens/settings/push_notification_settings_screen.dart'; // 4단계에서 경로 수정 예정 +import '../screens/settings/withdrawal_screen.dart'; // 4단계에서 경로 수정 예정 +import '../widgets/my_page_menu_item.dart'; +import '../widgets/logout_dialog.dart'; + +class MyPageMenuView extends StatelessWidget { + const MyPageMenuView({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + MyPageMenuItem( + title: '푸시 알림 설정', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const PushNotificationSettingsScreen(), + ), + ); + }, + ), + const SizedBox(height: 10), + MyPageMenuItem( + title: '로그아웃', + onTap: () { + showDialog( + context: context, + builder: (context) => const LogoutDialog(), + ); + }, + ), + const SizedBox(height: 10), + MyPageMenuItem( + title: '회원 탈퇴', + textColor: AppColors.notification, + showArrow: true, + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const WithdrawalScreen()), + ); + }, + ), + ], + ); + } +} diff --git a/lib/features/user/widgets/profile_header.dart b/lib/features/user/views/profile_header_view.dart similarity index 82% rename from lib/features/user/widgets/profile_header.dart rename to lib/features/user/views/profile_header_view.dart index f1de06d..9200fb5 100644 --- a/lib/features/user/widgets/profile_header.dart +++ b/lib/features/user/views/profile_header_view.dart @@ -3,17 +3,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_typography.dart'; -import '../../../shared/models/tag_data.dart'; +import '../../../shared/models/tag_model.dart'; import '../../../shared/widgets/tag_badge.dart'; +import 'package:haenaem/shared/widgets/user_profile_circle.dart'; // 이미지, 닉네임, 소개글, 그리고 복잡했던 태그 정렬 로직 -class ProfileHeader extends StatelessWidget { +class ProfileHeaderView extends StatelessWidget { final String nickname; final String introduction; final String profileImageUrl; final List tags; - const ProfileHeader({ + const ProfileHeaderView({ super.key, required this.nickname, required this.introduction, @@ -40,20 +41,10 @@ class ProfileHeader extends StatelessWidget { } Widget _buildImage() { - return Container( - width: 150, - height: 150, - decoration: const BoxDecoration( - color: Color(0xFFDFE1DC), - shape: BoxShape.circle, - ), - child: ClipOval( - child: profileImageUrl.isNotEmpty - ? Image.network(profileImageUrl, fit: BoxFit.cover) - : SvgPicture.asset( - 'assets/images/placeholders/default_profile.svg', - ), - ), + return UserProfileCircle( + // URL이 비어있으면 null을 넘겨 UserProfileCircle이 기본 아이콘을 그리게 함. + imageUrl: profileImageUrl.isNotEmpty ? profileImageUrl : null, + size: 150, ); } diff --git a/lib/features/user/widgets/challenge_section.dart b/lib/features/user/widgets/challenge_section.dart deleted file mode 100644 index e3b6e28..0000000 --- a/lib/features/user/widgets/challenge_section.dart +++ /dev/null @@ -1,558 +0,0 @@ -// 최초 작성자 : 김채영 -import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_typography.dart'; -import '../../challenge/model/challenge_model.dart'; -import '../../challenge/provider/challenge_provider.dart'; -import '../screens/challenge_list_screen.dart'; - -// 챌린지 로직 (탭 전환, 리스트 필터링, 카드 디자인) -class ChallengeSection extends ConsumerStatefulWidget { - const ChallengeSection({super.key}); - - @override - ConsumerState createState() => _ChallengeSectionState(); -} - -class _ChallengeSectionState extends ConsumerState { - // 섹션 내부에서 탭 상태 관리 - MyPageTab selectedTab = MyPageTab.inProgress; - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.gray4, width: 1), - ), - clipBehavior: Clip.antiAlias, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildHeader(), - const Divider(height: 1, color: AppColors.gray4), - _buildChallengeListArea(), - ], - ), - ); - } - - // --- 헤더 영역 (제목 + 더보기 + 탭) --- - Widget _buildHeader() { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - color: AppColors.gray5, - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - '나의 챌린지', - style: AppTypography.h3.copyWith(color: Colors.black), - ), - _buildMoreButton(), - ], - ), - const SizedBox(height: 8), - _buildStatusTabs(), - ], - ), - ); - } - - Widget _buildMoreButton() { - return InkWell( - onTap: () => Navigator.push( - context, - MaterialPageRoute(builder: (context) => const ChallengeListScreen()), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text('더보기', style: AppTypography.b1.copyWith(color: AppColors.gray3)), - SvgPicture.asset( - 'assets/images/icons/right_arrow_icon.svg', - width: 20, - height: 20, - ), - ], - ), - ); - } - - // --- 탭 버튼 영역 --- - Widget _buildStatusTabs() { - return Row( - children: [ - _buildTabButton( - '진행중', - MyPageTab.inProgress, - 'assets/images/icons/inprogress.svg', - ), - const SizedBox(width: 8), - _buildTabButton( - '완료', - MyPageTab.success, - 'assets/images/icons/success_check.svg', - ), - const SizedBox(width: 8), - _buildTabButton( - '실패', - MyPageTab.fail, - 'assets/images/icons/fail_circle.svg', - ), - ], - ); - } - - Widget _buildTabButton(String label, MyPageTab tab, String svgPath) { - final bool isSelected = selectedTab == tab; - final Color activeColor = _getTabColor(tab); - - return Expanded( - child: InkWell( - onTap: () => setState(() => selectedTab = tab), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 6), - decoration: BoxDecoration( - color: isSelected ? activeColor : Colors.white, - borderRadius: BorderRadius.circular(9), - border: isSelected ? null : Border.all(color: AppColors.gray4), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SvgPicture.asset( - svgPath, - width: 16, - height: 16, - colorFilter: ColorFilter.mode( - isSelected ? Colors.white : AppColors.gray3, - BlendMode.srcIn, - ), - ), - const SizedBox(width: 4), - Text( - label, - style: AppTypography.b2.copyWith( - color: isSelected ? Colors.white : AppColors.gray3, - ), - ), - ], - ), - ), - ), - ); - } - - Color _getTabColor(MyPageTab tab) { - switch (tab) { - case MyPageTab.inProgress: - return AppColors.blue; - case MyPageTab.success: - return AppColors.primaryAble; - case MyPageTab.fail: - return AppColors.notification; - } - } - - // --- 챌린지 리스트 데이터 처리 영역 --- - Widget _buildChallengeListArea() { - final challengesAsync = _getChallengesProvider(); - - return challengesAsync.when( - loading: () => const Padding( - padding: EdgeInsets.all(30), - child: Center(child: CircularProgressIndicator()), - ), - error: (err, stack) => const Padding( - padding: EdgeInsets.all(20), - child: Center(child: Text('데이터 로드 실패')), - ), - data: (list) { - if (list.isEmpty) { - return Container( - height: 100, - alignment: Alignment.center, - child: const Text( - '해당하는 챌린지가 없습니다.', - style: TextStyle(color: AppColors.gray2), - ), - ); - } - return Column( - children: list.asMap().entries.map((entry) { - final isLast = entry.key == (list.length - 1); - return Column( - children: [ - _buildChallengeCard(entry.value), - if (!isLast) Divider(height: 1, color: AppColors.gray5), - ], - ); - }).toList(), - ); - }, - ); - } - - AsyncValue> _getChallengesProvider() { - switch (selectedTab) { - case MyPageTab.inProgress: - return ref.watch(myInProgressChallengesProvider(onlyTwo: true)); - case MyPageTab.success: - return ref.watch(mySuccessChallengesProvider(onlyTwo: true)); - case MyPageTab.fail: - return ref.watch(myFailedChallengesProvider(onlyTwo: true)); - } - } - - // --- 개별 챌린지 카드 UI --- - Widget _buildChallengeCard(ChallengeInProgressModel item) { - final serverStatus = item.status.toUpperCase(); - - // 실패한 챌린지일 경우 전용 UI를 반환 - if (serverStatus == "FAIL" || serverStatus == "FAILED") { - return _buildFailedChallengeCard(item); - } - - // 완료 상태일 때 - if (serverStatus == "SUCCESS") { - return _buildSuccessChallengeCard(item); - } - - final Color themeColor = _getTabColor(selectedTab); - - String statusText = '진행중'; - if (serverStatus == "SUCCESS") - statusText = '완료'; - else if (serverStatus == "FAIL" || serverStatus == "FAILED") - statusText = '실패'; - - return Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - color: Colors.transparent, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.start, // 추가 - children: [ - Flexible( - child: Text( - item.title, - style: AppTypography.b3.copyWith( - color: AppColors.black, - ), - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - ), - const SizedBox(width: 7.35), - _buildStatusBadge(statusText, themeColor), - ], - ), - - const SizedBox(height: 3), - Text( - item.dateInfo, - style: AppTypography.b2.copyWith(color: AppColors.gray2), - ), - ], - ), - ), - _buildProgressText(item.progress, themeColor), - ], - ), - const SizedBox(height: 8), - _buildInfoRow(item, serverStatus), - const SizedBox(height: 8), - LinearProgressIndicator( - value: item.progress, - backgroundColor: AppColors.gray5, - color: themeColor, - minHeight: 4, - borderRadius: BorderRadius.circular(2), - ), - ], - ), - ); - } - - // 완료 전용 카드 UI - Widget _buildSuccessChallengeCard(ChallengeInProgressModel item) { - const Color successColor = AppColors.primaryAble; - Color gray5 = AppColors.gray5; - - return Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: ShapeDecoration( - color: Colors.white, - shape: RoundedRectangleBorder( - side: BorderSide(width: 0.69, color: gray5), - borderRadius: BorderRadius.circular(12), - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 왼쪽 정보 영역 - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Flexible( - child: Text( - item.title, - style: AppTypography.b3.copyWith( - color: AppColors.black, - ), - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - ), - const SizedBox(width: 7.35), - _buildStatusBadge('완료', successColor), - ], - ), - const SizedBox(height: 2), - // 완료일 데이터 바인딩 - Text( - '완료일 ${item.endDate.replaceAll('-', '/')}', - style: AppTypography.b2.copyWith(color: AppColors.gray2), - ), - const SizedBox(height: 2), - // 총 진행 일수 + 불 아이콘 - Row( - children: [ - SvgPicture.asset( - 'assets/images/icons/small_fire_icon.svg', - width: 16, - height: 16, - ), - const SizedBox(width: 2), - Text( - '총 ${item.duringDate}일', - style: AppTypography.b2.copyWith( - color: AppColors.black, - ), - ), - ], - ), - ], - ), - ), - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '${(item.progress * 100).toInt()}%', - textAlign: TextAlign.right, - style: AppTypography.h2.copyWith(color: successColor), - ), - Text( - '달성률', - textAlign: TextAlign.right, - style: AppTypography.c1.copyWith(color: AppColors.gray2), - ), - ], - ), - ], - ), - const SizedBox(height: 8), - // 하단 게이지 바 - ClipRRect( - borderRadius: BorderRadius.circular(23), - child: LinearProgressIndicator( - value: item.progress, - minHeight: 4, - backgroundColor: gray5, - valueColor: const AlwaysStoppedAnimation(successColor), - ), - ), - ], - ), - ); - } - - // 실패 전용 카드 UI - Widget _buildFailedChallengeCard(ChallengeInProgressModel item) { - const Color failColor = AppColors.notification; - Color gray5 = AppColors.gray5; - - return Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: ShapeDecoration( - color: Colors.white, - shape: RoundedRectangleBorder( - side: BorderSide(width: 0.69, color: gray5), - borderRadius: BorderRadius.circular(12), - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 1. 왼쪽 정보 영역 - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Flexible( - child: Text( - item.title, - style: AppTypography.b3.copyWith( - color: AppColors.black, - ), - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - ), - const SizedBox(width: 7.35), - _buildStatusBadge('실패', failColor), - ], - ), - const SizedBox(height: 2), - // 실패일 데이터 바인딩 - Text( - '실패일 ${item.endDate.replaceAll('-', '/')}', - style: AppTypography.b2.copyWith(color: AppColors.gray2), - ), - const SizedBox(height: 2), - // 누적 진행일 데이터 바인딩 - Row( - children: [ - SvgPicture.asset( - 'assets/images/icons/small_fire_icon.svg', // 불 아이콘 경로 확인 필요 - width: 16, - height: 16, - ), - const SizedBox(width: 2), // 아이콘과 텍스트 사이 간격 2 - Text( - '최대 ${item.duringDate}일', - style: AppTypography.b2.copyWith( - color: AppColors.black, - ), - ), - ], - ), - ], - ), - ), - // 2. 오른쪽 달성률 영역 - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '${(item.progress * 100).toInt()}%', - textAlign: TextAlign.right, - style: AppTypography.h2.copyWith( - color: AppColors.notification, - ), - ), - Text( - '달성률', - textAlign: TextAlign.right, - style: AppTypography.c1.copyWith(color: AppColors.gray2), - ), - ], - ), - ], - ), - const SizedBox(height: 8), - // 3. 하단 게이지 바 (Gauge) - ClipRRect( - borderRadius: BorderRadius.circular(23), - child: LinearProgressIndicator( - value: item.progress, - minHeight: 4, - backgroundColor: gray5, - valueColor: const AlwaysStoppedAnimation(failColor), - ), - ), - ], - ), - ); - } - - Widget _buildStatusBadge(String text, Color color) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: ShapeDecoration( - color: color.withOpacity(0.1), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - child: Text(text, style: AppTypography.c1.copyWith(color: color)), - ); - } - - Widget _buildProgressText(double progress, Color color) { - return Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '${(progress * 100).toInt()}%', - style: AppTypography.h2.copyWith(color: color), - ), - Text('달성률', style: AppTypography.c1.copyWith(color: AppColors.gray2)), - ], - ); - } - - Widget _buildInfoRow(ChallengeInProgressModel item, String status) { - return Row( - children: [ - SvgPicture.asset('assets/images/icons/small_fire_icon.svg', width: 16), - const SizedBox(width: 2), - Text( - '${item.duringDate}일째', - style: AppTypography.b2.copyWith(color: AppColors.black), - ), - const SizedBox(width: 12), - if (status == "IN_PROGRESS") ...[ - SvgPicture.asset( - 'assets/images/icons/mini_success_icon.svg', - width: 16, - ), - const SizedBox(width: 2), - Text( - item.countInfo, - style: AppTypography.b2.copyWith(color: AppColors.black), - ), - ], - ], - ); - } -} diff --git a/lib/features/user/widgets/logout_dialog.dart b/lib/features/user/widgets/logout_dialog.dart index 659a9a8..b575313 100644 --- a/lib/features/user/widgets/logout_dialog.dart +++ b/lib/features/user/widgets/logout_dialog.dart @@ -8,6 +8,7 @@ import '../../../../core/theme/app_typography.dart'; import 'package:haenaem/features/notification/services/fcm_service.dart'; import 'package:haenaem/features/auth/services/auth_service.dart'; import 'package:haenaem/features/auth/signup/screens/auth_gate.dart'; +import 'package:haenaem/features/notification/provider/push_notification_provider.dart'; class LogoutDialog extends ConsumerWidget { const LogoutDialog({super.key}); @@ -55,11 +56,18 @@ class LogoutDialog extends ConsumerWidget { // Navigator.pop(context); // 2. FCM 토큰 삭제 (알림 방지) - await ref.read(fcmServiceProvider).deleteFcmToken(); + try { + await ref.read(fcmServiceProvider).deleteFcmToken(); + } catch (e) { + debugPrint('FCM 토큰 삭제 실패 (로그아웃은 계속 진행): $e'); + } // 3. 로그아웃 API 호출 및 로컬 데이터 삭제 await AuthService.logout(); + // ✅ 알림 설정 상태 초기화 + ref.invalidate(pushNotificationProvider); + // 4. 화면 이동 (context가 유효한지 확인 후 실행) if (context.mounted) { // rootNavigator: true를 사용하여 최상단 네비게이터 기준으로 이동 diff --git a/lib/features/user/widgets/my_challenge_card.dart b/lib/features/user/widgets/my_challenge_card.dart new file mode 100644 index 0000000..26ab288 --- /dev/null +++ b/lib/features/user/widgets/my_challenge_card.dart @@ -0,0 +1,166 @@ +// 최초 작성자: 정승빈 +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import '../../../../core/theme/app_colors.dart'; +import '../../../../core/theme/app_typography.dart'; +//import '../../challenge/models/challenge_model.dart'; +import '../models/my_page_challenge_card.dart'; + +// 내페이지 나의 챌린지의 챌린지 리스트에 속하는 챌린지 카드 위젯 +// mypage challenge card 모델을 데이터로 받아서 화면에 그린다 +class MyChallengeCard extends StatelessWidget { + final MyPageChallengeCard item; + + const MyChallengeCard({super.key, required this.item}); + + @override + Widget build(BuildContext context) { + // 상태별 테마 설정 + Color themeColor; + String statusText; + bool showBorder = item.status != ChallengeStatus.inProgress; + + switch (item.status) { + case ChallengeStatus.success: + themeColor = AppColors.primaryAble; + statusText = '완료'; + break; + case ChallengeStatus.fail: + themeColor = AppColors.notification; + statusText = '실패'; + break; + case ChallengeStatus.inProgress: + default: + themeColor = AppColors.blue; + statusText = '진행중'; + break; + } + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: ShapeDecoration( + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: const BorderSide(width: 0.69, color: AppColors.gray5), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 2, + children: [ + Row( + children: [ + Flexible( + child: Text( + item.challengeInfo.challengeBase.title, + style: AppTypography.b3.copyWith( + color: AppColors.black, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + const SizedBox(width: 7.35), + _buildStatusBadge(statusText, themeColor), + ], + ), + // ✅ 날짜 형식 수정 + Text( + item.status == ChallengeStatus.inProgress + ? '주 ${item.challengeInfo.weeklyFrequency}회, 완료까지 D-${item.challengeInfo.dDay}' + : '$statusText일 ${item.failedDate?.toString().substring(0, 10).replaceAll('-', '/') ?? ''}', + style: AppTypography.b2.copyWith(color: AppColors.gray2), + ), + // ✅ 스트리크/멤버 정보를 Column 안으로 이동 + _buildDetailInfoRow(themeColor), + ], + ), + ), + _buildProgressText(item.rate, themeColor), + ], + ), + const SizedBox(height: 8), + _buildGaugeBar(item.rate, themeColor), + ], + ), + ); + } + + // --- 하단 상세 정보 (스트리크 + 멤버 참여도) --- + Widget _buildDetailInfoRow(Color themeColor) { + final info = item.challengeInfo; + return Row( + children: [ + // ✅ ChallengeCard와 동일한 조건 적용 + if (info.streakCount > 0 && info.isDone) + Padding( + padding: const EdgeInsets.only(right: 4), + child: SvgPicture.asset( + 'assets/images/icons/small_fire_icon.svg', + width: 14, + ), + ), + Text( + '${info.streakCount}일째', // ✅ currentStreak 매핑 + style: AppTypography.b2.copyWith(color: AppColors.black), + ), + const SizedBox(width: 12), + SvgPicture.asset( + 'assets/images/icons/mini_success_icon.svg', + width: 16, + ), + const SizedBox(width: 4), + // 예: 3/5명 인증 완료 + Text( + '${info.successParticipantCount}/${info.participantCount}명', + style: AppTypography.b2.copyWith(color: AppColors.black), + ), + ], + ); + } + + Widget _buildStatusBadge(String text, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: ShapeDecoration( + color: color.withValues(alpha: 0.1), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: Text(text, style: AppTypography.c1.copyWith(color: color)), + ); + } + + Widget _buildProgressText(double progress, Color color) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${progress.toInt()}%', + style: AppTypography.h2.copyWith(color: color), + ), + Text('달성률', style: AppTypography.c1.copyWith(color: AppColors.gray2)), + ], + ); + } + + Widget _buildGaugeBar(double progress, Color color) { + return ClipRRect( + borderRadius: BorderRadius.circular(23), + child: LinearProgressIndicator( + value: progress, + minHeight: 4, + backgroundColor: AppColors.gray5, + valueColor: AlwaysStoppedAnimation(color), + ), + ); + } +} diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart index 096f66a..0c91cd0 100644 --- a/lib/firebase_options.dart +++ b/lib/firebase_options.dart @@ -47,6 +47,7 @@ class DefaultFirebaseOptions { projectId: 'haenaem-65e32', authDomain: 'haenaem-65e32.firebaseapp.com', storageBucket: 'haenaem-65e32.firebasestorage.app', + measurementId: 'G-RNH78P04WT', ); static const FirebaseOptions android = FirebaseOptions( @@ -82,5 +83,7 @@ class DefaultFirebaseOptions { projectId: 'haenaem-65e32', authDomain: 'haenaem-65e32.firebaseapp.com', storageBucket: 'haenaem-65e32.firebasestorage.app', + measurementId: 'G-1RWZ2HT5L8', ); -} + +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 5ef7a7b..6691b7b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,16 +1,13 @@ // 최초 작성자: 김채영 import 'package:flutter/material.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:haenaem/features/social/screens/social_screen.dart'; -import 'package:haenaem/features/user/screens/my_page_screen.dart'; - import 'package:intl/date_symbol_data_local.dart'; import 'package:haenaem/core/theme/app_theme.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:haenaem/features/main/screens/main_screen.dart'; import 'package:haenaem/features/auth/signup/screens/auth_gate.dart'; import 'package:firebase_core/firebase_core.dart'; @@ -21,9 +18,14 @@ import 'package:kakao_flutter_sdk_user/kakao_flutter_sdk_user.dart'; void main() async { // 플러터 엔진 초기화 확인 WidgetsFlutterBinding.ensureInitialized(); + // .env 파일 로드 + await dotenv.load(fileName: ".env"); - // 비동기 초기화 - WidgetsFlutterBinding.ensureInitialized(); + // 전역 에러 핸들러 + FlutterError.onError = (FlutterErrorDetails details) { + debugPrint('🔴 Flutter 에러: ${details.exception}'); + debugPrint('🔴 스택: ${details.stack}'); + }; // 저장소 인스턴스 생성 const storage = FlutterSecureStorage(); @@ -48,7 +50,7 @@ void main() async { await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); // 카카오 SDK 초기화 - KakaoSdk.init(nativeAppKey: '05a36f172ea2945260862834654385ea'); + KakaoSdk.init(nativeAppKey: dotenv.get('KAKAO_NATIVE_APP_KEY')); runApp(const ProviderScope(child: MyApp())); } diff --git a/lib/shared/data/post_repository.dart b/lib/shared/data/post_repository.dart new file mode 100644 index 0000000..4e9d5d9 --- /dev/null +++ b/lib/shared/data/post_repository.dart @@ -0,0 +1,47 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../../features/challenge/detail/models/calendar_post.dart'; +import 'package:haenaem/core/network/dio_provider.dart'; + +part 'post_repository.g.dart'; + +// 최초 작성자 : 강선욱 +class PostRepository { + final Dio _dio; + + PostRepository(this._dio); + + // 챌린지의 특정 연월 인증 포스트 목록 조회 + Future> getChallengePosts({ + required int challengeId, + required int year, + required int month, + }) async { + try { + final response = await _dio.get( + '/api/challenges/$challengeId/calendar/posts', + queryParameters: {'year': year, 'month': month, 'page': 0}, + ); + + if (response.statusCode == 200) { + final List content = response.data['content'] ?? []; + return content + .map((e) => CalendarPost.fromJson(e as Map)) + .toList(); + } else { + throw Exception('인증글 목록 조회 실패'); + } + } on DioException catch (e) { + debugPrint('❌ 인증글 API 에러: ${e.response?.data}'); + return []; + } + } +} + +@riverpod +PostRepository postRepository(Ref ref) { + final dio = ref.watch(dioProvider); + return PostRepository(dio); +} diff --git a/lib/shared/data/post_repository.g.dart b/lib/shared/data/post_repository.g.dart new file mode 100644 index 0000000..bdb359a --- /dev/null +++ b/lib/shared/data/post_repository.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'post_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$postRepositoryHash() => r'b2ee3bdc546e5c693abd9df6300a6e2ff6089c2c'; + +/// See also [postRepository]. +@ProviderFor(postRepository) +final postRepositoryProvider = AutoDisposeProvider.internal( + postRepository, + name: r'postRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$postRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef PostRepositoryRef = AutoDisposeProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/shared/models/challenge_base.dart b/lib/shared/models/challenge_base.dart new file mode 100644 index 0000000..a55bb79 --- /dev/null +++ b/lib/shared/models/challenge_base.dart @@ -0,0 +1,29 @@ +// 최초 작성자: 강선욱 +// 챌린지 기본 정보를 정의한 모델 +class ChallengeBase { + final int id; + final String title; + // final bool isLeader; // 현재 로그인 중인 유저가 해당 챌린지의 방장인지 여부 + + const ChallengeBase({ + required this.id, + required this.title, + // required this.isLeader, + }); + + factory ChallengeBase.fromJson(Map json) { + return ChallengeBase( + id: (json['challengeId']) as int, + title: json['title'] as String, + // isLeader: json['is_leader'] as bool, + ); + } + + ChallengeBase copyWith({int? id, String? title, bool? isLeader}) { + return ChallengeBase( + id: id ?? this.id, + title: title ?? this.title, + // isLeader: isLeader ?? this.isLeader, + ); + } +} diff --git a/lib/shared/models/challenge_detail.dart b/lib/shared/models/challenge_detail.dart new file mode 100644 index 0000000..9ffd732 --- /dev/null +++ b/lib/shared/models/challenge_detail.dart @@ -0,0 +1,70 @@ +import 'package:haenaem/shared/models/challenge_base.dart'; +import 'package:haenaem/shared/models/user.dart'; + +// 최초 작성자: 강선욱 +// 챌린지 상세 모델 +// ChallengeBase에 정의된 필드(id, title, isLeader)를 재사용 +// User 모델을 방장(host), 오늘 인증한 유저 리스트(todaySuccessUsers)에 재사용 +class ChallengeDetail { + final DateTime startDate; // 챌린지 시작 날짜 + final DateTime endDate; // 챌린지 종료 날짜 + final int weeklyFrequency; // 주간 최소 인증 빈도 + final bool photoRequired; // 사진 인증 필요 여부 + final List tags; // 챌린지 태그 리스트 + final String description; // 챌린지 설명 + final User leader; // 방장 정보 (id, profileUrl, nickname) + final int participantCount; // 참여자 수 + final List todaySuccessUsers; // 오늘 인증 완료한 유저 리스트 + + const ChallengeDetail({ + required this.startDate, + required this.endDate, + required this.weeklyFrequency, + required this.photoRequired, + required this.tags, + required this.description, + required this.leader, + required this.participantCount, + required this.todaySuccessUsers, + }); + + factory ChallengeDetail.fromJson(Map json) { + return ChallengeDetail( + startDate: DateTime.parse(json['startDate'] as String), + endDate: DateTime.parse(json['endDate'] as String), + weeklyFrequency: json['requiredWeeklyCount'] as int, + photoRequired: json['photoRequired'] as bool, + tags: List.from(json['tags'] as List), + description: json['description'] as String, + leader: User.fromJson(json['host'] as Map), + participantCount: json['participantCount'] as int, + todaySuccessUsers: (json['todaySuccessUsers'] as List) + .map((e) => User.fromJson(e as Map)) + .toList(), + ); + } + + ChallengeDetail copyWith({ + DateTime? startDate, + DateTime? endDate, + int? weeklyFrequency, + bool? photoRequired, + List? tags, + String? description, + User? leader, + int? participantCount, + List? todaySuccessUsers, + }) { + return ChallengeDetail( + startDate: startDate ?? this.startDate, + endDate: endDate ?? this.endDate, + weeklyFrequency: weeklyFrequency ?? this.weeklyFrequency, + photoRequired: photoRequired ?? this.photoRequired, + tags: tags ?? this.tags, + description: description ?? this.description, + leader: leader ?? this.leader, + participantCount: participantCount ?? this.participantCount, + todaySuccessUsers: todaySuccessUsers ?? this.todaySuccessUsers, + ); + } +} diff --git a/lib/shared/models/home_challenge_card.dart b/lib/shared/models/home_challenge_card.dart new file mode 100644 index 0000000..e2e1862 --- /dev/null +++ b/lib/shared/models/home_challenge_card.dart @@ -0,0 +1,62 @@ +import 'challenge_base.dart'; + +// 최초 작성자: 강선욱 +// 홈 화면 챌린지 카드 모델 +// ChallengeBase에 정의된 필드(id, title, isLeader)를 토대로 홈 화면에서 필요한 데이터로 구성 +class HomeChallengeCard { + final ChallengeBase challengeBase; // id, title, isLeader + final int streakCount; // 최근 인증 연속 일수 + final int participantCount; // 참여 인원 수 + final int successParticipantCount; // 인증 완료 인원 수 + final bool warning; // 챌린지 실패 여부 + final bool isDone; // 오늘 인증 완료 여부 + final int dDay; // 챌린지 종료까지 남은 날짜 + final int weeklyFrequency; // 주간 최소 인증 빈도 + + const HomeChallengeCard({ + required this.challengeBase, + required this.streakCount, + required this.participantCount, + required this.successParticipantCount, + required this.warning, + required this.isDone, + required this.dDay, + required this.weeklyFrequency, + }); + + factory HomeChallengeCard.fromJson(Map json) { + return HomeChallengeCard( + challengeBase: ChallengeBase.fromJson(json), + streakCount: json['currentStreak'] as int? ?? 0, + participantCount: json['participantNumber'] as int? ?? 0, + successParticipantCount: json['todaySuccessCount'] as int? ?? 0, + warning: json['warning'] as bool? ?? false, + isDone: json['doIt'] as bool? ?? false, + dDay: json['dueToDate'] as int? ?? 0, + weeklyFrequency: json['requiredWeeklyCount'] as int? ?? 0, + ); + } + + HomeChallengeCard copyWith({ + ChallengeBase? challengeBase, + int? streakCount, + int? participantCount, + int? successParticipantCount, + bool? warning, + bool? isDone, + int? dDay, + int? weeklyFrequency, + }) { + return HomeChallengeCard( + challengeBase: challengeBase ?? this.challengeBase, + streakCount: streakCount ?? this.streakCount, + participantCount: participantCount ?? this.participantCount, + successParticipantCount: + successParticipantCount ?? this.successParticipantCount, + warning: warning ?? this.warning, + isDone: isDone ?? this.isDone, + dDay: dDay ?? this.dDay, + weeklyFrequency: weeklyFrequency ?? this.weeklyFrequency, + ); + } +} diff --git a/lib/shared/models/post.dart b/lib/shared/models/post.dart new file mode 100644 index 0000000..9f98a0a --- /dev/null +++ b/lib/shared/models/post.dart @@ -0,0 +1,133 @@ +import 'package:haenaem/shared/models/user.dart'; +// import 'package:haenaem/features/challenge/models/image_model.dart'; + +// 최초 작성자: 강선욱 +// 인증글 모델 클래스 +// User에 정의된 필드(id, profileUrl, nickname)를 작성자 정보로 재사용 +class Post { + final int id; // 인증글 id + final String title; // 인증글 제목 + final String content; // 인증글 내용 + final List pictureUrl; // 인증글 사진 URL 리스트 + final int likeCount; // 좋아요 수 + final bool isLiked; // 현재 로그인 유저의 좋아요 여부 + final int commentCount; // 댓글 수 + final DateTime date; // 작성 날짜 + final User writer; // 작성자 정보 (id, profileUrl, nickname) + final int challengeId; // 챌린지 id + final String challengeTitle; // 챌린지 제목 + final int totalSuccessDays; // 챌린지 총 성공 일수 + + final bool isAuthor; // 작성자 본인 여부 (수정/삭제 권한) + final bool isEdited; // 수정된 게시글 여부 + + // 편의 메서드: 사진이 있는지 여부와 첫 번째 사진 URL을 쉽게 접근할 수 있도록 합니다. + bool get hasImage => pictureUrl.isNotEmpty; + // 첫 번째 사진 URL을 반환하는 편의 메서드입니다. 사진이 없으면 null을 반환합니다. + String? get imageUrl => + pictureUrl.isNotEmpty ? pictureUrl.first.imageUrl : null; + + const Post({ + required this.id, + required this.title, + required this.content, + required this.pictureUrl, + required this.likeCount, + required this.isLiked, + required this.commentCount, + required this.date, + required this.writer, + required this.challengeId, + required this.challengeTitle, + required this.totalSuccessDays, + required this.isAuthor, + required this.isEdited, + }); + + factory Post.fromJson(Map json) { + // 1. 날짜 파싱: updatedAt이 있으면 우선 사용하고, 없으면 createdAt 사용 + DateTime parsedDate = DateTime.now(); + if (json['updatedAt'] != null) { + parsedDate = DateTime.parse(json['updatedAt'].toString()).toLocal(); + } else if (json['createdAt'] != null) { + parsedDate = DateTime.parse(json['createdAt'].toString()).toLocal(); + } + + // 2. 작성자 정보 조립: 서버의 평면적 데이터를 Nested User 객체로 매핑 + final writer = User( + id: json['userId'] ?? 0, + nickname: json['userNickname'] ?? '익명', + profileUrl: json['userImageUrl'], + ); + + return Post( + id: json['postId'] ?? 0, + challengeId: json['challengeId'] ?? 0, + challengeTitle: json['challengeTitle'] ?? '', + totalSuccessDays: json['totalSuccessDays'] ?? 0, + title: json['title'] ?? '', + content: json['content'] ?? '', + pictureUrl: + (json['images'] as List?) + ?.map((e) => PostImage.fromJson(e as Map)) + .toList() ?? + const [], + likeCount: json['likeNumber'] ?? 0, + isLiked: json['liked'] ?? false, + commentCount: json['commentNumber'] ?? 0, + date: parsedDate, + writer: writer, + isAuthor: json['author'] ?? false, + isEdited: json['edited'] ?? false, + ); + } + + Post copyWith({ + int? id, + String? title, + String? content, + List? pictureUrl, + int? likeCount, + bool? isLiked, + int? commentCount, + DateTime? date, + User? writer, + int? challengeId, + String? challengeTitle, + int? totalSuccessDays, + bool? isAuthor, + bool? isEdited, + }) { + return Post( + id: id ?? this.id, + title: title ?? this.title, + content: content ?? this.content, + pictureUrl: pictureUrl ?? this.pictureUrl, + likeCount: likeCount ?? this.likeCount, + isLiked: isLiked ?? this.isLiked, + commentCount: commentCount ?? this.commentCount, + date: date ?? this.date, + writer: writer ?? this.writer, + challengeId: challengeId ?? this.challengeId, + challengeTitle: challengeTitle ?? this.challengeTitle, + totalSuccessDays: totalSuccessDays ?? this.totalSuccessDays, + isAuthor: isAuthor ?? this.isAuthor, + isEdited: isEdited ?? this.isEdited, + ); + } +} + +// 인증글 사진 정보를 관리하는 클래스 +class PostImage { + final int imageId; + final String imageUrl; + + PostImage({required this.imageId, required this.imageUrl}); + + factory PostImage.fromJson(Map json) { + return PostImage( + imageId: json['imageId'] ?? 0, + imageUrl: json['imageUrl'] ?? '', + ); + } +} diff --git a/lib/shared/models/search_challenge_card.dart b/lib/shared/models/search_challenge_card.dart new file mode 100644 index 0000000..bb3d501 --- /dev/null +++ b/lib/shared/models/search_challenge_card.dart @@ -0,0 +1,41 @@ +import 'package:haenaem/shared/models/challenge_base.dart'; + +// 최초 작성자: 강선욱 +// 챌린지 검색 후 검색 결과로 나오는 챌린지 카드 모델 +// ChallengeBase에 정의된 필드(id, title, isLeader)를 재사용 +class SearchChallengeCard { + final ChallengeBase base; // 챌린지 기본 정보 (id, title, isLeader) + final int participantCount; // 챌린지 참여자 수 + final int dDay; // 챌린지 종료 D-Day + final List tags; // 챌린지 태그 리스트 + + const SearchChallengeCard({ + required this.base, + required this.participantCount, + required this.dDay, + required this.tags, + }); + + factory SearchChallengeCard.fromJson(Map json) { + return SearchChallengeCard( + base: ChallengeBase.fromJson(json), + participantCount: json['participant_count'] as int, + dDay: json['end_date'] as int, + tags: List.from(json['tag'] as List), + ); + } + + SearchChallengeCard copyWith({ + ChallengeBase? base, + int? participantCount, + int? dDay, + List? tags, + }) { + return SearchChallengeCard( + base: base ?? this.base, + participantCount: participantCount ?? this.participantCount, + dDay: dDay ?? this.dDay, + tags: tags ?? this.tags, + ); + } +} diff --git a/lib/shared/models/tag_data.dart b/lib/shared/models/tag_model.dart similarity index 74% rename from lib/shared/models/tag_data.dart rename to lib/shared/models/tag_model.dart index 7bbacf6..777dad5 100644 --- a/lib/shared/models/tag_data.dart +++ b/lib/shared/models/tag_model.dart @@ -1,4 +1,27 @@ // 최초 작성자 : 김채영 + +class ChallengeTagModel { + final int id; + final String tag; + final String tagCategory; + + int get tagId => id; + + ChallengeTagModel({ + required this.id, + required this.tag, + required this.tagCategory, + }); + + factory ChallengeTagModel.fromJson(Map json) { + return ChallengeTagModel( + id: json['tagId'] ?? 0, + tag: json['tag'] ?? '', + tagCategory: json['tagCategory'] ?? 'AGE', + ); + } +} + // 서버의 영문 카테고리를 앱 내 한글 명칭으로 변환 class TagMapper { // 카테고리 배치 순서 diff --git a/lib/shared/models/user.dart b/lib/shared/models/user.dart new file mode 100644 index 0000000..b9ec3a3 --- /dev/null +++ b/lib/shared/models/user.dart @@ -0,0 +1,30 @@ +// 최초 작성자: 강선욱 +// 사용자 정보를 저장하는 모델 클래스 + +class User { + final int id; + final String? profileUrl; + final String nickname; + + const User({required this.id, this.profileUrl, required this.nickname}); + + // API 데이터와 모델 클래스 데이터 매핑 + factory User.fromJson(Map json) { + return User( + id: (json['id'] ?? json['userId']) is int + ? (json['id'] ?? json['userId']) + : int.tryParse(json['id'].toString()) ?? 0, + profileUrl: json['profileImageUrl'] as String?, + nickname: (json['nickname'] ?? json['name']) as String, + ); + } + + // 특정 필드만 변경하여 새 User 객체를 반환하는 메서드 + User copyWith({int? id, String? profileUrl, String? nickname}) { + return User( + id: id ?? this.id, + profileUrl: profileUrl ?? this.profileUrl, + nickname: nickname ?? this.nickname, + ); + } +} diff --git a/lib/shared/models/user_detail.dart b/lib/shared/models/user_detail.dart new file mode 100644 index 0000000..58ae92c --- /dev/null +++ b/lib/shared/models/user_detail.dart @@ -0,0 +1,31 @@ +// 최초 작성자: 김채영 +import 'user.dart'; + +// 한줄소개, 태그 모델 + 기본 유저 정보 +class UserDetail { + final User user; + final String introduction; + final List tags; + + UserDetail({ + required this.user, + required this.introduction, + required this.tags, + }); + + factory UserDetail.fromJson(Map json) { + return UserDetail( + user: User.fromJson(json), + introduction: json['introduction'] ?? '', + tags: List.from(json['tags'] ?? []), + ); + } + // 로컬 업데이트를 위한 copyWith + UserDetail copyWith({User? user, String? introduction, List? tags}) { + return UserDetail( + user: user ?? this.user, + introduction: introduction ?? this.introduction, + tags: tags ?? this.tags, + ); + } +} diff --git a/lib/shared/provider/challenge_detail_provider.dart b/lib/shared/provider/challenge_detail_provider.dart new file mode 100644 index 0000000..302e3a7 --- /dev/null +++ b/lib/shared/provider/challenge_detail_provider.dart @@ -0,0 +1,17 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:haenaem/shared/models/challenge_detail.dart'; +import 'package:haenaem/features/challenge/detail/data/challenge_detail_repository.dart'; + +part 'challenge_detail_provider.g.dart'; + +// 최초 작성자 : 강선욱 +// 챌린지 상세 정보 provider +@riverpod +Future challengeDetail( + Ref ref, { + required int challengeId, +}) async { + final repository = ref.watch(challengeDetailRepositoryProvider); + return repository.getChallengeDetail(challengeId); +} diff --git a/lib/shared/provider/challenge_detail_provider.g.dart b/lib/shared/provider/challenge_detail_provider.g.dart new file mode 100644 index 0000000..210a8b5 --- /dev/null +++ b/lib/shared/provider/challenge_detail_provider.g.dart @@ -0,0 +1,155 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'challenge_detail_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$challengeDetailHash() => r'ea0b246d99206d198ec31081a6a32ee83aaee20c'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [challengeDetail]. +@ProviderFor(challengeDetail) +const challengeDetailProvider = ChallengeDetailFamily(); + +/// See also [challengeDetail]. +class ChallengeDetailFamily extends Family> { + /// See also [challengeDetail]. + const ChallengeDetailFamily(); + + /// See also [challengeDetail]. + ChallengeDetailProvider call({required int challengeId}) { + return ChallengeDetailProvider(challengeId: challengeId); + } + + @override + ChallengeDetailProvider getProviderOverride( + covariant ChallengeDetailProvider provider, + ) { + return call(challengeId: provider.challengeId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'challengeDetailProvider'; +} + +/// See also [challengeDetail]. +class ChallengeDetailProvider + extends AutoDisposeFutureProvider { + /// See also [challengeDetail]. + ChallengeDetailProvider({required int challengeId}) + : this._internal( + (ref) => challengeDetail( + ref as ChallengeDetailRef, + challengeId: challengeId, + ), + from: challengeDetailProvider, + name: r'challengeDetailProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$challengeDetailHash, + dependencies: ChallengeDetailFamily._dependencies, + allTransitiveDependencies: + ChallengeDetailFamily._allTransitiveDependencies, + challengeId: challengeId, + ); + + ChallengeDetailProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.challengeId, + }) : super.internal(); + + final int challengeId; + + @override + Override overrideWith( + FutureOr Function(ChallengeDetailRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: ChallengeDetailProvider._internal( + (ref) => create(ref as ChallengeDetailRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + challengeId: challengeId, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _ChallengeDetailProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is ChallengeDetailProvider && other.challengeId == challengeId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, challengeId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin ChallengeDetailRef on AutoDisposeFutureProviderRef { + /// The parameter `challengeId` of this provider. + int get challengeId; +} + +class _ChallengeDetailProviderElement + extends AutoDisposeFutureProviderElement + with ChallengeDetailRef { + _ChallengeDetailProviderElement(super.provider); + + @override + int get challengeId => (origin as ChallengeDetailProvider).challengeId; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/shared/provider/home_provider.dart b/lib/shared/provider/home_provider.dart new file mode 100644 index 0000000..872ac91 --- /dev/null +++ b/lib/shared/provider/home_provider.dart @@ -0,0 +1,55 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:haenaem/features/home/data/home_repository.dart'; +import 'package:haenaem/features/home/models/home_response.dart'; + +part 'home_provider.g.dart'; + +@riverpod +class HomeNotifier extends _$HomeNotifier { + @override + FutureOr build() async { + final today = _getFormattedDate(DateTime.now()); + // 🔍 빌드 시점에 날짜가 어떻게 찍히는지 확인 + print('🧐 [HomeNotifier] build() 호출 - 요청 날짜: $today'); + + return ref + .watch(homeRepositoryProvider) + .getHomeData(_getFormattedDate(DateTime.now())); + } + + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard( + () => ref + .read(homeRepositoryProvider) + .getHomeData(_getFormattedDate(DateTime.now())), + ); + } + + // ♥️ 삭제할 거임 여기부터 + //✅ 인증 완료 시 서버 재조회 없이 로컬 상태만 즉시 갱신 + void markChallengeAsDone(int challengeId) { + final current = state.valueOrNull; + if (current == null) return; + + final updatedChallenges = current.myChallenges.map((c) { + if (c.challengeBase.id == challengeId) { + return c.copyWith(isDone: true); + } + return c; + }).toList(); + + state = AsyncValue.data( + HomeResponse( + myChallenges: updatedChallenges, + notificationNumber: current.notificationNumber, + ), + ); + } + + // ♥️ 여기까지 + + String _getFormattedDate(DateTime dateTime) { + return "${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')}"; + } +} diff --git a/lib/shared/provider/home_provider.g.dart b/lib/shared/provider/home_provider.g.dart new file mode 100644 index 0000000..76b9a6c --- /dev/null +++ b/lib/shared/provider/home_provider.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'home_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$homeNotifierHash() => r'f97dc114d5fc6da358e915a6a7a545d60bdc8e0f'; + +/// See also [HomeNotifier]. +@ProviderFor(HomeNotifier) +final homeNotifierProvider = + AutoDisposeAsyncNotifierProvider.internal( + HomeNotifier.new, + name: r'homeNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$homeNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$HomeNotifier = AutoDisposeAsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/shared/provider/post_provider.dart b/lib/shared/provider/post_provider.dart new file mode 100644 index 0000000..1b264a6 --- /dev/null +++ b/lib/shared/provider/post_provider.dart @@ -0,0 +1,23 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../../features/challenge/detail/models/calendar_post.dart'; +import '../data/post_repository.dart'; + +part 'post_provider.g.dart'; + +// 최초 작성자 : 강선욱 +// 내현황 탭 달력, 인증글 리스트 데이터 불러오기 +@riverpod +Future> monthlyChallengePosts( + Ref ref, { + required int challengeId, + required int year, + required int month, +}) async { + final repository = ref.watch(postRepositoryProvider); + return repository.getChallengePosts( + challengeId: challengeId, + year: year, + month: month, + ); +} diff --git a/lib/shared/provider/post_provider.g.dart b/lib/shared/provider/post_provider.g.dart new file mode 100644 index 0000000..d209d1b --- /dev/null +++ b/lib/shared/provider/post_provider.g.dart @@ -0,0 +1,199 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'post_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$monthlyChallengePostsHash() => + r'dfcde62130fba3d3465ea6297f7d4b4c294b58f9'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [monthlyChallengePosts]. +@ProviderFor(monthlyChallengePosts) +const monthlyChallengePostsProvider = MonthlyChallengePostsFamily(); + +/// See also [monthlyChallengePosts]. +class MonthlyChallengePostsFamily + extends Family>> { + /// See also [monthlyChallengePosts]. + const MonthlyChallengePostsFamily(); + + /// See also [monthlyChallengePosts]. + MonthlyChallengePostsProvider call({ + required int challengeId, + required int year, + required int month, + }) { + return MonthlyChallengePostsProvider( + challengeId: challengeId, + year: year, + month: month, + ); + } + + @override + MonthlyChallengePostsProvider getProviderOverride( + covariant MonthlyChallengePostsProvider provider, + ) { + return call( + challengeId: provider.challengeId, + year: provider.year, + month: provider.month, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'monthlyChallengePostsProvider'; +} + +/// See also [monthlyChallengePosts]. +class MonthlyChallengePostsProvider + extends AutoDisposeFutureProvider> { + /// See also [monthlyChallengePosts]. + MonthlyChallengePostsProvider({ + required int challengeId, + required int year, + required int month, + }) : this._internal( + (ref) => monthlyChallengePosts( + ref as MonthlyChallengePostsRef, + challengeId: challengeId, + year: year, + month: month, + ), + from: monthlyChallengePostsProvider, + name: r'monthlyChallengePostsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$monthlyChallengePostsHash, + dependencies: MonthlyChallengePostsFamily._dependencies, + allTransitiveDependencies: + MonthlyChallengePostsFamily._allTransitiveDependencies, + challengeId: challengeId, + year: year, + month: month, + ); + + MonthlyChallengePostsProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.challengeId, + required this.year, + required this.month, + }) : super.internal(); + + final int challengeId; + final int year; + final int month; + + @override + Override overrideWith( + FutureOr> Function(MonthlyChallengePostsRef provider) + create, + ) { + return ProviderOverride( + origin: this, + override: MonthlyChallengePostsProvider._internal( + (ref) => create(ref as MonthlyChallengePostsRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + challengeId: challengeId, + year: year, + month: month, + ), + ); + } + + @override + AutoDisposeFutureProviderElement> createElement() { + return _MonthlyChallengePostsProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is MonthlyChallengePostsProvider && + other.challengeId == challengeId && + other.year == year && + other.month == month; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, challengeId.hashCode); + hash = _SystemHash.combine(hash, year.hashCode); + hash = _SystemHash.combine(hash, month.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin MonthlyChallengePostsRef + on AutoDisposeFutureProviderRef> { + /// The parameter `challengeId` of this provider. + int get challengeId; + + /// The parameter `year` of this provider. + int get year; + + /// The parameter `month` of this provider. + int get month; +} + +class _MonthlyChallengePostsProviderElement + extends AutoDisposeFutureProviderElement> + with MonthlyChallengePostsRef { + _MonthlyChallengePostsProviderElement(super.provider); + + @override + int get challengeId => (origin as MonthlyChallengePostsProvider).challengeId; + @override + int get year => (origin as MonthlyChallengePostsProvider).year; + @override + int get month => (origin as MonthlyChallengePostsProvider).month; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/user/provider/tag_provider.dart b/lib/shared/provider/tag_provider.dart similarity index 85% rename from lib/features/user/provider/tag_provider.dart rename to lib/shared/provider/tag_provider.dart index 8b0ae14..9199d7e 100644 --- a/lib/features/user/provider/tag_provider.dart +++ b/lib/shared/provider/tag_provider.dart @@ -1,12 +1,20 @@ // 최초 작성자 : 김채영 import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../../challenge/data/challenge_repository.dart'; -import '../../challenge/model/challenge_model.dart'; -import 'package:haenaem/features/user/model/user_model.dart'; +import 'package:haenaem/shared/models/tag_model.dart'; +// import '../../challenge/models/challenge_model.dart'; +import 'package:haenaem/shared/models/user_detail.dart'; import 'package:haenaem/features/user/data/user_repository.dart'; import 'package:flutter/foundation.dart'; -import '../../auth/signup/models/signup_state.dart'; +import '../../features/auth/signup/models/signup_state.dart'; + +part 'tag_provider.g.dart'; + +// 서버 태그 목록 불러오기 (생성 화면에서 사용하므로 함께 이동하는 것이 좋습니다) +@riverpod +Future> allTags(AllTagsRef ref) { + return ref.watch(userRepositoryProvider).getAllTags(); +} final tagProvider = NotifierProvider(() { return TagNotifier(); @@ -30,11 +38,11 @@ class TagNotifier extends Notifier { // 전체 태그와 내 프로필 동시 로드 final results = await Future.wait([ repository.getAllTags(), - repository.getMyProfile(), // GET /api/users/me/profile + repository.getMyProfile(), ]); _allServerTags = results[0] as List; - final profile = results[1] as UserProfileModel; // 프로필 모델 가정 + final profile = results[1] as UserDetail; // 프로필 모델 가정 _initialTagNames = List.from(profile.tags); diff --git a/lib/features/challenge/data/challenge_repository.g.dart b/lib/shared/provider/tag_provider.g.dart similarity index 58% rename from lib/features/challenge/data/challenge_repository.g.dart rename to lib/shared/provider/tag_provider.g.dart index df674c1..2977fc4 100644 --- a/lib/features/challenge/data/challenge_repository.g.dart +++ b/lib/shared/provider/tag_provider.g.dart @@ -1,29 +1,28 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'challenge_repository.dart'; +part of 'tag_provider.dart'; // ************************************************************************** // RiverpodGenerator // ************************************************************************** -String _$challengeRepositoryHash() => - r'3920ce5147cf50cb20768c46077b5b86ce03cba5'; +String _$allTagsHash() => r'509a8857edbc465e553a55ab145a6a56fd3ac1f8'; -/// See also [challengeRepository]. -@ProviderFor(challengeRepository) -final challengeRepositoryProvider = - AutoDisposeProvider.internal( - challengeRepository, - name: r'challengeRepositoryProvider', +/// See also [allTags]. +@ProviderFor(allTags) +final allTagsProvider = + AutoDisposeFutureProvider>.internal( + allTags, + name: r'allTagsProvider', debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') ? null - : _$challengeRepositoryHash, + : _$allTagsHash, dependencies: null, allTransitiveDependencies: null, ); @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element -typedef ChallengeRepositoryRef = AutoDisposeProviderRef; +typedef AllTagsRef = AutoDisposeFutureProviderRef>; // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/feed/screens/challenge_detail_screen.dart b/lib/shared/screens/challenge_detail_screen.dart similarity index 89% rename from lib/features/feed/screens/challenge_detail_screen.dart rename to lib/shared/screens/challenge_detail_screen.dart index 56ffd72..6adb946 100644 --- a/lib/features/feed/screens/challenge_detail_screen.dart +++ b/lib/shared/screens/challenge_detail_screen.dart @@ -4,14 +4,21 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:haenaem/core/theme/app_colors.dart'; import 'package:haenaem/core/theme/app_typography.dart'; -import 'package:haenaem/features/challenge/detail/widgets/challenge_detail_content.dart'; -import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; +// import 'package:haenaem/features/challenge/provider/challenge_provider.dart'; +import '../../features/feed/provider/challenge_participate_provider.dart'; +import 'package:haenaem/shared/provider/challenge_detail_provider.dart'; +import 'package:haenaem/shared/widgets/challenge_detail_content.dart'; import 'package:haenaem/features/feed/widgets/enter_confirm_dialog.dart'; import 'package:haenaem/shared/widgets/bottom_action_button.dart'; class ChallengeDetailScreen extends ConsumerStatefulWidget { final int challengeId; - const ChallengeDetailScreen({super.key, required this.challengeId}); + final String challengeTitle; + const ChallengeDetailScreen({ + super.key, + required this.challengeId, + required this.challengeTitle, + }); @override ConsumerState createState() => @@ -53,7 +60,7 @@ class _ChallengeDetailScreenState extends ConsumerState { onPressed: () => Navigator.pop(context), ), title: Text( - "챌린지 상세정보", + widget.challengeTitle, style: AppTypography.h3.copyWith(color: AppColors.black), ), centerTitle: true, @@ -109,7 +116,7 @@ class _ChallengeDetailScreenState extends ConsumerState { context: context, builder: (context) => EnterConfirmDialog( challengeId: widget.challengeId, - challengeTitle: challenge.title, + challengeTitle: widget.challengeTitle, ), ); } else { diff --git a/lib/shared/widgets/animated_toast.dart b/lib/shared/widgets/animated_toast.dart new file mode 100644 index 0000000..f681511 --- /dev/null +++ b/lib/shared/widgets/animated_toast.dart @@ -0,0 +1,110 @@ +// 최초 작성자: 정승빈 + +import 'package:flutter/material.dart'; +import '../../core/theme/app_typography.dart'; + +// 토스트를 띄우기 위한 공통 함수 +void displayToast(BuildContext context, String message) { + final overlay = Overlay.of(context); + late OverlayEntry overlayEntry; + + overlayEntry = OverlayEntry( + builder: (context) => AnimatedToast( + message: message, + onDismissed: () { + overlayEntry.remove(); + }, + ), + ); + + overlay.insert(overlayEntry); +} + +class AnimatedToast extends StatefulWidget { + final String message; + final VoidCallback onDismissed; + + const AnimatedToast({ + super.key, + required this.message, + required this.onDismissed, + }); + + @override + State createState() => _AnimatedToastState(); +} + +class _AnimatedToastState extends State + with SingleTickerProviderStateMixin { + late AnimationController controller; + late Animation slideAnimation; + late Animation opacityAnimation; + + @override + void initState() { + super.initState(); + controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + ); + + slideAnimation = Tween( + begin: const Offset(0, 0.5), + end: Offset.zero, + ).animate(CurvedAnimation(parent: controller, curve: Curves.easeOutQuart)); + + opacityAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation(parent: controller, curve: Curves.easeIn)); + + controller.forward().then((_) async { + await Future.delayed(const Duration(seconds: 2)); + if (mounted) { + await controller.reverse(); + widget.onDismissed(); + } + }); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Positioned( + bottom: 60, + left: 20, + right: 20, + child: IgnorePointer( + child: Material( + color: Colors.transparent, + child: FadeTransition( + opacity: opacityAnimation, + child: SlideTransition( + position: slideAnimation, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + decoration: BoxDecoration( + color: const Color(0xCC1A1D1B), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + widget.message, + textAlign: TextAlign.center, + style: AppTypography.b1.copyWith(color: Colors.white), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/challenge/detail/widgets/challenge_detail_content.dart b/lib/shared/widgets/challenge_detail_content.dart similarity index 62% rename from lib/features/challenge/detail/widgets/challenge_detail_content.dart rename to lib/shared/widgets/challenge_detail_content.dart index 36e8083..30d39bc 100644 --- a/lib/features/challenge/detail/widgets/challenge_detail_content.dart +++ b/lib/shared/widgets/challenge_detail_content.dart @@ -3,14 +3,16 @@ import 'package:intl/intl.dart'; import 'package:flutter_svg/svg.dart'; import 'package:haenaem/core/theme/app_colors.dart'; import 'package:haenaem/core/theme/app_typography.dart'; -import 'package:haenaem/features/challenge/model/challenge_model.dart'; -import 'package:haenaem/shared/models/tag_data.dart'; +import 'package:haenaem/shared/models/challenge_detail.dart'; +import 'package:haenaem/shared/widgets/user_profile_circle.dart'; +// import 'package:haenaem/features/challenge/models/challenge_model.dart'; +// import 'package:haenaem/shared/models/tag_model.dart'; import 'package:haenaem/shared/widgets/tag_badge.dart'; class ChallengeDetailContent extends StatelessWidget { - final ChallengeDetailModel challenge; + final ChallengeDetail challenge; final ScrollController scrollController; - final bool showTitle; // ✅ 제목 노출 여부 추가 + final bool showTitle; const ChallengeDetailContent({ super.key, @@ -21,56 +23,47 @@ class ChallengeDetailContent extends StatelessWidget { @override Widget build(BuildContext context) { - print("DEBUG: 태그 리스트 길이 -> ${challenge.tags.length}"); - if (challenge.tags.isNotEmpty) { - print("DEBUG: 첫 번째 태그 내용 -> ${challenge.tags.first.tag}"); - } - // 1. 날짜 데이터 가공 - String formattedStart = challenge.startDate.isNotEmpty - ? DateFormat( - 'yyyy년 MM월 dd일', - ).format(DateTime.parse(challenge.startDate)) - : ""; - String formattedEnd = challenge.endDate.isNotEmpty - ? DateFormat('yyyy년 MM월 dd일').format(DateTime.parse(challenge.endDate)) - : ""; + String formattedStart = DateFormat( + 'yyyy년 MM월 dd일', + ).format(challenge.startDate); + String formattedEnd = DateFormat('yyyy년 MM월 dd일').format(challenge.endDate); // 2. D-Day 계산 로직 String dDayString = ""; - if (challenge.endDate.isNotEmpty) { - DateTime end = DateTime.parse(challenge.endDate); - DateTime today = DateTime.now(); - DateTime targetDay = DateTime(end.year, end.month, end.day); - DateTime currentDay = DateTime(today.year, today.month, today.day); - - int difference = targetDay.difference(currentDay).inDays; + final DateTime targetDay = DateTime( + challenge.endDate.year, + challenge.endDate.month, + challenge.endDate.day, + ); + final DateTime today = DateTime.now(); + final DateTime currentDay = DateTime(today.year, today.month, today.day); + final int difference = targetDay.difference(currentDay).inDays + 1; - if (difference == 0) { - dDayString = "(D-Day)"; - } else if (difference > 0) { - dDayString = "(D-$difference)"; - } else { - dDayString = "(종료됨)"; - } + if (difference == 0) { + dDayString = "(D-Day)"; + } else if (difference > 0) { + dDayString = "(D-$difference)"; + } else { + dDayString = "(종료됨)"; } // 인증 빈도 텍스트 가공 로직 - String frequencyText = challenge.requiredWeeklyCount == 7 + String frequencyText = challenge.weeklyFrequency == 7 ? '매일' - : '주 ${challenge.requiredWeeklyCount}회'; + : '주 ${challenge.weeklyFrequency}회'; // TagMapper 기준 정렬 로직 - final List sortedTags = List.from(challenge.tags); - final priorityList = TagMapper.tagInternalOrder.values - .expand((e) => e) - .toList(); + // final List sortedTags = List.from(challenge.tags); + // final priorityList = TagMapper.tagInternalOrder.values + // .expand((e) => e) + // .toList(); - sortedTags.sort((a, b) { - final indexA = priorityList.indexOf(a.tag); - final indexB = priorityList.indexOf(b.tag); - return (indexA == -1 ? 99 : indexA).compareTo(indexB == -1 ? 99 : indexB); - }); + // sortedTags.sort((a, b) { + // final indexA = priorityList.indexOf(a.tag); + // final indexB = priorityList.indexOf(b.tag); + // return (indexA == -1 ? 99 : indexA).compareTo(indexB == -1 ? 99 : indexB); + // }); return SingleChildScrollView( controller: scrollController, @@ -78,15 +71,6 @@ class ChallengeDetailContent extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 챌린지 제목 - if (showTitle) ...[ - Text( - challenge.title, - style: AppTypography.h3.copyWith(color: AppColors.black), - ), - const SizedBox(height: 24), - ], - _buildInfoSection('챌린지 시작일', formattedStart), _buildInfoSection('챌린지 마감일', '$formattedEnd $dDayString'), _buildInfoSection('인증 빈도', frequencyText), @@ -102,7 +86,6 @@ class ChallengeDetailContent extends StatelessWidget { ), const SizedBox(height: 8), - // 가로 스크롤 대신 Wrap을 사용하여 정돈된 느낌을 줍니다. challenge.tags.isEmpty ? Text( "-", @@ -111,11 +94,25 @@ class ChallengeDetailContent extends StatelessWidget { : Wrap( spacing: 8, runSpacing: 10, - children: sortedTags.map((tagObj) { - // 💡 AppTagChip 대신 새로 만든 TagBadge를 사용합니다. - return TagBadge(label: tagObj.tag); - }).toList(), + children: challenge.tags + .map((tag) => TagBadge(label: tag)) + .toList(), ), + + // 가로 스크롤 대신 Wrap을 사용하여 정돈된 느낌을 줍니다. + // challenge.tags.isEmpty + // ? Text( + // "-", + // style: AppTypography.b1.copyWith(color: AppColors.gray3), + // ) + // : Wrap( + // spacing: 8, + // runSpacing: 10, + // children: sortedTags.map((tagObj) { + // // 💡 AppTagChip 대신 새로 만든 TagBadge를 사용합니다. + // return TagBadge(label: tagObj.tag); + // }).toList(), + // ), const _CustomDivider(), // 챌린지 설명 @@ -139,10 +136,13 @@ class ChallengeDetailContent extends StatelessWidget { const SizedBox(height: 12), Row( children: [ - _buildProfileImage(challenge.host.profileImageUrl), + UserProfileCircle( + imageUrl: challenge.leader.profileUrl, + size: 36, + ), const SizedBox(width: 12), Text( - challenge.host.name, + challenge.leader.nickname, style: AppTypography.b1.copyWith(color: AppColors.black), ), ], @@ -185,7 +185,29 @@ class ChallengeDetailContent extends StatelessWidget { clipBehavior: Clip.none, child: Row( children: challenge.todaySuccessUsers.map((user) { - return _buildAttendee(user.name, user.profileImageUrl); + return Padding( + padding: const EdgeInsets.only(right: 15), + child: Column( + children: [ + UserProfileCircle( + imageUrl: user.profileUrl, + size: 36, + ), + const SizedBox(height: 6), + SizedBox( + width: 50, + child: Text( + user.nickname, + style: AppTypography.c1.copyWith( + color: AppColors.black, + ), + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); }).toList(), ), ), @@ -231,27 +253,6 @@ class ChallengeDetailContent extends StatelessWidget { ), ); } - - Widget _buildAttendee(String name, String imageUrl) { - return Padding( - padding: const EdgeInsets.only(right: 15), - child: Column( - children: [ - _buildProfileImage(imageUrl), // 재사용 - const SizedBox(height: 6), - SizedBox( - width: 50, - child: Text( - name, - style: AppTypography.c1.copyWith(color: AppColors.black), - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ); - } } class _CustomDivider extends StatelessWidget { diff --git a/lib/shared/widgets/custom_search_bar.dart b/lib/shared/widgets/custom_search_bar.dart new file mode 100644 index 0000000..1d2bd20 --- /dev/null +++ b/lib/shared/widgets/custom_search_bar.dart @@ -0,0 +1,65 @@ +// 최초 작성자: 정승빈 +// 검색창 위젯 + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:haenaem/core/theme/app_colors.dart'; +import 'package:haenaem/core/theme/app_typography.dart'; + +class CustomSearchBar extends StatelessWidget { + final TextEditingController controller; + final String hintText; + final ValueChanged? onChanged; + final ValueChanged? onSubmitted; + final TextInputAction textInputAction; + + const CustomSearchBar({ + super.key, + required this.controller, + this.hintText = '검색', + this.onChanged, + this.onSubmitted, + this.textInputAction = TextInputAction.search, + }); + + @override + Widget build(BuildContext context) { + return Container( + height: 40, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.gray4), + ), + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + SvgPicture.asset( + 'assets/images/icons/search_icon.svg', + width: 18, + colorFilter: const ColorFilter.mode( + AppColors.gray3, + BlendMode.srcIn, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: controller, + onChanged: onChanged, + onSubmitted: onSubmitted, + textInputAction: textInputAction, + decoration: InputDecoration( + hintText: hintText, + hintStyle: AppTypography.b2, + border: InputBorder.none, + isDense: true, + ), + style: AppTypography.b1, // 텍스트 크기 통일 + ), + ), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/custom_tab_bar.dart b/lib/shared/widgets/custom_tab_bar.dart new file mode 100644 index 0000000..01f20b8 --- /dev/null +++ b/lib/shared/widgets/custom_tab_bar.dart @@ -0,0 +1,116 @@ +// 최초 작성자: 강선욱 +// 공통 탭바 위젯 +// - TabController 생성 및 관리 +// - 공통 TabBar 스타일 적용 +// - TabBarView 포함 +// - 현재 탭 재탭 시 스크롤 최상단 이동 (scrollControllers 전달 시에만 동작) + +import 'package:flutter/material.dart'; +import 'package:haenaem/core/theme/app_colors.dart'; +import 'package:haenaem/core/theme/app_typography.dart'; + +class CustomTabBar extends StatefulWidget { + // 탭 항목 텍스트 목록 + final List tabs; + + // 각 탭에 해당하는 view들 리스트 + final List children; + + // 초기 탭 인덱스 (기본값: 0) + // 화면 진입시 가장 먼저 보이는 탭 설정 + final int initialIndex; + + // 탭 변경 시 호출되는 콜백 + final void Function(int index)? onTabChanged; + + // 현재 탭 재탭 시 스크롤을 최상단으로 올릴 ScrollController 목록 + // tabs 리스트와 동일한 순서로 전달해야 함 + // null이면 스크롤 동작 비활성화 + final List? scrollControllers; + + const CustomTabBar({ + super.key, + required this.tabs, + required this.children, + this.initialIndex = 0, + this.onTabChanged, + this.scrollControllers, + }) : assert( + scrollControllers == null || scrollControllers.length == tabs.length, + 'scrollControllers 길이는 tabs 길이와 동일해야 합니다.', + ); + + @override + State createState() => _CustomTabBarState(); +} + +class _CustomTabBarState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController( + length: widget.tabs.length, + vsync: this, + initialIndex: widget.initialIndex, + ); + + _tabController.addListener(() { + if (!_tabController.indexIsChanging) { + widget.onTabChanged?.call(_tabController.index); + } + }); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + void _scrollToTop(int index) { + final controllers = widget.scrollControllers; + if (controllers == null) return; + + final controller = controllers[index]; + if (controller.hasClients) { + controller.animateTo( + 0, + duration: const Duration(milliseconds: 600), + curve: Curves.easeInOut, + ); + } + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + TabBar( + controller: _tabController, + labelColor: AppColors.primaryAble, + unselectedLabelColor: AppColors.gray2, + indicatorColor: AppColors.primaryAble, + indicatorWeight: 1, + indicatorSize: TabBarIndicatorSize.tab, + labelStyle: AppTypography.b1.copyWith(color: AppColors.primaryAble), + tabs: widget.tabs.map((t) => Tab(text: t)).toList(), + onTap: (index) { + // 이미 선택된 탭을 다시 눌렀을 때 스크롤 최상단 이동 + if (_tabController.index == index) { + _scrollToTop(index); + } + }, + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: widget.children, + ), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/user_list_tile.dart b/lib/shared/widgets/user_list_tile.dart new file mode 100644 index 0000000..1903563 --- /dev/null +++ b/lib/shared/widgets/user_list_tile.dart @@ -0,0 +1,56 @@ +// 최초 작성자: 정승빈 +// 유저 목록 타일 템플릿 + +import 'package:flutter/material.dart'; + +import 'package:haenaem/core/theme/app_colors.dart'; +import 'package:haenaem/core/theme/app_typography.dart'; +import 'package:haenaem/shared/models/user.dart'; +import 'package:haenaem/shared/widgets/user_profile_circle.dart'; + +class UserListTile extends StatelessWidget { + final User user; + final Widget? trailing; // 오른쪽 끝에 달릴 위젯 (버튼, 아이콘 등) + final EdgeInsetsGeometry padding; // 위아래 여백 조정을 위해 추가 + + const UserListTile({ + super.key, + required this.user, + this.trailing, + this.padding = const EdgeInsets.symmetric(vertical: 8), // 기본값 8 + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: padding, + child: Row( + children: [ + UserProfileCircle(imageUrl: user.profileUrl, size: 44), + const SizedBox(width: 12), + Expanded( + // 오른쪽 위젯이 공간을 침범하지 않게 방어 + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user.nickname, + style: AppTypography.b1.copyWith(color: AppColors.black), + ), + /* + Text( + "해냄 메이트", + // TODO 추후 칭호 기능 연동 시 User 모델에서 받아오도록 수정 + style: AppTypography.c1.copyWith(color: AppColors.gray2), + ), + */ + ], + ), + ), + // trailing으로 전달받은 위젯이 있으면 화면에 그려줌 + if (trailing != null) trailing!, + ], + ), + ); + } +} diff --git a/lib/shared/widgets/user_profile_circle.dart b/lib/shared/widgets/user_profile_circle.dart new file mode 100644 index 0000000..a140d65 --- /dev/null +++ b/lib/shared/widgets/user_profile_circle.dart @@ -0,0 +1,43 @@ +// 최초 작성자: 정승빈 +// 사용자 프로필 사진을 원형으로 보여주는 위젯입니다. 이미지 URL이 제공되지 않으면 기본 아이콘이 표시됩니다. + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +class UserProfileCircle extends StatelessWidget { + final String? imageUrl; + final double size; + + const UserProfileCircle({super.key, this.imageUrl, required this.size}); + + @override + Widget build(BuildContext context) { + // 🔥 디버깅용 콘솔 출력 추가: 어떤 URL 값이 들어오는지 확인 + debugPrint('UserProfileCircle - 전달받은 imageUrl: $imageUrl'); + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: const Color(0x7FDFE1DC), + shape: BoxShape.circle, + image: imageUrl != null && imageUrl!.startsWith('http') + ? DecorationImage(image: NetworkImage(imageUrl!), fit: BoxFit.cover) + : (imageUrl != null + ? DecorationImage( + image: AssetImage(imageUrl!), + fit: BoxFit.cover, + ) + : null), + ), + child: imageUrl == null + ? Center( + child: SvgPicture.asset( + 'assets/images/icons/default_profile_icon.svg', + width: size, + ), + ) + : null, + ); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 922e5f2..312398c 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,12 +10,14 @@ import file_selector_macos import firebase_core import firebase_messaging import flutter_appauth +import flutter_image_compress_macos import flutter_secure_storage_darwin import google_sign_in_ios import path_provider_foundation import photo_manager import share_plus import shared_preferences_foundation +import url_launcher_macos import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -24,11 +26,13 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FlutterAppauthPlugin.register(with: registry.registrar(forPlugin: "FlutterAppauthPlugin")) + FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PhotoManagerPlugin.register(with: registry.registrar(forPlugin: "PhotoManagerPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 670a919..c8289f0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: cd83f7d6bd4e4c0b0b4fef802e8796784032e1cc23d7b0e982cf5d05d9bbe182 + sha256: afe15ce18a287d2f89da95566e62892df339b1936bbe9b83587df45b944ee72a url: "https://pub.dev" source: hosted - version: "1.3.66" + version: "1.3.67" analyzer: dependency: transitive description: @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff url: "https://pub.dev" source: hosted - version: "4.0.7" + version: "4.0.9" args: dependency: transitive description: @@ -77,10 +77,10 @@ packages: dependency: "direct main" description: name: audioplayers - sha256: "5441fa0ceb8807a5ad701199806510e56afde2b4913d9d17c2f19f2902cf0ae4" + sha256: a72dd459d1a48f61a6fb9c0134dba26597c9236af40639ff0eb70eb4e0baab70 url: "https://pub.dev" source: hosted - version: "6.5.1" + version: "6.6.0" audioplayers_android: dependency: transitive description: @@ -93,10 +93,10 @@ packages: dependency: transitive description: name: audioplayers_darwin - sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333" + sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91 url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.4.0" audioplayers_linux: dependency: transitive description: @@ -117,18 +117,18 @@ packages: dependency: transitive description: name: audioplayers_web - sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7" + sha256: faa8fa6587f996a6f604433b53af44c57a1407d4fe8dff5766cf63d6875e8de9 url: "https://pub.dev" source: hosted - version: "5.1.1" + version: "5.2.0" audioplayers_windows: dependency: transitive description: name: audioplayers_windows - sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7" + sha256: bafff2b38b6f6d331887558ba6e0a01c9c208d9dbb3ad0005234db065122a734 url: "https://pub.dev" source: hosted - version: "4.2.1" + version: "4.3.0" boolean_selector: dependency: transitive description: @@ -170,7 +170,7 @@ packages: source: hosted version: "2.5.4" build_runner: - dependency: "direct main" + dependency: "direct dev" description: name: build_runner sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" @@ -197,34 +197,34 @@ packages: dependency: transitive description: name: built_value - sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8" + sha256: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9" url: "https://pub.dev" source: hosted - version: "8.12.3" + version: "8.12.4" camera: dependency: "direct main" description: name: camera - sha256: eefad89f262a873f38d21e5eec853461737ea074d7c9ede39f3ceb135d201cab + sha256: "4142a19a38e388d3bab444227636610ba88982e36dff4552d5191a86f65dc437" url: "https://pub.dev" source: hosted - version: "0.11.3" + version: "0.11.4" camera_android_camerax: dependency: transitive description: name: camera_android_camerax - sha256: dba476d6c316671ae943f20ad7d02b3d363eed0dbfd25465bb92d42e35faacd8 + sha256: "8516fe308bc341a5067fb1a48edff0ddfa57c0d3cdcc9dbe7ceca3ba119e2577" url: "https://pub.dev" source: hosted - version: "0.6.28" + version: "0.6.30" camera_avfoundation: dependency: transitive description: name: camera_avfoundation - sha256: a600b60a7752cc5fa9de476cd0055539d7a3b9d62662f4f446bae49eba2267df + sha256: "11b4aee2f5e5e038982e152b4a342c749b414aa27857899d20f4323e94cb5f0b" url: "https://pub.dev" source: hosted - version: "0.9.22+9" + version: "0.9.23+2" camera_platform_interface: dependency: transitive description: @@ -245,10 +245,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -309,12 +309,12 @@ packages: dependency: transitive description: name: cross_file - sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" url: "https://pub.dev" source: hosted - version: "0.3.5+1" + version: "0.3.5+2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -365,18 +365,18 @@ packages: dependency: "direct main" description: name: dio - sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25 + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c url: "https://pub.dev" source: hosted - version: "5.9.1" + version: "5.9.2" dio_web_adapter: dependency: transitive description: name: dio_web_adapter - sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" encrypt: dependency: transitive description: @@ -413,10 +413,10 @@ packages: dependency: transitive description: name: ffi - sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.2.0" file: dependency: transitive description: @@ -461,10 +461,10 @@ packages: dependency: transitive description: name: firebase_core - sha256: "923085c881663ef685269b013e241b428e1fb03cdd0ebde265d9b40ff18abf80" + sha256: f0997fee80fbb6d2c658c5b88ae87ba1f9506b5b37126db64fc2e75d8e977fbb url: "https://pub.dev" source: hosted - version: "4.4.0" + version: "4.5.0" firebase_core_platform_interface: dependency: transitive description: @@ -477,34 +477,34 @@ packages: dependency: transitive description: name: firebase_core_web - sha256: "83e7356c704131ca4d8d8dd57e360d8acecbca38b1a3705c7ae46cc34c708084" + sha256: "856ca92bf2d75a63761286ab8e791bda3a85184c2b641764433b619647acfca6" url: "https://pub.dev" source: hosted - version: "3.4.0" + version: "3.5.0" firebase_messaging: dependency: "direct main" description: name: firebase_messaging - sha256: "06fad40ea14771e969a8f2bbce1944aa20ee2f4f57f4eca5b3ba346b65f3f644" + sha256: bd17823b70e629877904d384841cda72ed2cc197517404c0c90da5c0ba786a8c url: "https://pub.dev" source: hosted - version: "16.1.1" + version: "16.1.2" firebase_messaging_platform_interface: dependency: transitive description: name: firebase_messaging_platform_interface - sha256: "6c49e901c77e6e10e86d98e32056a087eb1ca1b93acdf58524f1961e617657b7" + sha256: "550435235cc7d53683f32bf0762c28ef8cfc20a8d36318a033676ae09526d7fb" url: "https://pub.dev" source: hosted - version: "4.7.6" + version: "4.7.7" firebase_messaging_web: dependency: transitive description: name: firebase_messaging_web - sha256: "2756f8fea583ffb9d294d15ddecb3a9ad429b023b70c9990c151fc92c54a32b3" + sha256: "6b1b93ed90309fbce91c219e3cd32aa831e8eccaf4a61f3afaea1625479275d2" url: "https://pub.dev" source: hosted - version: "4.1.2" + version: "4.1.3" fixnum: dependency: transitive description: @@ -534,6 +534,62 @@ packages: url: "https://pub.dev" source: hosted version: "11.0.0" + flutter_dotenv: + dependency: "direct main" + description: + name: flutter_dotenv + sha256: d41da11fb497314fbf89811ec30af02d1d898b47980a129f0a8c0a1720460ba2 + url: "https://pub.dev" + source: hosted + version: "6.0.1" + flutter_image_compress: + dependency: "direct main" + description: + name: flutter_image_compress + sha256: "51d23be39efc2185e72e290042a0da41aed70b14ef97db362a6b5368d0523b27" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + flutter_image_compress_common: + dependency: transitive + description: + name: flutter_image_compress_common + sha256: c5c5d50c15e97dd7dc72ff96bd7077b9f791932f2076c5c5b6c43f2c88607bfb + url: "https://pub.dev" + source: hosted + version: "1.0.6" + flutter_image_compress_macos: + dependency: transitive + description: + name: flutter_image_compress_macos + sha256: "20019719b71b743aba0ef874ed29c50747461e5e8438980dfa5c2031898f7337" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + flutter_image_compress_ohos: + dependency: transitive + description: + name: flutter_image_compress_ohos + sha256: e76b92bbc830ee08f5b05962fc78a532011fcd2041f620b5400a593e96da3f51 + url: "https://pub.dev" + source: hosted + version: "0.0.3" + flutter_image_compress_platform_interface: + dependency: transitive + description: + name: flutter_image_compress_platform_interface + sha256: "579cb3947fd4309103afe6442a01ca01e1e6f93dc53bb4cbd090e8ce34a41889" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + flutter_image_compress_web: + dependency: transitive + description: + name: flutter_image_compress_web + sha256: b9b141ac7c686a2ce7bb9a98176321e1182c9074650e47bb140741a44b6f5a96 + url: "https://pub.dev" + source: hosted + version: "0.1.5" flutter_launcher_icons: dependency: "direct main" description: @@ -689,10 +745,10 @@ packages: dependency: transitive description: name: google_sign_in_android - sha256: "5ec98ab35387c68c0050495bb211bd88375873723a80fae7c2e9266ea0bdd8bb" + sha256: f353140580797e01c1f35748810326f326664c52040b6f62d88e7d6d1cd30917 url: "https://pub.dev" source: hosted - version: "7.2.7" + version: "7.2.9" google_sign_in_ios: dependency: transitive description: @@ -713,10 +769,10 @@ packages: dependency: transitive description: name: google_sign_in_web - sha256: "2fc1f941e6443b2d6984f4056a727a3eaeab15d8ee99ba7125d79029be75a1da" + sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14 url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.3" graphs: dependency: transitive description: @@ -769,10 +825,10 @@ packages: dependency: "direct main" description: name: image - sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce url: "https://pub.dev" source: hosted - version: "4.7.2" + version: "4.8.0" image_gallery_saver_plus: dependency: "direct main" description: @@ -793,10 +849,10 @@ packages: dependency: transitive description: name: image_picker_android - sha256: "518a16108529fc18657a3e6dde4a043dc465d16596d20ab2abd49a4cac2e703d" + sha256: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156 url: "https://pub.dev" source: hosted - version: "0.8.13+13" + version: "0.8.13+14" image_picker_for_web: dependency: transitive description: @@ -873,10 +929,10 @@ packages: dependency: transitive description: name: json_annotation - sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 url: "https://pub.dev" source: hosted - version: "4.10.0" + version: "4.11.0" kakao_flutter_sdk: dependency: "direct main" description: @@ -993,26 +1049,26 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mime: dependency: transitive description: @@ -1105,18 +1161,18 @@ packages: dependency: transitive description: name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.2" photo_manager: dependency: "direct main" description: name: photo_manager - sha256: "807688e3221e90fb02a4466746edd9cb9a0de025f8754c819f96604c00f6f1f5" + sha256: fb3bc8ea653370f88742b3baa304700107c83d12748aa58b2b9f2ed3ef15e6c2 url: "https://pub.dev" source: hosted - version: "3.8.3" + version: "3.9.0" photo_manager_image_provider: dependency: "direct main" description: @@ -1161,10 +1217,10 @@ packages: dependency: transitive description: name: posix - sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" url: "https://pub.dev" source: hosted - version: "6.0.3" + version: "6.5.0" pub_semver: dependency: transitive description: @@ -1206,7 +1262,7 @@ packages: source: hosted version: "2.6.1" riverpod_generator: - dependency: "direct main" + dependency: "direct dev" description: name: riverpod_generator sha256: "44a0992d54473eb199ede00e2260bd3c262a86560e3c6f6374503d86d0580e36" @@ -1326,10 +1382,10 @@ packages: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.10.2" stack_trace: dependency: transitive description: @@ -1398,10 +1454,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.6" timing: dependency: transitive description: @@ -1426,6 +1482,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.1" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + url: "https://pub.dev" + source: hosted + version: "6.3.6" url_launcher_linux: dependency: transitive description: @@ -1434,6 +1514,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" url_launcher_platform_interface: dependency: transitive description: @@ -1462,18 +1550,18 @@ packages: dependency: transitive description: name: uuid - sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" url: "https://pub.dev" source: hosted - version: "4.5.2" + version: "4.5.3" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + sha256: "7076216a10d5c390315fbe536a30f1254c341e7543e6c4c8a815e591307772b1" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.1.20" vector_graphics_codec: dependency: transitive description: @@ -1486,10 +1574,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: "201e876b5d52753626af64b6359cd13ac6011b80728731428fd34bc840f71c9b" + sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74" url: "https://pub.dev" source: hosted - version: "1.1.20" + version: "1.2.0" vector_math: dependency: transitive description: @@ -1539,7 +1627,7 @@ packages: source: hosted version: "3.0.3" webview_flutter: - dependency: transitive + dependency: "direct main" description: name: webview_flutter sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9 @@ -1566,10 +1654,10 @@ packages: dependency: transitive description: name: webview_flutter_wkwebview - sha256: fc0af89d403e1c053f03d023d97550412fa79f35332e2939514c82e6fe633198 + sha256: "2df8fd9ada04d699b9db8e79aa783a16e5d89b69e5b74009b87e16b59912cf98" url: "https://pub.dev" source: hosted - version: "3.23.8" + version: "3.24.0" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 51842e2..4625f69 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 +version: 1.0.0+2 environment: sdk: ^3.9.2 @@ -52,21 +52,26 @@ dependencies: flutter_secure_storage: ^10.0.0 flutter_riverpod: ^2.5.1 riverpod_annotation: ^2.3.5 - build_runner: ^2.4.8 - riverpod_generator: ^2.4.0 + crypto: ^3.0.7 + webview_flutter: ^4.13.1 extended_image: ^10.0.1 firebase_messaging: ^16.1.1 kakao_flutter_sdk: ^1.10.0 + url_launcher: ^6.3.2 + flutter_image_compress: ^2.4.0 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 google_sign_in: ^7.2.0 dio: ^5.9.1 + flutter_dotenv: ^6.0.1 dev_dependencies: flutter_test: sdk: flutter + build_runner: ^2.4.8 + riverpod_generator: ^2.4.0 # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is @@ -91,8 +96,8 @@ flutter: - assets/images/icons/ - assets/images/illustrations/ - assets/images/placeholders/ - - assets/images/profiles/ - assets/images/ + - .env # - images/a_dot_burr.jpeg