diff --git a/site/lib/_sass/_site.scss b/site/lib/_sass/_site.scss index a2afa1e4589..992f23edafd 100644 --- a/site/lib/_sass/_site.scss +++ b/site/lib/_sass/_site.scss @@ -30,6 +30,7 @@ @use 'components/next-prev-nav'; @use 'components/os-selector'; @use 'components/pill'; +@use 'components/quiz'; @use 'components/sidebar'; @use 'components/side-menu'; @use 'components/site-switcher'; diff --git a/site/lib/_sass/components/_quiz.scss b/site/lib/_sass/components/_quiz.scss new file mode 100644 index 00000000000..cff526640a7 --- /dev/null +++ b/site/lib/_sass/components/_quiz.scss @@ -0,0 +1,148 @@ +.quiz { + display: flex; + flex-direction: column; + + background-color: var(--site-raised-bgColor-translucent); + border-radius: var(--site-radius); + padding: 1rem; + + .quiz-title { + margin: 0; + font-size: 1.5rem; + font-weight: 500; + color: var(--site-base-fgColor-lighter); + } + + .quiz-progress { + font-size: 0.9rem; + color: var(--site-primary-color); + font-weight: 500; + } + + .quiz-question { + margin-top: 1.5rem; + display: none; + + &.active { + display: block; + } + + ol { + padding: 0; + margin: 0; + margin-top: 1rem; + list-style: upper-alpha; + list-style-position: inside; + + li { + padding: 1rem; + background-color: var(--site-raised-bgColor); + border-radius: var(--site-radius); + margin-bottom: 0.2rem; + transition: background-color 500ms; + + &:not(:where([aria-pressed="true"], [aria-disabled="true"])):hover { + background-color: var(--site-inset-bgColor); + cursor: pointer; + } + + &[aria-pressed="true"]:has(.correct) { + background-color: oklch(from var(--site-alert-tip-color) l c h / 0.2); + } + + &[aria-pressed="true"]:has(.incorrect) { + background-color: oklch(from var(--site-alert-error-color) l c h / 0.2); + } + + &:not([aria-pressed="true"])[aria-disabled="true"] { + opacity: 0.6; + } + + p { + margin-bottom: 0; + } + + .question-wrapper { + display: grid; + grid-template-rows: min-content 0fr; + transition: grid-template-rows 500ms; + } + + &[aria-pressed="true"] .question-wrapper { + grid-template-rows: min-content 1fr; + } + + .question { + margin-top: -1lh; + margin-left: 1.4rem; + } + + .solution { + position: relative; + padding-left: 1.4rem; + font-size: 0.9rem; + overflow: hidden; + + p.correct, + p.incorrect { + padding-top: 0.5rem; + font-weight: 600; + margin-bottom: 0.5rem; + + &::before { + position: absolute; + left: 0; + } + } + + p.correct { + color: green; + + &::before { + content: "✓"; + } + } + + p.incorrect { + color: red; + + &::before { + content: "✗"; + } + } + } + } + } + + } + + .quiz-complete { + min-height: 15rem; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + strong { + font-size: 2rem; + } + } + + .quiz-actions { + display: flex; + justify-content: space-between; + margin-top: 1rem; + + .quiz-button { + &.secondary { + background-color: var(--site-inset-bgColor); + color: var(--site-base-fgColor); + } + + &[disabled] { + opacity: 0.4; + pointer-events: none; + } + } + } +} diff --git a/site/lib/jaspr_options.dart b/site/lib/jaspr_options.dart index 24580968e97..175ad48d16a 100644 --- a/site/lib/jaspr_options.dart +++ b/site/lib/jaspr_options.dart @@ -35,7 +35,9 @@ import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_fil as prefix13; import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters_sidebar.dart' as prefix14; -import 'package:jaspr_content/components/file_tree.dart' as prefix15; +import 'package:docs_flutter_dev_site/src/components/tutorial/client/quiz.dart' + as prefix15; +import 'package:jaspr_content/components/file_tree.dart' as prefix16; /// Default [JasprOptions] for use with your jaspr project. /// @@ -122,8 +124,13 @@ JasprOptions get defaultJasprOptions => JasprOptions( ClientTarget( 'src/components/pages/learning_resource_filters_sidebar', ), + + prefix15.InteractiveQuiz: ClientTarget( + 'src/components/tutorial/client/quiz', + params: _prefix15InteractiveQuiz, + ), }, - styles: () => [...prefix15.FileTree.styles], + styles: () => [...prefix16.FileTree.styles], ); Map _prefix2CopyButton(prefix2.CopyButton c) => { @@ -148,3 +155,7 @@ Map _prefix11ArchiveTable(prefix11.ArchiveTable c) => { 'os': c.os, 'channel': c.channel, }; +Map _prefix15InteractiveQuiz(prefix15.InteractiveQuiz c) => { + 'title': c.title, + 'questions': c.questions.map((i) => i.toJson()).toList(), +}; diff --git a/site/lib/main.dart b/site/lib/main.dart index ad23bfe11d2..9d58ec40e3c 100644 --- a/site/lib/main.dart +++ b/site/lib/main.dart @@ -19,6 +19,7 @@ import 'src/components/pages/archive_table.dart'; import 'src/components/pages/devtools_release_notes_index.dart'; import 'src/components/pages/expansion_list.dart'; import 'src/components/pages/learning_resource_index.dart'; +import 'src/components/tutorial/quiz.dart'; import 'src/extensions/registry.dart'; import 'src/layouts/catalog_page_layout.dart'; import 'src/layouts/doc_layout.dart'; @@ -96,6 +97,7 @@ List get _embeddableComponents => [ const DashImage(), const YoutubeEmbed(), const FileTree(), + const Quiz(), CustomComponent( pattern: RegExp('OSSelector', caseSensitive: false), builder: (_, _, _) => const OsSelector(), diff --git a/site/lib/src/components/common/button.dart b/site/lib/src/components/common/button.dart index 41eea870529..e1c486c0202 100644 --- a/site/lib/src/components/common/button.dart +++ b/site/lib/src/components/common/button.dart @@ -16,6 +16,7 @@ class Button extends StatelessComponent { this.href, this.content, this.style = ButtonStyle.text, + this.ref, this.id, this.attributes = const {}, this.classes, @@ -29,6 +30,7 @@ class Button extends StatelessComponent { final String? title; final ButtonStyle style; final String? icon; + final Key? ref; final String? id; final String? href; final Map attributes; @@ -59,6 +61,7 @@ class Button extends StatelessComponent { if (href case final href?) { return a( + key: ref, id: id, href: href, classes: mergedClasses, @@ -68,6 +71,7 @@ class Button extends StatelessComponent { ); } else { return button( + key: ref, id: id, classes: mergedClasses, attributes: mergedAttributes, diff --git a/site/lib/src/components/tutorial/client/quiz.dart b/site/lib/src/components/tutorial/client/quiz.dart new file mode 100644 index 00000000000..bc354c09ca3 --- /dev/null +++ b/site/lib/src/components/tutorial/client/quiz.dart @@ -0,0 +1,188 @@ +// Copyright 2025 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:jaspr/jaspr.dart'; +import 'package:universal_web/web.dart' as web; + +import '../../../models/quiz_model.dart'; +import '../../../util.dart'; +import '../../common/button.dart'; + +@client +class InteractiveQuiz extends StatefulComponent { + const InteractiveQuiz({ + required this.title, + required this.questions, + super.key, + }); + + final String? title; + final List questions; + + @override + State createState() => _InteractiveQuizState(); +} + +class _InteractiveQuizState extends State { + final quizKey = GlobalNodeKey(); + final nextButtonKey = GlobalNodeKey(); + + int currentQuestionIndex = 0; + List selectedOptionIndices = []; + + Question? get currentQuestion { + if (currentQuestionIndex >= component.questions.length) { + return null; + } + return component.questions[currentQuestionIndex]; + } + + AnswerOption? get selectedOption { + final question = currentQuestion; + if (question == null || + selectedOptionIndices.length <= currentQuestionIndex) { + return null; + } + return question.options[selectedOptionIndices[currentQuestionIndex]]; + } + + void toggleOption(int index, [bool fromKeyboard = false]) { + if (selectedOption != null) { + return; + } + setState(() { + if (selectedOptionIndices.length <= currentQuestionIndex) { + selectedOptionIndices.add(index); + } else { + selectedOptionIndices[currentQuestionIndex] = index; + } + }); + if (fromKeyboard) { + context.binding.addPostFrameCallback(() { + // Move focus to the next button. + final nextButton = nextButtonKey.currentNode; + nextButton?.focus(); + }); + } + } + + @override + Component build(BuildContext context) { + return div(key: quizKey, classes: 'quiz not-content', [ + if (component.title case final title?) + h3(classes: 'quiz-title', [ + text(title), + ]), + span(classes: 'quiz-progress', [ + text( + currentQuestion != null + ? '${currentQuestionIndex + 1} / ${component.questions.length}' + : 'Complete', + ), + ]), + for (final question in component.questions) + div( + classes: [ + 'quiz-question', + if (question == currentQuestion) 'active', + ].toClasses, + [ + strong([text(question.question)]), + ol([ + for (final (index, option) in question.options.indexed) + li( + attributes: { + 'role': 'button', + if (selectedOption == null) 'tabindex': '0', + if (option == selectedOption) 'aria-pressed': 'true', + if (selectedOption != null) 'aria-disabled': 'true', + }, + events: { + 'click': (_) { + toggleOption(index); + }, + 'keyup': (event) { + if ((event as web.KeyboardEvent).key == 'Enter' || + event.key == ' ') { + toggleOption(index, true); + } + }, + }, + [ + div(classes: 'question-wrapper', [ + div(classes: 'question', [ + p([text(option.text)]), + ]), + div(classes: 'solution', [ + if (option.correct) + p(classes: 'correct', [text('That\'s right!')]) + else + p(classes: 'incorrect', [text('Not quite')]), + p([text(option.explanation)]), + ]), + ]), + ], + ), + ]), + ], + ), + + if (currentQuestion == null) + div(classes: 'quiz-complete', [ + strong([text('Great job!')]), + p([text('You completed the quiz.')]), + ]), + div(classes: 'quiz-actions', [ + Button( + classes: ['quiz-button', 'secondary'], + style: ButtonStyle.filled, + disabled: currentQuestionIndex == 0, + onClick: () { + setState(() { + currentQuestionIndex--; + }); + }, + content: 'Previous', + ), + Button( + ref: nextButtonKey, + classes: ['quiz-button'], + style: ButtonStyle.filled, + disabled: currentQuestion != null && selectedOption == null, + onClick: () { + if (currentQuestion == null) { + // Restart the quiz. + setState(() { + currentQuestionIndex = 0; + selectedOptionIndices = []; + }); + return; + } + if (selectedOption == null) return; + final correct = selectedOption!.correct; + setState(() { + if (correct) { + currentQuestionIndex++; + } else { + // Clear the selected option to allow retry. + selectedOptionIndices.removeLast(); + } + }); + }, + content: switch (( + currentQuestion == null, + currentQuestionIndex == component.questions.length - 1, + selectedOption?.correct, + )) { + // (isComplete, isLast, isCorrect) + (true, _, _) => 'Restart', + (false, _, false) => 'Try again', + (false, false, _) => 'Next question', + (false, true, _) => 'Finish quiz', + }, + ), + ]), + ]); + } +} diff --git a/site/lib/src/components/tutorial/quiz.dart b/site/lib/src/components/tutorial/quiz.dart new file mode 100644 index 00000000000..d552df4f8c3 --- /dev/null +++ b/site/lib/src/components/tutorial/quiz.dart @@ -0,0 +1,38 @@ +// Copyright 2025 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:jaspr/jaspr.dart'; +import 'package:jaspr_content/jaspr_content.dart'; +import 'package:yaml/yaml.dart'; + +import '../../models/quiz_model.dart'; +import 'client/quiz.dart'; + +class Quiz extends CustomComponent { + const Quiz() : super.base(); + + @override + Component? create(Node node, NodesBuilder builder) { + if (node is ElementNode && node.tag.toLowerCase() == 'quiz') { + if (node.children?.whereType().isNotEmpty ?? false) { + throw Exception( + 'Invalid Quiz content. Remove any leading empty lines to ' + 'avoid parsing as markdown.', + ); + } + + final title = node.attributes['title']; + + final content = node.children?.map((n) => n.innerText).join('\n') ?? ''; + final data = loadYamlNode(content); + assert(data is YamlList, 'Invalid Quiz content. Expected a YAML list.'); + final questions = (data as YamlList).nodes + .map((n) => Question.fromMap(n as YamlMap)) + .toList(); + assert(questions.isNotEmpty, 'Quiz must contain at least one question.'); + return InteractiveQuiz(title: title, questions: questions); + } + return null; + } +} diff --git a/site/lib/src/models/quiz_model.dart b/site/lib/src/models/quiz_model.dart new file mode 100644 index 00000000000..4f38e27b687 --- /dev/null +++ b/site/lib/src/models/quiz_model.dart @@ -0,0 +1,48 @@ +import 'package:jaspr/jaspr.dart'; + +class Question { + const Question(this.question, this.options); + + final String question; + final List options; + + @decoder + factory Question.fromMap(Map json) { + return Question( + json['question'] as String, + (json['options'] as List) + .map((e) => AnswerOption.fromJson(e as Map)) + .toList(), + ); + } + + @encoder + Map toJson() => { + 'question': question, + 'options': options.map((e) => e.toJson()).toList(), + }; +} + +class AnswerOption { + const AnswerOption(this.text, this.correct, this.explanation); + + final String text; + final bool correct; + final String explanation; + + @decoder + factory AnswerOption.fromJson(Map json) { + return AnswerOption( + json['text'] as String, + json['correct'] as bool? ?? false, + json['explanation'] as String, + ); + } + + @encoder + Map toJson() => { + 'text': text, + 'correct': correct, + 'explanation': explanation, + }; +} diff --git a/site/lib/src/pages/custom_pages.dart b/site/lib/src/pages/custom_pages.dart index 306df7d51fe..283d5690303 100644 --- a/site/lib/src/pages/custom_pages.dart +++ b/site/lib/src/pages/custom_pages.dart @@ -15,6 +15,8 @@ import 'glossary.dart'; List get allMemoryPages => [ _glossaryPage, _devtoolsReleasesIndex, + // TODO(schultek): Remove this test page when FWE lands. + if (kDebugMode) _fweTestingPage, ]; /// The `/resources/glossary` page which hosts the [GlossaryIndex]. @@ -66,3 +68,45 @@ MemoryPage get _devtoolsReleasesIndex => MemoryPage.builder( return const Component.empty(); }, ); + +MemoryPage get _fweTestingPage => const MemoryPage( + path: 'fwe.md', + content: ''' +--- +title: FWE Testing Page +description: This is a test page for experimenting with First Week Experience (FWE) features. +sitemap: false +--- + + +- question: What is the Effective Dart guideline for the first sentence of a documentation comment? + options: + - text: It should be a complete paragraph with at least two sentences to provide sufficient context. + correct: false + explanation: The guideline recommends a short summary sentence, not a full paragraph. + - text: It must include the names of all parameters using square brackets. + correct: false + explanation: Parameter names can be included elsewhere, but the first sentence is a summary. + - text: It should be a single-sentence summary, separated from the rest of the comment by a blank line. + correct: true + explanation: Effective Dart recommends starting with a single-sentence summary, followed by a blank line before details. + - text: It should always begin with the name of the member being documented. + correct: false + explanation: Starting with the member name is not required; a concise summary is preferred. +- question: In Flutter, which widget is typically used to create a scrollable list of items? + options: + - text: Column + correct: false + explanation: A Column is not scrollable by default; use ListView for scrollable lists. + - text: ListView + correct: true + explanation: ListView is the standard widget for creating scrollable lists in Flutter. + - text: Row + correct: false + explanation: A Row arranges items horizontally and is not scrollable by default. + - text: Stack + correct: false + explanation: Stack is used for overlapping widgets, not for scrollable lists. + +''', +); diff --git a/site/lib/src/style_hash.dart b/site/lib/src/style_hash.dart index e9f740e5508..879e547701c 100644 --- a/site/lib/src/style_hash.dart +++ b/site/lib/src/style_hash.dart @@ -2,4 +2,4 @@ // dart format off /// The generated hash of the `main.css` file. -const generatedStylesHash = 'owxV2FisLq0C'; +const generatedStylesHash = 'URzUaI467vwY'; diff --git a/site/pubspec.yaml b/site/pubspec.yaml index 4a69f28c6ba..706bf7a27cc 100644 --- a/site/pubspec.yaml +++ b/site/pubspec.yaml @@ -24,6 +24,7 @@ dependencies: path: ^1.9.1 pub_semver: ^2.2.0 universal_web: ^1.1.1+1 + yaml: ^3.1.3 dev_dependencies: analysis_defaults: @@ -31,7 +32,7 @@ dev_dependencies: url: https://github.com/dart-lang/site-shared path: pkgs/analysis_defaults ref: f91ed8ecef6a0b31685804fe4102b25fda021460 - build_runner: ^2.10.1 + build_runner: ^2.10.2 build_web_compilers: ^4.4.0 jaspr_builder: ^0.21.6 sass: ^1.93.3