はじめに
Flutter公式のサンプルアプリで用いられているutils系のディレクトリを調べてみました。
中には便利なものもあるので今後のアプリ開発で取り入れてみようと思います。
処理の結果をハンドリングするResultクラス
// Copyright 2024 The Flutter team. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/// Utility class to wrap result data
///
/// Evaluate the result using a switch statement:
/// ```dart
/// switch (result) {
/// case Ok(): {
/// print(result.value);
/// }
/// case Error(): {
/// print(result.error);
/// }
/// }
/// ```
sealed class Result<T> {
const Result();
/// Creates a successful [Result], completed with the specified [value].
const factory Result.ok(T value) = Ok._;
/// Creates an error [Result], completed with the specified [error].
const factory Result.error(Exception error) = Error._;
}
/// Subclass of Result for values
final class Ok<T> extends Result<T> {
const Ok._(this.value);
/// Returned value in result
final T value;
@override
String toString() => 'Result<$T>.ok($value)';
}
/// Subclass of Result for errors
final class Error<T> extends Result<T> {
const Error._(this.error);
/// Returned error in result
final Exception error;
@override
String toString() => 'Result<$T>.error($error)';
}
Resultクラスを用いて処理を実行するCommandクラス
// Copyright 2024 The Flutter team. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'result.dart';
typedef CommandAction0<T> = Future<Result<T>> Function();
typedef CommandAction1<T, A> = Future<Result<T>> Function(A);
/// Facilitates interaction with a ViewModel.
///
/// Encapsulates an action,
/// exposes its running and error states,
/// and ensures that it can't be launched again until it finishes.
///
/// Use [Command0] for actions without arguments.
/// Use [Command1] for actions with one argument.
///
/// Actions must return a [Result].
///
/// Consume the action result by listening to changes,
/// then call to [clearResult] when the state is consumed.
abstract class Command<T> extends ChangeNotifier {
Command();
bool _running = false;
/// True when the action is running.
bool get running => _running;
Result<T>? _result;
/// true if action completed with error
bool get error => _result is Error;
/// true if action completed successfully
bool get completed => _result is Ok;
/// Get last action result
Result? get result => _result;
/// Clear last action result
void clearResult() {
_result = null;
notifyListeners();
}
/// Internal execute implementation
Future<void> _execute(CommandAction0<T> action) async {
// Ensure the action can't launch multiple times.
// e.g. avoid multiple taps on button
if (_running) return;
// Notify listeners.
// e.g. button shows loading state
_running = true;
_result = null;
notifyListeners();
try {
_result = await action();
} finally {
_running = false;
notifyListeners();
}
}
}
/// [Command] without arguments.
/// Takes a [CommandAction0] as action.
class Command0<T> extends Command<T> {
Command0(this._action);
final CommandAction0<T> _action;
/// Executes the action.
Future<void> execute() async {
await _execute(() => _action());
}
}
/// [Command] with one argument.
/// Takes a [CommandAction1] as action.
class Command1<T, A> extends Command<T> {
Command1(this._action);
final CommandAction1<T, A> _action;
/// Executes the action with the argument.
Future<void> execute(A argument) async {
await _execute(() => _action(argument));
}
}
サンプル
// 非同期でサーバーリクエストを模した処理
Future<Result<String>> fetchDataFromServer(int id) async {
await Future.delayed(const Duration(seconds: 2)); // ネットワーク遅延を仮定
if (id > 0) {
return Ok("Fetched data for id=$id");
} else {
return Error("Invalid ID: $id");
}
}
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
MyApp({super.key});
// Command1 のインスタンス生成
// int を引数として受け取り、Stringを返す非同期処理を紐づける
final command = Command1<String, int>(fetchDataFromServer);
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Command1 Sample',
home: Scaffold(
appBar: AppBar(
title: const Text('Command1 Sample'),
),
body: Center(
child: AnimatedBuilder(
animation: command,
builder: (context, _) {
if (command.running) {
return const CircularProgressIndicator();
} else if (command.result is Ok<String>) {
final okResult = command.result as Ok<String>;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Success: ${okResult.value}'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
command.clearResult();
},
child: const Text('Clear Result'),
),
],
);
} else if (command.error) {
final errorResult = command.result as Error<String>;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: ${errorResult.message}', style: const TextStyle(color: Colors.red)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
command.clearResult();
},
child: const Text('Clear Result'),
),
],
);
} else {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('No result yet. Press the button to execute.'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
// 引数に int を渡してコマンド実行
command.execute(42);
},
child: const Text('Fetch Data with ID=42'),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
// 失敗例: 無効な引数を渡す
command.execute(-1);
},
child: const Text('Fetch Data with ID=-1 (will fail)'),
),
],
);
}
},
),
),
),
);
}
}
文字列の変換を補助する
// Copyright 2020 The Flutter team. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file
import 'dart:convert';
// テキストにインデントを付与する
String indent(String content, int spaces) =>
LineSplitter.split(content).join('\n${' ' * spaces}');
/// 変数の形式を変更する
String kebabCase(String input) => _fixCase(input, '-');
String snakeCase(String input) => _fixCase(input, '_');
final _upperCase = RegExp('[A-Z]');
String pascalCase(String input) {
if (input.isEmpty) {
return '';
}
return input[0].toUpperCase() + input.substring(1);
}
String _fixCase(String input, String separator) =>
input.replaceAllMapped(_upperCase, (match) {
var group = match.group(0);
if (group == null) return input;
var lower = group.toLowerCase();
if (match.start > 0) {
lower = '$separator$lower';
}
return lower;
});
日付の同日チェックと正午を取得
意外と活用するかもしれません...
// Copyright 2020, the Flutter project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
extension DayUtils on DateTime {
/// The UTC date portion of a datetime, without the minutes, seconds, etc.
DateTime get atMidnight {
return DateTime.utc(year, month, day);
}
/// Checks that the two [DateTime]s share the same date.
bool isSameDay(DateTime d2) {
return year == d2.year && month == d2.month && day == d2.day;
}
}
感想
あれ...思ったよりなかった...
と言うことで、Flutter界隈で有名な以下の3つのテンプレートリポジトリにあるutilを調べてみたのでこちらも気になったら活用してください。
調査対象
ゆめみ
Altive
Never
アプリライフサイクルを監視するウィジェット
import 'dart:ui';
import 'package:flutter/material.dart';
/// A widget that listens to the lifecycle of the app.
/// Surround the widget you want to listen to with [CustomAppLifecycleListener].
class CustomAppLifecycleListener extends StatefulWidget {
const CustomAppLifecycleListener({
required Widget child,
VoidCallback? onResume,
VoidCallback? onInactive,
VoidCallback? onHide,
VoidCallback? onShow,
VoidCallback? onPause,
VoidCallback? onRestart,
VoidCallback? onDetach,
Future<AppExitResponse> Function()? onExitRequested,
void Function(AppLifecycleState state)? onStateChange,
super.key,
}) : _child = child,
_onResume = onResume,
_onInactive = onInactive,
_onHide = onHide,
_onShow = onShow,
_onPause = onPause,
_onRestart = onRestart,
_onDetach = onDetach,
_onExitRequested = onExitRequested,
_onStateChange = onStateChange;
final Widget _child;
final VoidCallback? _onResume;
final VoidCallback? _onInactive;
final VoidCallback? _onHide;
final VoidCallback? _onShow;
final VoidCallback? _onPause;
final VoidCallback? _onRestart;
final VoidCallback? _onDetach;
final Future<AppExitResponse> Function()? _onExitRequested;
final void Function(AppLifecycleState state)? _onStateChange;
@override
State<CustomAppLifecycleListener> createState() =>
_CustomAppLifecycleListenerState();
}
class _CustomAppLifecycleListenerState
extends State<CustomAppLifecycleListener> {
late final AppLifecycleListener _appLifecycleListener;
@override
void initState() {
_appLifecycleListener = AppLifecycleListener(
onResume: widget._onResume,
onInactive: widget._onInactive,
onHide: widget._onHide,
onShow: widget._onShow,
onPause: widget._onPause,
onRestart: widget._onRestart,
onDetach: widget._onDetach,
onExitRequested: widget._onExitRequested,
onStateChange: widget._onStateChange,
);
super.initState();
}
@override
void dispose() {
_appLifecycleListener.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return widget._child;
}
}
ログ出力
import 'package:flutter/foundation.dart';
import 'package:simple_logger/simple_logger.dart';
final logger = SimpleLogger()
..setLevel(
Level.FINEST,
includeCallerInfo: kDebugMode,
);
Jsonへの変換(Converter)
ColorConverter
import 'dart:ui';
import 'package:json_annotation/json_annotation.dart';
/// Returns a constant [ColorConverter].
const colorConverter = ColorConverter();
/// Converter for inter-conversion between aRGB (hexadecimal) and Color.
class ColorConverter implements JsonConverter<Color, int> {
/// Creates a new instance of [ColorConverter].
const ColorConverter();
@override
// Since it is a Converter, a Color constructor is required.
// ignore: avoid_hardcoded_color
Color fromJson(int json) => Color(json);
@override
int toJson(Color object) => object.value;
}
IconDataConverter
import 'package:flutter/widgets.dart';
import 'package:json_annotation/json_annotation.dart';
/// Returns a constant [IconDataConverter].
const iconDataConverter = IconDataConverter();
/// Converter for inter-conversion between IconData and int (codePoint).
class IconDataConverter implements JsonConverter<IconData, int> {
/// Creates a new instance of [IconDataConverter].
const IconDataConverter();
@override
IconData fromJson(int json) => IconData(json);
@override
int toJson(IconData object) => object.codePoint;
}
クリップボードへのコピペ
import 'package:flutter/services.dart' as service;
class Clipboard {
Clipboard._();
static Future<void> copy(String text) async {
final data = service.ClipboardData(text: text);
await service.Clipboard.setData(data);
}
static Future<String> paste(String text) async {
final data = await service.Clipboard.getData('text/plain');
return data?.text ?? '';
}
}
画像のトリミング
import 'package:flutter/material.dart';
import 'package:image_cropper/image_cropper.dart';
Future<CroppedFile?> cropAvatar(BuildContext context, String path) =>
_cropImage(
context,
path,
cropStyle: CropStyle.circle,
toolbarTitle: 'プロフィール',
);
Future<CroppedFile?> cropThumbnail(
BuildContext context,
String path, {
String title = 'サムネイル',
}) =>
_cropImage(
context,
path,
cropStyle: CropStyle.rectangle,
toolbarTitle: title,
);
Future<CroppedFile?> _cropImage(
BuildContext context,
String path, {
required CropStyle cropStyle,
required String toolbarTitle,
}) async {
final file = await ImageCropper().cropImage(
sourcePath: path,
uiSettings: [
AndroidUiSettings(
cropStyle: cropStyle,
toolbarTitle: toolbarTitle,
toolbarWidgetColor: Colors.white,
initAspectRatio: CropAspectRatioPreset.original,
lockAspectRatio: false,
aspectRatioPresets: [
CropAspectRatioPreset.square,
CropAspectRatioPreset.ratio3x2,
CropAspectRatioPreset.original,
CropAspectRatioPreset.ratio4x3,
CropAspectRatioPreset.ratio16x9,
],
),
IOSUiSettings(
cropStyle: cropStyle,
aspectRatioPresets: [
CropAspectRatioPreset.original,
CropAspectRatioPreset.square,
CropAspectRatioPreset.ratio3x2,
CropAspectRatioPreset.ratio4x3,
CropAspectRatioPreset.ratio5x3,
CropAspectRatioPreset.ratio5x4,
CropAspectRatioPreset.ratio7x5,
CropAspectRatioPreset.ratio16x9,
],
),
],
);
return file;
}
ScrollControllerをproviderで管理
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
typedef HashCode = int;
final scrollControllerProviders =
Provider.family.autoDispose<ScrollController, HashCode>(
(ref, _) {
final scrollController = ScrollController();
ref.onDispose(scrollController.dispose);
return scrollController;
},
);
タブ操作を管理するprovider
import 'package:hooks_riverpod/hooks_riverpod.dart';
typedef PageName = String;
enum TabTapOperationType {
duplication,
}
final tabTapOperationProviders =
Provider.family.autoDispose<TabTapOperation, PageName>((ref, _) {
final tabTapAction = TabTapOperation();
ref.onDispose(tabTapAction.dispose);
return tabTapAction;
});
class TabTapOperation {
void Function(TabTapOperationType)? _listener;
void addListener(void Function(TabTapOperationType) listener) {
_listener = listener;
}
void call(TabTapOperationType actionType) {
_listener?.call(actionType);
}
void dispose() {
_listener = null;
}
}
UUID生成
import 'package:hashids2/hashids2.dart';
import 'package:uuid/uuid.dart';
class UuidGenerator {
UuidGenerator._();
static String create({int length = 8}) {
final hashIds = HashIds(
salt: const Uuid().v4(),
minHashLength: length,
);
final id = hashIds.encode([1, 2, 3]);
return id;
}
static String get long => const Uuid().v4();
}
バイブレーション操作
import 'package:flutter/services.dart';
class Vibration {
Vibration._();
static Future<void> select() => HapticFeedback.heavyImpact();
static Future<void> sound() => HapticFeedback.vibrate();
}
注意点
今回気になったためutil系を調べましたが、あまりutil系に実装を詰め込みすぎるのも好ましくありません。適材適所な実装を心がけるべきです。
例えば、クラスに対するハンドリングをしたい場合はutilでクラスを作成するのではなくextensionを用いることも可能です。
こちらは参考リンクのみ添付します。
Never
Altive
ゆめみ
💡 ディレクトリでまとめているわけではないので検索リンクのみ記載します