🚀

Flutter + Geminiで習慣サポートアプリの応援機能を追加してみる

2025/02/21に公開

個人で開発している習慣サポートアプリに、毎日目標を達成できたタイミングで次の日のやる気につながる応援コメントをAIで表示したいと思い、実装してみることにしました。

  • 技術
    • Gemini API (gemini-1.5-flash-latest)
    • Flutter v3.27.3
  • ライブラリ
    • google_generative_ai: ^0.4.6
    • flutter_dotenv: ^5.2.1s
  • エディタ
    • Cursor

Geminiの調査とモデルを選択

生成AIの種類としてコストが抑えられて高性能なモデルを使用できそうだったGeminiを使用することにしました。

今回は約50文字の応援コメントを生成する想定なので、Gemini 1.5 Flashのモデルを使用すると、100 万トークンあたりの米ドル単位で計算されるらしく、100万トークン使うごとに $0.075 かかるようでした。

プロンプトが約120トークンで、出力された応援メッセージが約40トークンくらいだったので、一日160トークン、例えばこれを1年間100ユーザーが使用した場合、200円いかないくらいで収まりそうでした(安い!)料金の詳細は以下から確認できます。
Gemini Developer API の料金

ほんとはGemini2.0を使用してみたかったですが、まだAPIが提供されてないようなので、また今度使ってみたいですね。

Gemini APIキーを作成・管理

以下のリンクからAPIキーを作成します。
https://aistudio.google.com/app/apikey

わたしはAPIキーはコードに直接書かないようにするためflutter_dotenvというライブラリを使用して.envファイルに保存して漏洩しないように管理しました。

パッケージ追加

FlutterアプリからGoogleの最先端生成AIモデル(例えばGemini)を利用するためのDart用SDKです。 このパッケージを使用することで、テキスト生成やマルチモーダル入力(テキストと画像の組み合わせ)によるコンテンツ生成、チャット機能、埋め込み(embedding)など、多彩なAI機能をアプリに組み込むことができるみたいです
https://pub.dev/packages/google_generative_ai/install

実装

モデルの初期化

今のところ応援メッセージ機能のみGenerativeModelを使用しますが、今後どこでも使用できるようにProviderを使用してモデルを初期化しました。

/// Gemini APIのクライアントを提供するプロバイダー
/// 環境変数からAPIキーを取得し、GenerativeModelを返す
@riverpod
GenerativeModel gemini(GeminiRef ref) {
  final apiKey = dotenv.env['GEMINI_API_KEY']!;
  return GenerativeModel(
    model: 'gemini-1.5-flash-latest',
    apiKey: apiKey,
  );
}

プロンプト生成

プロンプトはAI Studio Googleでgemini-1.5-flash-latestを選択して実際の出力を確認しながら作成していきました。個人的に言語学習サービスDuolingoのDuoくんが好きなので、そのキャラクターっぽく励まされるようなメッセージが出力されるように工夫しました。
https://aistudio.google.com/prompts/new_chat

class HabitSupportPrompts {
  static String generateDailyMotivation({
    required String habitName,
    required int currentStreak,
    String? lastCompletion,
  }) {
    return '''
あなたはDuolingoのDuo風AIアシスタントです。ユーザーの習慣継続を促す励ましメッセージを50文字以内で作成。30日チャレンジを意識し、簡潔に。絵文字1つまで。

状況:

目標:$habitName
継続日数:$currentStreak日
期間:${GlobalConst.maxContinuousDays}日間

出力:

50文字以内。Duo風で励みになる大げさでユニークなメッセージ。科学的根拠に基づき、30日チャレンジを意識した応援を。名言を入れることも可。
    ''';
  }
}

Repository

FutureProviderを使用して、Gemini APIと通信して応援メッセージを取得しています。

/// Gemini APIとの通信をするリポジトリ
@riverpod
class GeminiRepository extends _$GeminiRepository {
  @override
  Future<String> build() => Future.value('');

  /// Gemini APIを使用して応援メッセージを生成する
  /// [prompt] 生成に使用するプロンプト
  /// 返り値: 生成されたメッセージ。エラー時はデフォルトメッセージを返す
  Future<String> generateMotivationMessage(String prompt) async {
    final gemini = ref.watch(geminiProvider);
    try {
      final content = [Content.text(prompt)];
      final response = await gemini.generateContent(content);
      return response.text ?? GlobalConst.defaultMotivationMessage;
    } catch (e) {
      return GlobalConst.defaultMotivationMessage;
    }
  }
}

Service

プロンプトの生成をするビジネスロジックを実装して、リポジトリを通じてAPIと通信しています。

/// Gemini APIを使用して習慣に関する応援メッセージを生成する
/// ビジネスロジックを実装し、リポジトリを使用してデータを取得する
@riverpod
class GeminiService extends _$GeminiService {
  @override
  Future<String> build({
    required String habitName,
    required int currentStreak,
    String? lastCompletion,
  }) async {
    // プロンプトの生成
    final prompt = HabitSupportPrompts.generateDailyMotivation(
      habitName: habitName,
      currentStreak: currentStreak,
      lastCompletion: lastCompletion,
    );

    // リポジトリを通じてAPIと通信
    final repository = ref.watch(geminiRepositoryProvider.notifier);
    return repository.generateMotivationMessage(prompt);
  }
}

Viewmodel

UIとビジネスロジックの橋渡しをしてます。

/// AIコメントの状態を管理するプロバイダー
/// デフォルトメッセージから開始し、新しいメッセージが生成されると更新される
final motivationMessageStateProvider =
    StateProvider<String>((ref) => GlobalConst.defaultMotivationMessage);

/// AIコメントを取得するプロバイダー
/// GeminiServiceを使用して新しいメッセージを生成する
final motivationMessageProvider = FutureProvider.family<String,
    ({String habitTitle, int currentStreak, String lastCompletion})>(
  (ref, params) async {
    return ref.watch(
      geminiServiceProvider(
        habitName: params.habitTitle,
        currentStreak: params.currentStreak,
        lastCompletion: params.lastCompletion,
      ).future,
    );
  },
);

習慣の更新ボタンをタップすると応援コメントが生成されるようにする

invalidate(motivationMessageProvider) でキャッシュをクリアして、タップ時に新しいデータを取得するようにしました。

class UpdateButton extends ConsumerWidget {
  const UpdateButton({
    required this.habit,
    required this.width,
    super.key,
  });

  final HabitModel habit;
  final double width;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final logger = Logger();

    ****省略****

    return InkWell(
      splashColor: Colors.transparent,
      highlightColor: Colors.transparent,
      onTap: isUpdated
          ? null
          : () async {
              await HapticFeedback.heavyImpact();

               ****省略****

              // AIコメントの更新
              try {
                 ref.invalidate(motivationMessageProvider);
                final message = await ref.read(
                  motivationMessageProvider(
                    (
                      habitTitle: habit.title,
                      currentStreak: habit.currentStreak,
                      lastCompletion: DateTime.now().toString(),
                    ),
                  ).future,
                );
                ref.read(motivationMessageStateProvider.notifier).state =
                    message;
              } catch (e) {
                logger.e('Error getting motivation message: $e');
              }
            },
      child: Container(
        padding: const EdgeInsets.all(64),
        width: width,
        decoration: BoxDecoration(
          border: Border.all(color: Colors.grey.shade400),
          shape: BoxShape.circle,
          color: Colors.white,
          boxShadow: isUpdated
              ? null
              : [
                  const BoxShadow(
                    color: Colors.grey,
                    blurRadius: 16,
                    offset: Offset(0, 8),
                  ),
                ],
        ),
        child: ContinuousDaysAnimation(habit.currentStreak),
      ),
    );
  }
}

これでその日の目標を達成したら、AIが応援メッセージを出力してくれるようになりました!今後は応援メッセージのキャラクターを選択できる機能とかを追加していき、もっとユーザー体験を向上できるよう改善していきたいです!

やってみて

ライブラリのおかげで、大きくハマることもなく、初めてAIのAPIを使用した機能を作ることができてうれしかったです。Riverpodでの状態管理はまだコントロールできている感覚は薄いですが、個人開発をしていく中でも成長していければと思っています(おかしい箇所あればご指摘ください...!)。
また、今回はじめてCursorを使用して実装しました。今までgptにエラーなどをコピペして使用したりしていたので、コマンドですぐ解決できるようになったことが快適でした。いつもVSCodeを使用しているため、フォークして作られているCursorは全く違和感なくすんなり使うことができました。

参考サイト

https://zenn.dev/aoi_umigishi/articles/89b4d5fa54af88
https://qiita.com/enumura1/items/4a8cb32f948374dd069

Discussion