🟤

Create a signed URL Dart

2024/02/17に公開

読んでほしい人

  • FlutterでSupabaseを使っている人
  • テーブルに画像のURLを保存して、UIに表示する方法を知りたい人

補足情報

Supabaseの知識あるの前提で記事を書いてます🙇
公式チュートリアルにやり方載ってるからこれで学習するとよいかなと思います。僕も参考にしました。

こちらを参考に学習した

記事の内容

今回は、createSignedUrlというメソッドを使って、画像のuploadをした後に、パスを取得して、t_userというテーブルのカラムに画像のURLを保存しました。

今回使用するメソッド:
https://supabase.com/docs/reference/dart/storage-from-createsignedurl

Create signed url to download file without requiring permissions. This URL can be valid for a set number of seconds.

Policy permissions required:
buckets permissions: none
objects permissions: select

権限を必要とせずにファイルをダウンロードするための署名付き URL を作成します。 この URL は、設定された秒数の間有効です。

必要なポリシー権限:
バケット権限: なし
オブジェクトの権限: 選択

final String signedUrl = await supabase
  .storage
  .from('avatars')
  .createSignedUrl('avatar1.png', 60);

内部実装を見てみる。

/// 権限を必要とせずにファイルをダウンロードするための署名付き URL を作成します。 このURL
   /// 設定された秒数の間有効にすることができます。
   ///
   /// [パス] は、現在のファイルを含む、ダウンロードされるファイルのパスです。
   /// 名前。 例: `createdSignedUrl('folder/image.png')`。
   ///
   /// [expiresIn] は、署名付き URL の有効期限が切れるまでの秒数です。 のために
   /// たとえば、1 分間有効な URL の場合は '60' です。
   ///
   /// [transform] は、生成された URL に画像変換パラメータを追加します。
  Future<String> createSignedUrl(
    String path,
    int expiresIn, {
    TransformOptions? transform,
  }) async {
    final finalPath = _getFinalPath(path);
    final options = FetchOptions(headers: headers);
    final response = await _storageFetch.post(
      '$url/object/sign/$finalPath',
      {
        'expiresIn': expiresIn,
        if (transform != null) 'transform': transform.toQueryParams,
      },
      options: options,
    );
    final signedUrlPath = (response as Map<String, dynamic>)['signedURL'];
    final signedUrl = '$url$signedUrlPath';
    return signedUrl;
  }

  /// 権限を必要とせずにファイルをダウンロードするための署名付き URL を作成します。 これら
   /// URL は、設定された秒数の間有効です。
   ///
   /// [paths] は、現在のファイルを含む、ダウンロードされるファイル パスです。
   /// 名前。 例: `createdSignedUrl(['folder/image.png', 'folder2/image2.png'])`。
   ///
   /// [expiresIn] は、署名された URL が期限切れになるまでの秒数です。 のために
   /// たとえば、1 分間有効な URL の場合は '60' です。
   ///
   /// [SignedUrl] のリストが返されます。
  Future<List<SignedUrl>> createSignedUrls(
    List<String> paths,
    int expiresIn,
  ) async {
    final options = FetchOptions(headers: headers);
    final response = await _storageFetch.post(
      '$url/object/sign/$bucketId',
      {
        'expiresIn': expiresIn,
        'paths': paths,
      },
      options: options,
    );
    final List<SignedUrl> urls = (response as List).map((e) {
      return SignedUrl(
        // Prevents exceptions being thrown when null value is returned
        // https://github.com/supabase/storage-api/issues/353
        path: e['path'] ?? '',
        signedUrl: '$url${e['signedURL']}',
      );
    }).toList();
    return urls;
  }

Freezedが必要なので追加して、モデルクラス作ってください!、UI側でのこのクラスのプロパティを使います。

モデル
import 'package:flutter/foundation.dart';
// ignore: depend_on_referenced_packages
import 'package:freezed_annotation/freezed_annotation.dart';

part 't_user.freezed.dart';
part 't_user.g.dart';


class TUserState with _$TUserState {
  const factory TUserState({
    (0) int id,
    ('') String uuid,
    ('') String birthday,
    ('') String job_id,
    ('') String iconImagePath,
    ('') String user_name,
    ('') String profile,
    DateTime? created_at,
    DateTime? updated_at,
    (false) bool is_delete,
  }) = _TUserState;

  factory TUserState.fromJson(Map<String, dynamic> json) =>
      _$TUserStateFromJson(json);
}

使う時は、ほぼ公式なコードな気がするが、こんなコードを作りました。実験用なので、いい感じではないかも???

画像のuploadと表示するクラス
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

import '../../core/logger/logger.dart';
import '../../domain/entity/t_user.dart';

part 'user_repository.g.dart';

abstract interface class UserRepository {
  Future<String?> uploadImage();
  Future<void> createUserOrUpdate(
    String userName,
    String dateText,
    String iconImagePath,
    String profile,
    String jobId,
  );
  Future<List<TUserState>> fetchUser();
}

(keepAlive: true)
UserRepository userRepository(UserRepositoryRef ref) {
  return UserRepositoryImpl(ref);
}

class UserRepositoryImpl implements UserRepository {
  UserRepositoryImpl(this.ref);
  final Ref ref;

  final Supabase supabase = Supabase.instance;

  
  Future<String?> uploadImage() async {
    final picker = ImagePicker();
    final imageFile = await picker.pickImage(
      source: ImageSource.gallery,
      maxWidth: 300,
      maxHeight: 300,
    );
    if (imageFile == null) {
      return null;
    }
    try {
      // 画像を圧縮
      final result = await FlutterImageCompress.compressWithFile(
        imageFile.path,
        minWidth: 300,
        minHeight: 300,
        quality: 88,
      );
      final bytes = result;
      // !つけないように、nullチェックを行う
      if (bytes == null) {
        return null;
      }
      final fileExt = imageFile.path.split('.').last;
      final session = supabase.client.auth.currentSession;
      final user = session?.user;
      final fileName = '${DateTime.now().toIso8601String()}.$fileExt';
      final filePath = '${user?.id}/$fileName';
      await supabase.client.storage.from('avatars').uploadBinary(
            filePath,
            bytes,
            fileOptions: FileOptions(contentType: imageFile.mimeType),
          );
      logger.d('📁filePath: $filePath');
      // createSignedUrlで画像のURLを作成
      final imageUrlResponse = await supabase.client.storage
          .from('avatars')
          .createSignedUrl(filePath, 60 * 60 * 24 * 365 * 10);
      logger.d('📁imageUrlResponse: $imageUrlResponse');
      // storageのURLを返す
      return imageUrlResponse;
      // return filePath;
    } on StorageException catch (e) {
      logger.d('😇upload error: $e');
      return null;
    }
  }

  // ユーザーの新規登録
  
  Future<void> createUserOrUpdate(
    String userName,
    String dateText,
    String iconImagePath,
    String profile,
    String jobId,
  ) async {
    try {
      final session = supabase.client.auth.currentSession;
      final user = session?.user;
      if (user?.id == null) {
        throw Exception('User ID is null');
      }
      final jaUtc = DateTime.now().toUtc().toIso8601String();
      await supabase.client.from('t_user').upsert(
        {
          'uuid': user?.id,
          'user_name': userName,
          'birthday': dateText,
          'iconImagePath': iconImagePath,
          'job_id': jobId,
          'profile': profile,
          'created_at': jaUtc,
          'updated_at': jaUtc,
          'is_delete': false,
        },
        onConflict: 'uuid',
      );
    } on Exception catch (e) {
      logger.d('😇createUserOrUpdate error: $e');
      rethrow;
    }
  }

  // ユーザー情報の取得
  
  Future<List<TUserState>> fetchUser() async {
    try {
      final session = supabase.client.auth.currentSession;
      final user = session?.user;
      if (user?.id == null) {
        throw Exception('User ID is null');
      }
      final response =
          await supabase.client.from('t_user').select().eq('uuid', user!.id);
      if (response.isEmpty) {
        throw Exception('Failed to fetch user: ${response.hashCode}');
      }
      final data = response;
      final users = data.map(TUserState.fromJson).toList();
      return users;
    } on Exception catch (e) {
      logger.d('😇fetchUser error: $e');
      rethrow;
    }
  }
}

入力フォームでは、画像のuploadするところは、useStateで保持して、テーブル作るメソッドに、引数として渡します。人間のiconを押すと画像がstorageにuploadされる。

これがForm
// ignore_for_file: lines_longer_than_80_chars

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:intl/intl.dart';

import '../../application/api/user_repository.dart';
import '../../core/theme/app_color.dart';
import '../../infrastructure/provider/job_provider.dart';
import '../component/indicator_component.dart';

class InputMyPage extends HookConsumerWidget {
  const InputMyPage({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final userName = useTextEditingController();
    final profile = useTextEditingController();
    final iconImagePath = useState<String?>('');
    final dateText = useState('生年月日を選択');
    final dataValue = useState<DateTime?>(null);
    final selectedJob = useState<String?>(null);
    final jobs = ref.watch(jobProvider);

    Future<void> _selectDate(BuildContext context) async {
      final picked = await showDatePicker(
        context: context,
        initialDate: DateTime.now(),
        firstDate: DateTime(1900),
        lastDate: DateTime.now(),
      );
      if (picked != null) {
        dataValue.value = picked;
        dateText.value = DateFormat('yyyy年MM月dd日').format(picked);
      }
    }

    return Scaffold(
      backgroundColor: AppColor.grey,
      appBar: AppBar(
        automaticallyImplyLeading: false,
        // 左側に戻るボタンを表示
        leading: IconButton(
          onPressed: () {
            // context.goNamed(RouterPath.MYPAGE);
          },
          icon: const Icon(Icons.arrow_back_ios_new),
        ),
      ),
      body: SingleChildScrollView(
        child: Center(
          child: Column(
            children: [
              IconButton(
                onPressed: () async {
                  iconImagePath.value =
                      await ref.read(userRepositoryProvider).uploadImage();
                },
                icon: const Icon(Icons.person),
              ),
              const Padding(
                padding: EdgeInsets.only(left: 16),
                child: Align(
                  alignment: Alignment.centerLeft,
                  child: Text('名前'),
                ),
              ),
              UserInputForm(
                formColor: AppColor.white,
                controller: userName,
                width: double.infinity,
                height: 54,
                labelText: '名前を入力',
                contentPadding: 16,
              ),
              const SizedBox(height: 16),
              const Padding(
                padding: EdgeInsets.only(left: 16),
                child: Align(
                  alignment: Alignment.centerLeft,
                  child: Text('職業'),
                ),
              ),
              Container(
                color: AppColor.white,
                width: double.infinity,
                height: 54,
                child: Padding(
                  padding: const EdgeInsets.only(left: 16),
                  child: Align(
                    alignment: AlignmentDirectional.centerStart,
                    child: jobs.when(
                      data: (data) {
                        return DropdownButton<String>(
                          value: selectedJob.value,
                          icon: const Icon(Icons.arrow_drop_down),
                          elevation: 16,
                          style: const TextStyle(color: Colors.deepPurple),
                          underline: Container(
                            height: 2,
                            color: Colors.deepPurpleAccent,
                          ),
                          onChanged: (newValue) {
                            selectedJob.value = newValue;
                          },
                          items: data
                              .map<DropdownMenuItem<String>>(
                                (e) => DropdownMenuItem<String>(
                                  value: e.job_name,
                                  child: Text(e.job_name),
                                ),
                              )
                              .toList(),
                        );
                      },
                      error: (e, s) => Text(
                        e.toString(),
                      ),
                      loading: () => const IndicatorComponent(),
                    ),
                  ),
                ),
              ),
              const SizedBox(height: 16),
              const Padding(
                padding: EdgeInsets.only(left: 16),
                child: Align(
                  alignment: Alignment.centerLeft,
                  child: Text('生年月日'),
                ),
              ),
              const SizedBox(height: 8),
              Container(
                color: AppColor.white,
                width: double.infinity,
                height: 54,
                child: Row(
                  children: [
                    Padding(
                      padding: const EdgeInsets.only(left: 14),
                      child: Text(
                        // DatePickerで選択した日付を表示する
                        dateText.value,
                        style: const TextStyle(color: Colors.grey),
                      ),
                    ),
                    const Spacer(),
                    Padding(
                      padding: const EdgeInsets.only(right: 14),
                      child: IconButton(
                        onPressed: () => _selectDate(context),
                        icon: const Icon(Icons.calendar_today),
                      ),
                    ),
                  ],
                ),
              ),
              const SizedBox(height: 8),
              const Padding(
                padding: EdgeInsets.only(left: 16),
                child: Align(
                  alignment: Alignment.centerLeft,
                  child: Text('自己紹介'),
                ),
              ),
              const SizedBox(height: 8),
              UserInputForm(
                formColor: AppColor.white,
                controller: profile,
                width: double.infinity,
                height: 54,
                labelText: '自己紹介を入力',
                contentPadding: 16,
              ),
              const SizedBox(height: 16),
              ElevatedButton(
                onPressed: () async {
                  try {
                    await ref.read(userRepositoryProvider).createUserOrUpdate(
                          userName.text,
                          dateText.value,
                          iconImagePath.value ?? '',
                          profile.text,
                          selectedJob.value ?? '',
                        );
                    if (context.mounted) {
                      context.pop();
                    }
                  } on Exception catch (e) {
                    throw Exception(e);
                  }
                },
                child: const Text('登録'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class UserInputForm extends HookConsumerWidget {
  const UserInputForm({
    super.key,
    required this.width,
    required this.height,
    required this.labelText,
    required this.controller,
    required this.formColor,
    required this.contentPadding,
  });

  final double width;
  final double height;
  final String labelText;
  final TextEditingController controller;
  final Color formColor;
  final double contentPadding;

  
  Widget build(BuildContext context, WidgetRef ref) {
    return Container(
      color: formColor,
      width: width,
      height: height,
      child: TextFormField(
        controller: controller,
        decoration: InputDecoration(
          labelText: labelText,
          border: InputBorder.none,
          contentPadding: EdgeInsets.only(left: contentPadding),
        ),
      ),
    );
  }
}

View側のコードはこれ。カッコよくはないです。

画像を表示するページ
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

import '../../application/usecase/t_user_notifier.dart';
import '../../core/theme/app_color.dart';
import '../router/router_path.dart';

class MyPage extends HookConsumerWidget {
  const MyPage({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final tUser = ref.watch(tUserNotifierProvider);

    return Scaffold(
      backgroundColor: AppColor.grey,
      appBar: AppBar(
        actions: [
          IconButton(
            onPressed: () {
              context.goNamed(RouterPath.INPUT_MYPAGE);
            },
            icon: const Icon(Icons.more_vert),
          ),
        ],
      ),
      body: tUser.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (error, stackTrace) => Center(
          child: Text('Error: $error'),
        ),
        data: (data) {
          return ListView.builder(
            itemCount: data.length,
            itemBuilder: (context, index) {
              return ListTile(
                leading: CircleAvatar(
                  backgroundImage: NetworkImage(data[index].iconImagePath),
                ),
                title: Text(data[index].user_name),
              );
            },
          );
        },
      ),
    );
  }
}

コードは全部諸事情で見せられないので、表示できてるかサンプル画像載せておきます。
URLのところのカラムをアプリ側で取得すれば画像をUIに表示できる。

こんな感じですね。

最後に

今回は、storageに保存した画像のURLの情報を作成する方法をご紹介いたしました。Firestoreに、Firebase Storageの画像のURLを保存するのと仕組みは似てますね。この記事が誰かのお役立てると嬉しいです。

Discussion