diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8b037596e2..8ed4511add 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,7 +28,7 @@ jobs: experimental: true continue-on-error: ${{ matrix.experimental }} steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - uses: subosito/flutter-action@e938fdf56512cc96ef2f93601a5a40bde3801046 with: channel: ${{ matrix.branch }} @@ -50,7 +50,7 @@ jobs: runs-on: ubuntu-latest if: github.repository == 'cfug/flutter.cn' steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - uses: dart-lang/setup-dart@e51d8e571e22473a2ddebf0ef8a2123f0ab2c02c with: sdk: beta @@ -66,7 +66,7 @@ jobs: timeout-minutes: 30 if: github.repository == 'cfug/flutter.cn' steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - uses: dart-lang/setup-dart@e51d8e571e22473a2ddebf0ef8a2123f0ab2c02c with: sdk: beta @@ -92,7 +92,7 @@ jobs: runs-on: ubuntu-latest if: github.repository == 'cfug/flutter.cn' steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - uses: dart-lang/setup-dart@e51d8e571e22473a2ddebf0ef8a2123f0ab2c02c with: sdk: beta @@ -110,7 +110,7 @@ jobs: github.ref == 'refs/heads/main' && github.repository == 'cfug/flutter.cn' steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 with: # docs.flutter.cn | https://github.com/cfug/flutter.cn/pull/1518 fetch-depth: 0 diff --git a/.github/workflows/compile_host_redirect_js.yml b/.github/workflows/compile_host_redirect_js.yml index f5883fc567..25cc23b30d 100644 --- a/.github/workflows/compile_host_redirect_js.yml +++ b/.github/workflows/compile_host_redirect_js.yml @@ -22,7 +22,7 @@ jobs: github.repository == 'cfug/flutter.cn' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: ${{ env.NODE_VERSION }} diff --git a/.github/workflows/stage.yml b/.github/workflows/stage.yml index 325b06fafa..e2a00ee791 100644 --- a/.github/workflows/stage.yml +++ b/.github/workflows/stage.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest if: ${{ github.event.pull_request.head.repo.full_name == 'cfug/flutter.cn' }} steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 with: # docs.flutter.cn | https://github.com/cfug/flutter.cn/pull/1518 fetch-depth: 0 diff --git a/firebase.json b/firebase.json index cfe56dcbfa..e5f8b2ae8f 100644 --- a/firebase.json +++ b/firebase.json @@ -50,6 +50,7 @@ { "source": "/development", "destination": "/", "type": 301 }, { "source": "/development/:rest*", "destination": "/:rest*", "type": 301 }, { "source": "/devtools/:rest*", "destination": "/tools/devtools/:rest*", "type": 301 }, + { "source": "/download", "destination": "/install", "type": 301 }, { "source": "/downloads/:resource*", "destination": "/resources/:resource*", "type": 301 }, { "source": "/f/dart-devtools-survey-metadata.json", "destination": "https://storage.googleapis.com/flutter-uxr/surveys/devtools-survey-metadata.json", "type": 301 }, { "source": "/f/flutter-survey-metadata.json", "destination": "https://storage.googleapis.com/flutter-uxr/surveys/flutter-survey-metadata.json", "type": 301 }, @@ -360,6 +361,7 @@ { "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 }, @@ -436,6 +438,7 @@ { "source": "/go/flutter-drop-win7-2024", "destination": "https://docs.google.com/document/d/18gfRT8klo0zEvn6fIpders7ghoWIBKO22cNYS22WLhc/edit?resourcekey=0-SFkxdqfyM6KNNkG4zS6aaA", "type": 301 }, { "source": "/go/flutter-engine-clocks", "destination": "https://docs.google.com/document/d/1Sx8QA1qXgJGw5r4ESviDnU2LSShNHiq_LjbRWPgSvXQ/edit?usp=sharing&resourcekey=0-BoBvLxgqf_nc_rwLc0zmTw", "type": 301 }, { "source": "/go/flutter-engine-extensions", "destination": "https://docs.google.com/document/d/1xG7jR4FserdW7TdwnklF3_lXUGmt4myPQjDGF3LFtCQ/edit?resourcekey=0-Iug4D2mWuyQI6suvC_2itw#", "type": 301 }, + { "source": "/go/flutter-file-system", "destination": "https://docs.google.com/document/d/150KFR6WRSqpxbUcvQriBgrv6P5DMcq6dORikf_cOgZo/edit?usp=sharing", "type": 301 }, { "source": "/go/flutter-for-embedded-linux", "destination": "https://docs.google.com/document/d/1n4NXCk0QlGz16gUCtywR79H0Z1fzPqB2iNL8oxuexuk/edit?usp=sharing", "type": 301 }, { "source": "/go/flutter-gradle-plugin-apply", "destination": "/release/breaking-changes/flutter-gradle-plugin-apply", "type": 301 }, { "source": "/go/flutter-iap-migrate-pblv2", "destination": "https://docs.google.com/document/d/1XM16UsLE_aPWoZnheE9waO06mhxLkkWjpPf9jtI1AdY/edit", "type": 301 }, diff --git a/site/lib/_sass/_site.scss b/site/lib/_sass/_site.scss index 992f23edaf..3c5ba5f402 100644 --- a/site/lib/_sass/_site.scss +++ b/site/lib/_sass/_site.scss @@ -14,6 +14,7 @@ @use 'components/alert'; @use 'components/banner'; @use 'components/books'; +@use 'components/breadcrumbs'; @use 'components/button'; @use 'components/card'; @use 'components/code'; @@ -29,14 +30,17 @@ @use 'components/misc'; @use 'components/next-prev-nav'; @use 'components/os-selector'; +@use 'components/pagenav'; +@use 'components/platform-cards'; @use 'components/pill'; @use 'components/quiz'; @use 'components/sidebar'; @use 'components/side-menu'; @use 'components/site-switcher'; +@use 'components/summary-card'; +@use 'components/stepper'; @use 'components/tabs'; @use 'components/theming'; -@use 'components/toc'; @use 'components/tooltip'; @use 'components/trailing'; diff --git a/site/lib/_sass/base/_base.scss b/site/lib/_sass/base/_base.scss index dbbeada244..ca8258a344 100644 --- a/site/lib/_sass/base/_base.scss +++ b/site/lib/_sass/base/_base.scss @@ -8,8 +8,10 @@ body { color: var(--site-base-fgColor); // The top TOC is not shown on narrow screens. - @media (min-width: 1200px) { - --site-subheader-height: 0rem; + &:not(:has(#site-subheader.show-always)) { + @media (min-width: 1200px) { + --site-subheader-height: 0rem; + } } // If the TOC is disabled, reduce the subheader height to @@ -46,8 +48,13 @@ h2 { } dd { - margin-bottom: .75rem; + margin-block-end: .75rem; margin-left: 1rem; + + >p:only-child { + margin-block-start: 1rem; + margin-block-end: 1rem; + } } img { @@ -153,43 +160,6 @@ main figure { font-style: italic; text-align: center; } - - &.code-and-image { - gap: 0.25rem; - justify-content: space-between; - flex-direction: row; - flex-wrap: wrap; - - >div { - width: 100%; - - &:last-child { - text-align: center; - } - } - - @media(min-width: 769px) { - >div { - &:first-child { - flex: 0 0 58%; - max-width: 58%; - } - - &:last-child { - flex: 0 0 40%; - max-width: 40%; - } - } - - figcaption { - text-align: left; - } - - img { - max-width: 100%; - } - } - } } .text-icon { @@ -353,14 +323,19 @@ ol.steps { margin-block-end: 0.75rem; } + >h3:first-child, + >p:first-child { + height: $step-indicator-height; + margin-block-end: 0.5rem; + font-weight: 500; + } + >h3:first-child { display: flex; align-items: center; - height: $step-indicator-height; font-size: 1.125rem; font-weight: 500; color: var(--site-base-fgColor-lighter); - margin-block-end: 0.5rem; } padding-block-end: 0.75rem; @@ -429,6 +404,7 @@ p+dl { } .figure-caption { + margin-top: 0.25rem; font-size: .875rem; font-style: italic; color: var(--site-base-fgColor-lighter); diff --git a/site/lib/_sass/components/_breadcrumbs.scss b/site/lib/_sass/components/_breadcrumbs.scss new file mode 100644 index 0000000000..e2ba3a23b5 --- /dev/null +++ b/site/lib/_sass/components/_breadcrumbs.scss @@ -0,0 +1,47 @@ +nav.breadcrumbs { + align-items: center; + + ol.breadcrumb-list { + margin: 0; + padding: 0; + border-radius: var(--site-radius); + font-size: 0.925rem; + + align-items: center; + list-style: none; + + font-family: var(--site-ui-fontFamily); + + display: flex; + flex-wrap: wrap; + flex-direction: row; + + li.breadcrumb-item { + display: flex; + flex-direction: row; + align-items: center; + padding: 0; + + & a { + padding: 0.125rem; + border-radius: 0.125rem; + } + + &.active a { + color: var(--site-base-fgColor-lighter); + cursor: default; + text-decoration: none; + } + + &:before { + display: none; + } + } + + .material-symbols { + user-select: none; + font-size: 1.25rem; + color: var(--site-base-fgColor-lighter); + } + } +} diff --git a/site/lib/_sass/components/_button.scss b/site/lib/_sass/components/_button.scss index 530dca309a..aa37b99852 100644 --- a/site/lib/_sass/components/_button.scss +++ b/site/lib/_sass/components/_button.scss @@ -94,3 +94,22 @@ button { } } } + +.segmented-button { + display: inline-flex; + + a, + button { + &:not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + + border-right: 1px solid var(--site-outline-variant); + } + + &:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + } +} diff --git a/site/lib/_sass/components/_code.scss b/site/lib/_sass/components/_code.scss index 165892a59d..32e277076e 100644 --- a/site/lib/_sass/components/_code.scss +++ b/site/lib/_sass/components/_code.scss @@ -130,11 +130,8 @@ pre { } } - .terminal-command::before { - color: $margin-fgColor; - content: "$"; - content: "$" / ""; - padding-right: 0.5rem; + span[aria-hidden="true"] { + user-select: none; } } @@ -142,13 +139,111 @@ pre { span.line { padding-left: 0.5rem; - &[data-line]::before { - display: inline-block; - content: attr(data-line) ""; - width: 2em; - margin-right: 0.5rem; - text-align: right; - color: var(--site-base-fgColor-alt); + &[data-line] { + padding-left: 0; + + &::before { + display: inline-block; + content: attr(data-line) ""; + width: 2em; + margin-right: 1rem; + text-align: right; + color: var(--site-base-fgColor-alt); + } + } + } + } + + &.show-folding-ranges code { + + --folding-inset: 0px; + + // A folding range. + details { + margin: 0; + position: relative; + + --level: 0; + --inset: calc(var(--folding-inset) + 2px + (var(--level) * 1em)); + + // Vertical line drawn along a folding range. + &::before { + content: ""; + display: none; + position: absolute; + // inset + caret width/2 - line width/2 + left: calc(var(--inset) + .5em - .5px); + width: 1px; + background: var(--site-inset-borderColor); + height: calc(100% - 1lh); + top: .8lh; + } + + summary { + margin: 0; + font-weight: initial; + user-select: initial; + list-style: none; + position: relative; + + // Caret at the beginning of the folding range. + span.material-symbols { + position: absolute; + cursor: pointer; + z-index: var(--site-z-base); + left: var(--inset); + top: 50%; + font-size: 1em; + user-select: none; + translate: 0 -50%; + transition: transform 300ms ease; + transform: rotate(0); + transform-origin: center; + } + + &:hover>span { + color: initial; + } + } + + &:not([open])>summary .line { + >span { + opacity: 0.5; + } + + >span::after { + content: ' …'; + } + } + + &[open] { + &::before { + display: block; + } + + >summary { + cursor: auto; + + span.material-symbols { + transform: rotate(90deg); + } + } + } + + >* { + margin: 0; + } + } + } + + &.show-line-numbers.show-folding-ranges code { + --folding-inset: 2.5em; + + span.line { + &[data-line] { + &::before { + margin-right: 3rem; + } } } } @@ -178,24 +273,59 @@ pre { } .code-block-wrapper { + display: grid; + grid-template-rows: min-content 1fr; + grid-template-columns: 100%; + margin-block-start: 1rem; margin-block-end: 1rem; border: 1px solid var(--site-inset-borderColor); background-color: var(--site-inset-bgColor); + transition: grid-template-rows 0.3s ease, border-bottom-width 0.3s ease; + + &.collapsed { + grid-template-rows: min-content 0fr; + border-bottom-width: 0px; + + .collapse-button > .material-symbols { + transform: rotate(180deg); + } + } + + .collapse-button > .material-symbols { + transform: rotate(0deg); + transform-origin: center; + transition: transform 0.3s ease; + } + .code-block-header { + display: flex; + align-items: center; + background-color: var(--site-raised-bgColor); border-bottom: 1px solid var(--site-inset-borderColor); - font-size: 0.9375rem; - font-weight: 500; - overflow-x: hidden; - text-overflow: ellipsis; + padding: 0.75rem 0.5rem 0.67rem 1rem; + gap: 0.5rem; + + > span:first-child { + flex-grow: 1; + overflow-x: hidden; + text-overflow: ellipsis; + + font-size: 0.9375rem; + font-weight: 500; + } } .code-block-body { + flex-grow: 1; + position: relative; background: none; + min-height: 0; + overflow: hidden; .copy-button { position: absolute; @@ -230,6 +360,7 @@ pre { } pre { + height: 100%; margin: 0; padding-right: 0; padding-left: 0; @@ -260,8 +391,8 @@ iframe[src^="https://dartpad"] { --file-tree-text: var(--site-base-fgColor); --file-tree-icon: var(--site-base-fgColor-alt); --file-tree-highlight: var(--site-link-fgColor); - - font-family: var(--site-code-fontFamily); + + font-family: var(--site-code-fontFamily); border: 1px solid var(--site-inset-borderColor); margin-block-start: 1rem; @@ -276,3 +407,58 @@ iframe[src^="https://dartpad"] { } } } + +.code-preview { + display: flex; + flex-direction: column; + + border: 1px solid var(--site-inset-borderColor); + margin-block-start: 1rem; + margin-block-end: 1rem; + + .preview-area { + display: flex; + flex-flow: column nowrap; + justify-content: center; + align-items: center; + + padding: 2rem; + text-align: center; + + &.fixed-bg * { + // --site-base-fgColor-lighter, but fixed to light mode variant. + color: #{color.scale(#212121, $lightness: 20%)} + } + } + + .code-block-wrapper { + border: none; + margin: 0; + + border-top: inherit; + } + + &[data-direction="row"] { + flex-direction: row-reverse; + flex-wrap: wrap; + + >* { + width: 100%; + } + + @media (min-width: 760px) { + .preview-area { + flex: 0 0 42%; + max-width: 42%; + } + + .code-block-wrapper { + flex: 0 0 58%; + max-width: 58%; + + border-top: none; + border-right: inherit; + } + } + } +} diff --git a/site/lib/_sass/components/_content.scss b/site/lib/_sass/components/_content.scss index 891015e016..0800aeb3e3 100644 --- a/site/lib/_sass/components/_content.scss +++ b/site/lib/_sass/components/_content.scss @@ -53,10 +53,50 @@ main { } #site-content-title { - margin-block-end: 1rem; + position: relative; + font-family: var(--site-ui-fontFamily); + margin-block-end: 1.5rem; + scroll-margin-top: calc(var(--site-header-height) + var(--site-subheader-height) + 1.25rem); + + &.wrap { + display: flex; + flex-direction: column; + gap: 0.35rem; + + background-color: var(--site-raised-bgColor); + padding: 1rem; + border-radius: var(--site-radius); + } h1 { - margin-bottom: 0; + text-wrap: pretty; + padding-right: 1rem; + } + + .page-description { + margin-block: 0; + } + + #page-header-options { + position: absolute; + top: 0.75rem; + right: 0.75rem; + + > button { + color: var(--site-base-fgColor-alt); + + > span.material-symbols { + font-size: 1.5rem; + } + } + + .dropdown-content { + right: -0.5rem; + + background-color: var(--site-raised-bgColor); + border: var(--site-outline) 1px solid; + box-shadow: 0 .5rem 1rem rgba(0, 0, 0, .15); + } } } @@ -88,9 +128,9 @@ main { h1 { font-size: 2.75rem; + font-weight: 500; margin-top: 0; margin-bottom: 0; - scroll-margin-top: 8rem; } h2 { @@ -168,53 +208,6 @@ main { } } -nav.breadcrumbs { - align-items: center; - margin-block-end: 1rem; - - >ol { - border-radius: 0.375rem; - margin-block-start: 0; - padding: 0.375rem 0; - - align-items: center; - list-style: none; - - font-family: var(--site-ui-fontFamily); - - display: flex; - flex-wrap: wrap; - flex-direction: row; - - li.breadcrumb-item { - display: flex; - flex-direction: row; - align-items: center; - padding: 0; - - & a { - padding: 0.125rem; - border-radius: 0.125rem; - } - - &.active a { - color: var(--site-base-fgColor-alt); - cursor: default; - text-decoration: none; - } - - &:before { - display: none; - } - } - - .child-icon { - user-select: none; - color: var(--site-base-fgColor-lighter); - } - } -} - .full-width { width: 100%; } diff --git a/site/lib/_sass/components/_dropdown.scss b/site/lib/_sass/components/_dropdown.scss index c6efc6934b..d121e25261 100644 --- a/site/lib/_sass/components/_dropdown.scss +++ b/site/lib/_sass/components/_dropdown.scss @@ -12,6 +12,10 @@ border: var(--site-outline-variant) 1px solid; z-index: var(--site-z-dropdown); + .text-button { + color: var(--site-base-fgColor-lighter); + } + .dropdown-divider { background-color: var(--site-outline-variant); border-radius: 0.5rem; @@ -37,6 +41,7 @@ button { display: flex; align-items: center; + justify-content: flex-start; flex-direction: row; width: 100%; gap: 0.4rem; diff --git a/site/lib/_sass/components/_header.scss b/site/lib/_sass/components/_header.scss index 03b2866353..57528e26ad 100644 --- a/site/lib/_sass/components/_header.scss +++ b/site/lib/_sass/components/_header.scss @@ -7,8 +7,10 @@ border-bottom: 0.1rem solid var(--site-outline-variant); @media (min-width: 1200px) { - box-shadow: 0 2px 4px rgba(0, 0, 0, .05); - border-bottom: none; + &:not(:has(~* #site-subheader.show-always)) { + box-shadow: 0 2px 4px rgba(0, 0, 0, .05); + border-bottom: none; + } } .navbar { @@ -167,3 +169,28 @@ body.open_menu #menu-toggle span.material-symbols { } } } + +#site-subheader { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + + position: sticky; + top: var(--site-header-height); + z-index: var(--site-z-subheader); + height: var(--site-subheader-height); + + font-family: var(--site-ui-fontFamily); + font-size: 0.875rem; + + background-color: var(--site-base-bgColor); + 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; + } + } +} diff --git a/site/lib/_sass/components/_pagenav.scss b/site/lib/_sass/components/_pagenav.scss new file mode 100644 index 0000000000..f61d7ebf4e --- /dev/null +++ b/site/lib/_sass/components/_pagenav.scss @@ -0,0 +1,197 @@ +#pagenav { + flex-grow: 1; + min-width: 0; + max-width: 100%; + + >button.dropdown-button { + display: flex; + flex-direction: row; + align-items: center; + line-height: 1.25rem; + padding: .45rem .7rem; + width: 100%; + border-radius: 0; + margin: 2px; + + >span { + display: flex; + flex-direction: row; + align-items: center; + } + + .material-symbols { + user-select: none; + color: var(--site-base-fgColor-alt); + font-size: 20px; + } + + >.material-symbols:first-child { + margin-right: 0.25rem; + } + } + + .toc-breadcrumb { + flex-shrink: 2; + 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; + } + + .page-number { + flex-shrink: 0; + height: 1.3rem; + width: 1.3rem; + + margin-right: 0.4rem; + background-color: var(--site-primary-color); + color: var(--site-onPrimary-color-lightest); + } + } + + .toc-current { + flex-shrink: 1; + white-space: nowrap; + overflow: hidden; + + color: var(--site-base-fgColor-alt); + + span:last-child { + overflow: hidden; + text-overflow: ellipsis; + } + } + + #pagenav-content { + position: absolute; + box-shadow: 0 2px 4px rgba(0, 0, 0, .05); + border-bottom: 0.1rem solid var(--site-outline-variant); + border-radius: 0; + + top: var(--site-subheader-height); + left: 0; + max-height: calc(75vh - var(--site-header-height)); + min-width: 100%; + max-width: 100%; + + overflow-y: auto; + scrollbar-width: thin; + overscroll-behavior: contain; + + padding: 0.5rem; + + >div { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + @media (min-width: 420px) { + border: none; + border-radius: 0.4rem; + box-shadow: 0 6px 18px 0 rgba(0, 0, 0, 0.2); + + top: calc(var(--site-subheader-height) + .75rem); + left: 0.75rem; + + min-width: 18rem; + max-width: 24rem; + } + + a#return-to-top { + margin: 0.4rem 0; + padding: 0.1rem; + font-size: 1rem; + text-decoration: none; + display: flex; + align-items: center; + gap: 4px; + color: var(--site-base-fgColor-alt); + font-weight: 500; + + .material-symbols { + font-size: 1.5rem; + user-select: none; + } + + &:hover { + color: var(--site-link-fgColor); + } + + &:active { + color: var(--site-link-fgColor-active); + } + } + + 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/_platform-cards.scss b/site/lib/_sass/components/_platform-cards.scss new file mode 100644 index 0000000000..ba690e2c84 --- /dev/null +++ b/site/lib/_sass/components/_platform-cards.scss @@ -0,0 +1,86 @@ +.platforms-grid { + display: flex; + flex-flow: row wrap; + gap: 1rem; +} + +.platform-card { + flex: 1 0 400px; + min-width: 0; + + background-color: var(--site-raised-bgColor-translucent); + border-radius: var(--site-radius); + border: 1px solid var(--site-inset-borderColor); + + padding: 0.8rem 1rem; + + .platform-card-header { + display: flex; + align-items: center; + gap: 1rem; + + >span { + font-size: 0.875rem; + background-color: var(--site-raised-bgColor); + border-radius: 0.5rem; + padding: 0.5rem; + } + + h3 { + margin: 0; + flex: 1; + white-space: nowrap; + } + } + + .platform-card-tags { + display: flex; + flex-flow: row wrap; + align-items: center; + gap: 0.5rem; + + margin-top: 0.5rem; + + span { + font-size: 0.875rem; + line-height: 1; + + background-color: var(--site-raised-bgColor-translucent); + border-radius: var(--site-radius); + border: 1px solid var(--site-inset-borderColor); + + padding: 0.2rem 0.4rem; + } + } + + .platform-card-details { + display: flex; + flex-flow: row nowrap; + gap: 0.5rem; + + margin-top: 1rem; + + >span { + flex-grow: 1; + + display: flex; + flex-direction: column; + align-items: start; + gap: 0.1rem; + + >span:first-child { + font-size: 0.75rem; + font-weight: 500; + } + + .platform-card-supported, + .platform-card-ci-tested { + color: var(--site-alert-tip-color); + } + + .platform-card-unsupported { + color: var(--site-alert-error-color); + } + } + } +} diff --git a/site/lib/_sass/components/_side-menu.scss b/site/lib/_sass/components/_side-menu.scss index eba9b9a7b0..06cf7f2d81 100644 --- a/site/lib/_sass/components/_side-menu.scss +++ b/site/lib/_sass/components/_side-menu.scss @@ -1,4 +1,4 @@ -.styled-toc-list { +.toc-list { margin: 0; --toc-indent: 0; diff --git a/site/lib/_sass/components/_stepper.scss b/site/lib/_sass/components/_stepper.scss new file mode 100644 index 0000000000..41d14406f2 --- /dev/null +++ b/site/lib/_sass/components/_stepper.scss @@ -0,0 +1,94 @@ +.stepper { + + details { + position: relative; + margin: 0; + padding-bottom: 1px; + + // Vertical line between steps + &::before { + content: ''; + display: block; + position: absolute; + width: 2px; + height: 100%; + border-radius: 1px; + background: var(--site-inset-borderColor); + transform: translateY(2rem); + } + + &:last-child::before { + transform: none; + } + + &:last-child:not([open])::before { + display: none; + } + + summary { + position: relative; + display: flex; + align-items: center; + list-style: none; + + width: 100%; + padding-left: 1.5rem; + padding-block: 1rem; + + .step-number { + position: absolute; + left: -1rem; + width: 2rem; + height: 2rem; + border-radius: 50%; + background: var(--site-raised-bgColor); + color: var(--site-base-fgColor); + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + + transition: background-color 300ms ease, color 300ms ease; + } + + .step-title { + .header-wrapper, h1, h2, h3, h4, h5, h6 { + margin: 0; + } + } + + span.material-symbols { + position: absolute; + right: 0; + transition: transform 300ms ease; + transform: rotate(180deg); + transform-origin: center; + } + } + + &[open] summary { + .step-number { + background-color: var(--site-primary-color); + color: var(--site-onPrimary-color-lightest); + } + + span.material-symbols { + transform: rotate(0); + } + } + + &:not([open]):has(~[open]) summary .step-number { + background-color: var(--site-onPrimary-color-light); + color: var(--site-primary-color); + } + + .step-content { + margin-left: 1.5rem; + } + + .step-actions { + display: flex; + justify-content: flex-end; + } + } +} diff --git a/site/lib/_sass/components/_summary-card.scss b/site/lib/_sass/components/_summary-card.scss new file mode 100644 index 0000000000..0ff63af00b --- /dev/null +++ b/site/lib/_sass/components/_summary-card.scss @@ -0,0 +1,85 @@ +.summary-card { + background-color: var(--site-raised-bgColor-translucent); + border-radius: var(--site-radius); + border: 1px solid var(--site-inset-borderColor); + + header { + padding: 1rem 1.2rem 1.2rem; + display: flex; + align-items: center; + + >div { + flex: 1; + color: var(--site-base-fgColor-alt); + } + + h3 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--site-base-fgColor); + } + + .summary-card-completed { + color: var(--site-alert-tip-color); + } + } + + .summary-card-item { + display: flex; + align-items: center; + gap: 1rem; + + border-top: 1px solid var(--site-inset-borderColor); + padding: 0.8rem 1.2rem; + + >:first-child { + font-size: 0.875rem; + background-color: var(--site-primary-color-highlight); + color: var(--site-primary-color); + border-radius: 0.5rem; + + padding: 0.5rem; + } + + .summary-card-item-title { + flex: 1; + + font-size: 1rem; + font-weight: 500; + } + } + + details { + margin: 0; + + summary { + margin: 0; + cursor: pointer; + list-style: none; + + >.material-symbols { + transition: transform .25s ease-out; + transform: rotate(180deg); + transform-origin: center; + } + } + + &[open] { + summary>.material-symbols { + transform: rotate(0); + } + } + + .summary-card-item-details { + margin: 0; + border-top: 1px solid var(--site-inset-borderColor); + padding: .8rem 1.2rem; + color: var(--site-base-fgColor-alt); + + >:last-child { + margin-bottom: 0; + } + } + } +} diff --git a/site/lib/_sass/components/_tabs.scss b/site/lib/_sass/components/_tabs.scss index 88e0a49d10..17381350e7 100644 --- a/site/lib/_sass/components/_tabs.scss +++ b/site/lib/_sass/components/_tabs.scss @@ -76,11 +76,21 @@ ul.nav-tabs { border-bottom-right-radius: $wrapper-radius; ul { - padding-left: 1rem; + padding-left: 1.5rem; } - .tab-pane> :first-child { - margin-block-start: 0; + ol { + padding-left: 1.75rem; + } + + .tab-pane { + > :first-child { + margin-block-start: 0; + } + + > :last-child { + margin-block-end: 1rem; + } } } } diff --git a/site/lib/_sass/components/_toc.scss b/site/lib/_sass/components/_toc.scss deleted file mode 100644 index 8d183cf9b3..0000000000 --- a/site/lib/_sass/components/_toc.scss +++ /dev/null @@ -1,141 +0,0 @@ -#toc-top { - font-family: var(--site-ui-fontFamily); - - display: none; - flex-direction: row; - flex-wrap: wrap; - justify-content: space-between; - align-content: center; - height: var(--site-subheader-height); - - @media (min-width: 240px) { - display: flex; - } - - @media (min-width: 1200px) { - display: none; - } - - position: sticky; - top: var(--site-header-height); - - background-color: var(--site-base-bgColor); - border-bottom: 0.1rem solid var(--site-outline-variant); - box-shadow: 0 2px 4px rgba(0, 0, 0, .05); - - font-size: 0.875rem; - z-index: var(--site-z-subheader); - - >button.dropdown-button { - display: flex; - flex-direction: row; - align-items: center; - line-height: 1.25rem; - padding: .45rem .7rem; - width: 100%; - border-radius: 0; - margin: 2px; - - >span { - display: flex; - flex-direction: row; - align-items: center; - } - - .material-symbols { - user-select: none; - color: var(--site-base-fgColor-alt); - font-size: 20px; - } - } - - .toc-intro { - white-space: nowrap; - - .material-symbols { - margin-right: 0.25rem; - } - } - - .toc-current { - flex-wrap: nowrap; - white-space: nowrap; - overflow: hidden; - - display: none; - - @media (min-width: 320px) { - display: flex; - } - } - - #current-header { - color: var(--site-base-fgColor-alt); - - overflow: hidden; - text-overflow: ellipsis; - } - - .dropdown-content { - position: absolute; - box-shadow: 0 2px 4px rgba(0, 0, 0, .05); - border-bottom: 0.1rem solid var(--site-outline-variant); - border-radius: 0; - - top: var(--site-subheader-height); - left: 0; - max-height: calc(75vh - var(--site-header-height)); - min-width: 100%; - max-width: 100%; - - overflow-y: auto; - scrollbar-width: thin; - overscroll-behavior: contain; - - padding: 0.2rem 0.4rem; - - @media (min-width: 420px) { - border: none; - border-radius: 0.4rem; - box-shadow: 0 6px 18px 0 rgba(0, 0, 0, 0.2); - - top: calc(var(--site-subheader-height) + .75rem); - left: 0.75rem; - - min-width: 18rem; - max-width: 24rem; - } - - >a { - margin: 0.4rem 0; - padding: 0.1rem; - font-size: 1rem; - text-decoration: none; - display: flex; - align-items: center; - color: var(--site-base-fgColor-alt); - font-weight: 500; - - .material-symbols { - font-size: 1.5rem; - user-select: none; - } - - span:last-child { - margin-left: 3px; - } - - &:hover { - color: var(--site-link-fgColor); - } - - &:active { - color: var(--site-link-fgColor-active); - } - } - - >nav { - padding: 0.6rem 0 0.8rem; - } - } -} diff --git a/site/lib/_sass/components/_tooltip.scss b/site/lib/_sass/components/_tooltip.scss index ec160911dd..5584613fb1 100644 --- a/site/lib/_sass/components/_tooltip.scss +++ b/site/lib/_sass/components/_tooltip.scss @@ -1,7 +1,7 @@ .tooltip-wrapper { position: relative; - a.tooltip-target { + .tooltip-target a { color: inherit; text-decoration: underline; text-decoration-style: dotted; @@ -10,14 +10,13 @@ .tooltip { visibility: hidden; - display: flex; + display: block; position: absolute; z-index: var(--site-z-floating); top: 100%; left: 50%; transform: translateX(-50%); - flex-flow: column nowrap; width: 16rem; background: var(--site-raised-bgColor); @@ -26,20 +25,22 @@ box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, .15); padding: 0.8rem; - font-size: 1rem; + font-size: 0.875rem; font-weight: normal; font-style: normal; + .tooltip-content { + display: flex; + flex-flow: column nowrap; + color: var(--site-secondary-textColor); + } + .tooltip-header { font-size: 1.2rem; font-weight: 500; margin-bottom: 0.25rem; } - .tooltip-content { - font-size: 0.875rem; - color: var(--site-secondary-textColor); - } } // On non-touch devices, show tooltip on hover or focus. @@ -53,10 +54,10 @@ } } - // On touch devices, show tooltip on click (see global_scripts.dart). + // On touch devices, show tooltip on click. @media all and (pointer: coarse) { .tooltip.visible { visibility: visible; } } -} +} \ No newline at end of file diff --git a/site/lib/jaspr_options.dart b/site/lib/jaspr_options.dart index 175ad48d16..b61f98913b 100644 --- a/site/lib/jaspr_options.dart +++ b/site/lib/jaspr_options.dart @@ -7,37 +7,45 @@ import 'package:jaspr/jaspr.dart'; import 'package:docs_flutter_dev_site/src/client/global_scripts.dart' as prefix0; -import 'package:docs_flutter_dev_site/src/components/common/client/cookie_notice.dart' +import 'package:docs_flutter_dev_site/src/components/common/client/collapse_button.dart' as prefix1; import 'package:docs_flutter_dev_site/src/components/common/client/copy_button.dart' as prefix2; -import 'package:docs_flutter_dev_site/src/components/common/client/download_latest_button.dart' +import 'package:docs_flutter_dev_site/src/components/common/client/download_button.dart' as prefix3; -import 'package:docs_flutter_dev_site/src/components/common/client/feedback.dart' +import 'package:docs_flutter_dev_site/src/components/common/client/download_latest_button.dart' as prefix4; -import 'package:docs_flutter_dev_site/src/components/common/client/on_this_page_button.dart' +import 'package:docs_flutter_dev_site/src/components/common/client/feedback.dart' as prefix5; -import 'package:docs_flutter_dev_site/src/components/common/client/os_selector.dart' +import 'package:docs_flutter_dev_site/src/components/common/client/on_this_page_button.dart' as prefix6; -import 'package:docs_flutter_dev_site/src/components/dartpad/dartpad_injector.dart' +import 'package:docs_flutter_dev_site/src/components/common/client/os_selector.dart' as prefix7; -import 'package:docs_flutter_dev_site/src/components/layout/menu_toggle.dart' +import 'package:docs_flutter_dev_site/src/components/common/client/page_header_options.dart' as prefix8; -import 'package:docs_flutter_dev_site/src/components/layout/site_switcher.dart' +import 'package:docs_flutter_dev_site/src/components/common/client/simple_tooltip.dart' as prefix9; -import 'package:docs_flutter_dev_site/src/components/layout/theme_switcher.dart' +import 'package:docs_flutter_dev_site/src/components/dartpad/dartpad_injector.dart' as prefix10; -import 'package:docs_flutter_dev_site/src/components/pages/archive_table.dart' +import 'package:docs_flutter_dev_site/src/components/layout/client/pagenav.dart' as prefix11; -import 'package:docs_flutter_dev_site/src/components/pages/glossary_search_section.dart' +import 'package:docs_flutter_dev_site/src/components/layout/menu_toggle.dart' as prefix12; -import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters.dart' +import 'package:docs_flutter_dev_site/src/components/layout/site_switcher.dart' as prefix13; -import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters_sidebar.dart' +import 'package:docs_flutter_dev_site/src/components/layout/theme_switcher.dart' as prefix14; -import 'package:docs_flutter_dev_site/src/components/tutorial/client/quiz.dart' +import 'package:docs_flutter_dev_site/src/components/pages/archive_table.dart' as prefix15; -import 'package:jaspr_content/components/file_tree.dart' as prefix16; +import 'package:docs_flutter_dev_site/src/components/pages/glossary_search_section.dart' + as prefix16; +import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters.dart' + as prefix17; +import 'package:docs_flutter_dev_site/src/components/pages/learning_resource_filters_sidebar.dart' + as prefix18; +import 'package:docs_flutter_dev_site/src/components/tutorial/client/quiz.dart' + as prefix19; +import 'package:jaspr_content/components/file_tree.dart' as prefix20; /// Default [JasprOptions] for use with your jaspr project. /// @@ -61,8 +69,9 @@ JasprOptions get defaultJasprOptions => JasprOptions( 'src/client/global_scripts', ), - prefix1.CookieNotice: ClientTarget( - 'src/components/common/client/cookie_notice', + prefix1.CollapseButton: ClientTarget( + 'src/components/common/client/collapse_button', + params: _prefix1CollapseButton, ), prefix2.CopyButton: ClientTarget( @@ -70,92 +79,133 @@ JasprOptions get defaultJasprOptions => JasprOptions( params: _prefix2CopyButton, ), - prefix3.DownloadLatestButton: ClientTarget( + prefix3.DownloadButton: ClientTarget( + 'src/components/common/client/download_button', + params: _prefix3DownloadButton, + ), + + prefix4.DownloadLatestButton: ClientTarget( 'src/components/common/client/download_latest_button', - params: _prefix3DownloadLatestButton, + params: _prefix4DownloadLatestButton, ), - prefix4.FeedbackComponent: ClientTarget( + prefix5.FeedbackComponent: ClientTarget( 'src/components/common/client/feedback', - params: _prefix4FeedbackComponent, + params: _prefix5FeedbackComponent, ), - prefix5.OnThisPageButton: ClientTarget( + prefix6.OnThisPageButton: ClientTarget( 'src/components/common/client/on_this_page_button', ), - prefix6.OsSelector: ClientTarget( + prefix7.OsSelector: ClientTarget( 'src/components/common/client/os_selector', ), - prefix7.DartPadInjector: ClientTarget( + prefix8.PageHeaderOptions: ClientTarget( + 'src/components/common/client/page_header_options', + params: _prefix8PageHeaderOptions, + ), + + prefix9.SimpleTooltip: ClientTarget( + 'src/components/common/client/simple_tooltip', + params: _prefix9SimpleTooltip, + ), + + prefix10.DartPadInjector: ClientTarget( 'src/components/dartpad/dartpad_injector', - params: _prefix7DartPadInjector, + params: _prefix10DartPadInjector, ), - prefix8.MenuToggle: ClientTarget( + prefix11.PageNav: ClientTarget( + 'src/components/layout/client/pagenav', + params: _prefix11PageNav, + ), + + prefix12.MenuToggle: ClientTarget( 'src/components/layout/menu_toggle', ), - prefix9.SiteSwitcher: ClientTarget( + prefix13.SiteSwitcher: ClientTarget( 'src/components/layout/site_switcher', ), - prefix10.ThemeSwitcher: ClientTarget( + prefix14.ThemeSwitcher: ClientTarget( 'src/components/layout/theme_switcher', ), - prefix11.ArchiveTable: ClientTarget( + prefix15.ArchiveTable: ClientTarget( 'src/components/pages/archive_table', - params: _prefix11ArchiveTable, + params: _prefix15ArchiveTable, ), - prefix12.GlossarySearchSection: - ClientTarget( + prefix16.GlossarySearchSection: + ClientTarget( 'src/components/pages/glossary_search_section', ), - prefix13.LearningResourceFilters: - ClientTarget( + prefix17.LearningResourceFilters: + ClientTarget( 'src/components/pages/learning_resource_filters', ), - prefix14.LearningResourceFiltersSidebar: - ClientTarget( + prefix18.LearningResourceFiltersSidebar: + ClientTarget( 'src/components/pages/learning_resource_filters_sidebar', ), - prefix15.InteractiveQuiz: ClientTarget( + prefix19.InteractiveQuiz: ClientTarget( 'src/components/tutorial/client/quiz', - params: _prefix15InteractiveQuiz, + params: _prefix19InteractiveQuiz, ), }, - styles: () => [...prefix16.FileTree.styles], + styles: () => [...prefix20.FileTree.styles], ); +Map _prefix1CollapseButton(prefix1.CollapseButton c) => { + 'classes': c.classes, + 'title': c.title, +}; Map _prefix2CopyButton(prefix2.CopyButton c) => { - 'toCopy': c.toCopy, 'buttonText': c.buttonText, 'classes': c.classes, 'title': c.title, }; -Map _prefix3DownloadLatestButton( - prefix3.DownloadLatestButton c, +Map _prefix3DownloadButton(prefix3.DownloadButton c) => { + 'name': c.name, +}; +Map _prefix4DownloadLatestButton( + prefix4.DownloadLatestButton c, ) => {'os': c.os, 'arch': c.arch}; -Map _prefix4FeedbackComponent(prefix4.FeedbackComponent c) => { +Map _prefix5FeedbackComponent(prefix5.FeedbackComponent c) => { 'issueUrl': c.issueUrl, }; -Map _prefix7DartPadInjector(prefix7.DartPadInjector c) => { +Map _prefix8PageHeaderOptions(prefix8.PageHeaderOptions c) => { + 'title': c.title, + 'sourceUrl': c.sourceUrl, + 'issueUrl': c.issueUrl, +}; +Map _prefix9SimpleTooltip(prefix9.SimpleTooltip c) => { + 'target': c.target.toId(), + 'content': c.content.toId(), +}; +Map _prefix10DartPadInjector(prefix10.DartPadInjector c) => { 'title': c.title, 'theme': c.theme, 'height': c.height, 'runAutomatically': c.runAutomatically, }; -Map _prefix11ArchiveTable(prefix11.ArchiveTable c) => { +Map _prefix11PageNav(prefix11.PageNav c) => { + 'breadcrumbs': c.breadcrumbs, + 'pageNumber': c.pageNumber, + 'initialHeading': c.initialHeading, + 'content': c.content.toId(), +}; +Map _prefix15ArchiveTable(prefix15.ArchiveTable c) => { 'os': c.os, 'channel': c.channel, }; -Map _prefix15InteractiveQuiz(prefix15.InteractiveQuiz c) => { +Map _prefix19InteractiveQuiz(prefix19.InteractiveQuiz c) => { 'title': c.title, 'questions': c.questions.map((i) => i.toJson()).toList(), }; diff --git a/site/lib/main.dart b/site/lib/main.dart index af8dd804e0..86072a86dd 100644 --- a/site/lib/main.dart +++ b/site/lib/main.dart @@ -12,19 +12,29 @@ import 'jaspr_options.dart'; // Generated. Do not remove or edit. import 'src/components/common/card.dart'; import 'src/components/common/client/download_latest_button.dart'; import 'src/components/common/client/os_selector.dart'; +import 'src/components/common/code_preview.dart'; import 'src/components/common/dash_image.dart'; +import 'src/components/common/material_icon.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/platforms_grid.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'; @@ -36,7 +46,7 @@ void main() { // Initializes the server environment with the generated default options. Jaspr.initializeApp(options: defaultJasprOptions); - runApp(_docsFlutterDevSite); + runApp(ComponentRefScope(child: _docsFlutterDevSite)); } Component get _docsFlutterDevSite => ContentApp.custom( @@ -65,7 +75,11 @@ Component get _docsFlutterDevSite => ContentApp.custom( rawOutputPattern: _passThroughPattern, extensions: allNodeProcessingExtensions, components: _embeddableComponents, - layouts: const [DocLayout(), TocLayout(), CatalogPageLayout()], + layouts: const [ + DocLayout(), + TocLayout(), + TutorialLayout(), + ], theme: const ContentTheme.none(), secondaryOutputs: [ const RobotsTxtOutput(), @@ -96,10 +110,24 @@ final RegExp _passThroughPattern = RegExp(r'.*\.(txt|json|pdf)$'); List get _embeddableComponents => [ const DashTabs(), const DashImage(), + const CodePreview(), const YoutubeEmbed(), const FileTree(), const Quiz(), const ProgressRing(), + const SummaryCard(), + const DownloadableSnippet(), + const Stepper(), + const WidgetCatalogCategories(), + const TutorialOutline(), + const WidgetCatalogGrid(), + const ArchitectureRecommendations(), + const PlatformsGrid(), + const PlatformCard(), + CustomComponent( + pattern: RegExp('Icon', caseSensitive: false), + builder: (_, attrs, _) => MaterialIcon.fromAttributes(attrs), + ), 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 890d91caa2..18aa06909f 100644 --- a/site/lib/src/client/global_scripts.dart +++ b/site/lib/src/client/global_scripts.dart @@ -44,7 +44,7 @@ void _setUpSite() { _setUpExpandableCards(); _setUpPlatformKeys(); _setUpToc(); - _setUpTooltips(); + _setUpSteppers(); } void _setUpSearchKeybindings() { @@ -117,10 +117,16 @@ 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); - web.window.localStorage.setItem( - 'tab-save-$currentSaveKey', - currentSaveId, - ); + try { + web.window.localStorage.setItem( + 'tab-save-$currentSaveKey', + currentSaveId, + ); + } catch (e) { + if (kDebugMode) { + print('Error accessing localStorage: $e'); + } + } } else { _clearActiveTabs(tabs); _setActiveTab(tabElement); @@ -129,12 +135,19 @@ void _setUpTabs() { tabElement.addEventListener('click', handleClick.toJS); - // 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; + 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'); + } } } @@ -165,8 +178,14 @@ void _updateTabsFromQueryParameters() { for (final MapEntry(:key, :value) in originalQueryParameters.entries) { if (key.startsWith('tab-save-')) { - web.window.localStorage.setItem(key, value); - updatedQueryParameters.remove(key); + try { + web.window.localStorage.setItem(key, value); + updatedQueryParameters.remove(key); + } catch (e) { + if (kDebugMode) { + print('Error accessing localStorage: $e'); + } + } } } @@ -285,10 +304,10 @@ void _setUpExpandableCards() { }).toJS, ); - if (card.id != currentFragment) { - card.classList.add('collapsed'); - expandButton.ariaExpanded = 'false'; - } else { + // If the card is the currently specified fragment, expand it. + if (card.id == currentFragment) { + card.classList.remove('collapsed'); + expandButton.ariaExpanded = 'true'; targetCard = card; } } @@ -322,78 +341,19 @@ void _setUpPlatformKeys() { /// Enables a "back to top" button in the TOC header. void _setUpToc() { _setUpTocActiveObserver(); - _setUpInlineTocDropdown(); } -void _setUpInlineTocDropdown() { - final inlineToc = web.document.getElementById('toc-top'); - if (inlineToc == null) return; - - final dropdownButton = inlineToc.querySelector('.dropdown-button'); - final dropdownMenu = inlineToc.querySelector('.dropdown-content'); - if (dropdownButton == null || dropdownMenu == null) return; - - void closeMenu() { - inlineToc.setAttribute('data-expanded', 'false'); - dropdownButton.ariaExpanded = 'false'; - } - - dropdownButton.addEventListener( - 'click', - ((web.Event _) { - if (inlineToc.getAttribute('data-expanded') == 'true') { - closeMenu(); - } else { - inlineToc.setAttribute('data-expanded', 'true'); - dropdownButton.ariaExpanded = 'true'; - } - }).toJS, - ); - - web.document.addEventListener( - 'keydown', - ((web.KeyboardEvent event) { - if (event.key == 'Escape') { - closeMenu(); - } - }).toJS, - ); - - // Close the dropdown if any link in the TOC is navigated to. - final inlineTocLinks = inlineToc.querySelectorAll('a'); - for (var i = 0; i < inlineTocLinks.length; i++) { - final tocLink = inlineTocLinks.item(i) as web.Element; - tocLink.addEventListener( - 'click', - ((web.Event _) { - closeMenu(); - }).toJS, - ); - } - - // Close the dropdown if anywhere not in the inline TOC is clicked. - web.document.addEventListener( - 'click', - ((web.Event event) { - if ((event.target as web.Element).closest('#toc-top') != null) { - return; - } - closeMenu(); - }).toJS, - ); -} +final ValueNotifier currentPageHeading = ValueNotifier(null); void _setUpTocActiveObserver() { final headings = web.document.querySelectorAll( 'article .header-wrapper, #site-content-title', ); - final currentHeaderText = web.document.getElementById('current-header'); // No need to have toc scrollspy if there is only one non-title heading. - if (headings.length < 2 || currentHeaderText == null) return; + if (headings.length < 2) return; final visibleAnchors = {}; - final initialHeaderText = currentHeaderText.textContent; final observer = web.IntersectionObserver( ((JSArray entries) { @@ -414,12 +374,12 @@ void _setUpTocActiveObserver() { // If the page title is visible, set the current header to its contents. if (visibleAnchors.contains('document-title')) { - currentHeaderText.textContent = initialHeaderText; + currentPageHeading.value = null; isFirst = false; } final tocLinks = web.document.querySelectorAll( - '.site-toc .sidenav-item a', + '.toc-list .sidenav-item a', ); for (var i = 0; i < tocLinks.length; i++) { final tocLink = tocLinks.item(i) as web.Element; @@ -433,7 +393,7 @@ void _setUpTocActiveObserver() { sidenavItem.classList.add('active'); if (isFirst) { - currentHeaderText.textContent = tocLink.textContent; + currentPageHeading.value = tocLink.textContent!; isFirst = false; } } else { @@ -450,92 +410,64 @@ void _setUpTocActiveObserver() { } } -void _setUpTooltips() { - final tooltipWrappers = web.document.querySelectorAll('.tooltip-wrapper'); - - final isTouchscreen = web.window.matchMedia('(pointer: coarse)').matches; - - void setup({required bool setUpClickListener}) { - for (var i = 0; i < tooltipWrappers.length; i++) { - final linkWrapper = tooltipWrappers.item(i) as web.HTMLElement; - final target = linkWrapper.querySelector('.tooltip-target'); - final tooltip = linkWrapper.querySelector('.tooltip') as web.HTMLElement?; - - if (target == null || tooltip == null) { - continue; - } - _ensureVisible(tooltip); +void _setUpSteppers() { + final steppers = web.document.querySelectorAll('.stepper'); + + for (var i = 0; i < steppers.length; i++) { + final stepper = steppers.item(i) as web.HTMLElement; + final steps = stepper.querySelectorAll('details'); + + for (var j = 0; j < steps.length; j++) { + final step = steps.item(j) as web.HTMLDetailsElement; + + step.addEventListener( + 'toggle', + ((web.Event e) { + // Close all other steps when one is opened. + if (step.open) { + for (var k = 0; k < steps.length; k++) { + final otherStep = steps.item(k) as web.HTMLDetailsElement; + if (otherStep != step) { + otherStep.open = false; + } + } + } + }).toJS, + ); - if (setUpClickListener && isTouchscreen) { - // On touchscreen devices, toggle tooltip visibility on tap. - target.addEventListener( + final nextButton = step.querySelector('.next-step-button'); + if (nextButton != null) { + nextButton.addEventListener( 'click', ((web.Event e) { - final isVisible = tooltip.classList.contains('visible'); - if (!isVisible) { - tooltip.classList.add('visible'); - e.preventDefault(); + e.preventDefault(); + step.open = false; + _scrollTo(step, smooth: false); + if (j + 1 < steps.length) { + final nextStep = steps.item(j + 1) as web.HTMLDetailsElement; + nextStep.open = true; + _scrollTo(nextStep, smooth: true); } }).toJS, ); } } } - - void closeAll() { - final visibleTooltips = web.document.querySelectorAll( - '.tooltip.visible', - ); - for (var i = 0; i < visibleTooltips.length; i++) { - final tooltip = visibleTooltips.item(i) as web.HTMLElement; - tooltip.classList.remove('visible'); - } - } - - setup(setUpClickListener: true); - - // Reposition tooltips on window resize. - web.EventStreamProviders.resizeEvent.forTarget(web.window).listen((_) { - setup(setUpClickListener: false); - }); - - // Close tooltips when clicking outside of any tooltip wrapper. - web.EventStreamProviders.clickEvent.forTarget(web.document).listen((e) { - if ((e.target as web.Element).closest('.tooltip-wrapper') == null) { - closeAll(); - } - }); - - // On touchscreen devices, close tooltips when scrolling. - if (isTouchscreen) { - web.EventStreamProviders.scrollEvent.forTarget(web.window).listen((_) { - closeAll(); - }); - } } -/// Adjust the tooltip position to ensure it is fully inside the -/// ancestor .content element. -void _ensureVisible(web.HTMLElement tooltip) { - final containerRect = tooltip.closest('.content')?.getBoundingClientRect(); - final tooltipRect = tooltip.getBoundingClientRect(); - final offset = double.parse(tooltip.getAttribute('data-adjusted') ?? '0'); - - final tooltipLeft = tooltipRect.left - offset; - final tooltipRight = tooltipRect.right - offset; - final containerLeft = containerRect?.left ?? 0.0; - final containerRight = containerRect?.right ?? web.window.innerWidth; - - if (tooltipLeft < containerLeft) { - final offset = containerLeft - tooltipLeft; - tooltip.style.left = 'calc(50% + ${offset}px)'; - tooltip.dataset['adjusted'] = offset.toString(); - } else if (tooltipRight > containerRight) { - final offset = tooltipRight - containerRight; - tooltip.style.left = 'calc(50% - ${offset}px)'; - tooltip.dataset['adjusted'] = (-offset).toString(); - } else { - tooltip.style.left = '50%'; - tooltip.dataset['adjusted'] = '0'; - } +void _scrollTo(web.Element element, {required bool smooth}) { + // Scroll the next step into view, accounting for the fixed header and toc. + final headerOffset = + web.document.getElementById('site-header')?.clientHeight ?? 0; + final tocOffset = web.document.getElementById('toc-top')?.clientHeight ?? 0; + final elementPosition = element.getBoundingClientRect().top; + final offsetPosition = + elementPosition + web.window.scrollY - headerOffset - tocOffset; + + web.window.scrollTo( + web.ScrollToOptions( + top: offsetPosition, + behavior: smooth ? 'smooth' : 'auto', + ), + ); } diff --git a/site/lib/src/components/common/button.dart b/site/lib/src/components/common/button.dart index f6320f0730..1420f5bae8 100644 --- a/site/lib/src/components/common/button.dart +++ b/site/lib/src/components/common/button.dart @@ -94,3 +94,17 @@ enum ButtonStyle { ButtonStyle.text => 'text-button', }; } + +class SegmentedButton extends StatelessComponent { + const SegmentedButton({ + super.key, + required this.children, + }); + + final List children; + + @override + Component build(BuildContext context) { + return span(classes: ['segmented-button'].toClasses, children); + } +} diff --git a/site/lib/src/components/common/card.dart b/site/lib/src/components/common/card.dart index 91892ba115..3ba5276e49 100644 --- a/site/lib/src/components/common/card.dart +++ b/site/lib/src/components/common/card.dart @@ -62,7 +62,7 @@ class Card extends StatelessComponent { ], content: [?child], link: link, - filled: link != null, + filled: link != null && attributes['filled'] != 'false', outlined: outlined, ); } @@ -88,6 +88,7 @@ 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/collapse_button.dart b/site/lib/src/components/common/client/collapse_button.dart new file mode 100644 index 0000000000..e87e03d861 --- /dev/null +++ b/site/lib/src/components/common/client/collapse_button.dart @@ -0,0 +1,48 @@ +// Copyright 2025 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:jaspr/jaspr.dart'; + +import 'package:universal_web/web.dart' as web; + +import '../button.dart'; + +@client +class CollapseButton extends StatefulComponent { + const CollapseButton({ + this.classes = const [], + this.title, + }); + + final String? title; + final List classes; + + @override + State createState() => _CollapseButtonState(); +} + +class _CollapseButtonState extends State { + final buttonKey = GlobalNodeKey(); + bool _collapsed = true; + + void toggleCollapse() { + setState(() => _collapsed = !_collapsed); + + buttonKey.currentNode + ?.closest('.code-block-wrapper') + ?.classList + .toggle('collapsed'); + } + + @override + Component build(BuildContext _) { + return Button( + ref: buttonKey, + classes: ['collapse-button', ...component.classes], + title: component.title, + icon: 'keyboard_arrow_up', + onClick: toggleCollapse, + ); + } +} diff --git a/site/lib/src/components/common/client/cookie_notice.dart b/site/lib/src/components/common/client/cookie_notice.dart index d04fbadc74..7254ee452d 100644 --- a/site/lib/src/components/common/client/cookie_notice.dart +++ b/site/lib/src/components/common/client/cookie_notice.dart @@ -26,18 +26,28 @@ final class _CookieNoticeState extends State { void initState() { if (kIsWeb) { var shouldShowNotice = true; - 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; + 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; + } } } + } 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; @@ -69,10 +79,16 @@ final class _CookieNoticeState extends State { content: 'OK, got it', style: ButtonStyle.filled, onClick: () { - web.window.localStorage.setItem( - _cookieStorageKey, - DateTime.now().millisecondsSinceEpoch.toString(), - ); + try { + web.window.localStorage.setItem( + _cookieStorageKey, + DateTime.now().millisecondsSinceEpoch.toString(), + ); + } catch (e) { + if (kDebugMode) { + print('Failed to set stored consent: $e'); + } + } setState(() { showNotice = false; }); diff --git a/site/lib/src/components/common/client/copy_button.dart b/site/lib/src/components/common/client/copy_button.dart index 8fdaf2d306..e8b6416a46 100644 --- a/site/lib/src/components/common/client/copy_button.dart +++ b/site/lib/src/components/common/client/copy_button.dart @@ -11,13 +11,11 @@ import '../button.dart'; @client class CopyButton extends StatefulComponent { const CopyButton({ - required this.toCopy, this.buttonText, this.classes = const [], this.title, }); - final String toCopy; final String? title; final String? buttonText; final List classes; @@ -27,21 +25,47 @@ class CopyButton extends StatefulComponent { } class _CopyButtonState extends State { - bool _hidden = true; + final buttonKey = GlobalNodeKey(); + + String? content; bool _copied = false; + static final RegExp _terminalReplacementPattern = RegExp( + r'^(\s*\$\s*)|(PS\s+)?(C:\\(.*)>\s*)', + multiLine: true, + ); + @override void initState() { - // Unhide the copy button if successfully initialized on the client. - if (kIsWeb && component.toCopy.isNotEmpty) { - _hidden = false; + if (kIsWeb) { + // Extract the code content and unhide the copy button on the client. + context.binding.addPostFrameCallback(() { + setState(() { + content = buttonKey.currentNode + ?.closest('.code-block-wrapper') + ?.querySelector('pre code') + ?.textContent + ?.replaceAll(_terminalReplacementPattern, '') + .replaceAll('\u200B', ''); // Remove zero-width spaces + }); + + assert( + content != null, + 'CopyButton: Unable to find code content to copy. ' + 'Is the CopyButton inside a code block?', + ); + }); } super.initState(); } void _copy() { - web.window.navigator.clipboard.writeText(component.toCopy); + if (content == null) { + return; + } + + web.window.navigator.clipboard.writeText(content!); setState(() => _copied = true); @@ -57,13 +81,14 @@ class _CopyButtonState extends State { final iconButton = component.buttonText == null; return Button( + ref: buttonKey, style: iconButton ? ButtonStyle.text : ButtonStyle.filled, classes: [ 'copy-button', - if (_hidden) 'hidden', + if (content == null) 'hidden', ...component.classes, ], - title: component.title ?? 'Copy ${component.toCopy} to your clipboard.', + title: component.title ?? 'Copy $content to your clipboard.', content: _copied ? 'Copied!' : component.buttonText, icon: iconButton ? 'content_copy' : null, onClick: _copy, diff --git a/site/lib/src/components/common/client/download_button.dart b/site/lib/src/components/common/client/download_button.dart new file mode 100644 index 0000000000..f0be4b5a9f --- /dev/null +++ b/site/lib/src/components/common/client/download_button.dart @@ -0,0 +1,119 @@ +// Copyright 2025 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:jaspr/jaspr.dart'; +import 'package:universal_web/js_interop.dart'; +import 'package:universal_web/web.dart' as web; + +import '../button.dart'; + +@client +class DownloadButton extends StatefulComponent { + const DownloadButton({ + required this.name, + super.key, + }); + + final String name; + + @override + State createState() => _DownloadButtonState(); +} + +class _DownloadButtonState extends State { + final buttonKey = GlobalNodeKey(); + + Future saveAsFile() async { + final content = buttonKey.currentNode + ?.closest('.code-block-wrapper') + ?.querySelector('pre code') + ?.textContent + ?.replaceAll('\u200B', ''); // Remove zero-width spaces + + if (content == null) { + return; + } + + if (rawShowSaveFilePicker != null) { + try { + final file = await showSaveFilePicker( + SaveFilePickerOptions( + id: 'download-project-file', + startIn: 'documents', + suggestedName: component.name, + types: [ + FilePickerAcceptType( + description: 'Dart', + accept: { + 'text/plain': ['.dart'], + }.jsify(), + ), + ].toJS, + ), + ).toDart; + + final writable = await file.createWritable().toDart; + await writable.write(content.toJS).toDart; + await writable.close().toDart; + } catch (_) { + // User cancelled the picker + } + } else { + // Fallback for browsers that do not support the File System API + + final blob = web.Blob( + [content.toJS].toJS, + web.BlobPropertyBag(type: 'text/plain'), + ); + final objectUrl = web.URL.createObjectURL(blob); + + final anchor = web.document.createElement('a') as web.HTMLAnchorElement; + anchor.href = objectUrl; + anchor.download = component.name; + anchor.style.display = 'none'; + + web.document.body?.append(anchor); + anchor.click(); + + anchor.remove(); + web.URL.revokeObjectURL(objectUrl); + } + } + + @override + Component build(BuildContext context) { + return Button( + ref: buttonKey, + onClick: saveAsFile, + style: ButtonStyle.filled, + icon: 'download', + title: '下载文件', + content: '下载文件', + ); + } +} + +@JS('showSaveFilePicker') +external JSFunction? rawShowSaveFilePicker; + +@JS() +external JSPromise showSaveFilePicker( + SaveFilePickerOptions options, +); + +extension type SaveFilePickerOptions._(JSObject _) implements JSObject { + external factory SaveFilePickerOptions({ + String? id, + String? startIn, + String? suggestedName, + JSArray? types, + }); +} + +extension type FilePickerAcceptType._(JSObject _) implements JSObject { + external factory FilePickerAcceptType({ + required String description, + required JSAny? accept, + }); +} diff --git a/site/lib/src/components/common/client/page_header_options.dart b/site/lib/src/components/common/client/page_header_options.dart new file mode 100644 index 0000000000..192cc01b10 --- /dev/null +++ b/site/lib/src/components/common/client/page_header_options.dart @@ -0,0 +1,126 @@ +// Copyright 2025 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:jaspr/jaspr.dart'; +import 'package:universal_web/js_interop.dart'; +import 'package:universal_web/web.dart' as web; + +import '../button.dart'; +import '../dropdown.dart'; + +@client +final class PageHeaderOptions extends StatefulComponent { + const PageHeaderOptions({ + required this.title, + this.sourceUrl, + this.issueUrl, + super.key, + }); + + final String title; + final String? sourceUrl; + final String? issueUrl; + + @override + State createState() => _PageHeaderOptionsState(); +} + +final class _PageHeaderOptionsState extends State { + bool _isShareSupported = false; + + @override + void initState() { + super.initState(); + _checkShareSupport(); + } + + void _checkShareSupport() { + if (kIsWeb) { + try { + // Check if the share API is available. + _isShareSupported = web.window.navigator.canShare(_shareData); + } catch (_) { + _isShareSupported = false; + } + setState(() {}); + } + } + + String get _currentBaseUrl => + web.window.location.origin + web.window.location.pathname; + + web.ShareData get _shareData => web.ShareData( + url: _currentBaseUrl, + title: component.title, + ); + + @override + Component build(BuildContext _) => Dropdown( + id: 'page-header-options', + toggle: const Button(icon: 'more_vert', title: 'View page options.'), + content: nav( + classes: 'dropdown-menu', + attributes: { + 'role': 'menu', + }, + [ + ul( + [ + li( + [ + if (_isShareSupported) + Button( + icon: 'share', + content: '分享本页', + onClick: () { + web.window.navigator.share(_shareData).toDart.ignore(); + }, + ) + else + Button( + icon: 'copy', + content: '复制链接', + onClick: () { + web.window.navigator.clipboard + .writeText(_currentBaseUrl) + .toDart + .ignore(); + }, + ), + ], + ), + if (component.sourceUrl case final sourceUrl?) + li( + [ + Button( + icon: 'edit_document', + content: '查看文档源码', + href: sourceUrl, + attributes: const { + 'target': '_blank', + 'rel': 'noopener', + }, + ), + ], + ), + if (component.issueUrl case final issueUrl?) + li( + [ + Button( + icon: 'bug_report', + content: '为本页面内容提出建议', + href: issueUrl, + attributes: const { + 'target': '_blank', + 'rel': 'noopener', + }, + ), + ], + ), + ], + ), + ], + ), + ); +} diff --git a/site/lib/src/components/common/client/simple_tooltip.dart b/site/lib/src/components/common/client/simple_tooltip.dart new file mode 100644 index 0000000000..869527f5de --- /dev/null +++ b/site/lib/src/components/common/client/simple_tooltip.dart @@ -0,0 +1,28 @@ +// 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 '../../util/component_ref.dart'; +import '../tooltip.dart'; + +@client +class SimpleTooltip extends StatelessComponent { + const SimpleTooltip({ + required this.target, + required this.content, + super.key, + }); + + final ComponentRef target; + final ComponentRef content; + + @override + Component build(BuildContext context) { + return Tooltip( + target: target.component, + content: content.component, + ); + } +} diff --git a/site/lib/src/components/common/code_preview.dart b/site/lib/src/components/common/code_preview.dart new file mode 100644 index 0000000000..4823b0f17d --- /dev/null +++ b/site/lib/src/components/common/code_preview.dart @@ -0,0 +1,74 @@ +// 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 '../../util.dart'; +import 'wrapped_code_block.dart'; + +/// A component that displays a preview area alongside a code block. +/// +/// The `` component takes in standard markdown content, but +/// expects at least two children, with the last child being a code block. +/// Any content before the last child is treated as the preview area. +class CodePreview extends CustomComponent { + const CodePreview() : super.base(); + + @override + Component? create(Node node, NodesBuilder builder) { + if (node case ElementNode( + tag: 'CodePreview', + :final attributes, + :final children?, + )) { + if (children.length < 2) { + throw Exception('CodePreview requires at least two child elements.'); + } + final lastChild = children.last; + if (lastChild is! ComponentNode || + lastChild.component is! WrappedCodeBlock) { + throw Exception( + 'The last child of CodePreview must be a code block.', + ); + } + + final previewChildren = []; + + for (var i = 0; i < children.length - 1; i++) { + if (children[i] case ElementNode(tag: 'p', :final children?)) { + // Unwrap paragraph nodes to avoid extra spacing. + previewChildren.addAll(children); + } else { + previewChildren.add(children[i]); + } + } + + final direction = attributes['direction'] ?? 'column'; + final previewColor = attributes['previewcolor']; + + return div( + classes: 'code-preview', + attributes: {'data-direction': direction}, + [ + div( + classes: [ + 'preview-area', + if (previewColor != null) 'fixed-bg', + ].toClasses, + styles: previewColor != null + ? Styles(backgroundColor: Color(previewColor)) + : null, + [ + builder.build(previewChildren), + ], + ), + lastChild.component, + ], + ); + } + + return null; + } +} diff --git a/site/lib/src/components/common/dropdown.dart b/site/lib/src/components/common/dropdown.dart index f5ade0574c..ad301c90cc 100644 --- a/site/lib/src/components/common/dropdown.dart +++ b/site/lib/src/components/common/dropdown.dart @@ -8,21 +8,24 @@ import 'package:universal_web/web.dart' as web; import '../util/global_event_listener.dart'; -/// The root component of a dropdown in a client component. -/// -/// Should include a [DropdownToggle] and [DropdownContent] -/// as children. +/// A dropdown with a toggle button and expandable content. final class Dropdown extends StatefulComponent { - const Dropdown({required this.id, required this.children}); + const Dropdown({ + required this.id, + required this.toggle, + required this.content, + super.key, + }); final String id; - final List children; + final Component toggle; + final Component content; @override - State createState() => _DropdownState(); + State createState() => DropdownState(); } -final class _DropdownState extends State { +final class DropdownState extends State { bool _expanded = false; void toggle({bool? to}) { @@ -41,93 +44,45 @@ final class _DropdownState extends State { toggle(to: false); } }, - _DropdownRoot( + div( id: component.id, - expanded: _expanded, - toggle: toggle, - child: div( - id: component.id, - classes: 'dropdown', - attributes: {'data-expanded': _expanded.toString()}, - events: { - 'keydown': (e) { - final keydownEvent = e as web.KeyboardEvent; - if (_expanded && keydownEvent.key == 'Escape') { - toggle(to: false); - } + classes: 'dropdown', + attributes: {'data-expanded': _expanded.toString()}, + events: { + 'keydown': (e) { + final keydownEvent = e as web.KeyboardEvent; + if (_expanded && keydownEvent.key == 'Escape') { + toggle(to: false); + } + }, + 'focusout': (e) { + final relatedTarget = + (e as web.FocusEvent).relatedTarget as web.HTMLElement?; + if (relatedTarget == null || + relatedTarget.closest('#${component.id}') == null) { + toggle(to: false); + } + }, + }, + [ + Component.wrapElement( + classes: 'dropdown-button', + events: { + 'click': (e) { + toggle(); + }, }, - 'focusout': (e) { - final relatedTarget = - (e as web.FocusEvent).relatedTarget as web.HTMLElement?; - if (relatedTarget != null && - relatedTarget.closest('#${component.id}') == null) { - toggle(to: false); - } + attributes: { + 'aria-controls': '${component.id}-content', + 'aria-expanded': _expanded.toString(), }, - }, - component.children, - ), + child: component.toggle, + ), + div(id: '${component.id}-content', classes: 'dropdown-content', [ + component.content, + ]), + ], ), ); } } - -final class DropdownToggle extends StatelessComponent { - const DropdownToggle(this.child); - - final Component child; - - @override - Component build(BuildContext context) { - final root = _DropdownRoot.of(context); - - return Component.wrapElement( - child: child, - classes: 'dropdown-button', - events: { - 'click': (e) { - root.toggle(); - }, - }, - attributes: { - 'aria-controls': root.contentId, - 'aria-expanded': root.expanded.toString(), - }, - ); - } -} - -final class DropdownContent extends StatelessComponent { - const DropdownContent(this.child); - - final Component child; - - @override - Component build(BuildContext context) => div( - id: _DropdownRoot.of(context).contentId, - classes: 'dropdown-content', - [child], - ); -} - -final class _DropdownRoot extends InheritedComponent { - const _DropdownRoot({ - required this.id, - required this.toggle, - this.expanded = false, - required super.child, - }); - - final String id; - final bool expanded; - final void Function({bool? to}) toggle; - - String get contentId => '$id-content'; - - @override - bool updateShouldNotify(_DropdownRoot oldRoot) => - expanded != oldRoot.expanded; - - static _DropdownRoot of(BuildContext context) => - context.dependOnInheritedComponentOfExactType<_DropdownRoot>()!; -} diff --git a/site/lib/src/components/common/material_icon.dart b/site/lib/src/components/common/material_icon.dart index c9a87f50bc..1d8f23e1de 100644 --- a/site/lib/src/components/common/material_icon.dart +++ b/site/lib/src/components/common/material_icon.dart @@ -12,27 +12,61 @@ class MaterialIcon extends StatelessComponent { this.id, { this.title, this.label, + this.size, + this.filled = false, this.classes = const [], }); + /// Creates a [MaterialIcon] from a set of attributes parsed from Markdown. + factory MaterialIcon.fromAttributes( + Map attributes, + ) { + final id = + attributes['id'] ?? + (throw ArgumentError( + 'The "id" attribute is required for the Icon component.', + )); + final title = attributes['title']; + final label = attributes['label']; + final size = attributes['size']; + final filled = attributes['filled'] == 'true'; + final classes = attributes['class']?.split(' ') ?? const []; + + return MaterialIcon( + id, + title: title, + label: label, + size: size, + filled: filled, + classes: classes, + ); + } + final String id; - final List classes; final String? title; final String? label; + /// Optional custom size for the icon in a CSS unit, such as '1.25rem'; + final String? size; + final bool filled; + final List classes; + @override - Component build(BuildContext _) { - return span( - classes: ['material-symbols', ...classes].toClasses, - attributes: { - 'title': ?title, - if (label ?? title case final labelToUse?) - 'aria-label': labelToUse - else - 'aria-hidden': 'true', - 'translate': 'no', - }, - [text(id)], - ); - } + Component build(BuildContext _) => span( + classes: [ + 'material-symbols', + if (filled) 'ms-filled', + ...classes, + ].toClasses, + attributes: { + 'title': ?title, + if (label ?? title case final labelToUse?) + 'aria-label': labelToUse + else + 'aria-hidden': 'true', + 'translate': 'no', + if (size case final sizeToUse?) 'style': 'font-size: $sizeToUse;', + }, + [text(id)], + ); } diff --git a/site/lib/src/components/common/page_header.dart b/site/lib/src/components/common/page_header.dart new file mode 100644 index 0000000000..c9f319adc5 --- /dev/null +++ b/site/lib/src/components/common/page_header.dart @@ -0,0 +1,55 @@ +// 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 '../../utils/page_source_info.dart'; +import 'breadcrumbs.dart'; +import 'client/page_header_options.dart'; + +final class PageHeader extends StatelessComponent { + const PageHeader({ + super.key, + required this.title, + this.description, + this.wrap = true, + this.showBreadcrumbs = true, + }); + + final String title; + final String? description; + final bool wrap; + final bool showBreadcrumbs; + + @override + Component build(BuildContext context) { + final sourceInfo = context.page.sourceInfo; + + return header( + id: 'site-content-title', + classes: [if (wrap) 'wrap'].toClasses, + [ + if (showBreadcrumbs) const PageBreadcrumbs(), + h1(id: 'document-title', [ + DashMarkdown(content: title, inline: true), + ]), + if (description case final description? when description.isNotEmpty) + p( + classes: ['page-description'].toClasses, + [ + text(description), + ], + ), + PageHeaderOptions( + title: title, + sourceUrl: sourceInfo.sourceUrl, + issueUrl: sourceInfo.issueUrl, + ), + ], + ); + } +} diff --git a/site/lib/src/components/common/prev_next.dart b/site/lib/src/components/common/prev_next.dart index e68d390562..4cbb019fbc 100644 --- a/site/lib/src/components/common/prev_next.dart +++ b/site/lib/src/components/common/prev_next.dart @@ -4,6 +4,8 @@ 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 @@ -11,8 +13,8 @@ import 'material_icon.dart'; class PrevNext extends StatelessComponent { const PrevNext({super.key, this.previousPage, this.nextPage}); - final ({String url, String title})? previousPage; - final ({String url, String title})? nextPage; + final PageNavigationEntry? previousPage; + final PageNavigationEntry? nextPage; @override Component build(BuildContext context) { @@ -32,7 +34,7 @@ class PrevNext extends StatelessComponent { class _PrevNextCard extends StatelessComponent { const _PrevNextCard({required this.page, required this.isPrevious}); - final ({String url, String title}) page; + final PageNavigationEntry page; final bool isPrevious; @override @@ -49,7 +51,9 @@ class _PrevNextCard extends StatelessComponent { attributes: {'aria-label': ariaLabel}, [text(subtitle)], ), - span(classes: 'prev-next-title', [text(page.title)]), + span(classes: 'prev-next-title', [ + DashMarkdown(inline: true, content: page.title), + ]), ]), if (!isPrevious) const MaterialIcon('chevron_right'), ]); diff --git a/site/lib/src/components/common/tooltip.dart b/site/lib/src/components/common/tooltip.dart new file mode 100644 index 0000000000..630ed3b433 --- /dev/null +++ b/site/lib/src/components/common/tooltip.dart @@ -0,0 +1,149 @@ +// Copyright 2025 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:jaspr/jaspr.dart'; +import 'package:universal_web/web.dart' as web; + +import '../../util.dart'; +import '../util/global_event_listener.dart'; + +class Tooltip extends StatefulComponent { + const Tooltip({ + required this.target, + required this.content, + super.key, + }); + + final Component target; + final Component? content; + + @override + State createState() => _TooltipState(); +} + +class _TooltipState extends State { + static final isTouchscreen = + kIsWeb && web.window.matchMedia('(pointer: coarse)').matches; + + final wrapperKey = GlobalNodeKey(); + final targetKey = GlobalNodeKey(); + final tooltipKey = GlobalNodeKey(); + + bool isVisible = false; + double tooltipOffset = 0.0; + + @override + void initState() { + super.initState(); + + if (kIsWeb) { + setupTooltip(); + } + } + + void setupTooltip() { + context.binding.addPostFrameCallback(ensureVisible); + + // Reposition tooltips on window resize. + web.EventStreamProviders.resizeEvent.forTarget(web.window).listen((_) { + ensureVisible(); + }); + } + + /// Adjust the tooltip position to ensure it is fully inside the + /// ancestor .content element. + void ensureVisible() { + final target = targetKey.currentNode; + final tooltip = tooltipKey.currentNode; + if (tooltip == null || target == null) return; + + setState(() { + tooltipOffset = calculateTooltipOffset(target, tooltip); + }); + } + + @override + Component build(BuildContext context) { + return span( + key: wrapperKey, + classes: 'tooltip-wrapper', + [ + span( + key: targetKey, + classes: 'tooltip-target', + events: { + if (isTouchscreen) + 'click': (e) { + if (!isVisible) { + setState(() => isVisible = true); + e.preventDefault(); + } + }, + }, + [component.target], + ), + if (component.content case final content?) + GlobalEventListener( + // Close tooltip when clicking outside of this wrapper. + onClick: isTouchscreen + ? (e) { + if (wrapperKey.currentNode?.contains( + e.target as web.Node?, + ) == + true) { + return; + } + setState(() => isVisible = false); + } + : null, + // On touchscreen devices, close tooltips when scrolling. + onScroll: isTouchscreen + ? (_) { + setState(() => isVisible = false); + } + : null, + span( + key: tooltipKey, + classes: ['tooltip', if (isVisible) 'visible'].toClasses, + styles: Styles( + raw: { + 'left': tooltipOffset == 0 + ? '50%' + : tooltipOffset > 0 + ? 'calc(50% + ${tooltipOffset}px)' + : 'calc(50% - ${tooltipOffset.abs()}px)', + }, + ), + [ + content, + ], + ), + ), + ], + ); + } +} + +double calculateTooltipOffset(web.HTMLElement target, web.HTMLElement tooltip) { + final targetRect = target.getBoundingClientRect(); + final tooltipRect = tooltip.getBoundingClientRect(); + final containerRect = tooltip.closest('.content')?.getBoundingClientRect(); + + final targetCenter = targetRect.left + (targetRect.width / 2); + final tooltipWidth = tooltipRect.width; + + final initialLeft = targetCenter - (tooltipWidth / 2); + final initialRight = targetCenter + (tooltipWidth / 2); + + final containerLeft = containerRect?.left ?? 0.0; + final containerRight = containerRect?.right ?? web.window.innerWidth; + + if (initialLeft < containerLeft) { + return containerLeft - initialLeft; + } else if (initialRight > containerRight) { + return containerRight - initialRight; + } else { + return 0; + } +} diff --git a/site/lib/src/components/common/wrapped_code_block.dart b/site/lib/src/components/common/wrapped_code_block.dart index af9ed23dc7..b1f95b9137 100644 --- a/site/lib/src/components/common/wrapped_code_block.dart +++ b/site/lib/src/components/common/wrapped_code_block.dart @@ -5,7 +5,9 @@ import 'package:jaspr/jaspr.dart'; import '../../util.dart'; +import 'client/collapse_button.dart'; import 'client/copy_button.dart'; +import 'material_icon.dart'; /// A rendered code block with support for syntax highlighting, /// line highlighting, filenames, language specifying, @@ -19,15 +21,17 @@ final class WrappedCodeBlock extends StatelessComponent { this.highlightLines = const {}, this.addedLines = const {}, this.removedLines = const {}, + this.foldingRanges = const [], this.languagesToHide = const {'plaintext', 'console'}, this.tag, this.initialLineNumber = 1, this.showLineNumbers = false, - this.textToCopy, + this.showCopyButton = true, + this.collapsed = false, + this.actions = const [], }); final List> content; - final String? textToCopy; final String language; final String? title; @@ -35,22 +39,86 @@ final class WrappedCodeBlock extends StatelessComponent { final Set highlightLines; final Set addedLines; final Set removedLines; + final List foldingRanges; final Set languagesToHide; final CodeBlockTag? tag; final int initialLineNumber; final bool showLineNumbers; + final bool showCopyButton; + final bool collapsed; + final List actions; @override Component build(BuildContext context) { + final children = { + for (var lineIndex = 0; lineIndex < content.length; lineIndex += 1) + lineIndex: span( + classes: [ + 'line', + if (highlightLines.contains(lineIndex + 1)) 'highlighted-line', + if (removedLines.contains(lineIndex + 1)) 'removed-line', + if (addedLines.contains(lineIndex + 1)) 'added-line', + ].toClasses, + attributes: { + if (showLineNumbers) + 'data-line': '${initialLineNumber + lineIndex}', + }, + [ + switch (content[lineIndex]) { + // Add a zero-width space when empty + // so that the line isn't collapsed to 0 height. + final line when line.isEmpty => span( + styles: const Styles( + userSelect: UserSelect.none, + ), + [text('\u200b')], + ), + final lineSpans => span(lineSpans), + }, + text('\n'), + ], + ), + }; + + if (foldingRanges.isNotEmpty) { + for (final (:start, :end, :level, :open) in foldingRanges) { + final foldingSummary = children.remove(start - 1); + final foldedChildren = [ + for (var i = start + 1; i <= end; i += 1) ?children.remove(i - 1), + ]; + + children[start - 1] = details( + open: open, + styles: Styles(raw: {'--level': '$level'}), + [ + summary( + classes: 'fold-summary', + [ + const MaterialIcon('keyboard_arrow_right'), + foldingSummary ?? text('...'), + ], + ), + ...foldedChildren, + ], + ); + } + } + return div( - classes: 'code-block-wrapper language-$language', + classes: [ + 'code-block-wrapper language-$language', + if (collapsed) 'collapsed', + ].toClasses, [ if (title case final title?) - div( - classes: 'code-block-header', - [text(title)], - ), + div(classes: 'code-block-header', [ + span([ + text(title), + ]), + if (actions.isNotEmpty) ...actions, + if (collapsed) const CollapseButton(), + ]), div( classes: [ 'code-block-body', @@ -71,52 +139,18 @@ final class WrappedCodeBlock extends StatelessComponent { pre( classes: [ if (showLineNumbers) 'show-line-numbers', + if (foldingRanges.isNotEmpty) 'show-folding-ranges', 'opal', ].toClasses, attributes: {'tabindex': '0'}, [ - code( - [ - for ( - var lineIndex = 0; - lineIndex < content.length; - lineIndex += 1 - ) - span( - classes: [ - 'line', - if (highlightLines.contains(lineIndex + 1)) - 'highlighted-line', - if (removedLines.contains(lineIndex + 1)) - 'removed-line', - if (addedLines.contains(lineIndex + 1)) 'added-line', - ].toClasses, - attributes: { - if (showLineNumbers) - 'data-line': '${initialLineNumber + lineIndex}', - }, - [ - switch (content[lineIndex]) { - // Add a zero-width space when empty - // so that the line isn't collapsed to 0 height. - final line when line.isEmpty => span( - styles: const Styles( - userSelect: UserSelect.none, - ), - [text('\u200b')], - ), - final lineSpans => span(lineSpans), - }, - text('\n'), - ], - ), - ], - ), + code([ + for (var i = 0; i < content.length; i += 1) ?children[i], + ]), ], ), - if (textToCopy case final textToCopy?) - CopyButton( - toCopy: textToCopy, + if (showCopyButton) + const CopyButton( title: 'Copy code to clipboard', ), ], @@ -151,3 +185,5 @@ enum CodeBlockTag { _ => throw ArgumentError('Unknown tag for code blocks: $tag'), }; } + +typedef FoldingRange = ({int start, int end, int level, bool open}); diff --git a/site/lib/src/components/dartpad/dartpad_injector.dart b/site/lib/src/components/dartpad/dartpad_injector.dart index f9545c4dae..d36224ce8c 100644 --- a/site/lib/src/components/dartpad/dartpad_injector.dart +++ b/site/lib/src/components/dartpad/dartpad_injector.dart @@ -3,10 +3,9 @@ // found in the LICENSE file. import 'package:jaspr/jaspr.dart'; +import '../util/retake_element.dart'; import 'embedded_dartpad.dart'; -import 'extract_content.dart' if (dart.library.io) 'extract_content_vm.dart'; - /// Prepares a code block that will be replaced with an embedded /// DartPad when the site is loaded. final class DartPadWrapper extends StatefulComponent { @@ -79,7 +78,16 @@ class _DartPadInjectorState extends State { if (kIsWeb) { // During hydration, extract the content from the pre-rendered code block. - content = extractContent(context as Element); + final elem = retakeElement(context, (elem) { + return elem.tagName.toLowerCase() == 'pre'; + }); + + if (elem == null) { + content = ''; + } else { + elem.parentNode?.removeChild(elem); + content = elem.textContent ?? ''; + } } } diff --git a/site/lib/src/components/dartpad/extract_content.dart b/site/lib/src/components/dartpad/extract_content.dart deleted file mode 100644 index 433eab54e3..0000000000 --- a/site/lib/src/components/dartpad/extract_content.dart +++ /dev/null @@ -1,25 +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 'dart:js_interop'; - -import 'package:jaspr/browser.dart'; -import 'package:universal_web/web.dart' as web; - -/// Extracts the content of a
 block inside the given
-/// [element] during hydration.
-String extractContent(Element element) {
-  final r = element.parentRenderObjectElement?.renderObject as DomRenderObject?;
-  if (r == null) return '';
-
-  final code = r.retakeNode((node) {
-    return node.instanceOfString('Element') &&
-        (node as web.Element).tagName.toLowerCase() == 'pre';
-  });
-
-  if (code == null) return '';
-
-  code.parentNode?.removeChild(code);
-  return (code as web.Element).textContent ?? '';
-}
diff --git a/site/lib/src/components/layout/client/pagenav.dart b/site/lib/src/components/layout/client/pagenav.dart
new file mode 100644
index 0000000000..889938b4cf
--- /dev/null
+++ b/site/lib/src/components/layout/client/pagenav.dart
@@ -0,0 +1,147 @@
+// Copyright 2025 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:jaspr/jaspr.dart';
+import 'package:universal_web/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';
+
+@client
+class PageNav extends StatefulComponent {
+  const PageNav({
+    this.breadcrumbs = const [],
+    this.pageNumber,
+    required this.initialHeading,
+    required this.content,
+    super.key,
+  });
+
+  final List breadcrumbs;
+  final int? pageNumber;
+  final String initialHeading;
+  final ComponentRef content;
+
+  @override
+  State createState() => _PageNavState();
+}
+
+class _PageNavState extends State {
+  final dropdownKey = GlobalStateKey();
+
+  @override
+  void initState() {
+    super.initState();
+
+    if (kIsWeb) {
+      context.binding.addPostFrameCallback(() {
+        // Close the dropdown if any link in the TOC is navigated to.
+        final inlineTocLinks = web.document.querySelectorAll(
+          '#pagenav-content a',
+        );
+        for (var i = 0; i < inlineTocLinks.length; i++) {
+          final tocLink = inlineTocLinks.item(i) as web.Element;
+          tocLink.addEventListener(
+            'click',
+            ((web.Event _) {
+              dropdownKey.currentState?.toggle(to: false);
+            }).toJS,
+          );
+        }
+      });
+    }
+  }
+
+  @override
+  Component build(BuildContext context) {
+    return Dropdown(
+      key: dropdownKey,
+      id: 'pagenav',
+      toggle: button(
+        attributes: {
+          'title': '切换目录下拉菜单',
+          'aria-label': '切换目录下拉菜单',
+        },
+        [
+          const MaterialIcon('list'),
+          if (component.breadcrumbs.isEmpty)
+            span(classes: 'toc-breadcrumb', [
+              span(
+                attributes: {'aria-label': '本页目录'},
+                [text('本页目录')],
+              ),
+              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-current', [
+            ValueListenableBuilder(
+              listenable: currentPageHeading,
+              builder: (context, value) {
+                return span([text(value ?? component.initialHeading)]);
+              },
+            ),
+          ]),
+        ],
+      ),
+      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 f90fc38e69..651ee2ceb9 100644
--- a/site/lib/src/components/layout/header.dart
+++ b/site/lib/src/components/layout/header.dart
@@ -3,6 +3,7 @@
 // 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';
@@ -84,7 +85,7 @@ class DashHeader extends StatelessComponent {
                 content: '开始使用',
                 href: '/get-started/quick',
               ),
-              const MenuToggle(),
+              if (context.page.data['sidenav'] != null) const MenuToggle(),
             ],
           ),
         ]),
diff --git a/site/lib/src/components/layout/site_switcher.dart b/site/lib/src/components/layout/site_switcher.dart
index 9a645a68ad..63e4f693ff 100644
--- a/site/lib/src/components/layout/site_switcher.dart
+++ b/site/lib/src/components/layout/site_switcher.dart
@@ -13,66 +13,62 @@ final class SiteSwitcher extends StatelessComponent {
   const SiteSwitcher();
 
   @override
-  Component build(BuildContext _) => Dropdown(
-    id: 'site-switcher',
-    children: [
-      const DropdownToggle(Button(icon: 'apps', title: 'Visit related sites.')),
-      DropdownContent(
-        nav(
-          classes: 'dropdown-menu',
-          attributes: {
-            'role': 'menu',
-          },
-          [
-            ul(
-              const [
-                _SiteWordMarkListEntry(
-                  name: 'Flutter',
-                  href: 'https://flutter.cn',
-                ),
-                _SiteWordMarkListEntry(
-                  name: 'Flutter',
-                  subtype: 'Docs',
-                  href: '/',
-                  current: true,
-                ),
-                _SiteWordMarkListEntry(
-                  name: 'Flutter',
-                  subtype: 'API',
-                  href: 'https://api.flutter-io.cn',
-                ),
-                _SiteWordMarkListEntry(
-                  name: 'Flutter',
-                  subtype: 'Blog',
-                  href: 'https://blog.flutter.dev',
-                ),
-                Component.element(
-                  tag: 'li',
-                  classes: 'dropdown-divider',
-                  attributes: {'aria-hidden': 'true', 'role': 'separator'},
-                ),
-                _SiteWordMarkListEntry(
-                  name: 'Dart',
-                  href: 'https://dart.cn',
-                  dart: true,
-                ),
-                _SiteWordMarkListEntry(
-                  name: 'DartPad',
-                  href: 'https://dartpad.cn',
-                  dart: true,
-                ),
-                _SiteWordMarkListEntry(
-                  name: 'pub.dev',
-                  href: 'https://pub-web.flutter-io.cn',
-                  dart: true,
-                ),
-              ],
-            ),
-          ],
-        ),
+  Component build(BuildContext _) {
+    return Dropdown(
+      id: 'site-switcher',
+      toggle: const Button(icon: 'apps', title: 'Visit related sites.'),
+      content: nav(
+        classes: 'dropdown-menu',
+        attributes: {'role': 'menu'},
+        [
+          ul(
+            const [
+              _SiteWordMarkListEntry(
+                name: 'Flutter',
+                href: 'https://flutter.cn',
+              ),
+              _SiteWordMarkListEntry(
+                name: 'Flutter',
+                subtype: 'Docs',
+                href: '/',
+                current: true,
+              ),
+              _SiteWordMarkListEntry(
+                name: 'Flutter',
+                subtype: 'API',
+                href: 'https://api.flutter-io.cn',
+              ),
+              _SiteWordMarkListEntry(
+                name: 'Flutter',
+                subtype: 'Blog',
+                href: 'https://blog.flutter.dev',
+              ),
+              Component.element(
+                tag: 'li',
+                classes: 'dropdown-divider',
+                attributes: {'aria-hidden': 'true', 'role': 'separator'},
+              ),
+              _SiteWordMarkListEntry(
+                name: 'Dart',
+                href: 'https://dart.cn',
+                dart: true,
+              ),
+              _SiteWordMarkListEntry(
+                name: 'DartPad',
+                href: 'https://dartpad.cn',
+                dart: true,
+              ),
+              _SiteWordMarkListEntry(
+                name: 'pub.dev',
+                href: 'https://pub-web.flutter-io.cn',
+                dart: true,
+              ),
+            ],
+          ),
+        ],
       ),
-    ],
-  );
+    );
+  }
 }
 
 class _SiteWordMarkListEntry extends StatelessComponent {
diff --git a/site/lib/src/components/layout/theme_switcher.dart b/site/lib/src/components/layout/theme_switcher.dart
index ae9b93489a..8f49fe1fcf 100644
--- a/site/lib/src/components/layout/theme_switcher.dart
+++ b/site/lib/src/components/layout/theme_switcher.dart
@@ -73,7 +73,13 @@ final class _ThemeSwitcherState extends State {
       );
     }
 
-    web.window.localStorage.setItem('theme', newTheme.id);
+    try {
+      web.window.localStorage.setItem('theme', newTheme.id);
+    } catch (e) {
+      if (kDebugMode) {
+        print('Failed to save theme preference: $e');
+      }
+    }
 
     setState(() {
       _currentTheme = newTheme;
@@ -81,30 +87,25 @@ final class _ThemeSwitcherState extends State {
   }
 
   @override
-  Component build(BuildContext _) => Dropdown(
-    id: 'theme-switcher',
-    children: [
-      const DropdownToggle(Button(icon: 'routine', title: 'Select a theme.')),
-      DropdownContent(
-        div(
-          classes: 'dropdown-menu',
+  Component build(BuildContext _) {
+    return Dropdown(
+      id: 'theme-switcher',
+      toggle: const Button(icon: 'routine', title: 'Select a theme.'),
+      content: div(classes: 'dropdown-menu', [
+        ul(
+          attributes: {'role': 'listbox'},
           [
-            ul(
-              attributes: {'role': 'listbox'},
-              [
-                for (final mode in _Theme.values)
-                  _ThemeButtonEntry(
-                    mode: mode,
-                    selected: _currentTheme == mode,
-                    setMode: _setTheme,
-                  ),
-              ],
-            ),
+            for (final mode in _Theme.values)
+              _ThemeButtonEntry(
+                mode: mode,
+                selected: _currentTheme == mode,
+                setMode: _setTheme,
+              ),
           ],
         ),
-      ),
-    ],
-  );
+      ]),
+    );
+  }
 }
 
 final class _ThemeButtonEntry extends StatelessComponent {
diff --git a/site/lib/src/components/layout/toc.dart b/site/lib/src/components/layout/toc.dart
index 08d87714b2..3d3d42cb76 100644
--- a/site/lib/src/components/layout/toc.dart
+++ b/site/lib/src/components/layout/toc.dart
@@ -3,96 +3,147 @@
 // found in the LICENSE file.
 
 import 'package:jaspr/jaspr.dart';
+import 'package:jaspr_content/jaspr_content.dart';
 
-import '../../models/on_this_page_model.dart';
+import '../../markdown/markdown_parser.dart';
+import '../../models/page_navigation_model.dart';
+import '../../util.dart';
 import '../common/client/on_this_page_button.dart';
 import '../common/material_icon.dart';
+import '../util/component_ref.dart';
+import 'client/pagenav.dart';
 
-final class WideTableOfContents extends StatelessComponent {
-  const WideTableOfContents(this.data);
+final class DashTableOfContents extends StatelessComponent {
+  const DashTableOfContents(this.data);
 
-  final OnThisPageData data;
+  final TocNavigationData data;
 
   @override
   Component build(BuildContext _) {
-    return nav(id: 'toc-side', classes: 'site-toc', [
+    return nav(id: 'toc-side', [
       const OnThisPageButton(),
       _TocContents(data),
     ]);
   }
 }
 
-final class NarrowTableOfContents extends StatelessComponent {
-  const NarrowTableOfContents(
-    this.data, {
-    required this.currentTitle,
-  });
+final class PageNavBar extends StatelessComponent {
+  const PageNavBar(this.data);
 
-  final OnThisPageData data;
-  final String currentTitle;
+  final PageNavigationData data;
 
   @override
-  Component build(BuildContext _) {
-    return div(id: 'toc-top', classes: 'site-toc dropdown', [
-      button(
-        classes: 'dropdown-button',
-        attributes: {
-          'title': '切换目录下拉菜单',
-          'aria-expanded': 'false',
-          'aria-controls': 'toc-dropdown',
-          'aria-label': '切换目录下拉菜单',
-        },
-        [
-          span(classes: 'toc-intro', [
-            const MaterialIcon('list'),
-            span(
-              attributes: {'aria-label': '本页目录'},
+  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',
               [
-                text('本页目录'),
+                const MaterialIcon('vertical_align_top'),
+                span([text(currentTitle)]),
               ],
             ),
-          ]),
-          span(classes: 'toc-current', [
-            const MaterialIcon('chevron_right'),
-            span(id: 'current-header', [text(currentTitle)]),
-          ]),
-        ],
-      ),
-      div(id: 'toc-dropdown', classes: 'dropdown-content', [
-        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)
+              nav(
+                attributes: {'role': 'menu'},
+                [_TocContents(data.toc!)],
+              ),
+          ] 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)],
+                ),
+              ],
+            ],
           ],
-        ),
-        div(
-          classes: 'dropdown-divider',
-          attributes: {'aria-hidden': 'true', 'role': 'separator'},
-          [],
-        ),
-        nav(
-          attributes: {'role': 'menu'},
-          [_TocContents(data)],
-        ),
-      ]),
-    ]);
+        ]),
+      ),
+    );
   }
 }
 
 final class _TocContents extends StatelessComponent {
   const _TocContents(this.data);
 
-  final OnThisPageData data;
+  final TocNavigationData data;
 
   @override
   Component build(BuildContext _) => ul(
-    classes: 'styled-toc-list',
+    classes: 'toc-list',
     _buildEntries(data.topLevelEntries, 0),
   );
 
-  List _buildEntries(List entries, int depth) {
+  List _buildEntries(List entries, int depth) {
     final nextDepth = depth + 1;
 
     final result = [
diff --git a/site/lib/src/components/layout/trailing_content.dart b/site/lib/src/components/layout/trailing_content.dart
index 7ba458ddc4..10018600f6 100644
--- a/site/lib/src/components/layout/trailing_content.dart
+++ b/site/lib/src/components/layout/trailing_content.dart
@@ -5,47 +5,27 @@
 import 'package:jaspr/jaspr.dart';
 import 'package:jaspr_content/jaspr_content.dart';
 
+import '../../utils/page_source_info.dart';
 import '../common/client/feedback.dart';
 
 /// The trailing content of a content documentation page, such as
 /// its last updated information, report an issue links, and similar.
 class TrailingContent extends StatelessComponent {
-  const TrailingContent({super.key, this.repo});
-
-  final String? repo;
+  const TrailingContent({super.key});
 
   @override
   Component build(BuildContext context) {
     final page = context.page;
-    final pageUrl = page.url;
     final pageData = page.data.page;
     final siteData = page.data.site;
-    final branch = siteData['branch'] as String? ?? 'main';
-    final repoLinks = siteData['repo'] as Map? ?? {};
-    final repoUrl =
-        repo ??
-        repoLinks['this'] as String? ??
-        'https://github.com/dart-lang/site-www';
-    final inputPath = pageData['inputPath'] as String?;
     final pageDate = pageData['date'] as String?;
 
     final currentFlutterVersion =
         siteData['currentFlutterVersion'] as String? ?? '';
-    final siteUrl = siteData['url'] as String? ?? 'https://docs.flutter.cn';
-
-    final fullPageUrl = '$siteUrl$pageUrl';
-    final String issueUrl;
-    final String? pageSource;
 
-    if (inputPath != null) {
-      pageSource = '$repoUrl/blob/$branch/${inputPath.replaceAll('./', '')}';
-      issueUrl =
-          '$repoUrl/issues/new?template=1_page_issue.yml&page-url=$fullPageUrl&page-source=$pageSource';
-    } else {
-      pageSource = null;
-      issueUrl =
-          '$repoUrl/issues/new?template=1_page_issue.yml&page-url=$fullPageUrl';
-    }
+    final sourceInfo = page.sourceInfo;
+    final issueUrl = sourceInfo.issueUrl;
+    final pageSource = sourceInfo.sourceUrl;
 
     return div(
       id: 'trailing-content',
diff --git a/site/lib/src/components/pages/architecture_recommendations.dart b/site/lib/src/components/pages/architecture_recommendations.dart
new file mode 100644
index 0000000000..2df8ee6e31
--- /dev/null
+++ b/site/lib/src/components/pages/architecture_recommendations.dart
@@ -0,0 +1,111 @@
+// 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/devtools_release_notes_index.dart b/site/lib/src/components/pages/devtools_release_notes_index.dart
index b0833df676..4a2c2289ca 100644
--- a/site/lib/src/components/pages/devtools_release_notes_index.dart
+++ b/site/lib/src/components/pages/devtools_release_notes_index.dart
@@ -21,11 +21,11 @@ class DevToolsReleaseNotesIndex extends StatelessComponent {
       'release-notes-',
     ]);
     return context.pages
-        .where((p) => p.path.startsWith(releaseNotesPrefix))
+        .where((page) => page.path.startsWith(releaseNotesPrefix))
         .map(
-          (p) => (
-            version: Version.parse(p.data.page['breadcrumb'] as String),
-            page: p,
+          (page) => (
+            version: Version.parse(page.data.page['breadcrumb'] as String),
+            page: page,
           ),
         )
         .sortedBy((e) => e.version)
diff --git a/site/lib/src/components/pages/platforms_grid.dart b/site/lib/src/components/pages/platforms_grid.dart
new file mode 100644
index 0000000000..f292d331ad
--- /dev/null
+++ b/site/lib/src/components/pages/platforms_grid.dart
@@ -0,0 +1,112 @@
+// 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 '../common/button.dart';
+import '../common/material_icon.dart';
+
+class PlatformsGrid extends CustomComponentBase {
+  const PlatformsGrid();
+
+  @override
+  Pattern get pattern => 'PlatformsGrid';
+
+  @override
+  Component apply(
+    String name,
+    Map attributes,
+    Component? child,
+  ) {
+    return div(classes: 'platforms-grid', [
+      ?child,
+    ]);
+  }
+}
+
+class PlatformCard extends CustomComponentBase {
+  const PlatformCard();
+
+  @override
+  Pattern get pattern => 'PlatformCard';
+
+  @override
+  Component apply(
+    String name,
+    Map attributes,
+    Component? child,
+  ) {
+    final (
+      name,
+      icon,
+      arch,
+      supported,
+      ciTested,
+      unsupported,
+      deployToLink,
+    ) = switch (attributes) {
+      {
+        'name': final String name,
+        'icon': final String icon,
+        'arch': final String arch,
+        'supported': final String supported,
+        'ci-tested': final String ciTested,
+        'unsupported': final String unsupported,
+        'deploy-to-link': final String deployToLink,
+      } =>
+        (
+          name,
+          icon,
+          arch
+              .split(',')
+              .map((e) => e.trim())
+              .where((e) => e.isNotEmpty)
+              .toList(),
+          supported,
+          ciTested,
+          unsupported,
+          deployToLink,
+        ),
+      _ => throw ArgumentError(
+        'PlatformCard must have a name, icon, arch, supported, '
+        'ci-tested, unsupported, and deploy-to-link attribute.',
+      ),
+    };
+
+    final deployTo = attributes['deploy-to'] ?? name;
+
+    return div(classes: 'platform-card', [
+      div(classes: 'platform-card-header', [
+        span([
+          MaterialIcon(icon),
+        ]),
+        h3([text(name)]),
+        Button(
+          content: 'Deploy to $deployTo',
+          href: deployToLink,
+        ),
+      ]),
+      div(classes: 'platform-card-tags', [
+        for (final a in arch) span([text(a)]),
+      ]),
+
+      div(classes: 'platform-card-details', [
+        span([
+          span(classes: 'platform-card-supported', [text('Supported')]),
+          span([DashMarkdown(inline: true, content: supported)]),
+        ]),
+        span([
+          span(classes: 'platform-card-ci-tested', [text('CI tested')]),
+          span([text(ciTested)]),
+        ]),
+        span([
+          span(classes: 'platform-card-unsupported', [text('Unsupported')]),
+          span([text(unsupported)]),
+        ]),
+      ]),
+    ]);
+  }
+}
diff --git a/site/lib/src/components/pages/widget_catalog.dart b/site/lib/src/components/pages/widget_catalog.dart
new file mode 100644
index 0000000000..7b3635dfd5
--- /dev/null
+++ b/site/lib/src/components/pages/widget_catalog.dart
@@ -0,0 +1,185 @@
+// 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/downloadable_snippet.dart b/site/lib/src/components/tutorial/downloadable_snippet.dart
new file mode 100644
index 0000000000..b11391a25b
--- /dev/null
+++ b/site/lib/src/components/tutorial/downloadable_snippet.dart
@@ -0,0 +1,77 @@
+// Copyright 2025 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:jaspr/jaspr.dart';
+import 'package:jaspr_content/jaspr_content.dart';
+import 'package:path/path.dart' as path;
+
+import '../../extensions/code_block_processor.dart';
+import '../../util.dart';
+import '../common/client/copy_button.dart';
+import '../common/client/download_button.dart';
+import '../common/wrapped_code_block.dart';
+
+class DownloadableSnippet extends CustomComponentBase {
+  const DownloadableSnippet();
+
+  @override
+  Pattern get pattern => 'DownloadableSnippet';
+
+  @override
+  Component apply(_, Map attributes, _) {
+    final src = attributes['src'];
+    final name = attributes['name'];
+
+    if (src == null || name == null) {
+      throw ArgumentError(
+        'SnippetDownloadButton requires "src" and "name" attributes.',
+      );
+    }
+
+    return Builder(
+      builder: (context) {
+        final page = context.page;
+        final snippet = page.loader.readPartialSync(
+          path.join(siteSrcDirectoryPath, '_snippets', src),
+          page,
+        );
+        final language = src.split('.').last;
+
+        final processedContent = CodeBlockProcessor.highlightCode(
+          snippet
+              .split('\n')
+              .map((l) => CodeLine(content: l, highlights: const []))
+              .toList(),
+          language: language,
+        );
+
+        final codeBlock = WrappedCodeBlock(
+          content: processedContent,
+          language: language,
+          languagesToHide: const {
+            'plaintext',
+            'text',
+            'console',
+            'ps',
+            'diff',
+          },
+          title: name,
+          showLineNumbers: true,
+          showCopyButton: false,
+          collapsed: true,
+          actions: [
+            DownloadButton(
+              name: name,
+            ),
+            const CopyButton(
+              title: 'Copy to clipboard',
+            ),
+          ],
+        );
+
+        return codeBlock;
+      },
+    );
+  }
+}
diff --git a/site/lib/src/components/tutorial/stepper.dart b/site/lib/src/components/tutorial/stepper.dart
new file mode 100644
index 0000000000..b03d3533ba
--- /dev/null
+++ b/site/lib/src/components/tutorial/stepper.dart
@@ -0,0 +1,86 @@
+// 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 '../common/button.dart';
+import '../common/material_icon.dart';
+
+class Stepper extends CustomComponent {
+  const Stepper() : super.base();
+
+  @override
+  Component? create(Node node, NodesBuilder builder) {
+    if (node case ElementNode(
+      tag: 'Stepper',
+      :final attributes,
+      :final children,
+    )) {
+      final levelStr = attributes['level'] ?? '1';
+      final level = int.tryParse(levelStr) ?? 1;
+
+      assert(
+        level >= 1 && level <= 6,
+        'Stepper level must be between 1 and 6, got $level',
+      );
+
+      final steps = <({Node title, List content})>[];
+
+      if (children != null) {
+        for (final child in children) {
+          if (child case final ElementNode heading
+              when heading.tag == 'h$level') {
+            steps.add((title: child, content: []));
+          } else if (child case ElementNode(
+            tag: 'div',
+            attributes: {'class': 'header-wrapper'},
+            children: [final ElementNode heading, ..._],
+          ) when heading.tag == 'h$level') {
+            steps.add((title: child, content: []));
+          } else {
+            if (steps.isEmpty) {
+              throw Exception(
+                'Content found before first step title in Stepper. Make sure '
+                'your Stepper content starts with a heading of level $level.',
+              );
+            }
+            steps.last.content.add(child);
+          }
+        }
+      }
+
+      assert(steps.isNotEmpty, 'Stepper must have at least one step.');
+
+      return div(classes: 'stepper', [
+        for (final (index, step) in steps.indexed)
+          details(open: index == 0, [
+            summary([
+              span(
+                classes: 'step-number',
+                attributes: {'aria-label': 'Step ${index + 1}'},
+                [text('${index + 1}')],
+              ),
+              div(classes: 'step-title', [
+                builder.build([step.title]),
+              ]),
+              const MaterialIcon('keyboard_arrow_up'),
+            ]),
+            div(classes: 'step-content', [
+              builder.build(step.content),
+            ]),
+            div(classes: 'step-actions', [
+              Button(
+                classes: ['next-step-button'],
+                style: ButtonStyle.filled,
+                content: index == steps.length - 1 ? 'Finish' : 'Continue',
+              ),
+            ]),
+          ]),
+      ]);
+    }
+
+    return null;
+  }
+}
diff --git a/site/lib/src/components/tutorial/summary_card.dart b/site/lib/src/components/tutorial/summary_card.dart
new file mode 100644
index 0000000000..7250028fce
--- /dev/null
+++ b/site/lib/src/components/tutorial/summary_card.dart
@@ -0,0 +1,83 @@
+// Copyright 2025 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:jaspr/jaspr.dart';
+import 'package:jaspr_content/jaspr_content.dart';
+import 'package:yaml/yaml.dart';
+
+import '../../markdown/markdown_parser.dart';
+import '../../models/summary_card_model.dart';
+import '../common/material_icon.dart';
+
+class SummaryCard extends CustomComponent {
+  const SummaryCard() : super.base();
+
+  @override
+  Component? create(Node node, NodesBuilder builder) {
+    if (node is ElementNode && node.tag.toLowerCase() == 'summarycard') {
+      if (node.children?.whereType().isNotEmpty ?? false) {
+        throw Exception(
+          'Invalid SummaryCard content. Remove any leading empty lines to '
+          'avoid parsing as markdown.',
+        );
+      }
+
+      final content = node.children?.map((n) => n.innerText).join('\n') ?? '';
+      final data = loadYamlNode(content);
+      assert(
+        data is YamlMap,
+        'Invalid SummaryCard content. Expected a YAML map.',
+      );
+      final model = SummaryCardModel.fromMap(data as YamlMap);
+      assert(
+        model.items.isNotEmpty,
+        'SummaryCard must contain at least one item.',
+      );
+      return SummaryCardComponent(model: model);
+    }
+    return null;
+  }
+}
+
+class SummaryCardComponent extends StatelessComponent {
+  const SummaryCardComponent({super.key, required this.model});
+
+  final SummaryCardModel model;
+
+  @override
+  Component build(BuildContext context) {
+    return div(classes: 'summary-card', [
+      header([
+        div([
+          h3([text(model.title)]),
+          if (model.subtitle case final subtitle?) span([text(subtitle)]),
+        ]),
+        if (model.completed)
+          span(classes: 'summary-card-completed', [
+            const MaterialIcon('check_circle'),
+          ]),
+      ]),
+      for (final item in model.items) _buildSummaryItem(item),
+    ]);
+  }
+
+  Component _buildSummaryItem(SummaryCardItem item) {
+    if (item.details case final d?) {
+      return details([
+        summary(classes: 'summary-card-item', [
+          span([MaterialIcon(item.icon)]),
+          span(classes: 'summary-card-item-title', [text(item.title)]),
+          const MaterialIcon('keyboard_arrow_up'),
+        ]),
+        div(classes: 'summary-card-item-details', [
+          DashMarkdown(content: d),
+        ]),
+      ]);
+    }
+    return div(classes: 'summary-card-item', [
+      span([MaterialIcon(item.icon)]),
+      span(classes: 'summary-card-item-title', [text(item.title)]),
+    ]);
+  }
+}
diff --git a/site/lib/src/components/tutorial/tutorial_outline.dart b/site/lib/src/components/tutorial/tutorial_outline.dart
new file mode 100644
index 0000000000..0b960befe1
--- /dev/null
+++ b/site/lib/src/components/tutorial/tutorial_outline.dart
@@ -0,0 +1,50 @@
+// 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/components/util/component_ref.dart b/site/lib/src/components/util/component_ref.dart
new file mode 100644
index 0000000000..f4a0c499d0
--- /dev/null
+++ b/site/lib/src/components/util/component_ref.dart
@@ -0,0 +1,102 @@
+// 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:nanoid2/nanoid2.dart';
+
+import 'retake_element.dart';
+
+/// A wrapper around [Component] to make it usable across server/client boundaries.
+///
+/// This is a temporary (and limited) solution until server components have
+/// landed in Jaspr. They enable passing components to @client components
+/// directly, by creating a unique ID on the server and retaking the dom node
+/// on the client.
+///
+/// On the server, wrap your component with `context.ref(yourComponent)`, and
+/// pass the resulting [ComponentRef] to your @client component.
+/// On the client, retrieve the original component by calling `myRef.component`.
+class ComponentRef {
+  const ComponentRef._(this.id);
+
+  final String id;
+
+  Component get component {
+    return Builder(
+      builder: (context) {
+        if (!kIsWeb) {
+          final scope =
+              context
+                      .getElementForInheritedComponentOfExactType<
+                        ComponentRefScope
+                      >()
+                  as _ComponentRefScopeElement?;
+          return Component.wrapElement(
+            id: id,
+            child: scope!.getComponentById(id),
+          );
+        } else {
+          final elem = retakeElement(context, (elem) => elem.id == id);
+          assert(elem != null, 'Element with id "$id" not found');
+          return wrapNode(elem!);
+        }
+      },
+    );
+  }
+
+  @decoder
+  factory ComponentRef.fromId(String id) {
+    return ComponentRef._(id);
+  }
+
+  @encoder
+  String toId() => id;
+}
+
+extension ComponentRefExtension on BuildContext {
+  /// Wraps a [Component] in a [ComponentRef] for use in @client components.
+  ComponentRef ref(Component child) {
+    final scope =
+        getElementForInheritedComponentOfExactType()
+            as _ComponentRefScopeElement?;
+    assert(scope != null, 'No ComponentRefScope found in context');
+    final ref = scope!.register(child);
+    return ref;
+  }
+}
+
+/// A scope for registering and retrieving component references.
+///
+/// This should wrap your entire app, typically in `main.dart`.
+class ComponentRefScope extends InheritedComponent {
+  const ComponentRefScope({
+    required super.child,
+  });
+
+  @override
+  bool updateShouldNotify(ComponentRefScope oldComponent) {
+    return false;
+  }
+
+  @override
+  InheritedElement createElement() => _ComponentRefScopeElement(this);
+}
+
+class _ComponentRefScopeElement extends InheritedElement {
+  _ComponentRefScopeElement(ComponentRefScope super.component);
+
+  final Map _registeredComponents = {};
+
+  Component getComponentById(String id) {
+    final component = _registeredComponents[id];
+    assert(component != null, 'No component registered with id "$id"');
+    return component!;
+  }
+
+  ComponentRef register(Component child) {
+    final id = 'ref-${nanoid(length: 8)}';
+    _registeredComponents[id] = child;
+    return ComponentRef._(id);
+  }
+}
diff --git a/site/lib/src/components/util/global_event_listener.dart b/site/lib/src/components/util/global_event_listener.dart
index d761217ee7..50292859d7 100644
--- a/site/lib/src/components/util/global_event_listener.dart
+++ b/site/lib/src/components/util/global_event_listener.dart
@@ -8,11 +8,18 @@ import 'package:jaspr/jaspr.dart';
 import 'package:universal_web/web.dart' as web;
 
 final class GlobalEventListener extends StatefulComponent {
-  const GlobalEventListener(this.child, {this.onClick, this.onKeyDown});
+  const GlobalEventListener(
+    this.child, {
+    this.onClick,
+    this.onKeyDown,
+    this.onScroll,
+    super.key,
+  });
 
   final Component child;
   final void Function(web.MouseEvent)? onClick;
   final void Function(web.KeyboardEvent)? onKeyDown;
+  final void Function(web.Event)? onScroll;
 
   @override
   State createState() => _GlobalClickListenerState();
@@ -21,6 +28,7 @@ final class GlobalEventListener extends StatefulComponent {
 class _GlobalClickListenerState extends State {
   StreamSubscription? _clickSubscription;
   StreamSubscription? _keyDownSubscription;
+  StreamSubscription? _scrollSubscription;
 
   @override
   void initState() {
@@ -37,6 +45,11 @@ class _GlobalClickListenerState extends State {
             .forTarget(web.document)
             .listen(onKeyDown);
       }
+      if (component.onScroll case final onScroll?) {
+        _scrollSubscription = web.EventStreamProviders.scrollEvent
+            .forTarget(web.document)
+            .listen(onScroll);
+      }
     }
   }
 
@@ -44,6 +57,7 @@ class _GlobalClickListenerState extends State {
   void dispose() {
     unawaited(_clickSubscription?.cancel());
     unawaited(_keyDownSubscription?.cancel());
+    unawaited(_scrollSubscription?.cancel());
     super.dispose();
   }
 
diff --git a/site/lib/src/components/dartpad/extract_content_vm.dart b/site/lib/src/components/util/retake_element.dart
similarity index 58%
rename from site/lib/src/components/dartpad/extract_content_vm.dart
rename to site/lib/src/components/util/retake_element.dart
index 80b59939c2..b025116df5 100644
--- a/site/lib/src/components/dartpad/extract_content_vm.dart
+++ b/site/lib/src/components/util/retake_element.dart
@@ -2,7 +2,4 @@
 // 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';
-
-// Stub for non-web platforms.
-String extractContent(BuildContext context) => '';
+export 'retake_element_web.dart' if (dart.library.io) 'retake_element_vm.dart';
diff --git a/site/lib/src/components/util/retake_element_vm.dart b/site/lib/src/components/util/retake_element_vm.dart
new file mode 100644
index 0000000000..775262eedd
--- /dev/null
+++ b/site/lib/src/components/util/retake_element_vm.dart
@@ -0,0 +1,17 @@
+// Copyright 2025 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:jaspr/jaspr.dart';
+import 'package:universal_web/web.dart' as web;
+
+web.Element? retakeElement(
+  BuildContext context,
+  bool Function(web.Element element) predicate,
+) {
+  throw UnimplementedError();
+}
+
+Component wrapNode(web.Node node) {
+  throw UnimplementedError();
+}
diff --git a/site/lib/src/components/util/retake_element_web.dart b/site/lib/src/components/util/retake_element_web.dart
new file mode 100644
index 0000000000..8ff7e84efb
--- /dev/null
+++ b/site/lib/src/components/util/retake_element_web.dart
@@ -0,0 +1,25 @@
+// 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/browser.dart';
+// ignore: implementation_imports
+import 'package:jaspr/src/foundation/type_checks.dart';
+import 'package:universal_web/web.dart' as web;
+
+/// Retakes the element matching [predicate] during hydration.
+web.Element? retakeElement(
+  BuildContext context,
+  bool Function(web.Element element) predicate,
+) {
+  final r = (context as Element).parentRenderObjectElement?.renderObject;
+  if (r == null) return null;
+  final node = (r as DomRenderObject).retakeNode((node) {
+    return node.isElement && predicate(node as web.Element);
+  });
+  return node as web.Element?;
+}
+
+Component wrapNode(web.Node node) {
+  return RawNode(node);
+}
diff --git a/site/lib/src/extensions/code_block_processor.dart b/site/lib/src/extensions/code_block_processor.dart
index e58953a138..676f5eed29 100644
--- a/site/lib/src/extensions/code_block_processor.dart
+++ b/site/lib/src/extensions/code_block_processor.dart
@@ -88,16 +88,25 @@ final class CodeBlockProcessor implements PageExtension {
             );
           }
 
-          final diffResult = isDiff ? _processDiffLines(lines) : null;
-          final linesWithDiffRemoved = diffResult?.lines ?? lines;
-          final addedLines = diffResult?.addedLines ?? const {};
-          final removedLines = diffResult?.removedLines ?? const {};
+          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
+              ? _processDiffLines(lines)
+              : (lines: lines, addedLines: {}, removedLines: {});
+          final foldingResult = isFolding
+              ? _processFoldingLines(diffResult.lines)
+              : (lines: diffResult.lines, foldingRanges: []);
 
           final codeLines = _removeHighlights(
-            linesWithDiffRemoved,
+            foldingResult.lines,
             skipHighlighting,
           );
-          final processedContent = _highlightCode(
+          final processedContent = highlightCode(
             codeLines,
             language: language,
             skipSyntaxHighlighting: skipHighlighting,
@@ -106,7 +115,6 @@ final class CodeBlockProcessor implements PageExtension {
           return ComponentNode(
             WrappedCodeBlock(
               content: processedContent,
-              textToCopy: codeLines.copyContent,
               language: language,
               languagesToHide: const {
                 'plaintext',
@@ -117,11 +125,13 @@ final class CodeBlockProcessor implements PageExtension {
               },
               title: title,
               highlightLines: _parseNumbersAndRanges(rawHighlightLines),
-              addedLines: addedLines,
-              removedLines: removedLines,
+              addedLines: diffResult.addedLines,
+              removedLines: diffResult.removedLines,
+              foldingRanges: foldingResult.foldingRanges,
               tag: tag != null ? CodeBlockTag.parse(tag) : null,
               initialLineNumber: initialLineNumber ?? 1,
               showLineNumbers: showLineNumbers,
+              collapsed: isCollapsed,
             ),
           );
         }
@@ -136,8 +146,8 @@ final class CodeBlockProcessor implements PageExtension {
     );
   }
 
-  List> _highlightCode(
-    List<_CodeLine> codeLines, {
+  static List> highlightCode(
+    List codeLines, {
     required String language,
     bool skipSyntaxHighlighting = false,
   }) {
@@ -147,20 +157,57 @@ final class CodeBlockProcessor implements PageExtension {
       _ => opal.BuiltInLanguages.text,
     };
     final highlightedSpans = languageHighlighter.tokenize(content);
-    final renderedSpans = highlighter.ThemedSpanRenderer(
+    var 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),
     ];
   }
 
-  List _processLine(
+  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,
   ) {
@@ -192,7 +239,7 @@ final class CodeBlockProcessor implements PageExtension {
     return processedSpans;
   }
 
-  List<({int startColumn, int length})> _findIntersectingHighlights(
+  static List<({int startColumn, int length})> _findIntersectingHighlights(
     List<({int startColumn, int length})> highlights,
     int spanStart,
     int spanEnd,
@@ -203,7 +250,7 @@ final class CodeBlockProcessor implements PageExtension {
       })
       .sorted((a, b) => a.startColumn.compareTo(b.startColumn));
 
-  List _splitSpanByHighlights(
+  static List _splitSpanByHighlights(
     highlighter.ThemedSpan span,
     List<({int startColumn, int length})> highlights,
     int spanStart,
@@ -261,7 +308,7 @@ final class CodeBlockProcessor implements PageExtension {
     return result;
   }
 
-  jaspr.Component _createSpan(
+  static jaspr.Component _createSpan(
     highlighter.ThemedSpan span, {
     String? content,
   }) {
@@ -269,6 +316,7 @@ final class CodeBlockProcessor implements PageExtension {
       [jaspr.text(content ?? span.content)],
       attributes: {
         'style': ?span.toInlineStyle(defaultTheme: 'light'),
+        if (span.tag == _consolePromptTokenTag) 'aria-hidden': 'true',
       },
     );
   }
@@ -288,13 +336,13 @@ final class CodeBlockProcessor implements PageExtension {
         .toList(growable: false);
   }
 
-  List<_CodeLine> _removeHighlights(
+  List _removeHighlights(
     List lines, [
     bool skipHighlighting = false,
   ]) {
     if (skipHighlighting) {
       return lines
-          .map((line) => _CodeLine(content: line, highlights: const []))
+          .map((line) => CodeLine(content: line, highlights: const []))
           .toList(growable: false);
     }
 
@@ -399,7 +447,7 @@ final class CodeBlockProcessor implements PageExtension {
 
     return [
       for (var i = 0; i < processedLines.length; i++)
-        _CodeLine(
+        CodeLine(
           content: processedLines[i],
           highlights: lineHighlights[i] ?? [],
         ),
@@ -441,27 +489,73 @@ final class CodeBlockProcessor implements PageExtension {
       removedLines: removedLines,
     );
   }
+
+  /// Processes lines for folding mode, extracting folding range line markers.
+  ///
+  /// Lines equal to '[*' are marked as the start of an open folding range.
+  /// Lines equal to '[* -' are marked as the start of a closed folding range.
+  /// Lines equal to '*]' are marked as the end of a folding range.
+  /// The folding markers are removed from the lines.
+  ({
+    List lines,
+    List foldingRanges,
+  })
+  _processFoldingLines(List lines) {
+    final foldingRanges = [];
+    final processedLines = [];
+    final foldingStack = <({int start, bool open})>[];
+
+    for (var lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
+      final line = lines[lineIndex];
+
+      // To account for removal of the folding marker lines.
+      final lineIndexCorrection =
+          (foldingRanges.length * 2) + foldingStack.length;
+
+      if (line.trim() == '[*') {
+        foldingStack.add((
+          start: lineIndex + 1 - lineIndexCorrection,
+          open: true,
+        ));
+        // Skip adding this line to processed lines.
+        continue;
+      } else if (line.trim() == '[* -') {
+        foldingStack.add((
+          start: lineIndex + 1 - lineIndexCorrection,
+          open: false,
+        ));
+        // Skip adding this line to processed lines.
+        continue;
+      } else if (line.trim() == '*]') {
+        if (foldingStack.isNotEmpty) {
+          final (:start, :open) = foldingStack.removeLast();
+          foldingRanges.add((
+            start: start,
+            end: lineIndex - lineIndexCorrection,
+            level: foldingStack.length,
+            open: open,
+          ));
+        }
+        // Skip adding this line to processed lines.
+        continue;
+      }
+
+      processedLines.add(line);
+    }
+
+    return (
+      lines: processedLines,
+      foldingRanges: foldingRanges,
+    );
+  }
 }
 
 @immutable
-final class _CodeLine {
+final class CodeLine {
   final String content;
   final List<({int startColumn, int length})> highlights;
 
-  const _CodeLine({required this.content, required this.highlights});
-}
-
-extension on List<_CodeLine> {
-  static final RegExp _terminalReplacementPattern = RegExp(
-    r'^(\s*\$\s*)|(PS\s+)?(C:\\(.*)>\s*)',
-    multiLine: true,
-  );
-  static final RegExp _zeroWidthSpaceReplacementPattern = RegExp(r'\u200B');
-
-  String get copyContent => map((line) => line.content)
-      .join('\n')
-      .replaceAll(_terminalReplacementPattern, '')
-      .replaceAll(_zeroWidthSpaceReplacementPattern, '');
+  const CodeLine({required this.content, required this.highlights});
 }
 
 /// Parses a comma-separated list of numbers and ranges into a set of numbers.
diff --git a/site/lib/src/extensions/glossary_link_processor.dart b/site/lib/src/extensions/glossary_link_processor.dart
index 6d77acf7fd..fb688574fe 100644
--- a/site/lib/src/extensions/glossary_link_processor.dart
+++ b/site/lib/src/extensions/glossary_link_processor.dart
@@ -5,8 +5,9 @@
 import 'package:jaspr/jaspr.dart';
 import 'package:jaspr_content/jaspr_content.dart';
 
+import '../components/common/client/simple_tooltip.dart';
+import '../components/util/component_ref.dart';
 import '../pages/glossary.dart';
-import '../util.dart';
 
 /// A node-processing, page extension for Jaspr Content that looks for links to
 /// glossary entries and enhances them with interactive glossary tooltips.
@@ -38,20 +39,23 @@ class GlossaryLinkProcessor implements PageExtension {
           continue;
         }
 
+        final target = a(
+          href: node.attributes['href'] ?? '',
+          attributes: node.attributes,
+          [const NodesBuilder([]).build(node.children)],
+        );
+        final content = GlossaryTooltipContent(entry: entry);
+
         processedNodes.add(
-          ElementNode(
-            'span',
-            {'class': 'tooltip-wrapper'},
-            [
-              ElementNode('a', {
-                ...node.attributes,
-                'class': [
-                  ?node.attributes['class'],
-                  'tooltip-target',
-                ].toClasses,
-              }, node.children),
-              ComponentNode(GlossaryTooltip(entry: entry)),
-            ],
+          ComponentNode(
+            Builder(
+              builder: (context) {
+                return SimpleTooltip(
+                  target: context.ref(target),
+                  content: context.ref(content),
+                );
+              },
+            ),
           ),
         );
       } else if (node is ElementNode && node.children != null) {
@@ -71,18 +75,17 @@ class GlossaryLinkProcessor implements PageExtension {
   }
 }
 
-class GlossaryTooltip extends StatelessComponent {
-  const GlossaryTooltip({required this.entry});
+class GlossaryTooltipContent extends StatelessComponent {
+  const GlossaryTooltipContent({required this.entry});
 
   final GlossaryEntry entry;
 
   @override
   Component build(BuildContext context) {
-    return span(classes: 'tooltip', [
+    return span(classes: 'tooltip-content', [
       span(classes: 'tooltip-header', [text(entry.term)]),
-      span(classes: 'tooltip-content', [
-        text(entry.shortDescription),
-        text(' '),
+      span([
+        text('${entry.shortDescription} '),
         a(
           href: '/resources/glossary#${entry.id}',
           attributes: {
diff --git a/site/lib/src/extensions/registry.dart b/site/lib/src/extensions/registry.dart
index dc65b25051..13f168e801 100644
--- a/site/lib/src/extensions/registry.dart
+++ b/site/lib/src/extensions/registry.dart
@@ -10,6 +10,7 @@ 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.
@@ -20,4 +21,5 @@ 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
new file mode 100644
index 0000000000..2abcbe728e
--- /dev/null
+++ b/site/lib/src/extensions/tutorial_prefetch_processor.dart
@@ -0,0 +1,84 @@
+// 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
deleted file mode 100644
index 36ec1fabe8..0000000000
--- a/site/lib/src/layouts/catalog_page_layout.dart
+++ /dev/null
@@ -1,295 +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 '../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('更多 widget 请查看 '),
-            a(href: '/ui/widgets/material2', [
-              text('Material 2 widget 目录'),
-            ]),
-            text(' 以及其他类型的 '),
-            a(href: '/ui/widgets', [text('widget 目录')]),
-            text('.'),
-          ])
-        else
-          p([
-            text('更多 widget 请查看 '),
-            a(href: '/ui/widgets', [text('widget 目录')]),
-            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 7346e925a3..b3885bcb77 100644
--- a/site/lib/src/layouts/dash_layout.dart
+++ b/site/lib/src/layouts/dash_layout.dart
@@ -212,16 +212,19 @@ abstract class FlutterDocsLayout extends PageLayoutBase {
         // 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 e4cddf5891..0a2be9609d 100644
--- a/site/lib/src/layouts/doc_layout.dart
+++ b/site/lib/src/layouts/doc_layout.dart
@@ -5,14 +5,12 @@
 import 'package:jaspr/jaspr.dart';
 import 'package:jaspr_content/jaspr_content.dart';
 
-import '../components/common/breadcrumbs.dart';
+import '../components/common/page_header.dart';
 import '../components/common/prev_next.dart';
 import '../components/layout/banner.dart';
 import '../components/layout/toc.dart';
 import '../components/layout/trailing_content.dart';
-import '../extensions/header_extractor.dart';
-import '../models/on_this_page_model.dart';
-import '../util.dart';
+import '../models/page_navigation_model.dart';
 import 'dash_layout.dart';
 
 /// The Jaspr Content layout to use for normal docs pages,
@@ -23,55 +21,64 @@ 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;
     final siteData = page.data.site;
 
     final pageTitle = pageData['title'] as String;
+    final pageDescription = (pageData['description'] as String?)?.trim();
     final showBanner =
         (pageData['showBanner'] as bool?) ??
         (siteData['showBanner'] as bool?) ??
         false;
-    final tocData = _tocForPage(page);
+    final navigationData = page.navigationData;
 
     return super.buildBody(
       page,
       Component.fragment(
         [
-          if (tocData == null)
+          if (navigationData
+              case null || PageNavigationData(toc: null, pageEntries: []))
             const Document.body(attributes: {'data-toc': 'false'})
           else
-            NarrowTableOfContents(
-              tocData,
-              currentTitle: pageTitle,
+            div(
+              id: 'site-subheader',
+              classes: navigationData.pageEntries.isNotEmpty
+                  ? 'show-always'
+                  : null,
+              [
+                PageNavBar(navigationData),
+              ],
             ),
           if (showBanner)
             if (siteData['bannerHtml'] case final String bannerHtml
                 when bannerHtml.trim().isNotEmpty)
               DashBanner(bannerHtml),
           div(classes: 'after-leading-content', [
-            if (tocData != null)
+            if (navigationData case PageNavigationData(
+              toc: final toc?,
+              pageEntries: [],
+            ))
               aside(id: 'side-menu', [
-                WideTableOfContents(tocData),
+                DashTableOfContents(toc),
               ]),
             article([
-              div(id: 'site-content-title', [
-                h1(id: 'document-title', [
-                  if (pageData['underscore_breaker_titles'] == true)
-                    ...splitByUnderscore(pageTitle)
-                  else
-                    text(pageTitle),
-                ]),
-                if (pageData['showBreadcrumbs'] != false)
-                  const PageBreadcrumbs(),
-              ]),
+              PageHeader(
+                title: pageTitle,
+                description: pageDescription,
+                showBreadcrumbs:
+                    allowBreadcrumbs &&
+                    (pageData['showBreadcrumbs'] as bool? ?? true),
+              ),
 
               child,
 
               PrevNext(
-                previousPage: _pageInfoFromObject(pageData['prev']),
-                nextPage: _pageInfoFromObject(pageData['next']),
+                previousPage: PageNavigationEntry.fromData(pageData['prev']),
+                nextPage: PageNavigationEntry.fromData(pageData['next']),
               ),
               const TrailingContent(),
             ]),
@@ -80,34 +87,4 @@ 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
new file mode 100644
index 0000000000..f58fcb9ae8
--- /dev/null
+++ b/site/lib/src/layouts/tutorial_layout.dart
@@ -0,0 +1,51 @@
+// 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 430c2eeba3..ea8bead4e2 100644
--- a/site/lib/src/markdown/markdown_parser.dart
+++ b/site/lib/src/markdown/markdown_parser.dart
@@ -1,3 +1,7 @@
+// 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;
@@ -57,7 +61,9 @@ class DashMarkdown extends AsyncStatelessComponent {
   @override
   Future build(BuildContext context) async {
     final currentPage = context.page;
-    final markdownNodes = _defaultMarkdownDocument.parse(content);
+    final markdownNodes = inline
+        ? _defaultMarkdownDocument.parseInline(content)
+        : _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
deleted file mode 100644
index 2efd3eb7a4..0000000000
--- a/site/lib/src/models/on_this_page_model.dart
+++ /dev/null
@@ -1,75 +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 '../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
new file mode 100644
index 0000000000..54c8728f76
--- /dev/null
+++ b/site/lib/src/models/page_navigation_model.dart
@@ -0,0 +1,153 @@
+// 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/quiz_model.dart b/site/lib/src/models/quiz_model.dart
index 4f38e27b68..cdb9beca3a 100644
--- a/site/lib/src/models/quiz_model.dart
+++ b/site/lib/src/models/quiz_model.dart
@@ -1,3 +1,7 @@
+// 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 Question {
diff --git a/site/lib/src/models/summary_card_model.dart b/site/lib/src/models/summary_card_model.dart
new file mode 100644
index 0000000000..c604330c6e
--- /dev/null
+++ b/site/lib/src/models/summary_card_model.dart
@@ -0,0 +1,67 @@
+// 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 SummaryCardModel {
+  const SummaryCardModel({
+    required this.title,
+    this.subtitle,
+    this.completed = false,
+    required this.items,
+  });
+
+  final String title;
+  final String? subtitle;
+  final bool completed;
+  final List items;
+
+  @decoder
+  factory SummaryCardModel.fromMap(Map json) {
+    return SummaryCardModel(
+      title: json['title'] as String,
+      subtitle: json['subtitle'] as String?,
+      completed: json['completed'] as bool? ?? false,
+      items: (json['items'] as List)
+          .map((e) => SummaryCardItem.fromMap(e as Map))
+          .toList(),
+    );
+  }
+
+  @encoder
+  Map toJson() => {
+    'title': title,
+    'subtitle': subtitle,
+    'completed': completed,
+    'items': items.map((e) => e.toJson()).toList(),
+  };
+}
+
+class SummaryCardItem {
+  const SummaryCardItem({
+    required this.title,
+    required this.icon,
+    this.details,
+  });
+
+  final String title;
+  final String icon;
+  final String? details;
+
+  @decoder
+  factory SummaryCardItem.fromMap(Map json) {
+    return SummaryCardItem(
+      title: json['title'] as String,
+      icon: json['icon'] as String,
+      details: json['details'] as String?,
+    );
+  }
+
+  @encoder
+  Map toJson() => {
+    'title': title,
+    'icon': icon,
+    'details': details,
+  };
+}
diff --git a/site/lib/src/models/tutorial_model.dart b/site/lib/src/models/tutorial_model.dart
new file mode 100644
index 0000000000..0548bffcf3
--- /dev/null
+++ b/site/lib/src/models/tutorial_model.dart
@@ -0,0 +1,81 @@
+// 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
new file mode 100644
index 0000000000..a81286927e
--- /dev/null
+++ b/site/lib/src/models/widget_catalog_model.dart
@@ -0,0 +1,62 @@
+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 8ad698dee0..843036e97d 100644
--- a/site/lib/src/pages/custom_pages.dart
+++ b/site/lib/src/pages/custom_pages.dart
@@ -9,12 +9,14 @@ 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,
 ];
@@ -76,6 +78,7 @@ 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
@@ -126,5 +129,256 @@ sitemap: false
 
 
 
+## Foldable Code Block
+
+```dart foldable showLineNumbers
+[* -
+import 'package:flutter/material.dart'; 
+import 'package:flutter/services.dart';
+*]
+
+void main() => runApp(const MyApp());
+
+class MyApp extends StatelessWidget {
+  const MyApp({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    [* -
+    return MaterialApp( // Root widget
+      home: Scaffold(
+        appBar: AppBar(
+          title: const Text('My Home Page'),
+        ),
+        body: Center(
+          [*
+          child: Builder(
+            builder: (context) {
+              return Column(
+                children: [
+                  const Text('Hello, World!'),
+                  [* -
+                  const SizedBox(height: 20),
+                  ElevatedButton(
+                    onPressed: () {
+                      print('Click!');
+                    },
+                    child: const Text('A button'),
+                  ),
+                  *]
+                ],
+              );
+            },
+          ),
+          *]
+        ),
+      ),
+    );
+    *]
+  }
+}
+```
+
+## Summary Card
+
+
+title: What you'll learn in this Flutter lesson
+items:
+  - title: Introduction to Flutter and Dart programming
+    icon: flutter
+  - title: How to build beautiful UIs with widgets
+    icon: mobile_layout
+  - title: Adding navigation between different screens
+    icon: conversion_path
+
+
+---
+
+
+title: What you accomplished
+subtitle: Here's a summary of what you accomplished in this lesson.
+completed: true
+items:
+  - title: Reviewed the core concepts of Flutter
+    icon: flutter
+    details: >-
+      Solidified understanding of Flutter's core concepts, including the widget
+      tree, state management principles (Stateless vs. Stateful widgets), and
+      the basic project structure. Reviewed the essentials of the Dart
+      programming language.
+  - title: Practiced building layouts with widgets
+    icon: mobile_layout
+    details: >-
+      Built and experimented with common layout widgets (Row, Column,
+      Stack, and Flex), learned how to use padding, alignment, and
+      constraints to create responsive layouts across screen sizes.
+  - title: Implemented screen navigation and routing
+    icon: conversion_path
+    details: >-
+      Implemented navigation between screens using routes and
+      Navigator patterns; learned how to pass arguments between routes
+      and manage back navigation and nested navigation scenarios.
+
+
+## Stepper
+
+
+
+### Confirm your Dart setup
+
+First, make sure Dart is ready to go on your system by following these steps.
+
+1.  Open a terminal (or command prompt).
+
+2.  Run the following command to check your Dart SDK version:
+
+    ```bash
+    dart --version
+    ```
+
+3.  Make sure that you see output similar to this
+    (the version numbers might be different):
+
+    ```bash
+    Dart SDK version: 3.9.2 (stable) (Wed Aug 27 03:49:40 2025 -0700) on "linux_x64"
+    ```
+
+    If you see an error like "command not found," refer to the
+    [Dart installation guide](/get-dart) to set up your environment.
+
+### Create a new Dart project
+
+Now, create your first Dart command-line application.
+
+1.  In the same terminal,
+    create a new directory called `dartpedia` to hold your project.
+    Then switch into that directory.
+
+    ```bash
+    mkdir dartpedia
+    cd dartpedia
+    ```
+
+1.  Run the following command:
+
+    ```bash
+    dart create cli
+    ```
+
+    The `dart create` command generates a basic Dart project named
+    "cli" (for Command Line Interface).
+    It sets up the essential files and directories you need.
+
+1.  You should see output similar to this, confirming the project creation:
+
+    ```bash
+    Creating cli using template console...
+
+      .gitignore
+      analysis_options.yaml
+      CHANGELOG.md
+      pubspec.yaml
+      README.md
+      bin/cli.dart
+      lib/cli.dart
+      test/cli_test.dart
+
+    Running pub get...                     1.2s
+      Resolving dependencies...
+      Downloading packages...
+      Changed 49 dependencies!
+
+    Created project cli in cli! In order to get started, run the following commands:
+
+      cd cli
+      dart run
+    ```
+
+    :::note
+    The `dart create` command created a number of files.
+    Don't worry about these now.
+    Their specifics will be covered in future chapters.
+    :::
+
+### Run your first Dart program
+
+Next, run your program to test it out.
+
+1.  In the terminal, navigate into your new project directory:
+
+    ```bash
+    cd cli
+    ```
+
+1.  Run the default application:
+
+    ```bash
+    dart run
+    ```
+
+    This command tells Dart to execute your program.
+
+1.  You should see the following output:
+
+    ```bash
+    Building package executable...
+    Built cli:cli.
+    Hello world: 42!
+    ```
+
+    Congratulations! You've successfully run your first Dart program!
+
+### Make your first code change
+
+Next, modify the code that generated `Hello world: 42!`.
+
+1.  In a code editor, open the `bin/cli.dart` file.
+
+    The `bin/` directory is where your executable code lives.
+    `cli.dart` is the entry point of your application.
+
+    Inside, you'll see the `main` function.
+    Every Dart program [starts executing from its `main` function](/language#hello-world).
+
+1.  Check to make sure that your `bin/cli.dart` looks like this:
+
+    ```dart title="bin/cli.dart"
+    import 'package:cli/cli.dart' as cli;
+
+    void main(List arguments) {
+      print('Hello world: \${cli.calculate()}!');
+    }
+    ```
+
+1.  Simplify the output for now.
+    Delete the first line (you don't need this import statement), and change the
+    `print` statement to display a simple greeting: 
+
+    ```dart title="bin/cli.dart" highlightLines=1,4
+    import 'package:cli/cli.dart' as cli; // Delete this entire line
+
+    void main(List arguments) {
+      print('Hello, Dart!'); // Change this line
+    }
+    ```
+
+2.  Save your file. Then in the terminal, run your program again:
+
+    ```bash
+    dart run
+    ```
+
+3.  Check to make sure that you see the following:
+
+    ```bash
+    Building package executable...
+    Built cli:cli.
+    Hello, Dart!
+    ```
+
+    You've successfully modified and re-run your first Dart program!
+
+
+
 ''',
 );
diff --git a/site/lib/src/pages/glossary.dart b/site/lib/src/pages/glossary.dart
index 1fb66224b6..69e667f917 100644
--- a/site/lib/src/pages/glossary.dart
+++ b/site/lib/src/pages/glossary.dart
@@ -212,6 +212,7 @@ 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', [
@@ -231,7 +232,7 @@ final class GlossaryCard extends StatelessComponent {
             classes: const ['expand-button'],
             title: 'Expand or collapse card',
             attributes: {
-              'aria-expanded': 'true',
+              'aria-expanded': 'false',
               '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
new file mode 100644
index 0000000000..aa8e888135
--- /dev/null
+++ b/site/lib/src/pages/widget_catalog.dart
@@ -0,0 +1,195 @@
+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('更多 widget 请查看 '),
+                a(href: '/ui/widgets/material2', [
+                  text('Material 2 widget 目录'),
+                ]),
+                text(' 以及其他类型的 '),
+                a(href: '/ui/widgets', [text('widget 目录')]),
+                text('。'),
+              ])
+            else
+              p([
+                text('更多 widget 请查看 '),
+                a(href: '/ui/widgets', [text('widget 目录')]),
+                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 55199c6ce4..0db9c6b606 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 = 'IV1czN0gFDYQ';
+const generatedStylesHash = 'atn7pqr6+v6Z';
diff --git a/site/lib/src/util.dart b/site/lib/src/util.dart
index 4370dbcaee..2cba99ef56 100644
--- a/site/lib/src/util.dart
+++ b/site/lib/src/util.dart
@@ -2,7 +2,6 @@
 // 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:path/path.dart' as path;
 import 'package:universal_web/web.dart' as web;
 
@@ -12,29 +11,6 @@ const productionBuild = bool.fromEnvironment('PRODUCTION');
 /// Path to the `/src` directory where site content is located.
 final siteSrcDirectoryPath = path.join('..', 'src');
 
-/// Split the specific [sourceString] into a list of Jaspr [Component]
-/// by adding a ``  element after each underscore.
-///
-/// This is useful for long IDs separated with underscores, such as lint names,
-/// that might otherwise break across lines in an undesirable way.
-List splitByUnderscore(String sourceString) {
-  final parts = sourceString.split('_');
-  final result = [];
-
-  for (var i = 0; i < parts.length; i++) {
-    result.add(text(parts[i]));
-
-    // Add a word break opportunity after each underscore,
-    // except for the final one.
-    if (i < parts.length - 1) {
-      result.add(const Component.text('_'));
-      result.add(const Component.element(tag: 'wbr'));
-    }
-  }
-
-  return result;
-}
-
 /// Converts the specified [text] into a standardized URL slug
 /// that can be used as the ID for headers and other anchors in HTML.
 String slugify(String text) => text
@@ -132,6 +108,11 @@ 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/site/lib/src/utils/page_source_info.dart b/site/lib/src/utils/page_source_info.dart
new file mode 100644
index 0000000000..008da18060
--- /dev/null
+++ b/site/lib/src/utils/page_source_info.dart
@@ -0,0 +1,58 @@
+// 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';
+
+/// Information about a page's source location and related URLs.
+final class PageSourceInfo {
+  const PageSourceInfo({
+    required this.issueUrl,
+    this.sourceUrl,
+  });
+
+  /// The URL to create a new issue for this page.
+  final String issueUrl;
+
+  /// The URL to view the source of this page on GitHub.
+  ///
+  /// This will be `null` if the page doesn't have an `inputPath`.
+  final String? sourceUrl;
+}
+
+extension PageSourceInfoExtension on Page {
+  /// Returns the source information for this page.
+  PageSourceInfo get sourceInfo {
+    final pageUrl = url;
+    final pageData = data.page;
+    final siteData = data.site;
+    final branch = siteData['branch'] as String? ?? 'main';
+    final repoLinks = siteData['repo'] as Map? ?? {};
+    final repoUrl =
+        repoLinks['this'] as String? ?? 'https://github.com/cfug/flutter.cn';
+    final inputPath = pageData['inputPath'] as String?;
+    final siteUrl = siteData['url'] as String? ?? 'https://docs.flutter.cn';
+
+    final fullPageUrl = '$siteUrl$pageUrl';
+    final String? pageSourceUrl;
+    final String issueUrl;
+
+    if (inputPath != null) {
+      pageSourceUrl = '$repoUrl/blob/$branch/${inputPath.replaceAll('./', '')}';
+      issueUrl =
+          '$repoUrl/issues/new?template=1_page_issue.yml&'
+          'page-url=$fullPageUrl&'
+          'page-source=$pageSourceUrl';
+    } else {
+      pageSourceUrl = null;
+      issueUrl =
+          '$repoUrl/issues/new?template=1_page_issue.yml&'
+          'page-url=$fullPageUrl';
+    }
+
+    return PageSourceInfo(
+      issueUrl: issueUrl,
+      sourceUrl: pageSourceUrl,
+    );
+  }
+}
diff --git a/site/pubspec.yaml b/site/pubspec.yaml
index a5b4b99ed2..b0adf132f4 100644
--- a/site/pubspec.yaml
+++ b/site/pubspec.yaml
@@ -19,6 +19,7 @@ dependencies:
   markdown: ^7.3.0
   markdown_description_list: ^0.1.1
   meta: ^1.17.0
+  nanoid2: ^2.0.1
   # Used for syntax highlighting.
   opal: ^0.2.0
   path: ^1.9.1
diff --git a/src/_includes/docs/china-mirror.md b/src/_includes/docs/china-mirror.md
new file mode 100644
index 0000000000..ab1f88a76f
--- /dev/null
+++ b/src/_includes/docs/china-mirror.md
@@ -0,0 +1,48 @@
+
+ +### {{group}} + +[{{group}}][] maintains the `{{url}}` mirror. +It includes the Flutter SDK and pub packages. + +[{{group}}][] 维护着 `{{url}}` 镜像。 +它包括 Flutter SDK 和 pub package。 + +#### Configure your machine to use this mirror + +#### 配置你的机器使用镜像 + +To set your machine to use this mirror, use these commands. + +请使用以下指令,设置你的机器使用该镜像。 + +On macOS, Linux, or ChromeOS: + +在 macOS、Linux 或 ChromeOS 上: + +```console +export PUB_HOSTED_URL={{pubHosted}} +export FLUTTER_STORAGE_BASE_URL={{flutterStorage}} +``` + +On Windows: + +在 Windows 上: + +```console +$env:PUB_HOSTED_URL="{{pubHosted}}" +$env:FLUTTER_STORAGE_BASE_URL="{{flutterStorage}}" +``` + +#### Get support for this mirror + +#### 向镜像反馈 + +If you're running into issues that only occur when +using the `{{url}}` mirror, report the issue to their +[issue tracker]({{issueLink}}). + +如果你的问题仅在使用 `{{url}}` 镜像时才会出现, +请向他们的 [反馈]({{issueLink}})。 + +[{{group}}]: {{groupLink}} diff --git a/src/_includes/docs/code-and-image.md b/src/_includes/docs/code-and-image.md deleted file mode 100644 index 47b7e0e071..0000000000 --- a/src/_includes/docs/code-and-image.md +++ /dev/null @@ -1,27 +0,0 @@ -{% assign alt = alt | default: caption -%} -{% assign caption = caption | default: '' -%} -{% if width -%} -{% assign width = 'width: ' | append: width | append: ';' -%} -{% else -%} -{% assign width = '' -%} -{% endif -%} -{% if height -%} -{% assign height = 'height: ' | append: height | append: ';' -%} -{% else -%} -{% assign height = '' -%} -{% endif -%} - - -
-
- {{code}} -
-
- {{alt | escape}} - {% if caption and caption != '' -%} -
- {{caption}} -
- {% endif -%} -
-
diff --git a/src/_includes/docs/debug/debug-flow-ios.md b/src/_includes/docs/debug/debug-flow-ios.md index f9dd250702..1eaf7dfae8 100644 --- a/src/_includes/docs/debug/debug-flow-ios.md +++ b/src/_includes/docs/debug/debug-flow-ios.md @@ -21,14 +21,17 @@ If you use VS Code to debug most of your code, start with this section. ##### Start the Dart debugger in VS Code -{% render "docs/debug/debug-flow-vscode-as-start.md", add: add %} +{% render "docs/debug/debug-flow-vscode-as-start.md" %} + +{% if add == 'launch' %} +{% render "docs/debug/vscode-flutter-attach-json.md" %} +{% endif %} ##### Attach to the Flutter process in Xcode To attach to the Flutter app in Xcode: -1. Go to **Debug** > - **Attach to Process** > +1. Go to **Debug** > **Attach to Process**. 1. Select **Runner**. It should be at the top of the **Attach to Process** menu under the **Likely Targets** heading. diff --git a/src/_includes/docs/debug/debug-flow-vscode-as-start.md b/src/_includes/docs/debug/debug-flow-vscode-as-start.md index 6612a788a4..598f72f88f 100644 --- a/src/_includes/docs/debug/debug-flow-vscode-as-start.md +++ b/src/_includes/docs/debug/debug-flow-vscode-as-start.md @@ -34,7 +34,3 @@ default browser of your device. - **Launch in app**: This button opens this page within your app. This button only works for iOS or Android. Desktop apps launch a browser. - -{% if add == 'launch' -%} -{% render "docs/debug/vscode-flutter-attach-json.md" %} -{% endif -%} diff --git a/src/_includes/docs/get-started/setup-next-steps.html b/src/_includes/docs/get-started/setup-next-steps.html index 000ace38e3..e1aea16c74 100644 --- a/src/_includes/docs/get-started/setup-next-steps.html +++ b/src/_includes/docs/get-started/setup-next-steps.html @@ -46,8 +46,8 @@
  • Discover Flutter - widgets + href="https://www.youtube.com/watch?v=b_sQ9bMltGU&list=PLjxrf2q8roU23XGwz3Km7sQZFTdB996iG"> + Discover Flutter widgets
  • Explore samples & tutorials @@ -55,7 +55,7 @@
  • Learn Dart programming - +
  • @@ -81,19 +81,19 @@
  • Check out the blog - +
  • Subscribe on YouTube - +
  • Follow on Bluesky - +
  • diff --git a/src/_includes/docs/swift-package-manager/migrate-ios-project-manually.md b/src/_includes/docs/swift-package-manager/migrate-ios-project-manually.md index f9f71a3ed4..6ee3276b80 100644 --- a/src/_includes/docs/swift-package-manager/migrate-ios-project-manually.md +++ b/src/_includes/docs/swift-package-manager/migrate-ios-project-manually.md @@ -24,7 +24,7 @@ the following files in your issue: -1. Click add. +1. Click the button. 1. In the dialog that opens, click **Add Local...**. 1. Navigate to `ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage` and click **Add Package**. @@ -44,7 +44,7 @@ the following files in your issue: 1. Go to **Product > Scheme > Edit Scheme**. 1. Expand the **Build** section in the left side bar. 1. Click **Pre-actions**. -1. Click add and +1. Click the button and select **New Run Script Action** from the menu. 1. Click the **Run Script** title and change it to: diff --git a/src/_includes/docs/swift-package-manager/migrate-macos-project-manually.md b/src/_includes/docs/swift-package-manager/migrate-macos-project-manually.md index b639ad1acf..8b0e623dfa 100644 --- a/src/_includes/docs/swift-package-manager/migrate-macos-project-manually.md +++ b/src/_includes/docs/swift-package-manager/migrate-macos-project-manually.md @@ -24,7 +24,7 @@ the following files in your issue: -1. Click add. +1. Click the button. 1. In the dialog that opens, click the **Add Local...**. 1. Navigate to `macos/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage` and click the **Add Package**. @@ -44,7 +44,7 @@ the following files in your issue: 1. Go to **Product > Scheme > Edit Scheme**. 1. Expand the **Build** section in the left side bar. 1. Click **Pre-actions**. -1. Click the add button +1. Click the button and select **New Run Script Action** from the menu. 1. Click the **Run Script** title and change it to: diff --git a/src/_includes/docs/tutorial/game-code.md b/src/_snippets/tutorial/game-code.dart similarity index 93% rename from src/_includes/docs/tutorial/game-code.md rename to src/_snippets/tutorial/game-code.dart index 91803eaa9a..c10b97022b 100644 --- a/src/_includes/docs/tutorial/game-code.md +++ b/src/_snippets/tutorial/game-code.dart @@ -1,17 +1,3 @@ -
    - - -
    - -```dart import 'dart:collection'; import 'dart:math'; @@ -169,6 +155,7 @@ class Word with IterableMixin { String toStringVerbose() { return _letters.map((l) => '${l.char} - ${l.type.name}').join('\n'); } +} // Domain specific methods that contain word related logic. extension WordUtils on Word { @@ -217,6 +204,7 @@ extension WordUtils on Word { break; } } + } // Mark remaining letters in guessed word as misses for (var i = 0; i < length; i++) { @@ -227,9 +215,4 @@ extension WordUtils on Word { return this; } -} -``` - -
    - -
    +} \ No newline at end of file diff --git a/src/content/ai/ai-rules.md b/src/content/ai/ai-rules.md index 6b1584e5f8..48ce4a292d 100644 --- a/src/content/ai/ai-rules.md +++ b/src/content/ai/ai-rules.md @@ -1,6 +1,6 @@ --- title: AI rules for Flutter and Dart -description: > +description: >- Learn how to add AI rules to tools that accelerate your development workflow. --- @@ -18,34 +18,46 @@ instructions to an underlying LLM. These files help you: design. * Provide critical project context to the AI. - - + + Download the Flutter and Dart rules template ## Environments that support rules -Many AI environments support rules files to guide -LLM behavior. Here are some common examples and their -corresponding rule file names: - -| Environment | Rules File | Installation Instructions | -| :--- | :--- |:----------------------------------------------| -| Copilot powered IDEs | `copilot-instructions.md` | [Configure .github/copilot-instructions.md][] | -| Cursor | `AGENTS.md` | [Configure AGENTS.md][] | -| Firebase Studio | `airules.md` | [Configure airules.md][] | -| Gemini CLI | `GEMINI.md` | [Configure GEMINI.md][] | -| JetBrains IDEs | `guidelines.md` | [Configure guidelines.md][] | -| VS Code | `.instructions.md` | [Configure .instructions.md][] | -| Windsurf | `guidelines.md` | [Configure guidelines.md][] | - -[Configure airules.md]: https://firebase.google.com/docs/studio/set-up-gemini#custom-instructions -[Configure .github/copilot-instructions.md]: https://code.visualstudio.com/docs/copilot/copilot-customization#_custom-instructions -[Configure AGENTS.md]: https://cursor.com/docs/context/rules -[Configure guidelines.md]: https://www.jetbrains.com/help/junie/customize-guidelines.html -[Configure .instructions.md]: https://code.visualstudio.com/docs/copilot/copilot-customization#_custom-instructions -[Configure guidelines.md]: https://docs.windsurf.com/windsurf/cascade/memories#rules -[Configure GEMINI.md]: https://codelabs.developers.google.cn/gemini-cli-hands-on +Many AI environments support rules files to guide LLM behavior. +Here are some common examples and their corresponding +rule file or directory names: + +| Environment | Rules file or directory | Configuration instructions | +|:---------------------|:----------------------------------|:------------------------------------------------------| +| Copilot-powered IDEs | `.github/copilot-instructions.md` | [Configure instructions for Copilot][copilot] | +| Claude Code | `CLAUDE.md` | [Configure rules for Claude Code][claude] | +| Cursor | `AGENTS.md` | [Configure rules in Cursor][cursor] | +| Firebase Studio | `.idx/airules.md` | [Configure instructions in Firebase Studio][firebase] | +| Gemini CLI | `GEMINI.md` | [Configure context in Gemini CLI][gemini-cli] | +| Google Antigravity | `.agent/rules/.md` | [Configure rules for Antigravity Agent][antigravity] | +| JetBrains IDEs | `.junie/guidelines.md` | [Configure guidelines for Junie][junie] | +| VS Code | `.instructions.md` | [Configure instructions in VS Code][vs-code] | +| Windsurf | `.windsurf/rules/.md` | [Configure rules in Windsurf][windsurf] | + +{:.table .table-striped} + +:::note Support is evolving +Support for rules files is still evolving. +Please check the documentation for your specific development environment for +the most up-to-date naming conventions and instructions. +::: + +[copilot]: https://code.visualstudio.com/docs/copilot/customization/custom-instructions#_use-a-githubcopilotinstructionsmd-file +[claude]: https://www.anthropic.com/engineering/claude-code-best-practices#1-customize-your-setup +[cursor]: https://cursor.com/docs/context/rules +[firebase]: https://firebase.google.com/docs/studio/set-up-gemini#custom-instructions +[gemini-cli]: https://geminicli.com/docs/cli/gemini-md +[antigravity]: https://antigravity.google/docs/rules +[junie]: https://www.jetbrains.com/help/junie/customize-guidelines.html +[vs-code]: https://code.visualstudio.com/docs/copilot/customization/custom-instructions#_use-instructionsmd-files +[windsurf]: https://docs.windsurf.com/windsurf/cascade/memories#rules ## Create rules for your editor @@ -53,7 +65,7 @@ You can adapt our Flutter and Dart rules template for your specific environment. To do so, follow these steps: 1. Download the Flutter and Dart rules template: - rules.md + rules.md 1. In an LLM like [Gemini][], attach the `rules.md` file that you downloaded in diff --git a/src/content/ai/create-with-ai.md b/src/content/ai/create-with-ai.md index f1bd3c16f1..1d8925c1d2 100644 --- a/src/content/ai/create-with-ai.md +++ b/src/content/ai/create-with-ai.md @@ -6,8 +6,9 @@ description: > workflow. --- -This guide covers how you can leverage AI tools to build AI-powered features for -your Flutter apps and streamline your Flutter and Dart development. +This guide covers how you can leverage AI tools to build AI-powered +features for your Flutter apps and streamline your +Flutter and Dart development. ## Overview @@ -17,7 +18,7 @@ language understanding and content generation directly into your Flutter app using powerful SDKs, like the Firebase SDK for Generative AI. You can also use AI tools, such as Gemini Code Assist and Gemini CLI, to help with code generation and scaffolding. These tools are powered by the Dart and Flutter MCP -Server, which provides AI with a rich context about your codebase. The Flutter +server, which provides AI with a rich context about your codebase. The Flutter Extension for Gemini CLI makes it easy to leverage official rules, the MCP server, and custom commands for building your app. Additionally, rules files help fine-tune the AI's behavior and enforce project-specific best practices. @@ -35,7 +36,7 @@ resources: Vertex AI. To get started, check out the [official documentation][firebase-ai-logic-docs]. * [Flutter AI Toolkit][] - A sample app with pre-built widgets to help you build - AI-powered features in Flutter + AI-powered features in Flutter. [Firebase AI Logic]: {{site.firebase}}/docs/ai-logic [firebase-ai-logic-docs]: {{site.firebase}}/docs/ai-logic/get-started @@ -44,20 +45,56 @@ resources: ## AI development tools AI isn't only a feature in your app, but can also be a powerful assistant in -your development workflow. Tools like [Gemini Code -Assist](#gemini-code-assist), [Gemini CLI](#gemini-cli), [Claude Code][], +your development workflow. Tools like [Antigravity][], +[Gemini Code Assist][], [Gemini CLI][], [Claude Code][], [Cursor][], and [Windsurf][] can help you write code faster, understand complex concepts, and reduce boilerplate. +[Antigravity]: https://antigravity.google/ +[Gemini Code Assist]: https://codeassist.google/ +[Gemini CLI]: https://geminicli.com/ [Claude Code]: https://www.claude.com/product/claude-code [Cursor]: https://cursor.com/ [Windsurf]: https://windsurf.com/ +### GenUI SDK for Flutter {: #genui } + +The GenUI SDK transforms text-based conversations into rich, +interactive experiences. Essentially, it acts as an orchestration layer +that coordinates the flow of information between your user, your +Flutter widgets, and an AI agent. + + + +:::experimental +The `genui` package is in +alpha and is likely to change. +::: + +To learn more, visit the [GenUI SDK for Flutter][] documentation. + +[GenUI SDK for Flutter]: /ai/genui + +### Antigravity + +[Antigravity][] is an in-IDE AI agent that can read and write code, run +terminal commands, and help you build complex features. Some of its capabilities +include: + +* **Agentic capabilities**: Unlike chat-based assistants, Antigravity can + proactively edit files and run terminal commands to complete tasks. +* **Complex reasoning**: It can plan and execute multi-step workflows which + makes it suitable for larger refactors or feature implementations. +* **Verification**: It can run tests and verify its own changes to ensure + correctness. + +[Antigravity]: https://antigravity.google/ + ### Gemini Code Assist -[Gemini Code Assist][] is an AI-powered collaborator available in Visual Studio -Code and JetBrains IDEs (including Android Studio). It has a deep understanding -of your project's codebase and can help you with: +[Gemini Code Assist][] is an AI-powered collaborator available for IDEs like +Visual Studio Code, JetBrains IDEs, and Android Studio. It has a deep +understanding of your project's codebase and can help you with: * **Code completion and generation**: It suggests and generates entire blocks of code based on the context of what you're writing. @@ -65,7 +102,7 @@ of your project's codebase and can help you with: or best practices directly within your IDE. * **Debugging and explanation**: If you encounter an error, you can ask Gemini Code Assist to explain it and suggest a fix, and - [Dart and Flutter MCP Server][dart-mcp-flutter-docs] + [Dart and Flutter MCP server][dart-mcp-flutter-docs] [Gemini Code Assist]: https://codeassist.google/ @@ -85,13 +122,14 @@ To get started, visit the [Gemini CLI][] website, or try this [Gemini CLI]: https://geminicli.com/ [Gemini CLI codelab]: https://codelabs.developers.google.cn/gemini-cli-hands-on -## Flutter Extension for Gemini CLI +#### Flutter extension for Gemini CLI -The [Flutter Extension for Gemini CLI][flutter-extension] combines the [Dart and -Flutter MCP Server][dart-mcp-dart-docs] with rules and commands. It uses the -default set of [AI rules for Flutter and Dart][], adds commands like -`/create-app` and `/modify` to make structured changes to your app, and -automatically configures the [Dart and Flutter MCP Server][dart-mcp-dart-docs]. +The [Flutter extension for Gemini CLI][flutter-extension] combines the +[Dart and Flutter MCP server][dart-mcp-dart-docs] with rules and commands. +It uses the default set of [AI rules for Flutter and Dart][], +adds commands like `/create-app` and `/modify` to make +structured changes to your app, and automatically configures the +[Dart and Flutter MCP server][dart-mcp-dart-docs]. You can install it by running the following command: @@ -99,17 +137,16 @@ You can install it by running the following command: gemini extensions install https://github.com/gemini-cli-extensions/flutter ``` -To learn more, see the [blog post][flutter-extension-blog] or -the [README][flutter-extension]. +To learn more, check out +[Flutter extension for Gemini CLI](/ai/flutter-ext-for-gemini). [flutter-extension]: {{site.github}}/gemini-cli-extensions/flutter -[flutter-extension-blog]: https://blog.flutter.dev/meet-the-flutter-extension-for-gemini-cli-f8be3643eaad -## Dart and Flutter MCP Server +### Dart and Flutter MCP server To provide assistance during Flutter development, AI tools need to communicate with Dart and Flutter's developer tools. -The Dart and Flutter MCP Server facilitates this communication. +The Dart and Flutter MCP server facilitates this communication. The MCP (model context protocol) specification outlines how development tools can share the context of a user's code with an AI model, which allows the AI to better understand and interact with the code. @@ -127,10 +164,10 @@ on dart.dev and the [Dart and Flutter MCP repository][dart-mcp-github]. [dart-mcp-github]: {{site.github}}/dart-lang/ai/tree/main/pkgs/dart_mcp_server [dart-mcp-flutter-docs]: #dart-and-flutter-mcp-server -## Rules for Flutter and Dart +### Rules for Flutter and Dart You can use a rules file with AI-powered editors to provide context and instructions to an underlying LLM. To get -started, see the [AI rules for Flutter and Dart][] guide. +started, visit the [AI rules for Flutter and Dart][] guide. [AI rules for Flutter and Dart]: /ai/ai-rules diff --git a/src/content/ai/flutter-ext-for-gemini.md b/src/content/ai/flutter-ext-for-gemini.md new file mode 100644 index 0000000000..b7880b9b6f --- /dev/null +++ b/src/content/ai/flutter-ext-for-gemini.md @@ -0,0 +1,202 @@ +--- +title: Flutter extension for Gemini CLI +description: > + Learn how to use the Flutter extension for Gemini CLI + to make structured changes to your app at the command line + using the Dart and Flutter MCP server. +--- + +You might be familiar with Gemini CLI, +a command-line AI workflow tool that enables you +to interact with Gemini AI models without leaving +your development environment. +(If you aren’t familiar with Gemini, you can learn more +by working through the [Hands on with Gemini][] codelab.) + +[Hands on with Gemini]: {{site.codelabs}}/gemini-cli-hands-on + +AI agents are changing the way we build Flutter apps by +assisting with tasks like feature prototyping, code reviews, +as well as writing and running tests. +To use an AI agent effectively, you need to provide it with +context and access to tools to help it become a productive +Flutter coding assistant. +This is where the Flutter Extension for Gemini CLI comes in. +Gemini CLI extensions allow you to build integrations with +Gemini CLI and your tools, +and the Flutter extension expands on these capabilities. + +The Flutter Extension for Gemini CLI provides commands +to accelerate app development, follows explicit rules to +write high-quality code following Dart and Flutter best practices, +and runs tools from the Dart and Flutter MCP server to directly +access Dart and Flutter’s developer tools. You spend less time +on setup and more time building high quality Flutter apps. + +The following video showcases +[how to build multiplatform apps with Gemini CLI][gemini-cli-video]: + + + +[gemini-cli-video]: https://youtu.be/RZPkE5sllck?si=lM0sGs-V6nx7Tw6T + +## Prerequisites + +1. Install Gemini CLI 0.4.0 or later. + You can do this with npm or brew, depending on your platform, + preference, and system configuration. + +2. Install the Flutter SDK, which includes the Dart SDK. + If Flutter is already installed, + make sure that you have the latest versions of + Flutter and Dart by running flutter upgrade. + +3. Install Git and make sure it’s available on your PATH. + +## Get started + +:::experimental +The Flutter extension for Gemini CLI is in +alpha and is likely to change. +::: + +Once the prerequisites are satisfied, install the Flutter +extension for Gemini CLI by using one of the following commands: + +1. To install the current version, run the following: + + ```console + gemini extensions install https://github.com/gemini-cli-extensions/flutter + ``` + +2. To install the current version and ensure that future + updates are automatically installed, use the `auto-upate` tag: + + ```console + gemini extensions install https://github.com/gemini-cli-extensions/flutter.git --auto-update + ``` +After asking if you are sure you want to proceed, +you will see a message that the Flutter extension is installed and enabled. + +3. You can manage the extension with the following commands: + + - Update to the latest version: + + ```console + gemini extensions update flutter + ``` + + - Uninstall the extension: + + ```console + gemini extensions uninstall flutter + ``` + +## Available commands + +After installing the extension, +these commands are available when you open +a new Gemini CLI session: + +* `/create-app` - Guides you through bootstrapping a new + Flutter project with best practices. +* `/create-package` - Guides you through bootstrapping + a new Dart package with best practices. +* `/modify` - Manages a structured modification session + with automated planning. +* `/commit` - Automates pre-commit checks and generates + a descriptive commit message. + +## Create an app + +You can create a new application using the `/create-app` command. +This command bootstraps a brand-new, production-ready Flutter app. +It goes beyond flutter create by asking for your app’s purpose, +setting up recommended linter rules, +and generating detailed `DESIGN.md` and `IMPLEMENTATION.md` +files for your review before any code is written. + +```console +/create-app +``` + +The `DESIGN.md` file is a design document for the app; +it specifies the problems that the app solves and provides +technical details about how it will work. You can edit +this file before you continue with the implementation steps, +allowing you to guide Gemini to build the exact app that you’re looking for. + +Once the design is ready, `/create-app` generates an +`IMPLEMENTATION.md` file, a step-by-step implementation plan, +so that it can iteratively work on feature implementation. +It keeps a record of its progress, so you can pause and restart. +By default, `/create-app` splits the plan up into 3–5 phases, +where each phase is a logical stopping point. +After each phase, Gemini will analyze and format the code, +run tests, and commit the changes. It also updates this file +after it completes a phase in the Journal section. + +## Implement features from the plan + +After you’ve set up your project, you’re ready to implement +the features in your implementation plan using the generated +`IMPLEMENTATION.md` file. Each feature is implemented separately, +as outlined in this file. Once it finishes implementing a feature, +the Flutter extension will mark it as complete. + +Before moving to the next phase, the extension asks for your approval. +You can enter the prompt "looks good" to start generating code. + +## Modify + +To make changes to existing code, the `/modify` command +initiates a guided development session. It asks for your goals, +offers to create a new branch, and generates a `MODIFICATION_PLAN.md` +design doc outlining the proposed modifications and a phased implementation plan. + +```console +/modify +``` + +## Clean up and commit + +The final step is to commit the changes using `/commit`. +This command prepares your changes before committing them with Git. +It automatically runs `dart fix` and `dart format`, +runs the analyzer and tests, and then generates a descriptive +commit message based on the changes for you to approve. + +## Fully loaded with best practices + +Every interactive chat session includes rules containing +best practices for Flutter and Dart development. +These rules ensure that Gemini writes high-quality Dart and Flutter code, +interacts with MCP server tools correctly, +and follows best practices such as creating unit tests, +writing documentation, ensuring accessibility, and more. + +## Access to development tools with the Flutter and Dart MCP server + +The Dart and Flutter MCP server is automatically configured +when you install the Flutter Extension for Gemini CLI. +This allows Gemini CLI and other AI agents to perform common +development tasks. For example: + +* Analyze and fix errors in your project’s code. +* Introspect and interact with your running application + (such as trigger a hot reload, get the selected widget, + fetch runtime errors). +* Search `pub.dev` for the best package for your use case. +* Manage package dependencies in your `pubspec.yaml` file. +* Run tests and analyze the results. + +## Resources + +As previously mentioned, this extension is in alpha. +If you find a bug, please [file an issue][]. + +You also might want to check out the +[Gemini CLI extension][] repo. + +[file an issue]: {{site.github}}/gemini-cli-extensions/flutter/issues +[Gemini CLI extension]: {{site.github}}/gemini-cli-extensions/flutter diff --git a/src/content/ai/genui/components.md b/src/content/ai/genui/components.md new file mode 100644 index 0000000000..66741c38f1 --- /dev/null +++ b/src/content/ai/genui/components.md @@ -0,0 +1,113 @@ +--- +title: GenUI SDK main components and concepts +breadcrumb: Main components & concepts +description: >- + Familiarize yourself with the main components and concepts of the + Flutter for GenUI SDK. +prev: + title: GenUI SDK overview + path: /ai/genui +next: + title: Get started with the GenUI SDK + path: /ai/genui/get-started +--- + +:::experimental +The `genui` package is in +alpha and is likely to change. +::: + +## Main components + +The [`genui`][] package is built around the following main components: + +`GenUiConversation` +: The primary facade and entry point for the package. + It includes the `GenUiManager` and `ContentGenerator` classes, + manages the conversation history, + and orchestrates the entire generative UI process. + +`Catalog` +: A collection of `CatalogItem` objects that defines + the set of widgets that the AI is allowed to use. + Each `CatalogItem` specifies a widget's name (for the AI + to reference), a data schema for its properties, and a + builder function to render the Flutter widget. + +`DataModel` +: A centralized, observable store for all dynamic UI state. + Widgets are _bound_ to data in this model. When data changes, + only the widgets that depend on that specific piece of data are rebuilt. + +`ContentGenerator` +: An interface for communicating with a generative AI model. + This interface uses streams to send `A2uiMessage` commands, + text responses, and errors back to the `GenUiConversation`. + +`A2uiMessage` +: A message sent from the AI + (through the `ContentGenerator`) to the UI, + instructing it to perform actions like `beginRendering`, + `surfaceUpdate`, `dataModelUpdate`, or `deleteSurface`. + +## How it works + +The `GenUiConversation` manages the interaction cycle: + + 1. **User input** + + The user provides a prompt (for example, through a text field). + The app calls `genUiConversation.sendRequest()`. + + 2. **AI invocation** + + The `GenUiConversation` adds the user's message to its + internal conversation history and calls `contentGenerator.sendRequest()`. + + 3. **AI response** + + The `ContentGenerator` interacts with the AI model. + The AI, guided by the widget schemas, sends back responses. + + 4. **Stream handling** + + The `ContentGenerator` emits A2uiMessages, + text responses, or errors on its streams. + + 5. **UI state update** + + `GenUiConversation` listens to these streams. + `A2uiMessages` are passed to `GenUiManager.handleMessage()`, + which updates the UI state and `DataModel`. + + 6. **UI rendering** + + The `GenUiManager` broadcasts an update, + and any `GenUiSurface` widgets listening for that surface ID will rebuild. + Widgets are bound to the `DataModel`, so they update automatically + when their data changes. + + 7. **Callbacks** + + Text responses and errors trigger the `onTextResponse` + and `onError` callbacks on `GenUiConversation`. + + 8. **User interaction** + + The user interacts with the newly generated UI + (for example, by typing in a text field). This interaction directly + updates the `DataModel`. If the interaction is an action (like a button click), + the `GenUiSurface` captures the event and forwards it to the + `GenUiConversation`'s `GenUiManager`, which automatically creates + a new `UserMessage` containing the current state of the data model + and restarts the cycle. + +{:.steps} + +For more detailed information on the implementation of GenUI SDK for Flutter, +check out the [design doc][]. + +The next section walks you through adding `genui` to your app. + +[design doc]: {{site.repo.organization}}/genui/blob/main/packages/genui/DESIGN.md +[`genui`]: {{site.pub-pkg}}/genui diff --git a/src/content/ai/genui/get-started.md b/src/content/ai/genui/get-started.md new file mode 100644 index 0000000000..4efcb50a40 --- /dev/null +++ b/src/content/ai/genui/get-started.md @@ -0,0 +1,831 @@ +--- +title: Get started with the GenUI SDK for Flutter +shortTitle: Get started with the GenUI SDK +breadcrumb: Get started +description: >- + Learn how to use GenUI SDK for Flutter and add it + to your existing Flutter app. +prev: + title: GenUI SDK main components & concepts + path: /ai/genui/components +--- + +This guide explains how to get started with +GenUI SDK for Flutter and its series of packages. +The SDK's key components +are described in the [main components][] page. + +:::experimental +The `genui` package is in +alpha and is likely to change. +::: + +Use the following instructions to add [`genui`][] to your Flutter app. +The code examples show how to perform the instructions on a brand new +app created by running [`flutter create`][], but you can follow the same +steps for your existing Flutter app. + +[`genui`]: {{site.pub-pkg}}/genui +[main components]: /ai/genui/components +[`flutter create`]: /reference/create-new-app + +## Configure your agent provider + +The `genui` package can connect to a variety of agent providers. +Available providers include the following: + +**Google Gemini AI** +: The fastest way to get started! + Use this package for experimentation and local testing as + you're mapping out your experience. + +**Firebase AI Logic** +: Useful for production apps where interactions with the LLM are + all in your Flutter client, without requiring a server. + Firebase also makes it easier to ship your + AI features securely since Firebase handles the + management of your Gemini API key. + +**GenUI A2UI** +: Useful for client/server architectures where your + agent is running on the server. + +**Build your own** +: You can also build your own adapter + to connect to your preferred LLM provider. + Expect more from us and the community soon. + + + + + +The easiest way to start using GenUI is to use the +[`genui_google_generative_ai`][] package, +which only requires a `GEMINI_API_KEY`. + +This package provides the integration between `genui` and the +Google Cloud Generative Language API. +It allows you to use the power of Google's Gemini models to generate +dynamic user interfaces in your Flutter applications. + +This API is meant for quick explorations and local testing or prototyping, +not for production or deployment. +Flutter apps built for production should use Firebase AI. +For mobile and web applications that need client-side access, +consider using Firebase AI Logic instead. + + 1. Create an instance of `GoogleGenerativeAiContentGenerator` and + pass it to your `GenUiConversation`: + + ```dart + import 'package:genui/genui.dart'; + import 'package:genui_google_generative_ai/genui_google_generative_ai.dart'; + + final catalog = Catalog(components: [ + // ... + ]); + + final contentGenerator = GoogleGenerativeAiContentGenerator( + catalog: catalog, + systemInstruction: 'You are a helpful assistant.', + modelName: 'models/gemini-2.5-flash', + apiKey: 'YOUR_API_KEY', // Or set GEMINI_API_KEY environment variable. + ); + + final conversation = GenUiConversation( + contentGenerator: contentGenerator, + ); + ``` + + 2. To use this package, you need a Gemini API key. + If you don't already have one, + you can get it for free in [Google AI Studio][]. + + Enable the `GEMINI_API_KEY` in one of two ways: + + - **Environment variable** _(recommended)_ + + Set the `GEMINI_API_KEY` or `GOOGLE_API_KEY` environment variable. + + - **Constructor parameter** + + Pass the API key directly to the constructor. + + If neither approach is provided, the package will attempt to + use the default environment variable. + +[`genui_google_generative_ai`]: {{site.pub-pkg}}/genui_google_generative_ai +[Google AI Studio]: https://ai.google.dev/aistudio + + + + + +To use the built-in `FirebaseAiContentGenerator` to connect +to Gemini using the Firebase AI Logic SDK, follow these instructions: + + 1. [Create a new Firebase project][] using the Firebase Console. + + 2. [Enable the Gemini API][] for that project. + + 3. Follow the first three steps in [Firebase's Flutter setup guide][] + to add Firebase to your app. + + 4. Use `dart pub add` to add `genui` and [`genui_firebase_ai`][] as + dependencies in your `pubspec.yaml` file. + + ```console + $ dart pub add genui genui_firebase_ai + ``` + + 5. In your app's `main` method, ensure that the widget + bindings are initialized and then initialize Firebase. + + ```dart + void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + runApp(const MyApp()); + } + ``` + +[Create a new Firebase project]: https://support.google.com/appsheet/answer/10104995 +[Enable the Gemini API]: https://firebase.google.com/docs/gemini-in-firebase/set-up-gemini +[Firebase's Flutter setup guide]: https://firebase.google.com/docs/flutter/setup +[`genui_firebase_ai`]: {{site.pub-pkg}}/genui_firebase_ai + + + + + +An integration package for [`genui`][] and the +[A2UI Streaming UI Protocol][]. This package allows +Flutter applications to connect to an Agent-to-Agent (A2UI) +server and render dynamic user interfaces generated by an +AI agent using the `genui` framework. + +The main components in this package include: + +* `A2uiContentGenerator`: + Implements the `ContentGenerator` that manages the connection + to the A2A server and processes incoming A2UI messages, + updating the `GenUiManager`. +* `A2uiAgentConnector`: + Handles the low-level web socket communication with the + A2A server, including sending messages and parsing stream events. +* `AgentCard`: + A data class that holds metadata about the connected AI agent. + +Follow these instructions: + + 1. Set up dependencies: + Use `dart pub add` to add `genui`, `genui_a2ui`, and `a2a` as + dependencies in your `pubspec.yaml` file. + + ```console + $ dart pub add genui genui_a2ui a2a + ``` + + 2. Initialize `GenUIManager`: + Set up `GenUiManager` with your widget `Catalog`. + + 3. Create `A2uiContentGenerator`: + Instantiate `A2uiContentGenerator`, providing the A2A server URI. + + 4. Create `GenUiConversation`: + Pass the `A2uiContentGenerator` to the `GenUiConversation`. + + 5. Render with `GenUiSurface`: + Use `GenUiSurface` widgets in your UI to display + the agent-generated content. + + 6. Send Messages: + Use `GenUiConversation.sendRequest` to send user input + to the agent-generated content. + + ```dart + import 'package:flutter/material.dart'; + import 'package:genui/genui.dart'; + import 'package:genui_a2ui/genui_a2ui.dart'; + import 'package:logging/logging.dart'; + + void main() { + // Setup logging. + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + print('${record.level.name}: ${record.time}: ${record.message}'); + if (record.error != null) { + print(record.error); + } + if (record.stackTrace != null) { + print(record.stackTrace); + } + }); + + runApp(const GenUIExampleApp()); + } + + class GenUIExampleApp extends StatelessWidget { + const GenUIExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'A2UI Example', + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: const ChatScreen(), + ); + } + } + + class ChatScreen extends StatefulWidget { + const ChatScreen({super.key}); + + @override + State createState() => _ChatScreenState(); + } + + class _ChatScreenState extends State { + final TextEditingController _textController = TextEditingController(); + final GenUiManager _genUiManager = + GenUiManager(catalog: CoreCatalogItems.asCatalog()); + late final A2uiContentGenerator _contentGenerator; + late final GenUiConversation _uiAgent; + final List _messages = []; + + @override + void initState() { + super.initState(); + _contentGenerator = A2uiContentGenerator( + // TODO: Replace with your A2A server URL. + serverUrl: Uri.parse('http://localhost:8080'), + ); + _uiAgent = GenUiConversation( + contentGenerator: _contentGenerator, + genUiManager: _genUiManager, + ); + + // Listen for text responses from the agent. + _contentGenerator.textResponseStream.listen((String text) { + setState(() { + _messages.insert(0, AgentMessage.text(text)); + }); + }); + + // Listen for errors. + _contentGenerator.errorStream.listen((ContentGeneratorError error) { + print('Error from ContentGenerator: ${error.error}'); + // Optionally show the error to the user. + }); + } + + @override + void dispose() { + _textController.dispose(); + _uiAgent.dispose(); + _genUiManager.dispose(); + _contentGenerator.dispose(); + super.dispose(); + } + + void _handleSubmitted(String text) { + if (text.isEmpty) return; + _textController.clear(); + final message = UserMessage.text(text); + setState(() { + _messages.insert(0, message); + }); + _uiAgent.sendRequest(message); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('A2UI Example'), + ), + body: Column( + children: [ + Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(8.0), + reverse: true, + itemBuilder: (_, int index) => + _buildMessage(_messages[index]), + itemCount: _messages.length, + ), + ), + const Divider(height: 1.0), + Container( + decoration: BoxDecoration(color: Theme.of(context).cardColor), + child: _buildTextComposer(), + ), + // Surface for the main AI-generated UI: + SizedBox( + height: 300, + child: GenUiSurface( + host: _genUiManager, + surfaceId: 'main_surface', + ), + ), + ], + ), + ); + } + + Widget _buildMessage(ChatMessage message) { + return Container( + margin: const EdgeInsets.symmetric(vertical: 10.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.only(right: 16.0), + child: CircleAvatar(child: Text(message is UserMessage ? 'U' : 'A')), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(message is UserMessage ? 'User' : 'Agent', + style: const TextStyle(fontWeight: FontWeight.bold)), + Container( + margin: const EdgeInsets.only(top: 5.0), + child: Text(message.parts.whereType().map((e) => e.text).join('\n')), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildTextComposer() { + return IconTheme( + data: IconThemeData(color: Theme.of(context).colorScheme.secondary), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 8.0), + child: Row( + children: [ + Flexible( + child: TextField( + controller: _textController, + onSubmitted: _handleSubmitted, + decoration: + const InputDecoration.collapsed(hintText: 'Send a message'), + ), + ), + Container( + margin: const EdgeInsets.symmetric(horizontal: 4.0), + child: IconButton( + icon: const Icon(Icons.send), + onPressed: () => _handleSubmitted(_textController.text), + ), + ), + ], + ), + ), + ); + } + } + ``` + +The [example][] directory on pub.dev contains a +complete application demonstrating how to use this package. + +[example]: {{site.pub-pkg}}/genui_a2ui/example +[A2UI Streaming UI Protocol]: https://a2ui.org/ + + + + + +To use `genui` with another agent provider, +follow that provider's instructions to configure your app, +and then create your own subclass of `ContentGenerator` to connect +to that provider. + +For examples on how to do so, +reference `FirebaseAiContentGenerator` (from the [`genui_firebase_ai`][] package) +and `A2uiContentGenerator` (from the [`genui_a2ui`][] package). + +[`genui_firebase_ai`]: {{site.pub-pkg}}/genui_firebase_ai +[`genui_a2ui`]: {{site.pub-pkg}}/genui_a2ui + + + + + +## Create the connection to an agent + +If you build your Flutter project for iOS or macOS, +add this key to your `{ios,macos}/Runner/*.entitlements` file(s) +to enable outbound network requests: + +```xml + +... +com.apple.security.network.client + + +``` + +Next, use the following instructions to connect your app +to your chosen agent provider. + + 1. Create a `GenUiManager`, and provide it with the catalog + of widgets that you want to make available to the agent. + + 2. Create a `ContentGenerator`, and provide it with a + system instruction and a set of tools (functions + you want the agent to be able to invoke). + You should always include those provided by `GenUiManager`, + but feel free to include others. + + 3. Create a `GenUiConversation` using the instances of + `ContentGenerator` and `GenUiManager`. Your app will + primarily interact with this object to get things done. + + For example: + + ```dart + class _MyHomePageState extends State { + late final GenUiManager _genUiManager; + late final GenUiConversation _genUiConversation; + + @override + void initState() { + super.initState(); + + // Create a GenUiManager with a widget catalog. + // The CoreCatalogItems contain basic widgets for text, markdown, and images. + _genUiManager = GenUiManager(catalog: CoreCatalogItems.asCatalog()); + + // Create a ContentGenerator to communicate with the LLM. + // Provide system instructions and the tools from the GenUiManager. + final contentGenerator = FirebaseAiContentGenerator( + systemInstruction: ''' + You are an expert in creating funny riddles. Every time I give you a word, + you should generate UI that displays one new riddle related to that word. + Each riddle should have both a question and an answer. + ''', + tools: _genUiManager.getTools(), + ); + + // Create the GenUiConversation to orchestrate everything. + _genUiConversation = GenUiConversation( + genUiManager: _genUiManager, + contentGenerator: contentGenerator, + onSurfaceAdded: _onSurfaceAdded, // Added in the next step. + onSurfaceDeleted: _onSurfaceDeleted, // Added in the next step. + ); + } + + @override + void dispose() { + _textController.dispose(); + _genUiConversation.dispose(); + + super.dispose(); + } + } + ``` + +## Send messages and display the agent's responses + +Send a message to the agent using the `sendRequest` method +in the `GenUiConversation` class. + +To receive and display generated UI: + + 1. Use the callbacks in `GenUiConversation` to track the addition + and removal of UI surfaces as they are generated. + These events include a _surface ID_ for each surface. + + 2. Build a `GenUiSurface` widget for each active surface using + the surface IDs received in the previous step. + + For example: + + ```dart + class _MyHomePageState extends State { + // ... + + final _textController = TextEditingController(); + final _surfaceIds = []; + + // Send a message containing the user's [text] to the agent. + void _sendMessage(String text) { + if (text.trim().isEmpty) return; + _genUiConversation.sendRequest(UserMessage.text(text)); + } + + // A callback invoked by the [GenUiConversation] when a new + // UI surface is generated. Here, the ID is stored so the + // build method can create a GenUiSurface to display it. + void _onSurfaceAdded(SurfaceAdded update) { + setState(() { + _surfaceIds.add(update.surfaceId); + }); + } + + // A callback invoked by GenUiConversation when a UI surface is removed. + void _onSurfaceDeleted(SurfaceRemoved update) { + setState(() { + _surfaceIds.remove(update.surfaceId); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + title: Text(widget.title), + ), + body: Column( + children: [ + Expanded( + child: ListView.builder( + itemCount: _surfaceIds.length, + itemBuilder: (context, index) { + // For each surface, create a GenUiSurface to display it. + final id = _surfaceIds[index]; + return GenUiSurface(host: _genUiConversation.host, surfaceId: id); + }, + ), + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _textController, + decoration: const InputDecoration( + hintText: 'Enter a message', + ), + ), + ), + const SizedBox(width: 16), + ElevatedButton( + onPressed: () { + // Send the user's text to the agent. + _sendMessage(_textController.text); + _textController.clear(); + }, + child: const Text('Send'), + ), + ], + ), + ), + ), + ], + ), + ); + } + } + ``` + +## Add your own widgets to the catalog {:#custom-widgets} + +For your convenience, you can use the provided core catalog of widgets. +However, most production apps will want to define a custom +catalog of widgets. + +To add your own widgets, use the following instructions. + + 1. Depend on the `json_schema_builder` package + + Use `dart pub add` to add `json_schema_builder` as + a dependency in your `pubspec.yaml` file: + + ```console + $ dart pub add json_schema_builder + ``` + + 2. Create the new widget's schema + + Each catalog item needs a schema that defines the data required + to populate it. Using the `json_schema_builder` package, + define one for the new widget. + + ```dart + import 'package:json_schema_builder/json_schema_builder.dart'; + import 'package:flutter/material.dart'; + import 'package:genui/genui.dart'; + + final _schema = S.object( + properties: { + 'question': S.string(description: 'The question part of a riddle.'), + 'answer': S.string(description: 'The answer part of a riddle.'), + }, + required: ['question', 'answer'], + ); + ``` + + 3. Create a `CatalogItem` + + Each `CatalogItem` represents a type of widget that the agent + is allowed to generate. To do that, it combines a name, + a schema, and a builder function that produces the widgets + that compose the generated UI. + + ```dart + final riddleCard = CatalogItem( + name: 'RiddleCard', + dataSchema: _schema, + widgetBuilder: + ({ + required data, + required id, + required buildChild, + required dispatchEvent, + required context, + required dataContext, + }) { + final json = data as Map; + final question = json['question'] as String; + final answer = json['answer'] as String; + + return Container( + constraints: const BoxConstraints(maxWidth: 400), + decoration: BoxDecoration(border: Border.all()), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(question, style: Theme.of(context).textTheme.headlineMedium), + const SizedBox(height: 8.0), + Text(answer, style: Theme.of(context).textTheme.headlineSmall), + ], + ), + ); + }, + ); + ``` + + 4. Add the `CatalogItem` to the catalog + + Include your catalog items when instantiating `GenUiManager`. + + ```dart + _genUiManager = GenUiManager( + catalog: CoreCatalogItems.asCatalog().copyWith([riddleCard]), + ); + ``` + + 5. Update the system instruction to use the new widget + + To make sure that the agent knows to use your new widget, + tell the system instruction how and when to do so. + Provide the name from the `CatalogItem` when you do. + + ```dart + final contentGenerator = FirebaseAiContentGenerator( + systemInstruction: ''' + You are an expert in creating funny riddles. Every time I give you a word, + generate a RiddleCard that displays one new riddle related to that word. + Each riddle should have both a question and an answer. + ''', + tools: _genUiManager.getTools(), + ); + ``` + +{:.steps} + +## Data model and data binding + +A core concept in `genui` is the `DataModel`, a centralized, +observable store for all dynamic UI state. Instead of each widget +managing its own state, its state is stored in the `DataModel`. + +Widgets are _bound_ to data in this model. +When data in the model changes, only the widgets that depend +on that specific piece of data are rebuilt. +This is achieved through a `DataContext` object passed to each +widget's builder function. + +### Binding to the data model + +To bind a widget's property to the data model, +specify a special JSON object in the data sent from the AI. +This object can contain either a `literalString` +(for static values) or a `path` (to bind to a value in the data model). + +For example, to display a user's name in a `Text` widget, +the AI would generate: + +```json +{ + "Text": { + "text": { + "literalString": "Welcome to GenUI" + }, + "hint": "h1" + } +} +``` + +### Image + +```json +{ + "Image": { + "url": { + "literalString": "https://example.com/image.png" + }, + "hint": "mediumFeature" + } +} +``` + +### Updating the data model + +Input widgets, like `TextField`, update the DataModel directly. +When the user types in a text field that is bound to `/user/name`, +the `DataModel` updates, and any other widgets bound to that same +path will automatically rebuild to show the new value. + +This reactive data flow simplifies state management and creates a powerful, +high-bandwidth interaction loop between the user, the UI, and the AI. + +## Next steps + +Check out the [examples][] included in the `genui` repo. +The [travel app][] shows how to define your own widget +catalog that the agent can use to generate domain-specific UI. + +If something is unclear or missing, please [create an issue][]. + +[examples]: {{site.repo.organization}}/genui/blob/main/examples +[travel app]: {{site.repo.organization}}/genui/blob/main/examples/travel_app +[create an issue]: {{site.repo.organization}}/genui/issues/new/choose + +## System instructions + +The `genui` package gives the LLM a set of tools it can use to generate UI. +To get the LLM to use these tools, +the `systemInstruction` provided to `ContentGenerator` must +explicitly tell it to do so. + +This is why the [earlier example][instruction-example] includes +a system instruction for the agent with the line +"Every time I give you a word, you should generate UI that...": + +```dart highlightLines=4-5 +final contentGenerator = FirebaseAiContentGenerator( + systemInstruction: ''' + You are an expert in creating funny riddles. + Every time I give you a word, you should generate UI that + displays one new riddle related to that word. + Each riddle should have both a question and an answer. + ''', + tools: _genUiManager.getTools(), +); +``` + +[instruction-example]: /ai/genui/get-started#create-the-connection-to-an-agent + +## Troubleshooting/FAQ {:#troubleshoot} + +### How can I configure logging? + +To observe communication between your app and the agent, +enable logging in your `main` method. + +```dart +import 'package:logging/logging.dart'; +import 'package:genui/genui.dart'; + +final logger = configureGenUiLogging(level: Level.ALL); + +void main() async { + logger.onRecord.listen((record) { + debugPrint('${record.loggerName}: ${record.message}'); + }); + + // Additional initialization of bindings and Firebase. +} +``` + +### I'm getting errors about my minimum macOS/iOS version. + +Firebase has a [minimum version requirement][] for Apple's platforms, +which might be higher than Flutter's default. +Check your `Podfile` (for iOS) and `CMakeLists.txt` (for macOS) +to ensure that you're targeting a version that meets or exceeds +Firebase's requirements. + +[Create a new Firebase project]: https://support.google.com/appsheet/answer/10104995 +[create an issue]: {{site.repo.organization}}/genui/issues/new/choose +[Enable the Gemini API]: https://firebase.google.com/docs/gemini-in-firebase/set-up-gemini +[examples]: {{site.repo.organization}}/genui/blob/main/examples +[Firebase's Flutter setup guide]: https://firebase.google.com/docs/flutter/setup +[`genui`]: {{site.pub-pkg}}/genui +[Key components]: /ai/genui/components +[minimum version requirement]: https://firebase.google.com/support/release-notes/ios diff --git a/src/content/ai/genui/index.md b/src/content/ai/genui/index.md new file mode 100644 index 0000000000..6f5c2a3f4b --- /dev/null +++ b/src/content/ai/genui/index.md @@ -0,0 +1,55 @@ +--- +title: GenUI SDK for Flutter +shortTitle: GenUI SDK +description: >- + Learn how to use GenUI SDK for Flutter to build more + interactive experiences for applications and chatbots. +next: + title: GenUI SDK main components & concepts + path: /ai/genui/components +--- + +## What is GenUI? + +At its core, the GenUI SDK for Flutter is an orchestration layer. +This suite of packages coordinates the flow of information between your user, +your Flutter widgets, and an AI agent, +transforming text-based conversations into rich, interactive experiences. + +Imagine that, instead of presenting your user with a wall of text, +they are presented with a graphical UI consisting of (for example), +a row of labeled buttons and a date picker. + +The GenUI SDK for Flutter uses a JSON-based format to +compose a UI from your existing +widget catalog. As a user interacts with the UI, +state changes are fed back to the agent, +creating a high-bandwidth loop and turning +an agent interaction into a rich, intuitive experience. + +The GenUI SDK for Flutter is designed to easily integrate +into your Flutter application. + +## When would you use it? + +Use GenUI SDK for Flutter to incorporate graphical UI +into your app. For example: + +* Instead of describing a list of products in text, + use it to render a clickable carousel of product widgets. +* When a user asks to plan a trip, use it to generate a + complete form with sliders, date pickers, and text fields. + +For more context about GenUI SDK for Flutter, +check out the [Getting started with GenUI video][]: + + + +:::experimental +The `genui` package is in +alpha and is likely to change. +::: + +[Getting started with GenUI video]: https://www.youtube.com/watch?v=nWr6eZKM6no + +{% comment %} TODO: add NEXT/PREV page links between the GenUI pages {% endcomment %} diff --git a/src/content/ai/mcp-server.md b/src/content/ai/mcp-server.md index 18acf317d2..13270a2182 100644 --- a/src/content/ai/mcp-server.md +++ b/src/content/ai/mcp-server.md @@ -62,6 +62,92 @@ This section provides instructions for setting up the Dart and Flutter MCP server with popular tools like Firebase Studio, Gemini CLI, Gemini Code Assist, Cursor, and GitHub Copilot. +### Antigravity + +To configure [Antigravity][] to use the Dart and Flutter MCP server, +you can either install it from the list of available servers or +[connect it as a custom MCP server][antigravity-mcp]. + +1. Navigate to or open the **Agent** side panel. + + If it's closed, open it by either: + + - Pressing Cmd/Ctrl + L. + - Going to **View** + > **Open View...** + > **Agent**. + +1. In the upper right of the **Agent** panel, + click the **Additional options** (`...`) menu button. +1. Select **MCP Servers**. +1. In the upper right of the **Agent** panel, + click **Manage MCP Servers**. + +From here, you can choose to install from the list or configure manually. + +#### Install from list + +1. In the list of available servers, find **Dart** and click **Add**. +1. **Important**: The built-in configuration doesn't currently pass the + required `--force-roots-fallback` flag. You must add it manually. +1. In the upper right of the **Manage MCPs** editor view, + click **View raw config**. +1. Locate the `dart` entry in the `mcpServers` map and + update the `args` list to include `--force-roots-fallback`: + + ```json title="mcp_config.json" highlightLines=6 + "dart": { + "command": "dart", + "args": [ + "mcp-server", + "--force-roots-fallback" + ] + } + ``` + +#### Connect manually + +1. In the upper right of the **Manage MCPs** editor view, + click **View raw config**. +1. Add the following `dart-mcp-server` entry to the `mcpServers` map: + + ```json title="mcp_config.json" highlightLines=3-10 + { + "mcpServers": { + "dart-mcp-server": { + "command": "dart", + "args": [ + "mcp-server", + "--force-roots-fallback" + ], + "env": {} + } + } + } + ``` + +#### Install extensions + +It is also recommended to install the Dart and Flutter extensions: + +1. Open the **Extensions** view by either: + + - Pressing Shift + + Cmd/Ctrl + + P. + - Going to **View** + > **Extensions**. + +1. In the **Search Extensions** input box, enter **Flutter**. +1. From the list of extensions, select **Flutter**. +1. In the **Extension: Flutter** view that opens, + click the **Install** button. + + This installs both the Dart and Flutter extensions. + +[Antigravity]: https://antigravity.google/ +[antigravity-mcp]: https://antigravity.google/docs/mcp#connecting-custom-mcp-servers + ### Gemini CLI To configure the [Gemini CLI][] to use the Dart and Flutter MCP server, diff --git a/src/content/app-architecture/case-study/ui-layer.md b/src/content/app-architecture/case-study/ui-layer.md index 21e9c003d0..6e0371ae69 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, @@ -404,26 +404,30 @@ a [`Dismissible`][] widget. Recall this code from the previous snippet: -{% render "docs/code-and-image.md", -image:"app-architecture/case-study/dismissible.webp", -img-style:"max-height: 480px; border-radius: 12px; border: black 2px solid;", -alt: "A clip that demonstrates the 'dismissible' functionality of the Compass app." -code:" -```dart title=home_screen.dart highlightLines=9-10 -SliverList.builder( - itemCount: widget.viewModel.bookings.length, - itemBuilder: (_, index) => _Booking( - key: ValueKey(viewModel.bookings[index].id), - booking: viewModel.bookings[index], - onTap: () => context.push( - Routes.bookingWithId(viewModel.bookings[index].id) + + + + + ```dart title=home_screen.dart highlightLines=9-10 + SliverList.builder( + itemCount: widget.viewModel.bookings.length, + itemBuilder: (_, index) => _Booking( + key: ValueKey(viewModel.bookings[index].id), + booking: viewModel.bookings[index], + onTap: () => context.push( + Routes.bookingWithId(viewModel.bookings[index].id) + ), + onDismissed: (_) => + viewModel.deleteBooking.execute(widget.viewModel.bookings[index].id), ), - onDismissed: (_) => - viewModel.deleteBooking.execute(widget.viewModel.bookings[index].id), ), -), -``` -" %} + ``` + + On the `HomeScreen`, a user's saved trip is represented by the `_Booking` widget. When a `_Booking` is dismissed, diff --git a/src/content/app-architecture/recommendations.md b/src/content/app-architecture/recommendations.md index 8332281369..98ad62e744 100644 --- a/src/content/app-architecture/recommendations.md +++ b/src/content/app-architecture/recommendations.md @@ -27,49 +27,32 @@ 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. -{% 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 %} +## 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. + + @@ -100,14 +83,6 @@ which reflects how strongly the Flutter team recommends it. 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/community/china/index.md b/src/content/community/china/index.md index 571dbfab6f..53186a8c81 100644 --- a/src/content/community/china/index.md +++ b/src/content/community/china/index.md @@ -565,62 +565,29 @@ You can use other mirrors if they become available. Flutter 团队无法保证任何镜像的长期可用性。 如果其他镜像可用,你可以使用它们。 -{% for mirror in mirrors %} - -
    - -### {{mirror.group}} - -{% comment %} -[{{mirror.group}}][] maintains the `{{mirror.mirror}}` mirror. -It includes the Flutter SDK and pub packages. -{% endcomment %} - -[{{mirror.group}}][] 维护着 `{{mirror.mirror}}` 镜像。 -它包括 Flutter SDK 和 pub package。 - -#### Configure your machine to use this mirror - -#### 配置你的机器使用镜像 - -To set your machine to use this mirror, use these commands. - -请使用以下指令,设置你的机器使用该镜像。 - -On macOS, Linux, or ChromeOS: - -在 macOS、Linux 或 ChromeOS 上: - -```console -export PUB_HOSTED_URL={{mirror.urls.pubhosted}}; -export FLUTTER_STORAGE_BASE_URL={{mirror.urls.flutterstorage}} -``` - -On Windows: - -在 Windows 上: - -```console -$env:PUB_HOSTED_URL="{{mirror.urls.pubhosted}}"; -$env:FLUTTER_STORAGE_BASE_URL="{{mirror.urls.flutterstorage}}" -``` - -#### Get support for this mirror - -#### 向镜像反馈 - -If you're running into issues that only occur when -using the `{{mirror.mirror}}` mirror, report the issue to their -[issue tracker]({{mirror.urls.issues}}). - -如果你的问题仅在使用 `{{mirror.mirror}}` 镜像时才会出现, -请向他们的 [反馈]({{mirror.urls.issues}})。 - -{% endfor %} - -{% for mirror in mirrors %} -[{{mirror.group}}]: {{mirror.urls.group}} -{% endfor %} +{% render "docs/china-mirror.md", + group: "Flutter 社区 (CFUG)", + url: "flutter-io.cn", + pubHosted: "https://pub.flutter-io.cn", + flutterStorage: "https://storage.flutter-io.cn", + issueLink: "https://github.com/cfug/flutter.cn/issues/new/choose", + groupLink: "https://github.com/cfug" %} + +{% render "docs/china-mirror.md", + group: "上海交通大学 *nix 用户组", + url: "mirror.sjtu.edu.cn", + pubHosted: "https://mirror.sjtu.edu.cn/dart-pub", + flutterStorage: "https://mirror.sjtu.edu.cn", + issueLink: "https://github.com/sjtug/mirror-requests", + groupLink: "https://github.com/sjtug" %} + +{% render "docs/china-mirror.md", + group: "清华大学 TUNA 协会", + url: "mirrors.tuna.tsinghua.edu.cn", + pubHosted: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub", + flutterStorage: "https://mirrors.tuna.tsinghua.edu.cn/flutter", + issueLink: "https://github.com/tuna/issues", + groupLink: "https://tuna.moe" %} ## Offer to host a new mirror site diff --git a/src/content/cookbook/testing/widget/orientation.md b/src/content/cookbook/testing/widget/orientation.md index 75a7cd9ffa..3b012edfb4 100644 --- a/src/content/cookbook/testing/widget/orientation.md +++ b/src/content/cookbook/testing/widget/orientation.md @@ -3,8 +3,6 @@ title: Test orientation description: How to test if an app is in portrait or landscape mode. --- -{% assign api = site.api | append: '/flutter' -%} - In Flutter, you can build different layouts depending on a given [orientation][]. For example, you could present data in two columns if the app is in portrait mode, and three columns if in landscape mode. diff --git a/src/content/data-and-backend/state-mgmt/options.md b/src/content/data-and-backend/state-mgmt/options.md index 86fb6ed501..97c97a6ab3 100644 --- a/src/content/data-and-backend/state-mgmt/options.md +++ b/src/content/data-and-backend/state-mgmt/options.md @@ -177,7 +177,7 @@ refine the search to find packages that match your needs.
    状态管理 package - +
    diff --git a/src/content/get-started/custom.md b/src/content/get-started/custom.md index 6aa3dfdc7f..cd1a049a8b 100644 --- a/src/content/get-started/custom.md +++ b/src/content/get-started/custom.md @@ -166,7 +166,7 @@ Follow the codelab on [Building your first app][], set up development for an [additional target platform][], or explore some of these other learning resources. -{% render "docs/get-started/setup-next-steps.html", site:site %} +{% render "docs/get-started/setup-next-steps.html", site: site %} [Building your first app]: /get-started/codelab [additional target platform]: /platform-integration#setup diff --git a/src/content/get-started/flutter-for/compose-devs.md b/src/content/get-started/flutter-for/compose-devs.md index dae3735e7e..33ade046d4 100644 --- a/src/content/get-started/flutter-for/compose-devs.md +++ b/src/content/get-started/flutter-for/compose-devs.md @@ -56,14 +56,14 @@ Both composables and widgets only exist until they need to change. These languages call this property _immutability_. Jetpack Compose modifies UI component properties using an optional _modifier_ property backed by a `Modifier` object. -By contrast, Flutter uses widgets for both UI components and -their properties. +By contrast, Flutter widgets configure their properties directly +through constructor parameters. ```dart Padding( // <-- This is a Widget - padding: EdgeInsets.all(10.0), // <-- So is this - child: Text("Hello, World!"), // <-- This, too -))); + padding: EdgeInsets.all(10.0), // <-- a parameter to Padding + child: Text("Hello, World!"), // <-- This is also a Widget +); ``` To compose layouts, both Jetpack Compose and Flutter nest UI components diff --git a/src/content/get-started/fundamentals/layout.md b/src/content/get-started/fundamentals/layout.md index 7184ea9f18..806e4d4fa0 100644 --- a/src/content/get-started/fundamentals/layout.md +++ b/src/content/get-started/fundamentals/layout.md @@ -202,23 +202,27 @@ The first figure on this page used both. This is the most basic example of using a `Row` widget. -{% render "docs/code-and-image.md", -image:"fwe/layout/row.png", -caption: "This figure shows a row widget with three children." -alt: "A screenshot of a row widget with three children" -code:" -```dart -Widget build(BuildContext context) { - return Row( - children: [ - BorderedImage(), - BorderedImage(), - BorderedImage(), - ], - ); -} -``` -" %} + + + + + ```dart + Widget build(BuildContext context) { + return Row( + children: [ + BorderedImage(), + BorderedImage(), + BorderedImage(), + ], + ); + } + ``` + + Each child of `Row` or `Column` can be rows and columns themselves, @@ -226,40 +230,42 @@ combining to make a complex layout. For example, you could add labels to each of the images in the example above using columns. + -{% render "docs/code-and-image.md", -image:"fwe/layout/nested_row_column.png", -caption: "This figure shows a row widget with three children, each of which is a column." -alt: "A screenshot of a row of three widgets, each of which has a label underneath it." -code:" -```dart -Widget build(BuildContext context) { - return Row( - children: [ - Column( - children: [ - BorderedImage(), - Text('Dash 1'), - ], - ), - Column( - children: [ - BorderedImage(), - Text('Dash 2'), - ], - ), - Column( - children: [ - BorderedImage(), - Text('Dash 3'), - ], - ), - ], - ); -} -``` -" %} + + ```dart + Widget build(BuildContext context) { + return Row( + children: [ + Column( + children: [ + BorderedImage(), + Text('Dash 1'), + ], + ), + Column( + children: [ + BorderedImage(), + Text('Dash 2'), + ], + ), + Column( + children: [ + BorderedImage(), + Text('Dash 3'), + ], + ), + ], + ); + } + ``` + + ### Align widgets within rows and columns @@ -286,24 +292,28 @@ Setting the main axis alignment to `spaceEvenly` divides the free horizontal space evenly between, before, and after each image. -{% render "docs/code-and-image.md", -image:"fwe/layout/space_evenly.png", -caption: "This figure shows a row widget with three children, which are aligned with the MainAxisAlignment.spaceEvenly constant." -alt: "A screenshot of three widgets, spaced evenly from each other." -code:" -```dart -Widget build(BuildContext context) { - return Row( - [!mainAxisAlignment: MainAxisAlignment.spaceEvenly!], - children: [ - BorderedImage(), - BorderedImage(), - BorderedImage(), - ], - ); -} -``` -" %} + + + + + ```dart + Widget build(BuildContext context) { + return Row( + [!mainAxisAlignment: MainAxisAlignment.spaceEvenly!], + children: [ + BorderedImage(), + BorderedImage(), + BorderedImage(), + ], + ); + } + ``` + + Columns work the same way as rows. The following example shows a column of 3 images, @@ -339,29 +349,33 @@ To fix the previous example where the row of images is too wide for its render box, wrap each image with an [`Expanded`][] widget. -{% render "docs/code-and-image.md", -image:"fwe/layout/expanded_row.png", -caption: "This figure shows a row widget with three children that are wrapped with `Expanded` widgets." -alt: "A screenshot of three widgets, which take up exactly the amount of space available on the main axis. All three widgets are equal width." -code:" -```dart -Widget build(BuildContext context) { - return const Row( - children: [ - [!Expanded!]( - child: BorderedImage(width: 150, height: 150), - ), - [!Expanded!]( - child: BorderedImage(width: 150, height: 150), - ), - [!Expanded!]( - child: BorderedImage(width: 150, height: 150), - ), - ], - ); -} -``` -" %} + + + + + ```dart + Widget build(BuildContext context) { + return const Row( + children: [ + [!Expanded!]( + child: BorderedImage(width: 150, height: 150), + ), + [!Expanded!]( + child: BorderedImage(width: 150, height: 150), + ), + [!Expanded!]( + child: BorderedImage(width: 150, height: 150), + ), + ], + ); + } + ``` + + The `Expanded` widget can also dictate how much space a widget should take up relative @@ -374,30 +388,34 @@ for a widget. The default flex factor is 1. The following code sets the flex factor of the middle image to 2: -{% render "docs/code-and-image.md", -image:"fwe/layout/flex_2_row.png", -caption: "This figure shows a row widget with three children which are wrapped with `Expanded` widgets. The center child has it's `flex` property set to 2." -alt: "A screenshot of three widgets, which take up exactly the amount of space available on the main axis. The widget in the center is twice as wide as the widgets on the left and right." -code:" -```dart -Widget build(BuildContext context) { - return const Row( - children: [ - Expanded( - child: BorderedImage(width: 150, height: 150), - ), - Expanded( - [!flex: 2!], - child: BorderedImage(width: 150, height: 150), - ), - Expanded( - child: BorderedImage(width: 150, height: 150), - ), - ], - ); -} -``` -" %} + + + + + ```dart + Widget build(BuildContext context) { + return const Row( + children: [ + Expanded( + child: BorderedImage(width: 150, height: 150), + ), + Expanded( + [!flex: 2!], + child: BorderedImage(width: 150, height: 150), + ), + Expanded( + child: BorderedImage(width: 150, height: 150), + ), + ], + ); + } + ``` + + ## DevTools and debugging layout @@ -469,23 +487,27 @@ a `ListView` requires its children to take up all the available space on the cross axis, as shown in the example below. -{% render "docs/code-and-image.md", -image:"fwe/layout/basic_listview.png", -caption: "This figure shows a ListView widget with three children." -alt: "A screenshot of three widgets laid out vertically. They have expanded to take up all available space on the cross axis." -code:" -```dart -Widget build(BuildContext context) { - return [!ListView!]( - children: const [ - BorderedImage(), - BorderedImage(), - BorderedImage(), - ], - ); -} -``` -" %} + + + + + ```dart + Widget build(BuildContext context) { + return [!ListView!]( + children: const [ + BorderedImage(), + BorderedImage(), + BorderedImage(), + ], + ); + } + ``` + + `ListView`s are commonly used when you have an unknown or very large (or infinite) number of list items. @@ -499,35 +521,38 @@ the `ListView` is displaying a list of to-do items. The todo items are being fetched from a repository, and therefore the number of todos is unknown. - -{% render "docs/code-and-image.md", -image:"fwe/layout/listview_builder.png", -caption: "This figure shows the ListView.builder constructor to display an unknown number of children." -alt: "A screenshot of several widgets laid out vertically. They have expanded to take up all available space on the cross axis." -code:" -```dart -final List items = Repository.fetchTodos(); - -Widget build(BuildContext context) { - return ListView.builder( - itemCount: items.length, - itemBuilder: (context, idx) { - var item = items[idx]; - return Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(item.description), - Text(item.isComplete), - ], - ), - ); - }, - ); -} -``` -" %} + + + + + ```dart + final List items = Repository.fetchTodos(); + + Widget build(BuildContext context) { + return ListView.builder( + itemCount: items.length, + itemBuilder: (context, idx) { + var item = items[idx]; + return Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(item.description), + Text(item.isComplete), + ], + ), + ); + }, + ); + } + ``` + + ## Adaptive layouts @@ -597,26 +622,29 @@ changes based on whether the viewport is less than or equal 600 pixels, or greater than 600 pixels. - -{% render "docs/code-and-image.md", -image:"fwe/layout/layout_builder.png", -caption: "This figure shows a narrow layout, which lays out its children vertically, and a wider layout, which lays out its children in a grid." -alt: "Two screenshots, in which one shows a narrow layout and the other shows a wide layout." -code:" -```dart -Widget build(BuildContext context) { - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - [!if (constraints.maxWidth <= 600)!] { - return _MobileLayout(); - } else { - return _DesktopLayout(); - } - }, - ); -} -``` -" %} + + + + + ```dart + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + [!if (constraints.maxWidth <= 600)!] { + return _MobileLayout(); + } else { + return _DesktopLayout(); + } + }, + ); + } + ``` + + Meanwhile, the `itemBuilder` callback on the `ListView.builder` constructor is passed the @@ -666,34 +694,39 @@ To exemplify this, the following example changes the background color of every other list item. -{% render "docs/code-and-image.md", -image:"fwe/layout/alternating_list_items.png" -caption:"This figure shows a `ListView`, in which its children have alternating background colors. The background colors were determined programmatically based on the index of the child within the `ListView`." -code:" -```dart -final List items = Repository.fetchTodos(); - -Widget build(BuildContext context) { - return ListView.builder( - itemCount: items.length, - itemBuilder: (context, idx) { - var item = items[idx]; - return Container( - [!color: idx % 2 == 0 ? Colors.lightBlue : Colors.transparent!], - padding: const EdgeInsets.all(8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(item.description), - Text(item.isComplete), - ], - ), - ); - }, - ); -} -``` -" %} + + + + + ```dart + final List items = Repository.fetchTodos(); + + Widget build(BuildContext context) { + return ListView.builder( + itemCount: items.length, + itemBuilder: (context, idx) { + var item = items[idx]; + return Container( + [!color: idx % 2 == 0 ? Colors.lightBlue : Colors.transparent!], + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(item.description), + Text(item.isComplete), + ], + ), + ); + }, + ); + } + ``` + + ## Additional resources diff --git a/src/content/get-started/fundamentals/state-management.md b/src/content/get-started/fundamentals/state-management.md index 5150948e08..546549c0be 100644 --- a/src/content/get-started/fundamentals/state-management.md +++ b/src/content/get-started/fundamentals/state-management.md @@ -30,9 +30,8 @@ For other introductions to state management, check out these resources: * Video: [Managing state in Flutter][managing-state-video]. This video shows how to use the [riverpod][] package. - Tutorial: -[State management][]. -This shows how to use `ChangeNotifer` with the [provider][] package. +* Tutorial: [State management][]. + This shows how to use `ChangeNotifer` with the [provider][] package. This guide doesn't use third-party packages like provider or Riverpod. Instead, diff --git a/src/content/get-started/fundamentals/user-input.md b/src/content/get-started/fundamentals/user-input.md index 73ec56bb0c..067a530ad0 100644 --- a/src/content/get-started/fundamentals/user-input.md +++ b/src/content/get-started/fundamentals/user-input.md @@ -39,7 +39,7 @@ which is covered at the end of this section. No matter which design system you choose, the principals on this page apply. ::: -> **Reference**: +> **Reference**: > The [widget catalog][] has an inventory of commonly used widgets in the [Material][] and [Cupertino][] libraries. Next, we'll cover a few of the Material widgets that support common @@ -83,7 +83,7 @@ but styled differently for various use cases, including: - `FloatingActionButton`: An icon button that hovers over content to promote a primary action. -> **Video**: +> **Video**: > [FloatingActionButton (Widget of the Week)][] There are usually 3 main aspects to constructing a button: @@ -109,42 +109,42 @@ You can style a button based on its state using `WidgetStateProperty`. - Finally, a button's `style` controls its appearance: color, border, and so on. + -{% render "docs/code-and-image.md", -image:"fwe/user-input/ElevatedButton.webp", -caption: "This figure shows an ElevatedButton with the text \"Enabled\" being clicked." -alt: "A GIF of an elevated button with the text 'Enabled'" -code:" -```dart -int count = 0; + -@override -Widget build(BuildContext context) { - return ElevatedButton( - style: ElevatedButton.styleFrom( - textStyle: const TextStyle(fontSize: 20), - ), - onPressed: () { - setState(() { - count += 1; - }); - }, - child: const Text('Enabled'), - ); -} -``` -" %} + ```dart + int count = 0; + @override + Widget build(BuildContext context) { + return ElevatedButton( + style: ElevatedButton.styleFrom( + textStyle: const TextStyle(fontSize: 20), + ), + onPressed: () { + setState(() { + count += 1; + }); + }, + child: const Text('Enabled'), + ); + } + ``` -
    +
    -> **Checkpoint**: +> **Checkpoint**: > Complete this tutorial that teaches you how to build a > "favorite" button: [Add interactivity to your Flutter app][]
    - **API Docs**: [`ElevatedButton`][] • [`FilledButton`][] • [`OutlinedButton`][] • [`TextButton`][] • [`IconButton`][] • [`FloatingActionButton`][] + **API Docs**: [`ElevatedButton`][] • [`FilledButton`][] • [`OutlinedButton`][] • [`TextButton`][] • [`IconButton`][] • [`FloatingActionButton`][] [`ElevatedButton`]: {{site.api}}/flutter/material/ElevatedButton-class.html [`FilledButton`]: {{site.api}}/flutter/material/FilledButton-class.html @@ -165,25 +165,29 @@ Flutter's `Text` widget displays text on the screen, but doesn't allow users to highlight or copy the text. `SelectableText` displays a string of _user-selectable_ text. -{% render "docs/code-and-image.md", -image:"fwe/user-input/SelectableText.webp", -caption: "This figure shows a cursor highlighting a portion of a string of text." -alt: 'A GIF of a cursor highlighting two lines of text from a paragraph.' -code:" -```dart -@override -Widget build(BuildContext context) { - return const SelectableText(''' -Two households, both alike in dignity, -In fair Verona, where we lay our scene, -From ancient grudge break to new mutiny, -Where civil blood makes civil hands unclean. -From forth the fatal loins of these two foes'''); -} -``` -" %} + + + + + ```dart + @override + Widget build(BuildContext context) { + return const SelectableText(''' + Two households, both alike in dignity, + In fair Verona, where we lay our scene, + From ancient grudge break to new mutiny, + Where civil blood makes civil hands unclean. + From forth the fatal loins of these two foes'''); + } + ``` + + -> **Video**: +> **Video**: > [SelectableText (Widget of the Week)][] [SelectableText (Widget of the Week)]: {{site.youtube-site}}/watch?v=ZSU3ZXOs6hc @@ -195,32 +199,36 @@ From forth the fatal loins of these two foes'''); different text styles. It's not for handling user input, but is useful if you're allowing users edit and format text. -{% render "docs/code-and-image.md", -image:"fwe/user-input/RichText.png", -caption: "This figure shows a string of text formatted with different text styles." -alt: 'A screenshot of the text "Hello bold world!" with the word "bold" in bold font.' -code:" -```dart -@override -Widget build(BuildContext context) { - return RichText( - text: TextSpan( - text: 'Hello ', - style: DefaultTextStyle.of(context).style, - children: const [ - TextSpan(text: 'bold', style: TextStyle(fontWeight: FontWeight.bold)), - TextSpan(text: ' world!'), - ], - ), - ); -} -``` -" %} + + + + + ```dart + @override + Widget build(BuildContext context) { + return RichText( + text: TextSpan( + text: 'Hello ', + style: DefaultTextStyle.of(context).style, + children: const [ + TextSpan(text: 'bold', style: TextStyle(fontWeight: FontWeight.bold)), + TextSpan(text: ' world!'), + ], + ), + ); + } + ``` -> **Video**: + + +> **Video**: > [Rich Text (Widget of the Week)][] -> **Code**: +> **Code**: > [Rich Text Editor code][] [Rich Text (Widget of the Week)]: {{site.youtube-site}}/watch?v=rykDVh-QFfw @@ -251,28 +259,32 @@ The class supports other configurable properties, such as `obscureText` that turns each letter into a `readOnly` circle as its entered and `readOnly` which prevents the user from changing the text. -{% render "docs/code-and-image.md", -image:"fwe/user-input/TextField.webp", -caption: "This figure shows text being typed into a TextField with a selected border and label." -alt: "A GIF of a text field with the label 'Mascot Name', purple focus border and the phrase 'Dash the hummingbird' being typed in." -code:" -```dart -final TextEditingController _controller = TextEditingController(); + -@override -Widget build(BuildContext context) { - return TextField( - controller: _controller, - decoration: const InputDecoration( - border: OutlineInputBorder(), - labelText: 'Mascot Name', - ), - ); -} -``` -" %} + + + ```dart + final TextEditingController _controller = TextEditingController(); -> **Checkpoint**: + @override + Widget build(BuildContext context) { + return TextField( + controller: _controller, + decoration: const InputDecoration( + border: OutlineInputBorder(), + labelText: 'Mascot Name', + ), + ); + } + ``` + + + +> **Checkpoint**: > Complete this 4-part cookbook series that walks > you through how to create a text field, > retrieve its value, and update your app state: @@ -342,18 +354,18 @@ Widget build(BuildContext context) { } ``` -> **Checkpoint**: +> **Checkpoint**: > Complete this tutorial to learn how to [build a form with validation][]. -> **Demo**: +> **Demo**: > [Form app][] -> **Code**: +> **Code**: > [Form app code][]
    - **API Docs**: [`TextField`][] • [`RichText`][] • [`SelectableText`][] • [`Form`][] + **API Docs**: [`TextField`][] • [`RichText`][] • [`SelectableText`][] • [`Form`][] [Build a form with validation]: /cookbook/forms/validation [Form app]: https://github.com/flutter/samples/tree/main/form_app/ @@ -395,55 +407,54 @@ A `SegmentedButton` has a few relevant properties: For example, `style` takes a `ButtonStyle`, providing a way to configure a `selectedIcon`. -{% render "docs/code-and-image.md", -image:"fwe/user-input/segmented-button.webp", -caption: "This figure shows a SegmentedButton, each segment with an icon and -text representing its value." -alt: "A GIF of a SegmentedButton with 4 segments: Day, Week, Month, and Year. -Each has a calendar icon to represent its value and a text label. -Day is first selected, then week and month, then year." -code:" + -```dart -enum Calendar { day, week, month, year } + -// StatefulWidget... -Calendar calendarView = Calendar.day; + ```dart + enum Calendar { day, week, month, year } -@override -Widget build(BuildContext context) { - return SegmentedButton( - segments: const >[ - ButtonSegment( - value: Calendar.day, - label: Text('Day'), - icon: Icon(Icons.calendar_view_day)), - ButtonSegment( - value: Calendar.week, - label: Text('Week'), - icon: Icon(Icons.calendar_view_week)), - ButtonSegment( - value: Calendar.month, - label: Text('Month'), - icon: Icon(Icons.calendar_view_month)), - ButtonSegment( - value: Calendar.year, - label: Text('Year'), - icon: Icon(Icons.calendar_today)), - ], - selected: {calendarView}, - onSelectionChanged: (Set newSelection) { - setState(() { - // By default, there is only a single segment that can be - // selected at a time, so its value is always the first - calendarView = newSelection.first; - }); - }, - ); -} -``` -" %} + // StatefulWidget... + Calendar calendarView = Calendar.day; + @override + Widget build(BuildContext context) { + return SegmentedButton( + segments: const >[ + ButtonSegment( + value: Calendar.day, + label: Text('Day'), + icon: Icon(Icons.calendar_view_day)), + ButtonSegment( + value: Calendar.week, + label: Text('Week'), + icon: Icon(Icons.calendar_view_week)), + ButtonSegment( + value: Calendar.month, + label: Text('Month'), + icon: Icon(Icons.calendar_view_month)), + ButtonSegment( + value: Calendar.year, + label: Text('Year'), + icon: Icon(Icons.calendar_today)), + ], + selected: {calendarView}, + onSelectionChanged: (Set newSelection) { + setState(() { + // By default, there is only a single segment that can be + // selected at a time, so its value is always the first + calendarView = newSelection.first; + }); + }, + ); + } + ``` + + ### Chip @@ -470,50 +481,52 @@ You will typically use `Wrap`, a widget that displays its children in multiple horizontal or vertical runs, to make sure your chips wrap and don't get cut off at the edge of your app. -{% render "docs/code-and-image.md", -image:"fwe/user-input/chip.png", -caption: "This figure shows two rows of Chip widgets, each containing a circular -leading profile image and content text." -alt: "A screenshot of 4 Chips split over two rows with a leading circular -profile image with content text." -code:" -```dart -@override -Widget build(BuildContext context) { - return const SizedBox( - width: 500, - child: Wrap( - alignment: WrapAlignment.center, - spacing: 8, - runSpacing: 4, - children: [ - Chip( - avatar: CircleAvatar( - backgroundImage: AssetImage('assets/images/dash_chef.png')), - label: Text('Chef Dash'), - ), - Chip( - avatar: CircleAvatar( - backgroundImage: - AssetImage('assets/images/dash_firefighter.png')), - label: Text('Firefighter Dash'), - ), - Chip( - avatar: CircleAvatar( - backgroundImage: AssetImage('assets/images/dash_musician.png')), - label: Text('Musician Dash'), - ), - Chip( - avatar: CircleAvatar( - backgroundImage: AssetImage('assets/images/dash_artist.png')), - label: Text('Artist Dash'), - ), - ], - ), - ); -} -``` -" %} + + + + + ```dart + @override + Widget build(BuildContext context) { + return const SizedBox( + width: 500, + child: Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 4, + children: [ + Chip( + avatar: CircleAvatar( + backgroundImage: AssetImage('assets/images/dash_chef.png')), + label: Text('Chef Dash'), + ), + Chip( + avatar: CircleAvatar( + backgroundImage: + AssetImage('assets/images/dash_firefighter.png')), + label: Text('Firefighter Dash'), + ), + Chip( + avatar: CircleAvatar( + backgroundImage: AssetImage('assets/images/dash_musician.png')), + label: Text('Musician Dash'), + ), + Chip( + avatar: CircleAvatar( + backgroundImage: AssetImage('assets/images/dash_artist.png')), + label: Text('Artist Dash'), + ), + ], + ), + ); + } + ``` + + [InputChip]: {{site.api}}/flutter/material/InputChip-class.html [ChoiceChip]: {{site.api}}/flutter/material/ChoiceChip-class.html @@ -540,62 +553,63 @@ Configuration parameters include the following: - Additional parameters are also available for customizing the widget's look and behavior. -{% render "docs/code-and-image.md", -image:"fwe/user-input/dropdownmenu.webp", -caption: "This figure shows a DropdownMenu widget with 5 value options. Each -option's text color is styled to represent the color value." -alt: "A GIF the DropdownMenu widget that is selected, it displays 5 options: -Blue, Pink, Green, Orange, and Grey. The option text is displayed in the color -of its value." -code:" -```dart -enum ColorLabel { - blue('Blue', Colors.blue), - pink('Pink', Colors.pink), - green('Green', Colors.green), - orange('Orange', Colors.orange), - grey('Grey', Colors.grey); - - const ColorLabel(this.label, this.color); - final String label; - final Color color; -} + + + + + ```dart + enum ColorLabel { + blue('Blue', Colors.blue), + pink('Pink', Colors.pink), + green('Green', Colors.green), + orange('Orange', Colors.orange), + grey('Grey', Colors.grey); + + const ColorLabel(this.label, this.color); + final String label; + final Color color; + } -// StatefulWidget... -@override -Widget build(BuildContext context) { - return DropdownMenu( - initialSelection: ColorLabel.green, - controller: colorController, - // requestFocusOnTap is enabled/disabled by platforms when it is null. - // On mobile platforms, this is false by default. Setting this to true will - // trigger focus request on the text field and virtual keyboard will appear - // afterward. On desktop platforms however, this defaults to true. - requestFocusOnTap: true, - label: const Text('Color'), - onSelected: (ColorLabel? color) { - setState(() { - selectedColor = color; - }); - }, - dropdownMenuEntries: ColorLabel.values - .map>( - (ColorLabel color) { - return DropdownMenuEntry( - value: color, - label: color.label, - enabled: color.label != 'Grey', - style: MenuItemButton.styleFrom( - foregroundColor: color.color, - ), - ); - }).toList(), - ); -} -``` -" %} + // StatefulWidget... + @override + Widget build(BuildContext context) { + return DropdownMenu( + initialSelection: ColorLabel.green, + controller: colorController, + // requestFocusOnTap is enabled/disabled by platforms when it is null. + // On mobile platforms, this is false by default. Setting this to true will + // trigger focus request on the text field and virtual keyboard will appear + // afterward. On desktop platforms however, this defaults to true. + requestFocusOnTap: true, + label: const Text('Color'), + onSelected: (ColorLabel? color) { + setState(() { + selectedColor = color; + }); + }, + dropdownMenuEntries: ColorLabel.values + .map>( + (ColorLabel color) { + return DropdownMenuEntry( + value: color, + label: color.label, + enabled: color.label != 'Grey', + style: MenuItemButton.styleFrom( + foregroundColor: color.color, + ), + ); + }).toList(), + ); + } + ``` + + -> **Video**: +> **Video**: > [DropdownMenu (Widget of the Week)][] [DropdownMenu (Widget of the Week)]: {{site.youtube-site}}/watch?v=giV9AbM2gd8?si=E23hjg72cjMTe_mz @@ -613,41 +627,41 @@ Configuration parameters for the `Slider` widget: - `divisions` establishes a discrete interval with which the user can move the handle along the track. + -{% render "docs/code-and-image.md", -image:"fwe/user-input/slider.webp", -caption: "This figure shows a slider widget with a value ranging from 0.0 to 5.0 -broken up into 5 divisions. It shows the current value as a label as the dial -is dragged." -alt: "A GIF of a slider that has the dial dragged left to right in increments -of 1, from 0.0 to 5.0" -code:" -```dart -double _currentVolume = 1; + -@override -Widget build(BuildContext context) { - return Slider( - value: _currentVolume, - max: 5, - divisions: 5, - label: _currentVolume.toString(), - onChanged: (double value) { - setState(() { - _currentVolume = value; - }); - }, - ); -} -``` -" %} + ```dart + double _currentVolume = 1; -> **Video**: + @override + Widget build(BuildContext context) { + return Slider( + value: _currentVolume, + max: 5, + divisions: 5, + label: _currentVolume.toString(), + onChanged: (double value) { + setState(() { + _currentVolume = value; + }); + }, + ); + } + ``` + + + +> **Video**: > [Slider, RangeSlider, CupertinoSlider (Widget of the Week)][]
    - **API Docs:** [`SegmentedButton`][] • [`DropdownMenu`][] • [`Slider`][] • [`Chip`][] + **API Docs:** [`SegmentedButton`][] • [`DropdownMenu`][] • [`Slider`][] • [`Chip`][] [Slider, RangeSlider, CupertinoSlider (Widget of the Week)]: {{site.youtube-site}}/watch?v=ufb4gIPDmEss [`SegmentedButton`]: {{site.api}}/flutter/material/SegmentedButton-class.html @@ -681,58 +695,63 @@ The configuration for `Checkbox` and `Switch` contain: ### Checkbox -{% render "docs/code-and-image.md", -image:"fwe/user-input/checkbox.webp", -caption: "This figure shows a checkbox being checked and unchecked." -alt: "A GIF that shows a pointer clicking a checkbox -and then clicking again to uncheck it." -code:" -```dart -bool isChecked = false; + -@override -Widget build(BuildContext context) { - return Checkbox( - checkColor: Colors.white, - value: isChecked, - onChanged: (bool? value) { - setState(() { - isChecked = value!; - }); - }, - ); -} -``` -" %} + + + ```dart + bool isChecked = false; + + @override + Widget build(BuildContext context) { + return Checkbox( + checkColor: Colors.white, + value: isChecked, + onChanged: (bool? value) { + setState(() { + isChecked = value!; + }); + }, + ); + } + ``` + + ### Switch -{% render "docs/code-and-image.md", -image:"fwe/user-input/Switch.webp", -caption: "This figure shows a Switch widget that is toggled on and off." -alt: "A GIF of a Switch widget that is toggled on and off. In its off state, -it is gray with dark gray borders. In its on state, -it is red with a light red border." -code:" -```dart -bool light = true; + -@override -Widget build(BuildContext context) { - return Switch( - // This bool value toggles the switch. - value: light, - activeThumbColor: Colors.red, - onChanged: (bool value) { - // This is called when the user toggles the switch. - setState(() { - light = value; - }); - }, - ); -} -``` -" %} + + + ```dart + bool light = true; + + @override + Widget build(BuildContext context) { + return Switch( + // This bool value toggles the switch. + value: light, + activeThumbColor: Colors.red, + onChanged: (bool value) { + // This is called when the user toggles the switch. + setState(() { + light = value; + }); + }, + ); + } + ``` + + ### Radio @@ -747,119 +766,122 @@ the other radio buttons are unselected. - `RadioGroup` has an `onChanged` callback that gets triggered when users click it, like `Switch` and `Checkbox`. -{% render "docs/code-and-image.md", -image:"fwe/user-input/Radio.webp", -caption: "This figure shows a column of ListTiles containing a radio button and -label, where only one radio button can be selected at a time." -alt: "A GIF of 4 ListTiles in a column, each containing a leading Radio button -and title text. The Radio buttons are selected in order from top to bottom." -code:" -```dart -enum Character { musician, chef, firefighter, artist } + -class RadioExample extends StatefulWidget { - const RadioExample({super.key}); + - @override - State createState() => _RadioExampleState(); -} + ```dart + enum Character { musician, chef, firefighter, artist } -class _RadioExampleState extends State { - Character? _character = Character.musician; + class RadioExample extends StatefulWidget { + const RadioExample({super.key}); - void setCharacter(Character? value) { - setState(() { - _character = value; - }); + @override + State createState() => _RadioExampleState(); } - @override - Widget build(BuildContext context) { - return RadioGroup( - groupValue: _character, - onChanged: setCharacter, - child: Column( - children: [ - ListTile( - title: const Text('Musician'), - leading: Radio(value: Character.musician), - ), - ListTile( - title: const Text('Chef'), - leading: Radio(value: Character.chef), - ), - ListTile( - title: const Text('Firefighter'), - leading: Radio(value: Character.firefighter), - ), - ListTile( - title: const Text('Artist'), - leading: Radio(value: Character.artist), - ), - ], - ), - ); + class _RadioExampleState extends State { + Character? _character = Character.musician; + + void setCharacter(Character? value) { + setState(() { + _character = value; + }); + } + + @override + Widget build(BuildContext context) { + return RadioGroup( + groupValue: _character, + onChanged: setCharacter, + child: Column( + children: [ + ListTile( + title: const Text('Musician'), + leading: Radio(value: Character.musician), + ), + ListTile( + title: const Text('Chef'), + leading: Radio(value: Character.chef), + ), + ListTile( + title: const Text('Firefighter'), + leading: Radio(value: Character.firefighter), + ), + ListTile( + title: const Text('Artist'), + leading: Radio(value: Character.artist), + ), + ], + ), + ); + } } -} -``` -" %} + ``` + + #### Bonus: CheckboxListTile & SwitchListTile These convenience widgets are the same checkbox and switch widgets, but support a label (as a `ListTile`). -{% render "docs/code-and-image.md", -image:"fwe/user-input/SpecialListTiles.webp", -caption: "This figure shows a column containing a CheckboxListTile and -a SwitchListTile being toggled." -alt: "A ListTile with a leading icon, title text, and a trailing checkbox being -checked and unchecked. It also shows a ListTile with a leading icon, title text -and a switch being toggled on and off." -code:" -```dart -double timeDilation = 1.0; -bool _lights = false; + -@override -Widget build(BuildContext context) { - return Column( - children: [ - CheckboxListTile( - title: const Text('Animate Slowly'), - value: timeDilation != 1.0, - onChanged: (bool? value) { - setState(() { - timeDilation = value! ? 10.0 : 1.0; - }); - }, - secondary: const Icon(Icons.hourglass_empty), - ), - SwitchListTile( - title: const Text('Lights'), - value: _lights, - onChanged: (bool value) { - setState(() { - _lights = value; - }); - }, - secondary: const Icon(Icons.lightbulb_outline), - ), - ], - ); -} -``` -" %} + + + ```dart + double timeDilation = 1.0; + bool _lights = false; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + CheckboxListTile( + title: const Text('Animate Slowly'), + value: timeDilation != 1.0, + onChanged: (bool? value) { + setState(() { + timeDilation = value! ? 10.0 : 1.0; + }); + }, + secondary: const Icon(Icons.hourglass_empty), + ), + SwitchListTile( + title: const Text('Lights'), + value: _lights, + onChanged: (bool value) { + setState(() { + _lights = value; + }); + }, + secondary: const Icon(Icons.lightbulb_outline), + ), + ], + ); + } + ``` -> **Video**: + + +> **Video**: > [CheckboxListTile (Widget of the Week)][] -> **Video**: +> **Video**: > [SwitchListTile (Widget of the Week)][]
    - **API Docs**: + **API Docs**: [`Checkbox`][] • [`CheckboxListTile`][] • [`Switch`][] • [`SwitchListTile`][] • [`Radio`][] @@ -892,48 +914,49 @@ Activate by calling the `showDatePicker` function, which returns a `Future`, so don't forget to await the asynchronous function call! -{% render "docs/code-and-image.md", -image:"fwe/user-input/DatePicker.webp", -caption: "This figure shows a DatePicker that is displayed when the -'Pick a date' button is clicked." -alt: "A GIF of a pointer clicking a button that says 'Pick a date', -then shows a date picker. The date Friday, August 30 is selected and the 'OK' -button is clicked." -code:" -```dart -DateTime? selectedDate; + -@override -Widget build(BuildContext context) { - var date = selectedDate; + - return Column(children: [ - Text( - date == null - ? "You haven't picked a date yet." - : DateFormat('MM-dd-yyyy').format(date), - ), - ElevatedButton.icon( - icon: const Icon(Icons.calendar_today), - onPressed: () async { - var pickedDate = await showDatePicker( - context: context, - initialEntryMode: DatePickerEntryMode.calendarOnly, - initialDate: DateTime.now(), - firstDate: DateTime(2019), - lastDate: DateTime(2050), - ); + ```dart + DateTime? selectedDate; - setState(() { - selectedDate = pickedDate; - }); - }, - label: const Text('Pick a date'), - ) - ]); -} -``` -" %} + @override + Widget build(BuildContext context) { + var date = selectedDate; + + return Column(children: [ + Text( + date == null + ? 'You haven\\\'t picked a date yet.' + : DateFormat('MM-dd-yyyy').format(date), + ), + ElevatedButton.icon( + icon: const Icon(Icons.calendar_today), + onPressed: () async { + var pickedDate = await showDatePicker( + context: context, + initialEntryMode: DatePickerEntryMode.calendarOnly, + initialDate: DateTime.now(), + firstDate: DateTime(2019), + lastDate: DateTime(2050), + ); + + setState(() { + selectedDate = pickedDate; + }); + }, + label: const Text('Pick a date'), + ) + ]); + } + ``` + + ### TimePickerDialog @@ -943,44 +966,45 @@ Instead of returning a `Future`, `showTimePicker` instead returns a `Future`. Once again, don't forget to await the function call! -{% render "docs/code-and-image.md", -image:"fwe/user-input/TimePicker.webp", -caption: "This figure shows a TimePicker that is displayed when the -'Pick a time' button is clicked." -alt: "A GIF of a pointer clicking a button that says 'Pick a time', then shows - a time picker. The time picker shows a circular clock as the cursor moves the - hour hand, then minute hand, selects PM, then the 'OK' button is clicked." -code:" -```dart -TimeOfDay? selectedTime; + -@override -Widget build(BuildContext context) { - var time = selectedTime; + - return Column(children: [ - Text( - time == null ? "You haven't picked a time yet." : time.format(context), - ), - ElevatedButton.icon( - icon: const Icon(Icons.calendar_today), - onPressed: () async { - var pickedTime = await showTimePicker( - context: context, - initialEntryMode: TimePickerEntryMode.dial, - initialTime: TimeOfDay.now(), - ); + ```dart + TimeOfDay? selectedTime; - setState(() { - selectedTime = pickedTime; - }); - }, - label: const Text('Pick a time'), - ) - ]); -} -``` -" %} + @override + Widget build(BuildContext context) { + var time = selectedTime; + + return Column(children: [ + Text( + time == null ? 'You haven\\\'t picked a time yet.' : time.format(context), + ), + ElevatedButton.icon( + icon: const Icon(Icons.calendar_today), + onPressed: () async { + var pickedTime = await showTimePicker( + context: context, + initialEntryMode: TimePickerEntryMode.dial, + initialTime: TimeOfDay.now(), + ); + + setState(() { + selectedTime = pickedTime; + }); + }, + label: const Text('Pick a time'), + ) + ]); + } + ``` + + :::tip Calling `showDatePicker()` and `showTimePicker()` @@ -995,7 +1019,7 @@ on to the `Navigator` stack.
    - **API Docs:** + **API Docs:** [`showDatePicker`][] • [`showTimePicker`][] [`showDatePicker`]: {{site.api}}/flutter/material/showDatePicker.html @@ -1014,54 +1038,56 @@ It has a number of configuration parameters, including: - It's important to include a `key` object as well so that they can be uniquely identified from sibling `Dismissible` widgets in the widget tree. -{% render "docs/code-and-image.md", -image:"fwe/user-input/Dismissible.webp", -caption: "This figure shows a list of Dismissible widgets that each contain a -ListTile. Swiping across the ListTile reveals a green background and makes the tile -disappear." -alt: "A screenshot of three widgets, spaced evenly from each other." -code:" -```dart -List items = List.generate(100, (int index) => index); + -@override -Widget build(BuildContext context) { - return ListView.builder( - itemCount: items.length, - padding: const EdgeInsets.symmetric(vertical: 16), - itemBuilder: (BuildContext context, int index) { - return Dismissible( - background: Container( - color: Colors.green, - ), - key: ValueKey(items[index]), - onDismissed: (DismissDirection direction) { - setState(() { - items.removeAt(index); - }); - }, - child: ListTile( - title: Text( - 'Item ${items[index]}', + + + ```dart + List items = List.generate(100, (int index) => index); + + @override + Widget build(BuildContext context) { + return ListView.builder( + itemCount: items.length, + padding: const EdgeInsets.symmetric(vertical: 16), + itemBuilder: (BuildContext context, int index) { + return Dismissible( + background: Container( + color: Colors.green, ), - ), - ); - }, - ); -} -``` -" %} + key: ValueKey(items[index]), + onDismissed: (DismissDirection direction) { + setState(() { + items.removeAt(index); + }); + }, + child: ListTile( + title: Text( + 'Item ${items[index]}', + ), + ), + ); + }, + ); + } + ``` + + -> **Video**: +> **Video**: > [Dismissible (Widget of the Week)][] -> **Checkpoint**: +> **Checkpoint**: > Complete this tutorial on how to [implement swipe to dismiss][] using the > dismissible widget.
    - **API Docs:** + **API Docs:** [`Dismissible`][] [Dismissible (Widget of the Week)]: {{site.youtube-site}}/watch?v=iEMgjrfuc58?si=f0S7IdaA9PIWIYvl @@ -1075,7 +1101,7 @@ you can use for handling user input in your Flutter app. Check out the [Material Widget library][] and [Material Library API docs][] for a full list of widgets. -> **Demo**: +> **Demo**: > See Flutter's [Material 3 Demo][] for a curated sample of user input widgets > available in the Material library. @@ -1086,7 +1112,7 @@ For example, the [`flutter_slidable`][] package provides a `Slidable` widget that is more customizable than the `Dismissible` widget described in the previous section. -> **Video**: +> **Video**: > [flutter_slidable (Package of the Week)][] [Material Widget Library]: /ui/widgets/material @@ -1104,18 +1130,18 @@ fits the user interaction that you're looking for? You can build your own custom widget and make it interactive using `GestureDetector`. -> **Checkpoint**: +> **Checkpoint**: > Use this recipe as a starting point to create your own _custom_ button widget > that can [handle taps][]. -> **Video**: +> **Video**: > [GestureDetector (Widget of the Week)][] -> **Reference**: +> **Reference**: > Check out [Taps, drags, and other gestures][] which explains how to listen > for, and respond to, gestures in Flutter. -> **Bonus Video**: +> **Bonus Video**: > Curious how Flutter's `GestureArena` turns raw user interaction data into > human recognizable concepts like taps, drags, and pinches? > Check out this video: [GestureArena (Decoding Flutter)][] @@ -1132,13 +1158,13 @@ annotate its meaning with the `Semantics` widget. It provides descriptions and metadata to screen readers and other semantic analysis-based tools. -> **Video**: +> **Video**: > [Semantics (Flutter Widget of the Week)][]
    - **API Docs**: + **API Docs**: [`GestureDetector`][] • [`Semantics`][] [`GestureDetector`]: {{site.api}}/flutter/widgets/GestureDetector-class.html @@ -1153,11 +1179,11 @@ ensure that everything works as expected! These tutorials walk you through writing tests that simulate user interactions in your app: -> **Checkpoint**: +> **Checkpoint**: > Follow this [tap, drag, and enter text][] cookbook article and learn how to > use `WidgetTester` to simulate and test user interactions in your app. -> **Bonus Tutorial**: +> **Bonus Tutorial**: > The [handle scrolling][] cookbook recipe shows you how to verify that > lists of widgets contain the expected content by > scrolling through the lists using widget tests. diff --git a/src/content/get-started/index.md b/src/content/get-started/index.md index c5b0a4a1c1..18efaca1b7 100644 --- a/src/content/get-started/index.md +++ b/src/content/get-started/index.md @@ -21,7 +21,7 @@ your Flutter development environment.
    - + Quick start快速开始 Recommended推荐 @@ -36,7 +36,7 @@ your Flutter development environment.
    - + Custom setup自定义配置
    diff --git a/src/content/index.md b/src/content/index.md index 044a5e94e4..a2cbce46c9 100644 --- a/src/content/index.md +++ b/src/content/index.md @@ -12,11 +12,27 @@ keywords: Flutter文档,Flutter汉语文档,Flutter开发导航 ---
    -{% for card in docsCards -%} - - {{card.description}} + + 配置 Flutter 开发环境,开启 Flutter 应用之旅。 + + + 探索 Flutter SDK 中丰富多样的 Widget 合集。 + + + 将 Flutter 框架的 API 文档添加到书签中。 + + + 浏览示例代码、教程和指导。 + + + 观看 Flutter YouTube 频道上的视频。 + + + 关注 Flutter 中文社区的 bilibili 频道。 + + + 学习如何构建并整合强大的 AI 工具。 -{% endfor -%}
    **To see changes to the site since our last release, diff --git a/src/content/install/index.md b/src/content/install/index.md index f6d38d8d12..68c4bca060 100644 --- a/src/content/install/index.md +++ b/src/content/install/index.md @@ -25,7 +25,7 @@ You can quickly try Flutter online without any local setup.
    DartPad - +
    @@ -36,7 +36,7 @@ You can quickly try Flutter online without any local setup.
    Firebase Studio - +
    diff --git a/src/content/install/troubleshoot.md b/src/content/install/troubleshoot.md index f383a5a07d..6bc6472dd8 100644 --- a/src/content/install/troubleshoot.md +++ b/src/content/install/troubleshoot.md @@ -74,6 +74,38 @@ in a directory like Try relocating Flutter to a different folder, such as `C:\src\flutter`. +### Invoke-Expression: You cannot call a method on a null-valued expression + +__What does this issue look like?__ + +When running `flutter doctor` on Windows, you might see an error like: + +```plaintext +Invoke-Expression : You cannot call a method on a null-valued expression. +At ...\update_engine_version.ps1:60 char:20 +``` + +__Explanation and suggestions__ + +This error typically occurs when the `SystemRoot` environment variable is missing +or when the PowerShell execution policy prevents the script from running correctly. + +To resolve this: + +1. **Run as Administrator**: + Open your PowerShell terminal as an Administrator. + +2. **Check Environment Variables**: + Ensure the `SystemRoot` environment variable is set (usually to `C:\Windows`). You can check its value by running `echo $env:SystemRoot` in your PowerShell terminal. + +3. **Check Execution Policy**: + If the issue persists, you might need to adjust your execution policy. + Run the following command in an Administrator PowerShell window: + + ```powershell + Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser + ``` + ## Android setup ### Having multiple versions of Java installed diff --git a/src/content/packages-and-plugins/developing-packages.md b/src/content/packages-and-plugins/developing-packages.md index 6d6677d4a3..8186ab7fe1 100644 --- a/src/content/packages-and-plugins/developing-packages.md +++ b/src/content/packages-and-plugins/developing-packages.md @@ -241,66 +241,55 @@ The API is connected to the platform-specific implementation(s) using a ### 联合插件 -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. - -Federated plugins (联合插件) 是一种将对不同平台的支持分为单独的软件包。 -所以,联合插件能够使用针对 iOS、Android、Web 甚至是针对汽车 -(例如在 IoT 设备上)分别使用对应的 package。 -除了这些好处之外,它还能够让领域专家在他们最了解的平台上扩展现有平台插件。 - -A federated plugin requires the following packages: - -联合插件需要以下 package: - -**app-facing package** -
    The package that plugin users depend on to use the plugin. - This package specifies the API used by the Flutter app. - -**面向应用的 package** -
    该 package 是用户使用插件的的直接依赖。 - 它指定了 Flutter 应用使用的 API。 - -**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. - -**平台 package** -
    一个或多个包含特定平台代码的 package。 - 面向应用的 package 会调用这些平台 package—— - 除非它们带有一些终端用户需要的特殊平台功能,否则它们不会包含在应用中。 - -**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 +**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, +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 that defines this interface ensures that all platform packages implement the same functionality in a uniform way. -**平台接口 package** -
    将面向应用的 package 与平台 package 进行整合的 package。 - 该 package 会声明平台 package 需要实现的接口,供面向应用的 package 使用。 - 使用单一的平台接口 package 可以确保所有平台 package - 都按照各自的方法实现了统一要求的功能。 - #### Endorsed federated plugin #### 整合的联合插件 Ideally, when adding a platform implementation to -a federated plugin, you will coordinate with the package -author to include your implementation. +a packaged-separated federated plugin, you will coordinate with +the package author to include your implementation. In this way, the original author _endorses_ your implementation. -理想情况下,当你在为一个联合插件添加某个平台的实现时, +理想情况下,当你在向一个 package 分离模式的联合插件添加某个平台的实现时, 你会与 package 的作者合作,将你的实现纳入 package。 For example, say you write a `foobar_windows` diff --git a/src/content/packages-and-plugins/swift-package-manager/for-app-developers.md b/src/content/packages-and-plugins/swift-package-manager/for-app-developers.md index 150a9fccbb..31c9274caa 100644 --- a/src/content/packages-and-plugins/swift-package-manager/for-app-developers.md +++ b/src/content/packages-and-plugins/swift-package-manager/for-app-developers.md @@ -110,15 +110,15 @@ To undo this migration: 1. Navigate to **Package Dependencies** for the project. 1. Click the `FlutterGeneratedPluginSwiftPackage` package, then click - remove. + the button. 1. Navigate to **Frameworks, Libraries, and Embedded Content** for the `Runner` target. -1. Click `FlutterGeneratedPluginSwiftPackage`, then click the - remove. +1. Click `FlutterGeneratedPluginSwiftPackage`, then click + the button. @@ -130,7 +130,7 @@ To undo this migration: 1. Expand **Run Prepare Flutter Framework Script**. -1. Click **delete**. +1. Click the button. diff --git a/src/content/packages-and-plugins/swift-package-manager/for-plugin-authors.md b/src/content/packages-and-plugins/swift-package-manager/for-plugin-authors.md index 5c9817d711..c48c2f8073 100644 --- a/src/content/packages-and-plugins/swift-package-manager/for-plugin-authors.md +++ b/src/content/packages-and-plugins/swift-package-manager/for-plugin-authors.md @@ -117,14 +117,14 @@ To update your unit tests: If the build phase doesn't exist already, create one. - Click the add and + Click the button and then click **New Link Binary With Libraries Phase**. 1. Navigate to **Package Dependencies** for the project. - 1. Click add. + 1. Click the button. 1. In the dialog that opens, click the **Add Local...** button. diff --git a/src/content/perf/best-practices.md b/src/content/perf/best-practices.md index bc400ad6cc..b0980b02e9 100644 --- a/src/content/perf/best-practices.md +++ b/src/content/perf/best-practices.md @@ -465,10 +465,10 @@ For more information and examples, check out: 来自社区的 AbdulRahman AlHamali 撰写的文章 [Creating a `ListView` that loads one page at a time][] -* [`Listview.builder`][] API +* [`ListView.builder`][] API [Creating a `ListView` that loads one page at a time]: {{site.medium}}/saugo360/flutter-creating-a-listview-that-loads-one-page-at-a-time-c5c91b6fabd3 -[`Listview.builder`]: {{site.api}}/flutter/widgets/ListView/ListView.builder.html +[`ListView.builder`]: {{site.api}}/flutter/widgets/ListView/ListView.builder.html [Working with long lists]: /cookbook/lists/long-lists #### Avoid intrinsics diff --git a/src/content/platform-integration/index.md b/src/content/platform-integration/index.md index 03c7bfe7a5..d3d985c6b0 100644 --- a/src/content/platform-integration/index.md +++ b/src/content/platform-integration/index.md @@ -277,7 +277,7 @@ web platform to your Flutter app.
    Interop with JavaScript - +
    diff --git a/src/content/platform-integration/web/faq.md b/src/content/platform-integration/web/faq.md index 24baf1a477..ac1fcfbec9 100644 --- a/src/content/platform-integration/web/faq.md +++ b/src/content/platform-integration/web/faq.md @@ -79,13 +79,12 @@ investigate search engine indexability of Flutter web. ### Does hot reload work with a web app? -Yes! Though it's currently behind an experimental flag. -For more information, check out +Yes! For more information, check out [hot reload on the web][]. [hot reload on the web]: /platform-integration/web/building#hot-reload-web -Hot restart doesn't require a flag and is a fast way of seeing your +Hot restart 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 025ba65550..1da62614a3 100644 --- a/src/content/platform-integration/web/initialization.md +++ b/src/content/platform-integration/web/initialization.md @@ -150,11 +150,36 @@ The `config` argument is an object that can have the following optional fields: |`hostElement`| 用于 Flutter 渲染应用程序的 HTML 元素。未设置时,Flutter web 会占据整个页面。 |`HtmlElement`| |`renderer`| Specifies the [web renderer][web-renderers] for the current Flutter application, either `"canvaskit"` or `"skwasm"`. |`String`| |`renderer`| 指定当前 Flutter 应用程序的 [web 渲染器][web-renderers],可选 `"canvaskit"` 或 `"skwasm"`。 |`String`| +|`forceSingleThreadedSkwasm`| Forces the Skia WASM renderer to run in single-threaded mode for compatibility. |`bool`| +|`forceSingleThreadedSkwasm`| 强制 Skia WASM 渲染器以单线程模式运行,以确保兼容性。 |`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 ## 示例:根据 URL 查询参数自定义 Flutter 配置 diff --git a/src/content/reference/create-new-app.md b/src/content/reference/create-new-app.md index 0e70d5b165..be99548bf5 100644 --- a/src/content/reference/create-new-app.md +++ b/src/content/reference/create-new-app.md @@ -58,7 +58,7 @@ choose your preferred environment and follow the corresponding instructions.
    Add to app - +
    diff --git a/src/content/reference/supported-platforms.md b/src/content/reference/supported-platforms.md index 0213024c02..521851f39e 100644 --- a/src/content/reference/supported-platforms.md +++ b/src/content/reference/supported-platforms.md @@ -19,12 +19,113 @@ Flutter categorizes platforms as follows: Based on these categories, Flutter supports deploying to the following platforms. -{% assign opsys = platforms %} +## Mobile platforms -| Target platform | Target architectures | Supported versions | CI-tested versions | Unsupported versions | -|---|:---:|:---:|:---:|:---:| -{%- for platform in opsys %} - | {{platform.platform}} | {{platform.target-arch}} | {{platform.supported}} | {{platform.ci-tested}} | {{platform.unsupported}} | -{%- endfor %} + + + + -{:.table .table-striped} +## Desktop platforms + + + + + + + + +## Web platforms + + + + + + + diff --git a/src/content/reference/widgets.md b/src/content/reference/widgets.md index c45496980d..b30b65c3b8 100644 --- a/src/content/reference/widgets.md +++ b/src/content/reference/widgets.md @@ -32,26 +32,6 @@ our [videos](/resources/videos) page. [每周 Widget 的视频播放列表]({{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 4f2bcf3187..263ddc1f5e 100644 --- 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 are two ways to migrate away from importing `package:flutter_gen`: +There is one way to migrate away from importing `package:flutter_gen`: 1. Specify `synthetic-package: false` in the accompanying [`l10n.yaml`][] file: @@ -69,12 +69,6 @@ There are two ways 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/release/breaking-changes/index.md b/src/content/release/breaking-changes/index.md index 9931307fa8..b1db5478ce 100644 --- a/src/content/release/breaking-changes/index.md +++ b/src/content/release/breaking-changes/index.md @@ -60,17 +60,21 @@ They're sorted by release and listed in alphabetical order: ### Not yet released to stable -* [`FontWeight` also controls the weight attribute of variable fonts][] -* [UISceneDelegate adoption][] -* [`$FLUTTER_ROOT/version` replaced by `$FLUTTER_ROOT/bin/cache/flutter.version.json`][] +* [Merged threads on Linux][] * [Stop generating `AssetManifest.json`][] +* [`$FLUTTER_ROOT/version` replaced by `$FLUTTER_ROOT/bin/cache/flutter.version.json`][] +* [`FontWeight` also controls the weight attribute of variable fonts][] * [Deprecate `TextField.canRequestFocus`][] +* [Deprecate `findChildIndexCallback` in favor of `findItemIndexCallback` in `ListView` and `SliverList` separated constructors][] +* [Material 3 tokens update][] -[`FontWeight` also controls the weight attribute of variable fonts]: /release/breaking-changes/font-weight-variation -[UISceneDelegate adoption]: /release/breaking-changes/uiscenedelegate +[Merged threads on Linux]: /release/breaking-changes/linux-merged-threads [Stop generating `AssetManifest.json`]: /release/breaking-changes/asset-manifest-dot-json [`$FLUTTER_ROOT/version` replaced by `$FLUTTER_ROOT/bin/cache/flutter.version.json`]: /release/breaking-changes/flutter-root-version-file +[`FontWeight` also controls the weight attribute of variable fonts]: /release/breaking-changes/font-weight-variation [Deprecate `TextField.canRequestFocus`]: /release/breaking-changes/can-request-focus +[Deprecate `findChildIndexCallback` in favor of `findItemIndexCallback` in `ListView` and `SliverList` separated constructors]: /release/breaking-changes/separated-builder-find-child-index-callback +[Material 3 tokens update]: /release/breaking-changes/material-color-utilities ### Released in Flutter 3.38 @@ -80,12 +84,14 @@ They're sorted by release and listed in alphabetical order: * [Deprecate `SemanticsProperties.focusable` and `SemanticsConfiguration.isFocusable`][] * [SnackBar with action no longer auto-dismisses][] * [The default page transition on Android is now `PredictiveBackPageTransitionBuilder`][] +* [UISceneDelegate adoption][] [`CupertinoDynamicColor` wide gamut support]: /release/breaking-changes/wide-gamut-cupertino-dynamic-color [Deprecate `OverlayPortal.targetsRootOverlay`]: /release/breaking-changes/deprecate-overlay-portal-targets-root [Deprecate `SemanticsProperties.focusable` and `SemanticsConfiguration.isFocusable`]: /release/breaking-changes/deprecate-focusable [SnackBar with action no longer auto-dismisses]: /release/breaking-changes/snackbar-with-action-behavior-update [The default page transition on Android is now `PredictiveBackPageTransitionBuilder`]: /release/breaking-changes/default-android-page-transition +[UISceneDelegate adoption]: /release/breaking-changes/uiscenedelegate ### Released in Flutter 3.35 diff --git a/src/content/release/breaking-changes/linux-merged-threads.md b/src/content/release/breaking-changes/linux-merged-threads.md new file mode 100644 index 0000000000..d3fa8956bf --- /dev/null +++ b/src/content/release/breaking-changes/linux-merged-threads.md @@ -0,0 +1,49 @@ +--- +title: Merged threads on Linux +description: >- + Learn about the threading changes on Linux in Flutter 3.39. +--- + +## Summary + +Flutter 3.39 merges the UI and platform threads by default on Linux. + +## Context + +Originally, Flutter had separate threads to produce UI frames and +to interact with the native platform. + +The split-thread design prevented Flutter apps and plugins from using Dart FFI +to interoperate with native APIs that must be called on the platform thread. + +## Description of change + +Flutter version 3.39 merges the UI and platform threads by default on Linux. + +This mirrors all the other platforms, whose threads were merged by default in +Flutter 3.29 (iOS and Android) and 3.35 (macOS and Windows). + +## Migration guide + +Merged threads shouldn't affect your app. + +If you suspect merged threads have caused regressions to your app, +please reach out on [Issue 150525][]. + +## Timeline + +Landed in version: 3.39.0-0.1.pre
    +In stable release: Not yet + +## References + +Relevant issue: + +* [Issue 150525][] + +Relevant PRs: + +* [PR 176759][] + +[Issue 150525]: {{site.repo.flutter}}/issues/150525 +[PR 176759]: {{site.repo.flutter}}/pull/176759 diff --git a/src/content/release/breaking-changes/material-color-utilities.md b/src/content/release/breaking-changes/material-color-utilities.md new file mode 100644 index 0000000000..eb004df18c --- /dev/null +++ b/src/content/release/breaking-changes/material-color-utilities.md @@ -0,0 +1,43 @@ +--- +title: Material Color Utilities update in Flutter +description: >- + The latest Material Color Utilities have been applied to the Flutter Material + library. +--- + +{% render "docs/breaking-changes.md" %} + +## Summary + +This release updates `package:material_color_utilities` from +`v0.11.1` to `0.13.0`. This updated package includes algorithm changes that align +with the [Material 3 tokens update](/release/breaking-changes/material-design-3-token-update). + +The algorithm changes affect the same properties: + +* `onPrimaryContainer` +* `onSecondaryContainer` +* `onTertiaryContainer` +* `onErrorContainer` + +The changes will be reflected when generating a scheme using + +* `ColorScheme.fromSeed` +* `ColorScheme.fromImageProvider` +* `ThemeData(colorScheme:..)` + +## Migration guide + +In general, we believe the colors generated will be more legible and visually +appealing, but if you want to maintain the previous colors when upgrading +you will have to manually set those properties to their desired color after +generating. + +## Timeline + +* Landed in version: `3.39.0-1.0.pre-250` +* In stable release: TBD + +## References + +* Relevant commit: [Update material_color_utilities to v0.13.0](https://github.com/flutter/flutter/commit/153fd7fc603836d8d57032a9cb93118245dfba8c) diff --git a/src/content/release/breaking-changes/separated-builder-find-child-index-callback.md b/src/content/release/breaking-changes/separated-builder-find-child-index-callback.md new file mode 100644 index 0000000000..a67c0fb21d --- /dev/null +++ b/src/content/release/breaking-changes/separated-builder-find-child-index-callback.md @@ -0,0 +1,158 @@ +--- +title: Deprecate `findChildIndexCallback` in favor of `findItemIndexCallback` in `ListView` and `SliverList` separated constructors +description: >- + The findChildIndexCallback parameter in ListView.separated and + SliverList.separated have been deprecated in favor of findItemIndexCallback. +--- + +## Summary + +The `findChildIndexCallback` parameter in `ListView.separated` and +`SliverList.separated` constructors have been deprecated in favor of +`findItemIndexCallback`. The new callback returns item indices directly, +eliminating the need for manual index calculations to account for separators. + +## Background + +In `ListView.separated` and `SliverList.separated` constructors, +the `findChildIndexCallback` was used to locate widgets by their key. +However, this callback returned child indices, which include both items +and separators in the internal widget tree. This meant that developers had to +multiply item indices by 2 to get the correct child index, creating +confusion and error-prone code. + +The new `findItemIndexCallback` parameter simplifies this by working +directly with item indices, which do not include separators. +This makes the API more intuitive and reduces the likelihood of +index calculation errors. + +If you use the deprecated `findChildIndexCallback` parameter, +you will see a deprecation warning: + +``` +'findChildIndexCallback' is deprecated and shouldn't be used. +Use findItemIndexCallback instead. +findChildIndexCallback returns child indices (which include separators), +while findItemIndexCallback returns item indices (which do not). +If you were multiplying results by 2 to account for separators, +you can remove that workaround when migrating to findItemIndexCallback. +This feature was deprecated after v3.37.0-1.0.pre. +``` + +Additionally, if you try to provide both parameters, you will encounter +an assertion error: + +``` +Cannot provide both findItemIndexCallback and findChildIndexCallback. +Use findItemIndexCallback as findChildIndexCallback is deprecated. +``` + +## Migration guide + +To migrate from `findChildIndexCallback` to `findItemIndexCallback`, +replace the parameter name and remove any index multiplications +that were used to account for separators. + +Code before migration: + +```dart +ListView.separated( + itemCount: items.length, + findChildIndexCallback: (Key key) { + final ValueKey valueKey = key as ValueKey; + final int itemIndex = items.indexOf(valueKey.value); + // Multiply by 2 to account for separators + return itemIndex == -1 ? null : itemIndex * 2; + }, + itemBuilder: (BuildContext context, int index) { + return ListTile( + key: ValueKey(items[index]), + title: Text(items[index]), + ); + }, + separatorBuilder: (BuildContext context, int index) => const Divider(), +) +``` + +Code after migration: + +```dart +ListView.separated( + itemCount: items.length, + findItemIndexCallback: (Key key) { + final ValueKey valueKey = key as ValueKey; + final int itemIndex = items.indexOf(valueKey.value); + // Return item index directly - no need to multiply by 2 + return itemIndex == -1 ? null : itemIndex; + }, + itemBuilder: (BuildContext context, int index) { + return ListTile( + key: ValueKey(items[index]), + title: Text(items[index]), + ); + }, + separatorBuilder: (BuildContext context, int index) => const Divider(), +) +``` + +The same migration applies to `SliverList.separated`: + +Code before migration: + +```dart +SliverList.separated( + itemCount: items.length, + findChildIndexCallback: (Key key) { + final ValueKey valueKey = key as ValueKey; + final int itemIndex = items.indexOf(valueKey.value); + return itemIndex == -1 ? null : itemIndex * 2; + }, + itemBuilder: (BuildContext context, int index) { + return Container( + key: ValueKey(items[index]), + child: Text(items[index]), + ); + }, + separatorBuilder: (BuildContext context, int index) => const Divider(), +) +``` + +Code after migration: + +```dart +SliverList.separated( + itemCount: items.length, + findItemIndexCallback: (Key key) { + final ValueKey valueKey = key as ValueKey; + final int itemIndex = items.indexOf(valueKey.value); + return itemIndex == -1 ? null : itemIndex; + }, + itemBuilder: (BuildContext context, int index) { + return Container( + key: ValueKey(items[index]), + child: Text(items[index]), + ); + }, + separatorBuilder: (BuildContext context, int index) => const Divider(), +) +``` + +## Timeline + +Landed in version: 3.38.0-1.0.pre
    +In stable release: Not yet + +## References + +API documentation: + +* [`ListView.separated`][] +* [`SliverList.separated`][] + +Relevant PRs: + +* [Deprecate findChildIndexCallback for separated constructors][] + +[`ListView.separated`]: {{site.api}}/flutter/widgets/ListView/ListView.separated.html +[`SliverList.separated`]: {{site.api}}/flutter/widgets/SliverList/SliverList.separated.html +[Deprecate findChildIndexCallback for separated constructors]: {{site.repo.flutter}}/pull/174491 diff --git a/src/content/release/breaking-changes/uiscenedelegate.md b/src/content/release/breaking-changes/uiscenedelegate.md index 4685a4b767..f72ecc0709 100644 --- a/src/content/release/breaking-changes/uiscenedelegate.md +++ b/src/content/release/breaking-changes/uiscenedelegate.md @@ -6,12 +6,6 @@ description: > {% render "docs/breaking-changes.md" %} -:::note -This is an upcoming breaking change that has not yet been finalized or -implemented. The current details are provisional and might be altered. Further -announcements will be made as the change approaches implementation. -::: - ## Summary Apple now requires iOS developers to adopt the UIScene life cycle. @@ -79,6 +73,9 @@ sequence, plugin registration must now be handled in a new callback called 1. Add `FlutterImplicitEngineDelegate` and move `GeneratedPluginRegistrant`. + + + ```swift title="my_app/ios/Runner/AppDelegate.swift" diff - @objc class AppDelegate: FlutterAppDelegate { + @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @@ -96,6 +93,10 @@ sequence, plugin registration must now be handled in a new callback called + } } ``` + + + + ```objc title="my_app/ios/Runner/AppDelegate.h" diff - @interface AppDelegate : FlutterAppDelegate + @interface AppDelegate : FlutterAppDelegate @@ -112,6 +113,9 @@ sequence, plugin registration must now be handled in a new callback called + } ``` + + + 2. Create method channels and platform views in `didInitializeImplicitFlutterEngine`, if applicable. @@ -120,6 +124,9 @@ If you previously created [method channels][platform-views-docs] or `application:didFinishLaunchingWithOptions:`, move that logic to `didInitializeImplicitFlutterEngine`. + + + ```swift func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { // Register plugins with `engineBridge.pluginRegistry` @@ -136,6 +143,9 @@ move that logic to `didInitializeImplicitFlutterEngine`. } ``` + + + ```objc func didInitializeImplicitFlutterEngine:(NSObject*)engineBridge { // Register plugins with `engineBridge.pluginRegistry` @@ -152,6 +162,9 @@ move that logic to `didInitializeImplicitFlutterEngine`. } ``` + + + :::warning If you try to access the `FlutterViewController` in `application:didFinishLaunchingWithOptions:`, it might result in a crash. @@ -176,13 +189,13 @@ If you were using one of these depreacted APIs, such as [`applicationDidBecomeActive`]({{site.apple-dev}}/documentation/uikit/uiapplicationdelegate/applicationdidbecomeactive(_:)), you will likely need to create a SceneDelegate and migrate to scene life cycle events. See [Apple's -documenation]({{site.apple-dev}}/documentation/technotes/tn3187-migrating-to-the-uikit-scene-based-life-cycle) +documentation]({{site.apple-dev}}/documentation/technotes/tn3187-migrating-to-the-uikit-scene-based-life-cycle) on migrating. If you implement your own SceneDelegate, you must subclass it with `FlutterSceneDelegate` or conform to the `FlutterSceneLifeCycleProvider` protocol. See the [following -examples](/release/breaking-changes/uiscenedelegate/#createupdate-a-scenedelegate-uikit). +examples](/release/breaking-changes/uiscenedelegate/#createupdate-a-scenedelegate). ### Migrate Info.plist @@ -235,6 +248,9 @@ subclassing `FlutterSceneDelegate`. ![New Empty File option in Xcode](/assets/images/docs/breaking-changes/uiscene-new-file.png) + + + For Swift projects, create a `SceneDelegate.swift`: ```swift title=my_app/ios/Runner/SceneDelegate.swift @@ -246,6 +262,13 @@ class SceneDelegate: FlutterSceneDelegate { } ``` +3. Change the "Delegate Class Name" (`UISceneDelegateClassName`) in the +Info.plist from `FlutterSceneDelegate` to +`$(PRODUCT_MODULE_NAME).SceneDelegate`. + + + + For Objective-C projects, create a `SceneDelegate.h` and `SceneDelegate.m`: ```objc title=my_app/ios/Runner/SceneDelegate.h @@ -266,8 +289,10 @@ For Objective-C projects, create a `SceneDelegate.h` and `SceneDelegate.m`: ``` 3. Change the "Delegate Class Name" (`UISceneDelegateClassName`) in the -Info.plist from `FlutterSceneDelegate` to -`$(PRODUCT_MODULE_NAME).SceneDelegate`. +Info.plist from `FlutterSceneDelegate` to `SceneDelegate`. + + + ## Migration guide for adding Flutter to existing app (Add to App) @@ -275,7 +300,10 @@ Similar to the `FlutterAppDelegate`, the `FlutterSceneDelgate` is recommended but not required. The `FlutterSceneDelgate` forwards scene callbacks, such as [`openURL`][] to plugins such as [local_auth][]. -### Create/Update a SceneDelegate (UIKit) +### Create/Update a SceneDelegate + + + ```swift diff import UIKit @@ -285,15 +313,18 @@ but not required. The `FlutterSceneDelgate` forwards scene callbacks, such as + class SceneDelegate: FlutterSceneDelegate { ``` + + + ```objc diff - @interface SceneDelegate : UIResponder + @interface SceneDelegate : FlutterSceneDelegate ``` + + -### Create/Update a SceneDelegate (SwiftUI) - -When using Flutter in a SwifUI app, you can [optionally use a +When using Flutter in a SwiftUI app, you can [optionally use a FlutterAppDelegate](/add-to-app/ios/add-flutter-screen#using-the-flutterappdelegate) to receive application events. To migrate that to use UIScene events, you can make the following changes: @@ -330,6 +361,10 @@ UIApplicationSceneManifest](/assets/images/docs/breaking-changes/uiscenedelegate Otherwise, see [If your app supports multiple scenes](/release/breaking-changes/uiscenedelegate/#if-your-app-supports-multiple-scenes) for further instructions. + + + + ### If you can't directly make FlutterSceneDelegate a subclass If you can't directly make `FlutterSceneDelegate` a subclass, you can use the @@ -337,6 +372,9 @@ If you can't directly make `FlutterSceneDelegate` a subclass, you can use the `FlutterPluginSceneLifeCycleDelegate` object to forward scene life cycle events to Flutter. + + + ```swift title="SceneDelegate.swift" diff import Flutter import UIKit @@ -405,6 +443,10 @@ to Flutter. } } ``` + + + + ```objc title="SceneDelegate.h" diff - @interface SceneDelegate : UIResponder + @interface SceneDelegate : UIResponder @@ -464,6 +506,9 @@ to Flutter. } ``` + + + ### If your app supports multiple scenes When multiple scenes is enabled (UIApplicationSupportsMultipleScenes), Flutter cannot automatically associate a @@ -475,6 +520,9 @@ manually registered with either the `FlutterSceneDelegate` or `FlutterViewController` and `FlutterEngine`, is added to the view heirarchy, the `FlutterEngine` will automatically register for scene events. + + + ```swift title="SceneDelegate.swift" import Flutter import FlutterPluginRegistrant @@ -507,6 +555,10 @@ class SceneDelegate: FlutterSceneDelegate { } } ``` + + + + ```objc title="SceneDelegate.h" #import #import @@ -554,9 +606,15 @@ class SceneDelegate: FlutterSceneDelegate { @end ``` + + + If you manually register a `FlutterEngine` with a scene, you must also unregister it if the view created by the `FlutterEngine` changes scenes. + + + ```swift // If using FlutterSceneDelegate: self.unregisterSceneLifeCycle(with: flutterEngine) @@ -565,6 +623,9 @@ self.unregisterSceneLifeCycle(with: flutterEngine) sceneLifeCycleDelegate.unregisterSceneLifeCycle(with: flutterEngine) ``` + + + ```objc // If using FlutterSceneDelegate: [self unregisterSceneLifeCycleWithFlutterEngine:self.flutterEngine]; @@ -573,6 +634,9 @@ sceneLifeCycleDelegate.unregisterSceneLifeCycle(with: flutterEngine) [self.sceneLifeCycleDelegate unregisterSceneLifeCycleWithFlutterEngine:self.flutterEngine]; ``` + + + ## Migration guide for Flutter plugins Not all plugins use lifecycle events. If your plugin does, though, you will @@ -580,38 +644,44 @@ need to migrate to UIKit's scene-based lifecycle. 1. Update the Dart and Flutter SDK versions in your pubspec.yaml +The new APIs required for this migration are available in Flutter 3.38.0. + ```yaml environment: - sdk: ^3.10.0-290.1.beta - flutter: ">=3.38.0-0.1.pre" + sdk: ^3.10.0 + flutter: ">=3.38.0" ``` -:::warning -The below Flutter APIs are available in the 3.38.0-0.1.pre beta, but are not -yet available on stable. You might consider publishing a -[prerelease](https://dart.dev/tools/pub/publishing#publishing-prereleases) or -[preview](https://dart.dev/tools/pub/publishing#publish-preview-versions) -version of your plugin to migrate early. -::: - 2. Adopt the `FlutterSceneLifeCycleDelegate` protocol + + + ```swift diff - public final class MyPlugin: NSObject, FlutterPlugin { + public final class MyPlugin: NSObject, FlutterPlugin, FlutterSceneLifeCycleDelegate { ``` + + + ```objc diff - @interface MyPlugin : NSObject + @interface MyPlugin : NSObject ``` + + + 3. Registers the plugin as a receiver of `UISceneDelegate` calls. To continue supporting apps that have not migrated to the UIScene lifecycle yet, you might consider remaining registered to the App Delegate and keeping the App Delegate events as well. + + + ```swift diff public static func register(with registrar: FlutterPluginRegistrar) { ... @@ -620,6 +690,9 @@ Delegate events as well. } ``` + + + ```objc diff + (void)registerWithRegistrar:(NSObject *)registrar { ... @@ -628,6 +701,9 @@ Delegate events as well. } ``` + + + 4. Add one or more of the following scene events that are needed for your plugin. @@ -638,6 +714,8 @@ event, visit Apple's documentation on [`UISceneDelegate`]: {{site.apple-dev}}/documentation/uikit/uiscenedelegate [`UIWindowSceneDelegate`]: {{site.apple-dev}}/documentation/uikit/uiwindowscenedelegate + + ```swift public func scene( @@ -671,6 +749,9 @@ public func windowScene( ) -> Bool { } ``` + + + ```objc - (BOOL)scene:(UIScene*)scene willConnectToSession:(UISceneSession*)session @@ -695,6 +776,9 @@ public func windowScene( completionHandler:(void (^)(BOOL succeeded))completionHandler { } ``` + + + 5. Move launch logic from `application:willFinishLaunchingWithOptions:` and `application:didFinishLaunchingWithOptions:` to `scene:willConnectToSession:options:`. @@ -747,8 +831,8 @@ to your pubspec.yaml: ## Timeline -- Landed in main: TBD -- Landed in stable: TBD +- Landed in version: 3.38.0-0.1.pre +- Stable release: 3.38 - Unknown: Apple changes their warning to an assert and Flutter apps that haven't adopted `UISceneDelegate` will start crashing on startup with the latest SDK. diff --git a/src/content/resources/architectural-overview.md b/src/content/resources/architectural-overview.md index 7677debd64..f8df02cdca 100644 --- a/src/content/resources/architectural-overview.md +++ b/src/content/resources/architectural-overview.md @@ -152,32 +152,27 @@ the primitives necessary to support all Flutter applications. The engine is responsible for rasterizing composited scenes whenever a new frame needs to be painted. It provides the low-level implementation of Flutter's core API, -including graphics (through [Impeller][] -on iOS, Android, and desktop (behind a flag), -and [Skia][] on other platforms), text layout, -file and network I/O, accessibility support, -plugin architecture, and a Dart runtime +including graphics text layout, file and network I/O, a Dart runtime, and compile toolchain. **Flutter 引擎** 毫无疑问是 Flutter 的核心, 它主要使用 C++ 编写,并提供了 Flutter 应用所需的原语。 当需要绘制新一帧的内容时,引擎将负责对需要合成的场景进行栅格化。 -它提供了 Flutter 核心 API 的底层实现,包括图形 -(在 iOS 和 Android 上通过 [Impeller][],在其他平台上通过 [Skia][])、 -文本布局、文件及网络 IO、辅助功能支持、插件架构和 Dart 运行环境及编译环境的工具链。 +它提供了 Flutter 核心 API 的底层实现, +包括图形文本布局、文件及网络 I/O、Dart 运行时及编译环境的工具链。 :::note If you have a question about which devices support -Impeller, check out [Can I use Impeller?][] +Impeller, check out [Impeller availability][] for detailed information. 如果你对哪些设备支持 Impeller 有疑问, -请查看 [Can I use Impeller?][] 了解更多详细信息。 +请查看 [Impeller availability][] 了解更多详细信息。 ::: -[Can I use Impeller?]: {{site.main-url}}/go/can-i-use-impeller +[Impeller availability]: {{site.main-url}}/go/can-i-use-impeller [Skia]: https://skia.org [Impeller]: /perf/impeller @@ -1009,12 +1004,12 @@ Impeller 与应用一同捆绑运行, :::note If you want to know which devices Impeller supports, -check out [Can I use Impeller?][]. +check out [Impeller availability][]. For more information, visit [Impeller rendering engine][] 如果你想知道 Impeller 支持哪些设备, -请查看 [Can I use Impeller?][]。 +请查看 [Impeller availability][]。 更多信息,请查阅 [Impeller 渲染引擎][Impeller rendering engine]。 ::: diff --git a/src/content/resources/games-toolkit.md b/src/content/resources/games-toolkit.md index 0aa612db53..d5a0a18f89 100644 --- a/src/content/resources/games-toolkit.md +++ b/src/content/resources/games-toolkit.md @@ -34,7 +34,8 @@ These games respond to simple user input, like tapping on a card or entering a number or letter. These games are well suited for Flutter. -_Real-time games_ cover games a series of actions require real time responses. +_Real-time games_ cover games with a series of actions that +require real-time responses. These include endless runner games, racing games, and so on. You might want to create a game with advanced features like collision detection, camera views, game loops, and the like. @@ -45,7 +46,7 @@ These types of games could use an open source game engine like the The Casual Games Toolkit provides the following free resources. -* A repository that includes three new game templates that provide +* A repository that includes three game templates that provide a starting point for building a casual game. 1. A [base game template][basic-template] @@ -123,18 +124,18 @@ Are you ready? To get started: have a Discord account). 1. Review the codelabs and cookbook recipes. - * {{recipeIcon}} Build a [multiplayer game][multiplayer-recipe] with Cloud Firestore. - * {{codelab}} Build a [word puzzle][] with Flutter.—**NEW** - * {{codelab}} Build a [2D physics game][] with Flutter and Flame.—**NEW** - * {{codelab}} [Add sound and music][] to your Flutter game with SoLoud.—**NEW** - * {{recipeIcon}}Make your games more engaging + * Build a [multiplayer game][multiplayer-recipe] with Cloud Firestore. + * Build a [word puzzle][] with Flutter. + * Build a [2D physics game][] with Flutter and Flame. + * [Add sound and music][] to your Flutter game with SoLoud. + * Make your games more engaging with [leaderboards and achievements][leaderboard-recipe]. - * Monetize your games with {{recipeIcon}}[in-game ads][ads-recipe] - and {{codelab}} [in-app purchases][iap-recipe]. + * Monetize your games with [in-game ads][ads-recipe] + and [in-app purchases][iap-recipe]. * Add user authentication flow to your game with - {{recipeIcon}} [Firebase Authentication][firebase-auth]. + [Firebase Authentication][firebase-auth]. * Collect analytics about crashes and errors inside your game - with {{recipeIcon}} [Firebase Crashlytics][firebase-crashlytics]. + with [Firebase Crashlytics][firebase-crashlytics]. 1. Set up accounts on AdMob, Firebase, and Cloud, as needed. 1. Write your game! @@ -164,25 +165,6 @@ and Very Good Ventures created new games. Once you feel ready to go beyond these games templates, investigate other resources that our community recommended. -{% assign pkgIcon = 'package_2' %} -{% assign apiIcon = 'api' %} -{% assign docIcon = 'quick_reference_all' %} -{% assign codelab = 'science' %} -{% assign engine = 'manufacturing' %} -{% assign toolIcon = 'handyman' %} -{% assign recipeIcon = 'book_5' %} -{% assign assetsIcon = 'photo_album' %} - -:::secondary -{{pkgIcon}} Flutter package
    -{{apiIcon}} API documentation
    -{{codelab}} Codelab
    -{{recipeIcon}} Cookbook recipe
    -{{toolIcon}} Desktop application
    -{{assetsIcon}} Game assets
    -{{docIcon}} Guide
    -::: - @@ -193,10 +175,10 @@ investigate other resources that our community recommended. @@ -205,7 +187,7 @@ investigate other resources that our community recommended. @@ -214,9 +196,9 @@ investigate other resources that our community recommended. @@ -225,7 +207,7 @@ investigate other resources that our community recommended. @@ -234,7 +216,7 @@ investigate other resources that our community recommended. @@ -243,8 +225,8 @@ investigate other resources that our community recommended. @@ -253,7 +235,7 @@ investigate other resources that our community recommended. @@ -262,9 +244,9 @@ investigate other resources that our community recommended. @@ -273,9 +255,9 @@ investigate other resources that our community recommended. @@ -284,8 +266,8 @@ investigate other resources that our community recommended. @@ -294,7 +276,7 @@ investigate other resources that our community recommended. @@ -303,7 +285,7 @@ investigate other resources that our community recommended. @@ -312,7 +294,7 @@ investigate other resources that our community recommended. @@ -321,10 +303,10 @@ investigate other resources that our community recommended. @@ -333,9 +315,9 @@ investigate other resources that our community recommended. @@ -344,8 +326,8 @@ investigate other resources that our community recommended. @@ -354,8 +336,8 @@ investigate other resources that our community recommended. @@ -412,7 +394,7 @@ investigate other resources that our community recommended. [game-svc-pkg]: {{site.pub-pkg}}/games_services [rive]: {{site.pub-pkg}}/rive [shared_preferences]: {{site.pub-pkg}}/shared_preferences -[spriteWidget]: {{site.pub-pkg}}/spritewidget +[spritewidget]: {{site.pub-pkg}}/spritewidget [sqflite]: {{site.pub-pkg}}/sqflite [win32_gamepad]: {{site.pub-pkg}}/win32_gamepad [read how the game was created in 6 weeks]: {{site.flutter-blog}}/how-we-built-the-new-super-dash-demo-in-flutter-and-flame-in-just-six-weeks-9c7aa2a5ad31 @@ -432,12 +414,12 @@ the Flutter 3.22 release: we've enabled the SoLoud audio engine. This free and portable engine delivers the low-latency and high-performance sound that's essential for many games. - To help you get started, check out the new codelab, + To help you get started, check out the codelab, [Add sound and music to your Flutter game with SoLoud][], dedicated to adding sound and music to your game. **Word puzzle games** -: Check out the new codelab, +: Check out the codelab, [Build a word puzzle with Flutter][], focused on building word puzzle games. This genre is perfect for exploring Flutter's UI capabilities, @@ -446,7 +428,7 @@ the Flutter 3.22 release: interlocking words without compromising the user experience. **Forge 2D physics engine** -: The new Forge2D codelab, +: The Forge2D codelab, [Build a 2D physics game with Flutter and Flame][], guides you through crafting game mechanics in a Flutter and Flame game using a 2D physics simulation @@ -454,7 +436,7 @@ the Flutter 3.22 release: **Optimize loading speed for Flutter web-based games** : In the fast-paced world of web-based gaming, - a slow loading game is a major deterrent. + a slow-loading game is a major deterrent. Players expect instant gratification and will quickly abandon a game that doesn't load promptly. Hence, we've published a guide, @@ -471,7 +453,7 @@ the Flutter 3.22 release: [Cheng Lin]: {{site.medium}}/@mhclin113_26002 [Forge2D]: {{site.pub-pkgs}}/forge2d -## Other new resources +## Other resources Check out the following videos: 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 new file mode 100644 index 0000000000..b52b948ec0 --- /dev/null +++ b/src/content/tools/devtools/release-notes/release-notes-2.53.0.md @@ -0,0 +1,34 @@ +--- +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/editors.md b/src/content/tools/editors.md index 679b8614fe..f9ee04cfd2 100644 --- a/src/content/tools/editors.md +++ b/src/content/tools/editors.md @@ -53,7 +53,7 @@ with one of the following editors.
    DartPad - +
    @@ -64,7 +64,7 @@ with one of the following editors.
    Firebase Studio - +
    diff --git a/src/content/tools/hot-reload.md b/src/content/tools/hot-reload.md index 10e299635a..b98e9fc5a5 100644 --- a/src/content/tools/hot-reload.md +++ b/src/content/tools/hot-reload.md @@ -135,6 +135,8 @@ The code updates and execution continues. 应用程序将以你的更改进行更新,并保留应用程序当前的状态。 你的应用程序将继续从之前运行热重载命令的位置开始执行。代码被更新并继续执行。 + + :::secondary **What is the difference between hot reload, hot restart, diff --git a/src/content/tutorial/index.md b/src/content/tutorial/index.md index bff15c330a..97c80e9a61 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. -showToc: false +layout: tutorial 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,71 +22,42 @@ 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 -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.) +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 ## 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. -## 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 +[installing Flutter on your machine]: /get-started/install [Firebase Studio]: https://firebase.studio/ [Chrome]: https://www.google.com/chrome/ -[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/ +## Contents + + diff --git a/src/content/tutorial/state/3-change-notifier.md b/src/content/tutorial/state/change-notifier.md similarity index 54% rename from src/content/tutorial/state/3-change-notifier.md rename to src/content/tutorial/state/change-notifier.md index a4dd72f253..9f7631c5cc 100644 --- a/src/content/tutorial/state/3-change-notifier.md +++ b/src/content/tutorial/state/change-notifier.md @@ -1,28 +1,30 @@ --- title: State management in Flutter description: Instructions on how to manage state with ChangeNotifiers. -permalink: /tutorial/change-notifier/ +layout: tutorial 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. -## Create the basic ViewModel structure +[`ChangeNotifier`]: {{site.api}}/flutter/foundation/ChangeNotifier-class.html -Create the `ArticleViewModel` class with its basic structure and state -properties: +## Create the basic view model structure + +Create the `ArticleViewModel` class with its +basic structure and state properties: ```dart class ArticleViewModel extends ChangeNotifier { @@ -35,7 +37,7 @@ class ArticleViewModel extends ChangeNotifier { } ``` -The ViewModel holds three pieces of state: +The `ArticleViewModel` holds three pieces of state: - `summary`: The current Wikipedia article data. - `errorMessage`: Any error that occurred during data fetching. @@ -44,7 +46,7 @@ The ViewModel holds three pieces of state: ## Add constructor initialization Update the constructor to automatically fetch content when the -ViewModel is created: +`ArticleViewModel` is created: ```dart class ArticleViewModel extends ChangeNotifier { @@ -57,17 +59,18 @@ class ArticleViewModel extends ChangeNotifier { getRandomArticleSummary(); } - // Method will be added next + // Methods will be added next. } ``` -This constructor initialization provides immediate content when the -ViewModel is created. Because constructors can't be asynchronous, +This constructor initialization provides immediate content when +a `ArticleViewModel` object is created. +Because constructors can't be asynchronous, it delegates initial content fetching to a separate method. -## Create the getRandomArticleSummary method +## Set up the `getRandomArticleSummary` method -Add the method that fetches data and manages state updates: +Add the `getRandomArticleSummary` that fetches data and manages state updates: ```dart class ArticleViewModel extends ChangeNotifier { @@ -91,19 +94,20 @@ 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. -## Retrieve an article from the ArticleModel +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` -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. +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. ```dart class ArticleViewModel extends ChangeNotifier { @@ -121,7 +125,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; @@ -132,12 +136,14 @@ 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 your -`ArticleViewModel`'s `getRandomArticleSummary` method to print the -results: +printing results to the console. +First, update the `getRandomArticleSummary` method to +print the results: ```dart Future getRandomArticleSummary() async { @@ -146,7 +152,7 @@ Future getRandomArticleSummary() async { try { summary = await model.getRandomArticleSummary(); print('Article loaded: ${summary!.titles.normalized}'); // Temporary - errorMessage = null; + errorMessage = null; // Clear any previous errors. } on HttpException catch (error) { print('Error loading article: ${error.message}'); // Temporary errorMessage = error.message; @@ -157,8 +163,8 @@ Future getRandomArticleSummary() async { } ``` -Then, update the `MainApp` widget to create the ViewModel, which calls -the `getRandomArticleSummary` method on creation: +Then, update the `MainApp` widget to create the `ArticleViewModel`, +which calls the `getRandomArticleSummary` method on creation: ```dart class MainApp extends StatelessWidget { @@ -166,7 +172,7 @@ class MainApp extends StatelessWidget { @override Widget build(BuildContext context) { - // Create ViewModel to test HTTP requests + // Instantiate your `ArticleViewModel` to test its HTTP requests. final viewModel = ArticleViewModel(ArticleModel()); return MaterialApp( @@ -183,9 +189,6 @@ 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. - -[ChangeNotifier]: {{site.api}}/flutter/foundation/ChangeNotifier-class.html -[try-catch block]: {{site.dart-site}}/language/error-handling +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. diff --git a/src/content/tutorial/state/2-http-requests.md b/src/content/tutorial/state/http-requests.md similarity index 52% rename from src/content/tutorial/state/2-http-requests.md rename to src/content/tutorial/state/http-requests.md index b6ada1f7c0..39b7c92931 100644 --- a/src/content/tutorial/state/2-http-requests.md +++ b/src/content/tutorial/state/http-requests.md @@ -1,36 +1,42 @@ --- title: Fetch data from the internet description: Instructions on how to make HTTP requests and parse responses. -permalink: /tutorial/http-request/ +layout: tutorial 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: 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. +_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. 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, you'll create an `ArticleModel`, `ArticleViewModel` and -`ArticleView`, in addition to Flutter widgets. +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 ## 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 a 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 used by a Flutter plugin. A model doesn't usually need to import Flutter libraries. Create an empty `ArticleModel` class in your `main.dart` file: -```dart +```dart title="lib/main.dart" class ArticleModel { // Properties and methods will be added here. } @@ -39,14 +45,13 @@ 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. -```txt +```text https://en.wikipedia.org/api/rest_v1/page/random/summary ``` -Add a method to fetch random Wikipedia article summaries: +Add a method to fetch a random Wikipedia article summary: ```dart class ArticleModel { @@ -63,20 +68,23 @@ class ArticleModel { ``` Use the [`async` and `await`][] keywords to handle asynchronous operations. -The `async` keyword marks a method as asynchronous, and `await` marks -expressions that return a [`Future`][]. +The `async` keyword marks a method as asynchronous, and +`await` waits for 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. +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 ## 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 { @@ -98,8 +106,9 @@ 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 { @@ -119,13 +128,10 @@ class ArticleModel { } ``` -The `dartpedia` package provides the `Summary` class. If you're -unfamiliar with JSON parsing, see the [Dart Getting Started -tutorial][]. +The `dartpedia` package provides the `Summary` class. +If you're unfamiliar with JSON parsing, +check out the [Getting started with Dart][] tutorial. -[architectural pattern]: /architecture/guide -[JSON]: {{site.dart-site}}/tutorial/json -[`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 +[JSON]: {{site.dart-site}}/tutorial/json +[Getting started with Dart]: {{site.dart-site}}/tutorial diff --git a/src/content/tutorial/state/4-listenable-builder.md b/src/content/tutorial/state/listenable-builder.md similarity index 64% rename from src/content/tutorial/state/4-listenable-builder.md rename to src/content/tutorial/state/listenable-builder.md index eda57e8d1f..0f9d66d49d 100644 --- a/src/content/tutorial/state/4-listenable-builder.md +++ b/src/content/tutorial/state/listenable-builder.md @@ -1,21 +1,26 @@ --- title: Rebuild UI when state changes description: Instructions on how to manage state with ChangeNotifiers. -permalink: /tutorial/listenables/ +layout: tutorial 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()`. -## Create the ArticleView widget +[`ListenableBuilder`]: {{site.api}}/flutter/widgets/ListenableBuilder-class.html +[`ChangeNotifier`]: {{site.api}}/flutter/foundation/ChangeNotifier-class.html -Create the `ArticleView` widget that manages the overall page layout -and state handling. Start with the basic class structure and widgets: +## 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: ```dart class ArticleView extends StatelessWidget { @@ -35,9 +40,9 @@ class ArticleView extends StatelessWidget { } ``` -## Create the ViewModel +## Create the article view model -Create the ViewModel in this widget. +Create the `ArticleViewModel` in this widget: ```dart class ArticleView extends StatelessWidget { @@ -59,11 +64,11 @@ class ArticleView extends StatelessWidget { } ``` -## Add ListenableBuilder +## Listen for state changes -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 { @@ -88,22 +93,27 @@ 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. +`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 +## Handle possible view model states -## Handle all states with switch expression +Recall the `ArticleViewModel`, which has three properties that +the UI is interested in: -Recall the `ArticleViewModel`, which has three properties that the UI -is interested in: -* `Summary? summary` -* `bool loading` -* `String? errorMessage` +- `Summary? summary` +- `bool loading` +- `String? errorMessage` -The UI needs to display different widgets based on the combination of -states of all three of those properties. Use Dart's switch expressions +Depending on the combined state of these properties, +the UI can display different widgets. +Use Dart's support for [switch expressions][] to handle all possible combinations in a clean, readable way: ```dart @@ -132,9 +142,9 @@ class ArticleView extends StatelessWidget { (false, null, null) => Center( child: Text('An unknown error has occurred'), ), - // summary must be non-null in this swich case - (false, Summary _, null) => ArticlePage( - summary: viewModel.summary!, + // The summary must be non-null in this switch case. + (false, Summary summary, null) => ArticlePage( + summary: summary, onPressed: viewModel.getRandomArticleSummary, ), }; @@ -145,23 +155,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 ViewModel. +by the view model to build the UI. -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: +Now create a `ArticlePage` widget that displays the actual article content. +This reusable widget takes summary data and a callback function: ```dart class ArticlePage extends StatelessWidget { @@ -181,7 +191,7 @@ class ArticlePage extends StatelessWidget { } ``` -## Add scrollable layout +## Add a scrollable layout Replace the placeholder with a scrollable column layout: @@ -211,7 +221,7 @@ class ArticlePage extends StatelessWidget { ## Add article content and button -Complete the layout with the article widget and navigation button: +Complete the layout with an article widget and navigation button: ```dart class ArticlePage extends StatelessWidget { @@ -245,14 +255,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. -## Create the basic ArticleWidget structure +### Set up the basic article structure -Start with the widget that accepts a summary parameter: +Start with the widget that accepts a `summary` parameter: ```dart class ArticleWidget extends StatelessWidget { @@ -267,7 +277,7 @@ class ArticleWidget extends StatelessWidget { } ``` -## Add padding and column layout +### Add padding and column layout Wrap the content in proper padding and layout: @@ -292,7 +302,7 @@ class ArticleWidget extends StatelessWidget { } ``` -## Add conditional image display +### Add conditional image display Add the article image that only shows when available: @@ -321,10 +331,10 @@ class ArticleWidget extends StatelessWidget { } ``` -## Complete with styled text content +### Complete with styled text content -Replace the placeholder with properly styled title, description, and -extract: +Replace the placeholder text with a +properly styled title, description, and extract: ```dart class ArticleWidget extends StatelessWidget { @@ -364,21 +374,21 @@ class ArticleWidget extends StatelessWidget { } ``` -This widget demonstrates these important UI concepts: +This widget demonstrates a few 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 MainApp to use ArticleView +## Update your app to include the article view -Connect everything together by updating your `MainApp` to use the -complete `ArticleView`. +Connect everything together by updating your `MainApp` to +include your completed `ArticleView`. Replace your existing `MainApp` with this updated version: @@ -402,16 +412,12 @@ 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 -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. +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. -[`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 +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. diff --git a/src/content/tutorial/state/1-set-up-project.md b/src/content/tutorial/state/set-up-project.md similarity index 59% rename from src/content/tutorial/state/1-set-up-project.md rename to src/content/tutorial/state/set-up-project.md index 480290bcea..97cd2318ae 100644 --- a/src/content/tutorial/state/1-set-up-project.md +++ b/src/content/tutorial/state/set-up-project.md @@ -1,7 +1,7 @@ --- title: Set up your project description: Instructions on how to create a new Flutter app. -permalink: /tutorial/set-up-state-app/ +layout: tutorial sitemap: false --- @@ -16,54 +16,66 @@ 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. -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. +:::recommend Support Wikipedia -:::note Support Wikipedia -Wikipedia is a valuable resource, providing free +[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 -terminal, run the following command to create a minimal Flutter app: +Create a new Flutter project using the [Flutter CLI][]. +In your preferred terminal, run the following command to +create a minimal Flutter app: -```bash +```console $ 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: -```shell -$ cd wikipedia_reader -$ flutter pub add http dartpedia +```console +$ 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 +```dart title="lib/main.dart" import 'dart:convert'; import 'dart:io'; @@ -94,25 +106,18 @@ 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: -```bash +```console $ 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/ui-102/2-adaptive-layout.md b/src/content/tutorial/ui-102/adaptive-layout.md similarity index 72% rename from src/content/tutorial/ui-102/2-adaptive-layout.md rename to src/content/tutorial/ui-102/adaptive-layout.md index 5388a0435d..30c0458810 100644 --- a/src/content/tutorial/ui-102/2-adaptive-layout.md +++ b/src/content/tutorial/ui-102/adaptive-layout.md @@ -1,27 +1,28 @@ --- title: LayoutBuilder and adaptive layouts -description: Learn how to use the LayoutBuilder widget -permalink: /tutorial/adaptive-layouts/ +description: Learn how to use the LayoutBuilder widget. +layout: tutorial 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'; @@ -43,8 +44,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'; @@ -134,20 +135,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'; @@ -179,13 +180,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'; @@ -201,7 +202,6 @@ class AdaptiveLayout extends StatefulWidget { State createState() => _AdaptiveLayoutState(); } - class _AdaptiveLayoutState extends State { // New int selectedListId = 0; @@ -231,14 +231,13 @@ class _AdaptiveLayoutState extends State { ``` The `selectedListId` variable tracks the currently selected contact group, -and `_onContactListSelected` updates this value when the -user makes a selection. +and `_onContactListSelected` updates this value when the user makes a choice. ## 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'; @@ -272,7 +271,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(); } }, @@ -286,9 +285,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'), ], ), @@ -296,11 +295,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: @@ -312,17 +311,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 ), @@ -334,22 +333,23 @@ 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/1-intro.md b/src/content/tutorial/ui-102/intro.md similarity index 81% rename from src/content/tutorial/ui-102/1-intro.md rename to src/content/tutorial/ui-102/intro.md index 6a943297a3..3a3d02cc8b 100644 --- a/src/content/tutorial/ui-102/1-intro.md +++ b/src/content/tutorial/ui-102/intro.md @@ -1,14 +1,15 @@ --- title: Advanced UI features -description: | - A gentle introduction into advanced UI features: adaptive layouts, slivers, scrolling, navigation. -permalink: /tutorial/set-up-ui-102/ +description: >- + A gentle introduction into advanced UI features: + adaptive layouts, slivers, scrolling, navigation. +layout: tutorial 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
@@ -35,46 +36,51 @@ 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 terminal or command prompt, and run the following command to
-create a new Flutter project:
+Open your preferred terminal and run
+the following command to create a new Flutter project:
 
-```shell
+```console
 $ 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 file, `lib/data/contact_group.dart`, and add the `ContactGroup` class: +Create a new `lib/data/contact_group.dart` file and +add the `ContactGroup` class: -```dart -// lib/data/contact_group.dart +```dart title="lib/data/contact_group.dart" import 'dart:collection'; import 'package:flutter/cupertino.dart'; import 'contact.dart'; @@ -385,19 +389,18 @@ class ContactGroup { } ``` -A `ContactGroup` represents a collection of contacts, like "All Contacts" -or "Favorites". +A `ContactGroup` represents a collection of contacts, +such as "All Contacts" or "Favorites". Add the following helper code and sample data to the same file: -```dart -// lib/data/contact_group.dart - +```dart title="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) { @@ -443,14 +446,12 @@ 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 -// lib/data/contact_group.dart - +```dart title="lib/data/contact_group.dart" // ... class ContactGroupsModel { @@ -472,17 +473,18 @@ class ContactGroupsModel { } ``` -If you aren't familiar with `ValueNotifier`, you should -[complete the previous tutorial][] before continuing, +If you aren't familiar with `ValueNotifier`, +you should complete the [previous tutorial covering state][] 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 -// lib/main.dart +```dart title="lib/main.dart" import 'package:flutter/cupertino.dart'; import 'package:rolodex/data/contact_group.dart'; @@ -511,9 +513,7 @@ class RolodexApp extends StatelessWidget { } ``` -With all of the extraneous code out of the way, in the next lesson, +With all the extraneous code out of the way, in the next lesson, you'll start building the app in earnest. -[Flutter CLI tool]: /reference/flutter-cli -[complete the previous tutorial]: /tutorial/set-up-state-app -[`cupertino_icons` package]: https://pub.dev/packages/cupertino_icons +[`cupertino_icons` package]: {{site.pub-pkg}}/cupertino_icons diff --git a/src/content/tutorial/ui-102/4-navigation.md b/src/content/tutorial/ui-102/navigation.md similarity index 63% rename from src/content/tutorial/ui-102/4-navigation.md rename to src/content/tutorial/ui-102/navigation.md index 767ac97196..7881e23400 100644 --- a/src/content/tutorial/ui-102/4-navigation.md +++ b/src/content/tutorial/ui-102/navigation.md @@ -1,21 +1,20 @@ --- title: Stack-based navigation -description: Learn how to navigate from one page to another in a Flutter app -permalink: /tutorial/stack-based-navigation/ +description: Learn how to navigate from one page to another in a Flutter app. +layout: tutorial 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. - -```dart -// lib/screens/adaptive_layout.dart +displays the `ContactGroupsPage` by default on small screens. +```dart title="lib/screens/adaptive_layout.dart" class _AdaptiveLayoutState extends State { int selectedListId = 0; @@ -45,16 +44,14 @@ 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 -// lib/screens/contact_groups.dart - +```dart title="lib/screens/contact_groups.dart" class ContactGroupsPage extends StatelessWidget { const ContactGroupsPage({super.key}); @@ -74,15 +71,18 @@ 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. +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: -`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,13 +91,12 @@ back to the previous screen, you'd use the `Navigator.pop` method. ## 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 simple. Add this widget -to the bottom of `lib/screens/contact_groups.dart`: - -```dart -// lib/screens/contact_groups.dart +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`: +```dart title="lib/screens/contact_groups.dart" // ... /// A sidebar component for selecting contact groups, designed for large screens. @@ -121,21 +120,20 @@ 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 is easy to create by -reusing the `_ContactListView`. Add this widget to the bottom of your -`contacts.dart` file: - -```dart -// lib/screens/contacts.dart +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: +```dart title="lib/screens/contacts.dart" // ... /// A detail view component for showing contacts in a specific list. @@ -155,17 +153,16 @@ class ContactListDetail extends StatelessWidget { ``` The detail view reuses `_ContactListView` and sets -`automaticallyImplyLeading: false` to hide the back button, as -navigation is handled by the sidebar. +the `automaticallyImplyLeading` parameter to `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` to import the necessary files and update the -large screen 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: -```dart -// lib/screens/adaptive_layout.dart +```dart title="lib/screens/adaptive_layout.dart" import 'package:flutter/cupertino.dart'; import 'package:rolodex/screens/contact_groups.dart'; import 'package:rolodex/screens/contacts.dart'; @@ -173,9 +170,7 @@ import 'package:rolodex/screens/contacts.dart'; Then update the `_buildLargeScreenLayout` method: -```dart -// lib/screens/adaptive_layout.dart - +```dart title="lib/screens/adaptive_layout.dart" Widget _buildLargeScreenLayout() { return CupertinoPageScaffold( backgroundColor: CupertinoColors.extraLightBackgroundGray, @@ -211,15 +206,17 @@ 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 swipe gesture to return. +- Use the back button or a 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-102/3-slivers.md b/src/content/tutorial/ui-102/slivers.md similarity index 71% rename from src/content/tutorial/ui-102/3-slivers.md rename to src/content/tutorial/ui-102/slivers.md index f0021fd90a..2b91717d16 100644 --- a/src/content/tutorial/ui-102/3-slivers.md +++ b/src/content/tutorial/ui-102/slivers.md @@ -1,55 +1,55 @@ --- title: Advanced scrolling and slivers description: Learn how to implement performant scrolling with slivers. -permalink: /tutorial/slivers/ +layout: tutorial 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, like +- Slivers can **only** be direct children of scroll views, such as `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. - -```dart -// lib/screens/contact_groups.dart +Update `lib/screens/contact_groups.dart` by +adding `_ContactGroupsView` to the bottom of the file. +```dart title="lib/screens/contact_groups.dart" // New imports import 'package:rolodex/data/contact_group.dart'; import 'package:rolodex/main.dart'; @@ -99,26 +99,27 @@ 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 takes a callback function, `onListSelected`, to handle -taps, which makes it adaptable for both navigation and sidebar selection. +It accepts a callback function, `onListSelected`, to handle taps, +which makes it adaptable for both navigation and sidebar selection. -Now, update `ContactGroupsPage` to use this new private widget: +Now, update `ContactGroupsPage` to use your new `_ContactGroupsView` widget: -```dart -// lib/screens/contact_groups.dart +```dart title="lib/screens/contact_groups.dart" class ContactGroupsPage extends StatelessWidget { const ContactGroupsPage({super.key}); @@ -136,17 +137,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 helper method to your `_ContactGroupsView` class: +Now, add icons and contact counts to make the list more informative. +Add this `_buildTrailing` helper method to your `_ContactGroupsView` class: -```dart -// In lib/screens/contact_groups.dart, inside _ContactGroupsView +```dart title="lib/screens/contact_groups.dart" +// Inside _ContactGroupsView: Widget _buildTrailing(List contacts, BuildContext context) { final TextStyle style = CupertinoTheme.of( @@ -167,14 +168,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 +```dart title="lib/screens/contact_groups.dart" import 'package:flutter/cupertino.dart'; import 'package:rolodex/data/contact.dart'; import 'package:rolodex/data/contact_group.dart'; @@ -218,7 +219,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, @@ -254,27 +255,23 @@ 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. - -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. - +Now, work on the contacts page. Just like before, +you'll create a private, reusable view to avoid code duplication. -```dart -// lib/screens/adaptive_layout.dart +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 title="lib/screens/adaptive_layout.dart" class _AdaptiveLayoutState extends State { int selectedListId = 0; @@ -301,12 +298,10 @@ class _AdaptiveLayoutState extends State { } ``` +Update `lib/screens/contacts.dart` by adding `_ContactListView` to +the bottom of the file: -Update `lib/screens/contacts.dart` by adding `_ContactListView` to the -bottom of the file: - -```dart -// lib/screens/contacts.dart +```dart title="lib/screens/contacts.dart" class _ContactListView extends StatelessWidget { const _ContactListView({ required this.listId, @@ -348,8 +343,7 @@ class _ContactListView extends StatelessWidget { Now, update `ContactListsPage` to use this view: -```dart -// lib/screens/contacts.dart +```dart title="lib/screens/contacts.dart" import 'package:flutter/cupertino.dart'; import 'package:rolodex/data/contact_group.dart'; import 'package:rolodex/main.dart'; @@ -373,12 +367,10 @@ data in a reusable component. ## Add search integration with slivers -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`: +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: ```dart class _ContactListView extends StatelessWidget { @@ -426,19 +418,17 @@ 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. - -```dart -// lib/screens/contacts.dart +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 title="lib/screens/contacts.dart" // ... class ContactListSection extends StatelessWidget { @@ -489,16 +479,16 @@ class ContactListSection extends StatelessWidget { } ``` -This widget creates the familiar alphabetized sections that you see in iOS -Contacts. +This widget creates the familiar alphabetized sections that +you see in the iOS Contacts app. ## 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 -// In lib/screens/contacts.dart, inside _ContactListView's builder +```dart title="lib/screens/contacts.dart" +// Inside _ContactListView's builder: final AlphabetizedContactMap contacts = contactList.alphabetizedContacts; @@ -527,10 +517,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 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 a 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/1-create-an-app.md b/src/content/tutorial/ui/1-create-an-app.md deleted file mode 100644 index 911bcec610..0000000000 --- a/src/content/tutorial/ui/1-create-an-app.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -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/4-devtools.md b/src/content/tutorial/ui/4-devtools.md deleted file mode 100644 index 58b4f7c6ea..0000000000 --- a/src/content/tutorial/ui/4-devtools.md +++ /dev/null @@ -1,179 +0,0 @@ ---- -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/create-an-app.md b/src/content/tutorial/ui/create-an-app.md new file mode 100644 index 0000000000..b11199e1e5 --- /dev/null +++ b/src/content/tutorial/ui/create-an-app.md @@ -0,0 +1,131 @@ +--- +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 new file mode 100644 index 0000000000..f51efcb59d --- /dev/null +++ b/src/content/tutorial/ui/devtools.md @@ -0,0 +1,190 @@ +--- +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/tutorial/ui/7-implicit-animations.md b/src/content/tutorial/ui/implicit-animations.md similarity index 56% rename from src/content/tutorial/ui/7-implicit-animations.md rename to src/content/tutorial/ui/implicit-animations.md index 79754124d3..0217cd8de2 100644 --- a/src/content/tutorial/ui/7-implicit-animations.md +++ b/src/content/tutorial/ui/implicit-animations.md @@ -1,29 +1,31 @@ --- title: Simple animations description: Learn the simplest way to implement animations in Flutter. -permalink: /tutorial/animations/ +layout: tutorial 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 -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 intermediate 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 (from white to green, in this example). +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`. -Here's the current `Tile` widget code for reference: +For reference, here's the current implementation of the `Tile` widget: ```dart class Tile extends StatelessWidget { @@ -57,14 +59,15 @@ 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 like `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 such as +`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: @@ -101,25 +104,27 @@ class Tile extends StatelessWidget { } ``` -**`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. +**`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. Now, when the `hitType` changes and the `Tile` widget rebuilds -(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. +(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. -## Adjust the curve +## Adjust the animation curve -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 +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 at different points throughout the animation. {%- comment %} TODO(ewindmill) diagram {%- endcomment %} -To change the `Curve` of this animation, update the the code to the following: +To change the `Curve` of this animation, update the code to the following: ```dart class Tile extends StatelessWidget { @@ -128,12 +133,11 @@ class Tile extends StatelessWidget { final String letter; final HitType hitType; - @override Widget build(BuildContext context) { return AnimatedContainer( duration: Duration(milliseconds: 500), - curve: Curves.decelerate, // NEW + curve: Curves.decelerate, // NEW height: 60, width: 60, decoration: BoxDecoration( @@ -156,16 +160,15 @@ class Tile extends StatelessWidget { } ``` -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. +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. -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). +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. -[`AnimatedContainer`]: {{site.api}}/flutter/widgets/AnimatedContainer-class.html -[`Curve`]: {{site.curve}}/flutter/animation/Curves-class.html -[animations tutorial]: /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 diff --git a/src/content/tutorial/ui/3-layout.md b/src/content/tutorial/ui/layout.md similarity index 59% rename from src/content/tutorial/ui/3-layout.md rename to src/content/tutorial/ui/layout.md index 3cd243418d..1d7790b658 100644 --- a/src/content/tutorial/ui/3-layout.md +++ b/src/content/tutorial/ui/layout.md @@ -1,41 +1,48 @@ --- title: Layout description: Learn about common layout widgets in Flutter. -permalink: /tutorial/layout/ +layout: tutorial 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. -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. +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 ## `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 appbar to your app is by using two widgets: +The simplest way to add an app bar 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`'s `build` method: +Modify the `Scaffold` within your `MainApp` widget's `build` method: ```dart class MainApp extends StatelessWidget { @@ -58,22 +65,24 @@ class MainApp extends StatelessWidget { } ``` +[`Align`]: {{site.api}}/flutter/widgets/Align-class.html + ### An updated widget tree -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. +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: A screenshot that resembles the popular game Wordle. -## Create the GamePage widget +## Create a widget for the game page layout -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 +```dart title="lib/main.dart" class GamePage extends StatelessWidget { const GamePage({super.key}); // This object is part of the game.dart file. @@ -92,7 +101,7 @@ class GamePage extends StatelessWidget { **Solution:** -```dart +```dart title="solution.dart" collapsed class MainApp extends StatelessWidget { const MainApp({super.key}); @@ -107,57 +116,62 @@ 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, 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. +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: ```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`, add one row *for each* element in the `_game.guesses` -list. +Within `Column.children`, for each element in the `_game.guesses` list, +add a `Row` widget as a child. :::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 @@ -168,42 +182,39 @@ class GamePage extends StatelessWidget { spacing: 5.0, children: [ for (var guess in _game.guesses) - Row( - spacing: 5.0, - children: [ - // tiles - ] - ), - ], + Row( + spacing: 5.0, + children: [ + // We'll add the tiles here later. + ] + ), + ], ), ); } } ``` -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. +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 syntactic sugar makes it easier for you to work -with collections of widgets and achieves the same as the following psuedo code: +with collections of widgets, +providing a declarative alternative to the following: ```dart -[...ListOfData.map((element) => Widget(element)).toList()], +[..._game.guesses.map((guess) => Row(/* ... */))], ``` -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. -### 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.) +[collection for element]: /language/collections#for-element ### 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 tree for the first -time, and it now looks like the following figure. +The widget tree for this app has expanded significantly in this lesson. +Now, it looks more like the following (abridged) figure: A diagram showing a tree like structure with a node for each widget in the app. @@ -218,7 +229,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) { @@ -248,10 +259,4 @@ 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/6-stateful-widget.md b/src/content/tutorial/ui/stateful-widget.md similarity index 57% rename from src/content/tutorial/ui/6-stateful-widget.md rename to src/content/tutorial/ui/stateful-widget.md index 4914e40399..9c5d1d163e 100644 --- a/src/content/tutorial/ui/6-stateful-widget.md +++ b/src/content/tutorial/ui/stateful-widget.md @@ -1,42 +1,43 @@ --- title: Stateful widgets description: Learn about StatefulWidgets and rebuilding Flutter UI. -permalink: /tutorial/stateful-widget/ +layout: tutorial 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 has a counter that increases when the button is pressed, -and uses a stateful widget. +that uses a stateful widget with a counter that +increases when the button is pressed. A diagram of a widget tree with a stateful widget and state object. -Here is the basic `StatefulWidget` structure (don't do anything yet): +Here is the basic `StatefulWidget` structure (doesn't do anything yet): ```dart class ExampleWidget extends StatefulWidget { @@ -49,30 +50,29 @@ 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` widget (or any other) from +To convert the `GamePage` (or any other) widget from a stateless widget to a stateful widget, do the following steps: -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`. +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`. :::tip Quick assists You don't have to manually do this work, as the Flutter plugins for -VS Code and IntelliJ provides ["quick assists"][], which will do this -conversion for you. +VS Code and IntelliJ provide ["quick assists"][] that can +do this conversion for you. ::: @@ -119,27 +119,28 @@ 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 -`State`'s `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 `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 of the scope of this tutorial. +The game logic is abstracted away into the `Game` object, +and outside the scope of this tutorial. ::: @@ -188,12 +189,9 @@ 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/5-user-input.md b/src/content/tutorial/ui/user-input.md similarity index 68% rename from src/content/tutorial/ui/5-user-input.md rename to src/content/tutorial/ui/user-input.md index db31be9607..dd9fbdb06f 100644 --- a/src/content/tutorial/ui/5-user-input.md +++ b/src/content/tutorial/ui/user-input.md @@ -1,7 +1,7 @@ --- title: User input -description: Accept input from the user with buttons and text fields -permalink: /tutorial/user-input/ +description: Accept input from the user with buttons and text fields. +layout: tutorial sitemap: false --- @@ -9,15 +9,19 @@ 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`][]. +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 ## 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. @@ -30,7 +34,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 } } @@ -38,27 +42,29 @@ 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 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 a 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 { @@ -89,15 +95,16 @@ 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. ::: @@ -106,15 +113,20 @@ 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. +- 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 ## 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 { @@ -149,8 +161,9 @@ 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 { @@ -184,15 +197,16 @@ 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 { @@ -229,17 +243,10 @@ 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: - -:::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. -::: - +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: ```dart class GuessInput extends StatelessWidget { @@ -277,16 +284,26 @@ 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 { @@ -295,7 +312,8 @@ class GuessInput extends StatelessWidget { final void Function(String) onSubmitGuess; final TextEditingController _textEditingController = TextEditingController(); - @override + + @override Widget build(BuildContext context) { return Row( children: [ @@ -303,19 +321,19 @@ class GuessInput extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(8.0), child: TextField( - maxLength: 5, + maxLength: 5, inputDecoration: InputDecoration( border: OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(35)), ), ), - controller: _textEditingController, - autoFocus: true // NEW - onSubmitted: (String input) { + controller: _textEditingController, + autoFocus: true, // NEW + onSubmitted: (String input) { print(input); // Temporary _textEditingController.clear(); } - ), + ), ), ), ], @@ -324,9 +342,10 @@ 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: @@ -348,8 +367,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 { @@ -376,7 +395,7 @@ class GuessInput extends StatelessWidget { ), ), controller: _textEditingController, - autoFocus: true + autoFocus: true, focusNode: _focusNode, // NEW onSubmitted: (String input) { print(input); // Temporary @@ -392,13 +411,17 @@ class GuessInput extends StatelessWidget { } ``` -Now, when you press ‘Enter’ after inputting text, you can continue typing. +Now, when you press Enter after inputting text, +you can continue typing. + +[`FocusNode`]: {{site.api}}/flutter/widgets/FocusNode-class.html ## 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 @@ -426,10 +449,10 @@ class GuessInput extends StatelessWidget { ), ), controller: _textEditingController, - autoFocus: true + autoFocus: true, focusNode: _focusNode, onSubmitted: (String input) { - onSubmitGuess(_textEditionController.text.trim()) + onSubmitGuess(_textEditingController.text.trim()); _textEditingController.clear(); _focusNode.requestFocus(); } @@ -444,13 +467,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 whitespace. +otherwise, the user could enter a four-letter word plus a space character. ::: -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. +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: ```dart class GamePage extends StatelessWidget { @@ -483,29 +506,30 @@ 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 [`StatefulWidget` 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 next lesson. ## Buttons -To improve the UX on mobile and reflect well-known UI, there should -also be a button that can submit the guess. +To improve the UX on mobile and reflect well-known UI practices, +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 { @@ -516,11 +540,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), @@ -542,11 +566,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), @@ -564,19 +588,24 @@ 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 methods isn't repeated. +Refactor the code such that the logic inside this method isn't repeated. -**Solution** +**Solution:** -```dart +```dart title="solution.dart" collapsed class GuessInput extends StatelessWidget { GuessInput({super.key, required this.onSubmitGuess}); @@ -627,17 +656,3 @@ 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/2-widget-fundamentals.md b/src/content/tutorial/ui/widget-fundamentals.md similarity index 51% rename from src/content/tutorial/ui/2-widget-fundamentals.md rename to src/content/tutorial/ui/widget-fundamentals.md index 105dc52b97..e0f89d50c5 100644 --- a/src/content/tutorial/ui/2-widget-fundamentals.md +++ b/src/content/tutorial/ui/widget-fundamentals.md @@ -1,7 +1,7 @@ --- title: Create widgets description: Learn about stateless widgets and how to build your own. -permalink: /tutorial/stateless-widgets/ +layout: tutorial sitemap: false --- @@ -9,36 +9,50 @@ 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. Create a new file in the `lib` directory called `game.dart`. -2. Copy the following code into it and import that code into your `main.dart` file. +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. -{% render docs/tutorial/game-code.md %} + :::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. -Alternatively, you can find the full lists in [this github repository][], as well as instructions to import it into your project. +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. + ::: +[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 { @@ -51,19 +65,27 @@ 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. 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. +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. -### `Build` method +Passing data into widget constructors is at the core of making widgets reusable. -Finally, there’s the all important `build` method, which must be defined on +[constructor]: {{site.dart-site}}/language/constructors +[enum value]: {{site.dart-site}}/language/enums + +### Build method + +Finally, there's the all important `build` method, which must be defined on every widget, and will always return another widget. ```dart @@ -75,23 +97,24 @@ class Tile extends StatelessWidget { @override Widget build(BuildContext context) { - // TODO: Replace Containter with widgets. - return Container(); + // TODO: Replace Container with widgets. + return Container(); } } ``` ## Use the custom widget -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: +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: ```dart class MainApp extends StatelessWidget { const MainApp({super.key}); - @override + @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( @@ -104,20 +127,21 @@ 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 basic widgets: +The `Tile` widget consists of three of the most common core widgets: `Container`, `Center`, and `Text`. -[`Container`][] is a -convenience widget that wraps several basic styling widgets, like `Padding`, -[`ColoredBox`][], [`SizedBox`][], [`DecoratedBox`][], and many more. +[`Container`][] is a convenience widget that wraps several core styling widgets, +such as [`Padding`][], [`ColoredBox`][], [`SizedBox`][], and [`DecoratedBox`][]. -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 @@ -129,16 +153,22 @@ class Tile extends StatelessWidget { @override Widget build(BuildContext context) { - // NEW - return Container( - width: 60, - height: 60, - // TODO: Add needed widgets + // NEW + return Container( + width: 60, + height: 60, + // TODO: Add needed widgets ); } } ``` +[`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: @@ -152,8 +182,8 @@ class Tile extends StatelessWidget { @override Widget build(BuildContext context) { - // NEW - return Container( + // NEW + return Container( width: 60, height: 60, decoration: BoxDecoration( @@ -165,23 +195,26 @@ 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 on both axes. +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. 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 { @@ -192,16 +225,16 @@ class Tile extends StatelessWidget { @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, + 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, }, // TODO: add children ), @@ -210,13 +243,17 @@ 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 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 the best practice to use the same naming convention in +your own custom widgets. ```dart class Tile extends StatelessWidget { @@ -264,17 +301,4 @@ 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. - - - -[`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 +lesson, you'll start building the game grid itself. diff --git a/src/content/ui/accessibility/assistive-technologies.md b/src/content/ui/accessibility/assistive-technologies.md index 86c20565bf..e11804a6ab 100644 --- a/src/content/ui/accessibility/assistive-technologies.md +++ b/src/content/ui/accessibility/assistive-technologies.md @@ -25,8 +25,9 @@ navigate around your app. **To turn on the screen reader on your device, complete the following steps:** -{% tabs %} -{% tab "TalkBack on Android" %} + + + 1. On your device, open **Settings**. 2. Select **Accessibility** and then **TalkBack**. @@ -36,10 +37,10 @@ navigate around your app. To learn how to find and customize Android's accessibility features, view the following video. -{% ytEmbed 'FQyj_XTl01w', 'Customize Pixel and Android accessibility features' %} + -{% endtab %} -{% tab "VoiceOver on iPhone" %} + + 1. On your device, open **Settings > Accessibility > VoiceOver** 2. Turn the VoiceOver setting on or off @@ -47,10 +48,10 @@ accessibility features, view the following video. To learn how to find and customize iOS accessibility features, view the following video. -{% ytEmbed 'ROIe49kXOc8', 'How to navigate your iPhone or iPad with VoiceOver' %} + -{% endtab %} -{% tab "Browsers" %} + + For web, the following screen readers are currently supported: @@ -79,8 +80,8 @@ void main() { } ``` -{% endtab %} -{% tab "Desktop" %} + + Windows comes with a screen reader called Narrator but some developers recommend using the more popular @@ -93,7 +94,7 @@ Windows apps, check out On a Mac, you can use the desktop version of VoiceOver, which is included in macOS. -{% ytEmbed '5R-6WvAihms', 'Screen reader basics: VoiceOver' %} + On Linux, a popular screen reader is called Orca. It comes pre-installed with some distributions @@ -103,8 +104,8 @@ To learn about using Orca, check out [orca]: https://www.a11yproject.com/posts/getting-started-with-orca -{% endtab %} -{% endtabs %} + +
    diff --git a/src/content/ui/animations/hero-animations.md b/src/content/ui/animations/hero-animations.md index bce1ca65ea..8835337aa3 100644 --- a/src/content/ui/animations/hero-animations.md +++ b/src/content/ui/animations/hero-animations.md @@ -952,8 +952,8 @@ class RadialExpansion extends StatelessWidget { }) : [!clipRectSize = 2.0 * (maxRadius / math.sqrt2);!] final double maxRadius; - final clipRectSize; - final Widget child; + final double clipRectSize; + final Widget? child; @override Widget build(BuildContext context) { diff --git a/src/content/ui/animations/implicit-animations.md b/src/content/ui/animations/implicit-animations.md index c8c8f86f62..eadc2238c4 100644 --- a/src/content/ui/animations/implicit-animations.md +++ b/src/content/ui/animations/implicit-animations.md @@ -25,6 +25,8 @@ about implicit animations in Flutter. 其名字来源于它们所实现的 [`ImplicitlyAnimatedWidget`][] 类。 下列资源提供了许多在 Flutter 中学习使用隐式动画的方法。 +[animation library]: {{site.api}}/flutter/animation/animation-library.html + ## Documentation ## 文档 @@ -52,6 +54,7 @@ about implicit animations in Flutter. [Animations in Flutter codelab]: {{site.codelabs}}/advanced-flutter-animations [`AnimatedContainer` sample]: /cookbook/animation/animated-container +[`AnimatedContainer`]: {{site.api}}/flutter/widgets/AnimatedContainer-class.html [`ImplicitlyAnimatedWidget`]: {{site.api}}/flutter/widgets/ImplicitlyAnimatedWidget-class.html ## Flutter in Focus videos @@ -97,17 +100,25 @@ implicitly animated widgets: 在大约六十秒的时间里,你将会看到每个 widget 的实战代码,以及关于它是如何工作的演示。 下列「每周 Widget」视频涉及了隐含动画 widget 有: -{% assign animatedWidgets = 'AnimatedOpacity, AnimatedPadding, AnimatedPositioned, AnimatedSwitcher' | split: ", " %} -{% assign animatedUrls = 'BV1W54y1U7ma, BV1354y1U7gU, BV1T54y1D7hk, BV1dv4y1o7BG' | split: ", " %} - -{% for widget in animatedWidgets %} -{% assign videoUrl = animatedUrls[forloop.index0] %} -{% assign videoDescription = '了解 ' | append: widget | append: ' Flutter Widget' %} - -
    -

    {{videoDescription}}

    - -{% endfor -%} - -[`AnimatedContainer`]: {{site.api}}/flutter/widgets/AnimatedContainer-class.html -[animation library]: {{site.api}}/flutter/animation/animation-library.html +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    diff --git a/src/content/ui/design/graphics/fragment-shaders.md b/src/content/ui/design/graphics/fragment-shaders.md index e0cbcd219b..90da3ce792 100644 --- a/src/content/ui/design/graphics/fragment-shaders.md +++ b/src/content/ui/design/graphics/fragment-shaders.md @@ -122,6 +122,11 @@ 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/interactivity/index.md b/src/content/ui/interactivity/index.md index ee34226c3f..8302ae400b 100644 --- a/src/content/ui/interactivity/index.md +++ b/src/content/ui/interactivity/index.md @@ -9,8 +9,6 @@ tags: 用户界面,Flutter UI,布局 keywords: 交互,Flutter交互,有状态的widget,无状态,StatefulWidget,状态管理 --- -{% assign examples = site.repo.this | append: "/tree/" | append: site.branch | append: "/examples" -%} - :::secondary 你将学到什么 @@ -1143,13 +1141,13 @@ Wonderous app [running app][wonderous-app], [repo][wonderous-repo] [building layouts tutorial]: /ui/layout/tutorial [community]: {{site.main-url}}/community [Handle taps]: /cookbook/gestures/handling-taps -[`lake.jpg`]: {{examples}}/layout/lakes/step6/images/lake.jpg +[`lake.jpg`]: {{site.repo.this}}/blob/main/examples/layout/lakes/step6/images/lake.jpg [Libraries and imports]: {{site.dart-site}}/language/libraries [`ListView`]: {{site.api}}/flutter/widgets/ListView-class.html -[`main.dart`]: {{examples}}/layout/lakes/step6/lib/main.dart +[`main.dart`]: {{site.repo.this}}/blob/main/examples/layout/lakes/step6/lib/main.dart [Managing state]: #managing-state [Material Design guidelines]: {{site.material}}/styles -[`pubspec.yaml`]: {{examples}}/layout/lakes/step6/pubspec.yaml +[`pubspec.yaml`]: {{site.repo.this}}/blob/main/examples/layout/lakes/step6/pubspec.yaml [`Radio`]: {{site.api}}/flutter/material/Radio-class.html [`ElevatedButton`]: {{site.api}}/flutter/material/ElevatedButton-class.html [wonderous-app]: {{site.wonderous}}/web diff --git a/src/content/ui/layout/tutorial.md b/src/content/ui/layout/tutorial.md index 0e0741068b..904f39d92b 100644 --- a/src/content/ui/layout/tutorial.md +++ b/src/content/ui/layout/tutorial.md @@ -9,8 +9,6 @@ tags: 用户界面,Flutter UI,布局 keywords: 布局教程,自动换行 --- -{% assign examples = site.repo.this | append: "/tree/" | append: site.branch | append: "/examples" -%} - :::secondary What you'll learn * How to lay out widgets next to each other. * How to add space between widgets. @@ -276,7 +274,7 @@ Pass the provided name and location to the `TitleSection` constructor. [automatic reformatting support]: /tools/formatting [hot reload]: /tools/hot-reload -[`lib/main.dart`]: {{examples}}/layout/lakes/step2/lib/main.dart +[`lib/main.dart`]: {{site.repo.this}}/blob/main/examples/layout/lakes/step2/lib/main.dart ## Add the Button section @@ -586,9 +584,9 @@ You can access the resources used in this tutorial from these locations: **Image:** [ch-photo][]
    **Pubspec:** [`pubspec.yaml`][]
    -[`main.dart`]: {{examples}}/layout/lakes/step6/lib/main.dart +[`main.dart`]: {{site.repo.this}}/blob/main/examples/layout/lakes/step6/lib/main.dart [ch-photo]: https://unsplash.com/photos/red-and-gray-tents-in-grass-covered-mountain-5Rhl-kSRydQ -[`pubspec.yaml`]: {{examples}}/layout/lakes/step6/pubspec.yaml +[`pubspec.yaml`]: {{site.repo.this}}/blob/main/examples/layout/lakes/step6/pubspec.yaml ## Next Steps diff --git a/src/content/ui/navigation/index.md b/src/content/ui/navigation/index.md index 00313e12eb..016343bb95 100644 --- a/src/content/ui/navigation/index.md +++ b/src/content/ui/navigation/index.md @@ -47,7 +47,9 @@ visit the [Navigator API documentation][`Navigator`]. :::note We don't recommend using named routes for most applications. -For more information, see the Limitations section below. +Instead, use [go_router][] (or another routing package) or +use `Navigator` with [`MaterialPageRoute`][]. +For more information, see the [Limitations](#limitations) section. ::: Applications with simple navigation and deep linking requirements can use the @@ -67,7 +69,7 @@ onPressed: () { [Navigate with named routes][] recipe from the Flutter Cookbook. -### Limitations +### Limitations {: #limitations} Although named routes can handle deep links, the behavior is always the same and can't be customized. When a new deep link is received by the platform, Flutter @@ -75,7 +77,8 @@ pushes a new `Route` onto the Navigator regardless of where the user currently i Flutter also doesn't support the browser forward button for applications using named routes. For these reasons, we don't recommend using named routes in most -applications. +applications. Instead, use a routing package like [go_router][] or +use `Navigator` with [`MaterialPageRoute`][]. ## Using the Router @@ -176,3 +179,4 @@ resources: [Understanding navigation]: https://material.io/design/navigation/understanding-navigation.html [Learning Flutter's new navigation and routing system]: {{site.medium}}/flutter/learning-flutters-new-navigation-and-routing-system-7c9068155ade [Router design document]: {{site.main-url}}/go/navigator-with-router +[`MaterialPageRoute`]: {{site.api}}/flutter/material/MaterialPageRoute-class.html diff --git a/src/content/ui/widgets/accessibility.md b/src/content/ui/widgets/accessibility.md deleted file mode 100644 index 2defdab4e6..0000000000 --- a/src/content/ui/widgets/accessibility.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -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 deleted file mode 100644 index 8641dddee0..0000000000 --- a/src/content/ui/widgets/animation.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -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 deleted file mode 100644 index 359653407f..0000000000 --- a/src/content/ui/widgets/assets.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -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 deleted file mode 100644 index a7b399746d..0000000000 --- a/src/content/ui/widgets/async.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -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 deleted file mode 100644 index 8d64c71004..0000000000 --- a/src/content/ui/widgets/basics.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -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 deleted file mode 100644 index 78a8330843..0000000000 --- a/src/content/ui/widgets/cupertino.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -# title: Cupertino widgets -title: Cupertino widget -shortTitle: Cupertino -# description: > -# A catalog of Flutter's cupertino widgets that align with -# Apple's Human Interface Guidelines for iOS and macOS. -description: Flutter 中符合 iOS 和 macOS 的人机界面指南的 cupertino widget 目录。 -widgetCategory: Cupertino -layout: widget-catalog-page ---- diff --git a/src/content/ui/widgets/index.md b/src/content/ui/widgets/index.md index ea85dff8c4..c7cf3a1f5c 100644 --- a/src/content/ui/widgets/index.md +++ b/src/content/ui/widgets/index.md @@ -26,26 +26,25 @@ Flutter SDK 中附带了两套设计系统。 更多由 Flutter 社区创建的设计系统。
    -{% assign categories = catalog.index | sortBy: 'name' -%} -{% for section in categories %} - {%- if section.name == "Cupertino" or section.name == "Material components" -%} - -
    - {{section.name}} -
    -
    -

    {{section.description}}

    -
    -
    - {% endif -%} -{% endfor %} + + Beautiful and high-fidelity widgets that align with + Apple's Human Interface Guidelines for iOS and macOS. + + + Visual, behavioral, and motion-rich widgets implementing + the Material 3 design specification. +
    -You can find many more designs systems created by the Flutter community -on [pub.dev]({{site.pub}}), the package repository for Dart and Flutter, -like for example the Windows-inspired [fluent_ui]({{site.pub-pkg}}/fluent_ui), -macOS-inspired [macos_ui]({{site.pub-pkg}}/macos_ui), -and the Ubuntu-inspired [yaru]({{site.pub-pkg}}/yaru) widgets. +You can find many more design systems created by the Flutter community +on [pub.dev]({{site.pub}}), the package repository for Dart and Flutter. +For example, the Windows-inspired [fluent_ui][], +macOS-inspired [macos_ui][], +and the Ubuntu-inspired [yaru][] widgets. + +[fluent_ui]: {{site.pub-pkg}}/fluent_ui +[macos_ui]: {{site.pub-pkg}}/macos_ui +[yaru]: {{site.pub-pkg}}/yaru ## Base widgets @@ -57,21 +56,7 @@ like input, layout, and text. 基础 widget 支持一系列常用的功能, 如输入、布局和文本。 -
    -{% 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 deleted file mode 100644 index 34568a0484..0000000000 --- a/src/content/ui/widgets/input.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -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 deleted file mode 100644 index cfe215762a..0000000000 --- a/src/content/ui/widgets/interaction.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -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 deleted file mode 100644 index 7b16b6c211..0000000000 --- a/src/content/ui/widgets/layout.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -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 deleted file mode 100644 index 97d0752b8b..0000000000 --- a/src/content/ui/widgets/material.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -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 deleted file mode 100644 index c49b8f284b..0000000000 --- a/src/content/ui/widgets/material2.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -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 deleted file mode 100644 index 194ce85bf6..0000000000 --- a/src/content/ui/widgets/painting.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -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 deleted file mode 100644 index 5095c41225..0000000000 --- a/src/content/ui/widgets/scrolling.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -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 deleted file mode 100644 index 68a7e562c4..0000000000 --- a/src/content/ui/widgets/styling.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -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 deleted file mode 100644 index 69bb08176a..0000000000 --- a/src/content/ui/widgets/text.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -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 e7659902cc..72d8aa7c50 100644 --- a/src/data/architectureRecommendations.yml +++ b/src/data/architectureRecommendations.yml @@ -1,6 +1,4 @@ -- 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. +- category: separation-of-concerns recommendations: - recommendation: Use clearly defined data and UI layers. description: | @@ -28,7 +26,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][] or [other popular options][]. + Read about [our ChangeNotifier recommendation](/get-started/fwe/state-management) or [other popular options](https://docs.flutter.cn/data-and-backend/state-mgmt/options). - recommendation: Do not put logic in widgets. description: | @@ -48,10 +46,7 @@ confidence-description: | Use in apps with complex logic requirements. -- 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. +- category: handling-data recommendations: - recommendation: Use unidirectional data flow. description: | @@ -62,7 +57,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][]. + Read about commands in the [architecture case study](/app-architecture/guide). confidence: recommend - recommendation: Use immutable data models. @@ -74,7 +69,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][] or [built_value][]. + You can use packages to help generate useful functionality in your data models, [freezed](https://pub-web.flutter-io.cn/packages/freezed) or [built_value](https://pub-web.flutter-io.cn/packages/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 @@ -85,22 +80,19 @@ confidence: conditional confidence-description: Use in large apps. -- category: App structure - description: | - Well organized code benefits both the health of the app itself, and the team working on the code. +- category: app-structure recommendations: - - recommendation: Use dependency injection. description: | Dependency injection prevents your app from having globally accessible objects, which makes your code less error prone. - We recommend you use the [provider](https://pub.dev/packages/provider) package to handle dependency injection. + We recommend you use the [provider](https://pub-web.flutter-io.cn/packages/provider) package to handle dependency injection. confidence: strong - - recommendation: Use [go_router](https://pub.dev/packages/go_router) for navigation. + - recommendation: Use [go_router](https://pub-web.flutter-io.cn/packages/go_router) for navigation. 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][] directly or try other packages found on [pub.dev][]. + in which case you can use the [Flutter Navigator API](https://docs.flutter.cn/ui/navigation) directly or try other packages found on [pub.dev](https://pub.dev). confidence: recommend - recommendation: Use standardized naming conventions for classes, files and directories. @@ -126,12 +118,8 @@ which can be used for different app environments, such as "development" and "staging". confidence: strong -- 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. +- category: testing 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/docsCards.yml b/src/data/docsCards.yml deleted file mode 100644 index f680968c39..0000000000 --- a/src/data/docsCards.yml +++ /dev/null @@ -1,18 +0,0 @@ -- name: 开始使用 - description: 配置 Flutter 开发环境,开启 Flutter 应用之旅。 - url: /get-started -- name: Widget 目录 - description: 探索 Flutter SDK 中丰富多样的 Widget 合集。 - url: /ui/widgets -- name: API 文档 - description: 将 Flutter 框架的 API 文档添加到书签中。 - url: https://api.flutter-io.cn/ -- name: 指南和教程 - description: 浏览示例代码、教程和指导。 - url: /reference/learning-resources -- name: 视频资源(Flutter 官方) - description: 观看 Flutter YouTube 频道上的视频。 - url: https://www.youtube.com/@flutterdev -- name: 视频资源(Flutter 中文社区) - description: 关注 Flutter 中文社区的 bilibili 频道 - url: https://space.bilibili.com/344928717 diff --git a/src/data/glossary.yml b/src/data/glossary.yml index 083bcdefb2..717b56f92c 100644 --- a/src/data/glossary.yml +++ b/src/data/glossary.yml @@ -1,3 +1,81 @@ +- term: "Engine" + short_description: |- + The portable runtime for Flutter apps. + long_description: |- + The engine is Flutter's platform-agnostic logic that's written + in native code, mostly C++. + + The main responsibilities of the engine are as follows: + + 1. Exposes the `dart:ui` API, which are the low-level primitives + that the Flutter framework builds upon. + 2. Converts low-level drawing commands into pixels (also called + _rasterization_, this includes Impeller and Skia). + 3. Responsible for launching and managing Dart's runtime. + 4. Responsible for laying out text. + 5. Responsible for asset resolution. + + [framework]: /resources/architectural-overview#architectural-layers + [Impeller]: /resources/glossary#impeller + related_links: + - text: "Architectural overview: The Engine" + link: "/resources/architectural-overview#architectural-layers" + type: "doc" + - text: "Engine repository" + link: "https://github.com/flutter/flutter/tree/main/engine/src/flutter" + type: "code" + labels: + - "engine" + - "architecture" + +- 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, @@ -24,6 +102,73 @@ - "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. + long_description: |- + A sliver is a portion of a scrollable area that you can define + to behave in a special way. + Think of slivers as building blocks that you can compose together + inside a `CustomScrollView` to create custom scrolling experiences, + like elastic scrolling or a collapsing header. + Slivers are built lazily, which means that Flutter only renders + the slivers that are visible on screen, + making them very efficient for long lists of content. + related_links: + - text: "Sliver documentation" + link: "/ui/layout/scrolling/slivers" + type: "doc" + - text: "Slivers demystified" + link: "https://blog.flutter.dev/slivers-demystified-6ff68ab0296f" + type: "article" + - text: "SliverList and SliverGrid WotW" + link: "https://youtu.be/ORiTTaVY6mM" + type: "video" + - text: "SliverAppBar WotW" + link: "https://youtu.be/R9C5KMJKluE" + type: "video" + - text: "CustomScrollView class" + link: "https://api.flutter.dev/flutter/widgets/CustomScrollView-class.html" + type: "api" + - text: "SliverAppBar class" + link: "https://api.flutter.dev/flutter/material/SliverAppBar-class.html" + type: "api" + - text: "SliverGrid class" + link: "https://api.flutter.dev/flutter/widgets/SliverGrid-class.html" + type: "api" + - text: "SliverList class" + link: "https://api.flutter.dev/flutter/widgets/SliverList-class.html" + type: "api" + labels: + - "scrolling" + - "ui" + - term: "Widget" short_description: |- The basic building block of a Flutter user interface. diff --git a/src/data/mirrors.yml b/src/data/mirrors.yml deleted file mode 100644 index 1cdf391ac2..0000000000 --- a/src/data/mirrors.yml +++ /dev/null @@ -1,22 +0,0 @@ -- group: 'Flutter 社区 (CFUG)' - mirror: 'flutter-io.cn' - urls: - pubhosted: 'https://pub.flutter-io.cn' - flutterstorage: 'https://storage.flutter-io.cn' - issues: 'https://github.com/cfug/flutter.cn/issues/new/choose' - group: https://github.com/cfug -- group: '上海交通大学 *nix 用户组' - mirror: 'mirror.sjtu.edu.cn' - urls: - pubhosted: 'https://mirror.sjtu.edu.cn/dart-pub' - flutterstorage: 'https://mirror.sjtu.edu.cn' - issues: 'https://github.com/sjtug/mirror-requests' - group: https://github.com/sjtug -- group: '清华大学 TUNA 协会' - mirror: 'mirrors.tuna.tsinghua.edu.cn' - urls: - pubhosted: 'https://mirrors.tuna.tsinghua.edu.cn/dart-pub' - flutterstorage: 'https://mirrors.tuna.tsinghua.edu.cn/flutter' - issues: 'https://github.com/tuna/issues' - group: https://tuna.moe - diff --git a/src/data/platforms.yml b/src/data/platforms.yml deleted file mode 100644 index 290e14ceb8..0000000000 --- a/src/data/platforms.yml +++ /dev/null @@ -1,50 +0,0 @@ -- platform: 'Android SDK' - target-arch: 'x64, Arm32, Arm64' - supported: '24 to 36' - ci-tested: '24 to 36' - unsupported: '23 and earlier' -- platform: 'iOS' - target-arch: 'Arm64' - supported: '13 to 26' - ci-tested: '18' - unsupported: '12 and earlier' -- platform: 'macOS' - target-arch: 'x64, Arm64' - supported: 'Catalina (10.15) to Tahoe (26)' - ci-tested: 'Sonoma (14), Sequoia (15)' - unsupported: 'Mojave (10.14) and earlier' -- platform: 'Windows' - target-arch: 'x64, Arm64' - supported: '10, 11' - ci-tested: '10' - unsupported: '8 and earlier' -- platform: 'Debian (Linux)' - target-arch: 'x64, Arm64' - supported: '10, 11, 12' - ci-tested: '11, 12' - unsupported: '9 and earlier' -- platform: 'Ubuntu (Linux)' - target-arch: 'x64, Arm64' - supported: '20.04 LTS to 24.04 LTS' - ci-tested: '20.04 LTS, 22.04 LTS' - unsupported: '24.10 and earlier non-LTS' -- platform: 'Chrome (Web)' - target-arch: 'JavaScript, WebAssembly' - supported: '[Latest 2](https://chromereleases.googleblog.com/search/label/Stable%20updates)' - ci-tested: '119, 125' - unsupported: '95 and earlier' -- platform: 'Firefox (Web)' - target-arch: 'JavaScript' - supported: '[Latest 2](https://www.mozilla.org/en-US/firefox/releases/)' - ci-tested: '143' - unsupported: '98 and earlier' -- platform: 'Safari (Web)' - target-arch: 'JavaScript' - supported: '15.6 and newer' - ci-tested: '15.6' - unsupported: '15.5 and earlier' -- platform: 'Edge (Web)' - target-arch: 'JavaScript, WebAssembly' - supported: '[Latest 2](https://learn.microsoft.com/en-us/deployedge/microsoft-edge-relnote-stable-channel)' - ci-tested: '119, 125' - unsupported: '95 and earlier' diff --git a/src/data/sidenav.yml b/src/data/sidenav.yml index 049e552958..d656ca5183 100644 --- a/src/data/sidenav.yml +++ b/src/data/sidenav.yml @@ -187,10 +187,19 @@ permalink: /ai-toolkit/custom-llm-providers - title: 聊天客户端示例 permalink: /ai-toolkit/chat-client-sample + - title: GenUI SDK for Flutter + permalink: /ai/genui + children: + - title: 概览 + permalink: /ai/genui + - title: 主要组件 & 概念 + permalink: /ai/genui/components + - title: 开始使用 + permalink: /ai/genui/get-started - title: Dart & Flutter MCP server permalink: /ai/mcp-server - - title: Flutter 的 GenUI SDK - permalink: https://github.com/flutter/genui + - title: Flutter 的 Gemini CLI 扩展 + permalink: /ai/flutter-ext-for-gemini - title: Firebase AI Logic permalink: https://firebase.google.cn/docs/ai-logic/get-started?platform=flutter diff --git a/src/data/tutorial.yml b/src/data/tutorial.yml new file mode 100644 index 0000000000..35ee7c264e --- /dev/null +++ b/src/data/tutorial.yml @@ -0,0 +1,38 @@ +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/
    FeatureAnimation and sprites -{{recipeIcon}} [Special effects][]
    -{{toolIcon}} [Spriter Pro][]
    -{{pkgIcon}} [rive][]
    -{{pkgIcon}} [spriteWidget][] + [Special effects][]
    + [Spriter Pro][]
    + [rive][]
    + [spritewidget][]
    App review -{{pkgIcon}} [app_review][] + [app_review][]
    Audio -{{pkgIcon}} [audioplayers][]
    -{{pkgIcon}} [flutter_soloud][]—**NEW**
    -{{codelab}} [Add sound and music to your Flutter game with SoLoud][]—**NEW** + [audioplayers][]
    + [flutter_soloud][]
    + [Add sound and music to your Flutter game with SoLoud][]
    Authentication -{{codelab}} [User Authentication using Firebase][firebase-auth] + [User Authentication using Firebase][firebase-auth]
    Cloud services -{{codelab}} [Add Firebase to your Flutter game][] + [Add Firebase to your Flutter game][]
    Debugging -{{docIcon}} [Firebase Crashlytics overview][firebase-crashlytics]
    -{{pkgIcon}} [firebase_crashlytics][] + [Firebase Crashlytics overview][firebase-crashlytics]
    + [firebase_crashlytics][]
    Drivers -{{pkgIcon}} [win32_gamepad][] + [win32_gamepad][]
    Game assets
    and asset tools
    -{{assetsIcon}} [CraftPix][]
    -{{assetsIcon}} [Game Developer Studio][]
    -{{toolIcon}} [GIMP][] + [CraftPix][]
    + [Game Developer Studio][]
    + [GIMP][]
    Game engines -{{pkgIcon}} [Flame][flame-pkg]
    -{{pkgIcon}} [Bonfire][bonfire-pkg]
    -{{pkgIcon}} [forge2d][] + [Flame][flame-pkg]
    + [Bonfire][bonfire-pkg]
    + [forge2d][]
    Game features -{{recipeIcon}} [Add achievements and leaderboards to your game][leaderboard-recipe]
    -{{recipeIcon}} [Add multiplayer support to your game][multiplayer-recipe] + [Add achievements and leaderboards to your game][leaderboard-recipe]
    + [Add multiplayer support to your game][multiplayer-recipe]
    Game services integration -{{pkgIcon}} [games_services][game-svc-pkg] + [games_services][game-svc-pkg]
    Legacy code -{{codelab}} [Use the Foreign Function Interface in a Flutter plugin][] + [Use the Foreign Function Interface in a Flutter plugin][]
    Level editor -{{toolIcon}} [Tiled][] + [Tiled][]
    Monetization -{{recipeIcon}} [Add advertising to your Flutter game][ads-recipe]
    -{{codelab}} [Add AdMob ads to a Flutter app][]
    -{{codelab}} [Add in-app purchases to your Flutter app][iap-recipe]
    -{{docIcon}} [Gaming UX and Revenue Optimizations for Apps][] (PDF) + [Add advertising to your Flutter game][ads-recipe]
    + [Add AdMob ads to a Flutter app][]
    + [Add in-app purchases to your Flutter app][iap-recipe]
    + [Gaming UX and Revenue Optimizations for Apps][] (PDF)
    Persistence -{{pkgIcon}} [shared_preferences][]
    -{{pkgIcon}} [sqflite][]
    -{{pkgIcon}} [cbl_flutter][] (Couchbase Lite)
    + [shared_preferences][]
    + [sqflite][]
    + [cbl_flutter][] (Couchbase Lite)
    Special effects -{{apiIcon}} [Paint API][]
    -{{recipeIcon}} [Special effects][] + [Paint API][]
    + [Special effects][]
    User Experience -{{codelab}} [Build next generation UIs in Flutter][]
    -{{docIcon}} [Best practices for optimizing Flutter web loading speed][]—**NEW** + [Build next generation UIs in Flutter][]
    + [Best practices for optimizing Flutter web loading speed][]