diff --git a/android/settings.gradle b/android/settings.gradle index 536165d..f75a38f 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -19,7 +19,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "com.android.application" version "7.3.0" apply false - id "org.jetbrains.kotlin.android" version "1.7.10" apply false + id "org.jetbrains.kotlin.android" version "1.9.0" apply false } include ":app" diff --git a/lib/common/widgets/profile_card.dart b/lib/common/widgets/profile_card.dart index a729b75..dc8c1d4 100644 --- a/lib/common/widgets/profile_card.dart +++ b/lib/common/widgets/profile_card.dart @@ -1,7 +1,7 @@ import 'package:cogo/common/widgets/atoms/texts/texts.dart'; import 'package:cogo/common/widgets/tag_list.dart'; -import 'package:flutter/material.dart'; import 'package:cogo/constants/constants.dart'; +import 'package:flutter/material.dart'; class ProfileCard extends StatelessWidget { final String picture; @@ -58,13 +58,20 @@ class ProfileCard extends StatelessWidget { topLeft: Radius.circular(20), topRight: Radius.circular(20), ), - child: Image.asset( - picture.isNotEmpty ? picture : 'assets/default_img.png', - width: double.infinity, - height: 150, - fit: BoxFit.cover, - ), - ), + child: Image.network( + picture.isNotEmpty ? picture : '', // 빈 문자열로 설정해 에러를 유도 + width: double.infinity, + height: 150, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Image.asset( + 'assets/default_img.png', // 로컬 기본 이미지 + width: double.infinity, + height: 150, + fit: BoxFit.cover, + ); + }, + )), Positioned( top: 15, left: 15, diff --git a/lib/constants/paths.dart b/lib/constants/paths.dart index a53db29..00dbaf6 100644 --- a/lib/constants/paths.dart +++ b/lib/constants/paths.dart @@ -40,4 +40,6 @@ abstract class Paths { static const String report = '/report'; static const String reportDetail = '/report_detail'; + + static const String image = '/image'; } diff --git a/lib/data/di/get_it_locator.dart b/lib/data/di/get_it_locator.dart index ab13f22..1681826 100644 --- a/lib/data/di/get_it_locator.dart +++ b/lib/data/di/get_it_locator.dart @@ -1,5 +1,6 @@ import 'package:cogo/data/service/mentor_service.dart'; import 'package:cogo/data/service/refresh_service.dart'; +import 'package:cogo/data/service/s3_service.dart'; import 'package:cogo/data/service/user_service.dart'; import 'package:cogo/features/auth/login/login_view_model.dart'; import 'package:cogo/features/auth/signup/club/club_selection_view_model.dart'; @@ -27,6 +28,8 @@ void setupServiceLocator() { ///뷰모델 등록 + getIt.registerLazySingleton(() => S3Service()); + getIt.registerFactory(() => LoginViewModel()); getIt.registerFactory( diff --git a/lib/data/dto/response/image_save_response.dart b/lib/data/dto/response/image_save_response.dart new file mode 100644 index 0000000..fa4594f --- /dev/null +++ b/lib/data/dto/response/image_save_response.dart @@ -0,0 +1,14 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'image_save_response.freezed.dart'; +part 'image_save_response.g.dart'; + +@freezed +class ImageSaveResponse with _$ImageSaveResponse { + const factory ImageSaveResponse({ + String? savedUrl, + }) = _ImageSaveResponse; + + factory ImageSaveResponse.fromJson(Map json) => + _$ImageSaveResponseFromJson(json); +} diff --git a/lib/data/service/s3_service.dart b/lib/data/service/s3_service.dart index e69de29..adc6d6a 100644 --- a/lib/data/service/s3_service.dart +++ b/lib/data/service/s3_service.dart @@ -0,0 +1,51 @@ +import 'package:cogo/constants/apis.dart'; +import 'package:cogo/data/di/api_client.dart'; +import 'package:cogo/data/dto/response/base_response.dart'; +import 'package:cogo/data/dto/response/image_save_response.dart'; +import 'package:dio/dio.dart'; + +class S3Service { + final ApiClient _apiClient = ApiClient(); + static const apiVersion = "api/v2/"; + + Future uploadImage(String imagePath) async { + try { + const url = '$apiVersion${Apis.s3}/v2'; + + // FormData 생성 + final formData = FormData.fromMap({ + 'image': await MultipartFile.fromFile(imagePath, + filename: imagePath.split('/').last) + }); + + // 요청 전송 + final response = await _apiClient.dio.post( + url, + data: formData, + options: Options( + headers: { + 'accept': '*/*', + 'Content-Type': 'multipart/form-data', + }, + ), + ); + + // 상태 코드 확인 + if (response.statusCode != 201) { + throw Exception('업로드 실패: 상태 코드 = ${response.statusCode}'); + } + + final baseResponse = BaseResponse.fromJson( + response.data, + (contentJson) => ImageSaveResponse.fromJson(contentJson), + ); + return baseResponse.content.savedUrl; + } on DioException catch (e) { + // Dio 특화된 에러 처리 + throw Exception('업로드 중 오류 발생: ${e.response?.data ?? e.message}'); + } catch (e) { + // 기타 예외 처리 + throw Exception('업로드 중 오류 발생: ${e.toString()}'); + } + } +} diff --git a/lib/data/service/user_service.dart b/lib/data/service/user_service.dart index 68c06b2..df1912a 100644 --- a/lib/data/service/user_service.dart +++ b/lib/data/service/user_service.dart @@ -180,4 +180,34 @@ class UserService { throw Exception('An unexpected error occurred: $e'); } } + + ///PUT /api/v2/users/picture 이미지 저장하기 + Future saveImage(String imageUrl) async { + try { + final response = + await _apiClient.dio.put(apiVersion + Apis.saveImage, data: imageUrl); + + //todo 여기 response가 존재하나 필요가 없어서 안받음 + // { + // "statusCode": "201", + // "message": "CREATED", + // "content": { + // "username": "113343694546635833713", + // "name": "222", + // "email": "objet917@gmail.com", + // "role": "ROLE_MENTOR", + // "phoneNum": "123-1231-2312", + // "picture": "\"https://cogo-bucket.s3.ap-northeast-2.amazonaws.com/v2/113343694546635833713\"" + // } + // } + if (response.statusCode != 201) { + throw Exception('Failed to send verification code ${response.data}'); + } + return true; + } on DioException catch (e) { + throw Exception('Error: ${e.response?.data ?? e.message}'); + } catch (e) { + throw Exception('An unexpected error occurred: $e'); + } + } } diff --git a/lib/features/home/profile/profile_detail_screen.dart b/lib/features/home/profile/profile_detail_screen.dart index 1300cf3..6718565 100644 --- a/lib/features/home/profile/profile_detail_screen.dart +++ b/lib/features/home/profile/profile_detail_screen.dart @@ -1,3 +1,5 @@ +import 'dart:developer'; + import 'package:cogo/common/widgets/atoms/texts/styles.dart'; import 'package:cogo/common/widgets/components/basic_button.dart'; import 'package:cogo/constants/colors.dart'; @@ -85,20 +87,28 @@ class ProfileDetailScreen extends StatelessWidget { } return Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(20)), - child: Image.asset( - profile.imageUrl.isNotEmpty - ? profile.imageUrl - : 'assets/default_img.png', - width: double.infinity, - height: 150, - fit: BoxFit.cover, - ), - ), + child: Image.network( + profile.imageUrl.isNotEmpty + ? profile.imageUrl + : '', // 빈 문자열로 설정해 에러를 유도 + width: double.infinity, + height: 150, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + log("===이미지 에러==="); + return Image.asset( + 'assets/default_img.png', // 로컬 기본 이미지 + width: double.infinity, + height: 150, + fit: BoxFit.cover, + ); + }, + )), const SizedBox(height: 30), Center( child: Row( diff --git a/lib/features/mypage/image_upload_view_model.dart b/lib/features/mypage/image_upload_view_model.dart new file mode 100644 index 0000000..204f5d6 --- /dev/null +++ b/lib/features/mypage/image_upload_view_model.dart @@ -0,0 +1,101 @@ +import 'dart:io'; + +import 'package:cogo/data/service/s3_service.dart'; +import 'package:cogo/data/service/user_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:get_it/get_it.dart'; +import 'package:image_picker/image_picker.dart'; + +class ImageUploadViewModel extends ChangeNotifier { + final S3Service s3service = GetIt.instance(); + final UserService userService = GetIt.instance(); + + File? _selectedImage; + bool _isUploading = false; + bool _isUpload = false; + String? _uploadResult; + String? _errorMessage; + + // 생성자 + ImageUploadViewModel(); + + // Getters + File? get selectedImage => _selectedImage; + bool get isUploading => _isUploading; + + bool get isUpload => _isUpload; + String? get uploadResult => _uploadResult; + String? get errorMessage => _errorMessage; + + // 갤러리에서 이미지 선택 + Future pickImageFromGallery() async { + try { + final picker = ImagePicker(); + final pickedFile = await picker.pickImage(source: ImageSource.gallery); + + if (pickedFile != null) { + _selectedImage = File(pickedFile.path); + _errorMessage = null; + notifyListeners(); + } + } catch (e) { + _errorMessage = '이미지 선택 중 오류 발생: ${e.toString()}'; + notifyListeners(); + } + } + + // 카메라로 이미지 촬영 + Future takeImageFromCamera() async { + try { + final picker = ImagePicker(); + final pickedFile = await picker.pickImage(source: ImageSource.camera); + + if (pickedFile != null) { + _selectedImage = File(pickedFile.path); + _errorMessage = null; + notifyListeners(); + } + } catch (e) { + _errorMessage = '카메라 촬영 중 오류 발생: ${e.toString()}'; + notifyListeners(); + } + } + + // 이미지 업로드 + Future uploadImage() async { + // 이미지가 선택되지 않은 경우 + if (_selectedImage == null) { + _errorMessage = '먼저 이미지를 선택해주세요.'; + notifyListeners(); + return; + } + + try { + // 업로드 시작 + _isUploading = true; + _errorMessage = null; + notifyListeners(); + + // 서비스를 통해 이미지 업로드 + _uploadResult = await s3service.uploadImage(_selectedImage!.path); + + // 업로드 성공 + _isUpload = await userService.saveImage(_uploadResult!); + _isUploading = false; + notifyListeners(); + } catch (e) { + // 에러 처리 + _errorMessage = e.toString(); + _isUploading = false; + notifyListeners(); + } + } + + // 선택된 이미지 초기화 + void clearSelectedImage() { + _selectedImage = null; + _uploadResult = null; + _errorMessage = null; + notifyListeners(); + } +} \ No newline at end of file diff --git a/lib/features/mypage/image_uplooad_screen.dart b/lib/features/mypage/image_uplooad_screen.dart new file mode 100644 index 0000000..c67895b --- /dev/null +++ b/lib/features/mypage/image_uplooad_screen.dart @@ -0,0 +1,72 @@ +import 'dart:developer'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'image_upload_view_model.dart'; + +class ImageUploadScreen extends StatelessWidget { + const ImageUploadScreen({super.key}); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (_) => ImageUploadViewModel(), + child: Scaffold( + appBar: AppBar(title: Text("Image Uploader")), + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Consumer( + builder: (context, viewModel, child) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + if (viewModel.selectedImage != null) + Image.file( + viewModel.selectedImage!, + height: 200, + width: 200, + fit: BoxFit.cover, + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: viewModel.pickImageFromGallery, + child: const Text("갤러리")), + ElevatedButton( + onPressed: viewModel.takeImageFromCamera, + child: const Text("카메라")), + ], + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: () async => { + log("사진을 바꿔요"), + viewModel.uploadImage(), + if (viewModel.isUpload) {Navigator.of(context).pop()} + }, + child: viewModel.isUploading + ? const CircularProgressIndicator(color: Colors.white) + : const Text("저장하기"), + ), + if (viewModel.errorMessage != null) + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + viewModel.errorMessage!, + style: TextStyle(color: Colors.red), + ), + ), + ], + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/features/mypage/mypage_screen.dart b/lib/features/mypage/mypage_screen.dart index 3a7e3af..33b41ec 100644 --- a/lib/features/mypage/mypage_screen.dart +++ b/lib/features/mypage/mypage_screen.dart @@ -1,3 +1,5 @@ +import 'dart:developer'; + import 'package:cogo/common/enums/role.dart'; import 'package:cogo/common/widgets/tag_list.dart'; import 'package:cogo/common/widgets/widgets.dart'; @@ -61,69 +63,128 @@ class MypageScreen extends StatelessWidget { final user = state.myPageInfo; - return Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const SizedBox(height: 13), - Text( - '${user?.name ?? "사용자"}님', - style: CogoTextStyle.bodySB20, - textAlign: TextAlign.center, - ), - const SizedBox(height: 13), - if (user?.picture != null && user!.picture!.isNotEmpty) ...[ - Image.network( - user.picture!, - errorBuilder: (context, error, stackTrace) { - return SvgPicture.asset( - 'assets/image/img_image.svg'); // 기본 로컬 이미지 - }, - fit: BoxFit.cover, + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 13), + Text( + '${user?.name ?? "사용자"}님', + style: CogoTextStyle.bodySB20, + textAlign: TextAlign.center, ), - ] else ...[ - SvgPicture.asset('assets/image/img_image.svg'), - // 기본 로컬 이미지 - ], - const SizedBox(height: 13), - Center( - child: TagList(tags: user!.tags), - ), - const SizedBox(height: 20), - ListTile( - title: const Text('내 정보 관리', style: CogoTextStyle.body16), - trailing: const Icon(Icons.chevron_right), - onTap: () => context.push(Paths.myInfo), - ), - if (user.role == Role.MENTOR.name) ...[ + const SizedBox(height: 13), + if (user?.picture != null && + user!.picture!.isNotEmpty) ...[ + GestureDetector( + onTap: () { + context.push(Paths.image); + }, + child: Stack( + children: [ + ClipRRect( + borderRadius: const BorderRadius.all( + Radius.circular(20)), + child: Image.network( + user.picture?.isNotEmpty == true + ? user.picture! + : '', + width: double.infinity, + height: 150, + fit: BoxFit.cover, + loadingBuilder: + (context, child, loadingProgress) { + // 이미지를 로딩 중일 때, 아이콘을 항상 위에 놓음 + if (loadingProgress == null) { + return child; + } else { + return const Center( + child: CircularProgressIndicator()); + } + }, + errorBuilder: (context, error, stackTrace) { + log("===이미지 에러==="); + return Image.asset( + 'assets/default_img.png', // 로컬 기본 이미지 + width: double.infinity, + height: 150, + fit: BoxFit.cover, + ); + }, + ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + bottom: 0, + child: Align( + alignment: Alignment.center, // 중앙에 배치 + child: Icon( + Icons.camera_alt, + size: 48, + color: Colors.grey + .withOpacity(0.6), // 투명도 조절 + ), + ), + ), + ], + )), + ] else ...[ + GestureDetector( + onTap: () { + context.push(Paths.image); + }, + child: SvgPicture.asset( + 'assets/image/img_image.svg', // 기본 로컬 이미지 + ), + ), + ], + const SizedBox(height: 13), + Center( + child: TagList(tags: user!.tags), + ), + const SizedBox(height: 20), ListTile( title: - const Text('자기소개 관리', style: CogoTextStyle.body16), + const Text('내 정보 관리', style: CogoTextStyle.body16), trailing: const Icon(Icons.chevron_right), - onTap: () => context.push(Paths.myMentorIntroduce), + onTap: () => context.push(Paths.myInfo), ), + if (user.role == Role.MENTOR.name) ...[ + ListTile( + title: const Text('자기소개 관리', + style: CogoTextStyle.body16), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push(Paths.myMentorIntroduce), + ), + ListTile( + title: + const Text('시간 설정', style: CogoTextStyle.body16), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push(Paths.timeSetting), + ), + ], ListTile( - title: const Text('시간 설정', style: CogoTextStyle.body16), - trailing: const Icon(Icons.chevron_right), - onTap: () => context.push(Paths.timeSetting), - ), + title: + const Text('로그아웃', style: CogoTextStyle.body16), + trailing: const Icon(Icons.chevron_right), + onTap: () => { + viewModel.logOut(), + context.go(Paths.login), //라우팅 히스토리를 다 지움 + }), + ListTile( + title: + const Text('탈퇴하기', style: CogoTextStyle.body16), + trailing: const Icon(Icons.chevron_right), + onTap: () => { + viewModel.signOut(), + context.go(Paths.login), //라우팅 히스토리를 다 지움 + }), ], - ListTile( - title: const Text('로그아웃', style: CogoTextStyle.body16), - trailing: const Icon(Icons.chevron_right), - onTap: () => { - viewModel.logOut(), - context.go(Paths.login), //라우팅 히스토리를 다 지움 - }), - ListTile( - title: const Text('탈퇴하기', style: CogoTextStyle.body16), - trailing: const Icon(Icons.chevron_right), - onTap: () => { - viewModel.signOut(), - context.go(Paths.login), //라우팅 히스토리를 다 지움 - }), - ], + ), ), ); }, diff --git a/lib/route/routes.dart b/lib/route/routes.dart index a315a97..f6dc144 100644 --- a/lib/route/routes.dart +++ b/lib/route/routes.dart @@ -25,6 +25,7 @@ import 'package:cogo/features/home/profile/profile_detail_screen.dart'; import 'package:cogo/features/home/report/report_detail_screen.dart'; import 'package:cogo/features/home/report/report_screen.dart'; import 'package:cogo/features/home/search/search_screen.dart'; +import 'package:cogo/features/mypage/image_uplooad_screen.dart'; import 'package:cogo/features/mypage/mentor_introduce/my_mentor_introduce_screen.dart'; import 'package:cogo/features/mypage/mentor_time_checking/mentor_time_checking_screen.dart'; import 'package:cogo/features/mypage/mentor_time_setting/mentor_time_setting_screen.dart'; @@ -223,7 +224,7 @@ final AppRouter = GoRouter( path: Paths.myMentorIntroduce, pageBuilder: (context, state) => MaterialPage( key: state.pageKey, - child: const MentorIntroductionScreen(), + child: const MyMentorIntroductionScreen(), ), ), GoRoute( @@ -265,6 +266,13 @@ final AppRouter = GoRouter( child: const ReportDetailScreen(), ), ), + GoRoute( + path: Paths.image, + pageBuilder: (context, state) => MaterialPage( + key: state.pageKey, + child: ImageUploadScreen(), + ), + ), StatefulShellRoute.indexedStack( builder: (context, state, navigationShell) { return ScaffoldWithNestedNavigation( diff --git a/pubspec.yaml b/pubspec.yaml index 92f2903..2452cc9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,8 @@ dependencies: # 애니메이션 로티 lottie: ^2.2.0 + image_picker: ^0.8.7+5 + dev_dependencies: flutter_test: