diff --git a/android/app/build.gradle b/android/app/build.gradle index 7f9e757..aa64682 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -25,7 +25,7 @@ if (flutterVersionName == null) { android { namespace "com.peeroreum.peeroreum_client" - compileSdkVersion flutter.compileSdkVersion + compileSdk 36 ndkVersion "27.0.12077973" compileOptions { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0076656..bac1ebd 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,10 @@ + + + + @@ -102,6 +106,10 @@ + { @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: PeeroreumColor.black, body: Stack( alignment: Alignment.topCenter, children: [ @@ -35,13 +36,20 @@ class _ImageDetailState extends State { items: imageList.map((i) { var imageUrl = i.toString(); return Container( + color: PeeroreumColor.black, child: PhotoView( backgroundDecoration: BoxDecoration( - color: PeeroreumColor.black + color: PeeroreumColor.black, ), minScale: PhotoViewComputedScale.contained, filterQuality: FilterQuality.high, imageProvider: NetworkImage(imageUrl), + loadingBuilder: (context, event) => Center( + child: CircularProgressIndicator( + color: PeeroreumColor.white, + strokeWidth: 2, + ), + ), ) ); // 여기에 이미지 위젯 생성 코드 추가 diff --git a/lib/screens/iedu/iedu_create.dart b/lib/screens/iedu/iedu_create.dart index c38b026..5b65ef5 100644 --- a/lib/screens/iedu/iedu_create.dart +++ b/lib/screens/iedu/iedu_create.dart @@ -8,6 +8,7 @@ import 'package:flutter_svg/svg.dart'; import 'package:peeroreum_client/api/ApiClient.dart'; import 'package:peeroreum_client/designs/PeeroreumToast.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:peeroreum_client/widgets/custom_image_picker.dart'; import 'package:peeroreum_client/data/Subject.dart'; import 'package:peeroreum_client/designs/PeeroreumColor.dart'; import 'package:peeroreum_client/designs/PeeroreumTypo.dart'; @@ -40,7 +41,6 @@ class _CreateIeduState extends State { int detailSubject = 0; late Future initFuture; - final ImagePicker picker = ImagePicker(); final List _images = []; FocusNode ContentFocusNode = FocusNode(); @@ -821,17 +821,19 @@ class _CreateIeduState extends State { } void takeFromGallery() async { - final List selectedImages = await picker.pickMultiImage(); - - if (selectedImages.isNotEmpty) { - setState(() { - if (selectedImages.length + _images.length < 6) { - _images.addAll(selectedImages); - } else { - PeeroreumToast.show(context, '사진 첨부는 5장까지 가능해요.'); - } - }); - } + final selected = await showCustomImagePicker( + context, + multiple: true, + maxCount: 5 - _images.length, + ); + if (selected == null || selected.isEmpty) return; + setState(() { + if (selected.length + _images.length <= 5) { + _images.addAll(selected); + } else { + PeeroreumToast.show(context, '사진 첨부는 5장까지 가능해요.'); + } + }); } Future postIedu() async { diff --git a/lib/screens/iedu/iedu_detail.dart b/lib/screens/iedu/iedu_detail.dart index f466246..dbe592a 100644 --- a/lib/screens/iedu/iedu_detail.dart +++ b/lib/screens/iedu/iedu_detail.dart @@ -10,6 +10,7 @@ import 'package:peeroreum_client/designs/PeeroreumTypo.dart'; import 'package:peeroreum_client/screens/detail_image.dart'; import 'package:dio/dio.dart' as dio; import 'package:image_picker/image_picker.dart'; +import 'package:peeroreum_client/widgets/custom_image_picker.dart'; import 'package:peeroreum_client/api/ApiClient.dart'; import 'package:peeroreum_client/screens/iedu/iedu_whiteboard.dart'; import 'package:peeroreum_client/screens/mypage/mypage_profile.dart'; @@ -63,7 +64,6 @@ class _DetailIeduState extends State { String comment = ""; bool isSubmittable = false; - final ImagePicker picker = ImagePicker(); XFile? _image; //---------------- List commentData = []; @@ -915,118 +915,16 @@ class _DetailIeduState extends State { ); } - void takeFromCamera() async { - final XFile? image = await picker.pickImage(source: ImageSource.camera); - if (image != null) { + void showImagePickerSheet() async { + final selected = await showCustomImagePicker(context, multiple: false); + if (selected != null && selected.isNotEmpty) { setState(() { - _image = image; + _image = selected.first; isSubmittable = true; }); - } else { - // 이미지 선택이 취소되었을 때 - print('Image selection cancelled'); } } - void takeFromGallery() async { - final XFile? image = await picker.pickImage(source: ImageSource.gallery); - - if (image != null) { - setState(() { - print("리스트에 이미지 저장"); - _image = image; - isSubmittable = true; - }); - } - } - - void showImagePickerSheet() { - showModalBottomSheet( - backgroundColor: Colors.transparent, - context: context, - builder: (context) { - return Container( - decoration: const BoxDecoration( - color: PeeroreumColor.white, - borderRadius: BorderRadius.only( - topLeft: Radius.circular(16.0), - topRight: Radius.circular(16.0), - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(20), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: TextButton( - onPressed: () { - takeFromCamera(); - Get.back(); - }, - style: ButtonStyle( - backgroundColor: MaterialStateProperty.all( - PeeroreumColor.primaryPuple[400]), - padding: MaterialStateProperty.all( - EdgeInsets.all(12)), - shape: MaterialStateProperty.all< - RoundedRectangleBorder>( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8.0), - ))), - child: const Text( - '카메라', - style: TextStyle( - fontFamily: 'Pretendard', - fontSize: 16, - fontWeight: FontWeight.w600, - color: PeeroreumColor.white, - ), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: TextButton( - onPressed: () { - takeFromGallery(); - Get.back(); - }, - style: ButtonStyle( - backgroundColor: MaterialStateProperty.all( - PeeroreumColor.primaryPuple[400]), - padding: MaterialStateProperty.all( - const EdgeInsets.all(12)), - shape: MaterialStateProperty.all< - RoundedRectangleBorder>( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8.0), - ))), - child: const Text( - '갤러리', - style: TextStyle( - fontFamily: 'Pretendard', - fontSize: 16, - fontWeight: FontWeight.w600, - color: PeeroreumColor.white, - ), - ), - ), - ), - ], - ), - ), - ], - ), - ); - }); - } - deleteQuestionBottomSheet(writerName) { var isMyQuestion = writerName == nickname; return Container( diff --git a/lib/screens/mypage/mypage_profile.dart b/lib/screens/mypage/mypage_profile.dart index 0de8b75..b1afed2 100644 --- a/lib/screens/mypage/mypage_profile.dart +++ b/lib/screens/mypage/mypage_profile.dart @@ -7,6 +7,7 @@ import 'package:flutter_svg/svg.dart'; import 'package:peeroreum_client/designs/PeeroreumToast.dart'; import 'package:get/get.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:peeroreum_client/widgets/custom_image_picker.dart'; import 'package:kakao_flutter_sdk/kakao_flutter_sdk_share.dart'; import 'package:share_plus/share_plus.dart'; import 'package:peeroreum_client/api/ApiClient.dart'; @@ -132,6 +133,31 @@ class _MyPageProfileState extends State { } } + deleteProfileImageAPI() async { + try { + var result = await ApiClient().put('/member/delete/profileImage'); + if (result.statusCode == 200) { + setState(() { profileImage = null; }); + await storage.delete(key: 'profileImage'); + PeeroreumToast.show(context, "프로필 이미지가 삭제되었어요."); + } + } catch (e) { + print('Unexpected error: $e'); + } + } + + deleteBackgroundImageAPI() async { + try { + var result = await ApiClient().put('/member/delete/backgroundImage'); + if (result.statusCode == 200) { + setState(() { backgroundImage = null; }); + PeeroreumToast.show(context, "배경 이미지가 삭제되었어요."); + } + } catch (e) { + print('Unexpected error: $e'); + } + } + backgroundImageAPI(var _image1) async { var image1 = await dio.MultipartFile.fromFile(_image1!.path); var imageMap1 = {'profileImage': image1}; @@ -420,6 +446,24 @@ class _MyPageProfileState extends State { ), ), ), + if (profileImage != null) + InkWell( + splashColor: Colors.transparent, + highlightColor: PeeroreumColor.gray[100], + onTap: () async { + Get.back(); + await deleteProfileImageAPI(); + }, + child: SizedBox( + height: 56, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + B3_18px_R(text: '프로필 삭제', color: PeeroreumColor.error), + ], + ), + ), + ), InkWell( splashColor: Colors.transparent, highlightColor: PeeroreumColor.gray[100], @@ -442,20 +486,12 @@ class _MyPageProfileState extends State { splashColor: Colors.transparent, highlightColor: PeeroreumColor.gray[100], onTap: () async { - XFile? image1; - final ImagePicker picker1 = ImagePicker(); - final XFile? pickedFile1 = - await picker1.pickImage(source: ImageSource.gallery); - if (pickedFile1 != null) { - setState(() { - image1 = XFile(pickedFile1.path); - if (image1 != null) { - setState(() { - backgroundImageAPI(image1); - Get.back(); - }); - } - }); + final selected = await showCustomImagePicker(context, multiple: false); + if (selected == null || selected.isEmpty) return; + final cropped = await cropImage(context, selected.first); + if (cropped != null) { + backgroundImageAPI(cropped); + Get.back(); } }, child: const SizedBox( @@ -468,6 +504,24 @@ class _MyPageProfileState extends State { ), ), ), + if (backgroundImage != null) + InkWell( + splashColor: Colors.transparent, + highlightColor: PeeroreumColor.gray[100], + onTap: () async { + Get.back(); + await deleteBackgroundImageAPI(); + }, + child: SizedBox( + height: 56, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + B3_18px_R(text: '배경 삭제', color: PeeroreumColor.error), + ], + ), + ), + ), ], ), ), @@ -509,12 +563,12 @@ class _MyPageProfileState extends State { ), GestureDetector( onTap: () async { - final ImagePicker picker = ImagePicker(); - final XFile? pickedFile = - await picker.pickImage(source: ImageSource.gallery); - if (pickedFile != null) { + final selected = await showCustomImagePicker(context, multiple: false); + if (selected == null || selected.isEmpty) return; + final cropped = await cropImage(context, selected.first); + if (cropped != null) { setState(() { - image = XFile(pickedFile.path); + image = cropped; }); } }, diff --git a/lib/screens/wedu/wedu_create_invitation.dart b/lib/screens/wedu/wedu_create_invitation.dart index d1a9205..c5fe210 100644 --- a/lib/screens/wedu/wedu_create_invitation.dart +++ b/lib/screens/wedu/wedu_create_invitation.dart @@ -7,6 +7,7 @@ import 'package:peeroreum_client/api/ApiClient.dart'; import 'package:peeroreum_client/designs/PeeroreumToast.dart'; import 'package:peeroreum_client/designs/PeeroreumColor.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:peeroreum_client/widgets/custom_image_picker.dart'; import 'package:flutter_colorpicker/flutter_colorpicker.dart'; import 'package:widgets_to_image/widgets_to_image.dart'; @@ -101,18 +102,18 @@ class _CreateInvitationState extends State { } XFile? _image; - final ImagePicker picker = ImagePicker(); - Future getImage(ImageSource imageSource) async { - if (isImagePickerActive) { - return; - } + Future getImage() async { + if (isImagePickerActive) return; isImagePickerActive = true; - final XFile? pickedFile = await picker.pickImage(source: imageSource); - if (pickedFile != null) { - setState(() { - _image = XFile(pickedFile.path); - }); + final selected = await showCustomImagePicker(context, multiple: false); + if (selected != null && selected.isNotEmpty) { + final cropped = await cropImage(context, selected.first); + if (cropped != null) { + setState(() { + _image = cropped; + }); + } } isImagePickerActive = false; } @@ -448,7 +449,7 @@ class _CreateInvitationState extends State { }, ); } else if (index == 6) { - await getImage(ImageSource.gallery); + await getImage(); if (_image != null) { _backgroundColor = const Color(0xfffffffe); diff --git a/lib/screens/wedu/wedu_create_screen.dart b/lib/screens/wedu/wedu_create_screen.dart index 1ec099a..17432a9 100644 --- a/lib/screens/wedu/wedu_create_screen.dart +++ b/lib/screens/wedu/wedu_create_screen.dart @@ -6,6 +6,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:peeroreum_client/designs/PeeroreumToast.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:peeroreum_client/widgets/custom_image_picker.dart'; import 'package:flutter/cupertino.dart'; import 'package:peeroreum_client/designs/PeeroreumColor.dart'; import 'package:textfield_tags/textfield_tags.dart'; @@ -95,15 +96,14 @@ class _CreateWeduState extends State { } } - XFile? _image; //이미지를 담을 변수 선언 - final ImagePicker picker = ImagePicker(); //ImagePicker 초기화 - Future getImage(ImageSource imageSource) async { - //pickedFile에 ImagePicker로 가져온 이미지가 담긴다. - final XFile? pickedFile = await picker.pickImage(source: imageSource); - - if (pickedFile != null) { + XFile? _image; + Future getImage() async { + final selected = await showCustomImagePicker(context, multiple: false); + if (selected == null || selected.isEmpty) return; + final cropped = await cropImage(context, selected.first); + if (cropped != null) { setState(() { - _image = XFile(pickedFile.path); //가져온 이미지를 _image에 저장 + _image = cropped; }); } } @@ -224,7 +224,7 @@ class _CreateWeduState extends State { GestureDetector( behavior: HitTestBehavior.translucent, onTap: () { - getImage(ImageSource.gallery); + getImage(); }, child: Align( alignment: Alignment.bottomRight, diff --git a/lib/screens/wedu/wedu_detail_screen.dart b/lib/screens/wedu/wedu_detail_screen.dart index efa52a4..a9afa34 100644 --- a/lib/screens/wedu/wedu_detail_screen.dart +++ b/lib/screens/wedu/wedu_detail_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:peeroreum_client/designs/PeeroreumToast.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:peeroreum_client/widgets/custom_image_picker.dart'; import 'package:intl/intl.dart'; import 'package:peeroreum_client/designs/PeeroreumColor.dart'; import 'package:peeroreum_client/screens/detail_image.dart'; @@ -43,7 +44,6 @@ class _DetailWeduState extends State { var email, nickname; - final ImagePicker picker = ImagePicker(); final List _images = []; List successList = []; @@ -384,27 +384,12 @@ class _DetailWeduState extends State { } } - void takeFromCamera() async { - final XFile? image = await picker.pickImage(source: ImageSource.camera); - if (image == null) return; - - setState(() { - _images.add(image); - }); - - postImages(); - } - - void takeFromGallery() async { - final List selectedImages = await picker.pickMultiImage(); - - if (selectedImages == null || selectedImages.isEmpty) return; - + void openImagePicker() async { + final selected = await showCustomImagePicker(context); + if (selected == null || selected.isEmpty) return; setState(() { - print("리스트에 이미지 저장"); - _images.addAll(selectedImages); + _images.addAll(selected); }); - postImages(); } @@ -2031,99 +2016,6 @@ class _DetailWeduState extends State { void showDoChallengeBottomSheet() { _images.clear(); - showModalBottomSheet( - backgroundColor: Colors.transparent, - context: context, - builder: (context) { - return Container( - padding: const EdgeInsets.all(20), - decoration: const BoxDecoration( - color: PeeroreumColor.white, - borderRadius: BorderRadius.only( - topLeft: Radius.circular(16.0), - topRight: Radius.circular(16.0), - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - '인증 방식을 선택하세요.', - style: TextStyle( - fontFamily: 'Pretendard', - fontSize: 16, - fontWeight: FontWeight.w600, - color: PeeroreumColor.gray[800], - ), - ), - Container( - padding: const EdgeInsets.all(20), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: TextButton( - onPressed: () { - takeFromCamera(); - Get.back(); - }, - style: ButtonStyle( - backgroundColor: MaterialStateProperty.all( - PeeroreumColor.primaryPuple[400]), - padding: MaterialStateProperty.all( - const EdgeInsets.all(12)), - shape: MaterialStateProperty.all< - RoundedRectangleBorder>( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8.0), - ))), - child: const Text( - '카메라', - style: TextStyle( - fontFamily: 'Pretendard', - fontSize: 16, - fontWeight: FontWeight.w600, - color: PeeroreumColor.white, - ), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: TextButton( - onPressed: () { - takeFromGallery(); - Get.back(); - }, - style: ButtonStyle( - backgroundColor: MaterialStateProperty.all( - PeeroreumColor.primaryPuple[400]), - padding: MaterialStateProperty.all( - const EdgeInsets.all(12)), - shape: MaterialStateProperty.all< - RoundedRectangleBorder>( - RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8.0), - ))), - child: const Text( - '갤러리', - style: TextStyle( - fontFamily: 'Pretendard', - fontSize: 16, - fontWeight: FontWeight.w600, - color: PeeroreumColor.white, - ), - ), - ), - ), - ], - ), - ), - ], - ), - ); - }); + openImagePicker(); } } diff --git a/lib/screens/wedu/wedu_modify_screen.dart b/lib/screens/wedu/wedu_modify_screen.dart index b733cb6..1858533 100644 --- a/lib/screens/wedu/wedu_modify_screen.dart +++ b/lib/screens/wedu/wedu_modify_screen.dart @@ -8,6 +8,7 @@ import 'package:peeroreum_client/api/ApiClient.dart'; import 'package:peeroreum_client/designs/PeeroreumToast.dart'; import 'package:get/get.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:peeroreum_client/widgets/custom_image_picker.dart'; import 'package:flutter/cupertino.dart'; import 'package:peeroreum_client/designs/PeeroreumColor.dart'; import 'package:peeroreum_client/screens/wedu/wedu_detail_screen.dart'; @@ -163,13 +164,14 @@ class _ModifyWeduState extends State { } XFile? _image; - final ImagePicker picker = ImagePicker(); - Future getImage(ImageSource imageSource) async { - final XFile? pickedFile = await picker.pickImage(source: imageSource); - if (pickedFile != null) { + Future getImage() async { + final selected = await showCustomImagePicker(context, multiple: false); + if (selected == null || selected.isEmpty) return; + final cropped = await cropImage(context, selected.first); + if (cropped != null) { setState(() { - _image = XFile(pickedFile.path); + _image = cropped; }); } } @@ -347,7 +349,7 @@ class _ModifyWeduState extends State { child: GestureDetector( behavior: HitTestBehavior.translucent, onTap: () { - getImage(ImageSource.gallery); + getImage(); checkValidation(); }, child: Container( diff --git a/lib/widgets/custom_image_picker.dart b/lib/widgets/custom_image_picker.dart new file mode 100644 index 0000000..12d3814 --- /dev/null +++ b/lib/widgets/custom_image_picker.dart @@ -0,0 +1,403 @@ +import 'dart:math'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:image_cropper/image_cropper.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:photo_manager/photo_manager.dart'; +import 'package:peeroreum_client/designs/PeeroreumColor.dart'; + +/// 이미지를 크롭합니다. 취소 시 null 반환. +Future cropImage(BuildContext context, XFile image) async { + final cropped = await ImageCropper().cropImage( + sourcePath: image.path, + uiSettings: [ + AndroidUiSettings( + toolbarTitle: '이미지 편집', + toolbarColor: Colors.black, + toolbarWidgetColor: Colors.white, + activeControlsWidgetColor: PeeroreumColor.primaryPuple[400]!, + lockAspectRatio: false, + hideBottomControls: false, + ), + IOSUiSettings( + title: '이미지 편집', + doneButtonTitle: '완료', + cancelButtonTitle: '취소', + resetButtonHidden: false, + ), + ], + ); + return cropped != null ? XFile(cropped.path) : null; +} + +/// 커스텀 갤러리 피커를 바텀시트로 표시합니다. +/// [multiple] false이면 1장만 선택 가능합니다. +/// [maxCount] 최대 선택 가능 장수 (multiple=true일 때 적용). +/// 반환값: 선택된 XFile 리스트, 취소 시 null +Future?> showCustomImagePicker( + BuildContext context, { + bool multiple = true, + int maxCount = 10, +}) { + return showModalBottomSheet>( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => _CustomImagePickerSheet( + multiple: multiple, + maxCount: maxCount, + ), + ); +} + +class _CustomImagePickerSheet extends StatefulWidget { + final bool multiple; + final int maxCount; + + const _CustomImagePickerSheet({ + required this.multiple, + required this.maxCount, + }); + + @override + State<_CustomImagePickerSheet> createState() => + _CustomImagePickerSheetState(); +} + +class _CustomImagePickerSheetState extends State<_CustomImagePickerSheet> { + List _albums = []; + AssetPathEntity? _selectedAlbum; + List _photos = []; + final List _selectedPhotos = []; + final Map> _thumbnailCache = {}; + bool _loading = true; + bool _permissionDenied = false; + + @override + void initState() { + super.initState(); + _init(); + } + + Future _init() async { + final PermissionState ps = await PhotoManager.requestPermissionExtend(); + if (!ps.isAuth && !ps.hasAccess) { + if (mounted) { + setState(() { + _loading = false; + _permissionDenied = true; + }); + } + return; + } + _albums = await PhotoManager.getAssetPathList( + type: RequestType.image, + filterOption: FilterOptionGroup( + orders: [const OrderOption(type: OrderOptionType.createDate, asc: false)], + ), + ); + if (_albums.isNotEmpty) { + _selectedAlbum = _albums.first; + await _loadPhotos(); + } + if (mounted) setState(() => _loading = false); + } + + Future _loadPhotos() async { + if (_selectedAlbum == null) return; + final count = await _selectedAlbum!.assetCountAsync; + final photos = await _selectedAlbum!.getAssetListPaged( + page: 0, + size: min(count, 300), + ); + _thumbnailCache.clear(); + if (mounted) setState(() => _photos = photos); + } + + Future _getThumbnail(AssetEntity asset) { + return _thumbnailCache.putIfAbsent( + asset.id, + () => asset.thumbnailDataWithSize(const ThumbnailSize(300, 300)), + ); + } + + void _toggleSelection(AssetEntity photo) { + setState(() { + if (_selectedPhotos.contains(photo)) { + _selectedPhotos.remove(photo); + } else { + if (!widget.multiple) { + _selectedPhotos.clear(); + _selectedPhotos.add(photo); + } else if (_selectedPhotos.length < widget.maxCount) { + _selectedPhotos.add(photo); + } + } + }); + } + + Future _onCameraTap() async { + final picker = ImagePicker(); + final XFile? image = await picker.pickImage(source: ImageSource.camera); + if (image != null && mounted) { + Navigator.pop(context, [image]); + } + } + + Future _onAttach() async { + final files = []; + for (final asset in _selectedPhotos) { + final file = await asset.file; + if (file != null) files.add(XFile(file.path)); + } + if (mounted) Navigator.pop(context, files); + } + + void _showAlbumSelector() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (ctx) => ListView.builder( + shrinkWrap: true, + itemCount: _albums.length, + itemBuilder: (ctx, i) { + final album = _albums[i]; + final isSelected = album == _selectedAlbum; + return ListTile( + title: Text( + album.name, + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: + isSelected ? FontWeight.w600 : FontWeight.w400, + fontSize: 16, + color: isSelected + ? PeeroreumColor.primaryPuple[400] + : PeeroreumColor.black, + ), + ), + trailing: isSelected + ? Icon(Icons.check, color: PeeroreumColor.primaryPuple[400]) + : null, + onTap: () async { + Navigator.pop(ctx); + setState(() { + _selectedAlbum = album; + _photos = []; + _loading = true; + }); + await _loadPhotos(); + if (mounted) setState(() => _loading = false); + }, + ); + }, + ), + ); + } + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.92, + maxChildSize: 0.92, + minChildSize: 0.5, + builder: (context, scrollController) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + children: [ + // 핸들 바 + Container( + margin: const EdgeInsets.only(top: 8, bottom: 4), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + // 상단 바 + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Row( + children: [ + IconButton( + icon: Icon(Icons.close, + color: PeeroreumColor.gray[800]), + onPressed: () => Navigator.pop(context, null), + ), + Expanded( + child: GestureDetector( + onTap: _albums.length > 1 + ? _showAlbumSelector + : null, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _selectedAlbum?.name ?? '모두 보기', + style: const TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + if (_albums.length > 1) + const Icon(Icons.arrow_drop_down), + ], + ), + ), + ), + TextButton( + onPressed: + _selectedPhotos.isNotEmpty ? _onAttach : null, + child: Text( + '첨부', + style: TextStyle( + fontFamily: 'Pretendard', + fontWeight: FontWeight.w600, + fontSize: 16, + color: _selectedPhotos.isNotEmpty + ? PeeroreumColor.black + : PeeroreumColor.gray[400], + ), + ), + ), + ], + ), + ), + Divider(height: 1, color: PeeroreumColor.gray[100]), + // 사진 그리드 + Expanded( + child: _permissionDenied + ? _buildPermissionDenied() + : _loading + ? const Center(child: CircularProgressIndicator()) + : GridView.builder( + controller: scrollController, + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 2, + mainAxisSpacing: 2, + ), + itemCount: _photos.length + 1, + itemBuilder: (context, index) { + if (index == 0) return _buildCameraCell(); + return _buildPhotoCell(_photos[index - 1]); + }, + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildCameraCell() { + return GestureDetector( + onTap: _onCameraTap, + child: Container( + color: PeeroreumColor.gray[100], + child: Icon( + Icons.camera_alt_outlined, + size: 36, + color: PeeroreumColor.gray[600], + ), + ), + ); + } + + Widget _buildPhotoCell(AssetEntity asset) { + final isSelected = _selectedPhotos.contains(asset); + final selectionIndex = _selectedPhotos.indexOf(asset); + + return GestureDetector( + onTap: () => _toggleSelection(asset), + child: Stack( + fit: StackFit.expand, + children: [ + FutureBuilder( + future: _getThumbnail(asset), + builder: (context, snapshot) { + if (snapshot.data == null) { + return Container(color: PeeroreumColor.gray[100]); + } + return Image.memory(snapshot.data!, fit: BoxFit.cover); + }, + ), + if (isSelected) + Container(color: Colors.black.withOpacity(0.25)), + Positioned( + top: 6, + right: 6, + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected + ? Colors.black.withOpacity(0.55) + : Colors.transparent, + border: Border.all(color: Colors.white, width: 1), + ), + child: isSelected + ? Center( + child: Text( + '${selectionIndex + 1}', + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ) + : null, + ), + ), + ], + ), + ); + } + + Widget _buildPermissionDenied() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.photo_library_outlined, + size: 48, color: PeeroreumColor.gray[400]), + const SizedBox(height: 12), + Text( + '사진 접근 권한이 필요합니다.', + style: TextStyle( + fontFamily: 'Pretendard', + fontSize: 14, + color: PeeroreumColor.gray[600], + ), + ), + const SizedBox(height: 8), + TextButton( + onPressed: () => PhotoManager.openSetting(), + child: Text( + '설정으로 이동', + style: TextStyle( + fontFamily: 'Pretendard', + fontSize: 14, + color: PeeroreumColor.primaryPuple[400], + ), + ), + ), + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index f24344c..8452500 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -629,6 +629,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.8.0" + image_cropper: + dependency: "direct main" + description: + name: image_cropper + sha256: "95782c9068ff09b95a5ece6a2b5fb31b18d8e544d79ebfa7bdafc08df39b3440" + url: "https://pub.dev" + source: hosted + version: "12.2.1" + image_cropper_for_web: + dependency: transitive + description: + name: image_cropper_for_web + sha256: e09749714bc24c4e3b31fbafa2e5b7229b0ff23e8b14d4ba44bd723b77611a0f + url: "https://pub.dev" + source: hosted + version: "7.0.0" + image_cropper_platform_interface: + dependency: transitive + description: + name: image_cropper_platform_interface + sha256: "886a30ec199362cdcc2fbb053b8e53347fbfb9dbbdaa94f9ff85622609f5e7ff" + url: "https://pub.dev" + source: hosted + version: "8.0.0" image_picker: dependency: "direct main" description: @@ -965,6 +989,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" + photo_manager: + dependency: "direct main" + description: + name: photo_manager + sha256: fb3bc8ea653370f88742b3baa304700107c83d12748aa58b2b9f2ed3ef15e6c2 + url: "https://pub.dev" + source: hosted + version: "3.9.0" photo_view: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index b40736c..028569d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -69,6 +69,8 @@ dependencies: flutter_drawing_board: ^0.5.0 path_provider: ^2.0.5 photo_view: ^0.14.0 + photo_manager: ^3.3.0 + image_cropper: ^12.2.1 package_info_plus: ^9.0.0 url_launcher_ios: ^6.3.3 flutter_dotenv: ^6.0.0