はじめに
調子に乗って、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活用していきます!!