diff --git a/lib/character_input_page.dart b/lib/character_input_page.dart index ef9100f..58c32ba 100644 --- a/lib/character_input_page.dart +++ b/lib/character_input_page.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gakkai_07/character_result_page.dart'; +import 'package:flutter_gakkai_07/data/app_exception.dart'; +import 'package:flutter_gakkai_07/ui/failure_snackbar.dart'; import 'package:flutter_gakkai_07/usecase/generate_%20character_usecase.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -12,11 +14,21 @@ class CharacterInputPage extends ConsumerStatefulWidget { class _CharacterInputPageState extends ConsumerState { final _formKey = GlobalKey(); - final _nameController = TextEditingController(text: 'cat'); - final _personalityController = TextEditingController(text: '猫の見た目'); - final _storyController = TextEditingController(text: '道で拾われた'); - double _age = 25; - String _gender = '未選択'; + final _nameController = TextEditingController(text: 'フラッター'); + final _personalityController = TextEditingController( + text: '''好奇心旺盛で新しい技術を学ぶことが大好き。 +困っている開発者を見かけると放っておけない優しい性格''', + ); + final _storyController = TextEditingController( + text: '''スマートフォンアプリ開発の世界で生まれ育った若きエンジニア。 +クロスプラットフォーム開発の可能性に魅了され、世界中の開発者たちと知識を共有しながら成長を続けている。 +休日は技術書を読んだり、コミュニティイベントに参加したりして過ごしている。''', + ); + int _age = 23; + String _gender = '女性'; + + // 年齢選択肢の生成 + final List _ageList = List.generate(100, (index) => index + 1); @override void dispose() { @@ -54,14 +66,19 @@ class _CharacterInputPageState extends ConsumerState { }, ), const SizedBox(height: 16), - Text('年齢: ${_age.round()}歳'), - Slider( + DropdownButtonFormField( value: _age, - min: 1, - max: 100, - divisions: 99, - label: _age.round().toString(), - onChanged: (value) => setState(() => _age = value), + decoration: const InputDecoration( + labelText: '年齢', + border: OutlineInputBorder(), + ), + items: _ageList + .map((age) => DropdownMenuItem( + value: age, + child: Text('$age歳'), + )) + .toList(), + onChanged: (value) => setState(() => _age = value!), ), const SizedBox(height: 16), DropdownButtonFormField( @@ -113,24 +130,32 @@ class _CharacterInputPageState extends ConsumerState { ElevatedButton( onPressed: () async { if (_formKey.currentState!.validate()) { - final generatedCharacter = - await ref.read(generateImageUsecaseProvider).invoke( - characterName: _nameController.text, - age: _age.round(), - gender: _gender, - personality: _personalityController.text, - story: _storyController.text, - ); - if (!mounted) return; - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => CharacterResultPage( - description: generatedCharacter.description, - image: generatedCharacter.image, + final scaffoldMessenger = ScaffoldMessenger.of(context); + final navigator = Navigator.of(context); + try { + final generatedCharacter = + await ref.read(generateImageUsecaseProvider).invoke( + characterName: _nameController.text, + age: _age, + gender: _gender, + personality: _personalityController.text, + story: _storyController.text, + ); + if (!mounted) return; + navigator.push( + MaterialPageRoute( + builder: (context) => CharacterResultPage( + description: generatedCharacter.description, + image: generatedCharacter.image, + ), ), - ), - ); + ); + } on AppException catch (e) { + FailureSnackBar.show( + scaffoldMessenger, + message: e.toString(), + ); + } } }, child: const Text('キャラクターを生成'), diff --git a/lib/character_result_page.dart b/lib/character_result_page.dart index 30e97bc..bd6e04a 100644 --- a/lib/character_result_page.dart +++ b/lib/character_result_page.dart @@ -12,62 +12,83 @@ class CharacterResultPage extends StatelessWidget { @override Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + return Scaffold( appBar: AppBar( - title: const Text('生成されたキャラクター'), + backgroundColor: Colors.white, + elevation: 0, + title: Text( + '生成されたキャラクター', + style: textTheme.titleLarge?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), ), - body: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - height: 300, - width: 300, - decoration: BoxDecoration( - border: Border.all(color: Colors.grey), - borderRadius: BorderRadius.circular(8), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.memory( - Uri.parse(image).data!.contentAsBytes(), - fit: BoxFit.cover, - ), + body: Column( + children: [ + Container( + padding: const EdgeInsets.all(24.0), + child: Container( + height: 300, + width: 300, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + spreadRadius: 0, + blurRadius: 10, + offset: const Offset(0, 4), ), - ), + ], ), - const SizedBox(height: 24), - const Text( - 'AI生成された説明:', - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Image.memory( + Uri.parse(image).data!.contentAsBytes(), + fit: BoxFit.cover, + ), ), - Text(description), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - ElevatedButton.icon( - icon: const Icon(Icons.share), - label: const Text('SNSで共有'), - onPressed: () { - // TODO: SNS共有機能を実装 - }, + ), + ), + Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '生成された説明', + style: textTheme.titleMedium?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.bold, ), - ElevatedButton.icon( - icon: const Icon(Icons.cloud_upload), - label: const Text('ストレージに保存'), - onPressed: () { - // TODO: ストレージアップロード機能を実装 - }, + ), + const SizedBox(height: 16), + Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: colorScheme.outline.withOpacity(0.1), + ), ), - ], - ), - ], + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + description, + style: textTheme.bodyLarge?.copyWith( + height: 1.6, + ), + ), + ), + ), + ], + ), ), - ), + ], ), ); } diff --git a/lib/data/app_exception.dart b/lib/data/app_exception.dart new file mode 100644 index 0000000..ac1fe0f --- /dev/null +++ b/lib/data/app_exception.dart @@ -0,0 +1,9 @@ +class AppException implements Exception { + final String message; + const AppException([this.message = 'エラーが発生しました']); + + @override + String toString() { + return message; + } +} diff --git a/lib/data/genkit_client.dart b/lib/data/genkit_client.dart index eaacecf..b171163 100644 --- a/lib/data/genkit_client.dart +++ b/lib/data/genkit_client.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:flutter_gakkai_07/data/app_exception.dart'; import 'package:flutter_gakkai_07/data/genkit_response.dart'; class GenkitClient { @@ -38,12 +39,15 @@ class GenkitClient { ); if (response.statusCode != 200) { - throw Exception('Failed to generate image: ${response.statusCode}'); + throw AppException(errorMessage); } return GenkitResponse.fromJson(response.data); - } on DioException catch (e) { - throw Exception('Failed to generate image: ${e.message}'); + } catch (e) { + throw AppException(errorMessage); } } + + final errorMessage = '''Imagen3 による画像生成に失敗しました。 +再度、キャラクター生成をお試しください。'''; } diff --git a/lib/main.dart b/lib/main.dart index 42c5d27..be1394d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -44,6 +44,42 @@ class MyApp extends ConsumerWidget { return MaterialApp( title: 'Character Generator', + theme: ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.indigo, + brightness: Brightness.light, + surface: Colors.white, + surfaceContainerHighest: Colors.grey[50], + ), + cardTheme: CardTheme( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + color: Colors.white, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Colors.grey[50], + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.indigo[300]!), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, + ), + ), + ), builder: (context, child) { return Stack( children: [ diff --git a/lib/ui/failure_snackbar.dart b/lib/ui/failure_snackbar.dart new file mode 100644 index 0000000..a202040 --- /dev/null +++ b/lib/ui/failure_snackbar.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +class FailureSnackBar extends SnackBar { + FailureSnackBar._({required String message}) + : super( + content: Text(message), + backgroundColor: Colors.redAccent, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + ), + ); + + static void show( + ScaffoldMessengerState scaffoldMessenger, { + required String message, + }) { + scaffoldMessenger.showSnackBar(FailureSnackBar._(message: message)); + } +}