「[[.NET 開発基盤部会 Wiki>http://dotnetdevelopmentinfrastructure.osscons.jp]]」は、「[[Open棟梁Project>https://github.com/OpenTouryoProject/]]」,「[[OSSコンソーシアム .NET開発基盤部会>https://www.osscons.jp/dotNetDevelopmentInfrastructure/]]」によって運営されています。 -[[戻る>Flutter]] --...[[Flutterの5thステップ]] --[[Flutterのファースト・ステップ2]] --Flutterのセカンド・ステップ2 --[[Flutterのサード・ステップ2]] *目次 [#w4d48b9b] #contents *概要 [#p3e29e93] WebAPIを経由した簡単なCRUD処理の実装 *詳細 [#w39aca80] **WebAPI [#j28c3169] プロンプト 添付のReactプロジェクトのコードをFlutterに移植します。 なお、作成したScreenは以下からリンクします。 ※ 添付のReactプロジェクトのコード:https://github.com/OpenTouryoProject/FrontendTemplates/blob/develop/UI/SPA/React/vite-react-ts/src/pages/FetchData.tsx -添付のReactプロジェクトのコード:https://github.com/OpenTouryoProject/FrontendTemplates/blob/develop/UI/SPA/React/vite-react-ts/src/pages/FetchData.tsx -「以下からリンクします。」の以下:https://github.com/OpenTouryoProject/FrontendTemplates/blob/develop/UI/XPlat/flutter_template/lib/layout/main_layout.dart ※ 「以下からリンクします。」の以下:https://github.com/OpenTouryoProject/FrontendTemplates/blob/develop/UI/XPlat/flutter_template/lib/layout/main_layout.dart ***インストール [#jcd0ddb8] Reactの、fetch相当をインストール(http) -プロンプト pubspec.yamlはコマンドではなく手書き? -出力 flutter pub add http ***FetchData [#l19a7316] import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; // --- モデル --- class WeatherForecast { final String dateFormatted; final int temperatureC; final int temperatureF; final String summary; const WeatherForecast({ required this.dateFormatted, required this.temperatureC, required this.temperatureF, required this.summary, }); factory WeatherForecast.fromJson(Map<String, dynamic> json) { return WeatherForecast( dateFormatted: json['dateFormatted'] as String, temperatureC: json['temperatureC'] as int, temperatureF: json['temperatureF'] as int, summary: json['summary'] as String, ); } } // --- 定数 (constants.dart の FetchDataRootUrl に相当) --- // 実際の URL に合わせて変更してください。 const String _fetchDataRootUrl = 'https://localhost:44335/api/SampleData/WeatherForecasts?'; // --- Screen --- class ScreenFetchData extends StatefulWidget { const ScreenFetchData({super.key}); @override State<ScreenFetchData> createState() => _ScreenFetchDataState(); } class _ScreenFetchDataState extends State<ScreenFetchData> { List<WeatherForecast> _forecasts = []; bool _loading = true; int _currentPage = 1; String? _errorMessage; @override void initState() { super.initState(); _fetchForecasts(1); } Future<void> _fetchForecasts(int page) async { setState(() { _loading = true; _errorMessage = null; }); final uri = Uri.parse('${_fetchDataRootUrl}startDateIndex=$page'); try { // oauth_oidc.createHttpRequestHeader(false) に相当するヘッダーを設定してください。 final response = await http.get( uri, headers: { 'Content-Type': 'application/json', // 'Authorization': 'Bearer <token>', // 認証トークンが必要な場合 }, ); if (response.statusCode == 200) { final List<dynamic> jsonList = jsonDecode(response.body) as List<dynamic>; final forecasts = jsonList .map((e) => WeatherForecast.fromJson(e as Map<String, dynamic>)) .toList(); setState(() { _forecasts = forecasts; _currentPage = page; _loading = false; }); } else { throw Exception('HTTP ${response.statusCode}'); } } catch (e) { debugPrint('fetch error: $e'); setState(() { _errorMessage = e.toString(); _loading = false; }); } } void _handlePrev() { if (_currentPage > 1) { _fetchForecasts(_currentPage - 1); } } void _handleNext() { _fetchForecasts(_currentPage + 1); } // --- テーブル描画 --- Widget _buildForecastTable() { const headerStyle = TextStyle(fontWeight: FontWeight.bold); return SingleChildScrollView( scrollDirection: Axis.horizontal, child: DataTable( columns: const [ DataColumn(label: Text('Date', style: headerStyle)), DataColumn(label: Text('Temp. (C)', style: headerStyle)), DataColumn(label: Text('Temp. (F)', style: headerStyle)), DataColumn(label: Text('Summary', style: headerStyle)), ], rows: _forecasts .map( (f) => DataRow(cells: [ DataCell(Text(f.dateFormatted)), DataCell(Text('${f.temperatureC}')), DataCell(Text('${f.temperatureF}')), DataCell(Text(f.summary)), ]), ) .toList(), ), ); } // --- ページネーション --- Widget _buildPagination() { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ ElevatedButton( onPressed: (_loading || _currentPage <= 1) ? null : _handlePrev, child: const Text('« Prev'), ), const SizedBox(width: 12), Text('Page $_currentPage'), const SizedBox(width: 12), ElevatedButton( onPressed: _loading ? null : _handleNext, child: const Text('Next »'), ), ], ); } @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( 'Weather forecast', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), ), const SizedBox(height: 8), const Text('This component demonstrates fetching data from the server.'), const SizedBox(height: 16), // コンテンツ Expanded( child: Center( child: _loading ? const CircularProgressIndicator() : _errorMessage != null ? Text( 'Error: $_errorMessage', style: const TextStyle(color: Colors.red), ) : _buildForecastTable(), ), ), const SizedBox(height: 12), _buildPagination(), ], ), ); } } ***AppConfig [#m500026f] -プロンプト Flutterでapp.configのようなパラメタ設定ファイルはありますか? -出力~ lib/config/app_config.dart class AppConfig { // 基本URL static const String authServerRootUrl = String.fromEnvironment( 'AUTH_SERVER_ROOT_URL', defaultValue: 'https://localhost:44300/MultiPurposeAuthSite', ); static const String resourcesServerRootUrl = String.fromEnvironment( 'RESOURCES_SERVER_ROOT_URL', defaultValue: 'https://localhost:44335', ); // クライアント設定 static const String baseUrl = '~/'; static const String clientId = String.fromEnvironment( 'CLIENT_ID', defaultValue: 'f374a155909d486a9234693c34e94479', ); // 派生URL(文字列補間はconstにできないためstaticゲッターで定義) static String get authRequestUrl => '$authServerRootUrl/authorize'; static String get tokenRequestUrl => '$authServerRootUrl/token'; static String get userInfoRequestUrl => '$authServerRootUrl/userinfo'; static String get fetchDataRootUrl => '$resourcesServerRootUrl/api/sampledata/weatherforecasts?'; static String get crudSampleRootUrl => '$resourcesServerRootUrl/api/json/'; } -使い方 --定義 my_app/ ├── config.json ├── lib/ │ └── config/ │ └── app_config.dart ← ここが一般的 --使用 dartimport 'package:my_app/config/app_config.dart'; // 使用例 final clientId = AppConfig.clientId; final authUrl = AppConfig.authRequestUrl; final fetchUrl = AppConfig.fetchDataRootUrl; --変更~ defaultValueではなく.envの値を使用する場合 flutter run --dart-define-from-file=config.json ***リファクタリング [#i8244bbc] -共通関数抜き出し --プロンプト 以下はReactの実装です ``` fetchForecasts(page: number) { this.setState({ loading: true }); common.getFetch( constants.FetchDataRootUrl + `startDateIndex=${page}`, oauth_oidc.createHttpRequestHeader(false), (data) => { this.setState({ forecasts: data as unknown as WeatherForecast[], loading: false, currentPage: page }); }, (msg) => { this.setState({ loading: false }); console.error('fetch error', msg); }, ); } ``` 以下はFlutterに移植したコードです ``` Future<void> _fetchForecasts(int page) async { setState(() { _loading = true; _errorMessage = null; }); final uri = Uri.parse('${_fetchDataRootUrl}startDateIndex=$page'); try { final response = await http.get( uri, headers: { 'Content-Type': 'application/json', // 'Authorization': 'Bearer <token>', // 認証トークンが必要な場合 }, ); if (response.statusCode == 200) { final List<dynamic> jsonList = jsonDecode(response.body) as List<dynamic>; final forecasts = jsonList .map((e) => WeatherForecast.fromJson(e as Map<String, dynamic>)) .toList(); setState(() { _forecasts = forecasts; _currentPage = page; _loading = false; }); } else { throw Exception('HTTP ${response.statusCode}'); } } catch (e) { debugPrint('fetch error: $e'); setState(() { _errorMessage = e.toString(); _loading = false; }); } } ``` Flutterに容易スべき共通関数はありますか? ※ なお、共通関数を実装した関連ファイルを添付してある。~ ・https://github.com/OpenTouryoProject/FrontendTemplates/blob/develop/UI/SPA/React/vite-react-ts/src/common.ts~ ・https://github.com/OpenTouryoProject/FrontendTemplates/blob/develop/UI/SPA/React/vite-react-ts/src/touryo/common.ts~ ・https://github.com/OpenTouryoProject/FrontendTemplates/blob/develop/UI/SPA/React/vite-react-ts/src/touryo/oauth_oidc.ts~ ※ 追加で「fetchStatusHandlerも作成します。」と問い合わせた。 --出力 Future<void> postFetch( String url, Map<String, String> headers, Object body, void Function(Map<String, dynamic> data) onSuccess, void Function(String msg) setMessage, ) async { setMessage(''); try { final response = fetchStatusHandler( await http.post(Uri.parse(url), headers: headers, body: body), ); final data = jsonDecode(response.body) as Map<String, dynamic>; if (data['errorMSG'] != null) { setMessage(jsonEncode(data['errorMSG'])); } else if (data['exceptionMSG'] != null) { setMessage(jsonEncode(data['exceptionMSG'])); } else { onSuccess(data); } } catch (e) { setMessage(e.toString()); } } Future<void> getFetch( String url, Map<String, String> headers, void Function(Map<String, dynamic> data) onSuccess, void Function(String msg) setMessage, ) async { setMessage(''); try { final response = fetchStatusHandler( await http.get(Uri.parse(url), headers: headers), ); final data = jsonDecode(response.body) as Map<String, dynamic>; if (data['errorMSG'] != null) { setMessage(jsonEncode(data['errorMSG'])); } else if (data['exceptionMSG'] != null) { setMessage(jsonEncode(data['exceptionMSG'])); } else { onSuccess(data); } } catch (e) { setMessage(e.toString()); } } http.Response fetchStatusHandler(http.Response response) { if (response.statusCode == 200) { return response; } else { throw Exception(response.reasonPhrase ?? 'HTTP ${response.statusCode}'); } } Map<String, String> createHttpRequestHeader({bool isJsonRpc = false}) { final headers = <String, String>{ 'Accept': 'application/json', 'Content-Type': isJsonRpc ? 'application/json' : 'application/x-www-form-urlencoded', }; final token = getAccessToken(); // SharedPreferences等から取得 if (token != null) { headers['Authorization'] = 'Bearer $token'; } return headers; } Future<String?> getAccessToken() async { final prefs = await SharedPreferences.getInstance(); return prefs.getString('access_token'); } Future<void> setAccessToken(String token) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString('access_token', token); } ※ パスについて別途質問し、共通関数のファイルパスは変更無しとした。~ ・https://github.com/OpenTouryoProject/FrontendTemplates/blob/develop/UI/XPlat/flutter_template/lib/common.dart~ ・https://github.com/OpenTouryoProject/FrontendTemplates/blob/develop/UI/XPlat/flutter_template/lib/touryo/common.dart~ ・https://github.com/OpenTouryoProject/FrontendTemplates/blob/develop/UI/XPlat/flutter_template/lib/touryo/oauth_oidc.dart~ ※ 他にも、以下の必要になるファイルを移行した。~ ・https://github.com/OpenTouryoProject/FrontendTemplates/blob/develop/UI/XPlat/flutter_template/lib/importer.dart~ ※ 「SharedPreferences」の導入には以下の手順が必要だった。~ start ms-settings:developers Developer Mode の有効化 flutter pub add shared_preferences -共通関数の利用 --プロンプト ```dart Future<void> _fetchForecasts(int page) async { setState(() { _loading = true; _errorMessage = null; }); final uri = Uri.parse('${_fetchDataRootUrl}startDateIndex=$page'); try { final response = await http.get( uri, headers: { 'Content-Type': 'application/json', // 'Authorization': 'Bearer <token>', // 認証トークンが必要な場合 }, ); if (response.statusCode == 200) { final List<dynamic> jsonList = jsonDecode(response.body) as List<dynamic>; final forecasts = jsonList .map((e) => WeatherForecast.fromJson(e as Map<String, dynamic>)) .toList(); setState(() { _forecasts = forecasts; _currentPage = page; _loading = false; }); } else { throw Exception('HTTP ${response.statusCode}'); } } catch (e) { debugPrint('fetch error: $e'); setState(() { _errorMessage = e.toString(); _loading = false; }); } } ``` を、以下を使用するように書き換えて下さい ```dart Future<void> getFetch( String url, Map<String, String> headers, void Function(dynamic data) onSuccess, void Function(String msg) setMessage, ) async { setMessage(''); try { final response = fetchStatusHandler( await http.get(Uri.parse(url), headers: headers), ); final data = jsonDecode(response.body) as Map<String, dynamic>; if (data['errorMSG'] != null) { setMessage(jsonEncode(data['errorMSG'])); } else if (data['exceptionMSG'] != null) { setMessage(jsonEncode(data['exceptionMSG'])); } else { onSuccess(data); } } catch (e) { setMessage(e.toString()); } } ``` --出力 Future<void> _fetchForecasts(int page) async { setState(() { _loading = true; _errorMessage = null; }); await getFetch( '${_fetchDataRootUrl}startDateIndex=$page', {'Content-Type': 'application/json'}, (data) { final jsonList = data as List<dynamic>; final forecasts = jsonList .map((e) => WeatherForecast.fromJson(e as Map<String, dynamic>)) .toList(); setState(() { _forecasts = forecasts; _currentPage = page; }); }, (msg) { setState(() { _errorMessage = msg; }); }, ); setState(() { _loading = false; }); } ※ ココからいくらか手直しをした。詳しくは下記とのDiffで確認できる。~ https://github.com/OpenTouryoProject/FrontendTemplates/blob/develop/UI/XPlat/flutter_template/lib/screens/fetch_data.dart **CRUD [#ed359dfc] プロンプト 添付のReactプロジェクトのコードをFlutterに移植します。 ※ 添付のReactプロジェクトのコード:以下の「移行元」のファイル ***共通部品の移行 [#m0217540] 先ずは以下のファイルを移行(AIで旧記法を修正して移行) -移行元 --https://github.com/OpenTouryoProject/FrontendTemplates/blob/develop/UI/XPlat/old/flutter_template/lib/common/my_elevatedbutton.dart --https://github.com/OpenTouryoProject/FrontendTemplates/blob/develop/UI/XPlat/old/flutter_template/lib/common/my_dropdownbutton.dart -移行先 --https://github.com/OpenTouryoProject/FrontendTemplates/blob/develop/UI/XPlat/flutter_template/lib/widgets/my_elevated_button.dart --https://github.com/OpenTouryoProject/FrontendTemplates/blob/develop/UI/XPlat/flutter_template/lib/widgets/my_dropdown_button.dart ***CRUDページの移行 [#ub0099f7] main_layout.dartからリンクするという記載を怠ったため「appBar: AppBar()」、「drawer: MyDrawer()」を手動で削除するハメに。 -移行元:https://github.com/OpenTouryoProject/FrontendTemplates/blob/develop/UI/XPlat/old/flutter_template/lib/components/crud_page.dart -移行先:https://github.com/OpenTouryoProject/FrontendTemplates/blob/develop/UI/XPlat/flutter_template/lib/screens/crud_sample.dart ***共通関数の利用 [#u79caa43] -プロンプト 以下のコードを書き換えます ``` Future<void> _selectCount() async { var url = Uri.http(AppConfig.serverFqdn, 'ASPNETWebService/api/json/SelectCount',); var response = await http.post(url, headers: { "Accept": "application/json", "Content-Type": "application/x-www-form-urlencoded", "Authorization": "Bearer ${AppAuth.accessToken}", }, body: { "ddlDap" : this._ddlDap, "ddlMode1" : this._ddlMode1, "ddlMode2": this._ddlMode2, "ddlExRollback" : this._ddlExRollback, } ); if (response.statusCode == 200) { Map<String, dynamic> jsonResponse = jsonDecode(response.body) as Map<String, dynamic>; setState(() { this._display = jsonResponse['message']; }); } else { print('Request failed with status: ${response.statusCode}.'); } } ``` 以下の関数を使用します ``` Future<void> postFetch( String url, Map<String, String> headers, Object body, void Function(dynamic data) onSuccess, void Function(String msg) setMessage, ) async { try { final response = fetchStatusHandler( await http.post(Uri.parse(url), headers: headers, body: body), ); final decoded = jsonDecode(response.body); // Map の場合のみエラーキーをチェック if (decoded is Map<String, dynamic>) { if (decoded['errorMSG'] != null) { setMessage(jsonEncode(decoded['errorMSG'])); } else if (decoded['exceptionMSG'] != null) { setMessage(jsonEncode(decoded['exceptionMSG'])); } else { onSuccess(decoded); } } else { // List など Map 以外はそのまま onSuccess へ onSuccess(decoded); } } catch (e) { setMessage(e.toString()); } } ``` ``` Future<Map<String, String>> createHttpRequestHeader({bool isJsonRpc = false}) async { final headers = <String, String>{ 'Accept': 'application/json', 'Content-Type': isJsonRpc ? 'application/json' : 'application/x-www-form-urlencoded', }; final token = await getAccessToken(); // ← await を追加 if (token != null) { headers['Authorization'] = 'Bearer $token'; } return headers; } Future<String?> getAccessToken() async { final prefs = await SharedPreferences.getInstance(); return prefs.getString('access_token'); } Future<void> setAccessToken(String token) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString('access_token', token); } ``` -出力 Future<void> _selectCount() async { final url = Uri.http( AppConfig.serverFqdn, 'ASPNETWebService/api/json/SelectCount', ).toString(); final headers = await createHttpRequestHeader(); final body = { "ddlDap": this._ddlDap, "ddlMode1": this._ddlMode1, "ddlMode2": this._ddlMode2, "ddlExRollback": this._ddlExRollback, }; await postFetch( url, headers, body, (data) { setState(() { this._display = (data as Map<String, dynamic>)['message']; }); }, (msg) { setState(() { this._display = msg; }); }, ); } ※ 同様に「_selectAll」「_crud」も書き換え。~ ※ Uri.http は扱い辛かったので Uri.parse に変更。 ***デザインの修正 [#c337487e] -プロンプト~ build の body部のコードを添付 添付で、DataTable を MyDropdownButtonの並びの右側のSpaceに配置するには? -出力 --この生成は、それっぽい生成は出来たが貼り付けるとエラーになった。 --最終的には、Column(MyDropdownButtons)を「Column→Row」で囲い~ →Expanded(Column(DataTable))と横並びにする事で上手く行った。 -その他、以下のようなプロンプトで修正を行った --添付のDataTableを中央揃えしたいが左寄りになる。コレは、領域が画面端まで伸びず左寄りになっているためだろうか? --Flutterで「Column(」に続けて左寄せするにはどのようにしたらよいか?(MyDropdownButtons) ※ 詳細は割愛しているが、カナリ苦戦。生成AIもマークアップ系のネストは苦手である模様。~ ※ 最終的には修正方法を出力させて人間の頭で理解しながら修正する必要がある模様。 *参考 [#ta9801c7] -FrontendTemplates/UI/XPlat at develop · OpenTouryoProject/FrontendTemplates~ https://github.com/OpenTouryoProject/FrontendTemplates/tree/develop/UI/XPlat/ --https://github.com/OpenTouryoProject/FrontendTemplates/tree/develop/UI/XPlat/flutter_template --https://github.com/OpenTouryoProject/FrontendTemplates/tree/develop/UI/XPlat/old/flutter_template