Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions site/lib/_sass/_site.scss
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
@use 'components/theming';
@use 'components/tooltip';
@use 'components/trailing';
@use 'components/tutorial_pages';

// Styles for specific pages, alphabetically ordered.
@use 'pages/glossary';
Expand Down
7 changes: 7 additions & 0 deletions site/lib/_sass/base/_utils.scss
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,15 @@ main {
.simple-border {
border: 1px solid var(--site-inset-borderColor);
}

.center {
display: flex;
justify-content: center;

}
}

.text-center {
text-align: center;
}

13 changes: 12 additions & 1 deletion site/lib/_sass/components/_summary-card.scss
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,15 @@
}
}
}
}

.summary-card-item-static {
.summary-card-item-details {
padding: .8rem 1.2rem;
color: var(--site-base-fgColor-lighter);

>:last-child {
margin-bottom: 0;
}
}
}
}
40 changes: 40 additions & 0 deletions site/lib/_sass/components/_tutorial_pages.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Styles for TutorialLesson component sections.

.tutorial-lesson {
// Container for the entire tutorial lesson

.tutorial-intro {
margin: 1rem 0;

.description {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1rem;
}

.intro-video {
display: flex;
justify-content: center;
background-color: var(--site-diagram-wrap-bgColor);
padding: 1rem;
border-radius: 1rem;

margin-bottom: 1.5rem;
}
}

.tutorial-steps {
margin: 2rem;
}

.tutorial-divider {
border: none;
border-top: 1px solid var(--site-inset-borderColor);
margin: 3rem 1rem;
}

img {
display:flex;
justify-self: center;
}
}
2 changes: 2 additions & 0 deletions site/lib/main.server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import 'src/components/tutorial/progress_ring.dart';
import 'src/components/tutorial/quiz.dart';
import 'src/components/tutorial/stepper.dart';
import 'src/components/tutorial/summary_card.dart';
import 'src/components/tutorial/tutorial_lesson.dart';
import 'src/components/tutorial/tutorial_outline.dart';
import 'src/components/util/component_ref.dart';
import 'src/extensions/registry.dart';
Expand Down Expand Up @@ -118,6 +119,7 @@ List<CustomComponent> get _embeddableComponents => [
const SummaryCard(),
const DownloadableSnippet(),
const Stepper(),
const TutorialLesson(),
const WidgetCatalogCategories(),
const TutorialOutline(),
const WidgetCatalogGrid(),
Expand Down
24 changes: 22 additions & 2 deletions site/lib/src/components/tutorial/summary_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,25 @@ class SummaryCard extends CustomComponent {
model.items.isNotEmpty,
'SummaryCard must contain at least one item.',
);
return SummaryCardComponent(model: model);

final expandsAttr = node.attributes['expands'];
final expands = expandsAttr != 'false';

return SummaryCardComponent(model: model, expands: expands);
}
return null;
}
}

class SummaryCardComponent extends StatelessComponent {
const SummaryCardComponent({super.key, required this.model});
const SummaryCardComponent({
super.key,
required this.model,
this.expands = true,
});

final SummaryCardModel model;
final bool expands;

@override
Component build(BuildContext context) {
Expand All @@ -65,6 +74,17 @@ class SummaryCardComponent extends StatelessComponent {

Component _buildSummaryItem(SummaryCardItem item) {
if (item.details case final d?) {
if (!expands) {
return div(classes: 'summary-card-item-static', [
div(classes: 'summary-card-item', [
span([MaterialIcon(item.icon)]),
span(classes: 'summary-card-item-title', [.text(item.title)]),
]),
div(classes: 'summary-card-item-details', [
DashMarkdown(content: d),
]),
]);
}
return details([
summary(classes: 'summary-card-item', [
span([MaterialIcon(item.icon)]),
Expand Down
132 changes: 132 additions & 0 deletions site/lib/src/components/tutorial/tutorial_lesson.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Copyright 2025 The Flutter Authors. All rights reserved.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sure this isn't idiomatic. Maybe this shouldn't be here at all. It felt better than putting a bunch of in every markdown file. Is there a better way?

// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'package:jaspr/dom.dart';
import 'package:jaspr/jaspr.dart';
import 'package:jaspr_content/jaspr_content.dart';

/// A component that provides the structure for a tutorial lesson page.
class TutorialLesson extends CustomComponent {
const TutorialLesson() : super.base();

@override
Component? create(Node node, NodesBuilder builder) {
if (node case ElementNode(tag: 'TutorialLesson', :final children?)) {
List<Node>? introContent;
List<Node>? stepsContent;
List<Node>? outroContent;

for (final child in children) {
if (child case ElementNode(tag: 'TutorialIntro', :final children?)) {
introContent = children;
} else if (child case ElementNode(
tag: 'TutorialSteps',
:final children?,
)) {
stepsContent = children;
} else if (child case ElementNode(
tag: 'TutorialOutro',
:final children?,
)) {
outroContent = children;
}
}

return section(classes: 'tutorial-lesson', [
// Intro section
if (introContent != null)
section(classes: 'tutorial-intro', [
..._buildIntroContent(introContent, builder),
]),
// Divider between intro and steps
if (introContent != null && stepsContent != null)
const hr(classes: 'tutorial-divider'),
// Steps section
if (stepsContent != null)
section(classes: 'tutorial-steps', [
const h2([.text('Steps')]),
// Wrap steps content in a Stepper with level="3"
builder.build([
ElementNode('Stepper', {'level': '3'}, stepsContent),
]),
]),
// Outro section
if (outroContent != null)
section(classes: 'tutorial-outro', [
builder.build(outroContent),
]),
Comment on lines +55 to +58
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is likely unneeded (and currently not being used at all). I will remove it before merge

]);
}

return null;
}

/// Builds the intro content, automatically wrapping:
/// - Paragraph elements (`<p>`) get 'description' class added
/// - YouTubeEmbed elements in a div with class 'intro-video'
List<Component> _buildIntroContent(List<Node> content, NodesBuilder builder) {
final wrappedContent = <Component>[];

for (final node in content) {
if (node case ElementNode(tag: 'p', :final children)) {
// Check if paragraph contains only a video embed
final videoChild = _findVideoChild(children);
if (videoChild != null) {
// Extract video from paragraph and wrap in intro-video div
wrappedContent.add(
div(classes: 'intro-video', [
builder.build([videoChild]),
]),
);
} else {
// Regular paragraph - add description class
wrappedContent.add(
p(classes: 'description', [builder.build(children ?? [])]),
);
}
} else if (_isVideoEmbed(node)) {
// Direct video embed (not wrapped in paragraph)
wrappedContent.add(
div(classes: 'intro-video', [
builder.build([node]),
]),
);
} else {
// Pass through other elements (SummaryCard, comments, etc.)
wrappedContent.add(builder.build([node]));
}
}

return wrappedContent;
}

/// Checks if a node is a YouTube video embed
bool _isVideoEmbed(Node node) {
if (node case ElementNode(tag: final tag)) {
final lowerTag = tag.toLowerCase();
return lowerTag == 'youtubeembed' || lowerTag == 'lite-youtube';
}
return false;
}

/// Finds a video embed child in a list of nodes, returns it if it's the only
/// meaningful content (ignoring whitespace text nodes)
Node? _findVideoChild(List<Node>? children) {
if (children == null) return null;

Node? videoChild;
for (final child in children) {
if (_isVideoEmbed(child)) {
videoChild = child;
} else if (child case TextNode(:final text)) {
// Ignore whitespace-only text nodes
if (text.trim().isNotEmpty) return null;
} else {
// Non-video, non-whitespace content found
return null;
}
}
return videoChild;
}
}
Comment on lines +105 to +132
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was written by gemini. It feels wrong to me, but I'm not sure what the proper Jaspr way to accomplish this is.

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading