diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 079ade15620..55a758e5c1a 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -29,11 +29,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 + uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 + uses: github/codeql-action/init@fdbfb4d2750291e159f0156def62b853c2798ca2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -44,7 +44,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@fe4161a26a8629af62121b670040955b330f9af2 + uses: github/codeql-action/autobuild@fdbfb4d2750291e159f0156def62b853c2798ca2 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -58,4 +58,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@fe4161a26a8629af62121b670040955b330f9af2 + uses: github/codeql-action/analyze@fdbfb4d2750291e159f0156def62b853c2798ca2 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 08a25daf355..d8557dce963 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,7 +30,7 @@ jobs: experimental: true continue-on-error: ${{ matrix.experimental }} steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 - uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 with: channel: ${{ matrix.branch }} @@ -52,7 +52,7 @@ jobs: runs-on: ubuntu-latest if: github.repository == 'flutter/website' steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 - uses: dart-lang/setup-dart@e51d8e571e22473a2ddebf0ef8a2123f0ab2c02c with: sdk: beta @@ -67,7 +67,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 - uses: dart-lang/setup-dart@e51d8e571e22473a2ddebf0ef8a2123f0ab2c02c with: sdk: beta @@ -87,7 +87,7 @@ jobs: runs-on: ubuntu-latest if: github.repository == 'flutter/website' steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 - uses: dart-lang/setup-dart@e51d8e571e22473a2ddebf0ef8a2123f0ab2c02c with: sdk: beta diff --git a/firebase.json b/firebase.json index f8a68461b79..49a9048c422 100644 --- a/firebase.json +++ b/firebase.json @@ -361,7 +361,6 @@ { "source": "/go/dash-tooling-plugin-strategy", "destination": "https://docs.google.com/document/d/1Zc0AE8JTKfOSA-IFpEYcPFJ2eALbXE3AG4ZucWXeMig/", "type": 301 }, { "source": "/go/data-sync", "destination": "https://docs.google.com/document/d/1yH96-p-SkMmt6hL5xHHDtMvCKRz2XGrMuw9ZY_nE954", "type": 301 }, { "source": "/go/decouple-design", "destination": "https://docs.google.com/document/d/189AbzVGpxhQczTcdfJd13o_EL36t-M5jOEt1hgBIh7w/edit?usp=sharing", "type": 301 }, - { "source": "/go/decoupling-design-from-text", "destination": "https://docs.google.com/document/d/1oFezK5leJzTWA5lsw3BQGx7gLbhpSL8dMleU3HD7bNY/edit?usp=sharing", "type": 301}, { "source": "/go/dds-daemon", "destination": "https://docs.google.com/document/d/18IgFakijiv9CLFGT5BckbwZuf2pqhOUeN27mB9XqvpQ/edit?usp=sharing&resourcekey=0-rBHvH9gLXLjGPWt5WE-XFg", "type": 301 }, { "source": "/go/decoupling-framework-tests", "destination": "https://docs.google.com/document/d/1UHxALQqCbmgjnM1RNV9xE2pK3IGyx-UktGX1D7hYCjs/edit?pli=1&tab=t.0", "type": 301 }, { "source": "/go/deep-link-flag-migration", "destination": "https://docs.google.com/document/d/1TUhaEhNdi2BUgKWQFEbOzJgmUAlLJwIAhnFfZraKgQs/edit?usp=sharing", "type": 301 }, diff --git a/site/lib/_sass/base/_base.scss b/site/lib/_sass/base/_base.scss index ca8258a344c..de0e65ce20a 100644 --- a/site/lib/_sass/base/_base.scss +++ b/site/lib/_sass/base/_base.scss @@ -8,10 +8,8 @@ body { color: var(--site-base-fgColor); // The top TOC is not shown on narrow screens. - &:not(:has(#site-subheader.show-always)) { - @media (min-width: 1200px) { - --site-subheader-height: 0rem; - } + @media (min-width: 1200px) { + --site-subheader-height: 0rem; } // If the TOC is disabled, reduce the subheader height to diff --git a/site/lib/_sass/components/_code.scss b/site/lib/_sass/components/_code.scss index 32e277076e7..b77aa5e00ce 100644 --- a/site/lib/_sass/components/_code.scss +++ b/site/lib/_sass/components/_code.scss @@ -130,8 +130,11 @@ pre { } } - span[aria-hidden="true"] { - user-select: none; + .terminal-command::before { + color: $margin-fgColor; + content: "$"; + content: "$" / ""; + padding-right: 0.5rem; } } diff --git a/site/lib/_sass/components/_header.scss b/site/lib/_sass/components/_header.scss index 57528e26ad4..d2926018f9d 100644 --- a/site/lib/_sass/components/_header.scss +++ b/site/lib/_sass/components/_header.scss @@ -7,10 +7,8 @@ border-bottom: 0.1rem solid var(--site-outline-variant); @media (min-width: 1200px) { - &:not(:has(~* #site-subheader.show-always)) { - box-shadow: 0 2px 4px rgba(0, 0, 0, .05); - border-bottom: none; - } + box-shadow: 0 2px 4px rgba(0, 0, 0, .05); + border-bottom: none; } .navbar { @@ -188,9 +186,7 @@ body.open_menu #menu-toggle span.material-symbols { border-bottom: 0.1rem solid var(--site-outline-variant); box-shadow: 0 2px 4px rgba(0, 0, 0, .05); - &:not(.show-always) { - @media (width < 240px), (width >= 1200px) { - display: none; - } + @media (width < 240px), (width >= 1200px) { + display: none; } } diff --git a/site/lib/_sass/components/_pagenav.scss b/site/lib/_sass/components/_pagenav.scss index f61d7ebf4e7..c541f41ca65 100644 --- a/site/lib/_sass/components/_pagenav.scss +++ b/site/lib/_sass/components/_pagenav.scss @@ -1,7 +1,6 @@ #pagenav { flex-grow: 1; min-width: 0; - max-width: 100%; >button.dropdown-button { display: flex; @@ -24,56 +23,29 @@ color: var(--site-base-fgColor-alt); font-size: 20px; } - - >.material-symbols:first-child { - margin-right: 0.25rem; - } } - .toc-breadcrumb { - flex-shrink: 2; + .toc-intro { white-space: nowrap; - overflow: hidden; - - &.toc-hide-medium { - @media (width < 576px) { - display: none; - } - } - &.toc-hide-small { - @media (width < 420px) { - display: none; - } - } - - span:not(.material-symbols) { - overflow: hidden; - text-overflow: ellipsis; + .material-symbols { + margin-right: 0.25rem; } + } - .page-number { - flex-shrink: 0; - height: 1.3rem; - width: 1.3rem; + .toc-current { + display: none; - margin-right: 0.4rem; - background-color: var(--site-primary-color); - color: var(--site-onPrimary-color-lightest); + @media (min-width: 320px) { + display: flex; } - } - .toc-current { - flex-shrink: 1; + flex-wrap: nowrap; white-space: nowrap; overflow: hidden; + text-overflow: ellipsis; color: var(--site-base-fgColor-alt); - - span:last-child { - overflow: hidden; - text-overflow: ellipsis; - } } #pagenav-content { @@ -92,13 +64,7 @@ scrollbar-width: thin; overscroll-behavior: contain; - padding: 0.5rem; - - >div { - display: flex; - flex-direction: column; - gap: 0.25rem; - } + padding: 0.2rem 0.4rem; @media (min-width: 420px) { border: none; @@ -119,7 +85,6 @@ text-decoration: none; display: flex; align-items: center; - gap: 4px; color: var(--site-base-fgColor-alt); font-weight: 500; @@ -128,6 +93,10 @@ user-select: none; } + span:last-child { + margin-left: 3px; + } + &:hover { color: var(--site-link-fgColor); } @@ -140,58 +109,5 @@ nav { padding: 0.6rem 0 0.8rem; } - - .page-link { - display: flex; - align-items: center; - gap: 0.5rem; - - padding: 0; - font-weight: 400; - text-decoration: none; - color: var(--site-base-fgColor); - - &:hover { - color: var(--site-link-fgColor); - } - - &.active .page-number { - background-color: var(--site-primary-color); - color: var(--site-onPrimary-color-lightest); - } - - &:not(.active):has(~.page-link.active) .page-number { - background-color: var(--site-onPrimary-color-light); - color: var(--site-primary-color); - } - - ~nav { - padding: 0; - } - } - - .page-divider { - padding: 0.25rem; - font-weight: 600; - color: var(--site-base-fgColor-alt); - } - - .dropdown-divider:has(~.page-link) { - margin-top: 0.6rem; - } - } - - .page-number { - width: 25px; - height: 25px; - border-radius: 50%; - background: var(--site-inset-borderColor); - color: var(--site-base-fgColor); - display: inline-flex; - align-items: center; - justify-content: center; - font-weight: 500; - - transition: background-color 300ms ease, color 300ms ease; } -} \ No newline at end of file +} diff --git a/site/lib/_sass/components/_tooltip.scss b/site/lib/_sass/components/_tooltip.scss index 5584613fb15..cc71c232248 100644 --- a/site/lib/_sass/components/_tooltip.scss +++ b/site/lib/_sass/components/_tooltip.scss @@ -60,4 +60,4 @@ visibility: visible; } } -} \ No newline at end of file +} diff --git a/site/lib/jaspr_options.dart b/site/lib/jaspr_options.dart index 26353cf6c4a..3ebd94e678f 100644 --- a/site/lib/jaspr_options.dart +++ b/site/lib/jaspr_options.dart @@ -190,9 +190,7 @@ Map _prefix10DartPadInjector(prefix10.DartPadInjector c) => { 'runAutomatically': c.runAutomatically, }; Map _prefix11PageNav(prefix11.PageNav c) => { - 'breadcrumbs': c.breadcrumbs, - 'pageNumber': c.pageNumber, - 'initialHeading': c.initialHeading, + 'title': c.title, 'content': c.content.toId(), }; Map _prefix15ArchiveTable(prefix15.ArchiveTable c) => { diff --git a/site/lib/main.dart b/site/lib/main.dart index 00c376b0c0b..5f8a303c9b5 100644 --- a/site/lib/main.dart +++ b/site/lib/main.dart @@ -16,23 +16,20 @@ import 'src/components/common/code_preview.dart'; import 'src/components/common/dash_image.dart'; import 'src/components/common/tabs.dart'; import 'src/components/common/youtube_embed.dart'; -import 'src/components/pages/architecture_recommendations.dart'; 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/pages/widget_catalog.dart'; import 'src/components/tutorial/downloadable_snippet.dart'; 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_outline.dart'; import 'src/components/util/component_ref.dart'; import 'src/extensions/registry.dart'; +import 'src/layouts/catalog_page_layout.dart'; import 'src/layouts/doc_layout.dart'; import 'src/layouts/toc_layout.dart'; -import 'src/layouts/tutorial_layout.dart'; import 'src/loaders/data_processor.dart'; import 'src/markdown/markdown_parser.dart'; import 'src/pages/custom_pages.dart'; @@ -73,11 +70,7 @@ Component get _docsFlutterDevSite => ContentApp.custom( rawOutputPattern: _passThroughPattern, extensions: allNodeProcessingExtensions, components: _embeddableComponents, - layouts: const [ - DocLayout(), - TocLayout(), - TutorialLayout(), - ], + layouts: const [DocLayout(), TocLayout(), CatalogPageLayout()], theme: const ContentTheme.none(), secondaryOutputs: [ const RobotsTxtOutput(), @@ -116,10 +109,6 @@ List get _embeddableComponents => [ const SummaryCard(), const DownloadableSnippet(), const Stepper(), - const WidgetCatalogCategories(), - const TutorialOutline(), - const WidgetCatalogGrid(), - const ArchitectureRecommendations(), CustomComponent( pattern: RegExp('OSSelector', caseSensitive: false), builder: (_, _, _) => const OsSelector(), diff --git a/site/lib/src/client/global_scripts.dart b/site/lib/src/client/global_scripts.dart index 18aa06909f8..8ae3dc8f6bd 100644 --- a/site/lib/src/client/global_scripts.dart +++ b/site/lib/src/client/global_scripts.dart @@ -117,16 +117,10 @@ void _setUpTabs() { // If the tab wrapper and this tab have a save key and ID defined, // switch other tabs to the tab with the same ID. _findAndActivateTabsWithSaveId(currentSaveKey, currentSaveId); - try { - web.window.localStorage.setItem( - 'tab-save-$currentSaveKey', - currentSaveId, - ); - } catch (e) { - if (kDebugMode) { - print('Error accessing localStorage: $e'); - } - } + web.window.localStorage.setItem( + 'tab-save-$currentSaveKey', + currentSaveId, + ); } else { _clearActiveTabs(tabs); _setActiveTab(tabElement); @@ -135,19 +129,12 @@ void _setUpTabs() { tabElement.addEventListener('click', handleClick.toJS); - try { - // If a tab was previously specified as selected in local storage, - // save a reference to it that can be switched to later. - final tabSaveKey = localStorageKey != null - ? web.window.localStorage.getItem(localStorageKey) - : null; - if (saveId.isNotEmpty && tabSaveKey != null && tabSaveKey == saveId) { - tabToChangeTo = tabElement; - } - } catch (e) { - if (kDebugMode) { - print('Error accessing localStorage: $e'); - } + // If a tab was previously specified as selected in local storage, + // save a reference to it that can be switched to later. + if (saveId.isNotEmpty && + localStorageKey != null && + web.window.localStorage.getItem(localStorageKey) == saveId) { + tabToChangeTo = tabElement; } } @@ -178,14 +165,8 @@ void _updateTabsFromQueryParameters() { for (final MapEntry(:key, :value) in originalQueryParameters.entries) { if (key.startsWith('tab-save-')) { - try { - web.window.localStorage.setItem(key, value); - updatedQueryParameters.remove(key); - } catch (e) { - if (kDebugMode) { - print('Error accessing localStorage: $e'); - } - } + web.window.localStorage.setItem(key, value); + updatedQueryParameters.remove(key); } } @@ -304,10 +285,10 @@ void _setUpExpandableCards() { }).toJS, ); - // If the card is the currently specified fragment, expand it. - if (card.id == currentFragment) { - card.classList.remove('collapsed'); - expandButton.ariaExpanded = 'true'; + if (card.id != currentFragment) { + card.classList.add('collapsed'); + expandButton.ariaExpanded = 'false'; + } else { targetCard = card; } } diff --git a/site/lib/src/components/common/card.dart b/site/lib/src/components/common/card.dart index 3ba5276e49d..6dde9a7983f 100644 --- a/site/lib/src/components/common/card.dart +++ b/site/lib/src/components/common/card.dart @@ -88,7 +88,6 @@ class Card extends StatelessComponent { if (outlined) 'outlined-card', if (filled) 'filled-card', if (expandable) 'expandable-card', - if (expandable && !initiallyExpanded) 'collapsed', ?additionalClasses, ].toClasses; diff --git a/site/lib/src/components/common/client/cookie_notice.dart b/site/lib/src/components/common/client/cookie_notice.dart index 7254ee452dc..d04fbadc746 100644 --- a/site/lib/src/components/common/client/cookie_notice.dart +++ b/site/lib/src/components/common/client/cookie_notice.dart @@ -26,28 +26,18 @@ final class _CookieNoticeState extends State { void initState() { if (kIsWeb) { var shouldShowNotice = true; - try { - final storedConsent = web.window.localStorage.getItem( - _cookieStorageKey, - ); - if (storedConsent case final lastConsentedMs?) { - if (int.tryParse(lastConsentedMs) case final msFromEpoch?) { - final consentedDateTime = DateTime.fromMillisecondsSinceEpoch( - msFromEpoch, - ); - final difference = consentedDateTime.difference(DateTime.now()); - if (difference.inDays < 180) { - // If consented less than 180 days ago, don't show the notice. - shouldShowNotice = false; - } + if (web.window.localStorage.getItem(_cookieStorageKey) + case final lastConsentedMs?) { + if (int.tryParse(lastConsentedMs) case final msFromEpoch?) { + final consentedDateTime = DateTime.fromMillisecondsSinceEpoch( + msFromEpoch, + ); + final difference = consentedDateTime.difference(DateTime.now()); + if (difference.inDays < 180) { + // If consented less than 180 days ago, don't show the notice. + shouldShowNotice = false; } } - } catch (e) { - // If localStorage is unavailable or throws an error, - // keep the `shouldShowNotice` to true. - if (kDebugMode) { - print('Failed to get stored content: $e'); - } } showNotice = shouldShowNotice; @@ -79,16 +69,10 @@ final class _CookieNoticeState extends State { content: 'OK, got it', style: ButtonStyle.filled, onClick: () { - try { - web.window.localStorage.setItem( - _cookieStorageKey, - DateTime.now().millisecondsSinceEpoch.toString(), - ); - } catch (e) { - if (kDebugMode) { - print('Failed to set stored consent: $e'); - } - } + web.window.localStorage.setItem( + _cookieStorageKey, + DateTime.now().millisecondsSinceEpoch.toString(), + ); setState(() { showNotice = false; }); diff --git a/site/lib/src/components/common/prev_next.dart b/site/lib/src/components/common/prev_next.dart index 4bce2986190..3f207450bcf 100644 --- a/site/lib/src/components/common/prev_next.dart +++ b/site/lib/src/components/common/prev_next.dart @@ -4,8 +4,6 @@ import 'package:jaspr/jaspr.dart'; -import '../../markdown/markdown_parser.dart'; -import '../../models/page_navigation_model.dart'; import 'material_icon.dart'; /// Previous and next page buttons to display at the end of a page @@ -13,8 +11,8 @@ import 'material_icon.dart'; class PrevNext extends StatelessComponent { const PrevNext({super.key, this.previousPage, this.nextPage}); - final PageNavigationEntry? previousPage; - final PageNavigationEntry? nextPage; + final ({String url, String title})? previousPage; + final ({String url, String title})? nextPage; @override Component build(BuildContext context) { @@ -34,7 +32,7 @@ class PrevNext extends StatelessComponent { class _PrevNextCard extends StatelessComponent { const _PrevNextCard({required this.page, required this.isPrevious}); - final PageNavigationEntry page; + final ({String url, String title}) page; final bool isPrevious; @override @@ -51,9 +49,7 @@ class _PrevNextCard extends StatelessComponent { attributes: {'aria-label': ariaLabel}, [text(subtitle)], ), - span(classes: 'prev-next-title', [ - DashMarkdown(inline: true, content: page.title), - ]), + span(classes: 'prev-next-title', [text(page.title)]), ]), if (!isPrevious) const MaterialIcon('chevron_right'), ]); diff --git a/site/lib/src/components/layout/client/pagenav.dart b/site/lib/src/components/layout/client/pagenav.dart index 812e1a9e255..18737c4b68a 100644 --- a/site/lib/src/components/layout/client/pagenav.dart +++ b/site/lib/src/components/layout/client/pagenav.dart @@ -7,7 +7,6 @@ import 'package:universal_web/js_interop.dart'; import 'package:universal_web/web.dart' as web; import '../../../client/global_scripts.dart'; -import '../../../util.dart'; import '../../common/dropdown.dart'; import '../../common/material_icon.dart'; import '../../util/component_ref.dart'; @@ -15,16 +14,12 @@ import '../../util/component_ref.dart'; @client class PageNav extends StatefulComponent { const PageNav({ - this.breadcrumbs = const [], - this.pageNumber, - required this.initialHeading, + required this.title, required this.content, super.key, }); - final List breadcrumbs; - final int? pageNumber; - final String initialHeading; + final String title; final ComponentRef content; @override @@ -68,45 +63,21 @@ class _PageNavState extends State { 'aria-label': 'Toggle the table of contents dropdown', }, [ - const MaterialIcon('list'), - if (component.breadcrumbs.isEmpty) - span(classes: 'toc-breadcrumb', [ - span( - attributes: {'aria-label': 'On this page'}, - [text('On this page')], - ), - const MaterialIcon('chevron_right'), - ]) - else ...[ - for (final (index, crumb) in component.breadcrumbs.indexed) ...[ - span( - classes: [ - 'toc-breadcrumb', - if (index < component.breadcrumbs.length - 2) - 'toc-hide-medium', - if (index < component.breadcrumbs.length - 1) - 'toc-hide-small', - ].toClasses, - [ - if (index == component.breadcrumbs.length - 1 && - component.pageNumber != null) - span(classes: 'page-number', [ - text('${component.pageNumber}'), - ]), - span([ - _simpleInlineMarkdown(crumb), - ]), - const MaterialIcon('chevron_right'), - ], - ), - ], - ], - + span(classes: 'toc-intro', [ + const MaterialIcon('list'), + span( + attributes: {'aria-label': 'On this page'}, + [ + text('On this page'), + ], + ), + ]), span(classes: 'toc-current', [ + const MaterialIcon('chevron_right'), ValueListenableBuilder( listenable: currentPageHeading, builder: (context, value) { - return span([text(value ?? component.initialHeading)]); + return span([text(value ?? component.title)]); }, ), ]), @@ -115,33 +86,4 @@ class _PageNavState extends State { content: component.content.component, ); } - - /// Simple (and incomplete) implementation of inline markdown parsing - /// for use on the client. - Component _simpleInlineMarkdown(String content) { - final syntaxRegex = RegExp(r'`([^`]+)`|\*([^*]+)\*|\*\*([^*]+)\*\*'); - - final components = []; - - var current = 0; - final matches = syntaxRegex.allMatches(content); - - for (final match in matches) { - if (match.start > current) { - components.add(text(content.substring(current, match.start))); - } - if (match.group(1) != null) { - components.add(code([text(match.group(1)!)])); - } else if (match.group(2) != null) { - components.add(em([text(match.group(2)!)])); - } else if (match.group(3) != null) { - components.add(strong([text(match.group(3)!)])); - } - current = match.end; - } - if (current < content.length) { - components.add(text(content.substring(current))); - } - return components.length > 1 ? fragment(components) : components.first; - } } diff --git a/site/lib/src/components/layout/header.dart b/site/lib/src/components/layout/header.dart index 2daabbb885d..14a0d962f6e 100644 --- a/site/lib/src/components/layout/header.dart +++ b/site/lib/src/components/layout/header.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. import 'package:jaspr/jaspr.dart'; -import 'package:jaspr_content/jaspr_content.dart'; import '../common/button.dart'; import '../common/material_icon.dart'; @@ -85,7 +84,7 @@ class DashHeader extends StatelessComponent { content: 'Get started', href: '/get-started/quick', ), - if (context.page.data['sidenav'] != null) const MenuToggle(), + const MenuToggle(), ], ), ]), diff --git a/site/lib/src/components/layout/theme_switcher.dart b/site/lib/src/components/layout/theme_switcher.dart index aa6ec89de02..3b077ea0e2b 100644 --- a/site/lib/src/components/layout/theme_switcher.dart +++ b/site/lib/src/components/layout/theme_switcher.dart @@ -73,13 +73,7 @@ final class _ThemeSwitcherState extends State { ); } - try { - web.window.localStorage.setItem('theme', newTheme.id); - } catch (e) { - if (kDebugMode) { - print('Failed to save theme preference: $e'); - } - } + web.window.localStorage.setItem('theme', newTheme.id); setState(() { _currentTheme = newTheme; diff --git a/site/lib/src/components/layout/toc.dart b/site/lib/src/components/layout/toc.dart index 8c2785d665f..4b0d5c50634 100644 --- a/site/lib/src/components/layout/toc.dart +++ b/site/lib/src/components/layout/toc.dart @@ -3,11 +3,8 @@ // found in the LICENSE file. import 'package:jaspr/jaspr.dart'; -import 'package:jaspr_content/jaspr_content.dart'; -import '../../markdown/markdown_parser.dart'; -import '../../models/page_navigation_model.dart'; -import '../../util.dart'; +import '../../models/on_this_page_model.dart'; import '../common/client/on_this_page_button.dart'; import '../common/material_icon.dart'; import '../util/component_ref.dart'; @@ -16,7 +13,7 @@ import 'client/pagenav.dart'; final class DashTableOfContents extends StatelessComponent { const DashTableOfContents(this.data); - final TocNavigationData data; + final OnThisPageData data; @override Component build(BuildContext _) { @@ -25,109 +22,38 @@ final class DashTableOfContents extends StatelessComponent { _TocContents(data), ]); } -} - -final class PageNavBar extends StatelessComponent { - const PageNavBar(this.data); - - final PageNavigationData data; - - @override - Component build(BuildContext context) { - PageNavigationEntry? currentLinkedPage; - var currentLinkedPageNumber = 1; - String? currentDivider; - - final normalizedPageUrl = context.page.url.endsWith('/') - ? context.page.url - : '${context.page.url}/'; - - for (final page in data.pageEntries) { - final normalizedEntryUrl = page.url.endsWith('/') - ? page.url - : '${page.url}/'; - if (normalizedEntryUrl == normalizedPageUrl) { - currentLinkedPage = page; - break; - } - if (page.isDivider) { - currentDivider = page.title; - } else { - currentLinkedPageNumber++; - } - } - - final linkedPageTitle = currentLinkedPage?.title; - final currentTitle = context.page.data.page['title'] as String; - var pageEntryNumber = 1; - - return PageNav( - breadcrumbs: [ - ?data.parentTitle, - if (linkedPageTitle != null) ...[?currentDivider, linkedPageTitle], - ], - pageNumber: linkedPageTitle != null ? currentLinkedPageNumber : null, - initialHeading: currentTitle, - content: context.ref( - div([ - if (data.pageEntries.isEmpty) ...[ - a( - href: '#site-content-title', - id: 'return-to-top', - [ - const MaterialIcon('vertical_align_top'), - span([text(currentTitle)]), - ], - ), - div( - classes: 'dropdown-divider', - attributes: {'aria-hidden': 'true', 'role': 'separator'}, - [], - ), - if (data.toc != null) + static Component asDropdown( + OnThisPageData data, { + required String currentTitle, + }) { + return Builder( + builder: (context) { + return PageNav( + title: currentTitle, + content: context.ref( + div([ + a( + href: '#site-content-title', + id: 'return-to-top', + [ + const MaterialIcon('vertical_align_top'), + span([text(currentTitle)]), + ], + ), + div( + classes: 'dropdown-divider', + attributes: {'aria-hidden': 'true', 'role': 'separator'}, + [], + ), nav( attributes: {'role': 'menu'}, - [_TocContents(data.toc!)], + [_TocContents(data)], ), - ] else ...[ - for (final page in data.pageEntries) ...[ - if (!page.isDivider) ...[ - a( - classes: [ - 'page-link', - if (page == currentLinkedPage) 'active', - ].toClasses, - href: page.url, - attributes: {'role': 'menuitem'}, - [ - span(classes: 'page-number', [ - text('${pageEntryNumber++}'), - ]), - DashMarkdown(inline: true, content: page.title), - ], - ), - if (currentLinkedPage == page && data.toc != null) - nav( - attributes: {'role': 'menu'}, - [_TocContents(data.toc!)], - ), - ] else ...[ - if (page != data.pageEntries.first) - div( - classes: 'dropdown-divider', - attributes: {'aria-hidden': 'true', 'role': 'separator'}, - [], - ), - div( - classes: 'page-divider', - [text(page.title)], - ), - ], - ], - ], - ]), - ), + ]), + ), + ); + }, ); } } @@ -135,7 +61,7 @@ final class PageNavBar extends StatelessComponent { final class _TocContents extends StatelessComponent { const _TocContents(this.data); - final TocNavigationData data; + final OnThisPageData data; @override Component build(BuildContext _) => ul( @@ -143,7 +69,7 @@ final class _TocContents extends StatelessComponent { _buildEntries(data.topLevelEntries, 0), ); - List _buildEntries(List entries, int depth) { + List _buildEntries(List entries, int depth) { final nextDepth = depth + 1; return [ diff --git a/site/lib/src/components/pages/architecture_recommendations.dart b/site/lib/src/components/pages/architecture_recommendations.dart deleted file mode 100644 index 2df8ee6e319..00000000000 --- a/site/lib/src/components/pages/architecture_recommendations.dart +++ /dev/null @@ -1,111 +0,0 @@ -// 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 '../../markdown/markdown_parser.dart'; - -class ArchitectureRecommendations extends CustomComponentBase { - const ArchitectureRecommendations(); - - @override - Pattern get pattern => 'ArchitectureRecommendations'; - - @override - Component apply( - String name, - Map attributes, - Component? child, - ) { - return Builder( - builder: (context) { - final recommendations = - (context.page.data['architectureRecommendations'] as List) - .cast>() - .map(ArchitectureRecommendationCategory._) - .toList(); - - final categoryId = attributes['category']; - - final category = recommendations - .where((category) => category.category == categoryId) - .firstOrNull; - if (category == null) { - throw ArgumentError('Category $categoryId not found'); - } - - if (category.recommendations.isEmpty) { - return const Component.empty(); - } - - return table( - classes: 'table table-striped', - styles: Styles( - border: Border.only( - bottom: BorderSide.solid( - color: const Color('#DADCE0'), - width: 1.px, - ), - ), - ), - [ - thead([ - tr([ - th(styles: Styles(width: 30.percent), [ - text('Recommendation'), - ]), - th(styles: Styles(width: 70.percent), [ - text('Description'), - ]), - ]), - ]), - tbody([ - for (final rec in category.recommendations) - tr([ - td([ - DashMarkdown(inline: true, content: rec.recommendation), - switch (rec.confidence) { - 'strong' => div(classes: 'rrec-pill success', [ - text('Strongly recommend'), - ]), - 'recommend' => div(classes: 'rrec-pill info', [ - text('Recommend'), - ]), - _ => div(classes: 'rrec-pill', [ - text('Conditional'), - ]), - }, - ]), - td([ - DashMarkdown(content: rec.description), - if (rec.confidenceDescription - case final String confidenceDescription) - DashMarkdown(content: confidenceDescription), - ]), - ]), - ]), - ], - ); - }, - ); - } -} - -extension type ArchitectureRecommendationCategory._(Map data) { - String get category => data['category'] as String; - List get recommendations => - (data['recommendations'] as List) - .cast>() - .map(ArchitectureRecommendationItem._) - .toList(); -} - -extension type ArchitectureRecommendationItem._(Map data) { - String get recommendation => data['recommendation'] as String; - String get description => data['description'] as String; - String get confidence => data['confidence'] as String; - String? get confidenceDescription => - data['confidence-description'] as String?; -} diff --git a/site/lib/src/components/pages/widget_catalog.dart b/site/lib/src/components/pages/widget_catalog.dart deleted file mode 100644 index 7b3635dfd5b..00000000000 --- a/site/lib/src/components/pages/widget_catalog.dart +++ /dev/null @@ -1,185 +0,0 @@ -// 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:collection/collection.dart'; -import 'package:jaspr/jaspr.dart'; -import 'package:jaspr_content/jaspr_content.dart'; - -import '../../markdown/markdown_parser.dart'; -import '../../models/widget_catalog_model.dart'; -import '../../util.dart'; - -class WidgetCatalogCategories extends CustomComponentBase { - const WidgetCatalogCategories(); - - @override - Pattern get pattern => 'WidgetCatalogCategories'; - - @override - Component apply( - String name, - Map attributes, - Component? child, - ) { - return Builder( - builder: (context) { - final categories = switch (context.page.data) { - {'catalog': {'index': final List index}} => - index - .cast>() - .map(WidgetCatalogCategory.new) - .sortedBy((c) => c.name), - _ => throw Exception( - 'Widget Catalog not found. ' - 'Make sure the `data/catalog/index.yml` file exists.', - ), - }; - - const excludedCategories = { - 'Cupertino', - 'Material components', - 'Material 2 components', - }; - - return div(classes: 'card-grid', [ - for (final category in categories) - if (!excludedCategories.contains(category.name)) - a( - href: '${context.page.url}/${category.id}', - classes: 'card outlined-card', - [ - div(classes: 'card-header', [ - span(classes: 'card-title', [text(category.name)]), - ]), - div(classes: 'card-content', [ - p([text(category.description)]), - ]), - ], - ), - ]); - }, - ); - } -} - -class WidgetCatalogGrid extends CustomComponentBase { - const WidgetCatalogGrid(); - - @override - Pattern get pattern => 'WidgetCatalogGrid'; - - @override - Component apply( - String name, - Map attributes, - Component? child, - ) { - return Builder( - builder: (context) { - final widgets = switch (context.page.data) { - {'catalog': {'widgets': final List widgets}} => - widgets - .cast>() - .map(WidgetCatalogWidget.new) - .sortedBy((c) => c.name), - _ => throw Exception( - 'Catalog not found. ' - 'Make sure the `data/catalog/widgets.yml` file exists.', - ), - }; - - return div(classes: 'card-grid', [ - for (final widget in widgets) WidgetCatalogCard(widget: widget), - ]); - }, - ); - } -} - -class WidgetCatalogCard extends StatelessComponent { - const WidgetCatalogCard({ - required this.widget, - this.isMaterialCatalog = false, - this.subcategory, - super.key, - }); - - final WidgetCatalogWidget widget; - final bool isMaterialCatalog; - final WidgetCatalogSubcategory? subcategory; - - @override - Component build(BuildContext context) { - return a(href: widget.link, classes: 'card outlined-card', [ - _buildCardImageHolder(), - div(classes: 'card-header', [ - span(classes: 'card-title', [text(widget.name)]), - ]), - div(classes: 'card-content', [ - p([ - DashMarkdown( - inline: true, - content: truncateWords(widget.description, 25), - ), - ]), - ]), - ]); - } - - Component _buildCardImageHolder() { - final holderClass = isMaterialCatalog - ? 'card-image-holder-material-3' - : 'card-image-holder'; - - final imageAlt = isMaterialCatalog - ? 'Rendered example of the ${widget.name} Material widget.' - : 'Rendered image or visualization of the ${widget.name} widget.'; - - final styleAttributes = isMaterialCatalog && subcategory?.color != null - ? {'style': '--bg-color: ${subcategory?.color}'} - : {}; - - final placeholder = img( - alt: - 'Placeholder Flutter logo in place of ' - 'missing widget image or visualization.', - src: '/assets/images/docs/catalog-widget-placeholder.png', - attributes: {'aria-hidden': 'true'}, - ); - - return div( - classes: holderClass, - attributes: styleAttributes, - [ - if (isMaterialCatalog) ...[ - // Material catalog always expects an image. - if (widget.imageSrc case final imageSrc? when imageSrc.isNotEmpty) - img(alt: imageAlt, src: imageSrc) - else - placeholder, - if (widget.hoverBackgroundSrc case final hoverBackgroundSrc? - when hoverBackgroundSrc.isNotEmpty) - div(classes: 'card-image-material-3-hover', [ - img( - alt: - 'Decorated background for ' - 'Material widget visualizations.', - src: hoverBackgroundSrc, - attributes: {'aria-hidden': 'true'}, - ), - ]), - ] else ...[ - // Standard catalog prefers vector, then image, then placeholder. - if (widget.vector case final vector? when vector.isNotEmpty) - raw(vector) - else if (widget.imageSrc case final imageSrc? - when imageSrc.isNotEmpty) - img(alt: imageAlt, src: imageSrc) - else - placeholder, - ], - ], - ); - } -} diff --git a/site/lib/src/components/tutorial/tutorial_outline.dart b/site/lib/src/components/tutorial/tutorial_outline.dart deleted file mode 100644 index 0b960befe11..00000000000 --- a/site/lib/src/components/tutorial/tutorial_outline.dart +++ /dev/null @@ -1,50 +0,0 @@ -// 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 '../../markdown/markdown_parser.dart'; -import '../../models/tutorial_model.dart'; - -class TutorialOutline extends CustomComponentBase { - const TutorialOutline(); - - @override - Pattern get pattern => 'TutorialOutline'; - - @override - Component apply( - String name, - Map attributes, - Component? child, - ) { - return Builder( - builder: (context) { - final model = switch (context.page.data['tutorial']) { - final Map? tutorialData when tutorialData != null => - TutorialModel.fromMap(tutorialData), - _ => throw Exception('No tutorial data found.'), - }; - - return div(classes: 'tutorial-outline', [ - ol([ - for (final unit in model.units) - li([ - text(unit.title), - ol([ - for (final chapter in unit.chapters) - li([ - a(href: chapter.url, [ - DashMarkdown(content: chapter.title, inline: true), - ]), - ]), - ]), - ]), - ]), - ]); - }, - ); - } -} diff --git a/site/lib/src/extensions/code_block_processor.dart b/site/lib/src/extensions/code_block_processor.dart index 676f5eed291..aa1668e5740 100644 --- a/site/lib/src/extensions/code_block_processor.dart +++ b/site/lib/src/extensions/code_block_processor.dart @@ -88,11 +88,6 @@ final class CodeBlockProcessor implements PageExtension { ); } - final isCollapsed = metadata.containsKey('collapsed'); - if (isCollapsed && title == null) { - throw ArgumentError('Collapsed code blocks must have a title.'); - } - final isFolding = metadata.containsKey('foldable'); final diffResult = isDiff @@ -131,7 +126,6 @@ final class CodeBlockProcessor implements PageExtension { tag: tag != null ? CodeBlockTag.parse(tag) : null, initialLineNumber: initialLineNumber ?? 1, showLineNumbers: showLineNumbers, - collapsed: isCollapsed, ), ); } @@ -157,56 +151,19 @@ final class CodeBlockProcessor implements PageExtension { _ => opal.BuiltInLanguages.text, }; final highlightedSpans = languageHighlighter.tokenize(content); - var renderedSpans = highlighter.ThemedSpanRenderer( + final renderedSpans = highlighter.ThemedSpanRenderer( themeByName: { 'light': highlighter.SyntaxHighlightingTheme(dashLightTheme), 'dark': highlighter.SyntaxHighlightingTheme(dashDarkTheme), }, ).render(highlightedSpans); - if (language == 'console') { - renderedSpans = _applyConsoleStyles(renderedSpans); - } - return [ for (var i = 0; i < renderedSpans.length; i++) _processLine(renderedSpans[i], codeLines[i].highlights), ]; } - static const _consolePromptTokenTag = '__console_prompt_token'; - - static List> _applyConsoleStyles( - List> lines, - ) { - return [ - for (final line in lines) - if (line case [ - final span, - ...final rest, - ] when span.content.startsWith('\$ ')) - [ - highlighter.ThemedSpan( - content: '\$ ', - styleByTheme: { - 'light': dashLightTheme[opal.Tags.comment]!, - 'dark': dashDarkTheme[opal.Tags.comment]!, - }, - tag: _consolePromptTokenTag, - ), - if (span.content.length > 2) - highlighter.ThemedSpan( - content: span.content.substring(2), - styleByTheme: span.styleByTheme, - tag: span.tag, - ), - ...rest, - ] - else - line, - ]; - } - static List _processLine( List spans, List<({int startColumn, int length})> highlights, @@ -316,7 +273,6 @@ final class CodeBlockProcessor implements PageExtension { [jaspr.text(content ?? span.content)], attributes: { 'style': ?span.toInlineStyle(defaultTheme: 'light'), - if (span.tag == _consolePromptTokenTag) 'aria-hidden': 'true', }, ); } diff --git a/site/lib/src/extensions/registry.dart b/site/lib/src/extensions/registry.dart index 13f168e8019..dc65b250513 100644 --- a/site/lib/src/extensions/registry.dart +++ b/site/lib/src/extensions/registry.dart @@ -10,7 +10,6 @@ import 'glossary_link_processor.dart'; import 'header_extractor.dart'; import 'header_processor.dart'; import 'table_processor.dart'; -import 'tutorial_prefetch_processor.dart'; /// A list of all node-processing, page extensions to applied to /// content loaded with Jaspr Content. @@ -21,5 +20,4 @@ const List allNodeProcessingExtensions = [ TableWrapperExtension(), CodeBlockProcessor(), GlossaryLinkProcessor(), - TutorialNavigationExtension(), ]; diff --git a/site/lib/src/extensions/tutorial_prefetch_processor.dart b/site/lib/src/extensions/tutorial_prefetch_processor.dart deleted file mode 100644 index 2abcbe728e5..00000000000 --- a/site/lib/src/extensions/tutorial_prefetch_processor.dart +++ /dev/null @@ -1,84 +0,0 @@ -// 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 '../models/tutorial_model.dart'; - -/// A page extension for Jaspr Content that adds page navigation and a -/// prefetch link for the next unit to the current tutorial page. -final class TutorialNavigationExtension implements PageExtension { - const TutorialNavigationExtension(); - - @override - Future> apply(Page page, List nodes) async { - if (!page.path.startsWith('tutorial/')) { - return nodes; - } - - final tutorial = switch (page.data['tutorial']) { - final Map? tutorialData when tutorialData != null => - TutorialModel.fromMap(tutorialData), - _ => throw Exception('No tutorial data found.'), - }; - - final normalizedPageUrl = page.url.endsWith('/') - ? page.url - : '${page.url}/'; - - final allChapters = [ - for (final unit in tutorial.units) ...unit.chapters, - ]; - - final currentChapterIndex = allChapters.indexWhere((chapter) { - final normalizedUnitUrl = chapter.url.endsWith('/') - ? chapter.url - : '${chapter.url}/'; - return normalizedUnitUrl == normalizedPageUrl; - }); - - if (currentChapterIndex == -1) { - return nodes; - } - - final nextChapter = allChapters.length > currentChapterIndex + 1 - ? allChapters[currentChapterIndex + 1] - : null; - - final prevChapter = currentChapterIndex > 0 - ? allChapters[currentChapterIndex - 1] - : null; - - if (nextChapter == null && prevChapter == null) { - return nodes; - } - - page.apply( - data: { - 'page': { - if (nextChapter != null) - 'next': {'title': nextChapter.title, 'path': nextChapter.url}, - if (prevChapter != null) - 'prev': {'title': prevChapter.title, 'path': prevChapter.url}, - }, - }, - ); - - if (nextChapter == null) { - return nodes; - } - - return [ - ComponentNode( - Document.head( - children: [ - link(rel: 'prefetch', href: nextChapter.url), - ], - ), - ), - ...nodes, - ]; - } -} diff --git a/site/lib/src/layouts/catalog_page_layout.dart b/site/lib/src/layouts/catalog_page_layout.dart new file mode 100644 index 00000000000..abaa511a2c7 --- /dev/null +++ b/site/lib/src/layouts/catalog_page_layout.dart @@ -0,0 +1,295 @@ +// 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 '../markdown/markdown_parser.dart'; +import '../util.dart'; +import 'doc_layout.dart'; + +/// Used as the layout for the widget catalog pages. +// TODO: This is directly converted from the original Liquid logic. +// We should either completely replace it with a new widget catalog +// or clean it up. +final class CatalogPageLayout extends DocLayout { + static const String _placeholderImagePath = + '/assets/images/docs/catalog-widget-placeholder.png'; + + const CatalogPageLayout(); + + @override + String get name => 'widget-catalog-page'; + + @override + Component buildBody(Page page, Component child) { + final pageData = page.data.page; + final widgetCategory = pageData['widgetCategory'] as String; + final isMaterialCatalog = pageData['materialCatalog'] == true; + + final catalogData = page.data['catalog'] as Map; + final catalogIndex = (catalogData['index'] as List) + .cast>(); + final category = _CategoryInfo( + catalogIndex.firstWhere( + (c) => c['name'] == widgetCategory, + orElse: () => const {}, + ), + ); + + final catalogWidgets = (catalogData['widgets'] as List) + .cast>(); + + final widgetsInCategory = catalogWidgets + .map(_WidgetInfo.new) + .where((w) => w.categories.contains(widgetCategory)) + .toList(growable: false); + + final subcategories = category.subcategories; + + return super.buildBody( + page, + Component.fragment([ + child, + // Only show description for non-material catalogs. + if (!isMaterialCatalog) + if (category.description case final String description + when description.isNotEmpty) + DashMarkdown(content: description), + + // Only show main category widgets for non-material catalogs. + if (!isMaterialCatalog && widgetsInCategory.isNotEmpty) + _buildCardGrid( + widgetsInCategory, + isMaterialCatalog: isMaterialCatalog, + ), + + if (subcategories.isNotEmpty) ...[ + for (final sub in subcategories) + ..._buildSubcategorySection( + sub, + catalogWidgets, + isMaterialCatalog: isMaterialCatalog, + ), + ], + + if (isMaterialCatalog) + p([ + text('Find more widgets in the '), + a(href: '/ui/widgets/material2', [ + text('Material 2 widget catalog'), + ]), + text(' and other categories of the '), + a(href: '/ui/widgets', [text('widget catalog')]), + text('.'), + ]) + else + p([ + text('Find more widgets in the '), + a(href: '/ui/widgets', [text('widget catalog')]), + text('.'), + ]), + ]), + ); + } + + List _buildSubcategorySection( + _SubcategoryInfo subcategory, + List> allWidgets, { + required bool isMaterialCatalog, + }) { + final subName = subcategory.name; + if (subName.isEmpty) return const []; + + final widgets = allWidgets + .map(_WidgetInfo.new) + .where((w) => w.subcategories.contains(subName)) + .toList(growable: false); + + if (widgets.isEmpty) return const []; + + return [ + h2(id: slugify(subName), [text(subName)]), + _buildCardGrid( + widgets, + isMaterialCatalog: isMaterialCatalog, + subcategory: subcategory, + ), + ]; + } + + Component _buildCardGrid( + List<_WidgetInfo> widgets, { + required bool isMaterialCatalog, + _SubcategoryInfo? subcategory, + }) { + final gridClasses = isMaterialCatalog + ? 'card-grid material-cards' + : 'card-grid'; + + return div( + classes: gridClasses, + [ + for (final widget in widgets) + _buildWidgetCard( + widget, + isMaterialCatalog: isMaterialCatalog, + subcategory: subcategory, + ), + ], + ); + } + + Component _buildWidgetCard( + _WidgetInfo widget, { + required bool isMaterialCatalog, + _SubcategoryInfo? subcategory, + }) { + return a( + classes: 'card outlined-card', + href: widget.link, + [ + _buildCardImageHolder( + name: widget.name, + vector: widget.vector, + imageSrc: widget.imageSrc, + hoverBackgroundSrc: widget.hoverBackgroundSrc, + isMaterialCatalog: isMaterialCatalog, + subcategoryColor: subcategory?.color, + ), + div( + classes: 'card-header', + [ + header( + classes: 'card-title', + [text(widget.name)], + ), + ], + ), + div( + classes: 'card-content', + [ + DashMarkdown( + content: truncateWords(widget.description, 25), + ), + ], + ), + ], + ); + } + + Component _buildCardImageHolder({ + required String name, + required String? vector, + required String? imageSrc, + required String? hoverBackgroundSrc, + required bool isMaterialCatalog, + required String? subcategoryColor, + }) { + final holderClass = isMaterialCatalog + ? 'card-image-holder-material-3' + : 'card-image-holder'; + + final imageAlt = isMaterialCatalog + ? 'Rendered example of the $name Material widget.' + : 'Rendered image or visualization of the $name widget.'; + + const placeholderAlt = + 'Placeholder Flutter logo in place of ' + 'missing widget image or visualization.'; + + final styleAttributes = isMaterialCatalog && subcategoryColor != null + ? {'style': '--bg-color: $subcategoryColor'} + : {}; + + return div( + classes: holderClass, + attributes: styleAttributes, + [ + if (isMaterialCatalog) ...[ + // Material catalog always expects an image. + if (imageSrc != null && imageSrc.isNotEmpty) + img(alt: imageAlt, src: imageSrc) + else + img( + alt: placeholderAlt, + src: _placeholderImagePath, + attributes: {'aria-hidden': 'true'}, + ), + if (hoverBackgroundSrc != null && hoverBackgroundSrc.isNotEmpty) + div( + classes: 'card-image-material-3-hover', + [ + img( + alt: + 'Decorated background for ' + 'Material widget visualizations.', + src: hoverBackgroundSrc, + attributes: {'aria-hidden': 'true'}, + ), + ], + ), + ] else ...[ + // Standard catalog prefers vector, then image, then placeholder. + if (vector != null && vector.isNotEmpty) + raw(vector) + else if (imageSrc != null && imageSrc.isNotEmpty) + img(alt: imageAlt, src: imageSrc) + else + img( + alt: placeholderAlt, + src: _placeholderImagePath, + attributes: {'aria-hidden': 'true'}, + ), + ], + ], + ); + } +} + +extension type _WidgetInfo(Map _data) { + String get name => _data['name'] as String; + String get link => _data['link'] as String; + String get description => _data['description'] as String? ?? ''; + String? get vector => _data['vector'] as String?; + Map? get image => _data['image'] as Map?; + String? get imageSrc => image?['src'] as String?; + Map? get hoverBackground => + _data['hoverBackground'] as Map?; + String? get hoverBackgroundSrc => hoverBackground?['src'] as String?; + + List get categories { + final value = _data['categories']; + if (value is List) { + return value.cast(); + } + return const []; + } + + List get subcategories { + final value = _data['subcategories']; + if (value is List) { + return value.cast(); + } + return const []; + } +} + +extension type _CategoryInfo(Map _data) { + String get name => _data['name'] as String? ?? ''; + String get description => _data['description'] as String? ?? ''; + List<_SubcategoryInfo> get subcategories { + final value = _data['subcategories'] as List?; + if (value == null) return const []; + return value + .cast>() + .map(_SubcategoryInfo.new) + .toList(growable: false); + } +} + +extension type _SubcategoryInfo(Map _data) { + String get name => _data['name'] as String? ?? ''; + String? get color => _data['color'] as String?; +} diff --git a/site/lib/src/layouts/dash_layout.dart b/site/lib/src/layouts/dash_layout.dart index fa0d773cf61..bbbeabc3f22 100644 --- a/site/lib/src/layouts/dash_layout.dart +++ b/site/lib/src/layouts/dash_layout.dart @@ -171,19 +171,16 @@ ga('send', 'pageview'); // avoid a flash of the initial theme on load. raw(''' '''), diff --git a/site/lib/src/layouts/doc_layout.dart b/site/lib/src/layouts/doc_layout.dart index 622ae2a382a..a15a3a0b038 100644 --- a/site/lib/src/layouts/doc_layout.dart +++ b/site/lib/src/layouts/doc_layout.dart @@ -10,7 +10,8 @@ import '../components/common/prev_next.dart'; import '../components/layout/banner.dart'; import '../components/layout/toc.dart'; import '../components/layout/trailing_content.dart'; -import '../models/page_navigation_model.dart'; +import '../extensions/header_extractor.dart'; +import '../models/on_this_page_model.dart'; import '../util.dart'; import 'dash_layout.dart'; @@ -22,8 +23,6 @@ class DocLayout extends FlutterDocsLayout { @override String get name => 'docs'; - bool get allowBreadcrumbs => true; - @override Component buildBody(Page page, Component child) { final pageData = page.data.page; @@ -34,36 +33,29 @@ class DocLayout extends FlutterDocsLayout { (pageData['showBanner'] as bool?) ?? (siteData['showBanner'] as bool?) ?? false; - final navigationData = page.navigationData; + final tocData = _tocForPage(page); return super.buildBody( page, Component.fragment( [ - if (navigationData - case null || PageNavigationData(toc: null, pageEntries: [])) + if (tocData == null) const Document.body(attributes: {'data-toc': 'false'}) else - div( - id: 'site-subheader', - classes: navigationData.pageEntries.isNotEmpty - ? 'show-always' - : null, - [ - PageNavBar(navigationData), - ], - ), + div(id: 'site-subheader', [ + DashTableOfContents.asDropdown( + tocData, + currentTitle: pageTitle, + ), + ]), if (showBanner) if (siteData['bannerHtml'] case final String bannerHtml when bannerHtml.trim().isNotEmpty) DashBanner(bannerHtml), div(classes: 'after-leading-content', [ - if (navigationData case PageNavigationData( - toc: final toc?, - pageEntries: [], - )) + if (tocData != null) aside(id: 'side-menu', [ - DashTableOfContents(toc), + DashTableOfContents(tocData), ]), article([ div(id: 'site-content-title', [ @@ -73,15 +65,15 @@ class DocLayout extends FlutterDocsLayout { else text(pageTitle), ]), - if (allowBreadcrumbs && pageData['showBreadcrumbs'] != false) + if (pageData['showBreadcrumbs'] != false) const PageBreadcrumbs(), ]), child, PrevNext( - previousPage: PageNavigationEntry.fromData(pageData['prev']), - nextPage: PageNavigationEntry.fromData(pageData['next']), + previousPage: _pageInfoFromObject(pageData['prev']), + nextPage: _pageInfoFromObject(pageData['next']), ), const TrailingContent(), ]), @@ -90,4 +82,34 @@ class DocLayout extends FlutterDocsLayout { ), ); } + + OnThisPageData? _tocForPage(Page page) { + final pageData = page.data.page; + final showToc = pageData['showToc'] as bool? ?? true; + + // If 'showToc' was explicitly set to false, hide the toc. + if (!showToc) return null; + + final onThisPageData = OnThisPageData.fromContentHeaders( + page.data['contentHeaders'] as List? ?? const [], + minLevel: pageData['minTocDepth'] as int? ?? 2, + maxLevel: pageData['maxTocDepth'] as int? ?? 3, + ); + + // If there are less than 2 top-level entries, hide the toc. + if (onThisPageData.topLevelEntries.length < 2) return null; + + return onThisPageData; + } +} + +({String url, String title})? _pageInfoFromObject(Object? data) { + if (data case { + 'path': final String pageUrl, + 'title': final String pageTitle, + }) { + return (url: pageUrl, title: pageTitle); + } + + return null; } diff --git a/site/lib/src/layouts/tutorial_layout.dart b/site/lib/src/layouts/tutorial_layout.dart deleted file mode 100644 index f58fcb9ae87..00000000000 --- a/site/lib/src/layouts/tutorial_layout.dart +++ /dev/null @@ -1,51 +0,0 @@ -// 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 '../models/tutorial_model.dart'; -import 'doc_layout.dart'; - -class TutorialLayout extends DocLayout { - const TutorialLayout(); - - @override - String get name => 'tutorial'; - - @override - bool get allowBreadcrumbs => false; - - @override - Component buildBody(Page page, Component child) { - final model = switch (page.data['tutorial']) { - final Map? tutorialData when tutorialData != null => - TutorialModel.fromMap(tutorialData), - _ => throw Exception('No tutorial data found.'), - }; - - final navigationEntries = >[]; - - for (final unit in model.units) { - navigationEntries.add({'type': 'divider', 'title': unit.title}); - for (final chapter in unit.chapters) { - navigationEntries.add({'title': chapter.title, 'path': chapter.url}); - } - } - - return super.buildBody( - page..apply( - data: { - 'page': { - 'showBanner': false, - 'navigationCollectionTitle': model.title, - 'navigationEntries': navigationEntries, - }, - 'sidenav': null, - }, - ), - child, - ); - } -} diff --git a/site/lib/src/markdown/markdown_parser.dart b/site/lib/src/markdown/markdown_parser.dart index ea8bead4e26..430c2eeba31 100644 --- a/site/lib/src/markdown/markdown_parser.dart +++ b/site/lib/src/markdown/markdown_parser.dart @@ -1,7 +1,3 @@ -// 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 'dart:collection'; import 'package:html/parser.dart' as html; @@ -61,9 +57,7 @@ class DashMarkdown extends AsyncStatelessComponent { @override Future build(BuildContext context) async { final currentPage = context.page; - final markdownNodes = inline - ? _defaultMarkdownDocument.parseInline(content) - : _defaultMarkdownDocument.parse(content); + final markdownNodes = _defaultMarkdownDocument.parse(content); var nodes = DashMarkdownParser.buildNodes(markdownNodes); for (final extension in allNodeProcessingExtensions) { nodes = await extension.apply(currentPage, nodes); diff --git a/site/lib/src/models/on_this_page_model.dart b/site/lib/src/models/on_this_page_model.dart new file mode 100644 index 00000000000..2efd3eb7a4c --- /dev/null +++ b/site/lib/src/models/on_this_page_model.dart @@ -0,0 +1,75 @@ +// 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 '../extensions/header_extractor.dart'; + +class OnThisPageData { + final List topLevelEntries; + + OnThisPageData(this.topLevelEntries); + + factory OnThisPageData.fromContentHeaders( + List headers, { + required int minLevel, + required int maxLevel, + }) { + final rootEntries = []; + final levelMap = {}; + + for (final header in headers) { + // Clear entries at this level and below + // so that they aren't tracked any more. + for ( + var removeLevel = header.level; + removeLevel <= maxLevel; + removeLevel += 1 + ) { + levelMap.remove(removeLevel); + } + + final id = header.attributes['id']; + final classes = header.attributes['class']?.split(' ') ?? []; + + // Check if header should be skipped. + if (id == null || + classes.contains('no_toc') || + header.level < minLevel || + header.level > maxLevel) { + continue; + } + + final entry = OnThisPageEntry( + id: id, + text: header.text, + children: [], + ); + + // Check if this is a root level entry. + if (header.level == minLevel) { + rootEntries.add(entry); + levelMap[header.level] = entry; + } else { + // Look for parent at exactly one level above. + if (levelMap[header.level - 1] case final parent?) { + parent.children.add(entry); + levelMap[header.level] = entry; + } + } + } + + return OnThisPageData(rootEntries); + } +} + +final class OnThisPageEntry { + final String id; + final String text; + final List children; + + const OnThisPageEntry({ + required this.id, + required this.text, + this.children = const [], + }); +} diff --git a/site/lib/src/models/page_navigation_model.dart b/site/lib/src/models/page_navigation_model.dart deleted file mode 100644 index 54c8728f76a..00000000000 --- a/site/lib/src/models/page_navigation_model.dart +++ /dev/null @@ -1,153 +0,0 @@ -// 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_content/jaspr_content.dart'; - -import '../extensions/header_extractor.dart'; - -extension GetPageNavigationData on Page { - PageNavigationData? get navigationData { - final pageData = data.page; - final showToc = pageData['showToc'] as bool? ?? true; - - // If 'showToc' was explicitly set to false, hide the toc. - if (!showToc) return null; - - final tocData = _getTocData( - data['contentHeaders'] as List? ?? const [], - minLevel: pageData['minTocDepth'] as int? ?? 2, - maxLevel: pageData['maxTocDepth'] as int? ?? 3, - ); - - final parentTitle = pageData['navigationCollectionTitle'] as String?; - - final pageEntries = []; - if (pageData['navigationEntries'] case final List entries) { - for (final entry in entries) { - if (PageNavigationEntry.fromData(entry) case final entry?) { - pageEntries.add(entry); - } - } - } - - // If there are less than 2 top-level entries, hide the toc. - if (tocData.topLevelEntries.length < 2) { - return PageNavigationData(null, pageEntries, parentTitle); - } - - return PageNavigationData(tocData, pageEntries, parentTitle); - } - - TocNavigationData _getTocData( - List headers, { - required int minLevel, - required int maxLevel, - }) { - final rootEntries = []; - final levelMap = {}; - - for (final header in headers) { - // Clear entries at this level and below - // so that they aren't tracked any more. - for ( - var removeLevel = header.level; - removeLevel <= maxLevel; - removeLevel += 1 - ) { - levelMap.remove(removeLevel); - } - - final id = header.attributes['id']; - final classes = header.attributes['class']?.split(' ') ?? []; - - // Check if header should be skipped. - if (id == null || - classes.contains('no_toc') || - header.level < minLevel || - header.level > maxLevel) { - continue; - } - - final entry = TocNavigationEntry( - id: id, - text: header.text, - children: [], - ); - - // Check if this is a root level entry. - if (header.level == minLevel) { - rootEntries.add(entry); - levelMap[header.level] = entry; - } else { - // Look for parent at exactly one level above. - if (levelMap[header.level - 1] case final parent?) { - parent.children.add(entry); - levelMap[header.level] = entry; - } - } - } - - return TocNavigationData(rootEntries); - } -} - -final class PageNavigationData { - PageNavigationData(this.toc, this.pageEntries, this.parentTitle); - - final TocNavigationData? toc; - final List pageEntries; - final String? parentTitle; -} - -final class TocNavigationData { - TocNavigationData(this.topLevelEntries); - - final List topLevelEntries; -} - -final class TocNavigationEntry { - const TocNavigationEntry({ - required this.id, - required this.text, - this.children = const [], - }); - - final String id; - final String text; - final List children; -} - -final class PageNavigationEntry { - const PageNavigationEntry({ - required this.title, - required this.url, - }) : isDivider = false; - - const PageNavigationEntry.divider({ - required this.title, - }) : url = '', - isDivider = true; - - static PageNavigationEntry? fromData(Object? data) { - if (data case { - 'type': 'divider', - 'title': final String title, - }) { - return PageNavigationEntry.divider(title: title); - } - - if (data case { - 'title': final String title, - 'path': final String path, - }) { - return PageNavigationEntry(title: title, url: path); - } - - return null; - } - - final String title; - final String url; - final bool isDivider; -} diff --git a/site/lib/src/models/tutorial_model.dart b/site/lib/src/models/tutorial_model.dart deleted file mode 100644 index 0548bffcf37..00000000000 --- a/site/lib/src/models/tutorial_model.dart +++ /dev/null @@ -1,81 +0,0 @@ -// 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'; - -class TutorialModel { - const TutorialModel({ - required this.title, - required this.units, - }); - - final String title; - final List units; - - @decoder - factory TutorialModel.fromMap(Map json) { - return TutorialModel( - title: json['title'] as String, - units: (json['units'] as List) - .map((e) => TutorialUnit.fromMap(e as Map)) - .toList(), - ); - } - - @encoder - Map toJson() => { - 'title': title, - 'units': units.map((e) => e.toJson()).toList(), - }; -} - -class TutorialUnit { - const TutorialUnit({ - required this.title, - required this.chapters, - }); - - final String title; - final List chapters; - - @decoder - factory TutorialUnit.fromMap(Map json) { - return TutorialUnit( - title: json['title'] as String, - chapters: (json['chapters'] as List) - .map((e) => TutorialChapter.fromMap(e as Map)) - .toList(), - ); - } - - @encoder - Map toJson() => { - 'title': title, - 'chapters': chapters.map((e) => e.toJson()).toList(), - }; -} - -class TutorialChapter { - const TutorialChapter({ - required this.title, - required this.url, - }); - - final String title; - final String url; - - @decoder - factory TutorialChapter.fromMap(Map json) { - return TutorialChapter( - title: json['title'] as String, - url: json['url'] as String, - ); - } - - @encoder - Map toJson() => { - 'title': title, - 'url': url, - }; -} diff --git a/site/lib/src/models/widget_catalog_model.dart b/site/lib/src/models/widget_catalog_model.dart deleted file mode 100644 index a81286927e4..00000000000 --- a/site/lib/src/models/widget_catalog_model.dart +++ /dev/null @@ -1,62 +0,0 @@ -extension type WidgetCatalogCategory(Map _data) { - String get id => - _data['id'] as String? ?? - (throw Exception('Missing id for widget catalog category. ')); - String get name => - _data['name'] as String? ?? - (throw Exception('Missing name for widget catalog category. ')); - String get description => - _data['description'] as String? ?? - (throw Exception( - 'Missing description for widget catalog category "$name".', - )); - List get subcategories { - final value = _data['subcategories'] as List?; - if (value == null) return const []; - return value - .cast>() - .map(WidgetCatalogSubcategory.new) - .toList(growable: false); - } - - String get title => - '${name.endsWith('s') ? name.substring(0, name.length - 1) : name}' - ' widgets'; -} - -extension type WidgetCatalogSubcategory(Map _data) { - String get name => _data['name'] as String? ?? ''; - String? get color => _data['color'] as String?; -} - -extension type WidgetCatalogWidget(Map _data) { - String get name => - _data['name'] as String? ?? - (throw Exception('Missing name for widget catalog widget. ')); - String get description => - _data['description'] as String? ?? - (throw Exception( - 'Missing description for widget catalog widget "$name".', - )); - String get link => - _data['link'] as String? ?? - (throw Exception('Missing link for widget catalog widget "$name".')); - String? get vector => _data['vector'] as String?; - String? get imageSrc => switch (_data['image']) { - {'src': final String src} => src, - _ => null, - }; - String? get hoverBackgroundSrc => switch (_data['hoverBackground']) { - {'src': final String src} => src, - _ => null, - }; - - List get categories => switch (_data['categories']) { - final List categories => categories.cast(), - _ => const [], - }; - List get subcategories => switch (_data['subcategories']) { - final List subcategories => subcategories.cast(), - _ => const [], - }; -} diff --git a/site/lib/src/pages/custom_pages.dart b/site/lib/src/pages/custom_pages.dart index 843036e97d1..9627216520f 100644 --- a/site/lib/src/pages/custom_pages.dart +++ b/site/lib/src/pages/custom_pages.dart @@ -9,14 +9,12 @@ import 'package:jaspr_content/jaspr_content.dart'; import '../components/pages/devtools_release_notes_index.dart'; import 'glossary.dart'; -import 'widget_catalog.dart'; /// All pages that should be loaded from memory rather than /// from content loaded from the file system. List get allMemoryPages => [ _glossaryPage, _devtoolsReleasesIndex, - ...widgetCatalogPages, // TODO(schultek): Remove this test page when FWE lands. if (kDebugMode) _fweTestingPage, ]; @@ -78,7 +76,6 @@ MemoryPage get _fweTestingPage => const MemoryPage( title: FWE Testing Page description: This is a test page for experimenting with First Week Experience (FWE) features. sitemap: false -layout: tutorial --- ## Quiz diff --git a/site/lib/src/pages/glossary.dart b/site/lib/src/pages/glossary.dart index 69e667f917f..1fb66224b66 100644 --- a/site/lib/src/pages/glossary.dart +++ b/site/lib/src/pages/glossary.dart @@ -212,7 +212,6 @@ final class GlossaryCard extends StatelessComponent { 'data-partial-matches': partialMatches, 'data-full-matches': fullMatches, }, - initiallyExpanded: false, header: [ h2(classes: 'card-title', [text(entry.term)]), div(classes: 'card-header-buttons', [ @@ -232,7 +231,7 @@ final class GlossaryCard extends StatelessComponent { classes: const ['expand-button'], title: 'Expand or collapse card', attributes: { - 'aria-expanded': 'false', + 'aria-expanded': 'true', 'aria-controls': contentId, 'aria-label': 'Expand or collapse ${entry.term} card', }, diff --git a/site/lib/src/pages/widget_catalog.dart b/site/lib/src/pages/widget_catalog.dart deleted file mode 100644 index 4b568b37b32..00000000000 --- a/site/lib/src/pages/widget_catalog.dart +++ /dev/null @@ -1,195 +0,0 @@ -import 'dart:io'; - -import 'package:collection/collection.dart'; -import 'package:jaspr/jaspr.dart'; -import 'package:jaspr_content/jaspr_content.dart'; -import 'package:path/path.dart' as path; - -import '../components/pages/widget_catalog.dart'; -import '../markdown/markdown_parser.dart'; -import '../models/widget_catalog_model.dart'; -import '../util.dart'; - -final _widgetCatalogIndexFile = File( - path.join(siteSrcDirectoryPath, 'data', 'catalog', 'index.yml'), -); - -List get widgetCatalogPages { - final catalogData = _widgetCatalogIndexFile.readAsStringSync(); - final catalog = - (DataLoader.parseData('index.yml', catalogData) as List) - .cast>() - .map(WidgetCatalogCategory.new) - .sortedBy((c) => c.name); - - return [ - for (final category in catalog) - MemoryPage.builder( - path: 'ui/widgets/${category.id}.md', - initialData: { - 'page': { - 'title': category.title, - 'shortTitle': category.name, - 'description': - 'A catalog of Flutter\'s ${category.title.unCapitalize()}. ' - '${category.description}', - }, - }, - builder: (context) { - final catalogWidgets = switch (context.page.data) { - {'catalog': {'widgets': final List widgets}} => - widgets - .cast>() - .map(WidgetCatalogWidget.new) - .toList(growable: false), - _ => throw Exception( - 'Widget Catalog not found. ' - 'Make sure the `data/catalog/widgets.yml` file exists.', - ), - }; - - final widgetsInCategory = catalogWidgets - .where((w) => w.categories.contains(category.name)) - .toList(growable: false); - - final isMaterialCatalog = category.name == 'Material components'; - - return Component.fragment([ - if (_additionalCatalogContent[category.name] case final content?) - DashMarkdown(content: content), - // Only show description for non-material catalogs. - if (!isMaterialCatalog) - if (category.description case final String description - when description.isNotEmpty) - DashMarkdown(content: description), - - // Only show main category widgets for non-material catalogs. - if (!isMaterialCatalog && widgetsInCategory.isNotEmpty) - _buildCardGrid( - widgetsInCategory, - isMaterialCatalog: isMaterialCatalog, - ), - - if (category.subcategories case final subcategories - when subcategories.isNotEmpty) ...[ - for (final sub in subcategories) - ..._buildSubcategorySection( - sub, - catalogWidgets, - isMaterialCatalog: isMaterialCatalog, - ), - ], - - if (isMaterialCatalog) - p([ - text('Find more widgets in the '), - a(href: '/ui/widgets/material2', [ - text('Material 2 widget catalog'), - ]), - text(' and other categories of the '), - a(href: '/ui/widgets', [text('widget catalog')]), - text('.'), - ]) - else - p([ - text('Find more widgets in the '), - a(href: '/ui/widgets', [text('widget catalog')]), - text('.'), - ]), - ]); - }, - ), - ]; -} - -const _additionalCatalogContent = { - 'Material components': ''' -Flutter provides a variety of visual, behavioral, and motion-rich widgets -that implement the [Material 3][] design specification. -Material 3 is the default design language of Flutter, -enabling you to design and build beautiful, usable apps -that can adapt to any platform. - -:::secondary -The transition to Material 3 as the default was -completed in Flutter 3.16. - -To learn more about this transition, how to complete it for your own widgets, -or how to temporarily opt-out, check out -the [Migrate to Material 3][] migration guide. -::: - -To catch these and other widgets in action, -check out the [Material 3 demo][] web app. - -[Material 3]: https://m3.material.io/get-started -[Migrate to Material 3]: /release/breaking-changes/material-3-migration -[Material 3 demo]: https://github.com/flutter/samples/tree/main/material_3_demo/ -''', - 'Material 2 components': ''' -Flutter provides a variety of widgets -that implement the [Material 2][] design guidelines, -enabling you to create intuitive and beautiful apps. - -:::version-note -[Material 3][], the latest version of Material Design, is -Flutter's default design language as of Flutter 3.16. - -Material 2 will eventually be deprecated. -To learn more about this transition, check out -the [Migrate to Material 3][] migration guide. - -Also check out the [Material 3 widget catalog][]. -::: - -[Material 3]: https://m3.material.io/ -[Material 2]: https://m2.material.io/design -[Migrate to Material 3]: /release/breaking-changes/material-3-migration -[Material 3 widget catalog]: /ui/widgets/material -''', -}; - -List _buildSubcategorySection( - WidgetCatalogSubcategory subcategory, - List allWidgets, { - required bool isMaterialCatalog, -}) { - final subName = subcategory.name; - if (subName.isEmpty) return const []; - - final widgets = allWidgets - .where((w) => w.subcategories.contains(subName)) - .toList(growable: false); - - if (widgets.isEmpty) return const []; - - return [ - h2(id: slugify(subName), [text(subName)]), - _buildCardGrid( - widgets, - isMaterialCatalog: isMaterialCatalog, - subcategory: subcategory, - ), - ]; -} - -Component _buildCardGrid( - List widgets, { - required bool isMaterialCatalog, - WidgetCatalogSubcategory? subcategory, -}) { - return div( - classes: [ - 'card-grid', - if (isMaterialCatalog) 'material-cards', - ].toClasses, - [ - for (final widget in widgets) - WidgetCatalogCard( - widget: widget, - isMaterialCatalog: isMaterialCatalog, - subcategory: subcategory, - ), - ], - ); -} diff --git a/site/lib/src/style_hash.dart b/site/lib/src/style_hash.dart index 34f694e5c36..b4d0aecfda2 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 = 'NTjkkEUUtAQJ'; +const generatedStylesHash = 'lWAnsjm6RjR2'; diff --git a/site/lib/src/util.dart b/site/lib/src/util.dart index 110d387b6a5..4370dbcaee0 100644 --- a/site/lib/src/util.dart +++ b/site/lib/src/util.dart @@ -132,11 +132,6 @@ String truncateWordsMarkdown(String text, int maxWords) { return '$truncated...\n$endContent'; } -extension StringUnCapitalize on String { - String unCapitalize() => - isEmpty ? this : substring(0, 1).toLowerCase() + substring(1); -} - extension ListToClasses on List { /// Convert a list of classes into a single class string /// that can be added to an HTML element. diff --git a/src/content/ai-toolkit/chat-client-sample.md b/src/content/ai-toolkit/chat-client-sample.md index 911d4bb3027..f537a8e2b8d 100644 --- a/src/content/ai-toolkit/chat-client-sample.md +++ b/src/content/ai-toolkit/chat-client-sample.md @@ -8,7 +8,7 @@ prev: --- The AI Chat sample is meant to be a full-fledged chat app -built using the Flutter AI Toolkit and Vertex AI for Firebase. +built using the Flutter AI Toolkit and the Firebase AI Logic SDK. In addition to all of the multi-shot, multi-media, streaming features that it gets from the AI Toolkit, the AI Chat sample shows how to store and manage diff --git a/src/content/ai-toolkit/custom-llm-providers.md b/src/content/ai-toolkit/custom-llm-providers.md index b64c23c71e8..45223e70bf1 100644 --- a/src/content/ai-toolkit/custom-llm-providers.md +++ b/src/content/ai-toolkit/custom-llm-providers.md @@ -10,8 +10,8 @@ next: path: /ai-toolkit/chat-client-sample --- -The protocol connecting an LLM and the `LlmChatView` -is expressed in the [`LlmProvider` interface][]: +The protocol connecting an LLM and the `LlmChatView` is expressed in the +[`LlmProvider` interface][]: ```dart abstract class LlmProvider implements Listenable { @@ -29,26 +29,23 @@ it could be a proprietary LLM or open source. Any LLM or LLM-like endpoint that can be used to implement this interface can be plugged into the chat view as an LLM provider. The AI Toolkit -comes with three providers out of the box, -all of which implement the `LlmProvider` interface +comes with two providers out of the box, +both of which implement the `LlmProvider` interface that is required to plug the provider into the following: -* The [Gemini provider][], - which wraps the `google_generative_ai` package -* The [Vertex provider][], - which wraps the `firebase_vertexai` package +* The [Firebase AI Logic provider][], + which wraps the `firebase_ai` package * The [Echo provider][], which is useful as a minimal provider example [Echo provider]: {{site.pub-api}}/flutter_ai_toolkit/latest/flutter_ai_toolkit/EchoProvider-class.html -[Gemini provider]: {{site.pub-api}}/flutter_ai_toolkit/latest/flutter_ai_toolkit/GeminiProvider-class.html [`LlmProvider` interface]: {{site.pub-api}}/flutter_ai_toolkit/latest/flutter_ai_toolkit/LlmProvider-class.html -[Vertex provider]: {{site.pub-api}}/flutter_ai_toolkit/latest/flutter_ai_toolkit/VertexProvider-class.html +[Firebase AI Logic provider]: {{site.pub-api}}/flutter_ai_toolkit/latest/flutter_ai_toolkit/FirebaseProvider-class.html ## Implementation -To build your own provider, you need to implement -the `LlmProvider` interface with these things in mind: +To build your own provider, you need to implement the `LlmProvider` interface +with these things in mind: 1. Providing for full configuration support 1. Handling history @@ -58,12 +55,12 @@ the `LlmProvider` interface with these things in mind: 1. Configuration To support full configurability in your custom provider, you should allow the user to create the underlying model - and pass that in as a parameter, as the Gemini provider does: + and pass that in as a parameter, as the Firebase provider does: ```dart -class GeminiProvider extends LlmProvider ... { +class FirebaseProvider extends LlmProvider ... { @immutable - GeminiProvider({ + FirebaseProvider({ required GenerativeModel model, ... }) : _model = model, @@ -74,25 +71,22 @@ class GeminiProvider extends LlmProvider ... { } ``` -In this way, no matter what changes come -to the underlying model in the future, -the configuration knobs will all be available -to the user of your custom provider. +In this way, no matter what changes come to the underlying model in the future, +the configuration knobs will all be available to the user of your custom +provider. -2. History - History is a big part of any provider—not only - does the provider need to allow history to be - manipulated directly, but it has to notify listeners - as it changes. In addition, to support serialization - and changing provider parameters, it must also support - saving history as part of the construction process. +2. History History is a big part of any provider—not only does the provider need + to allow history to be manipulated directly, but it has to notify listeners as + it changes. In addition, to support serialization and changing provider + parameters, it must also support saving history as part of the construction + process. - The Gemini provider handles this as shown: + The Firebase provider handles this as shown: ```dart -class GeminiProvider extends LlmProvider with ChangeNotifier { +class FirebaseProvider extends LlmProvider with ChangeNotifier { @immutable - GeminiProvider({ + FirebaseProvider({ required GenerativeModel model, Iterable? history, ... @@ -143,11 +137,10 @@ class GeminiProvider extends LlmProvider with ChangeNotifier { ``` You'll notice several things in this code: -* The use of `ChangeNotifier` to implement the `Listenable` - method requirements from the `LlmProvider` interface +* The use of `ChangeNotifier` to implement the `Listenable` method requirements + from the `LlmProvider` interface * The ability to pass initial history in as a constructor parameter -* Notifying listeners when there's a new user - prompt/LLM response pair +* Notifying listeners when there's a new user prompt/LLM response pair * Notifying listeners when the history is changed manually * Creating a new chat when the history changes, using the new history @@ -155,25 +148,25 @@ Essentially, a custom provider manages the history for a single chat session with the underlying LLM. As the history changes, the underlying chat either needs to be kept up to date automatically -(as the Gemini AI SDK for Dart does when you call +(as the Firebase provider does when you call the underlying chat-specific methods) or manually recreated -(as the Gemini provider does whenever the history is set manually). +(as the Firebase provider does whenever the history is set manually). -3. Messages and attachments +1. Messages and attachments Attachments must be mapped from the standard `ChatMessage` class exposed by the `LlmProvider` type to whatever is handled by the underlying LLM. -For example, the Gemini provider maps from the +For example, the Firebase provider maps from the `ChatMessage` class from the AI Toolkit to the -`Content` type provided by the Gemini AI SDK for Dart, +`Content` type provided by the Firebase Logic AI SDK, as shown in the following example: ```dart import 'package:google_generative_ai/google_generative_ai.dart'; ... -class GeminiProvider extends LlmProvider with ChangeNotifier { +class FirebaseProvider extends LlmProvider with ChangeNotifier { ... static Part _partFrom(Attachment attachment) => switch (attachment) { (final FileAttachment a) => DataPart(a.mimeType, a.bytes), @@ -190,22 +183,21 @@ class GeminiProvider extends LlmProvider with ChangeNotifier { } ``` -The `_contentFrom` method is called whenever a user prompt -needs to be sent to the underlying LLM. -Every provider needs to provide for its own mapping. +The `_contentFrom` method is called whenever a user prompt needs to be sent to +the underlying LLM. Every provider needs to provide for its own mapping. -4. Calling the LLM +1. Calling the LLM How you call the underlying LLM to implement `generateStream` and `sendMessageStream` methods depends on the protocol it exposes. -The Gemini provider in the AI Toolkit +The Firebase provider in the AI Toolkit handles configuration and history but calls to `generateStream` and `sendMessageStream` each -end up in a call to an API from the Gemini AI SDK for Dart: +end up in a call to an API from the Firebase Logic AI SDK: ```dart -class GeminiProvider extends LlmProvider with ChangeNotifier { +class FirebaseProvider extends LlmProvider with ChangeNotifier { ... @override @@ -275,8 +267,8 @@ class GeminiProvider extends LlmProvider with ChangeNotifier { ## Examples -The [Gemini provider][] and [Vertex provider][] -implementations are nearly identical and provide +The [Firebase provider][] +implementation provides a good starting point for your own custom provider. If you'd like to see an example provider implementation with all of the calls to the underlying LLM stripped away, @@ -284,4 +276,5 @@ check out the [Echo example app][], which simply formats the user's prompt and attachments as Markdown to send back to the user as its response. -[Echo example app]: {{site.github}}/flutter/ai/blob/main/lib/src/providers/implementations/echo_provider.dart +[Echo example app]: + {{site.github}}/flutter/ai/blob/main/lib/src/providers/implementations/echo_provider.dart diff --git a/src/content/ai-toolkit/feature-integration.md b/src/content/ai-toolkit/feature-integration.md index bf7725a18c1..bdc4c0d3a57 100644 --- a/src/content/ai-toolkit/feature-integration.md +++ b/src/content/ai-toolkit/feature-integration.md @@ -10,44 +10,46 @@ next: path: /ai-toolkit/custom-llm-providers --- -In addition to the features that are provided -automatically by the [`LlmChatView`][], -a number of integration points allow your app to -blend seamlessly with other features to provide -additional functionality: +In addition to the features that are provided automatically by the +[`LlmChatView`][], a number of integration points allow your app to blend +seamlessly with other features to provide additional functionality: * **Welcome messages**: Display an initial greeting to users. * **Suggested prompts**: Offer users predefined prompts to guide interactions. -* **System instructions**: Provide the LLM with specific input to influence its responses. +* **System instructions**: Provide the LLM with specific input to influence its + responses. * **Disable attachments and audio input**: Remove optional parts of the chat UI. -* **Manage cancel or error behavior**: Change the user cancellation or LLM error behavior. -* **Manage history**: Every LLM provider allows for managing chat history, - which is useful for clearing it, - changing it dynamically and storing it between sessions. +* **Manage cancel or error behavior**: Change the user cancellation or LLM error + behavior. +* **Manage history**: Every LLM provider allows for managing chat history, which + is useful for clearing it, changing it dynamically and storing it between + sessions. * **Chat serialization/deserialization**: Store and retrieve conversations between app sessions. -* **Custom response widgets**: Introduce specialized UI components - to present LLM responses. -* **Custom styling**: Define unique visual styles to match the chat - appearance to the overall app. -* **Chat w/o UI**: Interact directly with the LLM providers without +* **Custom response widgets**: Introduce specialized UI components to present + LLM responses. +* **Custom styling**: Define unique visual styles to match the chat appearance + to the overall app. +* **Chat without UI**: Interact directly with the LLM providers without affecting the user's current chat session. * **Custom LLM providers**: Build your own LLM provider for integration of chat with your own model backend. * **Rerouting prompts**: Debug, log, or reroute messages meant for the provider to track down issues or route prompts dynamically. -[`LlmChatView`]: {{site.pub-api}}/flutter_ai_toolkit/latest/flutter_ai_toolkit/LlmChatView-class.html +[`LlmChatView`]: + {{site.pub-api}}/flutter_ai_toolkit/latest/flutter_ai_toolkit/LlmChatView-class.html ## Welcome messages -The chat view allows you to provide a custom welcome message -to set context for the user: +The chat view allows you to provide a custom welcome message to set context for +the user: -![Example welcome message](/assets/images/docs/ai-toolkit/example-of-welcome-message.png) +![Example welcome +message](/assets/images/docs/ai-toolkit/example-of-welcome-message.png) -You can initialize the `LlmChatView` with a welcome message -by setting the `welcomeMessage` parameter: +You can initialize the `LlmChatView` with a welcome message by setting the +`welcomeMessage` parameter: ```dart class ChatPage extends StatelessWidget { @@ -69,21 +71,23 @@ class ChatPage extends StatelessWidget { } ``` -To see a complete example of setting the welcome message, -check out the [welcome example][]. +To see a complete example of setting the welcome message, check out the [welcome +example][]. -[welcome example]: {{site.github}}/flutter/ai/blob/main/example/lib/welcome/welcome.dart +[welcome example]: + {{site.github}}/flutter/ai/blob/main/example/lib/welcome/welcome.dart ## Suggested prompts -You can provide a set of suggested prompts to give -the user some idea of what the chat session has been optimized for: +You can provide a set of suggested prompts to give the user some idea of what +the chat session has been optimized for: -![Example suggested prompts](/assets/images/docs/ai-toolkit/example-of-suggested-prompts.png) +![Example suggested +prompts](/assets/images/docs/ai-toolkit/example-of-suggested-prompts.png) The suggestions are only shown when there is no existing -chat history. Clicking one copies the text into the -user's prompt editing area. To set the list of suggestions, +chat history. Clicking one sends it immediately as a request to the underlying LLM. + To set the list of suggestions, construct the `LlmChatView` with the `suggestions` parameter: ```dart @@ -110,19 +114,18 @@ class ChatPage extends StatelessWidget { } ``` -To see a complete example of setting up suggestions for the user, -take a look at the [suggestions example][]. +To see a complete example of setting up suggestions for the user, take a look at +the [suggestions example][]. -[suggestions example]: {{site.github}}/flutter/ai/blob/main/example/lib/suggestions/suggestions.dart +[suggestions example]: + {{site.github}}/flutter/ai/blob/main/example/lib/suggestions/suggestions.dart ## LLM instructions -To optimize an LLM's responses based on the needs -of your app, you'll want to give it instructions. -For example, the [recipes example app][] uses the -`systemInstructions` parameter of the `GenerativeModel` -class to tailor the LLM to focus on delivering recipes -based on the user's instructions: +To optimize an LLM's responses based on the needs of your app, you'll want to +give it instructions. For example, the [recipes example app][] uses the +`systemInstructions` parameter of the `GenerativeModel` class to tailor the LLM +to focus on delivering recipes based on the user's instructions: ```dart class _HomePageState extends State { @@ -149,21 +152,21 @@ You should keep things casual and friendly. You may generate multiple recipes in } ``` -Setting system instructions is unique to each provider; -both the `GeminiProvider` and the `VertexProvider` -allow you to provide them through the `systemInstruction` parameter. +Setting system instructions is unique to each provider; both the +`GeminiProvider` and the `VertexProvider` allow you to provide them through the +`systemInstruction` parameter. -Notice that, in this case, we're bringing in user preferences -as part of the creation of the LLM provider passed to the -`LlmChatView` constructor. We set the instructions as part -of the creation process each time the user changes their preferences. -The recipes app allows the user to change their food preferences +Notice that, in this case, we're bringing in user preferences as part of the +creation of the LLM provider passed to the `LlmChatView` constructor. We set the +instructions as part of the creation process each time the user changes their +preferences. The recipes app allows the user to change their food preferences using a drawer on the scaffold: -![Example of refining prompt](/assets/images/docs/ai-toolkit/setting-food-preferences.png) +![Example of refining +prompt](/assets/images/docs/ai-toolkit/setting-food-preferences.png) -Whenever the user changes their food preferences, -the recipes app creates a new model to use the new preferences: +Whenever the user changes their food preferences, the recipes app creates a new +model to use the new preferences: ```dart class _HomePageState extends State { @@ -176,11 +179,24 @@ class _HomePageState extends State { } ``` +## Function calling + +To enable the LLM to perform actions on behalf of the user, you can provide a +set of tools (functions) that the LLM can call. The `FirebaseProvider` supports +function calling out of the box. It handles the loop of sending the user's +prompt, receiving a function call request from the LLM, executing the function, +and sending the result back to the LLM until a final text response is generated. + +To use function calling, you need to define your tools and pass them to the +`FirebaseProvider`. Check out the [function calling example][] for details. + +[function calling example]: {{site.github}}/flutter/ai/blob/main/example/lib/function_calls/function_calls.dart + ## Disable attachments and audio input -If you'd like to disable attachments (the **+** button) or audio input (the mic button), -you can do so with the `enableAttachments` and `enableVoiceNotes` parameters to -the `LlmChatView` constructor: +If you'd like to disable attachments (the **+** button) or audio input (the mic +button), you can do so with the `enableAttachments` and `enableVoiceNotes` +parameters to the `LlmChatView` constructor: ```dart class ChatPage extends StatelessWidget { @@ -204,13 +220,33 @@ class ChatPage extends StatelessWidget { Both of these flags default to `true`. +## Custom speech-to-text + +By default, the AI Toolkit uses the `LlmProvider` to pass to the `LlmChatView` +to provide the speech-to-text implementation. If you'd like to provide your own +implementation, for example to use a device-specific service, you can do so by +implementing the `SpeechToText` interface and passing it to the `LlmChatView` +constructor: + +```dart +LlmChatView( + // ... + speechToText: MyCustomSpeechToText(), +) +``` + +Check out the [custom STT example][] for details. + +[custom STT example]: + {{site.github}}/flutter/ai/tree/main/example/lib/custom_stt + ## Manage cancel or error behavior By default, when the user cancels an LLM request, the LLM's response will be appended with the string "CANCEL" and a message will pop up that the user has canceled the request. Likewise, in the event of an LLM error, like a dropped -network connection, the LLM's response will be appended with the -string "ERROR" and an alert dialog will pop up with the details of the error. +network connection, the LLM's response will be appended with the string "ERROR" +and an alert dialog will pop up with the details of the error. You can override the cancel and error behavior with the `cancelMessage`, `errorMessage`, `onCancelCallback` and `onErrorCallback` parameters of the @@ -244,9 +280,9 @@ its defaults for anything you don't override. ## Manage history -The [standard interface that defines all LLM providers][providerIF] -that can plug into the chat view includes the ability to -get and set history for the provider: +The [standard interface that defines all LLM providers][providerIF] that can +plug into the chat view includes the ability to get and set history for the +provider: ```dart abstract class LlmProvider implements Listenable { @@ -265,21 +301,20 @@ abstract class LlmProvider implements Listenable { } ``` -[providerIF]: {{site.pub-api}}/flutter_ai_toolkit/latest/flutter_ai_toolkit/LlmProvider-class.html +[providerIF]: + {{site.pub-api}}/flutter_ai_toolkit/latest/flutter_ai_toolkit/LlmProvider-class.html -When the history for a provider changes, -it calls the `notifyListener` method exposed by the -`Listenable` base class. This means that you manually -subscribe/unsubscribe with the `add` and `remove` methods -or use it to construct an instance of the `ListenableBuilder` class. +When the history for a provider changes, it calls the `notifyListener` method +exposed by the `Listenable` base class. This means that you manually +subscribe/unsubscribe with the `add` and `remove` methods or use it to construct +an instance of the `ListenableBuilder` class. -The `generateStream` method calls into the underlying LLM -without affecting the history. Calling the `sendMessageStream` -method changes the history by adding two new messages to the -provider's history—one for the user message and one for the LLM -response—when the response is completed. The chat view uses -`sendMessageStream` when it processes a user's chat prompt and -`generateStream` when it's processing the user's voice input. +The `generateStream` method calls into the underlying LLM without affecting the +history. Calling the `sendMessageStream` method changes the history by adding +two new messages to the provider's history—one for the user message and one for +the LLM response—when the response is completed. The chat view uses +`sendMessageStream` when it processes a user's chat prompt and `generateStream` +when it's processing the user's voice input. To see or set the history, you can access the `history` property: @@ -287,8 +322,8 @@ To see or set the history, you can access the `history` property: void _clearHistory() => _provider.history = []; ``` -The ability to access a provider's history is also useful -when it comes to recreating a provider while maintaining the history: +The ability to access a provider's history is also useful when it comes to +recreating a provider while maintaining the history: ```dart class _HomePageState extends State { @@ -301,14 +336,10 @@ class _HomePageState extends State { } ``` -The `_createProvider` method -creates a new provider with the history from -the previous provider _and_ the new user -preferences. -It's seamless for the user; they can keep chatting away -but now the LLM gives them responses taking their -new food preferences into account. -For example: +The `_createProvider` method creates a new provider with the history from the +previous provider _and_ the new user preferences. It's seamless for the user; +they can keep chatting away but now the LLM gives them responses taking their +new food preferences into account. For example: ```dart @@ -324,22 +355,20 @@ class _HomePageState extends State { } ``` -To see history in action, -check out the [recipes example app][] and the [history example app][]. +To see history in action, check out the [recipes example app][] and the [history +example app][]. -[history example app]: {{site.github}}/flutter/ai/blob/main/example/lib/history/history.dart +[history example app]: + {{site.github}}/flutter/ai/blob/main/example/lib/history/history.dart [recipes example app]: {{site.github}}/flutter/ai/tree/main/example/lib/recipes ## Chat serialization/deserialization -To save and restore chat history between sessions -of an app requires the ability to serialize and -deserialize each user prompt, including the attachments, -and each LLM response. Both kinds of messages -(the user prompts and LLM responses), -are exposed in the `ChatMessage` class. -Serialization can be accomplished by using the `toJson` -method of each `ChatMessage` instance. +To save and restore chat history between sessions of an app requires the ability +to serialize and deserialize each user prompt, including the attachments, and +each LLM response. Both kinds of messages (the user prompts and LLM responses), +are exposed in the `ChatMessage` class. Serialization can be accomplished by +using the `toJson` method of each `ChatMessage` instance. ```dart Future _saveHistory() async { @@ -360,8 +389,8 @@ Future _saveHistory() async { } ``` -Likewise, to deserialize, use the static `fromJson` -method of the `ChatMessage` class: +Likewise, to deserialize, use the static `fromJson` method of the `ChatMessage` +class: ```dart Future _loadHistory() async { @@ -380,34 +409,29 @@ Future _loadHistory() async { } ``` -To ensure fast turnaround when serializing, -we recommend only writing each user message once. -Otherwise, the user must wait for your app to -write every message every time and, -in the face of binary attachments, -that could take a while. +To ensure fast turnaround when serializing, we recommend only writing each user +message once. Otherwise, the user must wait for your app to write every message +every time and, in the face of binary attachments, that could take a while. To see this in action, check out the [history example app][]. -[history example app]: {{site.github}}/flutter/ai/blob/main/example/lib/history/history.dart +[history example app]: + {{site.github}}/flutter/ai/blob/main/example/lib/history/history.dart ## Custom response widgets -By default, the LLM response shown by the chat view is -formatted Markdown. However, in some cases, -you want to create a custom widget to show the -LLM response that's specific to and integrated with your app. -For example, when the user requests a recipe in the -[recipes example app][], the LLM response is used -to create a widget that's specific to showing recipes -just like the rest of the app does and to provide for an -**Add** button in case the user would like to add +By default, the LLM response shown by the chat view is formatted Markdown. +However, in some cases, you want to create a custom widget to show the LLM +response that's specific to and integrated with your app. For example, when the +user requests a recipe in the [recipes example app][], the LLM response is used +to create a widget that's specific to showing recipes just like the rest of the +app does and to provide for an **Add** button in case the user would like to add the recipe to their database: ![Add recipe button](/assets/images/docs/ai-toolkit/add-recipe-button.png) -This is accomplished by setting the `responseBuilder` -parameter of the `LlmChatView` constructor: +This is accomplished by setting the `responseBuilder` parameter of the +`LlmChatView` constructor: ```dart LlmChatView( @@ -419,9 +443,8 @@ LlmChatView( ), ``` -In this particular example, the `RecipeReponseView` -widget is constructed with the LLM provider's response text -and uses that to implement its `build` method: +In this particular example, the `RecipeReponseView` widget is constructed with +the LLM provider's response text and uses that to implement its `build` method: ```dart class RecipeResponseView extends StatelessWidget { @@ -482,17 +505,15 @@ class RecipeResponseView extends StatelessWidget { } ``` -This code parses the text to extract introductory text -and the recipe from the LLM, bundling them together -with an **Add Recipe** button to show in place of the Markdown. - -Notice that we're parsing the LLM response as JSON. -It's common to set the provider into JSON mode and -to provide a schema to restrict the format of its responses -to ensure that we've got something we can parse. -Each provider exposes this functionality in its own way, -but both the `GeminiProvider` and `VertexProvider` classes -enable this with a `GenerationConfig` object that the +This code parses the text to extract introductory text and the recipe from the +LLM, bundling them together with an **Add Recipe** button to show in place of +the Markdown. + +Notice that we're parsing the LLM response as JSON. It's common to set the +provider into JSON mode and to provide a schema to restrict the format of its +responses to ensure that we've got something we can parse. Each provider exposes +this functionality in its own way, but both the `GeminiProvider` and +`VertexProvider` classes enable this with a `GenerationConfig` object that the recipes example uses as follows: ```dart @@ -536,24 +557,21 @@ well as any trailing text commentary you care to provide: } ``` -This code initializes the `GenerationConfig` object -by setting the `responseMimeType` parameter to `'application/json'` -and the `responseSchema` parameter to an instance of the -`Schema` class that defines the structure of the JSON -that you're prepared to parse. In addition, -it's good practice to also ask for JSON and to provide -a description of that JSON schema in the system instructions, -which we've done here. +This code initializes the `GenerationConfig` object by setting the +`responseMimeType` parameter to `'application/json'` and the `responseSchema` +parameter to an instance of the `Schema` class that defines the structure of the +JSON that you're prepared to parse. In addition, it's good practice to also ask +for JSON and to provide a description of that JSON schema in the system +instructions, which we've done here. To see this in action, check out the [recipes example app][]. ## Custom styling -The chat view comes out of the box with a set of default styles -for the background, the text field, the buttons, the icons, -the suggestions, and so on. You can fully customize those -styles by setting your own by using the `style` parameter to the -`LlmChatView` constructor: +The chat view comes out of the box with a set of default styles for the +background, the text field, the buttons, the icons, the suggestions, and so on. +You can fully customize those styles by setting your own by using the `style` +parameter to the `LlmChatView` constructor: ```dart LlmChatView( @@ -562,46 +580,53 @@ LlmChatView( ), ``` -For example, the [custom styles example app][custom-ex] -uses this feature to implement an app with a Halloween theme: +For example, the [custom styles example app][custom-ex] uses this feature to +implement an app with a Halloween theme: ![Halloween-themed demo app](/assets/images/docs/ai-toolkit/demo-app.png) -For a complete list of the styles available in the -`LlmChatViewStyle` class, check out the [reference documentation][]. -To see custom styles in action, -in addition to the [custom styles example][custom-ex], -check out the [dark mode example][] and the [demo app][]. - -[custom-ex]: {{site.github}}/flutter/ai/blob/main/example/lib/custom_styles/custom_styles.dart -[dark mode example]: {{site.github}}/flutter/ai/blob/main/example/lib/dark_mode/dark_mode.dart +For a complete list of the styles available in the `LlmChatViewStyle` class, +check out the [reference documentation][]. You can also customize the appearance +of the voice recorder using the `voiceNoteRecorderStyle` parameter of the +`LlmChatViewStyle` class, which is demonstrated in the [styles +example][styles-ex]. + +To see custom styles in action, in addition to the [custom styles +example][custom-ex] and the [styles example][styles-ex], check out the [dark +mode example][] and the [demo app][]. + +[custom-ex]: + {{site.github}}/flutter/ai/blob/main/example/lib/custom_styles/custom_styles.dart +[styles-ex]: {{site.github}}/flutter/ai/blob/main/example/lib/styles/styles.dart +[dark mode example]: + {{site.github}}/flutter/ai/blob/main/example/lib/dark_mode/dark_mode.dart [demo app]: {{site.github}}/flutter/ai#online-demo -[reference documentation]: {{site.pub-api}}/flutter_ai_toolkit/latest/flutter_ai_toolkit/LlmChatViewStyle-class.html +[reference documentation]: + {{site.pub-api}}/flutter_ai_toolkit/latest/flutter_ai_toolkit/LlmChatViewStyle-class.html ## Chat without UI -You don't have to use the chat view to access the -functionality of the underlying provider. -In addition to being able to simply call it with -whatever proprietary interface it provides, -you can also use it with the [LlmProvider interface][]. +You don't have to use the chat view to access the functionality of the +underlying provider. In addition to being able to simply call it with whatever +proprietary interface it provides, you can also use it with the [LlmProvider +interface][]. -[LlmProvider interface]: {{site.pub-api}}/flutter_ai_toolkit/latest/flutter_ai_toolkit/LlmProvider-class.html +[LlmProvider interface]: + {{site.pub-api}}/flutter_ai_toolkit/latest/flutter_ai_toolkit/LlmProvider-class.html -As an example, the recipes example app provides a -Magic button on the page for editing recipes. -The purpose of that button is to update an existing recipe -in your database with your current food preferences. -Pressing the button allows you to preview the recommended changes and -decide whether you'd like to apply them or not: +As an example, the recipes example app provides a Magic button on the page for +editing recipes. The purpose of that button is to update an existing recipe in +your database with your current food preferences. Pressing the button allows you +to preview the recommended changes and decide whether you'd like to apply them +or not: -![User decides whether to update recipe in database](/assets/images/docs/ai-toolkit/apply-changes-decision.png) +![User decides whether to update recipe in +database](/assets/images/docs/ai-toolkit/apply-changes-decision.png) -Instead of using the same provider that the chat portion -of the app uses, which would insert spurious user messages -and LLM responses into the user's chat history, -the Edit Recipe page instead creates its own provider -and uses it directly: +Instead of using the same provider that the chat portion of the app uses, which +would insert spurious user messages and LLM responses into the user's chat +history, the Edit Recipe page instead creates its own provider and uses it +directly: ```dart class _EditRecipePageState extends State { @@ -654,27 +679,27 @@ class _EditRecipePageState extends State { } ``` -The call to `sendMessageStream` creates entries in the -provider's history, but since it's not associated with a chat view, -they won't be shown. If it's convenient, -you can also accomplish the same thing by calling `generateStream`, -which allows you to reuse an existing provider without affecting -the chat history. +The call to `sendMessageStream` creates entries in the provider's history, but +since it's not associated with a chat view, they won't be shown. If it's +convenient, you can also accomplish the same thing by calling `generateStream`, +which allows you to reuse an existing provider without affecting the chat +history. -To see this in action, -check out the [Edit Recipe page][] of the recipes example. +To see this in action, check out the [Edit Recipe page][] of the recipes +example. -[Edit Recipe page]: {{site.github}}/flutter/ai/blob/main/example/lib/recipes/pages/edit_recipe_page.dart +[Edit Recipe page]: + {{site.github}}/flutter/ai/blob/main/example/lib/recipes/pages/edit_recipe_page.dart ## Rerouting prompts -If you'd like to debug, log, or manipulate the connection -between the chat view and the underlying provider, -you can do so with an implementation of an [`LlmStreamGenerator`][] function. -You then pass that function to the `LlmChatView` in the -`messageSender` parameter: +If you'd like to debug, log, or manipulate the connection between the chat view +and the underlying provider, you can do so with an implementation of an +[`LlmStreamGenerator`][] function. You then pass that function to the +`LlmChatView` in the `messageSender` parameter: -[`LlmStreamGenerator`]: {{site.pub-api}}/flutter_ai_toolkit/latest/flutter_ai_toolkit/LlmStreamGenerator.html +[`LlmStreamGenerator`]: + {{site.pub-api}}/flutter_ai_toolkit/latest/flutter_ai_toolkit/LlmStreamGenerator.html ```dart class ChatPage extends StatelessWidget { @@ -714,13 +739,13 @@ class ChatPage extends StatelessWidget { } ``` -This example logs the user prompts and LLM responses -as they go back and forth. When providing a function -as a `messageSender`, it's your responsibility to call -the underlying provider. If you don't, it won't get the message. -This capability allows you to do advanced things like routing to -a provider dynamically or Retrieval Augmented Generation (RAG). +This example logs the user prompts and LLM responses as they go back and forth. +When providing a function as a `messageSender`, it's your responsibility to call +the underlying provider. If you don't, it won't get the message. This capability +allows you to do advanced things like routing to a provider dynamically or +Retrieval Augmented Generation (RAG). To see this in action, check out the [logging example app][]. -[logging example app]: {{site.github}}/flutter/ai/blob/main/example/lib/logging/logging.dart +[logging example app]: + {{site.github}}/flutter/ai/blob/main/example/lib/logging/logging.dart diff --git a/src/content/ai-toolkit/index.md b/src/content/ai-toolkit/index.md index 3a52c2834b0..73d586c6957 100644 --- a/src/content/ai-toolkit/index.md +++ b/src/content/ai-toolkit/index.md @@ -10,70 +10,54 @@ next: Hello and welcome to the Flutter AI Toolkit! -:::note -These pages are now out of date. They will be -updated soon but, in the meantime, be aware that the -`google_generative_ai` and `vertexai_firebase` packages -are deprecated and replaced with [`package:firebase_ai`][]. -::: - -[`package:firebase_ai`]: {{site.pub-pkg}}/firebase_ai - The AI Toolkit is a set of AI chat-related widgets that make it easy to add an AI chat window to your Flutter app. The AI Toolkit is organized around an abstract LLM provider API to make it easy to swap out the LLM provider that you'd like your chat provider to use. -Out of the box, it comes with support for two LLM provider -integrations: Google Gemini AI and Firebase Vertex AI. +Out of the box, it comes with support for [Firebase AI Logic][]. + +[Firebase AI Logic]: https://firebase.google.com/docs/ai-logic ## Key features -* **Multi-turn chat**: Maintains context across multiple interactions. -* **Streaming responses**: Displays AI responses in - real-time as they are generated. +* **Multiturn chat**: Maintains context across multiple interactions. +* **Streaming responses**: Displays AI responses in real-time as they are + generated. * **Rich text display**: Supports formatted text in chat messages. * **Voice input**: Allows users to input prompts using speech. -* **Multimedia attachments**: Enables sending and - receiving various media types. -* **Custom styling**: Offers extensive customization to - match your app's design. +* **Multimedia attachments**: Enables sending and receiving various media types. +* **Function calling**: Supports tool calls to the LLM provider. +* **Custom styling**: Offers extensive customization to match your app's design. * **Chat serialization/deserialization**: Store and retrieve conversations between app sessions. -* **Custom response widgets**: Introduce specialized UI components - to present LLM responses. -* **Pluggable LLM support**: Implement a simple interface to plug - in your own LLM. -* **Cross-platform support**: Compatible with Android, iOS, web, - and macOS platforms. +* **Custom response widgets**: Introduce specialized UI components to present + LLM responses. +* **Pluggable LLM support**: Implement a simple interface to plug in your own + LLM. +* **Cross-platform support**: Compatible with Android, iOS, web, and macOS + platforms. -## Online Demo +## Demo -Here's the online demo hosting the AI Toolkit: +Here's what the demo example looks like hosting the AI Toolkit: - -AI demo app - +AI demo app The [source code for this demo][src-code] is available in the repo on GitHub. -Or, you can open it in [Firebase Studio][], -Google's full-stack AI workspace and IDE that runs in the cloud: +Or, you can open it in [Firebase Studio][], Google's full-stack AI workspace and +IDE that runs in the cloud: - - - - + - Try in Firebase Studio - - + srcset="https://cdn.firebasestudio.dev/btn/try_dark_32.svg"> Try in Firebase Studio [src-code]: {{site.github}}/flutter/ai/blob/main/example/lib/demo/demo.dart [Firebase Studio]: https://firebase.studio/ @@ -88,92 +72,33 @@ Add the following dependencies to your `pubspec.yaml` file: ```yaml dependencies: flutter_ai_toolkit: ^latest_version - google_generative_ai: ^latest_version # you might choose to use Gemini, - firebase_core: ^latest_version # or Vertex AI or both + firebase_ai: ^latest_version + firebase_core: ^latest_version ``` -
  • Gemini AI configuration - -The toolkit supports both Google Gemini AI and -Firebase Vertex AI as LLM providers. -To use Google Gemini AI, -[obtain an API key][] from Gemini AI Studio. -Be careful not to check this key into your source code -repository to prevent unauthorized access. - -[obtain an API key]: https://aistudio.google.com/app/apikey - -You'll also need to choose a specific Gemini model name -to use in creating an instance of the Gemini model. -The following example uses `gemini-2.0-flash`, -but you can choose from an [ever-expanding set of models][models]. - -[models]: https://ai.google.dev/gemini-api/docs/models/gemini - +
  • Configuration -```dart -import 'package:google_generative_ai/google_generative_ai.dart'; -import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart'; - -// ... app stuff here - -class ChatPage extends StatelessWidget { - const ChatPage({super.key}); +The AI Toolkit supports both Google Gemini endpoint (for prototyping) and +the Vertex endpoint (for production). Both require a Firebase project and +the `firebase_core` package to be initialized, as described in the +[Get started with the Gemini API using the Firebase AI Logic SDKs][firebase_ai] docs. - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text(App.title)), - body: LlmChatView( - provider: GeminiProvider( - model: GenerativeModel( - model: 'gemini-2.0-flash', - apiKey: 'GEMINI-API-KEY', - ), - ), - ), - ); -} -``` - -The `GenerativeModel` class comes from the -`google_generative_ai` package. -The AI Toolkit builds on top of this package with -the `GeminiProvider`, which plugs Gemini AI into the -`LlmChatView`, the top-level widget that provides an -LLM-based chat conversation with your users. - -For a complete example, check out [`gemini.dart`][] on GitHub. - -[`gemini.dart`]: {{site.github}}/flutter/ai/blob/main/example/lib/gemini/gemini.dart -
  • +[firebase_ai]: + https://firebase.google.com/docs/ai-logic/get-started?platform=flutter -
  • Vertex AI configuration - -While Gemini AI is useful for quick prototyping, -the recommended solution for production apps is -Vertex AI in Firebase. This eliminates the need -for an API key in your client app and replaces it -with a more secure Firebase project. -To use Vertex AI in your project, -follow the steps described in the -[Get started with the Gemini API using the Vertex AI in Firebase SDKs][vertex] docs. - -[vertex]: https://firebase.google.com/docs/vertex-ai/get-started?platform=flutter - -Once that's complete, integrate the new Firebase project -into your Flutter app using the `flutterfire CLI` tool, -as described in the [Add Firebase to your Flutter app][firebase] docs. +Once that's complete, integrate the new Firebase project into your Flutter app +using the `flutterfire CLI` tool, as described in the [Add Firebase to your +Flutter app][firebase] docs. [firebase]: https://firebase.google.com/docs/flutter/setup -After following these instructions, -you're ready to use Firebase Vertex AI in your Flutter app. -Start by initializing Firebase: +After following these instructions, you're ready to use Firebase to integrate AI +in your Flutter app. Start by initializing Firebase: ```dart import 'package:firebase_core/firebase_core.dart'; -import 'package:firebase_vertexai/firebase_vertexai.dart'; +import 'package:firebase_ai/firebase_ai.dart'; import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart'; // ... other imports @@ -189,21 +114,28 @@ void main() async { // ...app stuff here ``` -With Firebase properly initialized in your Flutter app, -you're now ready to create an instance of the Vertex provider: +With Firebase properly initialized in your Flutter app, you're now ready to +create an instance of the Firebase provider. You can do this in two ways. For +prototyping, consider the Gemini AI endpoint: ```dart +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:flutter_ai_toolkit/flutter_ai_toolkit.dart'; + +// ... app stuff here + class ChatPage extends StatelessWidget { const ChatPage({super.key}); @override Widget build(BuildContext context) => Scaffold( appBar: AppBar(title: const Text(App.title)), - // create the chat view, passing in the Vertex provider + // create the chat view, passing in the Firebase provider body: LlmChatView( - provider: VertexProvider( - chatModel: FirebaseVertexAI.instance.generativeModel( - model: 'gemini-2.0-flash', + provider: FirebaseProvider( + // Use the Google AI endpoint + model: FirebaseAI.googleAI().generativeModel( + model: 'gemini-2.5-flash', ), ), ), @@ -211,20 +143,44 @@ class ChatPage extends StatelessWidget { } ``` - -The `FirebaseVertexAI` class comes from the -`firebase_vertexai` package. The AI Toolkit -builds the `VertexProvider` class to expose -Vertex AI to the `LlmChatView`. +The `FirebaseProvider` class exposes +the Firebase AI Logic SDK to the `LlmChatView`. Note that you provide a model name ([you have several options][options] from which to choose), but you do not provide an API key. All of that is handled as part of the Firebase project. -For a complete example, check out [vertex.dart][] on GitHub. +For production workloads, it's easy to swap in the Firebase Logic AI endpoint: + +```dart +class ChatPage extends StatelessWidget { + const ChatPage({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text(App.title)), + body: LlmChatView( + provider: FirebaseProvider( + // Use the Vertex AI endpoint + model: FirebaseAI.vertexAI().generativeModel( + model: 'gemini-2.5-flash', + ), + ), + ), + ); +} +``` + + +For a complete example, check out the [gemini.dart] and [vertex.dart][] +examples. -[options]: https://firebase.google.com/docs/vertex-ai/gemini-models#available-model-names -[vertex.dart]: {{site.github}}/flutter/ai/blob/main/example/lib/vertex/vertex.dart +[options]: + https://firebase.google.com/docs/vertex-ai/gemini-models#available-model-names +[gemini.dart]: + {{site.github}}/flutter/ai/blob/main/example/lib/gemini/gemini.dart +[vertex.dart]: + {{site.github}}/flutter/ai/blob/main/example/lib/vertex/vertex.dart
  • Set up device permissions @@ -232,9 +188,8 @@ For a complete example, check out [vertex.dart][] on GitHub. To enable your users to take advantage of features like voice input and media attachments, ensure that your app has the necessary permissions: -* **Network access:** - To enable network access on macOS, - add the following to your `*.entitlements` files: +* **Network access:** To enable network access on macOS, add the following to + your `*.entitlements` files: ```xml @@ -246,8 +201,8 @@ attachments, ensure that your app has the necessary permissions: ``` - To enable network access on Android, - ensure that your `AndroidManifest.xml` file contains the following: + To enable network access on Android, ensure that your `AndroidManifest.xml` + file contains the following: ```xml @@ -256,14 +211,14 @@ attachments, ensure that your app has the necessary permissions: ``` -* **Microphone access**: Configure according to the - [record package's permission setup instructions][record]. +* **Microphone access**: Configure according to the [record package's permission + setup instructions][record]. * **File selection**: Follow the [file_selector plugin's instructions][file]. * **Image selection**: To take a picture on _or_ select a picture from their - device, refer to the - [image_picker plugin's installation instructions][image_picker]. -* **Web photo**: To take a picture on the web, configure the app - according to the [camera plugin's setup instructions][camera]. + device, refer to the [image_picker plugin's installation + instructions][image_picker]. +* **Web photo**: To take a picture on the web, configure the app according to + the [camera plugin's setup instructions][camera]. [camera]: {{site.pub-pkg}}/camera#setup [file]: {{site.pub-pkg}}/file_selector#usage @@ -274,45 +229,32 @@ attachments, ensure that your app has the necessary permissions: ## Examples -To execute the [example apps][] in the repo, -you'll need to replace the `example/lib/gemini_api_key.dart` -and `example/lib/firebase_options.dart` files, -both of which are just placeholders. They're needed -to enable the example projects in the `example/lib` folder. - -**gemini_api_key.dart** - -Most of the example apps rely on a Gemini API key, -so for those to work, you'll need to plug your API key -in the `example/lib/gemini_api_key.dart` file. -You can get an API key in [Gemini AI Studio][]. - -:::note -**Be careful not to check the `gemini_api_key.dart` file into your git repo.** -::: +To execute the [example apps][] in the repo, you'll need to replace the +`example/lib/gemini_api_key.dart` and `example/lib/firebase_options.dart` files, +both of which are just placeholders. They're needed to enable the example +projects in the `example/lib` folder. **firebase_options.dart** -To use the [Vertex AI example app][vertex-ex], -place your Firebase configuration details -into the `example/lib/firebase_options.dart` file. -You can do this with the `flutterfire CLI` tool as described -in the [Add Firebase to your Flutter app][add-fb] docs -**from within the `example` directory**. - -:::note -**Be careful not to check the `firebase_options.dart` -file into your git repo.** -::: - -## Feedback! - -Along the way, as you use this package, -please [log issues and feature requests][file-issues] as well as -submit any [code you'd like to contribute][submit]. -We want your feedback and your contributions -to ensure that the AI Toolkit is just as robust and useful -as it can be for your real-world apps. +To use the [Vertex AI example app][vertex-ex], +place your Firebase configuration details +into the `example/lib/firebase_options.dart` file. +You can do this with the `flutterfire CLI` tool as described +in the [Add Firebase to your Flutter app][add-fb] docs +**from within the `example` directory**. + +:::note +**Be careful not to check the `firebase_options.dart` +file into your git repo.** +::: + +## Feedback + +Along the way, as you use this package, please [log issues and feature +requests][file-issues] as well as submit any [code you'd like to +contribute][submit]. We want your feedback and your contributions to ensure that +the AI Toolkit is just as robust and useful as it can be for your real-world +apps. [add-fb]: https://firebase.google.com/docs/flutter/setup [example apps]: {{site.github}}/flutter/ai/tree/main/example/lib diff --git a/src/content/ai-toolkit/user-experience.md b/src/content/ai-toolkit/user-experience.md index e1196aa548a..763f69d52c6 100644 --- a/src/content/ai-toolkit/user-experience.md +++ b/src/content/ai-toolkit/user-experience.md @@ -10,107 +10,113 @@ next: path: /ai-toolkit/feature-integration --- -The [`LlmChatView`][] widget is the entry point for the -interactive chat experience that AI Toolkit provides. -Hosting an instance of the `LlmChatView` enables a -number of user experience features that don't require -any additional code to use: - -* **Multi-line text input**: Allows users to paste long text - input or insert new lines into their text as they enter it. -* **Voice input**: Allows users to input prompts using speech - for ease of use. -* **Multimedia input**: Enables users to take pictures and - send images and other file types. +The [`LlmChatView`][] widget is the entry point for the interactive chat +experience that AI Toolkit provides. Hosting an instance of the `LlmChatView` +enables a number of user experience features that don't require any additional +code to use: + +* **Multiline text input**: Allows users to paste long text input or insert new + lines into their text as they enter it. +* **Voice input**: Allows users to input prompts using speech for ease of use. +* **Multimedia input**: Enables users to take pictures, send images and other + file types and attach URLs as link to online resources. * **Image zoom**: Enables users to zoom into image thumbnails. -* **Copy to clipboard**: Allows the user to copy the text of - a message or a LLM response to the clipboard. -* **Message editing**: Allows the user to edit the most recent - message for resubmission to the LLM. -* **Material and Cupertino**: Adapts to the best practices of - both design languages. +* **Copy to clipboard**: Allows the user to copy the text of a message or a LLM + response to the clipboard. +* **Message editing**: Allows the user to edit the most recent message for + resubmission to the LLM. +* **Material and Cupertino**: Adapts to the best practices of both design + languages. -[`LlmChatView`]: {{site.pub-api}}/flutter_ai_toolkit/latest/flutter_ai_toolkit/LlmChatView-class.html +[`LlmChatView`]: + {{site.pub-api}}/flutter_ai_toolkit/latest/flutter_ai_toolkit/LlmChatView-class.html -## Multi-line text input +## Multiline text input -The user has options when it comes to submitting -their prompt once they've finished composing it, -which again differs depending on their platform: +The user has options when it comes to submitting their prompt once they've +finished composing it, which again differs depending on their platform: * **Mobile**: Tap the **Submit** button * **Web**: Press **Enter** or tap the **Submit** button * **Desktop**: Press **Enter** or tap the **Submit** button -In addition, the chat view supports text prompts -with embedded newlines in them. If the user has existing -text with newlines, they can paste them into the -prompt text field as normal. +In addition, the chat view supports text prompts with embedded newlines in them. +If the user has existing text with newlines, they can paste them into the prompt +text field as normal. -If they'd like to embed newlines into their prompt -manually as they enter it, they can do so. -The gesture for that activity differs based on the -platform they're using: +If they'd like to embed newlines into their prompt manually as they enter it, +they can do so. The gesture for that activity differs based on the platform +they're using: * **Mobile**: Tap Return key on the virtual keyboard -* **Web**: Unsupported -* **Desktop**: Press `Ctrl+Enter` or `Opt/Alt+Enter` +* **Web**: Press `Shift+Enter` +* **Desktop**: Press `Shift+Enter` These options look like the following: -**Desktop**: +**Web and Desktop**: -![Screenshot of entering text on desktop](/assets/images/docs/ai-toolkit/desktop-enter-text.png) +![Screenshot of entering text on +desktop](/assets/images/docs/ai-toolkit/desktop-enter-text.png) **Mobile**: -![Screenshot of entering text on mobile](/assets/images/docs/ai-toolkit/mobile-enter-text.png) +![Screenshot of entering text on +mobile](/assets/images/docs/ai-toolkit/mobile-enter-text.png) ## Voice input -In addition to text input the chat view can take an -audio recording as input by tapping the Mic button, -which is visible when no text has yet been entered. +In addition to text input the chat view can take an audio recording as input by +tapping the Mic button, which is visible when no text has yet been entered. -Tapping the **Mic** button starts the recording: +Tap the **Mic** button to start the recording: -![Screenshot of entering text](/assets/images/docs/ai-toolkit/enter-textfield.png) +![Screenshot of entering +text](/assets/images/docs/ai-toolkit/enter-textfield.png) -Pressing the **Stop** button translates the user's voice input into text: +Select the **Stop** button to translate the user's voice input into text: This text can then be edited, augmented and submitted as normal. -![Screenshot of entered voice](/assets/images/docs/ai-toolkit/enter-voice-into-textfield.png) +![Screenshot of entered +voice](/assets/images/docs/ai-toolkit/enter-voice-into-textfield.png) -## Multi-media Input +## Multimedia input -![Textfield containing "Testing, testing, one, two, three"](/assets/images/docs/ai-toolkit/multi-media-testing-testing.png) +![Textfield containing "Testing, testing, one, two, +three"](/assets/images/docs/ai-toolkit/multi-media-testing-testing.png) -The chat view can also take images and files as input to pass along -to the underlying LLM. The user can press the **Plus** button to the -left of the text input and choose from the **Take Photo**, **Image Gallery**, -and **Attach File** icons: +The chat view can also take images and files as input to pass along to the +underlying LLM. The user can select the **Plus** button to the left of the text +input and choose from the **Take Photo**, **Image Gallery**, **Attach File** and +**Attach Link** icons: -![Screenshot of the 4 icons](/assets/images/docs/ai-toolkit/multi-media-icons.png) +![Screenshot of the 4 +icons](/assets/images/docs/ai-toolkit/multi-media-icons.png) -The **Take Photo** button allows the user to use their device's camera to take a photo: +The **Take Photo** button allows the user to use their device's camera to take a +photo: ![Selfie image](/assets/images/docs/ai-toolkit/selfie.png) -Pressing the **Image Gallery** button lets the user upload -from their device's image gallery: +Select the **Image Gallery** button to let the user upload from their device's +image gallery: -![Download image from gallery](/assets/images/docs/ai-toolkit/download-from-gallery.png) +![Download image from +gallery](/assets/images/docs/ai-toolkit/download-from-gallery.png) -Pressing the **Attach File** button lets the user select -a file of any type available on their device, like a PDF or TXT file. +Select the **Attach File** button to let the user select a file of any type +available on their device, like a PDF or TXT file. -Once a photo, image, or file has been selected, it becomes an attachment and shows up as a thumbnail associated with the currently active prompt: +Select the **Attach Link** button to let the user enter a link to a web page or +an online file. + +Once a photo, image, file, or link has been selected, it becomes an attachment +and shows up as a thumbnail associated with the currently active prompt: ![Thumbnails of images](/assets/images/docs/ai-toolkit/image-thumbnails.png) -The user can remove an attachment by clicking the -**X** button on the thumbnail. +The user can remove an attachment by clicking the **X** button on the thumbnail. ## Image zoom @@ -118,75 +124,72 @@ The user can zoom into an image thumbnail by tapping it: ![Zoomed image](/assets/images/docs/ai-toolkit/image-zoom.png) -Pressing the **ESC** key or tapping anywhere outside the -image dismisses the zoomed image. +Pressing the **Esc** key or tapping anywhere outside the image dismisses the +zoomed image. ## Copy to clipboard -The user can copy any text prompt or LLM response -in their current chat in a variety of ways. -On the desktop or the web, the user can mouse -to select the text on their screen and -copy it to the clipboard as normal: +The user can copy any text prompt or LLM response in their current chat in a +variety of ways. On the desktop or the web, the user can mouse to select the +text on their screen and copy it to the clipboard as normal: ![Copy to clipboard](/assets/images/docs/ai-toolkit/copy-to-clipboard.png) -In addition, at the bottom of each prompt or response, -the user can press the **Copy** button that pops up -when they hover their mouse: +In addition, at the bottom of each prompt or response, the user can select the +**Copy** button that pops up when they hover their mouse: -![Press the copy button](/assets/images/docs/ai-toolkit/chatbot-prompt.png) +![Select the copy button](/assets/images/docs/ai-toolkit/chatbot-prompt.png) -On mobile platforms, the user can long-tap a prompt or response and choose the Copy option: +On mobile platforms, the user can long-tap a prompt or response and choose the +Copy option: -![Long tap to see the copy button](/assets/images/docs/ai-toolkit/long-tap-choose-copy.png) +![Long tap to see the copy +button](/assets/images/docs/ai-toolkit/long-tap-choose-copy.png) ## Message editing -If the user would like to edit their last prompt -and cause the LLM to take another run at it, -they can do so. On the desktop, -the user can tap the **Edit** button alongside the -**Copy** button for their most recent prompt: +If the user would like to edit their last prompt and cause the LLM to take +another run at it, they can do so. On the desktop, the user can tap the **Edit** +button alongside the **Copy** button for their most recent prompt: ![How to edit prompt](/assets/images/docs/ai-toolkit/how-to-edit-prompt.png) -On a mobile device, the user can long-tap and get access -to the **Edit** option on their most recent prompt: +On a mobile device, the user can long-tap and get access to the **Edit** option +on their most recent prompt: -![How to access edit menu](/assets/images/docs/ai-toolkit/accessing-edit-menu.png) +![How to access edit +menu](/assets/images/docs/ai-toolkit/accessing-edit-menu.png) -Once the user taps the **Edit** button, they enter Editing mode, -which removes both the user's last prompt and the LLM's -last response from the chat history, -puts the text of the prompt into the text field, and -provides an Editing indicator: +Once the user taps the **Edit** button, they enter Editing mode, which removes +both the user's last prompt and the LLM's last response from the chat history, +puts the text of the prompt into the text field, and provides an Editing +indicator: -![How to exit editing mode](/assets/images/docs/ai-toolkit/how-to-exit-editing-mode.png) +![How to exit editing +mode](/assets/images/docs/ai-toolkit/how-to-exit-editing-mode.png) -In Editing mode, the user can edit the prompt as they choose -and submit it to have the LLM produce a response as normal. -Or, if they change their mind, they can tap the **X** -near the Editing indicator to cancel their edit and restore +In Editing mode, the user can edit the prompt as they choose and submit it to +have the LLM produce a response as normal. Or, if they change their mind, they +can tap the **X** near the Editing indicator to cancel their edit and restore their previous LLM response. ## Material and Cupertino -When the `LlmChatView` widget is hosted in a [Material app][], -it uses facilities provided by the Material design language, -such as Material's [`TextField`][]. -Likewise, when hosted in a [Cupertino app][], -it uses those facilities, such as [`CupertinoTextField`][]. +When the `LlmChatView` widget is hosted in a [Material app][], it uses +facilities provided by the Material design language, such as Material's +[`TextField`][]. Likewise, when hosted in a [Cupertino app][], it uses those +facilities, such as [`CupertinoTextField`][]. ![Cupertino example app](/assets/images/docs/ai-toolkit/cupertino-chat-app.png) -However, while the chat view supports both the Material and -Cupertino app types, it doesn't automatically adopt the associated themes. -Instead, that's set by the `style` property of the `LlmChatView` -as described in the [Custom styling][] documentation. +However, while the chat view supports both the Material and Cupertino app types, +it doesn't automatically adopt the associated themes. Instead, that's set by the +`style` property of the `LlmChatView` as described in the [Custom styling][] +documentation. [Cupertino app]: {{site.api}}/flutter/cupertino/CupertinoApp-class.html -[`CupertinoTextField`]: {{site.api}}/flutter/cupertino/CupertinoTextField-class.html +[`CupertinoTextField`]: + {{site.api}}/flutter/cupertino/CupertinoTextField-class.html [Custom styling]: /ai-toolkit/feature-integration#custom-styling [Material app]: {{site.api}}/flutter/material/MaterialApp-class.html [`TextField`]: {{site.api}}/flutter/material/TextField-class.html diff --git a/src/content/app-architecture/case-study/ui-layer.md b/src/content/app-architecture/case-study/ui-layer.md index 6e0371ae692..9de0ab62729 100644 --- a/src/content/app-architecture/case-study/ui-layer.md +++ b/src/content/app-architecture/case-study/ui-layer.md @@ -41,7 +41,7 @@ a view model class called the `HomeViewModel`. Its inputs are the [repositories][] that provide its data. In this case, the view model is dependent on the -`BookingRepository` and `UserRepository` as arguments. +`BookingRepository`and `UserRepository` as arguments. ```dart title=home_viewmodel.dart class HomeViewModel { @@ -61,7 +61,7 @@ class HomeViewModel { View models are always dependent on data repositories, which are provided as arguments to the view model's constructor. -View models and repositories have a many-to-many relationship, +view models and repositories have a many-to-many relationship, and most view models will depend on multiple repositories. As in the earlier `HomeViewModel` example declaration, diff --git a/src/content/app-architecture/recommendations.md b/src/content/app-architecture/recommendations.md index 98ad62e7445..83322813698 100644 --- a/src/content/app-architecture/recommendations.md +++ b/src/content/app-architecture/recommendations.md @@ -27,32 +27,49 @@ which reflects how strongly the Flutter team recommends it. * **Recommend**: This practice will likely improve your app. * **Conditional**: This practice can improve your app in certain circumstances. -## Separation of concerns - -You should separate your app into a UI layer and a data layer. Within those layers, -you should further separate logic into classes by responsibility. - - - -## Handling data - -Handling data with care makes your code easier to understand, less error prone, and -prevents malformed or unexpected data from being created. - - - -## App structure - -Well organized code benefits both the health of the app itself, and the team working on the code. - - - -## Testing - -Good testing practices makes your app flexible. -It also makes it straightforward and low risk to add new logic and new UI. - - +{% for section in architectureRecommendations %} +## {{section.category}} + +{{section.description}} + +{% if section.recommendations.size > 0 %} + + + + + + + + + +{% for rec in section.recommendations %} + + + + +{% endfor %} + +
    RecommendationDescription
    + + {{rec.recommendation}} + +{% if rec.confidence == "strong" %} +
    Strongly recommend
    +{% elsif rec.confidence == "recommend" %} +
    Recommend
    +{% else %} +
    Conditional
    +{% endif %} + +
    + + {{rec.description}} + {{rec.confidence-description}} + +
    + +{% endif %} +{% endfor %} @@ -83,6 +100,14 @@ It also makes it straightforward and low risk to add new logic and new UI. Use this package to encourage good coding practices across a team. +[Separation-of-concerns]: https://en.wikipedia.org/wiki/Separation_of_concerns +[architecture case study]: /app-architecture/guide +[our ChangeNotifier recommendation]: /get-started/fwe/state-management +[other popular options]: https://docs.flutter.dev/data-and-backend/state-mgmt/options +[freezed]: https://pub.dev/packages/freezed +[built_value]: https://pub.dev/packages/built_value +[Flutter Navigator API]: https://docs.flutter.dev/ui/navigation +[pub.dev]: https://pub.dev [Compass app source code]: https://github.com/flutter/samples/tree/main/compass_app [very_good_cli]: https://cli.vgv.dev/ [Very Good Engineering architecture documentation]: https://engineering.verygood.ventures/architecture/ diff --git a/src/content/packages-and-plugins/developing-packages.md b/src/content/packages-and-plugins/developing-packages.md index 3ef1815ec05..bf914e55813 100644 --- a/src/content/packages-and-plugins/developing-packages.md +++ b/src/content/packages-and-plugins/developing-packages.md @@ -133,49 +133,40 @@ implementation(s) using a [platform channel][]. ### Federated plugins -**Federated plugins** are a way of splitting the API of a plugin -into a platform interface, independent platform implementations -of that interface, and an app-facing interface that uses the -registered implementation of the running platform. - -**Package-separated federated plugins** are federated plugins where -the platform interface, platform implementations, and the app-facing -interface are all separated into their own Dart packages. - -So, a package-separated federated plugin can use one package for iOS, +Federated plugins are a way of splitting support for +different platforms into separate packages. +So, a federated plugin can use one package for iOS, another for Android, another for web, and yet another for a car (as an example of an IoT device). Among other benefits, this approach allows a domain expert to extend an existing plugin to work for the platform they know best. -A federated plugin requires the following: - -**app-facing interface** -: The interface that plugin users interact with when using the - plugin. This interface specifies the API used by the Flutter app. - In a package-separated federated plugin, this is the package - that plugin users depend on to use the plugin. - -**platform implementation(s)** -: One or more implementations that contain the platform-specific - implementation code. The app-facing interface calls into - these implementations—they aren't directly used, or - depended on when package-separated, in an app unless they contain - platform-specific functionality accessible to the end user. - -**platform interface** -: The interface that glues the app-facing interface - to the platform implementations(s). This declares an - interface that any platform implementation must implement to - support the app-facing interface. Having a separate package +A federated plugin requires the following packages: + +**app-facing package** +: The package that plugin users depend on to use the plugin. + This package specifies the API used by the Flutter app. + +**platform package(s)** +: One or more packages that contain the platform-specific + implementation code. The app-facing package calls into + these packages—they aren't included into an app, + unless they contain platform-specific functionality + accessible to the end user. + +**platform interface package** +: The package that glues the app-facing package + to the platform package(s). This package declares an + interface that any platform package must implement to + support the app-facing package. Having a single package that defines this interface ensures that all platform packages implement the same functionality in a uniform way. #### Endorsed federated plugin Ideally, when adding a platform implementation to -a packaged-separated federated plugin, you will coordinate with -the package author to include your implementation. +a federated plugin, you will coordinate with the package +author to include your implementation. In this way, the original author _endorses_ your implementation. diff --git a/src/content/platform-integration/web/faq.md b/src/content/platform-integration/web/faq.md index b0b25f56414..b8a396bd6f2 100644 --- a/src/content/platform-integration/web/faq.md +++ b/src/content/platform-integration/web/faq.md @@ -40,12 +40,13 @@ investigate search engine indexability of Flutter web. ### Does hot reload work with a web app? -Yes! For more information, check out +Yes! Though it's currently behind an experimental flag. +For more information, check out [hot reload on the web][]. [hot reload on the web]: /platform-integration/web/building#hot-reload-web -Hot restart is a fast way of seeing your +Hot restart doesn't require a flag and is a fast way of seeing your changes without having to relaunch your web app and wait for it to compile and load. This works similarly to the hot reload feature for Flutter mobile development. diff --git a/src/content/platform-integration/web/initialization.md b/src/content/platform-integration/web/initialization.md index 4b16b95fcf7..ab1d7f0f2cf 100644 --- a/src/content/platform-integration/web/initialization.md +++ b/src/content/platform-integration/web/initialization.md @@ -126,35 +126,11 @@ The `config` argument is an object that can have the following optional fields: |`entrypointBaseUrl`| The base URL of your Flutter app's entrypoint. Defaults to "/". |`String`| |`hostElement`| HTML Element into which Flutter renders the app. When not set, Flutter web takes over the whole page. |`HtmlElement`| |`renderer`| Specifies the [web renderer][web-renderers] for the current Flutter application, either `"canvaskit"` or `"skwasm"`. |`String`| -|`forceSingleThreadedSkwasm`| Forces the Skia WASM renderer to run in single-threaded mode for compatibility. |`bool`| {:.table} [web-renderers]: /platform-integration/web/renderers -## forceSingleThreadedSkwasm - -A boolean flag to force the Skia WebAssembly (skwasm) renderer -to run in **single-threaded mode**. This is useful if: - -- Your environment does not support multi-threaded WASM (for example, - `SharedArrayBuffer` is not available or required security - headers are missing). -- You want maximum browser compatibility. -- Use `false` (default) to allow multi-threaded rendering when - supported, which improves performance. - -## Example usage - -```js -_flutter.loader.load({ - config: { - renderer: 'skwasm', - forceSingleThreadedSkwasm: true, - }, -}); -``` - ## Example: Customizing Flutter configuration based on URL query parameters The following example shows a custom `flutter_bootstrap.js` that allows diff --git a/src/content/reference/widgets.md b/src/content/reference/widgets.md index d18b42d143e..82d8a91f383 100644 --- a/src/content/reference/widgets.md +++ b/src/content/reference/widgets.md @@ -18,6 +18,26 @@ our [videos](/resources/videos) page. [Widget of the Week playlist]({{site.yt.playlist}}PLjxrf2q8roU23XGwz3Km7sQZFTdB996iG) - + [catalog]: /ui/widgets diff --git a/src/content/release/breaking-changes/flutter-generate-i10n-source.md b/src/content/release/breaking-changes/flutter-generate-i10n-source.md index 263ddc1f5eb..4f2bcf3187d 100755 --- a/src/content/release/breaking-changes/flutter-generate-i10n-source.md +++ b/src/content/release/breaking-changes/flutter-generate-i10n-source.md @@ -55,7 +55,7 @@ const MaterialApp( ); ``` -There is one way to migrate away from importing `package:flutter_gen`: +There are two ways to migrate away from importing `package:flutter_gen`: 1. Specify `synthetic-package: false` in the accompanying [`l10n.yaml`][] file: @@ -69,6 +69,12 @@ There is one way to migrate away from importing `package:flutter_gen`: output-dir: lib/src/generated/i18n ``` + 2. Enable the `explicit-package-dependencies` feature flag: + + ```sh + flutter config --explicit-package-dependencies + ``` + ## Timeline Landed in version: 3.28.0-0.0.pre
    diff --git a/src/content/tools/devtools/release-notes/release-notes-2.53.0.md b/src/content/tools/devtools/release-notes/release-notes-2.53.0.md deleted file mode 100644 index b52b948ec09..00000000000 --- a/src/content/tools/devtools/release-notes/release-notes-2.53.0.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: DevTools 2.53.0 release notes -shortTitle: 2.53.0 release notes -breadcrumb: 2.53.0 -showToc: false ---- - -# DevTools 2.53.0 release notes - -The 2.53.0 release of the Dart and Flutter DevTools -includes the following changes among other general improvements. -To learn more about DevTools, check out the -[DevTools overview](/tools/devtools/overview). - -## General updates - -- Switched default compiler for DevTools to `dart2wasm`. - - [#9530](https://github.com/flutter/devtools/pull/9530) - -## Performance updates - -- Increased profile data limit from 64MB to 2GB, fixing issue where panel - wouldn't load for large profiles. - - [#9540](https://github.com/flutter/devtools/pull/9540) - -## Advanced developer mode updates - -- Fixed issue preventing CPU profiles from loading when "advanced developer - mode" was enabled. - [#9528](https://github.com/flutter/devtools/pull/9528) - -## Full commit history - -To find a complete list of changes in this release, check out the -[DevTools git log](https://github.com/flutter/devtools/tree/v2.53.0). diff --git a/src/content/tools/hot-reload.md b/src/content/tools/hot-reload.md index 6c36bafe18f..bcfa204fec5 100644 --- a/src/content/tools/hot-reload.md +++ b/src/content/tools/hot-reload.md @@ -75,8 +75,6 @@ Your app continues to execute from where it was prior to run the hot reload command. The code updates and execution continues. - - :::secondary **What is the difference between hot reload, hot restart, and full restart?** diff --git a/src/content/tutorial/index.md b/src/content/tutorial/index.md index 97c80e9a61d..bff15c330a7 100644 --- a/src/content/tutorial/index.md +++ b/src/content/tutorial/index.md @@ -1,7 +1,7 @@ --- title: Learn Flutter description: Resources to help you learn Flutter. -layout: tutorial +showToc: false sitemap: false --- @@ -10,8 +10,8 @@ sitemap: false Welcome to the Flutter tutorial! This tutorial teaches you how to build applications from scratch that run on mobile, desktop, and web. -You'll start from the very beginning: creating a blank Flutter application. -By the end, you'll have built a handful of small apps that demonstrate +You’ll start from the very beginning: creating a blank Flutter application. +By the end, you’ll have built a handful of small apps that demonstrate the critical features of Flutter development (and more!) {%- comment %} @@ -22,42 +22,71 @@ TODO(ewindmill) welcome video Flutter is an open-source UI toolkit that helps you build natively compiled, expressive apps across mobile, web, and desktop from a single codebase. -It's declarative, reactive, features hot reload for fast development cycles, +It’s declarative, reactive, features hot reload for fast development cycles, and has a rich set of customizable widgets for creating expressive interfaces. Flutter draws every pixel itself rather than wrapping native components, -giving developers complete control over the UI and -ensuring visual consistency across platforms. +giving developers complete control over the UI and ensuring visual consistency +across platforms. ## How to use this tutorial -To follow this tutorial, -you should be familiar with the Dart programming language. -This tutorial assumes you have all the knowledge from its Dart counterpart, -the [Getting started with Dart][] tutorial. - -Alternatively, if you're comfortable -with another all-purpose object-oriented language, like Java or Kotlin, -you'll likely be okay. - -[Getting started with Dart]: {{site.dart-site}}/tutorial +You should be familiar with the Dart programming language to follow this +tutorial. This tutorial assumes you have all the knowledge from its Dart +counterpart, the [Learn Dart tutorial][]. (Alternatively, if you’re comfortable +with another all-purpose object oriented language, like Java or Kotlin, you’ll +likely be okay.) ## Set up -While reading this tutorial, -you'll ideally be coding along with the examples presented. +While reading this tutorial, you’ll ideally be coding along with the examples presented. You can do so by [installing Flutter on your machine][], or by using [Firebase Studio][], a web IDE that supports Flutter. -If you're running locally, this tutorial assumes that -you're running Flutter apps on the web, using [Chrome][]. -This doesn't require Xcode or Android Studio, +If you’re running locally, this tutorial assumes that you’re running Flutter +apps on the web, using [Chrome][]. This doesn’t require Xcode or Android Studio, and thus is the quickest way to start using Flutter. -[installing Flutter on your machine]: /get-started/install +## Contents + +1. Introdution to Flutter UI + 1. [Create a Flutter app][] + 2. [Widget fundamentals][] + 3. [Layout widgets on a screen][] + 4. [Devtools][] + 5. [Handle user input][] + 6. [Learn about stateful widgets][] + 7. [Add implicit animations][] +2. State in Flutter apps + 1. [Set up a new project][] + 2. [Make Http Requests][] + 3. [Use `ChangeNotifier` to update app state][] + 4. [Use `ListenableBuilder` to update app UI][] +3. Flutter UI 102 + 1. [Set up your project][] + 2. [`LayoutBuilder` and adaptive layouts][] + 3. [Scrolling and slivers][] + 4. [Stack based navigation][] + +[Learn Dart tutorial]: https://dart.dev/ +[installing Flutter on your machine]: /get-started [Firebase Studio]: https://firebase.studio/ [Chrome]: https://www.google.com/chrome/ -## Contents - - +[Create a Flutter app]: /tutorial/ui/1-create-an-app/ +[Widget fundamentals]: /tutorial/ui/2-widget-fundamentals/ +[Layout widgets on a screen]: /tutorial/ui/3-layout/ +[Devtools]: /tutorial/ui/4-devtools/ +[Handle user input]: /tutorial/ui/5-user-input/ +[Learn about stateful widgets]: /tutorial/ui/6-stateful-widget/ +[Add implicit animations]: /tutorial/ui/7-implicit-animations/ + +[Set up a new project]: /tutorial/state/1-set-up-project/ +[Make Http Requests]: /tutorial/state/2-http-requests/ +[Use `ChangeNotifier` to update app state]: /tutorial/state/3-change-notifier/ +[Use `ListenableBuilder` to update app UI]: /tutorial/state/4-listenable-builder/ + +[Set up your project]: /tutorial/ui-102/1-intro/ +[`LayoutBuilder` and adaptive layouts]: /tutorial/ui-102/2-adaptive-layout/ +[Scrolling and slivers]: /tutorial/ui-102/3-slivers/ +[Stack based navigation]: /tutorial/ui-102/4-navigation/ diff --git a/src/content/tutorial/state/set-up-project.md b/src/content/tutorial/state/1-set-up-project.md similarity index 59% rename from src/content/tutorial/state/set-up-project.md rename to src/content/tutorial/state/1-set-up-project.md index 97cd2318ae7..480290bcea0 100644 --- a/src/content/tutorial/state/set-up-project.md +++ b/src/content/tutorial/state/1-set-up-project.md @@ -1,7 +1,7 @@ --- title: Set up your project description: Instructions on how to create a new Flutter app. -layout: tutorial +permalink: /tutorial/set-up-state-app/ sitemap: false --- @@ -16,66 +16,54 @@ description, and extract text."> This tutorial explores: -- Making HTTP requests in Flutter. -- Managing application state with `ChangeNotifier`. -- Using the MVVM architecture pattern. -- Creating responsive user interfaces that - update automatically when data changes. +* Making HTTP requests in Flutter +* Managing application state with `ChangeNotifier` +* Using the MVVM architecture pattern +* Creating responsive user interfaces that update automatically when + data changes -This tutorial assumes you've completed the -[Getting started with Dart][] and the [Introduction to Flutter UI][] tutorials, -and therefore doesn't explain concepts like HTTP, JSON, or widget basics. -:::recommend Support Wikipedia +This tutorial assumes you've completed the [Dart Getting Started +tutorial][] and the [introductory Flutter tutorial][], and therefore +doesn't explain concepts like HTTP, JSON, or widget basics. -[Wikipedia][] is a valuable resource, providing free +:::note Support Wikipedia +Wikipedia is a valuable resource, providing free access to human knowledge through millions of articles written -collaboratively by volunteers worldwide. -Consider [donating to Wikipedia][] to help keep this incredible resource -free and accessible to everyone. - +collaboratively by volunteers worldwide. Consider [donating to +Wikipedia][] to help keep this incredible resource free and accessible +to everyone. ::: -[Wikipedia API]: https://en.wikipedia.org/api/rest_v1/ -[Getting started with Dart]: {{site.dart-site}}/tutorial -[Introduction to Flutter UI]: /tutorial/ui/create-an-app/ -[Wikipedia]: https://wikipedia.org/ -[donating to Wikipedia]: https://donate.wikimedia.org/ - ## Create a new Flutter project -Create a new Flutter project using the [Flutter CLI][]. -In your preferred terminal, run the following command to -create a minimal Flutter app: +Create a new Flutter project using the [Flutter CLI][]. In your +terminal, run the following command to create a minimal Flutter app: -```console +```bash $ flutter create wikipedia_reader --empty ``` -[Flutter CLI]: /reference/flutter-cli - ## Add required dependencies Your app needs two [packages][] to work with HTTP requests and Wikipedia data. Add them to your project: -```console -$ cd wikipedia_reader && flutter pub add http dartpedia +```shell +$ cd wikipedia_reader +$ flutter pub add http dartpedia ``` The [`http` package][] provides tools for making HTTP requests, while the `dartpedia` package contains data models for working with Wikipedia's API responses. -[packages]: /packages-and-plugins/using-packages -[`http` package]: {{site.pub}}/packages/http - ## Examine the starter code -Open `lib/main.dart` and replace the existing code with -this basic structure, which adds required imports that the app uses: +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" +```dart import 'dart:convert'; import 'dart:io'; @@ -106,18 +94,25 @@ class MainApp extends StatelessWidget { } ``` -This code provides a basic app structure with -a title bar and placeholder content. -The imports at the top include everything you need for -HTTP requests, JSON parsing, and Wikipedia data models. +This code provides a basic app structure with a title bar and +placeholder content. The imports at the top include everything you +need for HTTP requests, JSON parsing, and Wikipedia data models. ## Run your app Test that everything works by running your app: -```console +```bash $ flutter run -d chrome ``` You should see a simple app with "Wikipedia Flutter" in the app bar and "Loading..." in the center of the screen. + +[Wikipedia API]: https://en.wikipedia.org/api/rest_v1/ +[donating to Wikipedia]: https://donate.wikimedia.org/ +[introductory Flutter tutorial]: /tutorial/create-an-app/ +[Dart Getting Started tutorial]: {{site.dart-site}}/tutorial +[Flutter CLI]: /reference/flutter-cli +[packages]: /packages-and-plugins/using-packages +[`http` package]: {{site.pub}}/packages/http diff --git a/src/content/tutorial/state/http-requests.md b/src/content/tutorial/state/2-http-requests.md similarity index 52% rename from src/content/tutorial/state/http-requests.md rename to src/content/tutorial/state/2-http-requests.md index 39b7c929313..b6ada1f7c06 100644 --- a/src/content/tutorial/state/http-requests.md +++ b/src/content/tutorial/state/2-http-requests.md @@ -1,42 +1,36 @@ --- title: Fetch data from the internet description: Instructions on how to make HTTP requests and parse responses. -layout: tutorial +permalink: /tutorial/http-request/ sitemap: false --- The overarching pattern that this tutorial implements is called -_Model-View-ViewModel_ or _MVVM_. -MVVM is an [architectural pattern][] used in client apps that -separates your app into three layers: - -- **Model**: Handles data operations. -- **View**: Displays the UI. -- **ViewModel**: Manages state and connects the two. - -The core tenet of MVVM (and many other patterns) is *separation of concerns*. -Managing state in separate classes (outside your UI widgets) makes -your code more testable, reusable, and easier to maintain. +*Model-View-ViewModel* or *MVVM*. MVVM is an [architectural pattern][] +used in client apps that separates your app into three layers: the +Model handles data operations, the View displays the UI, and the +ViewModel manages state and connects them. The core tenet of MVVM +(and many other patterns) is *separation of concerns*. Managing state +in separate classes (outside your UI widgets) makes your code more +testable, reusable, and easier to maintain. A diagram that shows the three layers of MVVM architecture: Model, ViewModel, and View. -A single feature in your app contains each one of the MVVM components. -In this tutorial, in addition to Flutter widgets, -you'll create `ArticleModel`, `ArticleViewModel`, and `ArticleView`. - -[architectural pattern]: /app-architecture/guide +A single feature in your app contains each one of the MVVM components. In +this tutorial, you'll create an `ArticleModel`, `ArticleViewModel` and +`ArticleView`, in addition to Flutter widgets. ## Define the Model -The Model is the source-of-truth for your app's data and is responsible for -low-level tasks such as making HTTP requests, caching data, or -managing system resources such as used by a Flutter plugin. +The Model is the source-of-truth for your app's data, and is +responsible for low-level tasks such as making HTTP +requests, caching data, or managing system resources such as a plugin. 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. } @@ -45,13 +39,14 @@ class ArticleModel { ## Build the HTTP request Wikipedia provides a REST API that returns JSON data about articles. -For this app, you'll use the endpoint that returns a random article summary. +For this app, you'll use the endpoint that returns a random article +summary. -```text +```txt https://en.wikipedia.org/api/rest_v1/page/random/summary ``` -Add a method to fetch a random Wikipedia article summary: +Add a method to fetch random Wikipedia article summaries: ```dart class ArticleModel { @@ -68,23 +63,20 @@ class ArticleModel { ``` Use the [`async` and `await`][] keywords to handle asynchronous operations. -The `async` keyword marks a method as asynchronous, and -`await` waits for expressions that return a [`Future`][]. +The `async` keyword marks a method as asynchronous, and `await` marks +expressions that return a [`Future`][]. -The `Uri.https` constructor safely builds URLs by -handling encoding and formatting. -This approach is more reliable than string concatenation, -especially when dealing with special characters or query parameters. - -[`async` and `await`]: {{site.dart-site}}/language/async -[`Future`]: {{site.api}}/flutter/dart-async/Future-class.html +The `Uri.https()` constructor safely builds URLs by handling encoding +and formatting. This approach is more reliable than string +concatenation, especially when dealing with special characters or +query parameters. ## Handle network errors -Always handle errors when making HTTP requests. -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. +Always handle errors when making HTTP requests. 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 { @@ -106,9 +98,8 @@ class ArticleModel { ## Parse JSON from Wikipedia -The [Wikipedia API][] returns [JSON][] data that -you decode into a `Summary` class -Complete the `getRandomArticleSummary` method: +The [Wikipedia API][] returns [JSON][] data that you decode into +a `Summary` class. Complete the `getRandomArticleSummary` method: ```dart class ArticleModel { @@ -128,10 +119,13 @@ class ArticleModel { } ``` -The `dartpedia` package provides the `Summary` class. -If you're unfamiliar with JSON parsing, -check out the [Getting started with Dart][] tutorial. +The `dartpedia` package provides the `Summary` class. If you're +unfamiliar with JSON parsing, see the [Dart Getting Started +tutorial][]. -[Wikipedia API]: https://en.wikipedia.org/api/rest_v1/ +[architectural pattern]: /architecture/guide [JSON]: {{site.dart-site}}/tutorial/json -[Getting started with Dart]: {{site.dart-site}}/tutorial +[`async` and `await`]: https://dart.dev/language/async +[`Future`]: https://api.dart.dev/stable/dart-async/Future-class.html +[Wikipedia API]: https://en.wikipedia.org/api/rest_v1/ +[Dart Getting Started tutorial]: {{site.dart-site}}/tutorial/json diff --git a/src/content/tutorial/state/change-notifier.md b/src/content/tutorial/state/3-change-notifier.md similarity index 54% rename from src/content/tutorial/state/change-notifier.md rename to src/content/tutorial/state/3-change-notifier.md index 9f7631c5cc0..7e9673f338d 100644 --- a/src/content/tutorial/state/change-notifier.md +++ b/src/content/tutorial/state/3-change-notifier.md @@ -1,30 +1,28 @@ --- title: State management in Flutter description: Instructions on how to manage state with ChangeNotifiers. -layout: tutorial +permalink: /tutorial/change-notifier/ sitemap: false --- -When developers talk about state-management in Flutter, -they're essentially referring to the pattern by which your app -updates the data it needs to render correctly and then -tells Flutter to re-render the UI with that new data. +When developers talk about state-management in Flutter, they're +essentially referring to the pattern by which your app updates the +data it needs to render correctly, and then tells Flutter to re-render +the UI with that new data. -In MVVM, this responsibility falls to the ViewModel layer, -which sits between and connects your UI to your Model layer. -In Flutter, ViewModels use Flutter's `ChangeNotifier` class to +In MVVM, this responsibility falls to the ViewModel layer, which sits +between and connects your UI to your Model layer. In Flutter, +ViewModels use Flutter's `ChangeNotifier` class to notify the UI when data changes. -To use [`ChangeNotifier`][], extend it in your state management class to -gain access to the `notifyListeners()` method, -which triggers UI rebuilds when called. +To use [ChangeNotifier][], extend it in your state management class to +gain access to the `notifyListeners()` method, which triggers UI +rebuilds when called. -[`ChangeNotifier`]: {{site.api}}/flutter/foundation/ChangeNotifier-class.html +## Create the basic ViewModel structure -## Create the basic view model structure - -Create the `ArticleViewModel` class with its -basic structure and state properties: +Create the `ArticleViewModel` class with its basic structure and state +properties: ```dart class ArticleViewModel extends ChangeNotifier { @@ -37,7 +35,7 @@ class ArticleViewModel extends ChangeNotifier { } ``` -The `ArticleViewModel` holds three pieces of state: +The ViewModel holds three pieces of state: - `summary`: The current Wikipedia article data. - `errorMessage`: Any error that occurred during data fetching. @@ -46,7 +44,7 @@ The `ArticleViewModel` holds three pieces of state: ## Add constructor initialization Update the constructor to automatically fetch content when the -`ArticleViewModel` is created: +ViewModel is created: ```dart class ArticleViewModel extends ChangeNotifier { @@ -59,18 +57,17 @@ class ArticleViewModel extends ChangeNotifier { getRandomArticleSummary(); } - // Methods will be added next. + // Method will be added next } ``` -This constructor initialization provides immediate content when -a `ArticleViewModel` object is created. -Because constructors can't be asynchronous, +This constructor initialization provides immediate content when the +ViewModel is created. Because constructors can't be asynchronous, it delegates initial content fetching to a separate method. -## Set up the `getRandomArticleSummary` method +## Create the getRandomArticleSummary method -Add the `getRandomArticleSummary` that fetches data and manages state updates: +Add the method that fetches data and manages state updates: ```dart class ArticleViewModel extends ChangeNotifier { @@ -94,20 +91,19 @@ class ArticleViewModel extends ChangeNotifier { } } ``` +The ViewModel updates the `loading` property and calls +`notifyListeners()` to inform the UI. When the operation completes, it +toggles the property back. When you build the UI, you'll use this +`loading` property to show a loading indicator while fetching a new +article. -The ViewModel updates the `loading` 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 -show a loading indicator while fetching a new article. - -## Retrieve an article from the `ArticleModel` +## Retrieve an article from the ArticleModel -Complete the `getRandomArticleSummary` 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. +Complete the `getRandomArticleSummary` 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 consistent state. ```dart class ArticleViewModel extends ChangeNotifier { @@ -125,7 +121,7 @@ class ArticleViewModel extends ChangeNotifier { notifyListeners(); try { summary = await model.getRandomArticleSummary(); - errorMessage = null; // Clear any previous errors. + errorMessage = null; // Clear any previous errors } on HttpException catch (error) { errorMessage = error.message; summary = null; @@ -136,14 +132,12 @@ class ArticleViewModel extends ChangeNotifier { } ``` -[try-catch block]: {{site.dart-site}}/language/error-handling#catch - ## Test the ViewModel Before building the full UI, test that your HTTP requests work by -printing results to the console. -First, update the `getRandomArticleSummary` method to -print the results: +printing results to the console. First, update your +`ArticleViewModel`'s `getRandomArticleSummary` method to print the +results: ```dart Future getRandomArticleSummary() async { @@ -152,7 +146,7 @@ Future getRandomArticleSummary() async { try { summary = await model.getRandomArticleSummary(); print('Article loaded: ${summary!.titles.normalized}'); // Temporary - errorMessage = null; // Clear any previous errors. + errorMessage = null; } on HttpException catch (error) { print('Error loading article: ${error.message}'); // Temporary errorMessage = error.message; @@ -163,8 +157,8 @@ Future getRandomArticleSummary() async { } ``` -Then, update the `MainApp` widget to create the `ArticleViewModel`, -which calls the `getRandomArticleSummary` method on creation: +Then, update the `MainApp` widget to create the ViewModel, which calls +the `getRandomArticleSummary` method on creation: ```dart class MainApp extends StatelessWidget { @@ -172,7 +166,7 @@ class MainApp extends StatelessWidget { @override Widget build(BuildContext context) { - // Instantiate your `ArticleViewModel` to test its HTTP requests. + // Create ViewModel to test HTTP requests final viewModel = ArticleViewModel(ArticleModel()); return MaterialApp( @@ -189,6 +183,9 @@ class MainApp extends StatelessWidget { } ``` -Hot reload your app and check your console output. -You should see either an article title or an error message, -which confirms that your Model and ViewModel are wired up correctly. +Hot reload your app and check your console output. You should see +either an article title or an error message, which confirms that your +Model and ViewModel are wired up correctly. + +[ChangeNotifier]: {{site.api}}/flutter/foundation/ChangeNotifier-class.html +[try-catch block]: https://dart.dev/language/error-handling diff --git a/src/content/tutorial/state/listenable-builder.md b/src/content/tutorial/state/4-listenable-builder.md similarity index 64% rename from src/content/tutorial/state/listenable-builder.md rename to src/content/tutorial/state/4-listenable-builder.md index 0f9d66d49d3..eda57e8d1f5 100644 --- a/src/content/tutorial/state/listenable-builder.md +++ b/src/content/tutorial/state/4-listenable-builder.md @@ -1,26 +1,21 @@ --- title: Rebuild UI when state changes description: Instructions on how to manage state with ChangeNotifiers. -layout: tutorial +permalink: /tutorial/listenables/ sitemap: false --- -The view layer is your UI, and in Flutter, -that refers to your app's widgets. -As it pertains to this tutorial, the important part is -wiring up your UI to respond to data changes from the ViewModel. +The view layer is your UI, and in Flutter, that refers to your app's +widgets. As it pertains to this tutorial, the important part is wiring +up your UI to respond to data changes from the ViewModel. [`ListenableBuilder`][] is a widget that can "listen" to a -[`ChangeNotifier`][], and automatically rebuilds when it's -provided `ChangeNotifier` calls `notifyListeners()`. +`ChangeNotifier`, and automatically rebuilds when it's provided +`ChangeNotifier` calls `notifyListeners()`. -[`ListenableBuilder`]: {{site.api}}/flutter/widgets/ListenableBuilder-class.html -[`ChangeNotifier`]: {{site.api}}/flutter/foundation/ChangeNotifier-class.html +## Create the ArticleView widget -## Create the article view widget - -Create the `ArticleView` widget that -manages the overall page layout and state handling. -Start with the basic class structure and widgets: +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 { @@ -40,9 +35,9 @@ class ArticleView extends StatelessWidget { } ``` -## Create the article view model +## Create the ViewModel -Create the `ArticleViewModel` in this widget: +Create the ViewModel in this widget. ```dart class ArticleView extends StatelessWidget { @@ -64,11 +59,11 @@ class ArticleView extends StatelessWidget { } ``` -## Listen for state changes +## Add ListenableBuilder -Wrap your UI in a [`ListenableBuilder`][] to listen for state changes, -and pass it a `ChangeNotifier` object. -In this case, the `ArticleViewModel` extends `ChangeNotifier`. +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 { @@ -93,27 +88,22 @@ class ArticleView extends StatelessWidget { } ``` -`ListenableBuilder` uses the *builder* pattern, -which requires a callback rather than a `child` widget to -build the widget tree below it. -These widgets are flexible because you can -perform operations within the callback, -building different widgets based on the state. - -[`ListenableBuilder`]: {{site.api}}/flutter/widgets/ListenableBuilder-class.html +`ListenableBuilder` uses the *builder* pattern, which requires a +callback rather than a `child` widget to build the widget tree below +it. These widgets are flexible because you can perform operations +within the callback. -## Handle possible view model states -Recall the `ArticleViewModel`, which has three properties that -the UI is interested in: +## Handle all states with switch expression -- `Summary? summary` -- `bool loading` -- `String? errorMessage` +Recall the `ArticleViewModel`, which has three properties that the UI +is interested in: +* `Summary? summary` +* `bool loading` +* `String? errorMessage` -Depending on the combined state of these properties, -the UI can display different widgets. -Use Dart's support for [switch expressions][] +The UI needs to display different widgets based on the combination of +states of all three of those properties. Use Dart's switch expressions to handle all possible combinations in a clean, readable way: ```dart @@ -142,9 +132,9 @@ class ArticleView extends StatelessWidget { (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, + // summary must be non-null in this swich case + (false, Summary _, null) => ArticlePage( + summary: viewModel.summary!, onPressed: viewModel.getRandomArticleSummary, ), }; @@ -155,23 +145,23 @@ class ArticleView extends StatelessWidget { } ``` -This is an excellent example of how a -declarative, reactive framework like Flutter and -a pattern like MVVM work together: -The UI is rendered based on the state and updates when -a state changes demands it, but it -doesn't manage any state or the process of updating itself. -The business logic and rendering are completely separate from each other. +This is an excellent example of how a declarative, reactive framework +like Flutter and a pattern like MVVM work together: The UI is rendered +based on the state, and updates when a state changes demands it, but +it doesn't manage any state or the process of updating itself. The +business logic and rendering are completely separate from each other. -[switch expressions]: {{site.dart-site}}/language/branches#switch-expressions ## Complete the UI The only thing remaining is to use the properties and methods provided -by the view model to build the UI. +by the ViewModel. -Now create a `ArticlePage` widget that displays the actual article content. -This reusable widget takes summary data and a callback function: +Now create the `ArticlePage` widget that displays the actual article +content. This reusable widget takes summary +data and a callback function. + +Create a simple widget that accepts the required parameters: ```dart class ArticlePage extends StatelessWidget { @@ -191,7 +181,7 @@ class ArticlePage extends StatelessWidget { } ``` -## Add a scrollable layout +## Add scrollable layout Replace the placeholder with a scrollable column layout: @@ -221,7 +211,7 @@ class ArticlePage extends StatelessWidget { ## Add article content and button -Complete the layout with an article widget and navigation button: +Complete the layout with the article widget and navigation button: ```dart class ArticlePage extends StatelessWidget { @@ -255,14 +245,14 @@ class ArticlePage extends StatelessWidget { } ``` -## Create the `ArticleWidget` +## Create the ArticleWidget The `ArticleWidget` handles the display of the actual article content with proper styling and conditional rendering. -### Set up the basic article structure +## Create the basic ArticleWidget structure -Start with the widget that accepts a `summary` parameter: +Start with the widget that accepts a summary parameter: ```dart class ArticleWidget extends StatelessWidget { @@ -277,7 +267,7 @@ class ArticleWidget extends StatelessWidget { } ``` -### Add padding and column layout +## Add padding and column layout Wrap the content in proper padding and layout: @@ -302,7 +292,7 @@ class ArticleWidget extends StatelessWidget { } ``` -### Add conditional image display +## Add conditional image display Add the article image that only shows when available: @@ -331,10 +321,10 @@ class ArticleWidget extends StatelessWidget { } ``` -### Complete with styled text content +## Complete with styled text content -Replace the placeholder text with a -properly styled title, description, and extract: +Replace the placeholder with properly styled title, description, and +extract: ```dart class ArticleWidget extends StatelessWidget { @@ -374,21 +364,21 @@ class ArticleWidget extends StatelessWidget { } ``` -This widget demonstrates a few important UI concepts: +This widget demonstrates these important UI concepts: -- **Conditional rendering**: - 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. +- **Conditional rendering**: 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. -## Update your app to include the article view +## Update MainApp to use ArticleView -Connect everything together by updating your `MainApp` to -include your completed `ArticleView`. +Connect everything together by updating your `MainApp` to use the +complete `ArticleView`. Replace your existing `MainApp` with this updated version: @@ -412,12 +402,16 @@ experience with proper state management. Hot reload your app one final time. You should now see: -1. A loading spinner while the initial article loads. -1. The article's title, description, and summary extract. -1. An image (if the article has one). -1. A button to load another random article. +1. A loading spinner while the initial article loads +2. The article content with title, description, and full text +3. An image (if the article has one) +4. A button to load another random article + +Click the "Next random article" button to see the reactive UI in +action. The app shows a loading state, fetches new data, and updates +the display automatically. -To see the reactive UI in action, -click the **Next random article** button. -The app shows a loading state, fetches new data, and -updates the display automatically. +[`ListenableBuilder`]: https://api.flutter.dev/flutter/widgets/ListenableBuilder-class.html +[widget]: https://docs.flutter.dev/ui/widgets-intro +[`ListView`]: https://api.flutter.dev/flutter/widgets/ListView-class.html +[try-catch block]: https://dart.dev/language/error-handling diff --git a/src/content/tutorial/ui-102/intro.md b/src/content/tutorial/ui-102/1-intro.md similarity index 81% rename from src/content/tutorial/ui-102/intro.md rename to src/content/tutorial/ui-102/1-intro.md index 3a3d02cc8ba..6a943297a38 100644 --- a/src/content/tutorial/ui-102/intro.md +++ b/src/content/tutorial/ui-102/1-intro.md @@ -1,15 +1,14 @@ --- title: Advanced UI features -description: >- - A gentle introduction into advanced UI features: - adaptive layouts, slivers, scrolling, navigation. -layout: tutorial +description: | + A gentle introduction into advanced UI features: adaptive layouts, slivers, scrolling, navigation. +permalink: /tutorial/set-up-ui-102/ sitemap: false --- -In this third installment of the Flutter tutorial series, -you'll use Flutter's Cupertino library to build a -partial clone of the iOS Contacts app. +In this third installment of the Flutter tutorial series, you'll use +Flutter's Cupertino library to build a partial clone of the iOS +Contacts app. A screenshot of the completed Rolodex contact
@@ -36,51 +35,46 @@ and the Flutter project structure.
 
 ## Create a new Flutter project
 
-To build a Flutter app, you first need a Flutter project.
-You can create a new app with the [Flutter CLI tool][],
-which is installed as part of the Flutter SDK.
+To build a Flutter app, you first need a Flutter project. You can
+create a new app with the [Flutter CLI tool][], which is installed as part of the
+Flutter SDK.
 
-Open your preferred terminal and run
-the following command to create a new Flutter project:
+Open your terminal or command prompt, and run the following command to
+create a new Flutter project:
 
-```console
+```shell
 $ flutter create rolodex --empty
 ```
 
-This command creates a new Flutter project that
-uses the minimal allContacts = { christopherDaniel, jessicaEdwards, }; + ``` -This sample data includes contacts with and without middle names and suffixes. -This gives you a variety of data to work with as you build the UI. +This sample data includes contacts with and without middle names and +suffixes. This gives you a variety of data to work with as you build the UI. ### `ContactGroup` data Now, create the contact groups that organize your contacts into lists. -Create a new `lib/data/contact_group.dart` file and -add the `ContactGroup` class: +Create a new file, `lib/data/contact_group.dart`, and add the `ContactGroup` class: -```dart title="lib/data/contact_group.dart" +```dart +// lib/data/contact_group.dart import 'dart:collection'; import 'package:flutter/cupertino.dart'; import 'contact.dart'; @@ -389,18 +385,19 @@ class ContactGroup { } ``` -A `ContactGroup` represents a collection of contacts, -such as "All Contacts" or "Favorites". +A `ContactGroup` represents a collection of contacts, like "All Contacts" +or "Favorites". Add the following helper code and sample data to the same file: -```dart title="lib/data/contact_group.dart" +```dart +// lib/data/contact_group.dart + // ... ContactGroup class from above typedef AlphabetizedContactMap = SplayTreeMap>; -/// Sorts a list of contacts alphabetically by -/// last name, then first name, then middle name. +/// Sorts a list of contacts alphabetically by last name, then first name, then middle name. /// If names are identical, sorts by contact ID to ensure consistent ordering. void _sortContacts(List contacts) { contacts.sort((Contact a, Contact b) { @@ -446,12 +443,14 @@ List generateSeedData() { } ``` -This code creates three sample groups and a function to -generate the initial data for the app. +This code creates three sample groups and a function to generate the initial +data for the app. Finally, add a class that manages state changes: -```dart title="lib/data/contact_group.dart" +```dart +// lib/data/contact_group.dart + // ... class ContactGroupsModel { @@ -473,18 +472,17 @@ class ContactGroupsModel { } ``` -If you aren't familiar with `ValueNotifier`, -you should complete the [previous tutorial covering state][] before continuing, +If you aren't familiar with `ValueNotifier`, you should +[complete the previous tutorial][] before continuing, which covers state management. -[previous tutorial covering state]: /tutorial/state/set-up-project - ## Connect the data to your app -Update your `main.dart` to include the global state and -import the new data file: +Update your `main.dart` to include the global state and import the new +data file: -```dart title="lib/main.dart" +```dart +// lib/main.dart import 'package:flutter/cupertino.dart'; import 'package:rolodex/data/contact_group.dart'; @@ -513,7 +511,9 @@ class RolodexApp extends StatelessWidget { } ``` -With all the extraneous code out of the way, in the next lesson, +With all of the extraneous code out of the way, in the next lesson, you'll start building the app in earnest. -[`cupertino_icons` package]: {{site.pub-pkg}}/cupertino_icons +[Flutter CLI tool]: /reference/flutter-cli +[complete the previous tutorial]: /tutorial/set-up-state-app +[`cupertino_icons` package]: https://pub.dev/packages/cupertino_icons diff --git a/src/content/tutorial/ui-102/adaptive-layout.md b/src/content/tutorial/ui-102/2-adaptive-layout.md similarity index 72% rename from src/content/tutorial/ui-102/adaptive-layout.md rename to src/content/tutorial/ui-102/2-adaptive-layout.md index 30c0458810d..5388a0435d5 100644 --- a/src/content/tutorial/ui-102/adaptive-layout.md +++ b/src/content/tutorial/ui-102/2-adaptive-layout.md @@ -1,28 +1,27 @@ --- title: LayoutBuilder and adaptive layouts -description: Learn how to use the LayoutBuilder widget. -layout: tutorial +description: Learn how to use the LayoutBuilder widget +permalink: /tutorial/adaptive-layouts/ sitemap: false --- -Modern apps need to work well on screens of all sizes. -On this page, you'll learn how to create layouts that -adapt to different screen widths. -This app shows a sidebar on large screens and -a navigation-based UI on small screens. -Specifically, this app handles two screen sizes: +Modern apps need to work well on screens of all sizes. On this page, +you'll learn how to create layouts that adapt to different screen +widths. This app shows a sidebar on large screens and a +navigation-based UI on small screens. Specifically, this app handles +two screen sizes: -- **Large screens (tablets, desktop)**: - Shows contact groups and contact details side-by-side. -- **Small screens (phones)**: - Uses navigation to move between contact groups and details. +* **Large screens (tablets, desktop)**: Shows contact groups and + contact details side-by-side. +* **Small screens (phones)**: Uses navigation to move between contact + groups and details. ## Create the contact groups page First, create the basic structure of the `ContactGroupsPage` widget -for your contact groups screen. -Create `lib/screens/contact_groups.dart` and add -the following basic structure: +for your contact groups screen. Create +`lib/screens/contact_groups.dart` and add the following basic +structure: ```dart import 'package:flutter/cupertino.dart'; @@ -44,8 +43,8 @@ class ContactGroupsPage extends StatelessWidget { ## Create the contacts page -Similarly, create `lib/screens/contacts.dart` to eventually -display individual contacts: +Similarly, create `lib/screens/contacts.dart` to eventually display +individual contacts: ```dart import 'package:flutter/cupertino.dart'; @@ -135,20 +134,20 @@ class _AdaptiveLayoutState extends State { } ``` -The `LayoutBuilder` widget provides information about -the parent's size constraints. -In the `builder` callback, you receive a`BoxConstraints` object that -tells you the maximum available width and height. +The `LayoutBuilder` widget provides information about the parent's +size constraints. In the `builder` callback, you receive a +`BoxConstraints` object that tells you the maximum available width and +height. -By checking if `constraints.maxWidth > largeScreenMinWidth`, -you can decide which layout to show. -The 600-pixel threshold is a common breakpoint that -separates phone-sized screens from tablet-sized screens. +By checking if `constraints.maxWidth > largeScreenMinWidth`, you can +decide which layout to show. The 600-pixel threshold is a common +breakpoint that separates phone-sized screens from tablet-sized +screens. ## Update the main app -Update `main.dart` to use the adaptive layout, -so you can see your changes: +Update `main.dart` to use the adaptive layout, so you can see +your changes. ```dart import 'package:flutter/cupertino.dart'; @@ -180,13 +179,13 @@ class RolodexApp extends StatelessWidget { } ``` -If you're running in Chrome, you can resize the browser window to -see layout changes. +If you're running in Chrome, you can resize the browser window to see +layout changes. ## Add list selection functionality -The large screen layout needs to track which contact group is selected. -Update the state object with the following code: +The large screen layout needs to track which contact group is +selected. Update the state object with the following code: ```dart import 'package:flutter/cupertino.dart'; @@ -202,6 +201,7 @@ class AdaptiveLayout extends StatefulWidget { State createState() => _AdaptiveLayoutState(); } + class _AdaptiveLayoutState extends State { // New int selectedListId = 0; @@ -231,13 +231,14 @@ class _AdaptiveLayoutState extends State { ``` The `selectedListId` variable tracks the currently selected contact group, -and `_onContactListSelected` updates this value when the user makes a choice. +and `_onContactListSelected` updates this value when the +user makes a selection. ## Build the large screen layout -Now, implement the side-by-side layout for large screens. -First, replace the temporary text with a widget that -contains the proper layout. +Now, implement the side-by-side layout for large screens. First, +replace the temporary text with a widget that contains the proper +layout. ```dart import 'package:flutter/cupertino.dart'; @@ -271,7 +272,7 @@ class _AdaptiveLayoutState extends State { if (isLargeScreen) { return _buildLargeScreenLayout(); // New } else { - // For small screens, use the original, navigation-style approach. + // For small screens, use the original, navigation-style approach return const ContactGroupsPage(); } }, @@ -285,9 +286,9 @@ class _AdaptiveLayoutState extends State { child: SafeArea( child: Row( children: [ - // Contact groups list: + // Contact groups list Text('Sidebar'), - // List detail view: + // List detail view Text('Details'), ], ), @@ -295,11 +296,11 @@ class _AdaptiveLayoutState extends State { ); } } + ``` -The large screen layout uses a `Row` to -place the sidebar and details side-by-side. -`SafeArea` ensures that the content doesn't overlap with +The large screen layout uses a `Row` to place the sidebar and details +side-by-side. `SafeArea` ensures that the content doesn't overlap with system UI elements like the status bar. Now, set the sizes of the two panels and add a visual divider: @@ -311,17 +312,17 @@ Widget _buildLargeScreenLayout() { child: SafeArea( child: Row( children: [ - // Contact groups list: + // Contact groups list SizedBox( width: 320, child: Text('Sidebar placeholder'), // Temporary ), - // Divider: + // Divider Container( width: 1, color: CupertinoColors.separator, ), - // List detail view: + // List detail view Expanded( child: Text('Details placeholder'), // Temporary ), @@ -333,23 +334,22 @@ Widget _buildLargeScreenLayout() { ``` This layout creates the following: - -- A fixed-width sidebar (320 pixels) for contact groups. -- A 1-pixel divider between the panels. -- A details panel that uses an `Expanded` widget to take the remaining space. +* A fixed-width sidebar (320 pixels) for contact groups. +* A 1-pixel divider between the panels. +* A details panel that uses an `Expanded` widget to take the remaining + space. ## Test the adaptive layout -Hot reload your app and test the responsive behavior. -If you're running in Chrome, you can resize the browser window to -see the layout change: +Hot reload your app and test the responsive behavior. If you're +running in Chrome, you can resize the browser window to see the layout +change: -- **Wide window (> 600px)**: - Shows placeholder text for the sidebar and details side-by-side. -- **Narrow window (< 600px)**: - Shows only the contact groups page. +* **Wide window (> 600px)**: Shows placeholder text for the sidebar + and details side-by-side. +* **Narrow window (< 600px)**: Shows only the contact groups page. Both the sidebar and main content area show placeholder text for now. -In the next lesson, you'll implement slivers to fill in -the contact list content. +In the next lesson, you'll implement slivers to fill in the contact +list content. diff --git a/src/content/tutorial/ui-102/slivers.md b/src/content/tutorial/ui-102/3-slivers.md similarity index 71% rename from src/content/tutorial/ui-102/slivers.md rename to src/content/tutorial/ui-102/3-slivers.md index 2b91717d16e..f0021fd90a5 100644 --- a/src/content/tutorial/ui-102/slivers.md +++ b/src/content/tutorial/ui-102/3-slivers.md @@ -1,55 +1,55 @@ --- title: Advanced scrolling and slivers description: Learn how to implement performant scrolling with slivers. -layout: tutorial +permalink: /tutorial/slivers/ sitemap: false --- -In this lesson, you'll learn about slivers, -which are special widgets that can take advantage of -Flutter's powerful and composable scrolling system. -Slivers enable you to create sophisticated scroll effects, -including collapsible headers, search integration, and custom scroll behaviors. -By the end of this section, you'll understand how to -use `CustomScrollView`, create navigation bars that collapse, -and organize content in scrollable sections. +In this lesson, you'll learn about slivers, which are special widgets +that can take advantage of Flutter's powerful and composable scrolling +system. Slivers enable you to create sophisticated scroll effects, +including collapsible headers, search integration, and custom scroll +behaviors. By the end of this section, you'll understand how to use +`CustomScrollView`, create navigation bars that collapse, and organize +content in scrollable sections. ## Slivers and widgets Slivers are scrollable areas that can be composed together in a -`CustomScrollView` or other scroll views. -Think of slivers as building blocks that each -contribute a portion of the overall scrollable content. +`CustomScrollView` or other scroll views. Think of slivers as +building blocks that each contribute a portion of the overall +scrollable content. -While slivers and widgets are both fundamental Flutter concepts, -they serve different purposes and aren't interchangeable. +While slivers and widgets are both fundamental Flutter concepts, they +serve different purposes and aren't interchangeable. -- **Widgets** are general UI building blocks that - can be used anywhere in your widget tree. +- **Widgets** are general UI building blocks that can be used anywhere + in your widget tree. - **Slivers** are specialized widgets designed specifically for scrollable layouts and have some constraints: -- Slivers can **only** be direct children of scroll views, such as +- Slivers can **only** be direct children of scroll views, like `CustomScrollView` and `NestedScrollView`. -- Some scroll views **only** accept slivers as children. - You can't pass regular widgets to `CustomScrollView.slivers`. -- To use regular widgets within a sliver context, - wrap them in `SliverToBoxAdapter` or `SliverFillRemaining`. +- Some scroll views **only** accept slivers as children. You can't + pass regular widgets to `CustomScrollView.slivers`. +- To use regular widgets within a sliver context, wrap them in + `SliverToBoxAdapter` or `SliverFillRemaining`. -This architectural separation allows Flutter to -optimize scrolling performance while it maintains clear boundaries between -different types of UI components. +This architectural separation allows Flutter to optimize scrolling +performance while it maintains clear boundaries between different +types of UI components. ## Add a basic sliver structure to contact groups -First, replace the placeholder content in your contact groups page. -To avoid duplicating code between the phone layout and the tablet sidebar, -you can create a private, reusable widget. +First, replace the placeholder content in your contact groups +page. To avoid duplicating code between the phone layout and the tablet +sidebar, you can create a private, reusable widget. -Update `lib/screens/contact_groups.dart` by -adding `_ContactGroupsView` to the bottom of the file. +Update `lib/screens/contact_groups.dart` by adding `_ContactGroupsView` to the bottom of the file. + +```dart +// lib/screens/contact_groups.dart -```dart title="lib/screens/contact_groups.dart" // New imports import 'package:rolodex/data/contact_group.dart'; import 'package:rolodex/main.dart'; @@ -99,27 +99,26 @@ class _ContactGroupsView extends StatelessWidget { } ``` -This private widget contains the shared UI for -displaying the list of contact groups. -On small screens, it will be used as a page, and on +This private widget contains the shared UI for displaying the list of +contact groups. On small screens, it will be used as a page, and on large screens it will be used to fill the left column. This widget introduces several slivers: +- `CupertinoSliverNavigationBar`: An opinionated navigation bar that + collapses as the page scrolls. +- `SliverList`: A scrollable list of items. +- `SliverFillRemaining`: A sliver that takes up the remaining space in + the scroll area, and who's child is a non-sliver widget. + -- `CupertinoSliverNavigationBar`: - An opinionated navigation bar that collapses as the page scrolls. -- `SliverList`: - A scrollable list of items. -- `SliverFillRemaining`: - A sliver that takes up the remaining space in - the scroll area, and whose child is a non-sliver widget. -It accepts a callback function, `onListSelected`, to handle taps, -which makes it adaptable for both navigation and sidebar selection. +It takes a callback function, `onListSelected`, to handle +taps, which makes it adaptable for both navigation and sidebar selection. -Now, update `ContactGroupsPage` to use your new `_ContactGroupsView` widget: +Now, update `ContactGroupsPage` to use this new private widget: -```dart title="lib/screens/contact_groups.dart" +```dart +// lib/screens/contact_groups.dart class ContactGroupsPage extends StatelessWidget { const ContactGroupsPage({super.key}); @@ -137,17 +136,17 @@ class ContactGroupsPage extends StatelessWidget { // ... _ContactGroupsView from above ``` -This structure keeps the `ContactGroupsPage` clean and -focused on its primary responsibility: navigation, -which you'll learn about in the next section of this tutorial. +This structure keeps the `ContactGroupsPage` clean and focused on its +primary responsibility: navigation, which you'll learn about in the +next section of this tutorial. ## Enhance the list with icons and visual elements -Now, add icons and contact counts to make the list more informative. -Add this `_buildTrailing` helper method to your `_ContactGroupsView` class: +Now, add icons and contact counts to make the list more +informative. Add this helper method to your `_ContactGroupsView` class: -```dart title="lib/screens/contact_groups.dart" -// Inside _ContactGroupsView: +```dart +// In lib/screens/contact_groups.dart, inside _ContactGroupsView Widget _buildTrailing(List contacts, BuildContext context) { final TextStyle style = CupertinoTheme.of( @@ -168,14 +167,14 @@ Widget _buildTrailing(List contacts, BuildContext context) { } ``` -This helper creates the trailing content for each list item. -It shows the contact count and a forward arrow. +This helper creates the trailing content for each list item. It shows +the contact count and a forward arrow. -Now, update the `CupertinoListSection` in `_ContactGroupsView` to -use icons and the trailing helper. Update the code within the -`ListenableBuilder.builder` callback in the `build` method: +Now, update the `CupertinoListSection` in `_ContactGroupsView` to use +icons and the trailing helper. Update the code within the +`ListenableBuilder.builder` callback in the `build` method. -```dart title="lib/screens/contact_groups.dart" +```dart import 'package:flutter/cupertino.dart'; import 'package:rolodex/data/contact.dart'; import 'package:rolodex/data/contact_group.dart'; @@ -219,7 +218,7 @@ class _ContactGroupsView extends StatelessWidget { valueListenable: contactGroupsModel.listsNotifier, builder: (context, contactLists, child) { - // New from here: + // New from here const groupIcon = Icon( CupertinoIcons.group, weight: 900, @@ -255,23 +254,27 @@ class _ContactGroupsView extends StatelessWidget { Widget _buildTrailing(List contacts, BuildContext context) { //... } + } ``` -The updated code now shows icons that differentiate between the -main "All iPhone" group and user-created groups, along with -contact counts and navigation indicators. +The updated code now shows icons that differentiate between the main +"All iPhone" group and user-created groups, along with contact counts +and navigation indicators. ## Create advanced scrolling for contacts -Now, work on the contacts page. Just like before, -you'll create a private, reusable view to avoid code duplication. +Now, work on the contacts page. Just like before, you'll create a +private, reusable view to avoid code duplication. + +In the next lesson, you'll implement navigation for small screens. To +see your progress on the contacts list page in the meantime, update +`AdaptiveLayout` to display the contacts list page. + -In the next lesson, you'll implement navigation for small screens. -To see your progress on the contacts list page in the meantime, -update `AdaptiveLayout` to display the contacts list page: +```dart +// lib/screens/adaptive_layout.dart -```dart title="lib/screens/adaptive_layout.dart" class _AdaptiveLayoutState extends State { int selectedListId = 0; @@ -298,10 +301,12 @@ class _AdaptiveLayoutState extends State { } ``` -Update `lib/screens/contacts.dart` by adding `_ContactListView` to -the bottom of the file: -```dart title="lib/screens/contacts.dart" +Update `lib/screens/contacts.dart` by adding `_ContactListView` to the +bottom of the file: + +```dart +// lib/screens/contacts.dart class _ContactListView extends StatelessWidget { const _ContactListView({ required this.listId, @@ -343,7 +348,8 @@ class _ContactListView extends StatelessWidget { Now, update `ContactListsPage` to use this view: -```dart title="lib/screens/contacts.dart" +```dart +// lib/screens/contacts.dart import 'package:flutter/cupertino.dart'; import 'package:rolodex/data/contact_group.dart'; import 'package:rolodex/main.dart'; @@ -367,10 +373,12 @@ data in a reusable component. ## Add search integration with slivers -Now, enhance the contacts page with integrated search functionality UI. -Update the `CustomScrollView` in `_ContactListView` to use the -`CupertinoSliverNavigationBar.search` constructor instead of the -default `CupertinoSliverNavigationBar` constructor: +The `CupertinoSliverNavigationBar.search` constructor provides +integrated search functionality. As you scroll down, the search field +smoothly transitions into the collapsed navigation bar. + +Now, enhance the contacts page with integrated search +functionality UI. Update the `CustomScrollView` in `_ContactListView`: ```dart class _ContactListView extends StatelessWidget { @@ -418,17 +426,19 @@ class _ContactListView extends StatelessWidget { ``` The `CupertinoSliverNavigationBar.search` constructor provides -integrated search functionality. As you scroll down, -the search field smoothly transitions into the collapsed navigation bar. +integrated search functionality. As you scroll down, the search field +smoothly transitions into the collapsed navigation bar. ## Create alphabetized contact sections -Real-world contact apps organize contacts alphabetically. -To do this, create sections for each letter. -Add the following widget to the bottom of your `contacts.dart` file. -This widget doesn't contain any slivers. +Real-world contact apps organize contacts alphabetically. To do this, +create sections for each letter. Add the following widget to the +bottom of your `contacts.dart` file. This widget doesn't contain any +slivers. + +```dart +// lib/screens/contacts.dart -```dart title="lib/screens/contacts.dart" // ... class ContactListSection extends StatelessWidget { @@ -479,16 +489,16 @@ class ContactListSection extends StatelessWidget { } ``` -This widget creates the familiar alphabetized sections that -you see in the iOS Contacts app. +This widget creates the familiar alphabetized sections that you see in iOS +Contacts. ## Use `SliverList` for the alphabetized sections -Now, replace the placeholder content in `_ContactListView` with -the alphabetized sections: +Now, replace the placeholder content in `_ContactListView` with the +alphabetized sections: -```dart title="lib/screens/contacts.dart" -// Inside _ContactListView's builder: +```dart +// In lib/screens/contacts.dart, inside _ContactListView's builder final AlphabetizedContactMap contacts = contactList.alphabetizedContacts; @@ -517,10 +527,10 @@ return CustomScrollView( ); ``` -`SliverList.list` allows you to provide a list of widgets that -become part of the scrollable content. This is the simplest way to -add a list of normal widgets to a scrollable sliver area. +`SliverList.list` allows you to provide a list of widgets that become +part of the scrollable content. This is the simplest way to add a list +of normal widgets to scrollable sliver area. In the next lesson, you'll learn about stack-based navigation and -update the UI on small screens to navigate between -the contacts list view and the contacts view. +update the UI on small screens to navigate between the contacts list +view and the contacts view. diff --git a/src/content/tutorial/ui-102/navigation.md b/src/content/tutorial/ui-102/4-navigation.md similarity index 63% rename from src/content/tutorial/ui-102/navigation.md rename to src/content/tutorial/ui-102/4-navigation.md index 7881e234005..767ac97196d 100644 --- a/src/content/tutorial/ui-102/navigation.md +++ b/src/content/tutorial/ui-102/4-navigation.md @@ -1,20 +1,21 @@ --- title: Stack-based navigation -description: Learn how to navigate from one page to another in a Flutter app. -layout: tutorial +description: Learn how to navigate from one page to another in a Flutter app +permalink: /tutorial/stack-based-navigation/ sitemap: false --- -Now that you understand slivers and scrolling, -you can implement navigation between screens. -In this lesson, -you'll update the small-screen view such that when a contact group is tapped, -it navigates to the contact list for that group. +Now that you understand slivers and scrolling, you can implement +navigation between screens. In this lesson, you'll update the +small-screen view such that when a contact group is tapped, it +navigates to the contact list for that group. First, revert changes in the adaptive layout widget so that it -displays the `ContactGroupsPage` by default on small screens. +displays the ContactGroupsPage by default on small screens. + +```dart +// lib/screens/adaptive_layout.dart -```dart title="lib/screens/adaptive_layout.dart" class _AdaptiveLayoutState extends State { int selectedListId = 0; @@ -44,14 +45,16 @@ class _AdaptiveLayoutState extends State { ## Add navigation to contact groups The `ContactGroupsPage` already uses a `_ContactGroupsView` -and provides it with a callback. -That callback needs to be updated to navigate when a group is tapped, -rather than printing the group to the console. +and provides it with a callback. That callback needs to be updated to +navigate when a group is tapped, rather than printing the group to the +console. Ensure that the `onListSelected` callback in `lib/screens/contact_groups.dart` is implemented as follows: -```dart title="lib/screens/contact_groups.dart" +```dart +// lib/screens/contact_groups.dart + class ContactGroupsPage extends StatelessWidget { const ContactGroupsPage({super.key}); @@ -71,18 +74,15 @@ class ContactGroupsPage extends StatelessWidget { This small code block contains the most important new information on this page. -`Navigator.of(context)` retrieves the -nearest `Navigator` widget from the widget tree. -The `push` method adds a new route to the navigator's stack, and -displays the widget returned from the `builder` property. +`Navigator.of(context)` retrieves the nearest `Navigator` widget from +the widget tree. The `push` method adds a new route to the +navigator's stack, and displays the widget returned from the `builder` property. This is the most basic implementation of using stack-based navigation, -where new screens are pushed on top of the current screen. -To navigate back to the previous screen, you'd use the `Navigator.pop` method. - -`CupertinoPageRoute` creates iOS-style page transitions with -the following features: +where new screens are pushed on top of the current screen. To navigate +back to the previous screen, you'd use the `Navigator.pop` method. +`CupertinoPageRoute` creates iOS-style page transitions with the following features: - A slide-in animation from the right. - Automatic back button support. - Proper title handling. @@ -91,12 +91,13 @@ the following features: ## Create the sidebar component for large screens For large screens, you need a sidebar that doesn't navigate but -instead updates the main content area. -Thanks to the refactoring in the previous step, -creating this component is more straightforward. -Add this widget to the bottom of `lib/screens/contact_groups.dart`: +instead updates the main content area. Thanks to the refactoring in +the previous step, creating this component is simple. Add this widget +to the bottom of `lib/screens/contact_groups.dart`: + +```dart +// lib/screens/contact_groups.dart -```dart title="lib/screens/contact_groups.dart" // ... /// A sidebar component for selecting contact groups, designed for large screens. @@ -120,20 +121,21 @@ class ContactGroupsSidebar extends StatelessWidget { } ``` -This sidebar component reuses the `_ContactGroupsView` and -provides a different callback. Instead of navigating, -it calls `onListSelected` with the ID of the tapped list. -It also passes the `selectedListId` to `_ContactGroupsView` so that -the selected item can be highlighted. +This sidebar component reuses the `_ContactGroupsView` and provides a +different callback. Instead of navigating, it calls `onListSelected` +with the ID of the tapped list. It also passes the `selectedListId` to +`_ContactGroupsView` so that the selected item can be highlighted. ## Create the detail view for large screens -For the large screen layout, you need a detail view that -doesn't show navigation controls. Just like the sidebar, -this can be recreated by reusing the `_ContactListView`. -Add this widget to the bottom of your `contacts.dart` file: +For the large screen layout, you need a detail view that doesn't show +navigation controls. Just like the sidebar, this is easy to create by +reusing the `_ContactListView`. Add this widget to the bottom of your +`contacts.dart` file: + +```dart +// lib/screens/contacts.dart -```dart title="lib/screens/contacts.dart" // ... /// A detail view component for showing contacts in a specific list. @@ -153,16 +155,17 @@ class ContactListDetail extends StatelessWidget { ``` The detail view reuses `_ContactListView` and sets -the `automaticallyImplyLeading` parameter to `false` to -hide the back button, as navigation is handled by the sidebar. +`automaticallyImplyLeading: false` to hide the back button, as +navigation is handled by the sidebar. ## Connect the sidebar to the adaptive layout -Now, connect the sidebar to your adaptive layout. -Update your `adaptive_layout.dart` file to import the necessary files and -update the large screen layout: +Now, connect the sidebar to your adaptive layout. Update your +`adaptive_layout.dart` to import the necessary files and update the +large screen layout: -```dart title="lib/screens/adaptive_layout.dart" +```dart +// lib/screens/adaptive_layout.dart import 'package:flutter/cupertino.dart'; import 'package:rolodex/screens/contact_groups.dart'; import 'package:rolodex/screens/contacts.dart'; @@ -170,7 +173,9 @@ import 'package:rolodex/screens/contacts.dart'; Then update the `_buildLargeScreenLayout` method: -```dart title="lib/screens/adaptive_layout.dart" +```dart +// lib/screens/adaptive_layout.dart + Widget _buildLargeScreenLayout() { return CupertinoPageScaffold( backgroundColor: CupertinoColors.extraLightBackgroundGray, @@ -206,17 +211,15 @@ controls the content of the detail area. Hot reload your app and test the navigation: **Small screens (< 600px width):** - - Tap contact groups to navigate to contact details. -- Use the back button or a swipe gesture to return. +- Use the back button or swipe gesture to return. - This is a classic stack-based navigation flow. **Large screens (> 600px width):** - - Click contact groups in the sidebar to update the detail view. - There is no navigation stack. The selection updates the content area. - This is a master-detail interface pattern. -The app automatically chooses the -appropriate navigation pattern based on screen size. -This provides an optimal experience on both phones and tablets. +The app automatically chooses the appropriate navigation pattern based +on screen size. This provides an optimal experience on both phones and +tablets. diff --git a/src/content/tutorial/ui/1-create-an-app.md b/src/content/tutorial/ui/1-create-an-app.md new file mode 100644 index 00000000000..911bcec610b --- /dev/null +++ b/src/content/tutorial/ui/1-create-an-app.md @@ -0,0 +1,121 @@ +--- +title: Create an app +description: Instructions on how to create a new Flutter app. +permalink: /tutorial/create-an-app/ +sitemap: false +--- + +{%- comment %} + +{%- endcomment %} + +In this first section of the Flutter tutorial, you’ll build the core UI of an +app called ‘Birdle’, a game similar to [Wordle, the popular New York Times +game][]. + +By the end of this tutorial, you’ll have learned the fundamentals of building +Flutter UIs, and your app will look like the following screenshot (and it’ll +even mostly work 😀). + +A screenshot that resembles the popular game Wordle. + +## Create a new Flutter project + +The first step to building Flutter apps is to create a new project. You create +new apps with the [Flutter CLI tool][], installed as part of the Flutter SDK. + +Open your terminal or command prompt and run the following command to create a +new Flutter project: + +```shell +$ flutter create birdle --empty +``` + +This creates a new Flutter project using the minimal “empty” template. + +## Examine the code + +In your IDE, open the file at `lib/main.dart`. Starting from the top, you’ll see +this code. + +```dart +import 'package:flutter/material.dart'; // imports Flutter + +void main() { + runApp(const MainApp()); +} +// ... +``` + +The `main` function is the entry point to any Dart program, and a Flutter app is +just a **Dart** program. The `runApp` method is part of the Flutter SDK, and it +takes a **widget** as an argument. (Most of this tutorial is about widgets, but +in the simplest terms a widget is a Dart object that describes a piece of UI.) +In this case, an instance of the `MainApp` widget is being passed in. + +Just below the `main` function, you’ll find the `MainApp` class declaration. + +```dart +class MainApp extends StatelessWidget { + const MainApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + home: Scaffold( + body: Center( + child: Text('Hello World!'), + ), + ), + ); + } +} + +``` + +`MainApp` is the **root widget**, as it’s the widget that’s passed into +`runApp`. Within this widget, there’s a `build` method, which returns another +widget called `MaterialApp`. Essentially, this is what a Flutter app is: a +composition of Widgets that make up a tree structure called the **widget tree.** +Your job as a Flutter developer is to compose widgets from the SDK into larger, +custom widgets that display a UI. + +At the moment, the widget tree is quite simple: + +A screenshot that resembles the popular game Wordle. + +## Run your app + +In your terminal at the root of your Flutter app, run: + +```shell +$ cd birdle +$ flutter run -d chrome +``` + +The app will build and launch in a new instance of Chrome. + +A screenshot that resembles the popular game Wordle. + +## Use hot reload + +**Stateful hot reload**, if you haven't heard of it, allows a running Flutter +app to re-render updated business logic or UI code in less than a second - all +without losing your place in the app. + +In your IDE, open the `main.dart` file and navigate to line ~15 and find this +code: + +```dart +child: Text('Hello World!'), +``` + +Change the text inside the string to anything you want. Then, hot-reload your +app by pressing `r` in your terminal where the app is running. The running app +should instantly show your updated text. + + +[Flutter CLI tool]: /reference/flutter-cli +[Wordle, the popular New York Times game]: https://www.nytimes.com/games/wordle/index.html +[read more about using pub packages]: {{site.dart-site}}/tools/pub/packages +[`flutter_gse`]: {{site.pub}}/packages/flutter_gse diff --git a/src/content/tutorial/ui/widget-fundamentals.md b/src/content/tutorial/ui/2-widget-fundamentals.md similarity index 57% rename from src/content/tutorial/ui/widget-fundamentals.md rename to src/content/tutorial/ui/2-widget-fundamentals.md index 13bd4f2c176..1209e60ba00 100644 --- a/src/content/tutorial/ui/widget-fundamentals.md +++ b/src/content/tutorial/ui/2-widget-fundamentals.md @@ -1,7 +1,7 @@ --- title: Create widgets description: Learn about stateless widgets and how to build your own. -layout: tutorial +permalink: /tutorial/stateless-widgets/ sitemap: false --- @@ -9,50 +9,36 @@ sitemap: false {%- endcomment %} -In this lesson, you'll create your own custom widget and -learn about some of the most common widgets included in the SDK. +In this lesson, you'll create your own custom widget, and learn about some of +the most common widgets included in the SDK. -Custom widgets allow you to reuse UI components across your app, -organize complex UI code into manageable pieces, and -create cleaner, more maintainable code. -By the end of this lesson, you'll have created your own custom `Tile` widget. +Custom widgets allow you to reuse UI components across your app, organize +complex UI code into manageable pieces, and create cleaner, more maintainable +code. By the end of this lesson, you’ll have created your own custom Tile +widget. ## Before you start -This app relies on a bit of game logic that isn't UI-related, -and thus is outside the scope of this tutorial. -Before you move on, you need to add this logic to your app. +This app relies on a bit of game logic that isn't UI-related, and thus is outside the scope of this tutorial. Before you move on, you need to add this logic to your app. -1. Download the file below and save it - as `lib/game.dart` in your project directory. -1. Import the file in your `lib/main.dart` file. +1. Download the file below and save it as `lib/game.dart` in your project directory. +2. Import the file in your `lib/main.dart` file. :::note Game logic note +You may notice the lists called `legalGuesses` and `legalWords` only contain a few words. The full lists combined have over 10,000 words, and were omitted for brevity. You don't need the full lists to continue the tutorial. When you're testing your app, make sure to use the few words from those lists. -You might notice the -`legalGuesses` and `legalWords` lists only contain a few words. -The full lists combined have over 10,000 words and were omitted for brevity. -You don't need the full lists to continue the tutorial. -When you're testing your app, make sure to use the words from those lists. - -Alternatively, you can find the full lists in -[this GitHub repository][full-words], as well as -instructions to import it into your project. - +Alternatively, you can find the full lists in [this github repository][], as well as instructions to import it into your project. ::: -[full-words]: https://github.com/ericwindmill/legal_wordle_words - ## Anatomy of a stateless widget -A `Widget` is a Dart class that extends one of the Flutter widget classes, -in this case [`StatelessWidget`][]. +A `Widget` is a Dart class that extends one of the Flutter widget classes, in this case [`StatelessWidget`][]. -Open your `main.dart` file and add this code below the `MainApp` class, -which defines a new widget called `Tile`. +Open your `main.dart` file and add this code below the `MainApp` class, which +defines a new widget called `Tile`. ```dart class Tile extends StatelessWidget { @@ -65,27 +51,19 @@ class Tile extends StatelessWidget { } ``` -[`StatelessWidget`]: {{site.api}}/flutter/widgets/StatelessWidget-class.html - ### Constructor -The `Tile` class has a [constructor][] that defines -what data needs to be passed into the widget to render the widget. -In this case, the constructor accepts two parameters: - -- A `String` representing the guessed letter of the tile. -- A `HitType` [enum value][] represent the guess result and - used to determine the color of the tile. - For example, `HitType.hit` results in a green tile. - -Passing data into widget constructors is at the core of making widgets reusable. +The `Tile` class has a [`constructor`][] that defines +what data needs to be passed into the widget to render the widget. Here, a +`String` is passed in, which represents the guessed letter, and a `HitType`, +which is an [enum value][] used to +determine the color of the tile. (For example `HitType.hit` results in a green +tile). Passing data into the widget is at the core of making widgets reusable. -[constructor]: {{site.dart-site}}/language/constructors -[enum value]: {{site.dart-site}}/language/enums -### Build method +### `Build` method -Finally, there's the all important `build` method, which must be defined on +Finally, there’s the all important `build` method, which must be defined on every widget, and will always return another widget. ```dart @@ -97,24 +75,23 @@ class Tile extends StatelessWidget { @override Widget build(BuildContext context) { - // TODO: Replace Container with widgets. - return Container(); + // TODO: Replace Containter with widgets. + return Container(); } } ``` ## Use the custom widget -When the app is finished, -there will be 25 instances of this widget on the screen. -For now, though, display just one so you can see the updates as they're made. -In the `MainApp.build` method, replace the `Text` widget with the following: +When this app is finished, there will be 25 instances of this widget on screen. +For now, though, display just one so you can see the updates as they’re made. In +the `MainApp.build` method, replace the `Text` widget with the following: ```dart class MainApp extends StatelessWidget { const MainApp({super.key}); - @override + @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( @@ -127,21 +104,20 @@ class MainApp extends StatelessWidget { } ``` -At the moment, your app will be blank, -because the `Tile` widget returns an empty `Container`, -which doesn't display anything by default. +At the moment, your app will be blank, because the `Tile` widget returns an +empty `Container`, which doesn’t display anything by default. ## The `Container` widget -The `Tile` widget consists of three of the most common core widgets: +The `Tile` widget consists of three of the most common basic widgets: `Container`, `Center`, and `Text`. -[`Container`][] is a convenience widget that wraps several core styling widgets, -such as [`Padding`][], [`ColoredBox`][], [`SizedBox`][], and [`DecoratedBox`][]. +[`Container`][] is a +convenience widget that wraps several basic styling widgets, like `Padding`, +[`ColoredBox`][], [`SizedBox`][], [`DecoratedBox`][], and many more. -Because the finished UI contains 25 `Tile` widgets in neat columns and rows, -it should have an explicit size. -Set the width and height properties on the `Container`. -(You could also do this with a `SizedBox` widget, but you'll use +Because the finished UI contains 25 `Tile` widgets in neat columns and rows, it +should have an explicit size. Set the width and height properties on the +`Container`. (You could also do this with a `SizedBox` widget, but you’ll use more properties of the `Container` next.) ```dart @@ -153,7 +129,7 @@ class Tile extends StatelessWidget { @override Widget build(BuildContext context) { - // NEW + // NEW return Container( width: 60, height: 60, @@ -163,12 +139,6 @@ class Tile extends StatelessWidget { } ``` -[`Container`]: {{site.api}}/flutter/widgets/Container-class.html -[`Padding`]: {{site.api}}/flutter/widgets/Padding-class.html -[`ColoredBox`]: {{site.api}}/flutter/widgets/ColoredBox-class.html -[`SizedBox`]: {{site.api}}/flutter/widgets/SizedBox-class.html -[`DecoratedBox`]: {{site.api}}/flutter/widgets/DecoratedBox-class.html - ## BoxDecoration Next, add a [`Border`][] to the box with the following code: @@ -182,7 +152,7 @@ class Tile extends StatelessWidget { @override Widget build(BuildContext context) { - // NEW + // NEW return Container( width: 60, height: 60, @@ -195,26 +165,23 @@ class Tile extends StatelessWidget { } ``` -`BoxDecoration` is an object that knows how to -add any number of decorations to a widget, from -background color to borders to box shadows and more. -In this case, you've added a border. -When you hot reload, there should be -a lightly colored border around the white square. +`BoxDecoration` is an object that knows how to add any number of decorations to +a widget, from background color to borders to box shadows and more. In this +case, you’ve added a border. When you hot reload, there should be a lightly +colored border around the white square. -When this game is complete, -the color of the tile will depend on the user's guess. -The tile will be green when the user has guessed correctly, -yellow when the letter is correct but the position is incorrect, and -gray if the guess is wrong in both respects. +When this game is complete, the color of the tile will depend on the user’s +guess. The tile will be green when the user has guessed correctly, yellow when +the letter is correct but the position is incorrect, and gray if the guess is +wrong on both axes. The following figure shows all three possibilities. A screenshot of a green, yellow, and grey tile. -To achieve this in UI, use a [switch expression][] to -set the `color` of the `BoxDecoration`. +To achieve this in UI, use a [switch expression][] to set the +`color` of the `BoxDecoration`. ```dart class Tile extends StatelessWidget { @@ -225,7 +192,7 @@ class Tile extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( + return Container( width: 60, height: 60, decoration: BoxDecoration( @@ -243,17 +210,13 @@ class Tile extends StatelessWidget { } ``` -[`Border`]: {{site.api}}/flutter/widgets/Container-class.html -[switch expression]: {{site.dart-site}}/language/branches#switch-expressions - ## Child widgets Finally, add the `Center` and `Text` widgets to the `Container.child` property. -Most widgets in the Flutter SDK have a `child` or `children` property that's -meant to be passed a widget or a list of widgets, respectively. -It's the best practice to use the same naming convention in -your own custom widgets. +Most widgets in the Flutter SDK have a `child` or `children` property that’s +meant to be passed a widget or a list of widgets, respectively. It's best +practice to use the same naming convention in your own custom widgets. ```dart class Tile extends StatelessWidget { @@ -301,4 +264,17 @@ child: Tile('A', HitType.partial) ``` Soon, this small box will be one of many widgets on the screen. In the next -lesson, you'll start building the game grid itself. +lesson, you’ll start building the game grid itself. + + + +[`StatelessWidget`]: {{site.api}}/flutter/widgets/StatelessWidget-class.html +[`constructor`]: {{site.dart-site}}/language/constructors +[`Container`]: {{site.api}}/flutter/widgets/Container-class.html +[`Border`]: {{site.api}}/flutter/widgets/Container-class.html +[`ColoredBox`]: {{site.api}}/flutter/widgets/ColoredBox-class.html +[`SizedBox`]: {{site.api}}/flutter/widgets/SizedBox-class.html +[`DecoratedBox`]: {{site.api}}/flutter/widgets/DecoratedBox-class.html +[switch expression]: {{site.dart-site}}/language/branches#switch-statements +[enum value]: {{site.dart-site}}/language/branches#switch-statements +[this github repository]: https://github.com/ericwindmill/legal_wordle_words diff --git a/src/content/tutorial/ui/layout.md b/src/content/tutorial/ui/3-layout.md similarity index 59% rename from src/content/tutorial/ui/layout.md rename to src/content/tutorial/ui/3-layout.md index 1d7790b6588..3cd243418de 100644 --- a/src/content/tutorial/ui/layout.md +++ b/src/content/tutorial/ui/3-layout.md @@ -1,48 +1,41 @@ --- title: Layout description: Learn about common layout widgets in Flutter. -layout: tutorial +permalink: /tutorial/layout/ sitemap: false --- {%- comment %} TODO(ewindmill) embed video {%- endcomment %} -Given that Flutter is a UI toolkit, -you'll spend a lot of time creating layouts with Flutter widgets. -In this section, you'll learn how to build layouts with -some of the most common layout widgets. -This includes high-level widgets like -[`Scaffold`][] and [`AppBar`][], which lay out the structure of a screen, -as well as lower-level widgets like [`Column`][] or [`Row`][] that -lay out widgets vertically or horizontally. - -[`Scaffold`]: {{site.api}}/flutter/material/Scaffold-class.html -[`AppBar`]: {{site.api}}/flutter/material/AppBar-class.html -[`Column`]: {{site.api}}/flutter/widgets/Column-class.html -[`Row`]: {{site.api}}/flutter/widgets/Row-class.html +Given that Flutter is a UI toolkit, you'll spend a lot of time creating layouts +with Flutter widgets. In this section, you'll learn how to build layouts with +some of the most common layout widgets, including high-level widgets like +[`Scaffold`][] and [`AppBar`][], which lay out the structure of a screen, to +lower-level widgets like [`Column`][] or [`Row`][] +that lay out widgets vertically or horizontally. ## `Scaffold` and `AppBar` -Mobile applications often have a bar at the top called an "app bar" that can +Mobile applications often have a bar at the top called an “app bar” that can display a title, navigation controls, and/or actions. -A screenshot of a simple application with a bar across the top that has a title and settings button. +A screenshot of a simple application with a bar across the top that has a title and settings button. -The simplest way to add an app bar to your app is by using two widgets: +The simplest way to add an appbar to your app is by using two widgets: `Scaffold` and `AppBar`. `Scaffold` is a convenience widget that provides a Material-style page layout, making it simple to add an app bar, drawer, navigation bar, and more to a page of your app. `AppBar` is, of course, the app bar. -The code generated from the `flutter create --empty` command already -contains an `AppBar` widget and a `Scaffold` widget. -The following code updates it to use an additional layout widget: [`Align`][]. -This positions the title to the left, which would be centered by default. -The `Text` widget contains the title itself. +The code generated from the `$ flutter create --empty` command already contains +an `AppBar` widget and a `Scaffold` widget. The following code updates it to use an +additional layout widget: [`Align`][]. This positions the title to the left, +which would be centered by default. The `Text` widget contains the +title itself. -Modify the `Scaffold` within your `MainApp` widget's `build` method: +Modify the `Scaffold` within your `MainApp`'s `build` method: ```dart class MainApp extends StatelessWidget { @@ -65,24 +58,22 @@ class MainApp extends StatelessWidget { } ``` -[`Align`]: {{site.api}}/flutter/widgets/Align-class.html - ### An updated widget tree -Considering your app's widget tree gets more important as your app grows. -At this point, there's a "branch" in the widget tree for the first time, -and it now looks like the following figure: +Note that your app's widget tree gets more important as your app +grows. At this point, there's a "branch" in the widget tree for the first +time, and it now looks like the following figure. A screenshot that resembles the popular game Wordle. -## Create a widget for the game page layout +## Create the GamePage widget -Add the following code for a new widget, -called `GamePage`, to your `main.dart` file. -This widget will eventually display the UI elements needed for the game itself. +Add the following code for a new widget, called `GamePage`, to your `main.dart` +file. This widget will eventually display the UI elements needed for the game +itself. -```dart title="lib/main.dart" +```dart class GamePage extends StatelessWidget { const GamePage({super.key}); // This object is part of the game.dart file. @@ -101,7 +92,7 @@ class GamePage extends StatelessWidget { **Solution:** -```dart title="solution.dart" collapsed +```dart class MainApp extends StatelessWidget { const MainApp({super.key}); @@ -116,62 +107,57 @@ class MainApp extends StatelessWidget { } } ``` - ::: ## Arrange widgets with `Column` and `Row` -The `GamePage` layout contains the grid of tiles that display a user's guesses. +The `GamePage` layout contains the grid of tiles that display a user’s guesses. A screenshot that resembles the popular game Wordle. -There are a number of ways you can build this layout. -The simplest is with the `Column` and `Row` widgets. -Each row contains five tiles that represent the five letters in a guess, -with five rows total. -So you'll need a single `Column` with five `Row` widgets as children, -where each row contains five children. - -To get started, replace the `Container` in `GamePage.build` with a -`Padding` widget with a `Column` widget as its child: +There are a number of ways you can build this layout, and the simplest is with +`Column` and `Row` widgets. Each row contains five tiles that represent the +five letters in a guess, with five rows total. You’ll need a column +with five rows, each row containing five children. +First, return a `Column` (wrapped with a `Padding` +widget) from the `GamePage.build` method. ```dart class GamePage extends StatelessWidget { const GamePage({super.key}); - // This manages game logic, and is out of scope for this lesson. + // This manages game logic, and is out of scope for this lesson final Game _game = Game(); - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - spacing: 5.0, - children: [ - // Add children next. - ], - ), - ); - } + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + spacing: 5.0, + children: [ + // Add children next + ], + ), + ); + }` } ``` The `spacing` property puts five pixels between each element on the main axis. -Within `Column.children`, for each element in the `_game.guesses` list, -add a `Row` widget as a child. +Within `Column.children`, add one row *for each* element in the `_game.guesses` +list. :::note This `guesses` list is a **fixed-size** list, starting with five -elements, one for each *potential* guess. -The list will always contain exactly five elements, -and therefore will always render five rows. +elements, one for each *potential* guess. The list will always contain exactly five +elements, and therefore will always render five rows. ::: ```dart class GamePage extends StatelessWidget { const GamePage({super.key}); - // This manages game logic, and is out of scope for this lesson. + // This manages game logic, and is out of scope for this lesson final Game _game = Game(); @override @@ -182,39 +168,42 @@ class GamePage extends StatelessWidget { spacing: 5.0, children: [ for (var guess in _game.guesses) - Row( - spacing: 5.0, - children: [ - // We'll add the tiles here later. - ] - ), - ], + Row( + spacing: 5.0, + children: [ + // tiles + ] + ), + ], ), ); } } ``` -The `for` loop in the `children` list is called a [collection for element][], -a Dart syntax that allows you to iteratively add items to a collection -when it is built at runtime. +This is called a [collection-for][] loop, a Dart feature that allows you to +unfurl a list inside of another list when the loop is executed. This syntactic sugar makes it easier for you to work -with collections of widgets, -providing a declarative alternative to the following: +with collections of widgets and achieves the same as the following psuedo code: ```dart -[..._game.guesses.map((guess) => Row(/* ... */))], +[...ListOfData.map((element) => Widget(element)).toList()], ``` -In this case, it adds five `Row` widgets to the column, -one for each guess on the `Game` object. +In this case, it adds five `Row` widgets to the column, one for each guess +on the `Game` object. -[collection for element]: /language/collections#for-element +### An updated widget tree + +The widget tree for this app has expanded significantly in this +lesson. Now, it looks more like the following figure (although it's +abridged for legibility.) ### An updated widget tree -The widget tree for this app has expanded significantly in this lesson. -Now, it looks more like the following (abridged) figure: +Considering your app's widget tree gets more important as your app +grows. At this point, there's a "branch" in the tree for the first +time, and it now looks like the following figure. A diagram showing a tree like structure with a node for each widget in the app. @@ -229,7 +218,7 @@ The `guess` variable in the loop is a [record][] with the type ```dart class GamePage extends StatelessWidget { const GamePage({super.key}); - // This manages game logic, and is out of scope for this lesson. + // This manages game logic, and is out of scope for this lesson final Game _game = Game(); Widget build(BuildContext context) { @@ -259,4 +248,10 @@ When you reload your app, you should see a 5x5 grid of white squares. A screenshot that resembles the popular game Wordle. +[`AppBar`]: {{site.api}}/flutter/material/AppBar-class.html +[`Scaffold`]: {{site.api}}/flutter/material/Scaffold-class.html +[`Column`]: {{site.api}}/flutter/widgets/Column-class.html +[`Row`]: {{site.api}}/flutter/widgets/Row-class.html +[`Align`]: {{site.api}}/flutter/widgets/Align-class.html +[collection-for]: {{site.dart-site}}/language/collections#for-element [record]: {{site.dart-site}}/language/records diff --git a/src/content/tutorial/ui/4-devtools.md b/src/content/tutorial/ui/4-devtools.md new file mode 100644 index 00000000000..58b4f7c6ea6 --- /dev/null +++ b/src/content/tutorial/ui/4-devtools.md @@ -0,0 +1,179 @@ +--- +title: DevTools +description: Learn to use the Dart DevTools when developing Flutter apps. +permalink: /tutorial/devtools/ +sitemap: false +--- + +{%- comment %} TODO(ewindmill) embed video {%- endcomment %} + +As your Flutter app grows in complexity, it becomes more important +to understand how each of the widgets properties affect the UI. +[Dart's DevTools][] assists you with two particularly useful features: the +**widget inspector** and the **property editor**. + +First, launch DevTools by running the following commands while your app is running in debug mode: + +```shell +$ flutter pub global activate devtools # You only need to run this once +$ devtools +``` + +:::note Run in your IDE + +You can also run DevTools directly inside [VS Code][] and [IntelliJ][], +provided you have the Flutter plugin installed. The screenshots in this lesson +are from VS Code. + +::: + +## The widget inspector + +The widget inspector allows you to visualize and explore your widget tree. It +helps you understand the layout of your UI and identifies which widgets are +responsible for different parts of the screen. Running against the app you've +built so far, the inspector looks like this: + +A screenshot of the Flutter widget inspector tool. + +Consider the `GamePage` widget you created in this section: + +```dart +class GamePage extends StatelessWidget { + const GamePage({super.key}); + + final Game _game = Game(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + spacing: 5.0, + children: [ + for (var guess in _game.guesses) + Row( + spacing: 5.0, + children: [ + for (var letter in guess) Tile(letter, ) + ] + ), + ], + ), + ); + } +} +``` + +And how it's used in `MainApp`: + +```dart +class MainApp extends StatelessWidget { + const MainApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: Center(child: GamePage()), + ), + ); + } +} +``` + +In the widget inspector, you should see a tree of exactly the same +widgets that are in your code: `MaterialApp` as the root, with +`Scaffold` as its `home` and an `AppBar` as its `appBar`, and so on +down the entire tree to the `Row` widgets with `Tile` children. You +can select any widget in the tree to see its properties and even jump +to its source code in your IDE. + +## Debugging layout issues + +The widget inspector is perhaps most useful for debugging layout issues. + +In certain situations, a widget's [constraints][] are unbounded, or +infinite. This means that either the maximum width or the maximum +height is set to [`double.infinity`][]. A widget that tries to be as +big as possible won't function usefully when given an unbounded +constraint and, in debug mode, throws an exception. + +The most common case where a render box ends up with an unbounded +constraint is within a flex box widget ([`Row`][] or [`Column`][]), +and within a scrollable region (such as [`ListView`][] and other +[`ScrollView`][] subclasses). `ListView`, for example, tries to expand +to fit the space available in its cross-direction (perhaps it's a +vertically-scrolling block and tries to be as wide as its parent). If +you nest a vertically scrolling `ListView` inside a horizontally +scrolling `ListView`, the inner list tries to be as wide as possible, +which is infinitely wide, since the outer one is scrollable in that +direction. + +Perhaps the most common error you'll run into while building a Flutter +application is due to incorrectly using layout widgets, and is +referred to as the "unbounded constraints" error. + +Watch the following video to get an understanding of how to spot and +resolve this issue. + + + +## The property editor + +When you select a widget in the widget inspector, the property editor +displays all the properties of that selected widget. This is a +powerful tool for understanding why a widget looks the way it does and +for experimenting with property value changes in real-time. + +A screenshot of the Flutter property editor tool. + +Look at the `Tile` widget's `build` method from earlier: + +```dart +class Tile extends StatelessWidget { + const Tile(required this.letter, required hitType, {super.key}); + + final String letter; + final HitType hitType; + + @override + Widget build(BuildContext context) { + return Container( + width: 60, + height: 60, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + color: switch (hitType) { + HitType.hit => Colors.green, + HitType.partial => Colors.yellow, + HitType.miss => Colors.grey, + _ => Colors.white, + }, + ), + ); + } +} +``` + +If you select a `Tile` widget in the Widget Inspector, the Property +Editor would show you its `width` (60), `height` (60), and the +`decoration` property. You could then expand the `BoxDecoration` to +see the `border` and `color` properties. + +For many properties, you can even modify their values directly within the +property editor. For example, to quickly test how a different +`width` or `height` would look for your `Container` in the `Tile` widget, + change the numerical value in the Property Editor and see the update +instantly on your running app without needing to recompile or even hot reload. +This allows for rapid iteration on UI design. + +[Dart's DevTools]: /tools/devtools +[constraints]: /ui/layout/constraints +[`double.infinity`]:{{site.api}}/flutter/dart-core/double/infinity-constant.html +[`Column`]: {{site.api}}/flutter/widgets/Column-class.html +[`Row`]: {{site.api}}/flutter/widgets/Row-class.html +[`ListView`]: {{site.api}}/flutter/widgets/ListView-class.html +[`ScrollView`]: {{site.api}}/flutter/widgets/ScrollView-class.html +[VS Code]: /tools/vs-code +[IntelliJ]: /tools/android-studio diff --git a/src/content/tutorial/ui/user-input.md b/src/content/tutorial/ui/5-user-input.md similarity index 69% rename from src/content/tutorial/ui/user-input.md rename to src/content/tutorial/ui/5-user-input.md index 264b39e7342..db31be96078 100644 --- a/src/content/tutorial/ui/user-input.md +++ b/src/content/tutorial/ui/5-user-input.md @@ -1,7 +1,7 @@ --- title: User input -description: Accept input from the user with buttons and text fields. -layout: tutorial +description: Accept input from the user with buttons and text fields +permalink: /tutorial/user-input/ sitemap: false --- @@ -9,19 +9,15 @@ sitemap: false The app will display the user's guesses in the `Tile` widgets, -but it needs a way for the user to input those guesses. -In this lesson, build that functionality with two interaction widgets: -[`TextField`][] and [`IconButton`][]. - -[`TextField`]: {{site.api}}/flutter/material/TextField-class.html -[`IconButton`]: {{site.api}}/flutter/material/IconButton-class.html +but it needs a way for the user to input those guesses. In this lesson, +build that functionality with two interaction widgets: [`TextField`][] and +[`IconButton`][]. ## Implement callback functions -To allow users to type in their guesses, -you'll create a dedicated widget named `GuessInput`. -First, create the basic structure for your `GuessInput` widget that -requires a callback function as an argument. +To allow users to type in their guesses, you'll create a dedicated +widget named `GuessInput`. First, create the basic structure for your +`GuessInput` widget that requires a callback function as an argument. Name the callback function `onSubmitGuess`. Add the following code to your `main.dart` file. @@ -34,7 +30,7 @@ class GuessInput extends StatelessWidget { @override Widget build(BuildContext context) { - // You'll build the UI in the next steps. + // You'll build the UI in the next steps return Container(); // Placeholder } } @@ -42,29 +38,27 @@ class GuessInput extends StatelessWidget { The line `final void Function(String) onSubmitGuess;` declares a `final` member of the class called `onSubmitGuess` -that has the type `void Function(String)`. -This function takes a single `String` argument (the user's guess) and -doesn't return any value (denoted by `void`). +that has the type `void Function(String)`. This function takes a +single `String` argument (the user's guess) and doesn't return any +value (denoted by `void`). -This callback tells us that the logic that -actually handles the user's guess will be written elsewhere. -It's a good practice for interactive widgets to -use callback functions to keep the widget that handles interactions reusable and -decoupled from any specific functionality. +This callback tells us that the logic that actually handles the user's +guess will be written elsewhere. It's good practice for interactive +widgets to use callback functions to keep the widget +that handles interactions reusable and decoupled from any +specific functionality. By the end of this lesson, the passed-in `onGuessSubmitted` function -is called when a user enters a guess. -First, you'll need to build the visual parts of this widget. -This is what the widget will look like. +is called when a user enters a guess. First, you'll need to build +the visual parts of this widget. This is what the widget will look like. A screenshot of the Flutter property editor tool. ## The `TextField` widget Given that the text field and button are displayed side-by-side, -create them as a `Row` widget. -Replace the `Container` placeholder in your `build` method with -a `Row` containing an `Expanded` `TextField`: +create them as a `Row` widget. Replace the `Container` placeholder in your +`build` method with a `Row` containing an `Expanded` `TextField`: ```dart class GuessInput extends StatelessWidget { @@ -95,16 +89,15 @@ class GuessInput extends StatelessWidget { } ``` -You have seen some of these widgets in previous lessons: -`Row` and `Padding`. New, though, is the [`Expanded`][] widget. -When a child of a `Row` (or `Column`) is wrapped in `Expanded`, -it tells that child to fill all the available space along the main axis -(horizontal for`Row`, vertical for `Column`) that -hasn't been taken by other children. -This makes the `TextField` stretch to take up all the space *except* -what's taken by other widgets in the row. +You have seen some of these widgets in previous lessons: `Row` and +`Padding`. New, though, is the [`Expanded`][] widget. When a child of +a `Row` (or `Column`) is wrapped in `Expanded`, it tells that child to +fill all the available space along the main axis (horizontal for +`Row`, vertical for `Column`) that hasn't been taken by other +children. This makes the `TextField` stretch to take up all the space +*except* what’s taken by other widgets in the row. -:::tip +:::tip Tip `Expanded` is often the solution to "[unbounded width/height][]" exceptions. ::: @@ -113,20 +106,15 @@ This is the basic Flutter widget for text input. Thus far, `TextField` has the following configuration. -- It's decorated with a rounded border. - Notice that the decoration configuration is - very similar to how a `Container` and boxes are decorated. -- Its `maxLength` property is set to 5 because the game - only allows guesses of 5-letter words. - -[`Expanded`]: {{site.api}}/flutter/widgets/Expanded-class.html -[unbounded width/height]: https://www.youtube.com/watch?v=jckqXR5CrPI +* It’s decorated with a rounded border. Notice that the decoration + configuration is very similar to how a `Container` and boxes are decorated. +* Its `maxLength` property is set to 5 because the game only + allows guesses of 5-letter words. ## Handle text with `TextEditingController` -Next, you need a way to manage the text that -the user types into the input field. -For this, use a [`TextEditingController`][]. +Next, you need a way to manage the text that the user types into the +input field. For this, use a [`TextEditingController`][]. ```dart class GuessInput extends StatelessWidget { @@ -161,9 +149,8 @@ class GuessInput extends StatelessWidget { } ``` -A `TextEditingController` is used to -read, clear, and modify the text in a `TextField`. -To use it, pass it into the `TextField`. +A `TextEditingController` is used to read, clear, and modify the text +in a `TextField`. To use it, pass it into the `TextField`. ```dart class GuessInput extends StatelessWidget { @@ -197,16 +184,15 @@ class GuessInput extends StatelessWidget { } ``` -Now, when a user inputs text, you can -capture it with the `_textEditingController`, but -you'll need to know _when_ to capture it. -The simplest way to react to input is by -using the `TextField.onSubmitted` argument. -This argument accepts a callback, and the callback is triggered whenever -the user presses the "Enter" key on the keyboard while the text field has focus. +Now, when a user inputs text, you can capture it with the +`_textEditingController`, but you'll need to know *when* to capture +it. The simplest way to react to input is by using the +`TextField.onSubmitted` argument. This argument accepts a callback, +and the callback is triggered whenever the user presses the +"Enter" key on the keyboard while the text field has focus. -For now, ensure that this works by -adding the following callback to `TextField.onSubmitted`: +For now, ensure that this works by adding the following callback to +`TextField.onSubmitted`. ```dart class GuessInput extends StatelessWidget { @@ -243,10 +229,17 @@ class GuessInput extends StatelessWidget { } ``` -In this case, -you could print the `input` passed to the `onSubmitted` callback directly, -but a better user experience clears the text after each guess: -You need a `TextEditingController` to do that. Update the code as follows: +In this case, you could print the `input` passed to the `onSubmitted` +callback directly, but a better user experience clears the text +after each guess: You need a `TextEditingController` to +do that. Update the code as follows: + +:::note +In Dart, it’s good practice to use the `_` [wildcard][] to +hide the input to a function that’ll never be used. The following +example does so. +::: + ```dart class GuessInput extends StatelessWidget { @@ -284,26 +277,16 @@ class GuessInput extends StatelessWidget { } ``` -:::note -In Dart, it's good practice to use the `_` [wildcard][] to -hide the input to a function that'll never be used. -The preceding example does so. -::: - -[`TextEditingController`]: {{site.api}}/flutter/widgets/TextEditingController-class.html -[wildcard]: {{site.dart-site}}/language/variables#wildcard-variables - ## Gain input focus -Often, you want a specific input or widget to -automatically gain focus without the user taking action. -In this app, for example, the only thing a user can do is enter a guess, -so the `TextField` should be focused automatically when the app launches. -And after the user enters a guess, the focus should stay -in the `TextField` so they can enter their next guess. +Often, you want a specific input or widget to automatically gain focus +without the user taking action. In this app, for example, the only +thing a user can do is enter a guess, so the `TextField` should be +focused automatically when the app launches. And after the user +enters a guess, the focus should stay in the `TextField` so they can +enter their next guess. -To resolve the first focus issue, -set up the `autoFocus` property on the `TextField`. +To resolve the first focus issue, set up the `autoFocus` property on the `TextField`. ```dart class GuessInput extends StatelessWidget { @@ -312,8 +295,7 @@ class GuessInput extends StatelessWidget { final void Function(String) onSubmitGuess; final TextEditingController _textEditingController = TextEditingController(); - - @override + @override Widget build(BuildContext context) { return Row( children: [ @@ -328,7 +310,7 @@ class GuessInput extends StatelessWidget { ), ), controller: _textEditingController, - autoFocus: true, // NEW + autoFocus: true // NEW onSubmitted: (String input) { print(input); // Temporary _textEditingController.clear(); @@ -342,10 +324,9 @@ class GuessInput extends StatelessWidget { } ``` -The second issue requires you to -use a [`FocusNode`][] to manage the keyboard focus. -You can use `FocusNode` to request that a `TextField` gain focus, -(making the keyboard appear on mobile), +The second issue requires you to use a [`FocusNode`][] to +manage the keyboard focus. You can use `FocusNode` to request +that a `TextField` gain focus (making the keyboard appear on mobile), or to know when a field has focus. First, create a `FocusNode` in the `GuessInput` class: @@ -367,8 +348,8 @@ class GuessInput extends StatelessWidget { } ``` -Then, use the `FocusNode` to request focus whenever -the `TextField` is submitted after the controller is cleared: +Then, use the `FocusNode` to request focus whenever the `TextField` is +submitted after the controller is cleared: ```dart class GuessInput extends StatelessWidget { @@ -395,7 +376,7 @@ class GuessInput extends StatelessWidget { ), ), controller: _textEditingController, - autoFocus: true, + autoFocus: true focusNode: _focusNode, // NEW onSubmitted: (String input) { print(input); // Temporary @@ -411,17 +392,13 @@ class GuessInput extends StatelessWidget { } ``` -Now, when you press Enter after inputting text, -you can continue typing. - -[`FocusNode`]: {{site.api}}/flutter/widgets/FocusNode-class.html +Now, when you press ‘Enter’ after inputting text, you can continue typing. ## Use the input Finally, you need to handle the text that the user enters. -Recall that the constructor for `GuessInput` requires a -callback called `onGuessSubmitted`. -In `GuessInput`, you need to use that callback. +Recall that the constructor for `GuessInput` requires a callback called +`onGuessSubmitted`. In `GuessInput`, you need to use that callback. Replace the `print` statement with a call to that function. ```dart @@ -449,10 +426,10 @@ class GuessInput extends StatelessWidget { ), ), controller: _textEditingController, - autoFocus: true, + autoFocus: true focusNode: _focusNode, onSubmitted: (String input) { - onSubmitGuess(_textEditingController.text.trim()); + onSubmitGuess(_textEditionController.text.trim()) _textEditingController.clear(); _focusNode.requestFocus(); } @@ -467,13 +444,13 @@ class GuessInput extends StatelessWidget { :::note The `trim` function prevents whitespace from being entered; -otherwise, the user could enter a four-letter word plus a space character. +otherwise, the user could enter a four letter word plus a whitespace. ::: -The remaining functionality is handled in the parent widget, `GamePage`. -In the `build` method of that class, -under the `Row` widgets in the `Column` widget's children, -add the `GuessInput` widget: +The remaining functionality is handled in the parent +widget, `GamePage`. In the `build` method of that class, add the +`GuessInput` widget under the `Row` widgets in the `Column`’s +children. ```dart class GamePage extends StatelessWidget { @@ -506,30 +483,29 @@ class GamePage extends StatelessWidget { } ``` -For the moment, this only prints the guess to -prove that it's wired up correctly. -Submitting the guess requires using the functionality of a `StatefulWidget`, -which you'll do in the next lesson. +For the moment, this only prints the guess to prove that it’s wired up +correctly. Submitting the guess requires using the functionality +of a `StatefulWidget`, which you’ll do in the [`StatefulWidget` lesson][]. ## Buttons -To improve the UX on mobile and reflect well-known UI practices, -there should also be a button that can submit the guess. +To improve the UX on mobile and reflect well-known UI, there should +also be a button that can submit the guess. There are many button widgets built into Flutter, like [`TextButton`][], -[`ElevatedButton`][], and the button you'll use now: [`IconButton`][]. -All of these buttons (and many other interaction widgets) require two +[`ElevatedButton`][], and the button you’ll use now: [`IconButton`][]. All of +these buttons (and many other interaction widgets) require two arguments (in addition to their optional arguments): -- A callback function passed to `onPressed`. -- A widget that makes up the content of the button (often `Text` or an `Icon`). +* A callback function passed to `onPressed`. +* A widget that makes up the content of the button (often `Text` or an `Icon`). -Add an icon button to the row widget's children list in the `GuessInput` widget, -and give it an [`Icon`][] widget to display. -The `Icon` widget requires configuration; in this case, -the `padding` property sets the padding between the -edge of the button and the icon it wraps to zero. -This removes the default padding and makes the button smaller. +Add an icon button to the row widget’s children list in the +`GuessInput` widget, and give it an [`Icon`][] widget to display. +The `Icon` widget requires configuration; in this case, the +`padding` property sets the padding between the edge of the +button and the icon it wraps to zero. This removes the default +padding and makes the button smaller. ```dart class GuessInput extends StatelessWidget { @@ -540,11 +516,11 @@ class GuessInput extends StatelessWidget { final TextEditingController _textEditingController = TextEditingController(); final FocusNode _focusNode = FocusNode(); - @override + @override Widget build(BuildContext context) { return Row( children: [ - Expanded(/* ... */), + Expanded(...), IconButton( padding: EdgeInsets.zero, icon: Icon(Icons.arrow_circle_up), @@ -566,11 +542,11 @@ class GuessInput extends StatelessWidget { final TextEditingController _textEditingController = TextEditingController(); final FocusNode _focusNode = FocusNode(); - @override + @override Widget build(BuildContext context) { return Row( children: [ - Expanded(/* ... */), + Expanded(...), IconButton( padding: EdgeInsets.zero, icon: Icon(Icons.arrow_circle_up), @@ -588,24 +564,19 @@ class GuessInput extends StatelessWidget { This method does the same as the `onSubmitted` callback on the `TextField`. -[`Icon`]: {{site.api}}/flutter/material/Icons-class.html -[`TextButton`]: {{site.api}}/flutter/material/TextButton-class.html -[`ElevatedButton`]: {{site.api}}/flutter/material/ElevatedButton-class.html -[`IconButton`]: {{site.api}}/flutter/material/IconButton-class.html - :::note Challenge - Share "on submitted" logic. -You might be thinking, "Shouldn't we abstract these methods into one -function and pass it to both inputs?" -You could, and as your app grows in complexity, you probably should. -That said, the callbacks `IconButton.onPressed` and `TextField.onSubmitted` have -different signatures, so it's not completely straight-forward. +You might be thinking, "Shouldn’t we abstract these methods into one +function and pass it to both inputs?" You could, and as your app grows +in complexity, you probably should. That said, the callbacks +`IconButton.onPressed` and `TextField.onSubmitted` have different + signatures, so it's not completely straight-forward. -Refactor the code such that the logic inside this method isn't repeated. +Refactor the code such that the logic inside this methods isn't repeated. -**Solution:** +**Solution** -```dart title="solution.dart" collapsed +```dart class GuessInput extends StatelessWidget { GuessInput({super.key, required this.onSubmitGuess}); @@ -656,3 +627,17 @@ class GuessInput extends StatelessWidget { ``` ::: + + +[`TextField`]: {{site.api}}/flutter/material/TextField-class.html +[`IconButton`]: {{site.api}}/flutter/material/IconButton-class.html +[`Expanded`]: {{site.api}}/flutter/widgets/Expanded-class.html +[unbounded width/height]: https://www.youtube.com/watch?v=jckqXR5CrPI +[`TextEditingController`]: {{site.api}}/flutter/widgets/TextEditingController-class.html +[wildcard]: {{site.dart-site}}/language/pattern-types#wildcard +[`FocusNode`]: {{site.api}}/flutter/widgets/FocusNode-class.html +[`StatefulWidget` lesson]: /tutorial/stateful-widget +[`Icon`]: {{site.api}}/flutter/material/Icons-class.html +[`TextButton`]: {{site.api}}/flutter/material/TextButton-class.html +[`ElevatedButton`]: {{site.api}}/flutter/material/ElevatedButton-class.html +[`IconButton`]: {{site.api}}/flutter/material/IconButton-class.html diff --git a/src/content/tutorial/ui/stateful-widget.md b/src/content/tutorial/ui/6-stateful-widget.md similarity index 57% rename from src/content/tutorial/ui/stateful-widget.md rename to src/content/tutorial/ui/6-stateful-widget.md index 9c5d1d163e5..4914e40399d 100644 --- a/src/content/tutorial/ui/stateful-widget.md +++ b/src/content/tutorial/ui/6-stateful-widget.md @@ -1,43 +1,42 @@ --- title: Stateful widgets description: Learn about StatefulWidgets and rebuilding Flutter UI. -layout: tutorial +permalink: /tutorial/stateful-widget/ sitemap: false + --- {%- comment %} TODO(ewindmill) embed video {%- endcomment %} -So far, your app displays a grid and an input field, -but the grid doesn't yet update to reflect the user's guesses. -When this app is complete, each tile in the next unfilled row should -update after each submitted user guess by: +So far, your app displays a grid and an input field, but the grid +doesn't yet update to reflect the user’s guesses. When this app is +complete, each tile in the next unfilled row should update after each +submitted user guess by: -- Displaying the correct letter. -- Changing color to reflect whether the letter is correct (green), - is in the word but at an incorrect position (yellow), or - doesn't appear in the word at all (grey). +* Displaying the correct letter. +* Changing color to reflect whether the letter is correct (green), is + in the word but at an incorrect position (yellow), or doesn't appear + in the word at all (grey). To handle this dynamic behavior, you need to convert `GamePage` from a `StatelessWidget` to a [`StatefulWidget`][]. -[`StatefulWidget`]: {{site.api}}/flutter/widgets/StatefulWidget-class.html - ## Why stateful widgets? -When a widget's appearance or data needs to change during its lifetime, -you need a `StatefulWidget` and a companion `State` object. +When a widget's appearance or data needs to change during its + lifetime, you need a `StatefulWidget` and a companion `State` object. While the `StatefulWidget` itself is still immutable (its properties -can't change after creation), the `State` object is long-lived, -can hold mutable data, and can be rebuilt when that data changes, -causing the UI to update. +can't change after creation), the `State` object is long-lived, can +hold mutable data, and can be rebuilt when that data changes, causing +the UI to update. For example, the following widget tree imagines a simple app -that uses a stateful widget with a counter that -increases when the button is pressed. +that has a counter that increases when the button is pressed, +and uses a stateful widget. A diagram of a widget tree with a stateful widget and state object. -Here is the basic `StatefulWidget` structure (doesn't do anything yet): +Here is the basic `StatefulWidget` structure (don't do anything yet): ```dart class ExampleWidget extends StatefulWidget { @@ -50,29 +49,30 @@ class ExampleWidget extends StatefulWidget { class _ExampleWidgetState extends State { @override Widget build(BuildContext context) { - return Container(); + return Container(); } } ``` ## Convert `GamePage` to a stateful widget -To convert the `GamePage` (or any other) widget from +To convert the `GamePage` widget (or any other) from a stateless widget to a stateful widget, do the following steps: -1. Change `GamePage` to extend `StatefulWidget` instead of `StatelessWidget`. -1. Create a new class named `_GamePageState`, that extends `State`. - This new class will hold the mutable state and the `build` method. - Move the `build` method and all properties *instantiated on the widget* - from `GamePage` to the state object. -1. Implement the `createState()` method in `GamePage`, which - returns an instance of `_GamePageState`. +1. Change `GamePage` to extend `StatefulWidget` instead of + `StatelessWidget`. +2. Create a new class named `_GamePageState`, that extends + `State`. This new class will hold the mutable state and + the `build` method. Move the `build` method and all properties + *instantiated on the widget* from `GamePage` to the state object. +3. Implement the `createState()` method in `GamePage`, which returns + an instance of `_GamePageState`. :::tip Quick assists You don't have to manually do this work, as the Flutter plugins for -VS Code and IntelliJ provide ["quick assists"][] that can -do this conversion for you. +VS Code and IntelliJ provides ["quick assists"][], which will do this +conversion for you. ::: @@ -119,28 +119,27 @@ class _GamePageState extends State { } ``` -["quick assists"]: /tools/android-studio#assists-quick-fixes - ## Updating the UI with `setState` -Whenever you mutate a `State` object, -you must call [`setState`][] to signal the framework to -update the user interface and call the `build` method again. +Whenever you mutate a `State` object, you must call [`setState`][] to +signal the framework to update the user interface and call the +`State`'s `build` method again. -In this app, when a user makes a guess, the word they guessed is -saved on the `Game` object, which is a property on the `GamePage` class, -and therefore is state that might change and require the UI to update. -When this state is mutated, the grid should be -re-drawn to show the user's guess. +In this app, when a user makes a guess, the word they guessed is saved +on the `Game` object, which is a property on the `GamePage` class, and +therefore is state that might change and require the UI to update. +When this state is mutated, the grid should be re-drawn to show the +user’s guess. -To implement this, update the callback function passed to `GuessInput`. -The function needs to call `setState` and, within `setState`, -it needs to execute the logic to determine whether the users guess was correct. +To implement this, update the callback function passed to +`GuessInput`. The function needs to call `setState` and, within +`setState`, it needs to execute the logic to determine whether the users +guess was correct. :::note -The game logic is abstracted away into the `Game` object, -and outside the scope of this tutorial. +The game logic is abstracted away into the [`Game` object][], and +outside of the scope of this tutorial. ::: @@ -189,9 +188,12 @@ class _GamePageState extends State { ``` Now, when you type a legal guess into the `TextInput` and submit it, -the application will reflect the user's guess. -If you were to call `_game.guess(guess)` *without* a calling `setState`, -the internal game data would change, but Flutter wouldn't know it -needs to repaint the screen, and the user wouldn't see any updates. +the application will reflect the user’s guess. If you were to call +`_game.guess(guess)` *without* a calling `setState`, the internal game +data would change, but Flutter wouldn't know it needs to repaint the +screen, and the user wouldn't see any updates. +["quick assists"]: /tools/android-studio#assists-quick-fixes +[`StatefulWidget`]: {{site.api}}/flutter/widgets/StatefulWidget-class.html [`setState`]: {{site.api}}/flutter/widgets/State/setState.html +[`Game` object]: https://github.com/flutter/demos diff --git a/src/content/tutorial/ui/implicit-animations.md b/src/content/tutorial/ui/7-implicit-animations.md similarity index 57% rename from src/content/tutorial/ui/implicit-animations.md rename to src/content/tutorial/ui/7-implicit-animations.md index aca03eea33d..79754124d3a 100644 --- a/src/content/tutorial/ui/implicit-animations.md +++ b/src/content/tutorial/ui/7-implicit-animations.md @@ -1,31 +1,29 @@ --- title: Simple animations description: Learn the simplest way to implement animations in Flutter. -layout: tutorial +permalink: /tutorial/animations/ sitemap: false --- Flutter provides a rich set of animation APIs, and the simplest way to -start using them is with **implicit animations**. -"Implicit animations" refers to a group of widgets that -automatically animate changes to their properties without you -needing to manage any intermediate behavior. +start using them is with **implicit animations**. "Implicit +animations" refers to a group of widgets that automatically animate +changes to their properties without you needing to manage any +behavior. In this lesson, you'll learn about one of the most common and -versatile implicit animation widgets: [`AnimatedContainer`][]. -With just two additional lines of code, the background color of each `Tile` +versatile implicit animation widgets: [`AnimatedContainer`][]. With +just two additional lines of code, the background color of each `Tile` animates to a new color in about half a second. -[`AnimatedContainer`]: {{site.api}}/flutter/widgets/AnimatedContainer-class.html - ## Convert `Container` to `AnimatedContainer` -Currently, the `Tile.build` method returns a `Container` to display a letter. -When the `hitType` changes, like from `HitType.none` to `HitType.hit`, -the background color of the tile changes instantly. -For example, from white to green in the case of `HitType.none` to `HitType.hit`. +Currently, the `Tile.build` method returns a `Container` to display +a letter. When the `hitType` changes, like from `HitType.none` +to `HitType.hit`, the background color of the tile changes +instantly (from white to green, in this example). -For reference, here's the current implementation of the `Tile` widget: +Here's the current `Tile` widget code for reference: ```dart class Tile extends StatelessWidget { @@ -59,15 +57,14 @@ class Tile extends StatelessWidget { } ``` -To make the color change animate smoothly, -replace the `Container` widget with an `AnimatedContainer`. +To make the color change animate smoothly, replace the `Container` +widget with an `AnimatedContainer`. -An `AnimatedContainer` is like a `Container`, but it -automatically animates changes to its properties over a specified `duration`. -When properties such as -`color`, `height`, `width`, `decoration`, or `alignment` change, -`AnimatedContainer` interpolates between the old and new values, -creating a smooth transition. +An `AnimatedContainer` is like a `Container`, but it automatically +animates changes to its properties over a specified `duration`. When +properties like `color`, `height`, `width`, `decoration`, or +`alignment` change, `AnimatedContainer` interpolates between the old +and new values, creating a smooth transition. Modify your `Tile` widget as follows: @@ -104,27 +101,25 @@ class Tile extends StatelessWidget { } ``` -**`duration`** is a required property that -specifies how long the animation should take. -In this example, passing `Duration(milliseconds: 500)` means -the color transition will take half a second. -You can also specify seconds, minutes, and many other units of time. +**`duration`** is a required property that specifies how long the +animation should take. In this example, `Duration(milliseconds: 500)` +means the color transition will take half of one second. You can also +specify seconds, minutes, and many other units of time. Now, when the `hitType` changes and the `Tile` widget rebuilds -(because `setState` was called in `GamePage`), -the color of the tile smoothly animates from its old color to -the new one over the specified duration. +(because `setState` was called in `GamePage`), the color of the tile +will smoothly animate from its old color to the new one over the +specified duration. -## Adjust the animation curve +## Adjust the curve -To add a bit of customization to an implicit animation, -you can pass it a different [`Curve`][]. -Different curves change the speed of the animation +You can add a bit of customization to an implicit animation by passing +it a [`Curve`][]. Different curves will change the speed of the animation at different points throughout the animation. {%- comment %} TODO(ewindmill) diagram {%- endcomment %} -To change the `Curve` of this animation, update the code to the following: +To change the `Curve` of this animation, update the the code to the following: ```dart class Tile extends StatelessWidget { @@ -133,6 +128,7 @@ class Tile extends StatelessWidget { final String letter; final HitType hitType; + @override Widget build(BuildContext context) { return AnimatedContainer( @@ -160,15 +156,16 @@ class Tile extends StatelessWidget { } ``` -There are many different curves provided by the Flutter SDK, so -feel free to try them out by passing different types to the `curve` parameter. +There are many different curves defined by the Flutter SDK, so feel +free to try them out by passing different types to the +`AnimatedContainer.curve` property. -Implicit animations like `AnimatedContainer` are powerful because -you just tell the widget what the new state should be, and -it handles the "how" of the animation. +Implicit animations like `AnimatedContainer` are powerful because you +just tell the widget what the new state should be, and it handles the +"how" of the animation. For complex, custom animations, you can write +your own animated widgets. If you’re curious, read the +[animations tutorial](https://docs.flutter.dev/ui/animations/tutorial). -For complex, custom animations, you can write your own animated widgets. -If you're curious, try it out in the [animations tutorial][]. - -[`Curve`]: {{site.api}}/flutter/animation/Curves-class.html -[animations tutorial]: /ui/animations/tutorial +[`AnimatedContainer`]: {{site.api}}/flutter/widgets/AnimatedContainer-class.html +[`Curve`]: {{site.curve}}/flutter/animation/Curves-class.html +[animations tutorial]: /ui/animations/tutorial. diff --git a/src/content/tutorial/ui/create-an-app.md b/src/content/tutorial/ui/create-an-app.md deleted file mode 100644 index b11199e1e59..00000000000 --- a/src/content/tutorial/ui/create-an-app.md +++ /dev/null @@ -1,131 +0,0 @@ ---- -title: Create an app -description: Instructions on how to create a new Flutter app. -layout: tutorial -sitemap: false ---- - -{%- comment %} - -{%- endcomment %} - -In this first section of the Flutter tutorial, -you'll build the core UI of an app called 'Birdle', -a game similar to [Wordle, the popular New York Times game][]. - -By the end of this tutorial, you'll have -learned the fundamentals of building Flutter UIs, and your app will -look like the following screenshot (and it'll even mostly work 😀). - -A screenshot that resembles the popular game Wordle. - -[Wordle, the popular New York Times game]: https://www.nytimes.com/games/wordle/index.html - -## Create a new Flutter project - -The first step to building Flutter apps is to create a new project. -You create new apps with the [Flutter CLI tool][], -installed as part of the Flutter SDK. - -Open your terminal or command prompt and run -the following command to create a new Flutter project: - -```console -$ flutter create birdle --empty -``` - -This creates a new Flutter project using the minimal "empty" template. - -[Flutter CLI tool]: /reference/flutter-cli - -## Examine the code - -In your IDE, open the file at `lib/main.dart`. -Starting from the top, you'll see this code. - -```dart title"lib/main.dart" -import 'package:flutter/material.dart'; // Imports Flutter. - -void main() { - runApp(const MainApp()); -} -// ... -``` - -The `main` function is the entry point to any Dart program, -and a Flutter app is just a **Dart** program. -The `runApp` method is part of the Flutter SDK, -and it takes a **widget** as an argument. -In this case, an instance of the `MainApp` widget is being passed in. - -Just below the `main` function, you'll find the `MainApp` class declaration. - -```dart -class MainApp extends StatelessWidget { - const MainApp({super.key}); - - @override - Widget build(BuildContext context) { - return const MaterialApp( - home: Scaffold( - body: Center( - child: Text('Hello World!'), - ), - ), - ); - } -} - -``` - -`MainApp` is the **root widget**, -as it's the widget that's passed into `runApp`. -Within this widget, there's a `build` method that -returns another widget called `MaterialApp`. -Essentially, this is what a Flutter app is: -a composition of widgets that make -up a tree structure called the **widget tree.** - -Your job as a Flutter developer is to -compose widgets from the SDK into larger, custom widgets that display a UI. - -At the moment, the widget tree is quite simple: - -A screenshot that resembles the popular game Wordle. - -## Run your app - -1. In your terminal, - navigate to the root directory of your created Flutter app: - - ```console - $ cd birdle - ``` - -1. Run the app using the Flutter CLI tool. - - ```console - $ flutter run -d chrome - ``` - - The app will build and launch in a new instance of Chrome. - -A screenshot that resembles the popular game Wordle. - -## Use hot reload - -**Stateful hot reload**, if you haven't heard of it, -allows a running Flutter app to re-render updated business logic or UI code in -less than a second – all without losing your place in the app. - -In your IDE, open the `main.dart` file and navigate to line ~15 and find this -code: - -```dart -child: Text('Hello World!'), -``` - -Change the text inside the string to anything you want. -Then, hot-reload your app by -pressing `r` in the terminal where the app is running. -The running app should instantly show your updated text. diff --git a/src/content/tutorial/ui/devtools.md b/src/content/tutorial/ui/devtools.md deleted file mode 100644 index f51efcb59d5..00000000000 --- a/src/content/tutorial/ui/devtools.md +++ /dev/null @@ -1,190 +0,0 @@ ---- -title: DevTools -description: Learn to use the Dart DevTools when developing Flutter apps. -layout: tutorial -sitemap: false ---- - -{%- comment %} TODO(ewindmill) embed video {%- endcomment %} - -As your Flutter app grows in complexity, it becomes more important -to understand how each of the widget properties affects the UI. -The [Dart and Flutter DevTools][] provide you with -two particularly useful features: -the **widget inspector** and the **property editor**. - -First, launch DevTools by running the following commands while -your app is running in debug mode: - -```console -$ dart devtools -``` - -:::note Run in your IDE - -Provided you have the appropriate Flutter plugin installed, -you can also run DevTools directly inside -Code OSS-based editors such as [VS Code][] as well as -[IntelliJ and Android Studio][]. -The screenshots in this lesson are from VS Code. - -::: - -[Dart and Flutter DevTools]: /tools/devtools -[VS Code]: /tools/vs-code -[IntelliJ and Android Studio]: /tools/android-studio - -## The widget inspector - -The widget inspector allows you to visualize and explore your widget tree. -It helps you understand the layout of your UI and -identifies which widgets are responsible for different parts of the screen. -Running against the app you've built so far, the inspector looks like this: - -A screenshot of the Flutter widget inspector tool. - -Consider the `GamePage` widget you created in this section: - -```dart -class GamePage extends StatelessWidget { - const GamePage({super.key}); - - final Game _game = Game(); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - spacing: 5.0, - children: [ - for (var guess in _game.guesses) - Row( - spacing: 5.0, - children: [ - for (var letter in guess) Tile(letter, ) - ] - ), - ], - ), - ); - } -} -``` - -And how it's used in `MainApp`: - -```dart -class MainApp extends StatelessWidget { - const MainApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - home: Scaffold( - body: Center(child: GamePage()), - ), - ); - } -} -``` - -In the widget inspector, you should see a tree of -exactly the same widgets that are in your code: -`MaterialApp` as the root, with `Scaffold` as its `home`, -an `AppBar` as its `appBar`, and so on down the entire tree to -the `Row` widgets with `Tile` children. -You can select any widget in the tree to see its properties and -even jump to its source code in your IDE. - -## Debugging layout issues - -The widget inspector is perhaps most useful for debugging layout issues. - -In certain situations, -a widget's [constraints][] are unbounded, or infinite. -This means that either -the maximum width or the maximum height is set to [`double.infinity`][]. -A widget that tries to be as big as possible won't function usefully when -given an unbounded constraint and, in debug mode, throws an exception. - -The most common case where a render box ends up with an unbounded -constraint is within a flex box widget ([`Row`][] or [`Column`][]), -and within a scrollable region, -such as a [`ListView`][] or [`ScrollView`][] subclasses. - -`ListView`, for example, tries to expand to -fit the space available in its cross-direction. Such as if -it's a vertically scrolling block that tries to be as wide as its parent. -If you nest a vertically scrolling `ListView` inside -a horizontally scrolling `ListView`, the inner list tries to -be as wide as possible, which is infinitely wide, since the -outer one is scrollable in that direction. - -Perhaps the most common error you'll run into while -building a Flutter application is due to incorrectly using layout widgets. -This error is referred to as the "unbounded constraints" error. - -Watch the following video to get an understanding of how to -spot and resolve this issue. - - - -[constraints]: /ui/layout/constraints -[`double.infinity`]: {{site.api}}/flutter/dart-core/double/infinity-constant.html -[`Column`]: {{site.api}}/flutter/widgets/Column-class.html -[`Row`]: {{site.api}}/flutter/widgets/Row-class.html -[`ListView`]: {{site.api}}/flutter/widgets/ListView-class.html -[`ScrollView`]: {{site.api}}/flutter/widgets/ScrollView-class.html - -## The property editor - -When you select a widget in the widget inspector, -the property editor displays all the properties of that selected widget. -This is a powerful tool for understanding why a widget looks the way it does and -for experimenting with property value changes in real-time. - -A screenshot of the Flutter property editor tool. - -Look at the `Tile` widget's `build` method from earlier: - -```dart -class Tile extends StatelessWidget { - const Tile(required this.letter, required hitType, {super.key}); - - final String letter; - final HitType hitType; - - @override - Widget build(BuildContext context) { - return Container( - width: 60, - height: 60, - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade300), - color: switch (hitType) { - HitType.hit => Colors.green, - HitType.partial => Colors.yellow, - HitType.miss => Colors.grey, - _ => Colors.white, - }, - ), - ); - } -} -``` - -If you select a `Tile` widget in the widget inspector, -the property editor would show you its -`width` (60), `height` (60), and the `decoration` property. -You could then expand the `BoxDecoration` to -see the `border` and `color` properties. - -For many properties, you can even -modify their values directly within the property editor. -For example, to quickly test how a different `width` or `height` would look -for your `Container` in the `Tile` widget, -change the numerical value in the property editor. -Then instantly see the update on your running app without -needing to recompile or even hot reload. -This allows for rapid iteration on UI design. diff --git a/src/content/ui/animations/hero-animations.md b/src/content/ui/animations/hero-animations.md index 3d934a106fb..e8f5bbc5613 100644 --- a/src/content/ui/animations/hero-animations.md +++ b/src/content/ui/animations/hero-animations.md @@ -561,8 +561,8 @@ class RadialExpansion extends StatelessWidget { }) : [!clipRectSize = 2.0 * (maxRadius / math.sqrt2);!] final double maxRadius; - final double clipRectSize; - final Widget? child; + final clipRectSize; + final Widget child; @override Widget build(BuildContext context) { diff --git a/src/content/ui/design/graphics/fragment-shaders.md b/src/content/ui/design/graphics/fragment-shaders.md index 90da3ce7923..e0cbcd219be 100644 --- a/src/content/ui/design/graphics/fragment-shaders.md +++ b/src/content/ui/design/graphics/fragment-shaders.md @@ -122,11 +122,6 @@ to apply shaders to already rendered content. [`ImageFilter`][] provides a constructor, [`ImageFilter.shader`][], for creating an [`ImageFilter`][] with a custom fragment shader. -:::warning -The `ImageFilter` API for custom shaders is only supported by the [Impeller][] backend. -Using it on other backends will throw an error. -::: - ```dart Widget build(BuildContext context, FragmentShader shader) { return ClipRect( diff --git a/src/content/ui/widgets/accessibility.md b/src/content/ui/widgets/accessibility.md new file mode 100644 index 00000000000..2defdab4e64 --- /dev/null +++ b/src/content/ui/widgets/accessibility.md @@ -0,0 +1,7 @@ +--- +title: Accessibility widgets +shortTitle: Accessibility +description: A catalog of Flutter's accessibility widgets. +widgetCategory: Accessibility +layout: widget-catalog-page +--- diff --git a/src/content/ui/widgets/animation.md b/src/content/ui/widgets/animation.md new file mode 100644 index 00000000000..8641dddee0d --- /dev/null +++ b/src/content/ui/widgets/animation.md @@ -0,0 +1,7 @@ +--- +title: Animation and motion widgets +shortTitle: Animation +description: A catalog of Flutter's animation widgets. +widgetCategory: Animation and motion +layout: widget-catalog-page +--- diff --git a/src/content/ui/widgets/assets.md b/src/content/ui/widgets/assets.md new file mode 100644 index 00000000000..359653407f3 --- /dev/null +++ b/src/content/ui/widgets/assets.md @@ -0,0 +1,7 @@ +--- +title: Assets, images, and icon widgets +shortTitle: Assets +description: A catalog of Flutter's asset widgets. +widgetCategory: Assets, images, and icons +layout: widget-catalog-page +--- diff --git a/src/content/ui/widgets/async.md b/src/content/ui/widgets/async.md new file mode 100644 index 00000000000..a7b399746d4 --- /dev/null +++ b/src/content/ui/widgets/async.md @@ -0,0 +1,7 @@ +--- +title: Async widgets +shortTitle: Async +description: A catalog of Flutter widgets for handling asynchronous code. +widgetCategory: Async +layout: widget-catalog-page +--- diff --git a/src/content/ui/widgets/basics.md b/src/content/ui/widgets/basics.md new file mode 100644 index 00000000000..8d64c710048 --- /dev/null +++ b/src/content/ui/widgets/basics.md @@ -0,0 +1,7 @@ +--- +title: Basic widgets +shortTitle: Basics +description: A catalog of Flutter's basic widgets. +widgetCategory: Basics +layout: widget-catalog-page +--- diff --git a/src/content/ui/widgets/cupertino.md b/src/content/ui/widgets/cupertino.md new file mode 100644 index 00000000000..59453d3aaec --- /dev/null +++ b/src/content/ui/widgets/cupertino.md @@ -0,0 +1,9 @@ +--- +title: Cupertino widgets +shortTitle: Cupertino +description: > + A catalog of Flutter's Cupertino widgets that align with + Apple's Human Interface Guidelines for iOS and macOS. +widgetCategory: Cupertino +layout: widget-catalog-page +--- diff --git a/src/content/ui/widgets/index.md b/src/content/ui/widgets/index.md index 3a58cd787a0..9be8667fcce 100644 --- a/src/content/ui/widgets/index.md +++ b/src/content/ui/widgets/index.md @@ -39,7 +39,21 @@ and the Ubuntu-inspired [yaru][] widgets. Base widgets support a range of common rendering options like input, layout, and text. - +
    +{% assign categories = catalog.index | sortBy: 'name' -%} +{% for section in categories %} + {%- if section.name != "Cupertino" and section.name != "Material components" and section.name != "Material 2 components" -%} + +
    + {{section.name}} +
    +
    +

    {{section.description}}

    +
    +
    + {% endif -%} +{% endfor %} +
    ## Widget of the Week diff --git a/src/content/ui/widgets/input.md b/src/content/ui/widgets/input.md new file mode 100644 index 00000000000..34568a0484b --- /dev/null +++ b/src/content/ui/widgets/input.md @@ -0,0 +1,7 @@ +--- +title: Input widgets +shortTitle: Input +description: A catalog of Flutter's input widgets. +widgetCategory: Input +layout: widget-catalog-page +--- diff --git a/src/content/ui/widgets/interaction.md b/src/content/ui/widgets/interaction.md new file mode 100644 index 00000000000..cfe215762a7 --- /dev/null +++ b/src/content/ui/widgets/interaction.md @@ -0,0 +1,8 @@ +--- +title: Interaction model widgets +shortTitle: Interaction +description: > + A catalog of Flutter's widgets supporting user interaction and navigation. +widgetCategory: Interaction models +layout: widget-catalog-page +--- diff --git a/src/content/ui/widgets/layout.md b/src/content/ui/widgets/layout.md new file mode 100644 index 00000000000..7b16b6c2113 --- /dev/null +++ b/src/content/ui/widgets/layout.md @@ -0,0 +1,7 @@ +--- +title: Layout widgets +shortTitle: Layout +description: A catalog of Flutter's widgets for building layouts. +widgetCategory: Layout +layout: widget-catalog-page +--- diff --git a/src/content/ui/widgets/material.md b/src/content/ui/widgets/material.md new file mode 100644 index 00000000000..97d0752b8bb --- /dev/null +++ b/src/content/ui/widgets/material.md @@ -0,0 +1,31 @@ +--- +title: Material component widgets +shortTitle: Material +description: > + A catalog of Flutter's widgets implementing Material 3 design guidelines. +widgetCategory: Material components +materialCatalog: true +layout: widget-catalog-page +--- + +Flutter provides a variety of visual, behavioral, and motion-rich widgets +that implement the [Material 3][] design specification. +Material 3 is the default design language of Flutter, +enabling you to design and build beautiful, usable apps +that can adapt to any platform. + +:::secondary +The transition to Material 3 as the default was +completed in Flutter 3.16. + +To learn more about this transition, how to complete it for your own widgets, +or how to temporarily opt-out, check out +the [Migrate to Material 3][] migration guide. +::: + +To catch these and other widgets in action, +check out the [Material 3 demo][] web app. + +[Material 3]: https://m3.material.io/get-started +[Migrate to Material 3]: /release/breaking-changes/material-3-migration +[Material 3 demo]: {{site.github}}/flutter/samples/tree/main/material_3_demo/ diff --git a/src/content/ui/widgets/material2.md b/src/content/ui/widgets/material2.md new file mode 100644 index 00000000000..c49b8f284be --- /dev/null +++ b/src/content/ui/widgets/material2.md @@ -0,0 +1,28 @@ +--- +title: Material 2 component widgets +shortTitle: Material 2 +description: > + A catalog of Flutter's widgets implementing the Material 2 design guidelines. +widgetCategory: Material 2 components +layout: widget-catalog-page +--- + +Flutter provides a variety of widgets +that implement the [Material 2][] design guidelines, +enabling you to create intuitive and beautiful apps. + +:::version-note +[Material 3][], the latest version of Material Design, is +Flutter's default design language as of Flutter 3.16. + +Material 2 will eventually be deprecated. +To learn more about this transition, check out +the [Migrate to Material 3][] migration guide. + +Also check out the [Material 3 widget catalog][]. +::: + +[Material 3]: https://m3.material.io/ +[Material 2]: https://m2.material.io/design +[Migrate to Material 3]: /release/breaking-changes/material-3-migration +[Material 3 widget catalog]: /ui/widgets/material diff --git a/src/content/ui/widgets/painting.md b/src/content/ui/widgets/painting.md new file mode 100644 index 00000000000..194ce85bf6d --- /dev/null +++ b/src/content/ui/widgets/painting.md @@ -0,0 +1,8 @@ +--- +title: Painting and effect widgets +shortTitle: Painting +description: > + A catalog of Flutter's widgets that provide effects and custom painting. +widgetCategory: Painting and effects +layout: widget-catalog-page +--- diff --git a/src/content/ui/widgets/scrolling.md b/src/content/ui/widgets/scrolling.md new file mode 100644 index 00000000000..5095c412251 --- /dev/null +++ b/src/content/ui/widgets/scrolling.md @@ -0,0 +1,7 @@ +--- +title: Scrolling widgets +shortTitle: Scrolling +description: A catalog of Flutter's widgets that enable or support scrolling. +widgetCategory: Scrolling +layout: widget-catalog-page +--- diff --git a/src/content/ui/widgets/styling.md b/src/content/ui/widgets/styling.md new file mode 100644 index 00000000000..68a7e562c43 --- /dev/null +++ b/src/content/ui/widgets/styling.md @@ -0,0 +1,7 @@ +--- +title: Styling widgets +shortTitle: Styling +description: A catalog of Flutter's theming and responsiveness widgets. +widgetCategory: Styling +layout: widget-catalog-page +--- diff --git a/src/content/ui/widgets/text.md b/src/content/ui/widgets/text.md new file mode 100644 index 00000000000..69bb08176a7 --- /dev/null +++ b/src/content/ui/widgets/text.md @@ -0,0 +1,7 @@ +--- +title: Text widgets +shortTitle: Text +description: A catalog of Flutter's widgets for displaying and styling text. +widgetCategory: Text +layout: widget-catalog-page +--- diff --git a/src/data/architectureRecommendations.yml b/src/data/architectureRecommendations.yml index c460d22bc30..e7659902cc6 100644 --- a/src/data/architectureRecommendations.yml +++ b/src/data/architectureRecommendations.yml @@ -1,4 +1,6 @@ -- category: separation-of-concerns +- category: Separation of concerns + description: | + You should separate your app into a UI layer and a data layer. Within those layers, you should further separate logic into classes by responsibility. recommendations: - recommendation: Use clearly defined data and UI layers. description: | @@ -26,7 +28,7 @@ confidence: conditional confidence-description: | There are many options to handle state-management, and ultimately the decision comes down to personal preference. - Read about [our ChangeNotifier recommendation](/get-started/fwe/state-management) or [other popular options](https://docs.flutter.dev/data-and-backend/state-mgmt/options). + Read about [our ChangeNotifier recommendation][] or [other popular options][]. - recommendation: Do not put logic in widgets. description: | @@ -46,7 +48,10 @@ confidence-description: | Use in apps with complex logic requirements. -- category: handling-data +- category: Handling data + description: | + Handling data with care makes your code easier to understand, less error prone, and + prevents malformed or unexpected data from being created. recommendations: - recommendation: Use unidirectional data flow. description: | @@ -57,7 +62,7 @@ - recommendation: Use `Commands` to handle events from user interaction. description: | Commands prevent rendering errors in your app, and standardize how the UI layer sends events to the data layer. - Read about commands in the [architecture case study](/app-architecture/guide). + Read about commands in the [architecture case study][]. confidence: recommend - recommendation: Use immutable data models. @@ -69,7 +74,7 @@ - recommendation: Use freezed or built_value to generate immutable data models. description: | - You can use packages to help generate useful functionality in your data models, [freezed](https://pub.dev/packages/freezed) or [built_value](https://pub.dev/packages/built_value). + You can use packages to help generate useful functionality in your data models, [freezed][] or [built_value][]. These can generate common model methods like JSON ser/des, deep equality checking and copy methods. These code generation packages can add significant build time to your applications if you have a lot of models. confidence: recommend @@ -80,8 +85,11 @@ confidence: conditional confidence-description: Use in large apps. -- category: app-structure +- category: App structure + description: | + Well organized code benefits both the health of the app itself, and the team working on the code. recommendations: + - recommendation: Use dependency injection. description: | Dependency injection prevents your app from having globally accessible objects, which makes your code less error prone. @@ -92,7 +100,7 @@ description: | Go_router is the preferred way to write 90% of Flutter applications. There are some specific use-cases that go_router doesn't solve, - in which case you can use the [Flutter Navigator API](https://docs.flutter.dev/ui/navigation) directly or try other packages found on [pub.dev](https://pub.dev). + in which case you can use the [Flutter Navigator API][] directly or try other packages found on [pub.dev][]. confidence: recommend - recommendation: Use standardized naming conventions for classes, files and directories. @@ -118,8 +126,12 @@ which can be used for different app environments, such as "development" and "staging". confidence: strong -- category: testing +- category: Testing + description: | + Good testing practices makes your app flexible. + It also makes it straightforward and low risk to add new logic and new UI. recommendations: + - recommendation: Test architectural components separately, and together. description: | * Write unit tests for every service, repository and ViewModel class. These tests should test the logic of every method individually. diff --git a/src/data/glossary.yml b/src/data/glossary.yml index b118ff9d269..bb25c785533 100644 --- a/src/data/glossary.yml +++ b/src/data/glossary.yml @@ -1,51 +1,3 @@ -- term: "Hot reload" - short_description: |- - A Flutter feature that allows you to inject updated code into - a running application in the Dart VM and see the changes immediately - while maintaining application state. - long_description: |- - This feature is also called "stateful hot reload". - After the Dart runtime updates classes with the new versions - of fields and functions, the Flutter framework automatically - rebuilds the widget tree, allowing you to quickly view the effects - of your changes. Hot reload greatly increases the speed of development. - - Hot reload works on mobile, web, and desktop apps that are - running in debug mode and is fully supported in VS Code, - Android Studio, and IntelliJ IDEA. It does not re-run `main` or - `initState`; for that, use [hot restart][]. - - [hot restart]: /resources/glossary/#hot-restart - related_links: - - text: "Hot reload documentation" - link: "/tools/hot-reload" - type: "doc" - - text: "Fast development cycles with Flutter's hot reload" - link: "https://youtu.be/YScJS8obxlo?si=QxJDIf_LGmle2Xs6" - type: "video" - - text: "Stateful hot reload for web is here" - link: "https://youtu.be/7nT3BHm6Gyg?si=nLUM0n69PSQnm8CF" - type: "video" - labels: - - "fast development" - - "tooling" - -- term: "Hot restart" - short_description: |- - Similar to hot reload, but it does not maintain app state. - Use hot restart to re-run `main` or `initState`. - long_description: |- - Hot restart is still faster than a full restart, which also - recompiles the native, platform code (such as Swift). - On the web, it also restarts the Dart Development Compiler (DDC). - related_links: - - text: "Difference between hot reload, hot restart, and full restart" - link: "/tools/hot-reload#hot-restart" - type: "doc" - labels: - - "fast development" - - "tooling" - - term: "Impeller" short_description: |- Flutter's modern graphics rendering engine, @@ -72,32 +24,6 @@ - "performance" - "engine" -- term: "Jank" - short_description: |- - When an app appears to stutter or jerk visually instead of animating - smoothly. - long_description: |- - Jank occurs when a system can't keep up with the expected frame rate - and drops frames. Jank is a performance problem. Flutter offers - information and tooling, such as the Performance tool in DevTools, - that can help you diagnose and fix jank in your application. - related_links: - - text: "Use the Performance view in DevTools" - link: "/tools/devtools/performance" - type: "doc" - - text: "Improving rendering performance" - link: "/perf/rendering-performance" - type: "doc" - - text: "Performance best practices" - link: "/perf/best-practices" - type: "doc" - - text: "Measure performance with an integration test" - link: "/cookbook/testing/integration/profiling" - type: "doc" - labels: - - "performance" - - "smooth rendering" - - term: "Sliver" short_description: |- A customizable portion of a scrollable area. diff --git a/src/data/tutorial.yml b/src/data/tutorial.yml deleted file mode 100644 index 35ee7c264e1..00000000000 --- a/src/data/tutorial.yml +++ /dev/null @@ -1,38 +0,0 @@ -title: Learn Flutter -units: - - title: Introduction to Flutter UI - chapters: - - title: Create a Flutter app - url: /tutorial/ui/create-an-app/ - - title: Widget fundamentals - url: /tutorial/ui/widget-fundamentals/ - - title: Layout widgets on a screen - url: /tutorial/ui/layout/ - - title: Devtools - url: /tutorial/ui/devtools/ - - title: Handle user input - url: /tutorial/ui/user-input/ - - title: Learn about stateful widgets - url: /tutorial/ui/stateful-widget/ - - title: Add implicit animations - url: /tutorial/ui/implicit-animations/ - - title: State in Flutter apps - chapters: - - title: Set up a new project - url: /tutorial/state/set-up-project/ - - title: Make Http Requests - url: /tutorial/state/http-requests/ - - title: Use `ChangeNotifier` to update app state - url: /tutorial/state/change-notifier/ - - title: Use `ListenableBuilder` to update app UI - url: /tutorial/state/listenable-builder/ - - title: Flutter UI 102 - chapters: - - title: Advanced UI features - url: /tutorial/ui-102/intro/ - - title: "`LayoutBuilder` and adaptive layouts" - url: /tutorial/ui-102/adaptive-layout/ - - title: Scrolling and slivers - url: /tutorial/ui-102/slivers/ - - title: Stack based navigation - url: /tutorial/ui-102/navigation/