앱/flutter

[flutter] 이미지 업로드하기

일 월 2023. 8. 6. 23:29

이미지 업로드

다음과 같은 기능을 포함해 구현했다.

1. 초기 설정

permission_handler (접근 권한 허용 여부 확인), image_picker (이미지 선택), dio (백과의 통신) 패키지를 설치하고, android/app/src/main/AndroidManifest.xml에 아래 코드를 추가해준다.

// pubspec.yaml
dependencies:
	...
    permission_handler: ^10.3.0
    image_picker: ^0.8.6+2
    dio: ^4.0.6
    
// android/app/src/main/AndroidManifest.xml
<manifest ...>
	// 저장공간 접근
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
    <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
...

2. 갤러리/파일 접근 권한이 허용되었을 경우,  갤러리/파일로 이동하고, 허용되지 않았을 경우, 모달 띄우기

갤러리/파일 접근 권한을 확인하고, 허용 여부에 따라 다르게 처리한다. 접근 권한이 없을 경우, 모달을 띄워주고, 있을 경우 휴대폰 내의 저장 공간으로 이동해 이미지를 선택해주었다.

// stateful widget (앞 부분은 생략)

XFile? selectedImage;

void imagePicker() async {
    Permission.storage.request().then((value) {
      if (value == PermissionStatus.denied ||
          value == PermissionStatus.permanentlyDenied) {
        // 접근 권한이 없음을 알리는 모달 띄우기
      } else {
        final ImagePicker picker = ImagePicker();
        picker.pickImage(
          source: ImageSource.gallery,
          imageQuality: 50,
        )
            .then((localImage) {
          setState(() {
            selectedImage = localImage;
            isImageUpdated = true;
          });
        }).catchError((error) {
          // 오류 발생했음을 알리는 모달 띄우기
        });
      }
    });
  }

 

3. 선택된 이미지를 화면에 띄우기

이미지가 선택됐을 경우와 선택되지 않았을 경우를 나눠서, 선택됐을 경우, 화면에 이미지를 띄워줬다.

// 상자
class CustomBoxContainer extends StatelessWidget {
  final bool hasRoundEdge, center;
  final Color? borderColor;
  final Color color;
  final BoxShadowList? boxShadow;
  final double? width, height;
  final Widget? child;
  final DecorationImage? image;
  final ReturnVoid? onTap, onLongPress;
  final BorderRadiusGeometry? borderRadius;
  const CustomBoxContainer({
    super.key,
    this.hasRoundEdge = true,
    this.borderColor,
    this.color = whiteColor,
    this.boxShadow,
    this.width,
    this.height,
    this.image,
    this.child,
    this.onTap,
    this.onLongPress,
    this.center = false,
    this.borderRadius,
  });

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onLongPress: onLongPress,
      onTap: onTap,
      child: Container(
        width: width,
        height: height,
        alignment: center ? Alignment.center : null,
        decoration: BoxDecoration(
          borderRadius:
              borderRadius ?? (hasRoundEdge ? BorderRadius.circular(10) : null),
          color: color,
          boxShadow: boxShadow,
          border: borderColor != null ? Border.all(color: borderColor!) : null,
          image: image,
        ),
        child: child,
      ),
    );
  }
}

// 아이콘 버튼
class CustomIconButton extends StatelessWidget {
  final IconData icon;
  final ReturnVoid onPressed;
  final Color color;
  final double size;
  const CustomIconButton(
      {super.key,
      required this.onPressed,
      required this.icon,
      this.size = 30,
      this.color = blackColor});

  @override
  Widget build(BuildContext context) {
    return IconButton(
      onPressed: onPressed,
      icon: CustomIcon(
        icon: icon,
        color: color,
        size: size,
      ),
      padding: EdgeInsets.zero,
      constraints: const BoxConstraints(),
      iconSize: size,
    );
  }
}

// 생략
if (selectedImage == null) {
      return CustomBoxContainer(
        width: 270,
        height: 150,
        onTap: imagePicker,
        borderColor: greyColor,
        hasRoundEdge: false,
        child: CustomIconButton(
          icon: addIcon,
          onPressed: imagePicker,
          color: greyColor,
        ),
      );
    } else {
      return CustomBoxContainer(
        width: 270,
        height: 150,
        onTap: imagePicker,
        image: DecorationImage(
          fit: BoxFit.fill,
          image: FileImage(File(selectedImage!.path)),
        ),
        // X 버튼 터치 시 이미지 삭제
        child: CustomIconButton(
          onPressed: deleteImage,
          icon: closeIcon,
        ),
      );
    }

4. 이미지를 form data를 이용해 보내기

추가로 전달할 map 형태의 데이터가 있다면 jsonEncode 함수를 이용해 json으로 변환해보내주면 된다. 이미지 파일이 존재할 경우에만 MultipartFile.fromFileSync를 통해 변환해주었다.

// 토큰 생략
final data = {}; // 추가로 전달할 데이터
final baseUrl = 'baseUrl 넣어주기';
final dio = Dio(BaseOptions(baseUrl: baseUrl, contentType: 'multipart/form-data'));
dio.post('path 넣어주기', data: FormData.fromMap({
                  'data': jsonEncode(data), // map을 json 형태로 변환
                  'img': selectedImage != null
                      ? MultipartFile.fromFileSync(
                          selectedImage!.path,
                          contentType: MediaType('image', 'png'),
                        )
                      : null,
                  'update_img': isImageUpdated,
                }))).then((response) {
                	// 성공했을 때 
                }).catchError((error) {
                	// 실패했을 때
                })