🌥️

FlutterからS3を操作する② -画像アップロード・削除編-

2022/06/26に公開

設定編はこちら

https://zenn.dev/ohtsuki/articles/edb887a52e7f98

Viewの実装

プロフィールアイコンの画像をアップロードする画面を作成します。

UIの仕様です↓
①カメラマークをタップでアイコン画像撮影・選択
②Saveボタンタップで画像をS3に保存

profile_icon.dart
import 'package:demo_profile_page/feature/profile/profile_state.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import '../../components/image_picker_menu_dialog.dart';

class ProfileIcon extends StatelessWidget {
  const ProfileIcon({
    Key? key,
  }) : super(key: key);

  
  Widget build(BuildContext context) {
    final controller = context.read<ProfileController>();
    final state = context.watch<ProfileState>();
    return SizedBox(
      height: 115,
      width: 115,
      child: Stack(
        fit: StackFit.expand,
        clipBehavior: Clip.none,
        children: [
          CircleAvatar(
            backgroundImage: state.selectFile != null
                ? Image.file(state.selectFile!, fit: BoxFit.cover).image
                : const AssetImage('assets/images/profile_icon.png'),
            backgroundColor: Colors.transparent,
          ),
          Positioned(
            right: -10,
            bottom: 0,
            child: SizedBox(
              height: 46,
              width: 46,
              child: InkWell(
                onTap: () {
                  showDialog<void>(
                    context: context,
                    builder: (_) {
                      return ImagePickerMenuDialog(
                        selectImage: controller.selectImage,
                        removeImage: controller.removeImage,
                        resizeMaxHeight: 1200,
                        resizeMaxWidth: 1200,
                      );
                    },
                  );
                },
                child: Container(
                  padding: EdgeInsets.zero,
                  decoration: BoxDecoration(
                    borderRadius: BorderRadius.circular(50),
                    border: Border.all(color: Colors.white, width: 2),
                    color: Colors.grey.shade100,
                  ),
                  child: Icon(
                    Icons.camera_alt_outlined,
                    color: Colors.grey.shade500,
                  ),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

カメラ起動とライブラリ選択ダイアログ

image_picker_menu_dialog
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';

class ImagePickerMenuDialog extends StatelessWidget {
  final void Function(File newImageFile) selectImage;
  final void Function() removeImage;
  final double? resizeMaxHeight;
  final double? resizeMaxWidth;

  const ImagePickerMenuDialog({
    Key? key,
    required this.selectImage,
    required this.removeImage,
    this.resizeMaxHeight,
    this.resizeMaxWidth,
  }) : super(key: key);

  
  Widget build(BuildContext context) {
    return AlertDialog(
      shape: const RoundedRectangleBorder(
          borderRadius: BorderRadius.all(Radius.circular(32.0))),
      actions: [
        Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            ListTile(
              title: const Text('カメラ'),
              onTap: () async {
                Navigator.pop(context);
                final image = await ImagePicker().pickImage(
                  source: ImageSource.camera,
                  maxHeight: resizeMaxHeight,
                  maxWidth: resizeMaxWidth,
                );
                if (image == null) return;
                selectImage(File(image.path));
              },
            ),
            const Divider(),
            ListTile(
              title: const Text('ライブラリ'),
              onTap: () async {
                Navigator.pop(context);
                final image = await ImagePicker().pickImage(
                  source: ImageSource.gallery,
                  maxHeight: resizeMaxHeight,
                  maxWidth: resizeMaxWidth,
                );
                if (image == null) return;
                selectImage(File(image.path));
              },
            ),
          ],
        )
      ],
    );
  }
}

View Controller

コントローラ部分です。

profile_controller.dart
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:state_notifier/state_notifier.dart';

import '../../repository/image_repository.dart';

part 'profile_state.freezed.dart';


class ProfileState with _$ProfileState {
  const factory ProfileState({
    File? selectFile,
  }) = _ProfileState;
}

class ProfileController extends StateNotifier<ProfileState> with LocatorMixin {
  ProfileController() : super(const ProfileState());

  ImageRepository get imageRepository => read<ImageRepository>();

  void selectImage(File selectImage) {
    state = state.copyWith(selectFile: selectImage);
  }

  void removeImage() {
    if (state.selectFile != null) {
      state = state.copyWith(selectFile: null);
    }
  }

  Future<String> upload({
    required void Function() onError,
  }) async {
    try {
      if (state.selectFile == null) return '';
      String dirName = 'sampleDir';

      final String newImagePath = await imageRepository.uploadImage(
        dirName: dirName,
        imageFileName: 'profile-icon-',
        oldImageKey: '',
        selectImageFile: state.selectFile!.path,
      );

      imageCache.clear();
      return newImagePath;
    } catch (e) {
      onError();
    }
    return '';
  }

  Future<void> remove({
    required void Function() onError,
  }) async {
    try {
      await imageRepository.removeImage(
        objectKey: 'objectKey',
      );
      imageCache.clear();
    } catch (e) {
      onError();
    }
  }
}

Controller説明

ファイルの選択状態をstateで管理


class ProfileState with _$ProfileState {
  const factory ProfileState({
    File? selectFile,
  }) = _ProfileState;
}

アップロード
①S3はバケット内をディレクトリの階層を切ってファイルを保存できるので、「sampleDir」と任意のディレクトリを指定。
②S3内で保存時のオブジェクトキー(ファイル名のこと)を指定。
③画像を更新したい場合はここに更新前のオブジェクトキーを指定。(ここでは割愛します。)

  Future<String> upload({
    required void Function() onError,
  }) async {
    try {
      if (state.selectFile == null) return '';
      String dirName = 'sampleDir'; // ①

      final String newImagePath = await imageRepository.uploadImage(
        dirName: dirName,
        imageFileName: 'profile-icon-', // ②
        oldImageKey: '', // ③
        selectImageFile: state.selectFile!.path,
      );

      imageCache.clear(); // Flutterが自動で画像をキャッシュしているのでキャッシュクリア
      return newImagePath;
    } catch (e) {
      onError();
    }
    return '';
  }

削除
①削除したいオブジェクトキーを指定

  Future<void> remove({
    required void Function() onError,
  }) async {
    try {
      await imageRepository.removeImage(
        objectKey: 'objectKey', // ①
      );
      imageCache.clear();
    } catch (e) {
      onError();
    }
  }

Repository

リポジトリも共通で使用するのでインスタンス作成後DIしてます。

List<SingleChildWidget> repositoryComponents = [
  Provider<ImageRepository>(
    create: (context) => ImageRepository(
      context.read<Minio>(),
      dotenv.env['BUCKET']!,
    ),
  ),
];

実際にS3へアクセスする部分です。
Minioはシンプルで使いやすいです。

image_repository.dart
import 'package:minio/io.dart';
import 'package:minio/minio.dart';
import 'package:uuid/uuid.dart';

class ImageRepository {
  final Minio _s3;
  final String _bucketName;
  ImageRepository(this._s3, this._bucketName);

  Future<String> uploadImage({
    required String dirName,
    required String imageFileName,
    required String oldImageKey,
    required String selectImageFile,
  }) async {
    try {
      String uuid = const Uuid().v4(); // お好みでファイル名にuuidを連結

      if (oldImageKey.isNotEmpty) {
        // 更新の場合は上書き
        uuid = oldImageKey.split(imageFileName).last;
      }

      // ここで指定したobjectKeyが画像の保存先名称とオブジェクトキーになる
      // 更新したい時はターゲットのオブジェクトキーを指定すると画像が上書きされる
      final objectKey = '$dirName/$imageFileName$uuid';
      await _s3.fPutObject(
        _bucketName,
        objectKey,
        selectImageFile,
      );

      return objectKey; // オブジェクトキーをリターンしてあとはこれをDBに保存など
    } on Exception catch (e) {
      throw e.toString();
    }
  }

  Future<void> removeImage({required String objectKey}) async {
    try {
      return await _s3.removeObject(
        _bucketName,
        objectKey,
      );
    } on Exception catch (e) {
      throw e.toString();
    }
  }
}

アップロード・削除編終わり

画像の表示編(近日公開予定)へ続きます!

UIのコード参考

https://youtu.be/sOVgPx8ljaE

Discussion