広告 Flutter Dart Unity アプリ開発

Gemini(AI)でVibe coding

はじめに

調子に乗って、Gemini3の力量を試すためにFlutterアプリのログイン画面の試作を行ってみました。
結論から言うと、Flutterアプリを自作できる力量がないと、ちゃんとしたコードを出力させられないことが確認できました。
当たり前といえばそれまでだけど。

でも、最終的にはちゃんと動くサンプルコードを示してくれました。滅茶苦茶冗長なソースでセンスは悪いけど、真面目な部下にコーディングさせてる気分でしたね。😅

作りたいアプリの仕様

アプリにログインした直後にEULA(End User License Agreement)利用規約画面を表示させたい。ちゃんと最後まで読んで、同意したら、2回目の起動時にはEULAは表示されないようにしたい。ただし、メニューからEULAの内容は確認できるようにしたい。

試行錯誤したプロンプト

プロンプトは、細かい部分まで手取り足取り指摘して、やっとまともなソースを作ってくれました。
最初、Geminiはシンプルなproviderモジュールで提案してきたのですが、僕が他の部分で状態保持にRiverpodを、ページ遷移はGoRouterを使う予定があったので、それを使うよう指示を追加しました。その他、色々ダメ出しして、かなり冗長なプロンプトになりました。Gemini3が理解できない文言は、しれっと無視されるので、「drawerメニューを使って」等と具体的な指示が必要でした。

最終的なプロンプトは以下の通りです。

flutterでアプリを作りたいのですが、アプリを起動後、IDとパスワードでログインした後に、EULA(End User License Agreement)画面を表示し、同意するようにしたい。
EULAのテキストは、assetsに保存したテキストファイルを読み込む仕様とする。
同意後、2回目以降のアプリ起動時には、EULA画面は表示せずにアプリのメイン画面が表示されるようにする。
drawerメニューからEULAの内容を読めるようにする。
GoRouterを使用して画面遷移をさせる。
EULAを最後までスクロールしないと同意ボタンを押せないようにする。
その時に、「同意していただけないとアプリを利用できません。よろしいですか?」と確認するダイアログボックスを表示する。
providerではなくriverpodを使ってほしい。

このプロンプトでも、最初に出力されたサンプルソースは、コンパイルできませんでした。
これはRiverpodの古いバージョンの仕様で記述されていたからです。
実はRiverpodは、仕様変更が激しいんですよね!😅 Flutter初心者がよくハマる罠です。

そこで、対応出来るかどうか不安でしたが、以下のような指摘をしてみました。

StateNotifierProviderは非推奨になり使えません。

そうしたら、以下のような出力を得られ、無事にコンパイルできました。
ソースはかなり冗長なのと、ログイン画面はやっつけ仕事のような作りなので、ここは作り直しだねぇ。

このように、「FlutterプログラミングスキルがないとVibe codingは行き詰まりますよ」ということを示せました。

でもGemini3が優秀であることは間違いないですね!

Geminiから出力されたコードサンプル

ご要望の仕様(Riverpod, GoRouter, Assets読み込み, スクロール判定, 永続化, 終了確認など)を全て盛り込んだ実装例を作成しました。

前提条件とセットアップ
プロジェクトの pubspec.yaml に以下の依存関係を追加し、flutter pub get を実行してください。

YAML
dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.5.1
  go_router: ^13.0.0
  shared_preferences: ^2.2.2
  
flutter:
  assets:
    - assets/terms.txt
また、プロジェクトのルートに assets フォルダを作成し、その中に利用規約のテキストファイル eula.txt を配置してください。

........(ここにサンプルソースが表示されていたが、StateNotifierProviderという、古い仕様のソースでコンパイラ通らず)

「StateNotifierProviderは非推奨になり使えません。」とプロンプト追加
それに対するGemini3の回答。紳士的でした!😀

ご指摘ありがとうございます。おっしゃる通り、Riverpod 2.0以降では StateNotifier / StateNotifierProvider はレガシー扱いとなっており、代わりに Notifier / NotifierProvider(または AsyncNotifier)の使用が推奨されています。

ご要望に合わせて、Riverpod 2.0 推奨の記法 (Notifier クラス) を使用して書き直しました。

修正後の main.dart
StateNotifier を Notifier に置き換え、初期化ロジックを build() メソッド内に移動しています。その他の仕様(GoRouter, Assets読み込み, スクロール判定など)は維持しています。

Dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; // assets読み込み, SystemNavigator用
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';

// -----------------------------------------------------------------------------
// 1. Providers & State Management (Riverpod 2.0 Notifier)
// -----------------------------------------------------------------------------

// SharedPreferencesのインスタンスを保持するプロバイダー
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
  throw UnimplementedError();
});

// ログイン状態管理 (Notifier版)
final authProvider = NotifierProvider<AuthNotifier, bool>(AuthNotifier.new);

class AuthNotifier extends Notifier<bool> {
  @override
  bool build() {
    // 初期状態: 未ログイン
    return false;
  }

  // ログイン処理
  Future<bool> login(String id, String password) async {
    // API通信の擬似的な遅延
    await Future.delayed(const Duration(seconds: 1)); 
    
    if (id.isNotEmpty && password.isNotEmpty) {
      state = true;
      return true;
    }
    return false;
  }

  void logout() {
    state = false;
  }
}

// EULA同意状態管理 (Notifier版)
final eulaStateProvider = NotifierProvider<EulaNotifier, bool>(EulaNotifier.new);

class EulaNotifier extends Notifier<bool> {
  @override
  bool build() {
    // SharedPreferencesを取得
    final prefs = ref.watch(sharedPreferencesProvider);
    // 保存されている値を初期値とする
    return prefs.getBool('is_eula_accepted') ?? false;
  }

  Future<void> agree() async {
    final prefs = ref.read(sharedPreferencesProvider);
    await prefs.setBool('is_eula_accepted', true);
    state = true;
  }

  // デバッグ用リセット
  Future<void> reset() async {
    final prefs = ref.read(sharedPreferencesProvider);
    await prefs.remove('is_eula_accepted');
    state = false;
  }
}

// -----------------------------------------------------------------------------
// 2. Router Configuration (GoRouter)
// -----------------------------------------------------------------------------

final routerProvider = Provider<GoRouter>((ref) {
  // 状態を監視 (値が変わるとリダイレクトロジックが再評価される)
  final isLoggedIn = ref.watch(authProvider);
  final isEulaAccepted = ref.watch(eulaStateProvider);

  return GoRouter(
    initialLocation: '/login',
    // リダイレクトロジック
    redirect: (context, state) {
      final loggingIn = state.uri.toString() == '/login';

      // 1. 未ログインならログイン画面へ
      if (!isLoggedIn) {
        return loggingIn ? null : '/login';
      }

      // 2. ログイン済みだが、EULA未同意ならEULA画面へ
      if (isLoggedIn && !isEulaAccepted) {
        if (state.uri.toString() == '/eula') return null;
        return '/eula';
      }

      // 3. ログイン済みかつEULA同意済みならホームへ
      // (ログイン画面やEULA同意画面に行こうとした場合のみリダイレクト)
      if (loggingIn || state.uri.toString() == '/eula') {
        return '/home';
      }

      return null;
    },
    routes: [
      GoRoute(
        path: '/login',
        builder: (context, state) => const LoginScreen(),
      ),
      GoRoute(
        path: '/eula',
        builder: (context, state) => const EulaScreen(isFromDrawer: false),
      ),
      GoRoute(
        path: '/home',
        builder: (context, state) => const HomeScreen(),
      ),
      GoRoute(
        path: '/eula-view',
        builder: (context, state) => const EulaScreen(isFromDrawer: true),
      ),
    ],
  );
});

// -----------------------------------------------------------------------------
// 3. Entry Point
// -----------------------------------------------------------------------------

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final prefs = await SharedPreferences.getInstance();

  runApp(
    ProviderScope(
      overrides: [
        // SharedPreferencesを注入
        sharedPreferencesProvider.overrideWithValue(prefs),
      ],
      child: const MyApp(),
    ),
  );
}

class MyApp extends ConsumerWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final router = ref.watch(routerProvider);

    return MaterialApp.router(
      title: 'EULA Sample',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        useMaterial3: true,
      ),
      routerConfig: router,
    );
  }
}

// -----------------------------------------------------------------------------
// 4. Screens
// -----------------------------------------------------------------------------

// --- Login Screen ---
class LoginScreen extends ConsumerStatefulWidget {
  const LoginScreen({super.key});

  @override
  ConsumerState<LoginScreen> createState() => _LoginScreenState();
}

class _LoginScreenState extends ConsumerState<LoginScreen> {
  final _idController = TextEditingController(text: "user");
  final _passController = TextEditingController(text: "pass");
  bool _isLoading = false;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Login')),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            TextField(
              controller: _idController,
              decoration: const InputDecoration(labelText: 'ID'),
            ),
            TextField(
              controller: _passController,
              decoration: const InputDecoration(labelText: 'Password'),
              obscureText: true,
            ),
            const SizedBox(height: 24),
            _isLoading
                ? const CircularProgressIndicator()
                : ElevatedButton(
                    onPressed: () async {
                      setState(() => _isLoading = true);
                      // Notifierのメソッドを呼び出す
                      await ref
                          .read(authProvider.notifier)
                          .login(_idController.text, _passController.text);
                      if (mounted) setState(() => _isLoading = false);
                    },
                    child: const Text('Login'),
                  ),
          ],
        ),
      ),
    );
  }
}

// --- EULA Screen ---
class EulaScreen extends ConsumerStatefulWidget {
  final bool isFromDrawer; 

  const EulaScreen({super.key, required this.isFromDrawer});

  @override
  ConsumerState<EulaScreen> createState() => _EulaScreenState();
}

class _EulaScreenState extends ConsumerState<EulaScreen> {
  final ScrollController _scrollController = ScrollController();
  bool _isAtBottom = false;
  String _termsText = '';
  bool _isLoading = true;

  @override
  void initState() {
    super.initState();
    _loadTerms();
    _scrollController.addListener(_onScroll);
  }

  Future<void> _loadTerms() async {
    try {
      final text = await rootBundle.loadString('assets/terms.txt');
      setState(() {
        _termsText = text;
        _isLoading = false;
      });
      WidgetsBinding.instance.addPostFrameCallback((_) {
        _checkIfScrollNeeded();
      });
    } catch (e) {
      setState(() {
        _termsText = "規約の読み込みに失敗しました。";
        _isLoading = false;
      });
    }
  }

  void _onScroll() {
    if (_isAtBottom) return;

    if (_scrollController.position.pixels >=
        _scrollController.position.maxScrollExtent - 5) {
      setState(() {
        _isAtBottom = true;
      });
    }
  }

  void _checkIfScrollNeeded() {
    if (_scrollController.hasClients) {
      // コンテンツが画面より短い場合、maxScrollExtentは0になる
      if (_scrollController.position.maxScrollExtent == 0) {
        setState(() {
          _isAtBottom = true;
        });
      }
    }
  }

  @override
  void dispose() {
    _scrollController.dispose();
    super.dispose();
  }

  Future<void> _handleDisagree() async {
    final shouldExit = await showDialog<bool>(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('確認'),
        content: const Text('同意していただけないとアプリを利用できません。よろしいですか?'),
        actions: [
          TextButton(
            onPressed: () => Navigator.of(context).pop(false),
            child: const Text('キャンセル'),
          ),
          TextButton(
            onPressed: () => Navigator.of(context).pop(true),
            style: TextButton.styleFrom(foregroundColor: Colors.red),
            child: const Text('終了する'),
          ),
        ],
      ),
    );

    if (shouldExit == true) {
      SystemNavigator.pop();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('利用規約 (EULA)'),
        automaticallyImplyLeading: widget.isFromDrawer,
      ),
      body: _isLoading
          ? const Center(child: CircularProgressIndicator())
          : Column(
              children: [
                Expanded(
                  child: Scrollbar(
                    controller: _scrollController,
                    thumbVisibility: true,
                    child: SingleChildScrollView(
                      controller: _scrollController,
                      padding: const EdgeInsets.all(16.0),
                      child: Text(
                        _termsText,
                        style: const TextStyle(fontSize: 16),
                      ),
                    ),
                  ),
                ),
                if (!widget.isFromDrawer)
                  Container(
                    padding: const EdgeInsets.all(16.0),
                    decoration: BoxDecoration(
                      color: Colors.grey[100],
                      border: const Border(top: BorderSide(color: Colors.grey)),
                    ),
                    child: Row(
                      children: [
                        Expanded(
                          child: OutlinedButton(
                            onPressed: _handleDisagree,
                            child: const Text('同意しない'),
                          ),
                        ),
                        const SizedBox(width: 16),
                        Expanded(
                          child: FilledButton(
                            onPressed: _isAtBottom
                                ? () {
                                    // Notifierのメソッドを呼び出す
                                    ref.read(eulaStateProvider.notifier).agree();
                                  }
                                : null,
                            child: const Text('同意する'),
                          ),
                        ),
                      ],
                    ),
                  ),
              ],
            ),
    );
  }
}

// --- Home Screen ---
class HomeScreen extends ConsumerWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(title: const Text('Main Screen')),
      drawer: Drawer(
        child: ListView(
          padding: EdgeInsets.zero,
          children: [
            const DrawerHeader(
              decoration: BoxDecoration(color: Colors.blue),
              child: Text(
                'Menu',
                style: TextStyle(color: Colors.white, fontSize: 24),
              ),
            ),
            ListTile(
              leading: const Icon(Icons.description),
              title: const Text('利用規約を確認'),
              onTap: () {
                Navigator.pop(context);
                context.push('/eula-view');
              },
            ),
            const Divider(),
            ListTile(
              leading: const Icon(Icons.logout),
              title: const Text('ログアウト'),
              onTap: () {
                Navigator.pop(context);
                // Notifierのメソッド呼び出し
                ref.read(authProvider.notifier).logout();
              },
            ),
            ListTile(
              leading: const Icon(Icons.refresh),
              title: const Text('規約同意リセット(デバッグ用)'),
              onTap: () async {
                Navigator.pop(context);
                await ref.read(eulaStateProvider.notifier).reset();
              },
            ),
          ],
        ),
      ),
      body: const Center(
        child: Text(
          'ようこそ!\nメイン画面です。',
          textAlign: TextAlign.center,
          style: TextStyle(fontSize: 20),
        ),
      ),
    );
  }
}
変更点の要約
AuthNotifier の定義

StateNotifier<bool> → Notifier<bool> に変更。

初期化処理はコンストラクタではなく、build() メソッド 内で行うように変更しました(return false)。

EulaNotifier の定義

StateNotifier<bool> → Notifier<bool> に変更。

初期値のロード(SharedPreferencesの読み取り)を build() メソッド 内で行っています。

Notifier 内では ref が最初から使えるため、コンストラクタでの受け渡しが不要になり、ref.watch(sharedPreferencesProvider) で直接取得しています。

プロバイダーの定義

StateNotifierProvider → NotifierProvider に変更。

定義例: final authProvider = NotifierProvider<AuthNotifier, bool>(AuthNotifier.new);

これで最新のRiverpodの推奨スタイルに準拠しつつ、ご要望の機能を実現できます。

う~ん、長い! ちょっと読む気にはなれないソースですが、画面遷移の手順の参考にはなりますね。
このままでは使い物にはなりませんが、参考にはなります。
これからも、Gemini活用していきます!!

-Flutter Dart Unity アプリ開発