diff --git a/examples/fwe/wikipedia_reader/.gitignore b/examples/fwe/wikipedia_reader/.gitignore new file mode 100644 index 0000000000..3820a95c65 --- /dev/null +++ b/examples/fwe/wikipedia_reader/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/examples/fwe/wikipedia_reader/.metadata b/examples/fwe/wikipedia_reader/.metadata new file mode 100644 index 0000000000..c9627623ee --- /dev/null +++ b/examples/fwe/wikipedia_reader/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "e308d690a1193ea0d93d62aad6efe48e5994bc3c" + channel: "[user-branch]" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: e308d690a1193ea0d93d62aad6efe48e5994bc3c + base_revision: e308d690a1193ea0d93d62aad6efe48e5994bc3c + - platform: android + create_revision: e308d690a1193ea0d93d62aad6efe48e5994bc3c + base_revision: e308d690a1193ea0d93d62aad6efe48e5994bc3c + - platform: ios + create_revision: e308d690a1193ea0d93d62aad6efe48e5994bc3c + base_revision: e308d690a1193ea0d93d62aad6efe48e5994bc3c + - platform: linux + create_revision: e308d690a1193ea0d93d62aad6efe48e5994bc3c + base_revision: e308d690a1193ea0d93d62aad6efe48e5994bc3c + - platform: macos + create_revision: e308d690a1193ea0d93d62aad6efe48e5994bc3c + base_revision: e308d690a1193ea0d93d62aad6efe48e5994bc3c + - platform: web + create_revision: e308d690a1193ea0d93d62aad6efe48e5994bc3c + base_revision: e308d690a1193ea0d93d62aad6efe48e5994bc3c + - platform: windows + create_revision: e308d690a1193ea0d93d62aad6efe48e5994bc3c + base_revision: e308d690a1193ea0d93d62aad6efe48e5994bc3c + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/examples/fwe/wikipedia_reader/README.md b/examples/fwe/wikipedia_reader/README.md new file mode 100644 index 0000000000..2a1fac6679 --- /dev/null +++ b/examples/fwe/wikipedia_reader/README.md @@ -0,0 +1,3 @@ +# wikipedia_reader + +A new Flutter project. diff --git a/examples/fwe/wikipedia_reader/analysis_options.yaml b/examples/fwe/wikipedia_reader/analysis_options.yaml new file mode 100644 index 0000000000..f9b303465f --- /dev/null +++ b/examples/fwe/wikipedia_reader/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/examples/fwe/wikipedia_reader/lib/main.dart b/examples/fwe/wikipedia_reader/lib/main.dart new file mode 100644 index 0000000000..67a457a2e8 --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/main.dart @@ -0,0 +1,167 @@ +// ignore_for_file: avoid_dynamic_calls + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart'; + +import 'summary.dart'; + +void main() { + runApp(const MainApp()); +} + +class MainApp extends StatelessWidget { + const MainApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: ArticleView()); + } +} + +class ArticleModel { + Future getRandomArticleSummary() async { + final uri = Uri.https( + 'en.wikipedia.org', + '/api/rest_v1/page/random/summary', + ); + final response = await get(uri); + + if (response.statusCode != 200) { + throw const HttpException('Failed to update resource'); + } + + return Summary.fromJson(jsonDecode(response.body) as Map); + } +} + +class ArticleViewModel extends ChangeNotifier { + ArticleViewModel(this.model); + final ArticleModel model; + + Summary? summary; + + bool isLoading = false; + + Exception? error; + + Future fetchArticle() async { + isLoading = true; + error = null; + notifyListeners(); + + try { + summary = await model.getRandomArticleSummary(); + error = null; + } on Exception catch (e) { + error = e; + } finally { + isLoading = false; + notifyListeners(); + } + } +} + +class ArticleView extends StatefulWidget { + const ArticleView({super.key}); + + @override + State createState() => _ArticleViewState(); +} + +class _ArticleViewState extends State { + late final ArticleViewModel viewModel; + + @override + void initState() { + super.initState(); + viewModel = ArticleViewModel(ArticleModel()); + viewModel.fetchArticle(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Wikipedia Flutter')), + body: Center( + child: ListenableBuilder( + listenable: viewModel, + builder: (context, _) { + return switch (( + viewModel.isLoading, + viewModel.summary, + viewModel.error, + )) { + (true, _, _) => const CircularProgressIndicator(), + (_, final summary?, _) => ArticlePage( + summary: summary, + nextArticleCallback: viewModel.fetchArticle, + ), + (_, _, final Exception e) => Text('Error: $e'), + _ => const Text('Something went wrong!'), + }; + }, + ), + ), + ); + } +} + +class ArticlePage extends StatelessWidget { + const ArticlePage({ + super.key, + required this.summary, + required this.nextArticleCallback, + }); + + final Summary summary; + final VoidCallback nextArticleCallback; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Column( + children: [ + ArticleWidget(summary: summary), + ElevatedButton( + onPressed: nextArticleCallback, + child: const Text('Next random article'), + ), + ], + ), + ); + } +} + +class ArticleWidget extends StatelessWidget { + const ArticleWidget({super.key, required this.summary}); + + final Summary summary; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + spacing: 10.0, + children: [ + if (summary.hasImage) Image.network(summary.originalImage!.source), + Text( + summary.titles.normalized, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.displaySmall, + ), + if (summary.description != null) + Text( + summary.description!, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + Text(summary.extract), + ], + ), + ); + } +} diff --git a/examples/fwe/wikipedia_reader/lib/step1_main.dart b/examples/fwe/wikipedia_reader/lib/step1_main.dart new file mode 100644 index 0000000000..affee140a0 --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/step1_main.dart @@ -0,0 +1,34 @@ +// ignore_for_file: unused_import + +// #docregion All +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart'; + +import 'summary.dart'; + +// #docregion main +void main() { + runApp(const MainApp()); +} +// #enddocregion main + +// #docregion MainApp +class MainApp extends StatelessWidget { + const MainApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Wikipedia Flutter')), + body: const Center(child: Text('Loading...')), + ), + ); + } +} +// #enddocregion MainApp + +// #enddocregion All diff --git a/examples/fwe/wikipedia_reader/lib/step2_main.dart b/examples/fwe/wikipedia_reader/lib/step2_main.dart new file mode 100644 index 0000000000..8ffa84c303 --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/step2_main.dart @@ -0,0 +1,46 @@ +// ignore_for_file: unused_import, avoid_dynamic_calls + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart'; + +import 'summary.dart'; + +void main() { + runApp(const MainApp()); +} + +class MainApp extends StatelessWidget { + const MainApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Wikipedia Flutter')), + body: const Center(child: Text('Loading...')), + ), + ); + } +} + +// #docregion ArticleModel +class ArticleModel { + Future getRandomArticleSummary() async { + final uri = Uri.https( + 'en.wikipedia.org', + '/api/rest_v1/page/random/summary', + ); + final response = await get(uri); + + if (response.statusCode != 200) { + throw HttpException('Failed to update resource'); + } + + return Summary.fromJson(jsonDecode(response.body) as Map); + } +} + +// #enddocregion ArticleModel diff --git a/examples/fwe/wikipedia_reader/lib/step2a_main.dart b/examples/fwe/wikipedia_reader/lib/step2a_main.dart new file mode 100644 index 0000000000..ca172369fd --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/step2a_main.dart @@ -0,0 +1,34 @@ +// ignore_for_file: unused_import + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart'; + +import 'summary.dart'; + +void main() { + runApp(const MainApp()); +} + +class MainApp extends StatelessWidget { + const MainApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Wikipedia Flutter')), + body: const Center(child: Text('Loading...')), + ), + ); + } +} + +// #docregion ArticleModel +class ArticleModel { + // Properties and methods will be added here. +} + +// #enddocregion ArticleModel diff --git a/examples/fwe/wikipedia_reader/lib/step2b_main.dart b/examples/fwe/wikipedia_reader/lib/step2b_main.dart new file mode 100644 index 0000000000..bd22fb7d96 --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/step2b_main.dart @@ -0,0 +1,43 @@ +// ignore_for_file: unused_import, unused_local_variable + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart'; + +import 'summary.dart'; + +void main() { + runApp(const MainApp()); +} + +class MainApp extends StatelessWidget { + const MainApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Wikipedia Flutter')), + body: const Center(child: Text('Loading...')), + ), + ); + } +} + +// #docregion ArticleModel +class ArticleModel { + Future getRandomArticleSummary() async { + final uri = Uri.https( + 'en.wikipedia.org', + '/api/rest_v1/page/random/summary', + ); + final response = await get(uri); + + // TODO: Add error handling and JSON parsing. + throw UnimplementedError(); + } +} + +// #enddocregion ArticleModel diff --git a/examples/fwe/wikipedia_reader/lib/step2c_main.dart b/examples/fwe/wikipedia_reader/lib/step2c_main.dart new file mode 100644 index 0000000000..00e253b4bd --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/step2c_main.dart @@ -0,0 +1,47 @@ +// ignore_for_file: unused_import + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart'; + +import 'summary.dart'; + +void main() { + runApp(const MainApp()); +} + +class MainApp extends StatelessWidget { + const MainApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Wikipedia Flutter')), + body: const Center(child: Text('Loading...')), + ), + ); + } +} + +// #docregion ArticleModel +class ArticleModel { + Future getRandomArticleSummary() async { + final uri = Uri.https( + 'en.wikipedia.org', + '/api/rest_v1/page/random/summary', + ); + final response = await get(uri); + + if (response.statusCode != 200) { + throw const HttpException('Failed to update resource'); + } + + // TODO: Parse JSON and return Summary. + throw UnimplementedError(); + } +} + +// #enddocregion ArticleModel diff --git a/examples/fwe/wikipedia_reader/lib/step3_main.dart b/examples/fwe/wikipedia_reader/lib/step3_main.dart new file mode 100644 index 0000000000..bb0dd4e88b --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/step3_main.dart @@ -0,0 +1,71 @@ +// ignore_for_file: unused_import, avoid_dynamic_calls + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart'; + +import 'summary.dart'; + +void main() { + runApp(const MainApp()); +} + +class MainApp extends StatelessWidget { + const MainApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Wikipedia Flutter')), + body: const Center(child: Text('Loading...')), + ), + ); + } +} + +class ArticleModel { + Future getRandomArticleSummary() async { + final uri = Uri.https( + 'en.wikipedia.org', + '/api/rest_v1/page/random/summary', + ); + final response = await get(uri); + + if (response.statusCode != 200) { + throw const HttpException('Failed to update resource'); + } + + return Summary.fromJson(jsonDecode(response.body) as Map); + } +} + +class ArticleViewModel extends ChangeNotifier { + ArticleViewModel({required this.model}); + final ArticleModel model; + + Summary? _summary; + Summary? get summary => _summary; + bool _isLoading = false; + bool get isLoading => _isLoading; + Exception? _error; + Exception? get error => _error; + + Future fetchArticle() async { + _isLoading = true; + _error = null; + notifyListeners(); + + try { + _summary = await model.getRandomArticleSummary(); + _error = null; + } on Exception catch (e) { + _error = e; + } finally { + _isLoading = false; + notifyListeners(); + } + } +} diff --git a/examples/fwe/wikipedia_reader/lib/step3a_main.dart b/examples/fwe/wikipedia_reader/lib/step3a_main.dart new file mode 100644 index 0000000000..b25e824c85 --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/step3a_main.dart @@ -0,0 +1,42 @@ +// ignore_for_file: unused_import, avoid_dynamic_calls + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart'; + +import 'summary.dart'; + +void main() { + runApp(const MainApp()); +} + +class MainApp extends StatelessWidget { + const MainApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold(body: Center(child: Text('Loading...'))), + ); + } +} + +class ArticleModel { + Future getRandomArticleSummary() async { + return throw UnimplementedError(); + } +} + +// #docregion ArticleViewModel +class ArticleViewModel extends ChangeNotifier { + final ArticleModel model; + Summary? summary; + Exception? error; + bool isLoading = false; + + ArticleViewModel(this.model); +} + +// #enddocregion ArticleViewModel diff --git a/examples/fwe/wikipedia_reader/lib/step3b_main.dart b/examples/fwe/wikipedia_reader/lib/step3b_main.dart new file mode 100644 index 0000000000..f457a60ba9 --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/step3b_main.dart @@ -0,0 +1,32 @@ +// ignore_for_file: unused_import, avoid_dynamic_calls + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart'; + +import 'summary.dart'; + +class ArticleModel { + Future getRandomArticleSummary() async { + throw UnimplementedError(); + } +} + +// #docregion ArticleViewModel +class ArticleViewModel extends ChangeNotifier { + final ArticleModel model; + Summary? summary; + Exception? error; + bool isLoading = false; + + ArticleViewModel(this.model) { + fetchArticle(); + } + + // Methods will be added next. + Future fetchArticle() async {} +} + +// #enddocregion ArticleViewModel diff --git a/examples/fwe/wikipedia_reader/lib/step3c_main.dart b/examples/fwe/wikipedia_reader/lib/step3c_main.dart new file mode 100644 index 0000000000..88a9516b63 --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/step3c_main.dart @@ -0,0 +1,34 @@ +// ignore_for_file: unused_import, avoid_dynamic_calls + +import 'package:flutter/material.dart'; +import 'summary.dart'; + +class ArticleModel { + Future getRandomArticleSummary() async { + throw UnimplementedError(); + } +} + +// #docregion ArticleViewModel +class ArticleViewModel extends ChangeNotifier { + final ArticleModel model; + Summary? summary; + Exception? error; + bool isLoading = false; + + ArticleViewModel(this.model) { + fetchArticle(); + } + + Future fetchArticle() async { + isLoading = true; + notifyListeners(); + + // TODO: Add data fetching logic + + isLoading = false; + notifyListeners(); + } +} + +// #enddocregion ArticleViewModel diff --git a/examples/fwe/wikipedia_reader/lib/step3d_main.dart b/examples/fwe/wikipedia_reader/lib/step3d_main.dart new file mode 100644 index 0000000000..67065479c7 --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/step3d_main.dart @@ -0,0 +1,39 @@ +// ignore_for_file: unused_import, avoid_dynamic_calls + +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'summary.dart'; + +class ArticleModel { + Future getRandomArticleSummary() async { + throw UnimplementedError(); + } +} + +// #docregion ArticleViewModel +class ArticleViewModel extends ChangeNotifier { + final ArticleModel model; + Summary? summary; + Exception? error; + bool isLoading = false; + + ArticleViewModel(this.model) { + fetchArticle(); + } + + Future fetchArticle() async { + isLoading = true; + notifyListeners(); + try { + summary = await model.getRandomArticleSummary(); + error = null; // Clear any previous errors. + } on HttpException catch (e) { + error = e; + summary = null; + } + isLoading = false; + notifyListeners(); + } +} + +// #enddocregion ArticleViewModel diff --git a/examples/fwe/wikipedia_reader/lib/step3e_main.dart b/examples/fwe/wikipedia_reader/lib/step3e_main.dart new file mode 100644 index 0000000000..edc069525c --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/step3e_main.dart @@ -0,0 +1,41 @@ +// ignore_for_file: unused_import, avoid_dynamic_calls, avoid_print + +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'summary.dart'; + +class ArticleModel { + Future getRandomArticleSummary() async { + throw UnimplementedError(); + } +} + +class ArticleViewModel extends ChangeNotifier { + final ArticleModel model; + Summary? summary; + Exception? error; + bool isLoading = false; + + ArticleViewModel(this.model) { + fetchArticle(); + } + + // #docregion fetchArticle + Future fetchArticle() async { + isLoading = true; + notifyListeners(); + try { + summary = await model.getRandomArticleSummary(); + print('Article loaded: ${summary!.titles.normalized}'); // Temporary + error = null; // Clear any previous errors. + } on HttpException catch (e) { + print('Error loading article: ${e.message}'); // Temporary + error = e; + summary = null; + } + isLoading = false; + notifyListeners(); + } + + // #enddocregion fetchArticle +} diff --git a/examples/fwe/wikipedia_reader/lib/step3f_main.dart b/examples/fwe/wikipedia_reader/lib/step3f_main.dart new file mode 100644 index 0000000000..7208169e83 --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/step3f_main.dart @@ -0,0 +1,34 @@ +// ignore_for_file: unused_local_variable +// ignore_for_file: unused_import, avoid_dynamic_calls + +import 'package:flutter/material.dart'; +import 'summary.dart'; + +class ArticleModel {} + +class ArticleViewModel { + ArticleViewModel(ArticleModel model); +} + +// #docregion MainApp +class MainApp extends StatelessWidget { + const MainApp({super.key}); + + @override + Widget build(BuildContext context) { + // Instantiate your `ArticleViewModel` to test its HTTP requests. + final viewModel = ArticleViewModel(ArticleModel()); + + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Wikipedia Flutter')), + body: const Center(child: Text('Check console for article data')), + ), + ); + } +} +// #enddocregion MainApp + +void main() { + runApp(const MainApp()); +} diff --git a/examples/fwe/wikipedia_reader/lib/step4_main.dart b/examples/fwe/wikipedia_reader/lib/step4_main.dart new file mode 100644 index 0000000000..4ed212ac64 --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/step4_main.dart @@ -0,0 +1,174 @@ +// ignore_for_file: avoid_dynamic_calls + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart'; + +import 'summary.dart'; + +void main() { + runApp(const MainApp()); +} + +// #docregion MainApp +class MainApp extends StatelessWidget { + const MainApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: ArticleView()); + } +} +// #enddocregion MainApp + +class ArticleModel { + Future getRandomArticleSummary() async { + final uri = Uri.https( + 'en.wikipedia.org', + '/api/rest_v1/page/random/summary', + ); + final response = await get(uri); + + if (response.statusCode != 200) { + throw const HttpException('Failed to update resource'); + } + + return Summary.fromJson(jsonDecode(response.body) as Map); + } +} + +class ArticleViewModel extends ChangeNotifier { + ArticleViewModel(this.model); + final ArticleModel model; + + Summary? summary; + + bool isLoading = false; + + Exception? error; + + Future fetchArticle() async { + isLoading = true; + error = null; + notifyListeners(); + + try { + summary = await model.getRandomArticleSummary(); + error = null; + } on Exception catch (e) { + error = e; + } finally { + isLoading = false; + notifyListeners(); + } + } +} + +class ArticleView extends StatefulWidget { + const ArticleView({super.key}); + + @override + State createState() => _ArticleViewState(); +} + +class _ArticleViewState extends State { + late final ArticleViewModel viewModel; + + @override + void initState() { + super.initState(); + viewModel = ArticleViewModel(ArticleModel()); + viewModel.fetchArticle(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Wikipedia Flutter')), + body: Center( + child: ListenableBuilder( + listenable: viewModel, + builder: (context, _) { + return switch (( + viewModel.isLoading, + viewModel.summary, + viewModel.error, + )) { + (true, _, _) => const CircularProgressIndicator(), + (_, final summary?, _) => ArticlePage( + summary: summary, + nextArticleCallback: viewModel.fetchArticle, + ), + (_, _, final Exception e) => Text('Error: $e'), + _ => const Text('Something went wrong!'), + }; + }, + ), + ), + ); + } +} + +class ArticlePage extends StatelessWidget { + const ArticlePage({ + super.key, + required this.summary, + required this.nextArticleCallback, + }); + + final Summary summary; + final VoidCallback nextArticleCallback; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Column( + children: [ + ArticleWidget(summary: summary), + ElevatedButton( + onPressed: nextArticleCallback, + child: const Text('Next random article'), + ), + ], + ), + ); + } +} + +class ArticleWidget extends StatelessWidget { + const ArticleWidget({super.key, required this.summary}); + + final Summary summary; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + if (summary.hasImage) ...[ + Image.network(summary.originalImage!.source), + const SizedBox(height: 10.0), + ], + Text( + summary.titles.normalized, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.displaySmall, + ), + const SizedBox(height: 10.0), + if (summary.description != null) ...[ + Text( + summary.description!, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 10.0), + ], + Text(summary.extract), + ], + ), + ); + } +} diff --git a/examples/fwe/wikipedia_reader/lib/step4a_main.dart b/examples/fwe/wikipedia_reader/lib/step4a_main.dart new file mode 100644 index 0000000000..2d08e714e9 --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/step4a_main.dart @@ -0,0 +1,25 @@ +// ignore_for_file: unused_import + +import 'package:flutter/material.dart'; + +// #docregion ArticleView +class ArticleView extends StatefulWidget { + const ArticleView({super.key}); + + @override + State createState() => _ArticleViewState(); +} + +class _ArticleViewState extends State { + // viewModel will be instantiated next + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Wikipedia Flutter')), + body: const Center(child: Text('Loading...')), + ); + } +} + +// #enddocregion ArticleView diff --git a/examples/fwe/wikipedia_reader/lib/step4b_main.dart b/examples/fwe/wikipedia_reader/lib/step4b_main.dart new file mode 100644 index 0000000000..0862a870ce --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/step4b_main.dart @@ -0,0 +1,73 @@ +// ignore_for_file: unused_import + +import 'package:flutter/material.dart'; +import 'summary.dart'; + +class ArticleViewModel extends ChangeNotifier { + ArticleViewModel(ArticleModel model); + bool get isLoading => false; + Summary? get summary => null; + Exception? get error => null; + Future fetchArticle() async {} +} + +class ArticleModel {} + +// #docregion ArticleView +class ArticleView extends StatefulWidget { + const ArticleView({super.key}); + + @override + State createState() => _ArticleViewState(); +} + +class _ArticleViewState extends State { + late final ArticleViewModel viewModel; + + @override + void initState() { + super.initState(); + viewModel = ArticleViewModel(ArticleModel()); + viewModel.fetchArticle(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Wikipedia Flutter')), + body: Center( + child: ListenableBuilder( + listenable: viewModel, + builder: (context, _) { + return switch (( + viewModel.isLoading, + viewModel.summary, + viewModel.error, + )) { + (true, _, _) => const CircularProgressIndicator(), + (_, final summary?, _) => ArticlePage( + summary: summary, + nextArticleCallback: viewModel.fetchArticle, + ), + (_, _, final Exception e) => Text('Error: $e'), + _ => const Text('Something went wrong!'), + }; + }, + ), + ), + ); + } +} +// #enddocregion ArticleView + +class ArticlePage extends StatelessWidget { + const ArticlePage({ + super.key, + required this.summary, + required this.nextArticleCallback, + }); + final Summary summary; + final VoidCallback nextArticleCallback; + @override + Widget build(BuildContext context) => const Placeholder(); +} diff --git a/examples/fwe/wikipedia_reader/lib/step4c_main.dart b/examples/fwe/wikipedia_reader/lib/step4c_main.dart new file mode 100644 index 0000000000..c61c9829aa --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/step4c_main.dart @@ -0,0 +1,27 @@ +// ignore_for_file: unused_import + +import 'package:flutter/material.dart'; +import 'summary.dart'; + +// #docregion ArticlePage +class ArticlePage extends StatelessWidget { + const ArticlePage({ + super.key, + required this.summary, + required this.nextArticleCallback, + }); + + final Summary summary; + final VoidCallback nextArticleCallback; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Column( + children: [const Text('Article content will be displayed here')], + ), + ); + } +} + +// #enddocregion ArticlePage diff --git a/examples/fwe/wikipedia_reader/lib/step4d_main.dart b/examples/fwe/wikipedia_reader/lib/step4d_main.dart new file mode 100644 index 0000000000..abc61fbffe --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/step4d_main.dart @@ -0,0 +1,40 @@ +// ignore_for_file: unused_import, prefer_const_constructors, prefer_const_literals_to_create_immutables + +import 'package:flutter/material.dart'; +import 'summary.dart'; + +class ArticleWidget extends StatelessWidget { + const ArticleWidget({super.key, required this.summary}); + final Summary summary; + @override + Widget build(BuildContext context) => const Placeholder(); +} + +// #docregion ArticlePage +class ArticlePage extends StatelessWidget { + const ArticlePage({ + super.key, + required this.summary, + required this.nextArticleCallback, + }); + + final Summary summary; + final VoidCallback nextArticleCallback; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Column( + children: [ + ArticleWidget(summary: summary), + ElevatedButton( + onPressed: nextArticleCallback, + child: const Text('Next random article'), + ), + ], + ), + ); + } +} + +// #enddocregion ArticlePage diff --git a/examples/fwe/wikipedia_reader/lib/step4e_main.dart b/examples/fwe/wikipedia_reader/lib/step4e_main.dart new file mode 100644 index 0000000000..db908d2b68 --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/step4e_main.dart @@ -0,0 +1,18 @@ +// ignore_for_file: unused_import, prefer_const_constructors, prefer_const_literals_to_create_immutables + +import 'package:flutter/material.dart'; +import 'summary.dart'; + +// #docregion ArticleWidget +class ArticleWidget extends StatelessWidget { + const ArticleWidget({super.key, required this.summary}); + + final Summary summary; + + @override + Widget build(BuildContext context) { + return const Text('Article content will be displayed here'); + } +} + +// #enddocregion ArticleWidget diff --git a/examples/fwe/wikipedia_reader/lib/step4f_main.dart b/examples/fwe/wikipedia_reader/lib/step4f_main.dart new file mode 100644 index 0000000000..5d22ba9884 --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/step4f_main.dart @@ -0,0 +1,23 @@ +// ignore_for_file: unused_import, prefer_const_constructors, prefer_const_literals_to_create_immutables + +import 'package:flutter/material.dart'; +import 'summary.dart'; + +// #docregion ArticleWidget +class ArticleWidget extends StatelessWidget { + const ArticleWidget({super.key, required this.summary}); + + final Summary summary; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [const Text('Article content will be displayed here')], + ), + ); + } +} + +// #enddocregion ArticleWidget diff --git a/examples/fwe/wikipedia_reader/lib/step4g_main.dart b/examples/fwe/wikipedia_reader/lib/step4g_main.dart new file mode 100644 index 0000000000..63acce0bf5 --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/step4g_main.dart @@ -0,0 +1,29 @@ +// ignore_for_file: unused_import, prefer_const_constructors, prefer_const_literals_to_create_immutables + +import 'package:flutter/material.dart'; +import 'summary.dart'; + +// #docregion ArticleWidget +class ArticleWidget extends StatelessWidget { + const ArticleWidget({super.key, required this.summary}); + + final Summary summary; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + if (summary.hasImage) ...[ + Image.network(summary.originalImage!.source), + const SizedBox(height: 10.0), + ], + const Text('Article content will be displayed here'), + ], + ), + ); + } +} + +// #enddocregion ArticleWidget diff --git a/examples/fwe/wikipedia_reader/lib/step4h_main.dart b/examples/fwe/wikipedia_reader/lib/step4h_main.dart new file mode 100644 index 0000000000..b61f400f24 --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/step4h_main.dart @@ -0,0 +1,43 @@ +// ignore_for_file: unused_import + +import 'package:flutter/material.dart'; +import 'summary.dart'; + +// #docregion ArticleWidget +class ArticleWidget extends StatelessWidget { + const ArticleWidget({super.key, required this.summary}); + + final Summary summary; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + if (summary.hasImage) ...[ + Image.network(summary.originalImage!.source), + const SizedBox(height: 10.0), + ], + Text( + summary.titles.normalized, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.displaySmall, + ), + const SizedBox(height: 10.0), + if (summary.description != null) ...[ + Text( + summary.description!, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 10.0), + ], + Text(summary.extract), + ], + ), + ); + } +} + +// #enddocregion ArticleWidget diff --git a/examples/fwe/wikipedia_reader/lib/summary.dart b/examples/fwe/wikipedia_reader/lib/summary.dart new file mode 100644 index 0000000000..e7b853db09 --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/summary.dart @@ -0,0 +1,252 @@ +// ignore_for_file: directives_ordering + +// #docregion All + +// Representation of the JSON data returned by the Wikipedia API. +// #docregion Summary +class Summary { + /// Returns a new [Summary] instance. + Summary({ + required this.titles, + required this.pageid, + required this.extract, + required this.extractHtml, + this.thumbnail, + this.originalImage, + required this.lang, + required this.dir, + this.description, + required this.url, + }); + + /// Titles + final TitlesSet titles; + + /// Page ID + final int pageid; + + /// Extract + final String extract; + + /// Extract HTML + final String extractHtml; + + /// Thumbnail image + final ImageFile? thumbnail; + + /// Original image + final ImageFile? originalImage; + + /// Language + final String lang; + + /// Directionality + final String dir; + + /// Description + final String? description; + + /// URL + final String url; + + bool get hasImage => originalImage != null && thumbnail != null; + + /// Returns a new [Summary] instance and imports its values from a JSON map + static Summary fromJson(Map json) { + return switch (json) { + { + 'titles': final Map titles, + 'pageid': final int pageid, + 'extract': final String extract, + 'extract_html': final String extractHtml, + 'thumbnail': final Map thumbnail, + 'originalimage': final Map originalImage, + 'lang': final String lang, + 'dir': final String dir, + 'description': final String description, + 'content_urls': { + 'desktop': {'page': final String url}, + 'mobile': {'page': String _}, + }, + } => + Summary( + titles: TitlesSet.fromJson(titles), + pageid: pageid, + extract: extract, + extractHtml: extractHtml, + thumbnail: ImageFile.fromJson(thumbnail), + originalImage: ImageFile.fromJson(originalImage), + lang: lang, + dir: dir, + description: description, + url: url, + ), + { + 'titles': final Map titles, + 'pageid': final int pageid, + 'extract': final String extract, + 'extract_html': final String extractHtml, + 'lang': final String lang, + 'dir': final String dir, + 'description': final String description, + 'content_urls': { + 'desktop': {'page': final String url}, + 'mobile': {'page': String _}, + }, + } => + Summary( + titles: TitlesSet.fromJson(titles), + pageid: pageid, + extract: extract, + extractHtml: extractHtml, + lang: lang, + dir: dir, + description: description, + url: url, + ), + { + 'titles': final Map titles, + 'pageid': final int pageid, + 'extract': final String extract, + 'extract_html': final String extractHtml, + 'lang': final String lang, + 'dir': final String dir, + 'content_urls': { + 'desktop': {'page': final String url}, + 'mobile': {'page': String _}, + }, + } => + Summary( + titles: TitlesSet.fromJson(titles), + pageid: pageid, + extract: extract, + extractHtml: extractHtml, + lang: lang, + dir: dir, + url: url, + ), + _ => throw FormatException('Could not deserialize Summary, json=$json'), + }; + } + + @override + String toString() => + 'Summary[' + 'titles=$titles, ' + 'pageid=$pageid, ' + 'extract=$extract, ' + 'extractHtml=$extractHtml, ' + 'thumbnail=${thumbnail ?? 'null'}, ' + 'originalImage=${originalImage ?? 'null'}, ' + 'lang=$lang, ' + 'dir=$dir, ' + 'description=$description' + ']'; +} +// #enddocregion Summary + +// Image path and size, but doesn't contain any Wikipedia descriptions. +/// +/// For images with metadata, see [WikipediaImage] +// #docregion ImageFile +class ImageFile { + /// Returns a new [ImageFile] instance. + ImageFile({required this.source, required this.width, required this.height}); + + /// Original image URI + final String source; + + /// Original image width + final int width; + + /// Original image height + final int height; + + String get extension { + final extension = getFileExtension(source); + // by default, return a non-viable image extension + return extension ?? 'err'; + } + + Map toJson() { + return { + 'source': source, + 'width': width, + 'height': height, + }; + } + + /// Returns a new [ImageFile] instance + // ignore: prefer_constructors_over_static_methods + static ImageFile fromJson(Map json) { + if (json case { + 'source': final String source, + 'height': final int height, + 'width': final int width, + }) { + return ImageFile(source: source, width: width, height: height); + } + throw FormatException('Could not deserialize OriginalImage, json=$json'); + } + + @override + String toString() => + 'OriginalImage[source_=$source, width=$width, height=$height]'; +} +// #enddocregion ImageFile + +// #docregion TitlesSet +class TitlesSet { + /// Returns a new [TitlesSet] instance. + TitlesSet({ + required this.canonical, + required this.normalized, + required this.display, + }); + + /// the DB key (non-prefixed), e.g. may have _ instead of spaces, + /// best for making request URIs, still requires Percent-encoding + final String canonical; + + /// the normalized title (https://www.mediawiki.org/wiki/API:Query#Example_2:_Title_normalization), + /// e.g. may have spaces instead of _ + final String normalized; + + /// the title as it should be displayed to the user + final String display; + + /// Returns a new [TitlesSet] instance and imports its values from a JSON map + static TitlesSet fromJson(Map json) { + if (json case { + 'canonical': final String canonical, + 'normalized': final String normalized, + 'display': final String display, + }) { + return TitlesSet( + canonical: canonical, + normalized: normalized, + display: display, + ); + } + throw FormatException('Could not deserialize TitleSet, json=$json'); + } + + @override + String toString() => + 'TitlesSet[' + 'canonical=$canonical, ' + 'normalized=$normalized, ' + 'display=$display' + ']'; +} +// #enddocregion TitlesSet + +String? getFileExtension(String file) { + final segments = file.split('.'); + if (segments.isNotEmpty) return segments.last; + return null; +} + +const acceptableImageFormats = ['png', 'jpg', 'jpeg']; + +// #enddocregion All diff --git a/examples/fwe/wikipedia_reader/pubspec.yaml b/examples/fwe/wikipedia_reader/pubspec.yaml new file mode 100644 index 0000000000..2d1682fa42 --- /dev/null +++ b/examples/fwe/wikipedia_reader/pubspec.yaml @@ -0,0 +1,20 @@ +name: wikipedia_reader +description: "A new Flutter project." +publish_to: 'none' +version: 0.1.0+1 + +environment: + sdk: ^3.12.0-113.2.beta + +dependencies: + flutter: + sdk: flutter + http: ^1.6.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true diff --git a/examples/fwe/wikipedia_reader/test/widget_test.dart b/examples/fwe/wikipedia_reader/test/widget_test.dart new file mode 100644 index 0000000000..e8cb3289cc --- /dev/null +++ b/examples/fwe/wikipedia_reader/test/widget_test.dart @@ -0,0 +1,13 @@ +// ignore_for_file: avoid_relative_lib_imports +import 'package:flutter_test/flutter_test.dart'; +import '../lib/main.dart'; + +void main() { + testWidgets('Wikipedia Reader smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MainApp()); + + // Verify that the title is Wikipedia Flutter. + expect(find.text('Wikipedia Flutter'), findsOneWidget); + }); +} diff --git a/src/content/learn/pathway/tutorial/change-notifier.md b/src/content/learn/pathway/tutorial/change-notifier.md index 824dc464a6..306d04f842 100644 --- a/src/content/learn/pathway/tutorial/change-notifier.md +++ b/src/content/learn/pathway/tutorial/change-notifier.md @@ -42,12 +42,13 @@ which triggers UI rebuilds when called. Create the `ArticleViewModel` class with its basic structure and state properties: + ```dart class ArticleViewModel extends ChangeNotifier { final ArticleModel model; Summary? summary; - String? errorMessage; - bool loading = false; + Exception? error; + bool isLoading = false; ArticleViewModel(this.model); } @@ -56,26 +57,28 @@ class ArticleViewModel extends ChangeNotifier { The `ArticleViewModel` holds three pieces of state: - `summary`: The current Wikipedia article data. -- `errorMessage`: Any error that occurred during data fetching. -- `loading`: A flag to show progress indicators. +- `error`: Any error that occurred during data fetching. +- `isLoading`: A flag to show progress indicators. ### Add constructor initialization Update the constructor to automatically fetch content when the `ArticleViewModel` is created: + ```dart class ArticleViewModel extends ChangeNotifier { final ArticleModel model; Summary? summary; - String? errorMessage; - bool loading = false; + Exception? error; + bool isLoading = false; ArticleViewModel(this.model) { - getRandomArticleSummary(); + fetchArticle(); } // Methods will be added next. + Future fetchArticle() async {} } ``` @@ -84,69 +87,71 @@ a `ArticleViewModel` object is created. Because constructors can't be asynchronous, it delegates initial content fetching to a separate method. -### Set up the `getRandomArticleSummary` method +### Set up the `fetchArticle` method -Add the `getRandomArticleSummary` that fetches data and manages state updates: +Add the `fetchArticle` that fetches data and manages state updates: + ```dart class ArticleViewModel extends ChangeNotifier { final ArticleModel model; Summary? summary; - String? errorMessage; - bool loading = false; + Exception? error; + bool isLoading = false; ArticleViewModel(this.model) { - getRandomArticleSummary(); + fetchArticle(); } - Future getRandomArticleSummary() async { - loading = true; + Future fetchArticle() async { + isLoading = true; notifyListeners(); // TODO: Add data fetching logic - loading = false; + isLoading = false; notifyListeners(); } } ``` -The ViewModel updates the `loading` property and +The ViewModel updates the `isLoading` property and calls `notifyListeners()` to inform the UI of the update. When the operation completes, it toggles the property back. -When you build the UI, you'll use this `loading` property to +When you build the UI, you'll use this `isLoading` property to show a loading indicator while fetching a new article. ### Retrieve an article from the `ArticleModel` -Complete the `getRandomArticleSummary` method to fetch an article summary. +Complete the `fetchArticle` method to fetch an article summary. Use a [try-catch block][] to gracefully handle network errors and store error messages that the UI can display to users. The method clears previous errors on success and clears the previous article summary on error to maintain a consistent state. + ```dart class ArticleViewModel extends ChangeNotifier { final ArticleModel model; Summary? summary; - String? errorMessage; - bool loading = false; + Exception? error; + bool isLoading = false; ArticleViewModel(this.model) { - getRandomArticleSummary(); + fetchArticle(); } - Future getRandomArticleSummary() async { - loading = true; + Future fetchArticle() async { + isLoading = true; notifyListeners(); try { summary = await model.getRandomArticleSummary(); - errorMessage = null; // Clear any previous errors. - } on HttpException catch (error) { - errorMessage = error.message; + error = null; // Clear any previous errors. + } on HttpException catch (e) { + error = e; summary = null; } - loading = false; + isLoading = false; notifyListeners(); } } @@ -158,30 +163,32 @@ class ArticleViewModel extends ChangeNotifier { Before building the full UI, test that your HTTP requests work by printing results to the console. -First, update the `getRandomArticleSummary` method to +First, update the `fetchArticle` method to print the results: + ```dart -Future getRandomArticleSummary() async { - loading = true; +Future fetchArticle() async { + isLoading = true; notifyListeners(); try { summary = await model.getRandomArticleSummary(); print('Article loaded: ${summary!.titles.normalized}'); // Temporary - errorMessage = null; // Clear any previous errors. - } on HttpException catch (error) { - print('Error loading article: ${error.message}'); // Temporary - errorMessage = error.message; + error = null; // Clear any previous errors. + } on HttpException catch (e) { + print('Error loading article: ${e.message}'); // Temporary + error = e; summary = null; } - loading = false; + isLoading = false; notifyListeners(); } ``` Then, update the `MainApp` widget to create the `ArticleViewModel`, -which calls the `getRandomArticleSummary` method on creation: +which calls the `fetchArticle` method on creation: + ```dart class MainApp extends StatelessWidget { const MainApp({super.key}); @@ -193,12 +200,8 @@ class MainApp extends StatelessWidget { return MaterialApp( home: Scaffold( - appBar: AppBar( - title: const Text('Wikipedia Flutter'), - ), - body: const Center( - child: Text('Check console for article data'), - ), + appBar: AppBar(title: const Text('Wikipedia Flutter')), + body: const Center(child: Text('Check console for article data')), ), ); } @@ -227,7 +230,7 @@ items: icon: toggle_on details: >- Your ViewModel tracks three pieces of state: - `loading`, `summary`, and `errorMessage`. + `isLoading`, `summary`, and `error`. Using `try` and `catch`, you handle network errors gracefully and maintain consistent state for each possible outcome. - title: Used notifyListeners to signal UI updates diff --git a/src/content/learn/pathway/tutorial/http-requests.md b/src/content/learn/pathway/tutorial/http-requests.md index 31bc4c8594..bdd3023562 100644 --- a/src/content/learn/pathway/tutorial/http-requests.md +++ b/src/content/learn/pathway/tutorial/http-requests.md @@ -49,7 +49,8 @@ A model doesn't usually need to import Flutter libraries. Create an empty `ArticleModel` class in your `main.dart` file: -```dart title="lib/main.dart" + +```dart class ArticleModel { // Properties and methods will be added here. } @@ -66,6 +67,7 @@ https://en.wikipedia.org/api/rest_v1/page/random/summary Add a method to fetch a random Wikipedia article summary: + ```dart class ArticleModel { Future getRandomArticleSummary() async { @@ -76,6 +78,7 @@ class ArticleModel { final response = await get(uri); // TODO: Add error handling and JSON parsing. + throw UnimplementedError(); } } ``` @@ -99,6 +102,7 @@ A status code of **200** indicates success, while other codes indicate errors. If the status code isn't **200**, the model throws an error for the UI to display to users. + ```dart class ArticleModel { Future getRandomArticleSummary() async { @@ -109,10 +113,11 @@ class ArticleModel { final response = await get(uri); if (response.statusCode != 200) { - throw HttpException('Failed to update resource'); + throw const HttpException('Failed to update resource'); } // TODO: Parse JSON and return Summary. + throw UnimplementedError(); } } ``` @@ -123,6 +128,7 @@ The [Wikipedia API][] returns [JSON][] data that you decode into a `Summary` class Complete the `getRandomArticleSummary` method: + ```dart class ArticleModel { Future getRandomArticleSummary() async { @@ -136,7 +142,7 @@ class ArticleModel { throw HttpException('Failed to update resource'); } - return Summary.fromJson(jsonDecode(response.body)); + return Summary.fromJson(jsonDecode(response.body) as Map); } } ``` diff --git a/src/content/learn/pathway/tutorial/listenable-builder.md b/src/content/learn/pathway/tutorial/listenable-builder.md index 8da714f918..fe5bc5b9de 100644 --- a/src/content/learn/pathway/tutorial/listenable-builder.md +++ b/src/content/learn/pathway/tutorial/listenable-builder.md @@ -38,19 +38,23 @@ Create the `ArticleView` widget that manages the overall page layout and state handling. Start with the basic class structure and widgets: + ```dart -class ArticleView extends StatelessWidget { - ArticleView({super.key}); +class ArticleView extends StatefulWidget { + const ArticleView({super.key}); + + @override + State createState() => _ArticleViewState(); +} + +class _ArticleViewState extends State { + // viewModel will be instantiated next @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text('Wikipedia Flutter'), - ), - body: const Center( - child: Text('UI will update here'), - ), + appBar: AppBar(title: const Text('Wikipedia Flutter')), + body: const Center(child: Text('Loading...')), ); } } @@ -60,20 +64,48 @@ class ArticleView extends StatelessWidget { Create the `ArticleViewModel` in this widget: + ```dart -class ArticleView extends StatelessWidget { - ArticleView({super.key}); +class ArticleView extends StatefulWidget { + const ArticleView({super.key}); - final viewModel = ArticleViewModel(ArticleModel()); + @override + State createState() => _ArticleViewState(); +} + +class _ArticleViewState extends State { + late final ArticleViewModel viewModel; + + @override + void initState() { + super.initState(); + viewModel = ArticleViewModel(ArticleModel()); + viewModel.fetchArticle(); + } @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text('Wikipedia Flutter'), - ), - body: const Center( - child: Text('UI will update here'), + appBar: AppBar(title: const Text('Wikipedia Flutter')), + body: Center( + child: ListenableBuilder( + listenable: viewModel, + builder: (context, _) { + return switch (( + viewModel.isLoading, + viewModel.summary, + viewModel.error, + )) { + (true, _, _) => const CircularProgressIndicator(), + (_, final summary?, _) => ArticlePage( + summary: summary, + nextArticleCallback: viewModel.fetchArticle, + ), + (_, _, final Exception e) => Text('Error: $e'), + _ => const Text('Something went wrong!'), + }; + }, + ), ), ); } @@ -86,23 +118,23 @@ Wrap your UI in a [`ListenableBuilder`][] to listen for state changes, and pass it a `ChangeNotifier` object. In this case, the `ArticleViewModel` extends `ChangeNotifier`. + ```dart -class ArticleView extends StatelessWidget { - ArticleView({super.key}); +class ArticlePage extends StatelessWidget { + const ArticlePage({ + super.key, + required this.summary, + required this.nextArticleCallback, + }); - final ArticleViewModel viewModel = ArticleViewModel(ArticleModel()); + final Summary summary; + final VoidCallback nextArticleCallback; @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Wikipedia Flutter'), - ), - body: ListenableBuilder( - listenable: viewModel, - builder: (context, child) { - return const Center(child: Text('UI will update here')); - }, + return SingleChildScrollView( + child: Column( + children: [const Text('Article content will be displayed here')], ), ); } @@ -132,39 +164,29 @@ the UI can display different widgets. Use Dart's support for [switch expressions][] to handle all possible combinations in a clean, readable way: + ```dart -class ArticleView extends StatelessWidget { - ArticleView({super.key}); +class ArticlePage extends StatelessWidget { + const ArticlePage({ + super.key, + required this.summary, + required this.nextArticleCallback, + }); - final ArticleViewModel viewModel = ArticleViewModel(ArticleModel()); + final Summary summary; + final VoidCallback nextArticleCallback; @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Wikipedia Flutter'), - actions: [], - ), - body: ListenableBuilder( - listenable: viewModel, - builder: (context, child) { - return switch (( - viewModel.loading, - viewModel.summary, - viewModel.errorMessage, - )) { - (true, _, _) => CircularProgressIndicator(), - (false, _, String message) => Center(child: Text(message)), - (false, null, null) => Center( - child: Text('An unknown error has occurred'), - ), - // The summary must be non-null in this switch case. - (false, Summary summary, null) => ArticlePage( - summary: summary, - nextArticleCallback: viewModel.getRandomArticleSummary, - ), - }; - }, + return SingleChildScrollView( + child: Column( + children: [ + ArticleWidget(summary: summary), + ElevatedButton( + onPressed: nextArticleCallback, + child: const Text('Next random article'), + ), + ], ), ); } @@ -189,20 +211,16 @@ by the view model to build the UI. Now create a `ArticlePage` widget that displays the actual article content. This reusable widget takes summary data and a callback function: + ```dart -class ArticlePage extends StatelessWidget { - const ArticlePage({ - super.key, - required this.summary, - required this.nextArticleCallback, - }); +class ArticleWidget extends StatelessWidget { + const ArticleWidget({super.key, required this.summary}); final Summary summary; - final VoidCallback nextArticleCallback; @override Widget build(BuildContext context) { - return Center(child: Text('Article content will be displayed here')); + return const Text('Article content will be displayed here'); } } ``` @@ -211,24 +229,19 @@ class ArticlePage extends StatelessWidget { Replace the placeholder with a scrollable column layout: + ```dart -class ArticlePage extends StatelessWidget { - const ArticlePage({ - super.key, - required this.summary, - required this.nextArticleCallback, - }); +class ArticleWidget extends StatelessWidget { + const ArticleWidget({super.key, required this.summary}); final Summary summary; - final VoidCallback nextArticleCallback; @override Widget build(BuildContext context) { - return SingleChildScrollView( + return Padding( + padding: const EdgeInsets.all(8.0), child: Column( - children: [ - Text('Article content will be displayed here'), - ], + children: [const Text('Article content will be displayed here')], ), ); } @@ -239,29 +252,24 @@ class ArticlePage extends StatelessWidget { Complete the layout with an article widget and navigation button: + ```dart -class ArticlePage extends StatelessWidget { - const ArticlePage({ - super.key, - required this.summary, - required this.nextArticleCallback, - }); +class ArticleWidget extends StatelessWidget { + const ArticleWidget({super.key, required this.summary}); final Summary summary; - final VoidCallback nextArticleCallback; @override Widget build(BuildContext context) { - return SingleChildScrollView( + return Padding( + padding: const EdgeInsets.all(8.0), child: Column( children: [ - ArticleWidget( - summary: summary, - ), - ElevatedButton( - onPressed: nextArticleCallback, - child: Text('Next random article'), - ), + if (summary.hasImage) ...[ + Image.network(summary.originalImage!.source), + const SizedBox(height: 10.0), + ], + const Text('Article content will be displayed here'), ], ), ); @@ -278,6 +286,7 @@ with proper styling and conditional rendering. Start with the widget that accepts a `summary` parameter: + ```dart class ArticleWidget extends StatelessWidget { const ArticleWidget({super.key, required this.summary}); @@ -286,7 +295,32 @@ class ArticleWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return Text('Article content will be displayed here'); + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + children: [ + if (summary.hasImage) ...[ + Image.network(summary.originalImage!.source), + const SizedBox(height: 10.0), + ], + Text( + summary.titles.normalized, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.displaySmall, + ), + const SizedBox(height: 10.0), + if (summary.description != null) ...[ + Text( + summary.description!, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 10.0), + ], + Text(summary.extract), + ], + ), + ); } } ``` @@ -295,23 +329,14 @@ class ArticleWidget extends StatelessWidget { Wrap the content in proper padding and layout: + ```dart -class ArticleWidget extends StatelessWidget { - const ArticleWidget({super.key, required this.summary}); - - final Summary summary; +class MainApp extends StatelessWidget { + const MainApp({super.key}); @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - spacing: 10.0, - children: [ - Text('Article content will be displayed here'), - ], - ), - ); + return const MaterialApp(home: ArticleView()); } } ``` @@ -331,7 +356,7 @@ class ArticleWidget extends StatelessWidget { return Padding( padding: const EdgeInsets.all(8.0), child: Column( - spacing: 10.0, + children: [ if (summary.hasImage) Image.network( @@ -361,7 +386,7 @@ class ArticleWidget extends StatelessWidget { return Padding( padding: const EdgeInsets.all(8.0), child: Column( - spacing: 10.0, + children: [ if (summary.hasImage) Image.network( @@ -394,8 +419,8 @@ This widget demonstrates a few important UI concepts: The `if` statements show content only when available. - **Text styling**: Different text styles create visual hierarchy using Flutter's theme system. -- **Proper spacing**: - The `spacing` parameter provides consistent vertical spacing. + + - **Overflow handling**: `TextOverflow.ellipsis` prevents text from breaking the layout. @@ -463,7 +488,7 @@ items: details: >- You created `ArticleView`, `ArticlePage`, and `ArticleWidget` with conditional rendering, text styling, - proper spacing, and overflow handling. + and overflow handling. These are core UI patterns you'll use in every Flutter app. - title: Completed the MVVM architecture icon: celebration diff --git a/src/content/learn/pathway/tutorial/set-up-state-project.md b/src/content/learn/pathway/tutorial/set-up-state-project.md index 93e39ba4d2..0460c92ad5 100644 --- a/src/content/learn/pathway/tutorial/set-up-state-project.md +++ b/src/content/learn/pathway/tutorial/set-up-state-project.md @@ -88,7 +88,10 @@ for Wikipedia article summaries. This file has no special logic, and is simply a collection of classes that represent the data returned by the Wikipedia API. Its sufficient to copy the code below into the file and then ignore it. If you aren't comfortable basic Dart classes, you should read the [Dart Getting Started][] tutorial first. + ```dart title="lib/summary.dart" collapsed + +// Representation of the JSON data returned by the Wikipedia API. class Summary { /// Returns a new [Summary] instance. Summary({ @@ -96,68 +99,47 @@ class Summary { required this.pageid, required this.extract, required this.extractHtml, - required this.lang, - required this.dir, this.thumbnail, this.originalImage, - this.url, - + required this.lang, + required this.dir, this.description, + required this.url, }); - /// - TitlesSet titles; + /// Titles + final TitlesSet titles; - /// The page ID - int pageid; + /// Page ID + final int pageid; - /// First several sentences of an article in plain text - String extract; + /// Extract + final String extract; - /// First several sentences of an article in simple HTML format - String extractHtml; + /// Extract HTML + final String extractHtml; - ImageFile? thumbnail; + /// Thumbnail image + final ImageFile? thumbnail; - /// Url to the article on Wikipedia - String? url; + /// Original image + final ImageFile? originalImage; - /// - ImageFile? originalImage; + /// Language + final String lang; - /// The page language code - String lang; + /// Directionality + final String dir; - /// The page language direction code - String dir; + /// Description + final String? description; - /// Wikidata description for the page - String? description; + /// URL + final String url; - bool get hasImage => - (originalImage != null || thumbnail != null) && preferredSource != null; + bool get hasImage => originalImage != null && thumbnail != null; - String? get preferredSource { - ImageFile? file; - - if (originalImage != null) { - file = originalImage; - } else { - file = thumbnail; - } - - if (file != null) { - if (acceptableImageFormats.contains(file.extension.toLowerCase())) { - return file.source; - } else { - return null; - } - } - - return null; - } - - /// Returns a new [Summary] instance + /// Returns a new [Summary] instance and imports its values from a JSON map static Summary fromJson(Map json) { return switch (json) { { @@ -165,37 +147,11 @@ class Summary { 'pageid': final int pageid, 'extract': final String extract, 'extract_html': final String extractHtml, - 'lang': final String lang, - 'dir': final String dir, - 'content_urls': { - 'desktop': {'page': final String url}, - 'mobile': {'page': String _}, - }, - 'description': final String description, 'thumbnail': final Map thumbnail, 'originalimage': final Map originalImage, - } => - Summary( - titles: TitlesSet.fromJson(titles), - pageid: pageid, - extract: extract, - extractHtml: extractHtml, - thumbnail: ImageFile.fromJson(thumbnail), - originalImage: ImageFile.fromJson(originalImage), - lang: lang, - dir: dir, - url: url, - description: description, - ), - { - 'titles': final Map titles, - 'pageid': final int pageid, - 'extract': final String extract, - 'extract_html': final String extractHtml, 'lang': final String lang, 'dir': final String dir, - 'thumbnail': final Map thumbnail, - 'originalimage': final Map originalImage, + 'description': final String description, 'content_urls': { 'desktop': {'page': final String url}, 'mobile': {'page': String _}, @@ -210,6 +166,7 @@ class Summary { originalImage: ImageFile.fromJson(originalImage), lang: lang, dir: dir, + description: description, url: url, ), { @@ -283,13 +240,13 @@ class ImageFile { ImageFile({required this.source, required this.width, required this.height}); /// Original image URI - String source; + final String source; /// Original image width - int width; + final int width; /// Original image height - int height; + final int height; String get extension { final extension = getFileExtension(source); @@ -333,14 +290,14 @@ class TitlesSet { /// the DB key (non-prefixed), e.g. may have _ instead of spaces, /// best for making request URIs, still requires Percent-encoding - String canonical; + final String canonical; /// the normalized title (https://www.mediawiki.org/wiki/API:Query#Example_2:_Title_normalization), /// e.g. may have spaces instead of _ - String normalized; + final String normalized; /// the title as it should be displayed to the user - String display; + final String display; /// Returns a new [TitlesSet] instance and imports its values from a JSON map static TitlesSet fromJson(Map json) { @@ -374,12 +331,12 @@ String? getFileExtension(String file) { } const acceptableImageFormats = ['png', 'jpg', 'jpeg']; - ``` Then, open `lib/main.dart` and replace the existing code with this basic structure, which adds required imports that the app uses: + ```dart title="lib/main.dart" import 'dart:convert'; import 'dart:io'; @@ -400,12 +357,8 @@ class MainApp extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp( home: Scaffold( - appBar: AppBar( - title: const Text('Wikipedia Flutter'), - ), - body: const Center( - child: Text('Loading...'), - ), + appBar: AppBar(title: const Text('Wikipedia Flutter')), + body: const Center(child: Text('Loading...')), ), ); }