From 61c826f37643f6d53e3eb6d354f4282f6e075d1b Mon Sep 17 00:00:00 2001 From: lamek Date: Tue, 17 Mar 2026 00:05:33 +0000 Subject: [PATCH 1/4] Reapply "Update fwe samples (pt2 of 3) (#13156)" (#13159) This reverts commit ff7d8abad3a2090789b192dc575f6b94e19a4e1e. --- examples/fwe/wikipedia_reader/.gitignore | 45 ++++ examples/fwe/wikipedia_reader/.metadata | 45 ++++ examples/fwe/wikipedia_reader/README.md | 3 + .../wikipedia_reader/analysis_options.yaml | 1 + examples/fwe/wikipedia_reader/lib/main.dart | 167 ++++++++++++ .../fwe/wikipedia_reader/lib/step1_main.dart | 34 +++ .../fwe/wikipedia_reader/lib/step2_main.dart | 46 ++++ .../fwe/wikipedia_reader/lib/step2a_main.dart | 34 +++ .../fwe/wikipedia_reader/lib/step2b_main.dart | 43 +++ .../fwe/wikipedia_reader/lib/step2c_main.dart | 47 ++++ .../fwe/wikipedia_reader/lib/step3_main.dart | 71 +++++ .../fwe/wikipedia_reader/lib/step3a_main.dart | 42 +++ .../fwe/wikipedia_reader/lib/step3b_main.dart | 32 +++ .../fwe/wikipedia_reader/lib/step3c_main.dart | 34 +++ .../fwe/wikipedia_reader/lib/step3d_main.dart | 39 +++ .../fwe/wikipedia_reader/lib/step3e_main.dart | 41 +++ .../fwe/wikipedia_reader/lib/step3f_main.dart | 34 +++ .../fwe/wikipedia_reader/lib/step4_main.dart | 169 ++++++++++++ .../fwe/wikipedia_reader/lib/step4a_main.dart | 25 ++ .../fwe/wikipedia_reader/lib/step4b_main.dart | 73 +++++ .../fwe/wikipedia_reader/lib/step4c_main.dart | 27 ++ .../fwe/wikipedia_reader/lib/step4d_main.dart | 40 +++ .../fwe/wikipedia_reader/lib/step4e_main.dart | 18 ++ .../fwe/wikipedia_reader/lib/step4f_main.dart | 24 ++ .../fwe/wikipedia_reader/lib/step4g_main.dart | 27 ++ .../fwe/wikipedia_reader/lib/step4h_main.dart | 38 +++ .../fwe/wikipedia_reader/lib/summary.dart | 252 ++++++++++++++++++ examples/fwe/wikipedia_reader/pubspec.yaml | 20 ++ .../wikipedia_reader/test/widget_test.dart | 13 + .../learn/pathway/tutorial/change-notifier.md | 89 ++++--- .../learn/pathway/tutorial/http-requests.md | 10 +- .../pathway/tutorial/listenable-builder.md | 227 ++++++++-------- .../pathway/tutorial/set-up-state-project.md | 95 ++----- 33 files changed, 1685 insertions(+), 220 deletions(-) create mode 100644 examples/fwe/wikipedia_reader/.gitignore create mode 100644 examples/fwe/wikipedia_reader/.metadata create mode 100644 examples/fwe/wikipedia_reader/README.md create mode 100644 examples/fwe/wikipedia_reader/analysis_options.yaml create mode 100644 examples/fwe/wikipedia_reader/lib/main.dart create mode 100644 examples/fwe/wikipedia_reader/lib/step1_main.dart create mode 100644 examples/fwe/wikipedia_reader/lib/step2_main.dart create mode 100644 examples/fwe/wikipedia_reader/lib/step2a_main.dart create mode 100644 examples/fwe/wikipedia_reader/lib/step2b_main.dart create mode 100644 examples/fwe/wikipedia_reader/lib/step2c_main.dart create mode 100644 examples/fwe/wikipedia_reader/lib/step3_main.dart create mode 100644 examples/fwe/wikipedia_reader/lib/step3a_main.dart create mode 100644 examples/fwe/wikipedia_reader/lib/step3b_main.dart create mode 100644 examples/fwe/wikipedia_reader/lib/step3c_main.dart create mode 100644 examples/fwe/wikipedia_reader/lib/step3d_main.dart create mode 100644 examples/fwe/wikipedia_reader/lib/step3e_main.dart create mode 100644 examples/fwe/wikipedia_reader/lib/step3f_main.dart create mode 100644 examples/fwe/wikipedia_reader/lib/step4_main.dart create mode 100644 examples/fwe/wikipedia_reader/lib/step4a_main.dart create mode 100644 examples/fwe/wikipedia_reader/lib/step4b_main.dart create mode 100644 examples/fwe/wikipedia_reader/lib/step4c_main.dart create mode 100644 examples/fwe/wikipedia_reader/lib/step4d_main.dart create mode 100644 examples/fwe/wikipedia_reader/lib/step4e_main.dart create mode 100644 examples/fwe/wikipedia_reader/lib/step4f_main.dart create mode 100644 examples/fwe/wikipedia_reader/lib/step4g_main.dart create mode 100644 examples/fwe/wikipedia_reader/lib/step4h_main.dart create mode 100644 examples/fwe/wikipedia_reader/lib/summary.dart create mode 100644 examples/fwe/wikipedia_reader/pubspec.yaml create mode 100644 examples/fwe/wikipedia_reader/test/widget_test.dart diff --git a/examples/fwe/wikipedia_reader/.gitignore b/examples/fwe/wikipedia_reader/.gitignore new file mode 100644 index 00000000000..3820a95c65c --- /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 00000000000..c9627623eef --- /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 00000000000..2a1fac66791 --- /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 00000000000..f9b303465f1 --- /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 00000000000..67a457a2e8e --- /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 00000000000..affee140a04 --- /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 00000000000..e880600cc01 --- /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)); + } +} + +// #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 00000000000..ca172369fda --- /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 00000000000..bd22fb7d969 --- /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 00000000000..00e253b4bd9 --- /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 00000000000..bb0dd4e88b4 --- /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 00000000000..b25e824c855 --- /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 00000000000..f457a60ba92 --- /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 00000000000..88a9516b63a --- /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 00000000000..67065479c7c --- /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 00000000000..edc069525c8 --- /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 00000000000..7208169e83e --- /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 00000000000..749c77ca033 --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/step4_main.dart @@ -0,0 +1,169 @@ +// 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( + 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/step4a_main.dart b/examples/fwe/wikipedia_reader/lib/step4a_main.dart new file mode 100644 index 00000000000..2d08e714e98 --- /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 00000000000..0862a870ce7 --- /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 00000000000..c61c9829aaa --- /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 00000000000..abc61fbffe1 --- /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 00000000000..db908d2b68d --- /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 00000000000..8c7a915ef90 --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/step4f_main.dart @@ -0,0 +1,24 @@ +// 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( + spacing: 10.0, + 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 00000000000..0bc4872e8ae --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/step4g_main.dart @@ -0,0 +1,27 @@ +// 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( + spacing: 10.0, + children: [ + if (summary.hasImage) Image.network(summary.originalImage!.source), + 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 00000000000..349b08c0d19 --- /dev/null +++ b/examples/fwe/wikipedia_reader/lib/step4h_main.dart @@ -0,0 +1,38 @@ +// 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( + 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), + ], + ), + ); + } +} + +// #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 00000000000..36c512c19e5 --- /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 + TitlesSet titles; + + /// Page ID + int pageid; + + /// Extract + String extract; + + /// Extract HTML + String extractHtml; + + /// Thumbnail image + ImageFile? thumbnail; + + /// Original image + ImageFile? originalImage; + + /// Language + String lang; + + /// Directionality + String dir; + + /// Description + String? description; + + /// URL + 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 + String source; + + /// Original image width + int width; + + /// Original image height + 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 + 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; + + /// the title as it should be displayed to the user + 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 00000000000..2d1682fa422 --- /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 00000000000..e8cb3289cce --- /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 824dc464a6d..306d04f8425 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 31bc4c85940..eaf85290b02 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 { diff --git a/src/content/learn/pathway/tutorial/listenable-builder.md b/src/content/learn/pathway/tutorial/listenable-builder.md index 8da714f9185..6c5fe0c56fc 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,20 @@ 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'), - ], + spacing: 10.0, + children: [const Text('Article content will be displayed here')], ), ); } @@ -239,29 +253,22 @@ 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( + spacing: 10.0, children: [ - ArticleWidget( - summary: summary, - ), - ElevatedButton( - onPressed: nextArticleCallback, - child: Text('Next random article'), - ), + if (summary.hasImage) Image.network(summary.originalImage!.source), + const Text('Article content will be displayed here'), ], ), ); @@ -278,6 +285,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 +294,27 @@ 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( + 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), + ], + ), + ); } } ``` @@ -295,23 +323,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()); } } ``` 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 93e39ba4d2d..b6dc86ad778 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, }); - /// + /// Titles TitlesSet titles; - /// The page ID + /// Page ID int pageid; - /// First several sentences of an article in plain text + /// Extract String extract; - /// First several sentences of an article in simple HTML format + /// Extract HTML String extractHtml; + /// Thumbnail image ImageFile? thumbnail; - /// Url to the article on Wikipedia - String? url; - - /// + /// Original image ImageFile? originalImage; - /// The page language code + /// Language String lang; - /// The page language direction code + /// Directionality String dir; - /// Wikidata description for the page + /// Description String? description; - bool get hasImage => - (originalImage != null || thumbnail != null) && preferredSource != null; - - String? get preferredSource { - ImageFile? file; - - if (originalImage != null) { - file = originalImage; - } else { - file = thumbnail; - } + /// URL + String url; - if (file != null) { - if (acceptableImageFormats.contains(file.extension.toLowerCase())) { - return file.source; - } else { - return null; - } - } + bool get hasImage => originalImage != null && thumbnail != 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, ), { @@ -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...')), ), ); } From 51fb792d05df54a380789bef6cae228d6a364dde Mon Sep 17 00:00:00 2001 From: lamek Date: Tue, 17 Mar 2026 00:23:16 +0000 Subject: [PATCH 2/4] Fix PR issues and sync tutorial excerpt --- .../fwe/wikipedia_reader/lib/step2_main.dart | 2 +- .../fwe/wikipedia_reader/lib/step4_main.dart | 11 +++++-- .../fwe/wikipedia_reader/lib/step4f_main.dart | 5 +-- .../fwe/wikipedia_reader/lib/step4g_main.dart | 6 ++-- .../fwe/wikipedia_reader/lib/step4h_main.dart | 11 +++++-- .../fwe/wikipedia_reader/lib/summary.dart | 32 +++++++++---------- .../learn/pathway/tutorial/http-requests.md | 2 +- .../pathway/tutorial/listenable-builder.md | 32 ++++++++++++------- .../pathway/tutorial/set-up-state-project.md | 32 +++++++++---------- 9 files changed, 77 insertions(+), 56 deletions(-) diff --git a/examples/fwe/wikipedia_reader/lib/step2_main.dart b/examples/fwe/wikipedia_reader/lib/step2_main.dart index e880600cc01..8ffa84c3038 100644 --- a/examples/fwe/wikipedia_reader/lib/step2_main.dart +++ b/examples/fwe/wikipedia_reader/lib/step2_main.dart @@ -39,7 +39,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/examples/fwe/wikipedia_reader/lib/step4_main.dart b/examples/fwe/wikipedia_reader/lib/step4_main.dart index 749c77ca033..4ed212ac64a 100644 --- a/examples/fwe/wikipedia_reader/lib/step4_main.dart +++ b/examples/fwe/wikipedia_reader/lib/step4_main.dart @@ -147,20 +147,25 @@ class ArticleWidget extends StatelessWidget { return Padding( padding: const EdgeInsets.all(8.0), child: Column( - spacing: 10.0, children: [ - if (summary.hasImage) Image.network(summary.originalImage!.source), + 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, ), - if (summary.description != null) + 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/step4f_main.dart b/examples/fwe/wikipedia_reader/lib/step4f_main.dart index 8c7a915ef90..74f6b8c7251 100644 --- a/examples/fwe/wikipedia_reader/lib/step4f_main.dart +++ b/examples/fwe/wikipedia_reader/lib/step4f_main.dart @@ -14,8 +14,9 @@ class ArticleWidget extends StatelessWidget { return Padding( padding: const EdgeInsets.all(8.0), child: Column( - spacing: 10.0, - children: [const Text('Article content will be displayed here')], + children: [ + const Text('Article content will be displayed here'), + ], ), ); } diff --git a/examples/fwe/wikipedia_reader/lib/step4g_main.dart b/examples/fwe/wikipedia_reader/lib/step4g_main.dart index 0bc4872e8ae..63acce0bf5b 100644 --- a/examples/fwe/wikipedia_reader/lib/step4g_main.dart +++ b/examples/fwe/wikipedia_reader/lib/step4g_main.dart @@ -14,9 +14,11 @@ class ArticleWidget extends StatelessWidget { return Padding( padding: const EdgeInsets.all(8.0), child: Column( - spacing: 10.0, children: [ - if (summary.hasImage) Image.network(summary.originalImage!.source), + if (summary.hasImage) ...[ + Image.network(summary.originalImage!.source), + const SizedBox(height: 10.0), + ], const Text('Article content will be displayed here'), ], ), diff --git a/examples/fwe/wikipedia_reader/lib/step4h_main.dart b/examples/fwe/wikipedia_reader/lib/step4h_main.dart index 349b08c0d19..b61f400f241 100644 --- a/examples/fwe/wikipedia_reader/lib/step4h_main.dart +++ b/examples/fwe/wikipedia_reader/lib/step4h_main.dart @@ -14,20 +14,25 @@ class ArticleWidget extends StatelessWidget { return Padding( padding: const EdgeInsets.all(8.0), child: Column( - spacing: 10.0, children: [ - if (summary.hasImage) Image.network(summary.originalImage!.source), + 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, ), - if (summary.description != null) + 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/summary.dart b/examples/fwe/wikipedia_reader/lib/summary.dart index 36c512c19e5..e7b853db095 100644 --- a/examples/fwe/wikipedia_reader/lib/summary.dart +++ b/examples/fwe/wikipedia_reader/lib/summary.dart @@ -20,34 +20,34 @@ class Summary { }); /// Titles - TitlesSet titles; + final TitlesSet titles; /// Page ID - int pageid; + final int pageid; /// Extract - String extract; + final String extract; /// Extract HTML - String extractHtml; + final String extractHtml; /// Thumbnail image - ImageFile? thumbnail; + final ImageFile? thumbnail; /// Original image - ImageFile? originalImage; + final ImageFile? originalImage; /// Language - String lang; + final String lang; /// Directionality - String dir; + final String dir; /// Description - String? description; + final String? description; /// URL - String url; + final String url; bool get hasImage => originalImage != null && thumbnail != null; @@ -154,13 +154,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); @@ -206,14 +206,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) { diff --git a/src/content/learn/pathway/tutorial/http-requests.md b/src/content/learn/pathway/tutorial/http-requests.md index eaf85290b02..bdd3023562b 100644 --- a/src/content/learn/pathway/tutorial/http-requests.md +++ b/src/content/learn/pathway/tutorial/http-requests.md @@ -142,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 6c5fe0c56fc..7f6546d55e2 100644 --- a/src/content/learn/pathway/tutorial/listenable-builder.md +++ b/src/content/learn/pathway/tutorial/listenable-builder.md @@ -241,8 +241,9 @@ class ArticleWidget extends StatelessWidget { return Padding( padding: const EdgeInsets.all(8.0), child: Column( - spacing: 10.0, - children: [const Text('Article content will be displayed here')], + children: [ + const Text('Article content will be displayed here'), + ], ), ); } @@ -265,9 +266,11 @@ class ArticleWidget extends StatelessWidget { return Padding( padding: const EdgeInsets.all(8.0), child: Column( - spacing: 10.0, children: [ - if (summary.hasImage) Image.network(summary.originalImage!.source), + if (summary.hasImage) ...[ + Image.network(summary.originalImage!.source), + const SizedBox(height: 10.0), + ], const Text('Article content will be displayed here'), ], ), @@ -297,20 +300,25 @@ class ArticleWidget extends StatelessWidget { return Padding( padding: const EdgeInsets.all(8.0), child: Column( - spacing: 10.0, children: [ - if (summary.hasImage) Image.network(summary.originalImage!.source), + 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, ), - if (summary.description != null) + 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), ], ), @@ -350,7 +358,7 @@ class ArticleWidget extends StatelessWidget { return Padding( padding: const EdgeInsets.all(8.0), child: Column( - spacing: 10.0, + children: [ if (summary.hasImage) Image.network( @@ -380,7 +388,7 @@ class ArticleWidget extends StatelessWidget { return Padding( padding: const EdgeInsets.all(8.0), child: Column( - spacing: 10.0, + children: [ if (summary.hasImage) Image.network( @@ -413,8 +421,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. @@ -482,7 +490,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 b6dc86ad778..0460c92ad54 100644 --- a/src/content/learn/pathway/tutorial/set-up-state-project.md +++ b/src/content/learn/pathway/tutorial/set-up-state-project.md @@ -108,34 +108,34 @@ class Summary { }); /// Titles - TitlesSet titles; + final TitlesSet titles; /// Page ID - int pageid; + final int pageid; /// Extract - String extract; + final String extract; /// Extract HTML - String extractHtml; + final String extractHtml; /// Thumbnail image - ImageFile? thumbnail; + final ImageFile? thumbnail; /// Original image - ImageFile? originalImage; + final ImageFile? originalImage; /// Language - String lang; + final String lang; /// Directionality - String dir; + final String dir; /// Description - String? description; + final String? description; /// URL - String url; + final String url; bool get hasImage => originalImage != null && thumbnail != null; @@ -240,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); @@ -290,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) { From da1d8bccd26ca3a39601f41c17b5b9be0a1d3cd6 Mon Sep 17 00:00:00 2001 From: lamek Date: Wed, 18 Mar 2026 19:59:28 +0000 Subject: [PATCH 3/4] Format step4f_main.dart --- examples/fwe/wikipedia_reader/lib/step4f_main.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/examples/fwe/wikipedia_reader/lib/step4f_main.dart b/examples/fwe/wikipedia_reader/lib/step4f_main.dart index 74f6b8c7251..5d22ba9884a 100644 --- a/examples/fwe/wikipedia_reader/lib/step4f_main.dart +++ b/examples/fwe/wikipedia_reader/lib/step4f_main.dart @@ -14,9 +14,7 @@ class ArticleWidget extends StatelessWidget { return Padding( padding: const EdgeInsets.all(8.0), child: Column( - children: [ - const Text('Article content will be displayed here'), - ], + children: [const Text('Article content will be displayed here')], ), ); } From 02cc4399e23b3d3f1ae203d94ae4ff2213408ab0 Mon Sep 17 00:00:00 2001 From: lamek Date: Wed, 18 Mar 2026 20:02:24 +0000 Subject: [PATCH 4/4] Refresh excerpts after dart format --- src/content/learn/pathway/tutorial/listenable-builder.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/content/learn/pathway/tutorial/listenable-builder.md b/src/content/learn/pathway/tutorial/listenable-builder.md index 7f6546d55e2..fe5bc5b9dee 100644 --- a/src/content/learn/pathway/tutorial/listenable-builder.md +++ b/src/content/learn/pathway/tutorial/listenable-builder.md @@ -241,9 +241,7 @@ class ArticleWidget extends StatelessWidget { return Padding( padding: const EdgeInsets.all(8.0), child: Column( - children: [ - const Text('Article content will be displayed here'), - ], + children: [const Text('Article content will be displayed here')], ), ); }