🌥️
FlutterからS3を操作する② -画像アップロード・削除編-
設定編はこちら
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のコード参考
Discussion
記事へのコメント失礼します。株式会社KICONIA WORKSの海保と申します。
画像加工に関して記事を書かれていたので、弊社で企画中のサービスについてインタビュー(web会議/謝礼あり)をお願いしたくご連絡しました。
画像加工(フォーマット変換、サムネイリング、メディアごとの出し分け等)の開発・運用を、会社もしくは個人でしている人を探しているのですが、いかがでしょうか?
(詳細はmailやSNS等でやりとりさせてください)