diff --git a/.gitignore b/.gitignore index 4436ac10d524..a0ce3bcdfb97 100644 --- a/.gitignore +++ b/.gitignore @@ -130,4 +130,7 @@ browser_extensions/ *.coverage.info # Secrets -.env \ No newline at end of file +.env + +# Playwright +/browser-profiles/ \ No newline at end of file diff --git a/catalyst_voices/apps/voices/e2e_tests/.gitignore b/catalyst_voices/apps/voices/e2e_tests/.gitignore index 58786aac7566..662bc478c401 100644 --- a/catalyst_voices/apps/voices/e2e_tests/.gitignore +++ b/catalyst_voices/apps/voices/e2e_tests/.gitignore @@ -5,3 +5,5 @@ node_modules/ /playwright-report/ /blob-report/ /playwright/.cache/ +/browser/ +/browser-profiles/ diff --git a/catalyst_voices/apps/voices/e2e_tests/README.md b/catalyst_voices/apps/voices/e2e_tests/README.md new file mode 100644 index 000000000000..1897caf5c902 --- /dev/null +++ b/catalyst_voices/apps/voices/e2e_tests/README.md @@ -0,0 +1,56 @@ +# Catalyst Voices E2E Tests + +End-to-end tests for the Catalyst Voices web application using Playwright. +Unlike regular Playwright setups, this project will connect to already existing browser binary via CDP +and the app will run locally. + +## Scope + +These tests cover browser-based end-to-end workflows for the Catalyst Voices application: + +* **Cardano wallet integration testing** (Lace, Eternl, Yoroi, Nufi) +* **User authentication and account management** +* **Cross-environment testing** (dev, staging, prod) +* **Browser extension interaction** +* **Application title and basic navigation** + +## Building & Setup + +### Prerequisites + +* **Node.js** (v18 or higher) +* **npm** package manager +* **Chrome for testing** ([Download manually](https://googlechromelabs.github.io/chrome-for-testing/)) +* Ability to run the app locally (Check `catalyst_voices/README.md` for instructions) + +### Installation + +1. Run the app locally: + + This ensures the app will be running on port 5555. + + ```bash + cd catalyst_voices/apps/voices && + flutter run --flavor preprod --web-port 5555 + --web-header "Cross-Origin-Opener-Policy=same-origin" + --web-header "Cross-Origin-Embedder-Policy=require-corp" + -d web-server lib/configs/main.dart + ``` + +2. In new terminal, navigate to the e2e tests directory: + + ```bash + cd catalyst_voices/apps/voices/e2e_tests + ``` + +3. Install dependencies: + + ```bash + npm install + ``` + +4. Run the e2e tests: + + ```bash + npx playwright test + ``` diff --git a/catalyst_voices/apps/voices/e2e_tests/data/browserExtensionConfigs.ts b/catalyst_voices/apps/voices/e2e_tests/data/browserExtensionConfigs.ts index cf3683511e0a..fcbd099b2f19 100644 --- a/catalyst_voices/apps/voices/e2e_tests/data/browserExtensionConfigs.ts +++ b/catalyst_voices/apps/voices/e2e_tests/data/browserExtensionConfigs.ts @@ -8,17 +8,17 @@ export const browserExtensions: BrowserExtensionModel[] = [ { Name: BrowserExtensionName.Lace, Id: "gafhhkghbfjjkeiendhlofajokpaflmk", - HomeUrl: "app.html#/setup", + HomeUrl: "/app.html#/setup", }, { Name: BrowserExtensionName.Eternl, Id: "kmhcihpebfmpgmihbkipmjlmmioameka", - HomeUrl: "index.html#/app/preprod/welcome", + HomeUrl: "/index.html#/app/preprod/welcome", }, { Name: BrowserExtensionName.Yoroi, Id: "poonlenmfdfbjfeeballhiibknlknepo", - HomeUrl: "main_window.html#", + HomeUrl: "/main_window.html#", }, { Name: BrowserExtensionName.Nufi, diff --git a/catalyst_voices/apps/voices/e2e_tests/fixtures.ts b/catalyst_voices/apps/voices/e2e_tests/fixtures.ts new file mode 100644 index 000000000000..8db17c69d794 --- /dev/null +++ b/catalyst_voices/apps/voices/e2e_tests/fixtures.ts @@ -0,0 +1,5 @@ +import { mergeTests } from "@playwright/test"; +import { test as walletTest } from "./fixtures/wallet-fixtures"; +import { test as endpointTest } from "./fixtures/endpoint-fixtures"; + +export const test = mergeTests(walletTest, endpointTest); diff --git a/catalyst_voices/apps/voices/e2e_tests/fixtures/endpoint-fixtures.ts b/catalyst_voices/apps/voices/e2e_tests/fixtures/endpoint-fixtures.ts new file mode 100644 index 000000000000..ec5cf93253d2 --- /dev/null +++ b/catalyst_voices/apps/voices/e2e_tests/fixtures/endpoint-fixtures.ts @@ -0,0 +1,16 @@ +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; + +dotenv.config(); + +type EndpointFixtures = { + appBaseURL: string; +}; + +export const test = base.extend({ + appBaseURL: async ({}, use) => { + const baseURL = + process.env.APP_URL || "localhost:5555"; + await use(baseURL); + }, +}); diff --git a/catalyst_voices/apps/voices/e2e_tests/fixtures/wallet-fixtures.ts b/catalyst_voices/apps/voices/e2e_tests/fixtures/wallet-fixtures.ts new file mode 100644 index 000000000000..4939410c6697 --- /dev/null +++ b/catalyst_voices/apps/voices/e2e_tests/fixtures/wallet-fixtures.ts @@ -0,0 +1,44 @@ +import path from "path"; +import dotenv from "dotenv"; +dotenv.config({ + path: path.join(process.cwd(), "catalyst_voices/apps/voices/e2e_tests/.env"), +}); + +import { test as base, BrowserContext } from "@playwright/test"; + +import { + connectToBrowser, + getDynamicUrlInChrome, +} from "../utils/browser-extension"; +import { WalletConfigModel } from "../models/walletConfigModel"; +import { onboardWallet } from "../utils/wallets/walletActions"; + +type WalletFixtures = { + restoreWallet: (walletConfig: WalletConfigModel) => Promise; +}; + +export const test = base.extend({ + restoreWallet: async ({}, use) => { + const restoreWalletFn = async (walletConfig: WalletConfigModel) => { + const chromePath = process.env.CHROME_PATH; + if (!chromePath) { + throw new Error("CHROME_PATH is not set"); + } + const browser = await connectToBrowser( + walletConfig.extension.Name, + chromePath + ); + const extensionTab = browser.pages()[0]; + walletConfig.extension.HomeUrl = await getDynamicUrlInChrome( + extensionTab, + walletConfig + ); + await extensionTab.goto(walletConfig.extension.HomeUrl); + const address = await onboardWallet(extensionTab, walletConfig); + return browser; + }; + + // Provide the function to the test + await use(restoreWalletFn); + }, +}); diff --git a/catalyst_voices/apps/voices/e2e_tests/pageobjects/discovery-page.ts b/catalyst_voices/apps/voices/e2e_tests/pageobjects/discovery-page.ts new file mode 100644 index 000000000000..063bc3e03d72 --- /dev/null +++ b/catalyst_voices/apps/voices/e2e_tests/pageobjects/discovery-page.ts @@ -0,0 +1,11 @@ +import { Locator, Page } from "@playwright/test"; + +export class DiscoveryPage { + page: Page; + getStartedButton: Locator; + + constructor(page: Page) { + this.page = page; + this.getStartedButton = page.getByTestId("GetStartedButton"); + } +} diff --git a/catalyst_voices/apps/voices/e2e_tests/playwright.config.ts b/catalyst_voices/apps/voices/e2e_tests/playwright.config.ts index 7c3cc6e49ee1..e34cef3f81c8 100644 --- a/catalyst_voices/apps/voices/e2e_tests/playwright.config.ts +++ b/catalyst_voices/apps/voices/e2e_tests/playwright.config.ts @@ -19,7 +19,10 @@ export default defineConfig({ projects: [ { name: "chromium", - use: { ...devices["Desktop Chrome"] }, + use: { + ...devices["Desktop Chrome"], + testIdAttribute: "flt-semantics-identifier", + }, }, ], }); diff --git a/catalyst_voices/apps/voices/e2e_tests/tests/example.spec.ts b/catalyst_voices/apps/voices/e2e_tests/tests/example.spec.ts deleted file mode 100644 index a29a4b366bb5..000000000000 --- a/catalyst_voices/apps/voices/e2e_tests/tests/example.spec.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { test, expect } from "@playwright/test"; - -test("has title", async ({ page }) => { - await page.goto("/"); - - // Expect a title "to contain" a substring. - await expect(page).toHaveTitle(/Catalyst/); -}); diff --git a/catalyst_voices/apps/voices/e2e_tests/tests/onboarding.spec.ts b/catalyst_voices/apps/voices/e2e_tests/tests/onboarding.spec.ts new file mode 100644 index 000000000000..8d6a8498259b --- /dev/null +++ b/catalyst_voices/apps/voices/e2e_tests/tests/onboarding.spec.ts @@ -0,0 +1,14 @@ +import { test } from "../fixtures"; +import { expect } from "@playwright/test"; +import { getWalletConfig } from "../data/walletConfigs"; + +test.describe("Onboarding - ", () => { + test("creates keychain", async ({ restoreWallet, appBaseURL }) => { + const browser = await restoreWallet(getWalletConfig("1")); + const page = browser.pages()[0]; + await page.goto(appBaseURL); + await page + .locator("//*[@aria-label='Enable accessibility']") + .evaluate((element: HTMLElement) => element.click()); + }); +}); diff --git a/catalyst_voices/apps/voices/e2e_tests/utils/browser-extension.ts b/catalyst_voices/apps/voices/e2e_tests/utils/browser-extension.ts new file mode 100644 index 000000000000..2c37c48a95f4 --- /dev/null +++ b/catalyst_voices/apps/voices/e2e_tests/utils/browser-extension.ts @@ -0,0 +1,163 @@ +import { Page, BrowserContext, chromium } from "@playwright/test"; +import { spawn, ChildProcess } from "child_process"; +import fs from "fs"; +import path from "path"; +import { WalletConfigModel } from "../models/walletConfigModel"; +import { getBrowserExtension } from "../data/browserExtensionConfigs"; +import { BrowserExtensionName } from "../models/browserExtensionModel"; +import { ExtensionDownloader } from "./extensionDownloader"; + +export const getDynamicUrlInChrome = async ( + extensionTab: Page, + wallet: WalletConfigModel +): Promise => { + await extensionTab.goto("chrome://extensions/"); + const extensionId = await extensionTab + .locator("extensions-item") + .getAttribute("id"); + return `chrome-extension://${extensionId}${ + getBrowserExtension(wallet.extension.Name).HomeUrl + }`; +}; + +export const connectToBrowser = async ( + extensionName: BrowserExtensionName, + chromePath: string +): Promise => { + // Download the extension + const extensionPath = await new ExtensionDownloader().getExtension( + extensionName + ); + + const chromeProcess = await launchChromeWithExtension( + chromePath, + extensionPath + ); + + await new Promise((resolve) => setTimeout(resolve, 3000)); + + const browser = await chromium.connectOverCDP("http://localhost:9222"); + const browserContext = browser.contexts()[0]; + + // Store the process reference for cleanup + (browserContext as any).chromeProcess = chromeProcess; + + return browserContext; +}; + +const cleanupChromeProfile = async (profilePath: string): Promise => { + if (fs.existsSync(profilePath)) { + try { + await fs.promises.rm(profilePath, { recursive: true, force: true }); + console.log(`Cleaned up Chrome profile: ${profilePath}`); + } catch (error) { + console.error(`Failed to cleanup Chrome profile: ${error}`); + } + } +}; + +const launchChromeWithExtension = async ( + chromePath: string, + extensionPath: string +): Promise => { + // Create a unique profile directory for this test run in the browser-profiles folder + const profilesDir = path.join(process.cwd(), "browser-profiles"); + + // Ensure the browser-profiles directory exists + if (!fs.existsSync(profilesDir)) { + fs.mkdirSync(profilesDir, { recursive: true }); + } + + const profilePath = path.join( + profilesDir, + `chrome-profile-${Date.now()}-${Math.random().toString(36).substring(7)}` + ); + + // Clean up any existing profile at this path (shouldn't exist, but just in case) + await cleanupChromeProfile(profilePath); + + const chromeArgs = [ + "--remote-debugging-port=9222", + "--no-first-run", + "--no-default-browser-check", + `--disable-extensions-except=${extensionPath}`, + `--load-extension=${extensionPath}`, + `--user-data-dir=${profilePath}`, + ]; + + const chromeProcess = spawn(chromePath, chromeArgs, { + detached: false, + stdio: "pipe", + }); + + (chromeProcess as any).profilePath = profilePath; + + chromeProcess.stdout?.on("data", (data) => { + console.log(`Chrome stdout: ${data}`); + }); + + chromeProcess.stderr?.on("data", (data) => { + console.log(`Chrome stderr: ${data}`); + }); + + chromeProcess.on("error", (error) => { + console.error(`Chrome process error: ${error}`); + }); + + return chromeProcess; +}; + +export const closeBrowserWithExtension = async ( + browserContext: BrowserContext +) => { + const chromeProcess = (browserContext as any).chromeProcess as ChildProcess; + const profilePath = chromeProcess ? (chromeProcess as any).profilePath : null; + + if (chromeProcess && !chromeProcess.killed) { + chromeProcess.kill("SIGTERM"); + + // Wait for the process to terminate gracefully + await new Promise((resolve) => { + const timeout = setTimeout(() => { + if (!chromeProcess.killed) { + chromeProcess.kill("SIGKILL"); + } + resolve(); + }, 5000); + + chromeProcess.on("exit", () => { + clearTimeout(timeout); + resolve(); + }); + }); + } + + await browserContext.close(); + + // Clean up the Chrome profile directory + if (profilePath) { + await cleanupChromeProfile(profilePath); + } +}; + +export const launchBrowserWith = async ( + extensionName: BrowserExtensionName +): Promise => { + const extensionPath = await new ExtensionDownloader().getExtension( + extensionName + ); + const browser = await chromium.launchPersistentContext("", { + viewport: { width: 1920, height: 1080 }, + headless: false, + ignoreHTTPSErrors: true, + channel: "chrome", + permissions: ["clipboard-read", "clipboard-write"], + args: [ + `--disable-extensions-except=${extensionPath}`, + `--load-extension=${extensionPath}`, + ], + }); + let [background] = browser.serviceWorkers(); + if (!background) background = await browser.waitForEvent("serviceworker"); + return browser; +}; diff --git a/catalyst_voices/apps/voices/e2e_tests/utils/wallets/laceActions.ts b/catalyst_voices/apps/voices/e2e_tests/utils/wallets/laceActions.ts index fa41a266bd44..250a4b2b2570 100644 --- a/catalyst_voices/apps/voices/e2e_tests/utils/wallets/laceActions.ts +++ b/catalyst_voices/apps/voices/e2e_tests/utils/wallets/laceActions.ts @@ -53,7 +53,9 @@ const clickRestoreWalletButton = async (page: Page): Promise => { * and sometimes leads to a page where the user has to click on the recovery phrase button to get to the recovery phrase page */ const handleNextPage = async (page: Page): Promise => { - const title = await page.getByTestId("wallet-setup-step-title").textContent(); + const title = await page + .locator('//*[@data-testid="wallet-setup-step-title"]') + .textContent(); if (title === "Choose recovery method") { await page.locator('[data-testid="wallet-setup-step-btn-next"]').click(); } else { @@ -65,37 +67,36 @@ export const onboardLaceWallet = async ( page: Page, walletConfig: WalletConfigModel ): Promise => { - await page.locator('[data-testid="analytics-accept-button"]').click(); - await clickRestoreWalletButton(page); + await page.locator('[data-testid="restore-wallet-button"]').click(); await handleNextPage(page); - await page.getByTestId("recovery-phrase-15").click(); - const seedPhrase = walletConfig.seed; - for (let i = 0; i < seedPhrase.length; i++) { - const ftSeedPhraseSelector = `//*[@id="mnemonic-word-${i + 1}"]`; - await page.locator(ftSeedPhraseSelector).fill(seedPhrase[i]); + await page.locator('//span[@data-testid="recovery-phrase-15"]').click(); + for (let i = 0; i < walletConfig.seed.length; i++) { + const seedPhraseSelector = `//*[@id="mnemonic-word-${i + 1}"]`; + await page.locator(seedPhraseSelector).fill(walletConfig.seed[i]); } - await page.getByRole("button", { name: "Next" }).click(); - await page.getByTestId("wallet-name-input").fill(walletConfig.username); + await page.locator('[data-testid="wallet-setup-step-btn-next"]').click(); await page - .getByTestId("wallet-password-verification-input") + .locator('[data-testid="wallet-name-input"]') + .fill(walletConfig.username); + await page + .locator('[data-testid="wallet-password-verification-input"]') .fill(walletConfig.password); await page - .getByTestId("wallet-password-confirmation-input") + .locator('[data-testid="wallet-password-confirmation-input"]') .fill(walletConfig.password); - await page.getByRole("button", { name: "Open wallet" }).click(); - //Lace is very slow at loading + await page.locator('[data-testid="wallet-setup-step-btn-next"]').click(); + await page.locator('[data-testid="wallet-setup-step-btn-next"]').click(); await page - .getByTestId("profile-dropdown-trigger-menu") + .locator('//*[@data-testid="profile-dropdown-trigger-menu"]') .click({ timeout: 300000 }); await page - .getByTestId("header-menu") - .getByTestId("header-menu-network-choice-container") + .locator('//*[@data-testid="header-menu"]') + .locator('//*[@data-testid="header-menu-network-choice-container"]') .click(); await page - .getByTestId("header-menu") - .getByTestId("network-preprod-radio-button") + .locator('//*[@data-testid="header-menu"]') + .locator('//*[@data-testid="network-preprod-radio-button"]') .click(); - await page.waitForTimeout(4000); }; export const signLaceData = async ( diff --git a/catalyst_voices/apps/voices/integration_test/pageobject/profile_page.dart b/catalyst_voices/apps/voices/integration_test/pageobject/profile_page.dart index c0c8431b9412..cf55571131a6 100644 --- a/catalyst_voices/apps/voices/integration_test/pageobject/profile_page.dart +++ b/catalyst_voices/apps/voices/integration_test/pageobject/profile_page.dart @@ -25,7 +25,7 @@ class ProfilePage { final appBarProfileAvatar = const Key('ProfileAvatar'); final profileAndKeychainText = const Key('ProfileAndKeychainText'); final accountEmailTextField = const Key('AccountEmailTextField'); - final emailTileSaveBtn = const Key('EmailTileSaveButton'); + final emailTileSaveBtn = const Key('EditableTileSaveButton'); final deleteKeychainContinueButton = const Key('DeleteKeychainContinueButton'); final deleteKeychainTextField = const Key('DeleteKeychainTextField'); final keychainDeletedDialogCloseButton = const Key('KeychainDeletedDialogCloseButton'); diff --git a/catalyst_voices/apps/voices/lib/pages/proposal_builder/proposal_builder_guidance.dart b/catalyst_voices/apps/voices/lib/pages/proposal_builder/proposal_builder_guidance.dart index cc7d18c85c3c..e2219588f79b 100644 --- a/catalyst_voices/apps/voices/lib/pages/proposal_builder/proposal_builder_guidance.dart +++ b/catalyst_voices/apps/voices/lib/pages/proposal_builder/proposal_builder_guidance.dart @@ -1,4 +1,5 @@ import 'package:catalyst_voices/common/ext/build_context_ext.dart'; +import 'package:catalyst_voices/widgets/common/semantics/combine_semantics.dart'; import 'package:catalyst_voices/widgets/rich_text/markdown_text.dart'; import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; import 'package:catalyst_voices_blocs/catalyst_voices_blocs.dart'; @@ -11,23 +12,28 @@ class ProposalBuilderGuidanceSelector extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocSelector< - ProposalBuilderBloc, - ProposalBuilderState, - ({bool isLoading, ProposalGuidance guidance}) - >( - selector: (state) => (isLoading: state.isLoading, guidance: state.guidance), - builder: (context, state) { - if (state.isLoading) { - return const _GuidanceListPlaceholder(); - } else if (state.guidance.isNoneSelected) { - return Text(context.l10n.selectASection); - } else if (state.guidance.showEmptyState) { - return Text(context.l10n.noGuidanceForThisSection); - } else { - return _GuidanceList(items: state.guidance.guidanceList); - } - }, + return Semantics( + identifier: 'ProposalBuilderGuidance', + container: true, + child: + BlocSelector< + ProposalBuilderBloc, + ProposalBuilderState, + ({bool isLoading, ProposalGuidance guidance}) + >( + selector: (state) => (isLoading: state.isLoading, guidance: state.guidance), + builder: (context, state) { + if (state.isLoading) { + return const _GuidanceListPlaceholder(); + } else if (state.guidance.isNoneSelected) { + return Text(context.l10n.selectASection); + } else if (state.guidance.showEmptyState) { + return Text(context.l10n.noGuidanceForThisSection); + } else { + return _GuidanceList(items: state.guidance.guidanceList); + } + }, + ), ); } } @@ -41,40 +47,60 @@ class _GuidanceCard extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: theme.colors.elevationsOnSurfaceNeutralLv2, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - VoicesAssets.icons.newspaper.buildIcon(), - const SizedBox(width: 4), - Text( - item.segmentTitle, - style: theme.textTheme.titleSmall, + return Semantics( + identifier: 'ProposalBuilderGuidanceCard[${item.nodeId}]', + container: true, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.colors.elevationsOnSurfaceNeutralLv2, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + CombineSemantics( + identifier: 'ProposalBuilderGuidanceCardSegment', + container: true, + child: Row( + children: [ + ExcludeSemantics( + child: VoicesAssets.icons.newspaper.buildIcon(), + ), + const SizedBox(width: 4), + Text( + item.segmentTitle, + style: theme.textTheme.titleSmall, + ), + ], + ), + ), + const SizedBox(height: 12), + if (item.sectionTitle.isNotEmpty) ...[ + CombineSemantics( + identifier: 'ProposalBuilderGuidanceCardSection', + container: true, + child: Text( + item.sectionTitle, + style: theme.textTheme.titleSmall?.copyWith( + color: theme.colors.textOnPrimaryLevel1, + ), + ), ), + const SizedBox(height: 10), ], - ), - const SizedBox(height: 12), - if (item.sectionTitle.isNotEmpty) ...[ - Text( - item.sectionTitle, - style: theme.textTheme.titleSmall?.copyWith(color: theme.colors.textOnPrimaryLevel1), + CombineSemantics( + identifier: 'ProposalBuilderGuidanceCardDescription', + container: true, + child: MarkdownText( + item.description, + pStyle: context.textTheme.bodyMedium, + pColor: theme.colors.textOnPrimaryLevel1, + ), ), - const SizedBox(height: 10), ], - MarkdownText( - item.description, - pStyle: context.textTheme.bodyMedium, - pColor: theme.colors.textOnPrimaryLevel1, - ), - ], + ), ), ); } @@ -87,12 +113,16 @@ class _GuidanceCardPlaceholder extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); - return Container( - width: double.infinity, - height: 200, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: theme.colors.onSurfacePrimary012, + return CombineSemantics( + identifier: 'ProposalBuilderGuidancePlaceholder', + container: true, + child: Container( + width: double.infinity, + height: 200, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.colors.onSurfacePrimary012, + ), ), ); } diff --git a/catalyst_voices/apps/voices/lib/pages/registration/account_completed/account_completed_panel.dart b/catalyst_voices/apps/voices/lib/pages/registration/account_completed/account_completed_panel.dart index afa8e8c9bbd0..9cbfa3ca4147 100644 --- a/catalyst_voices/apps/voices/lib/pages/registration/account_completed/account_completed_panel.dart +++ b/catalyst_voices/apps/voices/lib/pages/registration/account_completed/account_completed_panel.dart @@ -89,7 +89,10 @@ class _OpenSpaceButton extends StatelessWidget { Widget build(BuildContext context) { return VoicesFilledButton( onTap: onTap, - child: Text(context.l10n.registrationCompletedButton), + child: Text( + context.l10n.registrationCompletedButton, + semanticsIdentifier: 'OpenSpaceButton', + ), ); } } @@ -97,15 +100,16 @@ class _OpenSpaceButton extends StatelessWidget { class _ReviewMyAccountButton extends StatelessWidget { final VoidCallback onTap; - const _ReviewMyAccountButton({ - required this.onTap, - }); + const _ReviewMyAccountButton({required this.onTap}); @override Widget build(BuildContext context) { return VoicesTextButton( onTap: onTap, - child: Text(context.l10n.registrationCompletedAccountButton), + child: Text( + context.l10n.registrationCompletedAccountButton, + semanticsIdentifier: 'ReviewMyAccountButton', + ), ); } } @@ -168,9 +172,7 @@ class _RolesFooter extends StatelessWidget { class _RolesSelectedCard extends StatelessWidget { final Set roles; - const _RolesSelectedCard({ - required this.roles, - }); + const _RolesSelectedCard({required this.roles}); @override Widget build(BuildContext context) { @@ -217,20 +219,14 @@ class _TitleText extends StatelessWidget { class _WalletConnectedCard extends StatelessWidget { final String walletName; - const _WalletConnectedCard({ - required this.walletName, - }); + const _WalletConnectedCard({required this.walletName}); @override Widget build(BuildContext context) { return ActionCard( icon: VoicesAssets.icons.wallet.buildIcon(), - title: Text( - context.l10n.registrationCompletedWalletTitle(walletName), - ), - desc: Text( - context.l10n.registrationCompletedWalletInfo(walletName), - ), + title: Text(context.l10n.registrationCompletedWalletTitle(walletName)), + desc: Text(context.l10n.registrationCompletedWalletInfo(walletName)), statusIcon: VoicesAssets.icons.check.buildIcon(), ); } diff --git a/catalyst_voices/apps/voices/lib/pages/registration/create_account_progress/account_create_progress_panel.dart b/catalyst_voices/apps/voices/lib/pages/registration/create_account_progress/account_create_progress_panel.dart index 7fded366bfd2..886d7044eed7 100644 --- a/catalyst_voices/apps/voices/lib/pages/registration/create_account_progress/account_create_progress_panel.dart +++ b/catalyst_voices/apps/voices/lib/pages/registration/create_account_progress/account_create_progress_panel.dart @@ -13,10 +13,7 @@ import 'package:flutter/material.dart'; class AccountCreateProgressPanel extends StatelessWidget { final List completedSteps; - const AccountCreateProgressPanel({ - super.key, - required this.completedSteps, - }); + const AccountCreateProgressPanel({super.key, required this.completedSteps}); @override Widget build(BuildContext context) { @@ -38,9 +35,7 @@ class AccountCreateProgressPanel extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (nextStepText != null) ...[ - NextStep(nextStepText, key: const Key('NextStep')), - ], + if (nextStepText != null) ...[NextStep(nextStepText, key: const Key('NextStep'))], if (nextStep == AccountCreateStepType.keychain) ...[ const SizedBox(height: 10), _CreateKeychainButton(onTap: () => _goToNextStep(context)), @@ -62,9 +57,7 @@ class AccountCreateProgressPanel extends StatelessWidget { class _CreateKeychainButton extends StatelessWidget { final VoidCallback onTap; - const _CreateKeychainButton({ - required this.onTap, - }); + const _CreateKeychainButton({required this.onTap}); @override Widget build(BuildContext context) { @@ -72,7 +65,10 @@ class _CreateKeychainButton extends StatelessWidget { key: const Key('CreateKeychainButton'), onTap: onTap, leading: VoicesAssets.icons.key.buildIcon(size: 18), - child: Text(context.l10n.accountCreationSplashTitle), + child: Text( + context.l10n.accountCreationSplashTitle, + semanticsIdentifier: 'CreateKeychainButton', + ), ); } } @@ -80,9 +76,7 @@ class _CreateKeychainButton extends StatelessWidget { class _LinkWalletAndRolesButton extends StatelessWidget { final VoidCallback onTap; - const _LinkWalletAndRolesButton({ - required this.onTap, - }); + const _LinkWalletAndRolesButton({required this.onTap}); @override Widget build(BuildContext context) { @@ -90,7 +84,10 @@ class _LinkWalletAndRolesButton extends StatelessWidget { key: const Key('LinkWalletAndRolesButton'), onTap: onTap, leading: VoicesAssets.icons.wallet.buildIcon(size: 18), - child: Text(context.l10n.createKeychainLinkWalletAndRoles), + child: Text( + context.l10n.createKeychainLinkWalletAndRoles, + semanticsIdentifier: 'LinkWalletAndRolesButton', + ), ); } } @@ -105,10 +102,7 @@ class _TitleText extends StatelessWidget { final theme = Theme.of(context); final color = theme.colors.textOnPrimaryLevel1; - return Text( - data, - style: theme.textTheme.titleMedium?.copyWith(color: color), - ); + return Text(data, style: theme.textTheme.titleMedium?.copyWith(color: color)); } } diff --git a/catalyst_voices/apps/voices/lib/pages/registration/create_base_profile/stage/instructions_panel.dart b/catalyst_voices/apps/voices/lib/pages/registration/create_base_profile/stage/instructions_panel.dart index f10e8b136483..a33fe55fdf69 100644 --- a/catalyst_voices/apps/voices/lib/pages/registration/create_base_profile/stage/instructions_panel.dart +++ b/catalyst_voices/apps/voices/lib/pages/registration/create_base_profile/stage/instructions_panel.dart @@ -34,7 +34,10 @@ class _PanelMainMessage extends StatelessWidget { final l10n = context.l10n; return RegistrationStageMessage( - title: Text(l10n.createProfileInstructionsTitle), + title: Text( + l10n.createProfileInstructionsTitle, + semanticsIdentifier: 'createProfileInstructionsTitle', + ), spacing: 12, subtitle: Column( mainAxisSize: MainAxisSize.min, @@ -43,6 +46,7 @@ class _PanelMainMessage extends StatelessWidget { children: [ Text( l10n.createProfileInstructionsMessage(CardanoWalletDetails.minAdaForRegistration.ada), + semanticsIdentifier: 'createProfileInstructionsMessage', ), ], ), diff --git a/catalyst_voices/apps/voices/lib/pages/registration/create_base_profile/stage/widgets/drep_approval_contingency_checkbox.dart b/catalyst_voices/apps/voices/lib/pages/registration/create_base_profile/stage/widgets/drep_approval_contingency_checkbox.dart index 7f70be7a29ba..bd70dfbf87e7 100644 --- a/catalyst_voices/apps/voices/lib/pages/registration/create_base_profile/stage/widgets/drep_approval_contingency_checkbox.dart +++ b/catalyst_voices/apps/voices/lib/pages/registration/create_base_profile/stage/widgets/drep_approval_contingency_checkbox.dart @@ -30,6 +30,7 @@ class _DrepApprovalContingencyCheckbox extends StatelessWidget { onChanged: (value) { RegistrationCubit.of(context).baseProfile.updateDrepApprovalContingency(accepted: value); }, + semanticsIdentifier: 'drepApprovalContingencyCheckbox', ); } } diff --git a/catalyst_voices/apps/voices/lib/pages/registration/create_base_profile/stage/widgets/instructions_navigation.dart b/catalyst_voices/apps/voices/lib/pages/registration/create_base_profile/stage/widgets/instructions_navigation.dart index 7c45702e3ec8..6dca94dc4489 100644 --- a/catalyst_voices/apps/voices/lib/pages/registration/create_base_profile/stage/widgets/instructions_navigation.dart +++ b/catalyst_voices/apps/voices/lib/pages/registration/create_base_profile/stage/widgets/instructions_navigation.dart @@ -27,7 +27,10 @@ class _InstructionsNavigation extends StatelessWidget { return VoicesFilledButton( key: const Key('CreateBaseProfileNext'), onTap: isEnabled ? () => RegistrationCubit.of(context).nextStep() : null, - child: Text(context.l10n.createProfileInstructionsNext), + child: Text( + context.l10n.createProfileInstructionsNext, + semanticsIdentifier: 'createProfileInstructionsNext', + ), ); } } diff --git a/catalyst_voices/apps/voices/lib/pages/registration/create_base_profile/stage/widgets/receive_emails_checkbox.dart b/catalyst_voices/apps/voices/lib/pages/registration/create_base_profile/stage/widgets/receive_emails_checkbox.dart index 8bb0a84e290e..b79505752be1 100644 --- a/catalyst_voices/apps/voices/lib/pages/registration/create_base_profile/stage/widgets/receive_emails_checkbox.dart +++ b/catalyst_voices/apps/voices/lib/pages/registration/create_base_profile/stage/widgets/receive_emails_checkbox.dart @@ -33,6 +33,7 @@ class _ReceiveEmailsCheckbox extends StatelessWidget { RegistrationCubit.of(context).baseProfile.updateReceiveEmails(isAccepted: value); }, isEnabled: data.isEnabled, + semanticsIdentifier: 'ReceiveEmailsCheckbox', ); } } diff --git a/catalyst_voices/apps/voices/lib/pages/registration/create_base_profile/stage/widgets/registration_conditions_checkbox.dart b/catalyst_voices/apps/voices/lib/pages/registration/create_base_profile/stage/widgets/registration_conditions_checkbox.dart index 48a74ec3971b..f13992d98cd4 100644 --- a/catalyst_voices/apps/voices/lib/pages/registration/create_base_profile/stage/widgets/registration_conditions_checkbox.dart +++ b/catalyst_voices/apps/voices/lib/pages/registration/create_base_profile/stage/widgets/registration_conditions_checkbox.dart @@ -31,6 +31,7 @@ class _RegistrationConditionsCheckbox extends StatelessWidget { onChanged: (value) { RegistrationCubit.of(context).baseProfile.updateConditions(accepted: value); }, + semanticsIdentifier: 'RegistrationConditionsCheckbox', ); } } diff --git a/catalyst_voices/apps/voices/lib/pages/registration/create_base_profile/stage/widgets/tos_and_privacy_policy_checkbox.dart b/catalyst_voices/apps/voices/lib/pages/registration/create_base_profile/stage/widgets/tos_and_privacy_policy_checkbox.dart index a5831810183d..330634eb560b 100644 --- a/catalyst_voices/apps/voices/lib/pages/registration/create_base_profile/stage/widgets/tos_and_privacy_policy_checkbox.dart +++ b/catalyst_voices/apps/voices/lib/pages/registration/create_base_profile/stage/widgets/tos_and_privacy_policy_checkbox.dart @@ -30,6 +30,7 @@ class _TosAndPrivacyPolicyCheckbox extends StatelessWidget { onChanged: (value) { RegistrationCubit.of(context).baseProfile.updateTosAndPrivacyPolicy(accepted: value); }, + semanticsIdentifier: 'tosAndPrivacyPolicyCheckbox', ); } } diff --git a/catalyst_voices/apps/voices/lib/pages/registration/create_keychain/stage/seed_phrase_panel.dart b/catalyst_voices/apps/voices/lib/pages/registration/create_keychain/stage/seed_phrase_panel.dart index 78c21fde0f45..b51601da5af6 100644 --- a/catalyst_voices/apps/voices/lib/pages/registration/create_keychain/stage/seed_phrase_panel.dart +++ b/catalyst_voices/apps/voices/lib/pages/registration/create_keychain/stage/seed_phrase_panel.dart @@ -129,7 +129,10 @@ class _SeedPhraseWords extends StatelessWidget { VoicesTextButton( key: const Key('DownloadSeedPhraseButton'), onTap: () => unawaited(_exportSeedPhrase(context)), - child: Text(context.l10n.createKeychainSeedPhraseExport), + child: Text( + context.l10n.createKeychainSeedPhraseExport, + semanticsIdentifier: 'DownloadSeedPhraseButton', + ), ), ], ), @@ -161,6 +164,7 @@ class _StoredCheckbox extends StatelessWidget { onChanged: (value) { RegistrationCubit.of(context).keychainCreation.setSeedPhraseStored(value); }, + semanticsIdentifier: 'SeedPhraseStoredCheckbox', ); } } diff --git a/catalyst_voices/apps/voices/lib/pages/registration/create_keychain/stage/splash_panel.dart b/catalyst_voices/apps/voices/lib/pages/registration/create_keychain/stage/splash_panel.dart index c4f50b09ebd4..7df15b97c7ba 100644 --- a/catalyst_voices/apps/voices/lib/pages/registration/create_keychain/stage/splash_panel.dart +++ b/catalyst_voices/apps/voices/lib/pages/registration/create_keychain/stage/splash_panel.dart @@ -19,7 +19,10 @@ class SplashPanel extends StatelessWidget { ), footer: VoicesFilledButton( key: const Key('CreateKeychainButton'), - child: Text(context.l10n.accountCreationSplashNextButton), + child: Text( + context.l10n.accountCreationSplashNextButton, + semanticsIdentifier: 'CreateKeychainNowButton', + ), onTap: () => RegistrationCubit.of(context).nextStep(), ), ); diff --git a/catalyst_voices/apps/voices/lib/pages/registration/get_started/get_started_panel.dart b/catalyst_voices/apps/voices/lib/pages/registration/get_started/get_started_panel.dart index d1e678630da6..7504d54be785 100644 --- a/catalyst_voices/apps/voices/lib/pages/registration/get_started/get_started_panel.dart +++ b/catalyst_voices/apps/voices/lib/pages/registration/get_started/get_started_panel.dart @@ -46,6 +46,7 @@ class GetStartedPanel extends StatelessWidget { icon: type._icon, title: type._getTitle(context.l10n), subtitle: type._getSubtitle(context.l10n), + semanticsIdentifier: type.toString(), onTap: () async { switch (type) { case CreateAccountType.createNew: diff --git a/catalyst_voices/apps/voices/lib/pages/registration/recover/seed_phrase/account_details_panel.dart b/catalyst_voices/apps/voices/lib/pages/registration/recover/seed_phrase/account_details_panel.dart index f081a9a0d984..e3b7023e4951 100644 --- a/catalyst_voices/apps/voices/lib/pages/registration/recover/seed_phrase/account_details_panel.dart +++ b/catalyst_voices/apps/voices/lib/pages/registration/recover/seed_phrase/account_details_panel.dart @@ -17,9 +17,7 @@ import 'package:flutter/material.dart'; import 'package:result_type/result_type.dart'; class AccountDetailsPanel extends StatelessWidget { - const AccountDetailsPanel({ - super.key, - }); + const AccountDetailsPanel({super.key}); @override Widget build(BuildContext context) { @@ -41,9 +39,7 @@ class AccountDetailsPanel extends StatelessWidget { class _AccountRoles extends StatelessWidget { final Set roles; - const _AccountRoles({ - required this.roles, - }); + const _AccountRoles({required this.roles}); @override Widget build(BuildContext context) { @@ -69,11 +65,7 @@ class _AccountSummaryDetails extends StatelessWidget { final String? email; final Set roles; - const _AccountSummaryDetails({ - required this.username, - required this.email, - required this.roles, - }); + const _AccountSummaryDetails({required this.username, required this.email, required this.roles}); @override Widget build(BuildContext context) { @@ -81,25 +73,17 @@ class _AccountSummaryDetails extends StatelessWidget { padding: const EdgeInsets.all(12), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), - border: Border.all( - width: 1.5, - color: Theme.of(context).colors.outlineBorderVariant, - ), + border: Border.all(width: 1.5, color: Theme.of(context).colors.outlineBorderVariant), ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, spacing: 12, children: [ - _SummaryDetails( - label: Text(context.l10n.nickname), - value: UsernameText(username), - ), + _SummaryDetails(label: Text(context.l10n.nickname), value: UsernameText(username)), _SummaryDetails( label: Text(context.l10n.email), - value: Text( - email?.nullIfEmpty() ?? context.l10n.notAvailableAbbr.toLowerCase(), - ), + value: Text(email?.nullIfEmpty() ?? context.l10n.notAvailableAbbr.toLowerCase()), ), _SummaryDetails( label: Text(context.l10n.registeredRoles), @@ -141,9 +125,7 @@ class _BlocNavigation extends StatelessWidget { return BlocRecoverSelector( selector: (state) => state.isAccountSummaryNextEnabled, builder: (context, state) { - return _Navigation( - isNextEnabled: state, - ); + return _Navigation(isNextEnabled: state); }, ); } @@ -160,9 +142,7 @@ class _CheckOnCardanoScanButton extends StatelessWidget with LaunchUrlMixin { alignment: Alignment.topLeft, child: VoicesTextButton( style: const ButtonStyle( - padding: WidgetStatePropertyAll( - EdgeInsets.symmetric(horizontal: 8), - ), + padding: WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: 8)), ), child: Text( context.l10n.checkOnCardanoScan, @@ -185,9 +165,7 @@ class _CheckOnCardanoScanButton extends StatelessWidget with LaunchUrlMixin { class _Navigation extends StatelessWidget { final bool isNextEnabled; - const _Navigation({ - this.isNextEnabled = false, - }); + const _Navigation({this.isNextEnabled = false}); @override Widget build(BuildContext context) { @@ -198,7 +176,10 @@ class _Navigation extends StatelessWidget { VoicesFilledButton( key: const Key('SetUnlockPasswordButton'), onTap: isNextEnabled ? () => RegistrationCubit.of(context).nextStep() : null, - child: Text(context.l10n.recoveryAccountDetailsAction), + child: Text( + context.l10n.recoveryAccountDetailsAction, + semanticsIdentifier: 'recoveryAccountDetailsAction', + ), ), const SizedBox(height: 10), VoicesTextButton( @@ -218,9 +199,7 @@ class _Navigation extends StatelessWidget { class _RecoverAccountFailure extends StatelessWidget { final LocalizedException exception; - const _RecoverAccountFailure({ - required this.exception, - }); + const _RecoverAccountFailure({required this.exception}); @override Widget build(BuildContext context) { @@ -238,9 +217,7 @@ class _RecoverAccountFailure extends StatelessWidget { class _RecoveredAccountSummary extends StatelessWidget { final AccountSummaryData account; - const _RecoveredAccountSummary({ - required this.account, - }); + const _RecoveredAccountSummary({required this.account}); @override Widget build(BuildContext context) { @@ -291,10 +268,7 @@ class _SummaryDetails extends StatelessWidget { final Widget label; final Widget value; - const _SummaryDetails({ - required this.label, - required this.value, - }); + const _SummaryDetails({required this.label, required this.value}); @override Widget build(BuildContext context) { @@ -309,10 +283,7 @@ class _SummaryDetails extends StatelessWidget { ), ), Expanded( - child: DefaultTextStyle( - style: textStyle, - child: value, - ), + child: DefaultTextStyle(style: textStyle, child: value), ), ], ); @@ -337,10 +308,7 @@ class _WalletSummaryDetails extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 12), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), - border: Border.all( - width: 1.5, - color: Theme.of(context).colors.outlineBorderVariant, - ), + border: Border.all(width: 1.5, color: Theme.of(context).colors.outlineBorderVariant), ), child: Column( mainAxisSize: MainAxisSize.min, @@ -356,26 +324,19 @@ class _WalletSummaryDetails extends StatelessWidget { children: [ Text(address), if (clipboardAddress != null) - VoicesClipboardIconButton( - clipboardData: clipboardAddress.toBech32(), - ), + VoicesClipboardIconButton(clipboardData: clipboardAddress.toBech32()), ], ), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 12), - child: _SummaryDetails( - label: Text(context.l10n.balance), - value: Text(balance), - ), + child: _SummaryDetails(label: Text(context.l10n.balance), value: Text(balance)), ), if (clipboardAddress != null) Padding( padding: const EdgeInsets.symmetric(horizontal: 4), - child: _CheckOnCardanoScanButton( - address: clipboardAddress, - ), + child: _CheckOnCardanoScanButton(address: clipboardAddress), ), ], ), diff --git a/catalyst_voices/apps/voices/lib/pages/registration/wallet_link/stage/intro_panel.dart b/catalyst_voices/apps/voices/lib/pages/registration/wallet_link/stage/intro_panel.dart index 72b26d01452a..f7ce94b1aba7 100644 --- a/catalyst_voices/apps/voices/lib/pages/registration/wallet_link/stage/intro_panel.dart +++ b/catalyst_voices/apps/voices/lib/pages/registration/wallet_link/stage/intro_panel.dart @@ -24,7 +24,10 @@ class IntroPanel extends StatelessWidget { onTap: () { RegistrationCubit.of(context).nextStep(); }, - child: Text(context.l10n.chooseCardanoWallet), + child: Text( + context.l10n.chooseCardanoWallet, + semanticsIdentifier: 'ChooseCardanoWalletButton', + ), ), ); } diff --git a/catalyst_voices/apps/voices/lib/pages/registration/wallet_link/stage/rbac_transaction_panel.dart b/catalyst_voices/apps/voices/lib/pages/registration/wallet_link/stage/rbac_transaction_panel.dart index 32caccdcd98c..c8e4a4db1ae1 100644 --- a/catalyst_voices/apps/voices/lib/pages/registration/wallet_link/stage/rbac_transaction_panel.dart +++ b/catalyst_voices/apps/voices/lib/pages/registration/wallet_link/stage/rbac_transaction_panel.dart @@ -42,7 +42,10 @@ class _BlocSubmitTxButton extends StatelessWidget { child: VoicesCircularProgressIndicator(), ) : null, - child: Text(context.l10n.walletLinkTransactionSign), + child: Text( + context.l10n.walletLinkTransactionSign, + semanticsIdentifier: 'SignTransactionButton', + ), ); }, ); @@ -245,7 +248,10 @@ class _SuccessNavigation extends StatelessWidget { onTap: () { RegistrationCubit.of(context).changeRoleSetup(); }, - child: Text(context.l10n.walletLinkTransactionChangeRoles), + child: Text( + context.l10n.walletLinkTransactionChangeRoles, + semanticsIdentifier: 'TransactionReviewChangeRolesButton', + ), ), ], ); diff --git a/catalyst_voices/apps/voices/lib/pages/registration/wallet_link/stage/roles_summary_panel.dart b/catalyst_voices/apps/voices/lib/pages/registration/wallet_link/stage/roles_summary_panel.dart index 27d34e014b8d..0cf49f02f086 100644 --- a/catalyst_voices/apps/voices/lib/pages/registration/wallet_link/stage/roles_summary_panel.dart +++ b/catalyst_voices/apps/voices/lib/pages/registration/wallet_link/stage/roles_summary_panel.dart @@ -9,9 +9,7 @@ import 'package:catalyst_voices_view_models/catalyst_voices_view_models.dart'; import 'package:flutter/material.dart'; class RolesSummaryPanel extends StatelessWidget { - const RolesSummaryPanel({ - super.key, - }); + const RolesSummaryPanel({super.key}); @override Widget build(BuildContext context) { @@ -37,14 +35,20 @@ class RolesSummaryPanel extends StatelessWidget { onTap: () { RegistrationCubit.of(context).nextStep(); }, - child: Text(context.l10n.reviewRegistrationTransaction), + child: Text( + context.l10n.reviewRegistrationTransaction, + semanticsIdentifier: 'RolesSummaryReviewTransaction', + ), ), const SizedBox(height: 10), VoicesTextButton( onTap: () { RegistrationCubit.of(context).changeRoleSetup(); }, - child: Text(context.l10n.walletLinkTransactionChangeRoles), + child: Text( + context.l10n.walletLinkTransactionChangeRoles, + semanticsIdentifier: 'RolesSummaryChangeRoles', + ), ), ], ), @@ -88,16 +92,12 @@ class _Subtitle extends StatelessWidget { return Text.rich( TextSpan( children: [ - TextSpan( - text: context.l10n.walletLinkRoleSummaryContent1, - ), + TextSpan(text: context.l10n.walletLinkRoleSummaryContent1), TextSpan( text: context.l10n.walletLinkRoleSummaryContent2(selectedRoles.length), style: const TextStyle(fontWeight: FontWeight.bold), ), - TextSpan( - text: context.l10n.walletLinkRoleSummaryContent3, - ), + TextSpan(text: context.l10n.walletLinkRoleSummaryContent3), ], ), ); diff --git a/catalyst_voices/apps/voices/lib/pages/registration/widgets/registration_tile.dart b/catalyst_voices/apps/voices/lib/pages/registration/widgets/registration_tile.dart index 96a0a9c663b1..d32038d845ed 100644 --- a/catalyst_voices/apps/voices/lib/pages/registration/widgets/registration_tile.dart +++ b/catalyst_voices/apps/voices/lib/pages/registration/widgets/registration_tile.dart @@ -6,6 +6,7 @@ class RegistrationTile extends StatelessWidget { final String title; final String? subtitle; final VoidCallback? onTap; + final String semanticsIdentifier; const RegistrationTile({ super.key, @@ -13,6 +14,7 @@ class RegistrationTile extends StatelessWidget { required this.title, this.subtitle, this.onTap, + this.semanticsIdentifier = 'RegistrationTile', }); @override @@ -50,6 +52,7 @@ class RegistrationTile extends StatelessWidget { children: [ Text( key: const Key('RegistrationTileTitle'), + semanticsIdentifier: '${semanticsIdentifier}Title', title, maxLines: 2, overflow: TextOverflow.ellipsis, @@ -58,6 +61,7 @@ class RegistrationTile extends StatelessWidget { if (subtitle != null) Text( key: const Key('RegistrationTileSubtitle'), + semanticsIdentifier: '${semanticsIdentifier}Subtitle', subtitle, maxLines: 1, overflow: TextOverflow.clip, diff --git a/catalyst_voices/apps/voices/lib/pages/registration/widgets/seed_phrase_actions.dart b/catalyst_voices/apps/voices/lib/pages/registration/widgets/seed_phrase_actions.dart index e3ea2684b4f1..6cc9a20129a6 100644 --- a/catalyst_voices/apps/voices/lib/pages/registration/widgets/seed_phrase_actions.dart +++ b/catalyst_voices/apps/voices/lib/pages/registration/widgets/seed_phrase_actions.dart @@ -19,14 +19,14 @@ class SeedPhraseActions extends StatelessWidget { VoicesTextButton( key: const Key('UploadKeyButton'), onTap: onImportKeyTap, - child: Text(context.l10n.importCatalystKey), + child: Text(context.l10n.importCatalystKey, semanticsIdentifier: 'UploadKeyButton'), ), const Spacer(), if (onResetTap != null) VoicesTextButton( key: const Key('ResetButton'), onTap: onResetTap, - child: Text(context.l10n.reset), + child: Text(context.l10n.reset, semanticsIdentifier: 'ResetButton'), ), ], ); diff --git a/catalyst_voices/apps/voices/lib/pages/registration/widgets/unlock_password_form.dart b/catalyst_voices/apps/voices/lib/pages/registration/widgets/unlock_password_form.dart index 49bad6386e12..4d3f81766de7 100644 --- a/catalyst_voices/apps/voices/lib/pages/registration/widgets/unlock_password_form.dart +++ b/catalyst_voices/apps/voices/lib/pages/registration/widgets/unlock_password_form.dart @@ -65,6 +65,7 @@ class _ConfirmUnlockPasswordTextField extends StatelessWidget { Widget build(BuildContext context) { return VoicesPasswordTextField( key: const Key('PasswordConfirmInputField'), + semanticsIdentifier: 'PasswordConfirmInputField', controller: controller, decoration: VoicesTextFieldDecoration( labelText: context.l10n.confirmPassword, @@ -107,6 +108,7 @@ class _UnlockPasswordTextField extends StatelessWidget { Widget build(BuildContext context) { return VoicesPasswordTextField( key: const Key('PasswordInputField'), + semanticsIdentifier: 'PasswordInputField', controller: controller, textInputAction: TextInputAction.next, decoration: VoicesTextFieldDecoration( diff --git a/catalyst_voices/apps/voices/lib/pages/registration/widgets/wallet_summary.dart b/catalyst_voices/apps/voices/lib/pages/registration/widgets/wallet_summary.dart index 82dda6ca83d7..c37bc8f4c614 100644 --- a/catalyst_voices/apps/voices/lib/pages/registration/widgets/wallet_summary.dart +++ b/catalyst_voices/apps/voices/lib/pages/registration/widgets/wallet_summary.dart @@ -144,6 +144,7 @@ class _WalletSummaryAddress extends StatelessWidget { VoicesClipboardIconButton(clipboardData: clipboardAddress), ], ), + semanticsIdentifier: 'WalletAddress', ); } } @@ -183,6 +184,7 @@ class _WalletSummaryBalance extends StatelessWidget { ], ], ), + semanticsIdentifier: 'WalletBalance', ); } } @@ -190,31 +192,37 @@ class _WalletSummaryBalance extends StatelessWidget { class _WalletSummaryItem extends StatelessWidget { final Widget label; final Widget value; + final String semanticsIdentifier; const _WalletSummaryItem({ required this.label, required this.value, + this.semanticsIdentifier = 'WalletSummaryItem', }); @override Widget build(BuildContext context) { - return Row( - children: [ - Expanded( - child: DefaultTextStyle( - style: Theme.of(context).textTheme.labelMedium!.copyWith( - fontWeight: FontWeight.w800, + return Semantics( + identifier: semanticsIdentifier, + container: true, + child: Row( + children: [ + Expanded( + child: DefaultTextStyle( + style: Theme.of(context).textTheme.labelMedium!.copyWith( + fontWeight: FontWeight.w800, + ), + child: label, ), - child: label, ), - ), - Expanded( - child: DefaultTextStyle( - style: Theme.of(context).textTheme.labelMedium!, - child: value, + Expanded( + child: DefaultTextStyle( + style: Theme.of(context).textTheme.labelMedium!, + child: value, + ), ), - ), - ], + ], + ), ); } } @@ -234,6 +242,7 @@ class _WalletSummaryName extends StatelessWidget { walletName.capitalize(), key: const Key('NameOfWalletValue'), ), + semanticsIdentifier: 'NameOfWallet', ); } } diff --git a/catalyst_voices/apps/voices/lib/pages/spaces/appbar/actions/session_cta_action.dart b/catalyst_voices/apps/voices/lib/pages/spaces/appbar/actions/session_cta_action.dart index 6aad406879b6..33ddd010561e 100644 --- a/catalyst_voices/apps/voices/lib/pages/spaces/appbar/actions/session_cta_action.dart +++ b/catalyst_voices/apps/voices/lib/pages/spaces/appbar/actions/session_cta_action.dart @@ -60,7 +60,7 @@ class _GetStartedButton extends StatelessWidget { context, type: const FreshRegistration(), ), - child: Text(context.l10n.getStarted), + child: Text(context.l10n.getStarted, semanticsIdentifier: 'GetStartedButton'), ); } } diff --git a/catalyst_voices/apps/voices/lib/widgets/buttons/voices_buttons.dart b/catalyst_voices/apps/voices/lib/widgets/buttons/voices_buttons.dart index 7f5fee68e319..36160ad40cf4 100644 --- a/catalyst_voices/apps/voices/lib/widgets/buttons/voices_buttons.dart +++ b/catalyst_voices/apps/voices/lib/widgets/buttons/voices_buttons.dart @@ -258,7 +258,7 @@ class VoicesBackButton extends StatelessWidget { Widget build(BuildContext context) { return VoicesOutlinedButton( onTap: onTap, - child: Text(context.l10n.back), + child: Text(context.l10n.back, semanticsIdentifier: 'BackButton'), ); } } @@ -351,7 +351,7 @@ class VoicesLearnMoreFilledButton extends StatelessWidget { key: const Key('LearnMoreButton'), trailing: VoicesAssets.icons.externalLink.buildIcon(), onTap: onTap, - child: Text(context.l10n.learnMore), + child: Text(context.l10n.learnMore, semanticsIdentifier: 'LearnMoreButton'), ); } } @@ -396,8 +396,9 @@ class VoicesNextButton extends StatelessWidget { @override Widget build(BuildContext context) { return VoicesFilledButton( + key: const Key('NextButton'), onTap: onTap, - child: Text(context.l10n.next), + child: Text(context.l10n.next, semanticsIdentifier: 'NextButton'), ); } } diff --git a/catalyst_voices/apps/voices/lib/widgets/buttons/voices_segmented_button.dart b/catalyst_voices/apps/voices/lib/widgets/buttons/voices_segmented_button.dart index 03f08d0d94ed..b983df26f8bb 100644 --- a/catalyst_voices/apps/voices/lib/widgets/buttons/voices_segmented_button.dart +++ b/catalyst_voices/apps/voices/lib/widgets/buttons/voices_segmented_button.dart @@ -71,15 +71,19 @@ class VoicesSegmentedButton extends StatelessWidget { @override Widget build(BuildContext context) { - return SegmentedButton( - key: const Key('SegmentedButton'), - segments: segments, - selected: selected, - onSelectionChanged: onChanged, - multiSelectionEnabled: multiSelectionEnabled, - emptySelectionAllowed: emptySelectionAllowed, - showSelectedIcon: showSelectedIcon, - style: style, + return Semantics( + identifier: 'VoicesSegmentedButton', + container: true, + child: SegmentedButton( + key: const Key('SegmentedButton'), + segments: segments, + selected: selected, + onSelectionChanged: onChanged, + multiSelectionEnabled: multiSelectionEnabled, + emptySelectionAllowed: emptySelectionAllowed, + showSelectedIcon: showSelectedIcon, + style: style, + ), ); } } diff --git a/catalyst_voices/apps/voices/lib/widgets/cards/role_chooser_card.dart b/catalyst_voices/apps/voices/lib/widgets/cards/role_chooser_card.dart index 9a1024eb7b1e..152832a3558c 100644 --- a/catalyst_voices/apps/voices/lib/widgets/cards/role_chooser_card.dart +++ b/catalyst_voices/apps/voices/lib/widgets/cards/role_chooser_card.dart @@ -56,18 +56,12 @@ class RoleChooserCard extends StatelessWidget { decoration: isViewOnly ? null : BoxDecoration( - border: Border.all( - color: Theme.of(context).colors.outlineBorderVariant, - ), + border: Border.all(color: Theme.of(context).colors.outlineBorderVariant), borderRadius: BorderRadius.circular(8), ), child: Row( children: [ - Column( - children: [ - icon, - ], - ), + Column(children: [icon]), const SizedBox(width: 12), Expanded( child: Column( @@ -83,9 +77,7 @@ class RoleChooserCard extends StatelessWidget { ), if (!isLearnMoreHidden) ...[ const SizedBox(width: 10), - _LearnMoreText( - onTap: onLearnMore, - ), + _LearnMoreText(onTap: onLearnMore), ], ], ), @@ -93,10 +85,7 @@ class RoleChooserCard extends StatelessWidget { Row( children: [ if (isViewOnly) - _DisplayingValueAsChips( - value: value, - isDefault: isDefault, - ) + _DisplayingValueAsChips(value: value, isDefault: isDefault) else _DisplayingValueAsSegmentedButton( value: value, @@ -119,10 +108,7 @@ class _DisplayingValueAsChips extends StatelessWidget { final bool value; final bool isDefault; - const _DisplayingValueAsChips({ - required this.value, - required this.isDefault, - }); + const _DisplayingValueAsChips({required this.value, required this.isDefault}); @override Widget build(BuildContext context) { @@ -133,6 +119,7 @@ class _DisplayingValueAsChips extends StatelessWidget { VoicesChip.round( content: Text( value ? context.l10n.yes : context.l10n.no, + semanticsIdentifier: 'YesNoChip', style: TextStyle( color: value ? Theme.of(context).colors.successContainer @@ -151,14 +138,10 @@ class _DisplayingValueAsChips extends StatelessWidget { VoicesChip.round( content: Text( context.l10n.defaultRole, - style: TextStyle( - color: Theme.of(context).colors.iconsPrimary, - ), - ), - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 4, + semanticsIdentifier: 'DefaultRoleChip', + style: TextStyle(color: Theme.of(context).colors.iconsPrimary), ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), backgroundColor: Theme.of(context).colors.iconsBackgroundVariant, ), ], @@ -193,20 +176,18 @@ class _DisplayingValueAsSegmentedButton extends StatelessWidget { if (isDefault) '(${context.l10n.defaultRole})', ].join(' '), ), - icon: Icon( - value ? Icons.check : Icons.block, - ), + icon: Icon(value ? Icons.check : Icons.block), ), ] : [ ButtonSegment( value: true, - label: Text(context.l10n.yes), + label: Text(context.l10n.yes, semanticsIdentifier: 'RoleYesButton'), icon: value ? const Icon(Icons.check) : null, ), ButtonSegment( value: false, - label: Text(context.l10n.no), + label: Text(context.l10n.no, semanticsIdentifier: 'RoleNoButton'), icon: !value ? const Icon(Icons.block) : null, ), ], @@ -232,9 +213,7 @@ class _DisplayingValueAsSegmentedButton extends StatelessWidget { class _LearnMoreText extends StatelessWidget { final VoidCallback? onTap; - const _LearnMoreText({ - this.onTap, - }); + const _LearnMoreText({this.onTap}); @override Widget build(BuildContext context) { diff --git a/catalyst_voices/apps/voices/lib/widgets/common/semantics/combine_semantics.dart b/catalyst_voices/apps/voices/lib/widgets/common/semantics/combine_semantics.dart new file mode 100644 index 000000000000..8449ab2fdc35 --- /dev/null +++ b/catalyst_voices/apps/voices/lib/widgets/common/semantics/combine_semantics.dart @@ -0,0 +1,170 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +/// A widget that replaces this pattern: +/// ```dart +/// MergeSemantics( +/// child: Semantics( +/// // ... semantic properties +/// child: YourWidget(), +/// ) +/// ) +/// ``` +class CombineSemantics extends MergeSemantics { + CombineSemantics({ + super.key, + Widget? child, + bool container = false, + bool explicitChildNodes = false, + bool excludeSemantics = false, + bool blockUserActions = false, + bool? enabled, + bool? checked, + bool? mixed, + bool? selected, + bool? toggled, + bool? button, + bool? slider, + bool? keyboardKey, + bool? link, + Uri? linkUrl, + bool? header, + int? headingLevel, + bool? textField, + bool? readOnly, + bool? focusable, + bool? focused, + bool? inMutuallyExclusiveGroup, + bool? obscured, + bool? multiline, + bool? scopesRoute, + bool? namesRoute, + bool? hidden, + bool? image, + bool? liveRegion, + bool? expanded, + bool? isRequired, + int? maxValueLength, + int? currentValueLength, + String? identifier, + String? label, + AttributedString? attributedLabel, + String? value, + AttributedString? attributedValue, + String? increasedValue, + AttributedString? attributedIncreasedValue, + String? decreasedValue, + AttributedString? attributedDecreasedValue, + String? hint, + AttributedString? attributedHint, + String? tooltip, + String? onTapHint, + String? onLongPressHint, + TextDirection? textDirection, + SemanticsSortKey? sortKey, + SemanticsTag? tagForChildren, + VoidCallback? onTap, + VoidCallback? onLongPress, + VoidCallback? onScrollLeft, + VoidCallback? onScrollRight, + VoidCallback? onScrollUp, + VoidCallback? onScrollDown, + VoidCallback? onIncrease, + VoidCallback? onDecrease, + VoidCallback? onCopy, + VoidCallback? onCut, + VoidCallback? onPaste, + VoidCallback? onDismiss, + MoveCursorHandler? onMoveCursorForwardByCharacter, + MoveCursorHandler? onMoveCursorBackwardByCharacter, + SetSelectionHandler? onSetSelection, + SetTextHandler? onSetText, + VoidCallback? onDidGainAccessibilityFocus, + VoidCallback? onDidLoseAccessibilityFocus, + VoidCallback? onFocus, + Map? customSemanticsActions, + SemanticsRole? role, + Set? controlsNodes, + SemanticsValidationResult validationResult = SemanticsValidationResult.none, + ui.SemanticsInputType? inputType, + }) : super( + child: Semantics( + container: container, + explicitChildNodes: explicitChildNodes, + excludeSemantics: excludeSemantics, + blockUserActions: blockUserActions, + enabled: enabled, + checked: checked, + mixed: mixed, + selected: selected, + toggled: toggled, + button: button, + slider: slider, + keyboardKey: keyboardKey, + link: link, + linkUrl: linkUrl, + header: header, + headingLevel: headingLevel, + textField: textField, + readOnly: readOnly, + focusable: focusable, + focused: focused, + inMutuallyExclusiveGroup: inMutuallyExclusiveGroup, + obscured: obscured, + multiline: multiline, + scopesRoute: scopesRoute, + namesRoute: namesRoute, + hidden: hidden, + image: image, + liveRegion: liveRegion, + expanded: expanded, + isRequired: isRequired, + maxValueLength: maxValueLength, + currentValueLength: currentValueLength, + identifier: identifier, + label: label, + attributedLabel: attributedLabel, + value: value, + attributedValue: attributedValue, + increasedValue: increasedValue, + attributedIncreasedValue: attributedIncreasedValue, + decreasedValue: decreasedValue, + attributedDecreasedValue: attributedDecreasedValue, + hint: hint, + attributedHint: attributedHint, + tooltip: tooltip, + onTapHint: onTapHint, + onLongPressHint: onLongPressHint, + textDirection: textDirection, + sortKey: sortKey, + tagForChildren: tagForChildren, + onTap: onTap, + onLongPress: onLongPress, + onScrollLeft: onScrollLeft, + onScrollRight: onScrollRight, + onScrollUp: onScrollUp, + onScrollDown: onScrollDown, + onIncrease: onIncrease, + onDecrease: onDecrease, + onCopy: onCopy, + onCut: onCut, + onPaste: onPaste, + onDismiss: onDismiss, + onMoveCursorForwardByCharacter: onMoveCursorForwardByCharacter, + onMoveCursorBackwardByCharacter: onMoveCursorBackwardByCharacter, + onSetSelection: onSetSelection, + onSetText: onSetText, + onDidGainAccessibilityFocus: onDidGainAccessibilityFocus, + onDidLoseAccessibilityFocus: onDidLoseAccessibilityFocus, + onFocus: onFocus, + customSemanticsActions: customSemanticsActions, + role: role, + controlsNodes: controlsNodes, + validationResult: validationResult, + inputType: inputType, + child: child, + ), + ); +} diff --git a/catalyst_voices/apps/voices/lib/widgets/containers/sidebar/space_side_panel.dart b/catalyst_voices/apps/voices/lib/widgets/containers/sidebar/space_side_panel.dart index 49945c554913..1cf0dfad6783 100644 --- a/catalyst_voices/apps/voices/lib/widgets/containers/sidebar/space_side_panel.dart +++ b/catalyst_voices/apps/voices/lib/widgets/containers/sidebar/space_side_panel.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:catalyst_voices/widgets/buttons/voices_icon_button.dart'; +import 'package:catalyst_voices/widgets/common/semantics/combine_semantics.dart'; import 'package:catalyst_voices/widgets/common/tab_bar_stack_view.dart'; import 'package:catalyst_voices/widgets/tabbar/voices_tab.dart'; import 'package:catalyst_voices/widgets/tabbar/voices_tab_bar.dart'; @@ -108,69 +109,67 @@ class _SpaceSidePanelState extends State with SingleTickerProvid @override Widget build(BuildContext context) { return FocusTraversalGroup( - child: Stack( - children: [ - if (widget.isLeft) + child: Semantics( + identifier: 'SpaceSidePanel', + container: true, + child: Stack( + children: [ Positioned( top: 24, - left: 16, - child: VoicesIconButton( - child: VoicesAssets.icons.leftRailToggle.buildIcon(), - onTap: () { - unawaited(_controller.reverse()); - }, - ), - ) - else - Positioned( - top: 24, - right: 16, - child: VoicesIconButton( - child: VoicesAssets.icons.rightRailToggle.buildIcon(), - onTap: () { - unawaited(_controller.reverse()); - }, + right: widget.isLeft ? null : 16, + left: widget.isLeft ? 16 : null, + child: CombineSemantics( + identifier: 'SpaceSidePanelToggleButton', + child: VoicesIconButton( + child: widget.isLeft + ? VoicesAssets.icons.leftRailToggle.buildIcon() + : VoicesAssets.icons.rightRailToggle.buildIcon(), + onTap: () { + unawaited(_controller.reverse()); + }, + ), ), ), - SlideTransition( - position: _offsetAnimation, - child: _Container( - margin: widget.margin, - borderRadius: widget.isLeft - ? const BorderRadius.horizontal(right: Radius.circular(16)) - : const BorderRadius.horizontal(left: Radius.circular(16)), - child: DefaultTabController( - length: widget.tabs.length, - child: Column( - children: [ - _Header( - onCollapseTap: () { - unawaited(_controller.forward()); - widget.onCollapseTap?.call(); - }, - isLeft: widget.isLeft, - ), - _Tabs( - widget.tabs, - controller: widget.tabController, - ), - const SizedBox(height: 12), - Flexible( - child: SingleChildScrollView( - controller: widget.scrollController, - padding: const EdgeInsets.only(bottom: 12), - child: TabBarStackView( - controller: widget.tabController, - children: widget.tabs.map((e) => e.body).toList(), + SlideTransition( + position: _offsetAnimation, + child: _Container( + margin: widget.margin, + borderRadius: widget.isLeft + ? const BorderRadius.horizontal(right: Radius.circular(16)) + : const BorderRadius.horizontal(left: Radius.circular(16)), + child: DefaultTabController( + length: widget.tabs.length, + child: Column( + children: [ + _Header( + onCollapseTap: () { + unawaited(_controller.forward()); + widget.onCollapseTap?.call(); + }, + isLeft: widget.isLeft, + ), + _Tabs( + widget.tabs, + controller: widget.tabController, + ), + const SizedBox(height: 12), + Flexible( + child: SingleChildScrollView( + controller: widget.scrollController, + padding: const EdgeInsets.only(bottom: 12), + child: TabBarStackView( + controller: widget.tabController, + children: widget.tabs.map((e) => e.body).toList(), + ), ), ), - ), - ], + ], + ), ), ), ), - ), - ], + ], + ), ), ); } diff --git a/catalyst_voices/apps/voices/lib/widgets/document_builder/common/document_error_text.dart b/catalyst_voices/apps/voices/lib/widgets/document_builder/common/document_error_text.dart index 261ea67c9fe1..a86779dc589e 100644 --- a/catalyst_voices/apps/voices/lib/widgets/document_builder/common/document_error_text.dart +++ b/catalyst_voices/apps/voices/lib/widgets/document_builder/common/document_error_text.dart @@ -1,3 +1,4 @@ +import 'package:catalyst_voices/widgets/common/semantics/combine_semantics.dart'; import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; import 'package:flutter/material.dart'; @@ -15,10 +16,14 @@ class DocumentErrorText extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - return Text( - text ?? context.l10n.snackbarErrorLabelText, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: enabled ? theme.colorScheme.error : theme.colors.textDisabled, + return CombineSemantics( + identifier: 'DocumentErrorText', + container: true, + child: Text( + text ?? context.l10n.snackbarErrorLabelText, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: enabled ? theme.colorScheme.error : theme.colors.textDisabled, + ), ), ); } diff --git a/catalyst_voices/apps/voices/lib/widgets/document_builder/common/document_property_builder_title.dart b/catalyst_voices/apps/voices/lib/widgets/document_builder/common/document_property_builder_title.dart index d80804a9a408..58416d497edd 100644 --- a/catalyst_voices/apps/voices/lib/widgets/document_builder/common/document_property_builder_title.dart +++ b/catalyst_voices/apps/voices/lib/widgets/document_builder/common/document_property_builder_title.dart @@ -16,12 +16,16 @@ class DocumentPropertyBuilderTitle extends StatelessWidget { @override Widget build(BuildContext context) { - return Text( - title.starred(isEnabled: isRequired), - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: color ?? Theme.of(context).colors.textOnPrimaryLevel1, + return Semantics( + identifier: 'DocumentPropertyBuilderTitle', + container: true, + child: Text( + title.starred(isEnabled: isRequired), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: color ?? Theme.of(context).colors.textOnPrimaryLevel1, + ), ), ); } diff --git a/catalyst_voices/apps/voices/lib/widgets/document_builder/document_property_builder.dart b/catalyst_voices/apps/voices/lib/widgets/document_builder/document_property_builder.dart index 05ea3a36cb8f..62a18e1e0535 100644 --- a/catalyst_voices/apps/voices/lib/widgets/document_builder/document_property_builder.dart +++ b/catalyst_voices/apps/voices/lib/widgets/document_builder/document_property_builder.dart @@ -41,6 +41,14 @@ class DocumentPropertyBuilder extends StatelessWidget { @override Widget build(BuildContext context) { + return Semantics( + identifier: 'DocumentPropertyBuilder[${property.nodeId}]', + container: true, + child: _buildChild(context), + ); + } + + Widget _buildChild(BuildContext context) { final property = this.property; final overrideBuilder = _getOverrideBuilder(property.nodeId); if (overrideBuilder != null) { diff --git a/catalyst_voices/apps/voices/lib/widgets/document_builder/value/list_length_picker_widget.dart b/catalyst_voices/apps/voices/lib/widgets/document_builder/value/list_length_picker_widget.dart index c0e9a7830c93..4e13af3fbb09 100644 --- a/catalyst_voices/apps/voices/lib/widgets/document_builder/value/list_length_picker_widget.dart +++ b/catalyst_voices/apps/voices/lib/widgets/document_builder/value/list_length_picker_widget.dart @@ -28,37 +28,41 @@ class ListLengthPickerWidget extends StatelessWidget { final isRequired = list.schema.isRequired; final description = list.schema.description ?? MarkdownData.empty; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (title.isNotEmpty) ...[ - DocumentPropertyBuilderTitle( - title: title, - isRequired: isRequired, - ), - const SizedBox(height: 8), - ], - if (description.data.isNotEmpty && description.data != title) ...[ - MarkdownText( - description, - pStyle: context.textTheme.bodyMedium, + return Semantics( + identifier: 'ListLengthPickerWidget', + container: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (title.isNotEmpty) ...[ + DocumentPropertyBuilderTitle( + title: title, + isRequired: isRequired, + ), + const SizedBox(height: 8), + ], + if (description.data.isNotEmpty && description.data != title) ...[ + MarkdownText( + description, + pStyle: context.textTheme.bodyMedium, + ), + const SizedBox(height: 22), + ], + SingleSelectDropdown( + items: [ + for (int i = minCount; i <= maxCount; i++) + DropdownMenuEntry( + value: i, + label: list.schema.itemsSchema.title.formatAsPlural(i), + ), + ], + enabled: isEditMode, + onChanged: _onChanged, + value: currentCount, + hintText: list.schema.placeholder, ), - const SizedBox(height: 22), ], - SingleSelectDropdown( - items: [ - for (int i = minCount; i <= maxCount; i++) - DropdownMenuEntry( - value: i, - label: list.schema.itemsSchema.title.formatAsPlural(i), - ), - ], - enabled: isEditMode, - onChanged: _onChanged, - value: currentCount, - hintText: list.schema.placeholder, - ), - ], + ), ); } diff --git a/catalyst_voices/apps/voices/lib/widgets/document_builder/value/yes_no_choice_widget.dart b/catalyst_voices/apps/voices/lib/widgets/document_builder/value/yes_no_choice_widget.dart index 3bf3f78845b5..623a5397659f 100644 --- a/catalyst_voices/apps/voices/lib/widgets/document_builder/value/yes_no_choice_widget.dart +++ b/catalyst_voices/apps/voices/lib/widgets/document_builder/value/yes_no_choice_widget.dart @@ -95,9 +95,7 @@ class _YesNoChoiceSegmentButton extends VoicesFormField { class _YesNoChoiceWidgetState extends State { bool get _isRequired => widget.schema.isRequired; - String get _title => widget.schema.title; - bool? get _value => widget.property.value ?? widget.schema.defaultValue; @override diff --git a/catalyst_voices/apps/voices/lib/widgets/document_builder/viewer/document_property_builder_viewer.dart b/catalyst_voices/apps/voices/lib/widgets/document_builder/viewer/document_property_builder_viewer.dart index 7ba1652f78ed..dbdf108dc394 100644 --- a/catalyst_voices/apps/voices/lib/widgets/document_builder/viewer/document_property_builder_viewer.dart +++ b/catalyst_voices/apps/voices/lib/widgets/document_builder/viewer/document_property_builder_viewer.dart @@ -1,4 +1,5 @@ import 'package:catalyst_voices/common/ext/build_context_ext.dart'; +import 'package:catalyst_voices/widgets/common/semantics/combine_semantics.dart'; import 'package:catalyst_voices/widgets/document_builder/common/document_property_builder_title.dart'; import 'package:catalyst_voices/widgets/widgets.dart'; import 'package:catalyst_voices_localization/catalyst_voices_localization.dart'; @@ -114,7 +115,11 @@ class _DocumentPropertyBuilderViewerState extends State(): - yield _buildValueProperty(property); + yield Semantics( + identifier: 'DocumentPropertyBuilderViewer[${property.nodeId}]', + container: true, + child: _buildValueProperty(property), + ); } } @@ -266,13 +271,17 @@ class _ListItem extends StatelessWidget { ), const SizedBox(height: 8), ], - DefaultTextStyle( - style: (textTheme.bodyMedium ?? const TextStyle()).copyWith( - color: isAnswered ? colors.textOnPrimaryLevel0 : colors.textOnPrimaryLevel1, + CombineSemantics( + identifier: 'DocumentPropertyBuilderViewerListItem', + container: true, + child: DefaultTextStyle( + style: (textTheme.bodyMedium ?? const TextStyle()).copyWith( + color: isAnswered ? colors.textOnPrimaryLevel0 : colors.textOnPrimaryLevel1, + ), + maxLines: !isMultiline ? 1 : null, + overflow: !isMultiline ? TextOverflow.ellipsis : TextOverflow.clip, + child: child, ), - maxLines: !isMultiline ? 1 : null, - overflow: !isMultiline ? TextOverflow.ellipsis : TextOverflow.clip, - child: child, ), ], ); diff --git a/catalyst_voices/apps/voices/lib/widgets/dropdown/voices_dropdown.dart b/catalyst_voices/apps/voices/lib/widgets/dropdown/voices_dropdown.dart index 4c3f8edadeea..f90c4bcd2d11 100644 --- a/catalyst_voices/apps/voices/lib/widgets/dropdown/voices_dropdown.dart +++ b/catalyst_voices/apps/voices/lib/widgets/dropdown/voices_dropdown.dart @@ -1,5 +1,6 @@ import 'package:catalyst_voices/common/ext/build_context_ext.dart'; import 'package:catalyst_voices/common/ext/text_editing_controller_ext.dart'; +import 'package:catalyst_voices/widgets/common/semantics/combine_semantics.dart'; import 'package:catalyst_voices/widgets/form/voices_form_field.dart'; import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; @@ -113,46 +114,49 @@ class SingleSelectDropdown extends VoicesFormField { onChanged?.call(value); } - return ConstrainedBox( - constraints: const BoxConstraints(), - child: DropdownMenu( - requestFocusOnTap: false, - controller: state._controller, - focusNode: focusNode, - expandedInsets: EdgeInsets.zero, - initialSelection: state.value, - enabled: enabled, - hintText: hintText, - dropdownMenuEntries: items, - onSelected: onChangedHandler, - inputDecorationTheme: InputDecorationTheme( - hintStyle: theme.textTheme.bodyLarge?.copyWith( - color: theme.colors.textOnPrimaryLevel1, + return CombineSemantics( + identifier: 'SingleSelectDropdown', + child: ConstrainedBox( + constraints: const BoxConstraints(), + child: DropdownMenu( + requestFocusOnTap: false, + controller: state._controller, + focusNode: focusNode, + expandedInsets: EdgeInsets.zero, + initialSelection: state.value, + enabled: enabled, + hintText: hintText, + dropdownMenuEntries: items, + onSelected: onChangedHandler, + inputDecorationTheme: InputDecorationTheme( + hintStyle: theme.textTheme.bodyLarge?.copyWith( + color: theme.colors.textOnPrimaryLevel1, + ), + fillColor: theme.colors.elevationsOnSurfaceNeutralLv1Grey, + filled: filled, + enabledBorder: _border(field.context, borderRadius), + disabledBorder: _border(field.context, borderRadius), + focusedBorder: _border(field.context, borderRadius), + errorStyle: theme.textTheme.bodySmall?.copyWith( + color: enabled ? theme.colorScheme.error : theme.colors.textDisabled, + ), + focusColor: Colors.tealAccent, ), - fillColor: theme.colors.elevationsOnSurfaceNeutralLv1Grey, - filled: filled, - enabledBorder: _border(field.context, borderRadius), - disabledBorder: _border(field.context, borderRadius), - focusedBorder: _border(field.context, borderRadius), - errorStyle: theme.textTheme.bodySmall?.copyWith( - color: enabled ? theme.colorScheme.error : theme.colors.textDisabled, + errorText: field.errorText, + // using visibility so that the widget reserves + // the space for the icon, otherwise when widget changes + // to edits mode it expands to make space for the icon + trailingIcon: Visibility.maintain( + visible: enabled, + child: VoicesAssets.icons.chevronDown.buildIcon(), ), - focusColor: Colors.tealAccent, - ), - errorText: field.errorText, - // using visibility so that the widget reserves - // the space for the icon, otherwise when widget changes - // to edits mode it expands to make space for the icon - trailingIcon: Visibility.maintain( - visible: enabled, - child: VoicesAssets.icons.chevronDown.buildIcon(), - ), - selectedTrailingIcon: VoicesAssets.icons.chevronUp.buildIcon(), - menuStyle: MenuStyle( - backgroundColor: WidgetStatePropertyAll( - theme.colors.elevationsOnSurfaceNeutralLv1Grey, + selectedTrailingIcon: VoicesAssets.icons.chevronUp.buildIcon(), + menuStyle: MenuStyle( + backgroundColor: WidgetStatePropertyAll( + theme.colors.elevationsOnSurfaceNeutralLv1Grey, + ), + maximumSize: const WidgetStatePropertyAll(Size.fromHeight(350)), ), - maximumSize: const WidgetStatePropertyAll(Size.fromHeight(350)), ), ), ); diff --git a/catalyst_voices/apps/voices/lib/widgets/menu/voices_node_menu.dart b/catalyst_voices/apps/voices/lib/widgets/menu/voices_node_menu.dart index 573901a42297..ef1930e4ab80 100644 --- a/catalyst_voices/apps/voices/lib/widgets/menu/voices_node_menu.dart +++ b/catalyst_voices/apps/voices/lib/widgets/menu/voices_node_menu.dart @@ -1,3 +1,4 @@ +import 'package:catalyst_voices/widgets/common/semantics/combine_semantics.dart'; import 'package:catalyst_voices/widgets/widgets.dart'; import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; @@ -32,32 +33,40 @@ class VoicesNodeMenu extends StatelessWidget { @override Widget build(BuildContext context) { - return SimpleTreeView( - isExpanded: isExpanded, - root: SimpleTreeViewRootRow( - onTap: isExpandable ? onHeaderTap : null, - leading: [ - VoicesNodeMenuIcon(isOpen: isExpanded), - icon ?? VoicesAssets.icons.viewGrid.buildIcon(), - ], - child: name, + return Semantics( + identifier: 'VoicesNodeMenu', + container: true, + child: SimpleTreeView( + isExpanded: isExpanded, + root: SimpleTreeViewRootRow( + onTap: isExpandable ? onHeaderTap : null, + leading: [ + ExcludeSemantics(child: VoicesNodeMenuIcon(isOpen: isExpanded)), + ExcludeSemantics(child: icon ?? VoicesAssets.icons.viewGrid.buildIcon()), + ], + child: name, + ), + children: items.mapIndexed( + (index, item) { + return CombineSemantics( + identifier: 'VoicesNodeMenuItem[${item.id}]', + container: true, + child: SimpleTreeViewChildRow( + key: ValueKey('NodeMenu${item.id}RowKey'), + hasNext: index < items.length - 1, + isSelected: item.id == selectedItemId, + hasError: item.hasError, + onTap: item.isEnabled ? () => onItemTap(item.id) : null, + child: Text( + item.label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ); + }, + ).toList(), ), - children: items.mapIndexed( - (index, item) { - return SimpleTreeViewChildRow( - key: ValueKey('NodeMenu${item.id}RowKey'), - hasNext: index < items.length - 1, - isSelected: item.id == selectedItemId, - hasError: item.hasError, - onTap: item.isEnabled ? () => onItemTap(item.id) : null, - child: Text( - item.label, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ); - }, - ).toList(), ); } } diff --git a/catalyst_voices/apps/voices/lib/widgets/modals/voices_question_dialog.dart b/catalyst_voices/apps/voices/lib/widgets/modals/voices_question_dialog.dart index 27b0e5288e8b..d4ba79b139b3 100644 --- a/catalyst_voices/apps/voices/lib/widgets/modals/voices_question_dialog.dart +++ b/catalyst_voices/apps/voices/lib/widgets/modals/voices_question_dialog.dart @@ -85,13 +85,13 @@ class VoicesQuestionDialog extends StatelessWidget { return VoicesFilledButton( key: const Key('ButtonFilled'), onTap: () => Navigator.of(context).pop(item.isPositive), - child: Text(item.name), + child: Text(item.name, semanticsIdentifier: 'ButtonFilled'), ); case VoicesQuestionActionType.text: return VoicesTextButton( key: const Key('ButtonText'), onTap: () => Navigator.of(context).pop(item.isPositive), - child: Text(item.name), + child: Text(item.name, semanticsIdentifier: 'ButtonText'), ); } } diff --git a/catalyst_voices/apps/voices/lib/widgets/rich_text/markdown_text.dart b/catalyst_voices/apps/voices/lib/widgets/rich_text/markdown_text.dart index cd0045899051..5c9a780228e1 100644 --- a/catalyst_voices/apps/voices/lib/widgets/rich_text/markdown_text.dart +++ b/catalyst_voices/apps/voices/lib/widgets/rich_text/markdown_text.dart @@ -21,17 +21,27 @@ class MarkdownText extends StatelessWidget with LaunchUrlMixin { Widget build(BuildContext context) { final pColor = this.pColor; - return MarkdownBody( - data: markdownData.data, - selectable: selectable, - styleSheet: MarkdownStyleSheet( - p: pStyle?.copyWith(color: pColor) ?? (pColor != null ? TextStyle(color: pColor) : null), + return Semantics( + identifier: 'MarkdownText', + container: true, + // TODO(dt-iohk): clean markdown to select clear-text without formatting characters + label: markdownData.data, + child: ExcludeSemantics( + child: MarkdownBody( + data: markdownData.data, + selectable: selectable, + styleSheet: MarkdownStyleSheet( + p: + pStyle?.copyWith(color: pColor) ?? + (pColor != null ? TextStyle(color: pColor) : null), + ), + onTapLink: (text, href, title) async { + if (href != null) { + await launchUri(href.getUri()); + } + }, + ), ), - onTapLink: (text, href, title) async { - if (href != null) { - await launchUri(href.getUri()); - } - }, ); } } diff --git a/catalyst_voices/apps/voices/lib/widgets/rich_text/voices_rich_text.dart b/catalyst_voices/apps/voices/lib/widgets/rich_text/voices_rich_text.dart index a9557b18cb6c..cc156324233c 100644 --- a/catalyst_voices/apps/voices/lib/widgets/rich_text/voices_rich_text.dart +++ b/catalyst_voices/apps/voices/lib/widgets/rich_text/voices_rich_text.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:catalyst_voices/common/codecs/markdown_codec.dart'; +import 'package:catalyst_voices/widgets/common/semantics/combine_semantics.dart'; import 'package:catalyst_voices/widgets/form/voices_form_field.dart'; import 'package:catalyst_voices/widgets/rich_text/insert_image_error.dart'; import 'package:catalyst_voices/widgets/rich_text/insert_new_image_dialog.dart'; @@ -261,38 +262,42 @@ class _EditorState extends State<_Editor> { Widget build(BuildContext context) { final theme = Theme.of(context); final textTheme = theme.textTheme; - return KeyboardListener( - focusNode: _keyboardListenerFocus, - onKeyEvent: _onKeyEvent, - child: quill.QuillEditor( - controller: widget.controller, - focusNode: widget.focusNode, - scrollController: widget.scrollController, - config: quill.QuillEditorConfig( - minHeight: widget.minHeight, - padding: const EdgeInsets.all(16), - placeholder: widget.placeholder ?? context.l10n.placeholderRichText, - characterShortcutEvents: quill.standardCharactersShortcutEvents, - /* cSpell:disable */ - spaceShortcutEvents: quill.standardSpaceShorcutEvents, - /* cSpell:enable */ - customStyles: quill.DefaultStyles( - placeHolder: quill.DefaultTextBlockStyle( - textTheme.bodyLarge?.copyWith(color: theme.colors.textOnPrimaryLevel1) ?? - DefaultTextStyle.of(context).style, - quill.HorizontalSpacing.zero, - quill.VerticalSpacing.zero, - quill.VerticalSpacing.zero, - null, + return CombineSemantics( + identifier: 'VoicesRichTextEditor', + container: true, + child: KeyboardListener( + focusNode: _keyboardListenerFocus, + onKeyEvent: _onKeyEvent, + child: quill.QuillEditor( + controller: widget.controller, + focusNode: widget.focusNode, + scrollController: widget.scrollController, + config: quill.QuillEditorConfig( + minHeight: widget.minHeight, + padding: const EdgeInsets.all(16), + placeholder: widget.placeholder ?? context.l10n.placeholderRichText, + characterShortcutEvents: quill.standardCharactersShortcutEvents, + /* cSpell:disable */ + spaceShortcutEvents: quill.standardSpaceShorcutEvents, + /* cSpell:enable */ + customStyles: quill.DefaultStyles( + placeHolder: quill.DefaultTextBlockStyle( + textTheme.bodyLarge?.copyWith(color: theme.colors.textOnPrimaryLevel1) ?? + DefaultTextStyle.of(context).style, + quill.HorizontalSpacing.zero, + quill.VerticalSpacing.zero, + quill.VerticalSpacing.zero, + null, + ), ), + embedBuilders: CatalystPlatform.isWeb + ? quill_ext.FlutterQuillEmbeds.editorWebBuilders( + imageEmbedConfig: const QuillEditorImageEmbedConfig( + errorWidget: InsertImageError(), + ), + ) + : quill_ext.FlutterQuillEmbeds.editorBuilders(), ), - embedBuilders: CatalystPlatform.isWeb - ? quill_ext.FlutterQuillEmbeds.editorWebBuilders( - imageEmbedConfig: const QuillEditorImageEmbedConfig( - errorWidget: InsertImageError(), - ), - ) - : quill_ext.FlutterQuillEmbeds.editorBuilders(), ), ), ); @@ -362,44 +367,54 @@ class _Toolbar extends StatelessWidget { @override Widget build(BuildContext context) { - return DecoratedBox( - decoration: BoxDecoration( - color: Theme.of(context).colors.elevationsOnSurfaceNeutralLv2, - borderRadius: const BorderRadius.vertical(top: Radius.circular(8)), - ), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Wrap( - children: [ - _ToolbarAttributeIconButton( - controller: controller, - icon: VoicesAssets.icons.rtHeading, - attribute: quill.Attribute.h1, - ), - _ToolbarAttributeIconButton( - controller: controller, - icon: VoicesAssets.icons.rtBold, - attribute: quill.Attribute.bold, - ), - _ToolbarAttributeIconButton( - controller: controller, - icon: VoicesAssets.icons.rtItalic, - attribute: quill.Attribute.italic, - ), - _ToolbarAttributeIconButton( - controller: controller, - icon: VoicesAssets.icons.rtOrderedList, - attribute: quill.Attribute.ol, - ), - _ToolbarAttributeIconButton( - controller: controller, - icon: VoicesAssets.icons.rtUnorderedList, - attribute: quill.Attribute.ul, - ), - _ToolbarImageOptionButton( - controller: controller, - ), - ], + return Semantics( + identifier: 'VoicesRichTextToolbar', + container: true, + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colors.elevationsOnSurfaceNeutralLv2, + borderRadius: const BorderRadius.vertical(top: Radius.circular(8)), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Wrap( + children: [ + _ToolbarAttributeIconButton( + controller: controller, + icon: VoicesAssets.icons.rtHeading, + attribute: quill.Attribute.h1, + semanticsIdentifier: 'VoicesRichTextToolbarH1', + ), + _ToolbarAttributeIconButton( + controller: controller, + icon: VoicesAssets.icons.rtBold, + attribute: quill.Attribute.bold, + semanticsIdentifier: 'VoicesRichTextToolbarBold', + ), + _ToolbarAttributeIconButton( + controller: controller, + icon: VoicesAssets.icons.rtItalic, + attribute: quill.Attribute.italic, + semanticsIdentifier: 'VoicesRichTextToolbarItalic', + ), + _ToolbarAttributeIconButton( + controller: controller, + icon: VoicesAssets.icons.rtOrderedList, + attribute: quill.Attribute.ol, + semanticsIdentifier: 'VoicesRichTextToolbarOL', + ), + _ToolbarAttributeIconButton( + controller: controller, + icon: VoicesAssets.icons.rtUnorderedList, + attribute: quill.Attribute.ul, + semanticsIdentifier: 'VoicesRichTextToolbarUL', + ), + _ToolbarImageOptionButton( + controller: controller, + semanticsIdentifier: 'VoicesRichTextToolbarImage', + ), + ], + ), ), ), ); @@ -410,30 +425,36 @@ class _ToolbarAttributeIconButton extends StatelessWidget { final quill.QuillController controller; final quill.Attribute attribute; final SvgGenImage icon; + final String semanticsIdentifier; const _ToolbarAttributeIconButton({ required this.controller, required this.attribute, required this.icon, + required this.semanticsIdentifier, }); @override Widget build(BuildContext context) { - return quill.QuillToolbarToggleStyleButton( - controller: controller, - attribute: attribute, - options: quill.QuillToolbarToggleStyleButtonOptions( - // TODO(minikin): We need to use dynamic here because - // of the bug in the quill package. - // https://github.com/singerdmx/flutter-quill/issues/2511 - childBuilder: (dynamic options, dynamic extraOptions) { - return _ToolbarIconButton( - icon: icon, - tooltip: options.tooltip as String?, - isToggled: extraOptions.isToggled as bool, - onPressed: extraOptions.onPressed as VoidCallback?, - ); - }, + return CombineSemantics( + identifier: semanticsIdentifier, + container: true, + child: quill.QuillToolbarToggleStyleButton( + controller: controller, + attribute: attribute, + options: quill.QuillToolbarToggleStyleButtonOptions( + // TODO(minikin): We need to use dynamic here because + // of the bug in the quill package. + // https://github.com/singerdmx/flutter-quill/issues/2511 + childBuilder: (dynamic options, dynamic extraOptions) { + return _ToolbarIconButton( + icon: icon, + tooltip: options.tooltip as String?, + isToggled: extraOptions.isToggled as bool, + onPressed: extraOptions.onPressed as VoidCallback?, + ); + }, + ), ), ); } @@ -465,27 +486,35 @@ class _ToolbarIconButton extends StatelessWidget { class _ToolbarImageOptionButton extends StatelessWidget { final quill.QuillController controller; + final String semanticsIdentifier; - const _ToolbarImageOptionButton({required this.controller}); + const _ToolbarImageOptionButton({ + required this.controller, + required this.semanticsIdentifier, + }); @override Widget build(BuildContext context) { - return quill_ext.QuillToolbarImageButton( - controller: controller, - options: quill_ext.QuillToolbarImageButtonOptions( - // TODO(minikin): We need to use dynamic here because - // of the bug in the quill package. - // https://github.com/singerdmx/flutter-quill/issues/2511 - childBuilder: (dynamic options, dynamic extraOptions) { - return _ToolbarIconButton( - icon: VoicesAssets.icons.photograph, - tooltip: options.tooltip as String?, - isToggled: false, - onPressed: extraOptions.onPressed as VoidCallback?, - ); - }, - imageButtonConfig: quill_ext.QuillToolbarImageConfig( - insertImageUrlDialogBuilder: (context) => const InsertNewImageDialog(), + return CombineSemantics( + identifier: semanticsIdentifier, + container: true, + child: quill_ext.QuillToolbarImageButton( + controller: controller, + options: quill_ext.QuillToolbarImageButtonOptions( + // TODO(minikin): We need to use dynamic here because + // of the bug in the quill package. + // https://github.com/singerdmx/flutter-quill/issues/2511 + childBuilder: (dynamic options, dynamic extraOptions) { + return _ToolbarIconButton( + icon: VoicesAssets.icons.photograph, + tooltip: options.tooltip as String?, + isToggled: false, + onPressed: extraOptions.onPressed as VoidCallback?, + ); + }, + imageButtonConfig: quill_ext.QuillToolbarImageConfig( + insertImageUrlDialogBuilder: (context) => const InsertNewImageDialog(), + ), ), ), ); diff --git a/catalyst_voices/apps/voices/lib/widgets/rich_text/voices_rich_text_limit.dart b/catalyst_voices/apps/voices/lib/widgets/rich_text/voices_rich_text_limit.dart index c3029c6a3b1c..796736e637e2 100644 --- a/catalyst_voices/apps/voices/lib/widgets/rich_text/voices_rich_text_limit.dart +++ b/catalyst_voices/apps/voices/lib/widgets/rich_text/voices_rich_text_limit.dart @@ -1,3 +1,4 @@ +import 'package:catalyst_voices/widgets/common/semantics/combine_semantics.dart'; import 'package:catalyst_voices/widgets/rich_text/voices_rich_text.dart'; import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; import 'package:flutter/material.dart'; @@ -30,23 +31,31 @@ class VoicesRichTextLimit extends StatelessWidget { child: Row( children: [ Expanded( - child: Text( - error, - style: theme.textTheme.bodySmall!.copyWith( - color: enabled ? theme.colorScheme.error : theme.colors.textDisabled, + child: CombineSemantics( + identifier: 'VoicesRichTextError', + container: true, + child: Text( + error, + style: theme.textTheme.bodySmall!.copyWith( + color: enabled ? theme.colorScheme.error : theme.colors.textDisabled, + ), ), ), ), - StreamBuilder( - initialData: controller.markdownData.data.length, - stream: controller.changes.map((e) => controller.markdownData.data.length).distinct(), - builder: (context, snapshot) { - final data = snapshot.data; - return Text( - _formatText(data ?? 0), - style: theme.textTheme.bodySmall, - ); - }, + CombineSemantics( + identifier: 'VoicesRichTextLimit', + container: true, + child: StreamBuilder( + initialData: controller.markdownData.data.length, + stream: controller.changes.map((e) => controller.markdownData.data.length).distinct(), + builder: (context, snapshot) { + final data = snapshot.data; + return Text( + _formatText(data ?? 0), + style: theme.textTheme.bodySmall, + ); + }, + ), ), ], ), diff --git a/catalyst_voices/apps/voices/lib/widgets/segments/segment_header_tile.dart b/catalyst_voices/apps/voices/lib/widgets/segments/segment_header_tile.dart index b4b147b530e7..b5547bec853c 100644 --- a/catalyst_voices/apps/voices/lib/widgets/segments/segment_header_tile.dart +++ b/catalyst_voices/apps/voices/lib/widgets/segments/segment_header_tile.dart @@ -1,3 +1,4 @@ +import 'package:catalyst_voices/widgets/common/semantics/combine_semantics.dart'; import 'package:catalyst_voices/widgets/widgets.dart'; import 'package:catalyst_voices_models/catalyst_voices_models.dart'; import 'package:flutter/material.dart'; @@ -22,20 +23,24 @@ class SegmentHeaderTile extends StatelessWidget { final isOpened = value.openedSegments.contains(id); final hasSelectedStep = value.activeSectionId?.isChildOf(id) ?? false; - return SegmentHeader( - leading: ChevronExpandButton( - onTap: () => controller.toggleSegment(id), - isExpanded: isOpened, - ), - name: name, - isSelected: isOpened && hasSelectedStep, - onTap: () { - controller.toggleSegment(id); + return CombineSemantics( + identifier: 'SegmentHeaderTile[$id]', + container: true, + child: SegmentHeader( + leading: ChevronExpandButton( + onTap: () => controller.toggleSegment(id), + isExpanded: isOpened, + ), + name: name, + isSelected: isOpened && hasSelectedStep, + onTap: () { + controller.toggleSegment(id); - if (controller.value.openedSegments.contains(id)) { - controller.focusSection(id); - } - }, + if (controller.value.openedSegments.contains(id)) { + controller.focusSection(id); + } + }, + ), ); }, ); diff --git a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_password_text_field.dart b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_password_text_field.dart index 320da5552920..47190f5dfa32 100644 --- a/catalyst_voices/apps/voices/lib/widgets/text_field/voices_password_text_field.dart +++ b/catalyst_voices/apps/voices/lib/widgets/text_field/voices_password_text_field.dart @@ -22,6 +22,8 @@ final class VoicesPasswordTextField extends StatelessWidget { /// [VoicesTextField.autofocus]. final bool autofocus; + final String? semanticsIdentifier; + const VoicesPasswordTextField({ super.key, this.controller, @@ -30,6 +32,7 @@ final class VoicesPasswordTextField extends StatelessWidget { this.onSubmitted, this.decoration, this.autofocus = false, + this.semanticsIdentifier, }); @override diff --git a/catalyst_voices/apps/voices/lib/widgets/tiles/editable_tile.dart b/catalyst_voices/apps/voices/lib/widgets/tiles/editable_tile.dart index 1f85f9422ded..f122bc30b3a0 100644 --- a/catalyst_voices/apps/voices/lib/widgets/tiles/editable_tile.dart +++ b/catalyst_voices/apps/voices/lib/widgets/tiles/editable_tile.dart @@ -1,3 +1,4 @@ +import 'package:catalyst_voices/widgets/common/semantics/combine_semantics.dart'; import 'package:catalyst_voices/widgets/widgets.dart'; import 'package:catalyst_voices_assets/catalyst_voices_assets.dart'; import 'package:catalyst_voices_brands/catalyst_voices_brands.dart'; @@ -50,12 +51,15 @@ class EditableTile extends StatelessWidget { overrideAction ?? Offstage( offstage: !isEditEnabled, - child: VoicesEditCancelButton( - key: const Key('EditableTileEditCancelButton'), - style: editCancelButtonStyle, - onTap: _toggleEditMode, - isEditing: isEditMode, - hasError: errorText != null, + child: CombineSemantics( + identifier: 'EditableTileEditCancelButton', + child: VoicesEditCancelButton( + key: const Key('EditableTileEditCancelButton'), + style: editCancelButtonStyle, + onTap: _toggleEditMode, + isEditing: isEditMode, + hasError: errorText != null, + ), ), ), footer: showFooter @@ -152,14 +156,17 @@ class _Footer extends StatelessWidget { if (errorText != null) Expanded(child: _ErrorText(text: errorText)) else const Spacer(), Visibility.maintain( visible: showSaveButton, - child: VoicesFilledButton( - key: const Key('EmailTileSaveButton'), - onTap: isSaveEnabled ? onSave : null, - leading: saveButtonLeading, - child: Text( - saveText ?? context.l10n.saveButtonText.toUpperCase(), - style: Theme.of(context).textTheme.labelMedium!.copyWith( - color: Theme.of(context).colorScheme.onPrimary, + child: CombineSemantics( + identifier: 'EditableTileSaveButton', + child: VoicesFilledButton( + key: const Key('EditableTileSaveButton'), + onTap: isSaveEnabled ? onSave : null, + leading: saveButtonLeading, + child: Text( + saveText ?? context.l10n.saveButtonText.toUpperCase(), + style: Theme.of(context).textTheme.labelMedium!.copyWith( + color: Theme.of(context).colorScheme.onPrimary, + ), ), ), ), diff --git a/catalyst_voices/apps/voices/lib/widgets/tiles/property_tile.dart b/catalyst_voices/apps/voices/lib/widgets/tiles/property_tile.dart index aa9729913382..4c13b8a41ea4 100644 --- a/catalyst_voices/apps/voices/lib/widgets/tiles/property_tile.dart +++ b/catalyst_voices/apps/voices/lib/widgets/tiles/property_tile.dart @@ -1,3 +1,4 @@ +import 'package:catalyst_voices/widgets/common/semantics/combine_semantics.dart'; import 'package:catalyst_voices/widgets/widgets.dart'; import 'package:flutter/material.dart'; @@ -67,9 +68,13 @@ class _Header extends StatelessWidget { return Row( children: [ Expanded( - child: Text( - title, - style: Theme.of(context).textTheme.titleMedium, + child: CombineSemantics( + identifier: 'PropertyTileHeaderTitle', + container: true, + child: Text( + title, + style: Theme.of(context).textTheme.titleMedium, + ), ), ), if (action != null) ...[ diff --git a/catalyst_voices/apps/voices/lib/widgets/toggles/voices_checkbox.dart b/catalyst_voices/apps/voices/lib/widgets/toggles/voices_checkbox.dart index d75b83512a05..428e46dd26a3 100644 --- a/catalyst_voices/apps/voices/lib/widgets/toggles/voices_checkbox.dart +++ b/catalyst_voices/apps/voices/lib/widgets/toggles/voices_checkbox.dart @@ -31,6 +31,13 @@ class VoicesCheckbox extends StatelessWidget { /// Whether the widget can accept the user input. final bool isEnabled; + /// Optional semantics identifier for Playwright testing. + /// + /// When provided, the checkbox will use '${semanticsIdentifier}_checkbox' + /// and the label will use '${semanticsIdentifier}_label' as semantics identifiers. + /// When null, defaults to 'VoicesCheckbox_checkbox' and 'VoicesCheckbox_label'. + final String semanticsIdentifier; + const VoicesCheckbox({ super.key, required this.value, @@ -39,6 +46,7 @@ class VoicesCheckbox extends StatelessWidget { this.label, this.note, this.isEnabled = true, + this.semanticsIdentifier = 'VoicesCheckbox', }); @override @@ -54,18 +62,21 @@ class VoicesCheckbox extends StatelessWidget { label: label, note: note, spacings: const [12, 8], - child: Checkbox( - key: const Key('Checkbox'), - value: value, - // forcing null unwrapping because we're not allowing null value - onChanged: onChanged != null ? (value) => onChanged(value!) : null, - isError: isError, - side: isEnabled - ? null - : BorderSide( - width: 2, - color: Theme.of(context).colors.onSurfaceNeutral012, - ), + child: Semantics( + identifier: '${semanticsIdentifier}_checkbox', + child: Checkbox( + key: const Key('Checkbox'), + value: value, + // forcing null unwrapping because we're not allowing null value + onChanged: onChanged != null ? (value) => onChanged(value!) : null, + isError: isError, + side: isEnabled + ? null + : BorderSide( + width: 2, + color: Theme.of(context).colors.onSurfaceNeutral012, + ), + ), ), ), ), diff --git a/catalyst_voices/apps/voices/lib/widgets/toggles/voices_radio_button_form_field.dart b/catalyst_voices/apps/voices/lib/widgets/toggles/voices_radio_button_form_field.dart index 3b544f410557..24cd787b564a 100644 --- a/catalyst_voices/apps/voices/lib/widgets/toggles/voices_radio_button_form_field.dart +++ b/catalyst_voices/apps/voices/lib/widgets/toggles/voices_radio_button_form_field.dart @@ -6,16 +6,14 @@ import 'package:catalyst_voices_shared/catalyst_voices_shared.dart'; import 'package:flutter/material.dart'; class VoicesRadioButtonFormField extends VoicesFormField { - final List items; - VoicesRadioButtonFormField({ super.key, - required this.items, required super.value, required super.onChanged, super.enabled, super.validator, super.autovalidateMode = AutovalidateMode.onUserInteraction, + required List items, }) : super( builder: (field) { void onChangedHandler(String? groupValue) { diff --git a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/proposal_builder_bloc.dart b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/proposal_builder_bloc.dart index adec97cfb48c..910ea6bafc7c 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/proposal_builder_bloc.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_blocs/lib/src/proposal_builder/proposal_builder_bloc.dart @@ -326,6 +326,7 @@ final class ProposalBuilderBloc extends Bloc