Flutter公式サンプルで使われているutil系のものを調べてみた

## はじめに

Flutter公式のサンプルアプリで用いられているutils系のディレクトリを調べてみました。
中には便利なものもあるので今後のアプリ開発で取り入れてみようと思います。

## 処理の結果をハンドリングするResultクラス

[リンク](https://github.com/flutter/samples/blob/203a0f422dbc4f24be22642f67fc4d793d6e301e/compass_app/app/lib/utils/result.dart)

```dart
// 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クラス

[リンク](https://github.com/flutter/samples/blob/9a01200cf85b77e78f0d098dd5157e10f8a03247/compass_app/app/lib/utils/command.dart)

```dart
// 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));
  }
}
```

### サンプル

```dart
// 非同期でサーバーリクエストを模した処理
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)'),
                    ),
                  ],
                );
              }
            },
          ),
        ),
      ),
    );
  }
}
```

## 文字列の変換を補助する

[リンク](https://github.com/flutter/samples/blob/203a0f422dbc4f24be22642f67fc4d793d6e301e/web/samples_index/lib/src/util.dart#L4)

```dart
// 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;
    });
```

## 日付の同日チェックと正午を取得

意外と活用するかもしれません...

```dart
// 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を調べてみたのでこちらも気になったら活用してください。

### 調査対象

#### ゆめみ

https://www.yumemi.co.jp/

https://github.com/yumemi-inc/flutter-mobile-project-template

#### Altive

https://altive.co.jp/

https://github.com/altive/flutter_app_template

#### Never

https://neverjp.com/

https://github.com/hukusuke1007/flutter_app_template


## アプリライフサイクルを監視するウィジェット

```dart
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;
  }
}
```

## ログ出力

```dart
import 'package:flutter/foundation.dart';
import 'package:simple_logger/simple_logger.dart';

final logger = SimpleLogger()
  ..setLevel(
    Level.FINEST,
    includeCallerInfo: kDebugMode,
  );
```

## Jsonへの変換（Converter）

### ColorConverter

```dart
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

```dart
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;
}
```

## クリップボードへのコピペ

```dart
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 ?? '';
  }
}
```

## 画像のトリミング

```dart
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で管理

```dart
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

```dart
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生成

```Dart
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();
}
```

## バイブレーション操作

```dart
import 'package:flutter/services.dart';

class Vibration {
  Vibration._();
  static Future<void> select() => HapticFeedback.heavyImpact();
  static Future<void> sound() => HapticFeedback.vibrate();
}
```

<br> 
<br>

:::note warn
注意点
:::

今回気になったためutil系を調べましたが、あまりutil系に実装を詰め込みすぎるのも好ましくありません。適材適所な実装を心がけるべきです。

例えば、クラスに対するハンドリングをしたい場合はutilでクラスを作成するのではなくextensionを用いることも可能です。

こちらは参考リンクのみ添付します。

#### Never

https://github.com/hukusuke1007/flutter_app_template/tree/main/feature_first/lib/core/extensions

#### Altive

https://github.com/altive/flutter_app_template/tree/10137dbea4146aab5dc11f600149c4696f170e8d/packages/themes/lib/src/altive_theme/utils

#### ゆめみ

💡 ディレクトリでまとめているわけではないので検索リンクのみ記載します

https://github.com/search?q=repo%3Ayumemi-inc%2Fflutter-mobile-project-template%20extension&type=code