diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml new file mode 100644 index 0000000..4cad826 --- /dev/null +++ b/.github/workflows/deploy-site.yml @@ -0,0 +1,52 @@ +name: Deploy Microsite + +on: + push: + branches: ["main"] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + env: + BASE_HREF: /${{ github.event.repository.name }}/ + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./_site + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0e4835 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +_site/ \ No newline at end of file diff --git a/README.md b/README.md index 56fab93..14f8c0f 100644 --- a/README.md +++ b/README.md @@ -8,5 +8,63 @@ npx skills add rodydavis/skills | Skill | Description | |---|---| +| [how-to-run-astro-ssr-and-pocketbase-on-the-same-server](./skills/astro-ssr-pocketbase-single-server/SKILL.md) | Learn how to host PocketBase and an Astro SSR application on the same server, using PocketBase's Go integration and a reverse proxy to delegate requests to Astro for dynamic web content. | +| [async-preact-signals](./skills/async-preact-signal/SKILL.md) | Explore how to effectively manage asynchronous data with Preact Signals by creating a custom `asyncSignal` that handles loading, error, and data states without breaking the synchronous nature of signals. | +| [automate-flutter-app-releases](./skills/automate-flutter-apps/SKILL.md) | Automate your Flutter app releases to beta or production with this handy shell script that handles version bumping, formatting, cleaning, rebuilding, and deployment via Fastlane. | +| [dynamic-themes-with-codemirror](./skills/codemirror-dynamic-theme/SKILL.md) | Learn how to create a Lit web component with CodeMirror, dynamically themed using Material Design's color utilities, for a customizable code editing experience. | +| [how-to-create-html-web-components-with-dart](./skills/dart-html-web-components/SKILL.md) | Discover the power of Web Components and how to build them with both JavaScript and Dart for reusable, framework-agnostic UI elements. | +| [how-to-do-bitwise-operations-in-dart](./skills/dart_bitwise/SKILL.md) | Explore Dart's bitwise operations for both integers and booleans, including AND, OR (inclusive & exclusive), NAND, NOR, and XNOR, with practical code examples. | +| [various-ways-to-invoke-functions-in-dart](./skills/dart_function-invoking/SKILL.md) | Discover the surprising flexibility of calling Dart functions, including mixed positional and named arguments, the `.call` operator, and dynamic invocation with `Function.apply`. | +| [how-to-print-multiple-objects-to-the-console-with-print-in-dart](./skills/dart_print-multiple-objects/SKILL.md) | Learn how to print multiple objects to the console in Dart using Records, offering a similar experience to JavaScript's `console.log()` functionality. | +| [check-if-an-object-is-truthy-in-dart](./skills/dart_truthy/SKILL.md) | Learn how to extend Dart's functionality to implement JavaScript-style "truthy" checks for easier conditional logic and value evaluations. | +| [deep-linking-for-flutter-web](./skills/deep-linking-flutter-web/SKILL.md) | Learn how to implement proper URL navigation in your Flutter application, including deep linking to specific pages, handling protected routes, and creating custom transitions for a seamless user experience. | +| [displaying-html-in-flutter](./skills/display-html-in-flutter/SKILL.md) | Easily display and interact with HTML content in your Flutter app using the `easy_web_view` package, which supports both web and mobile platforms. | +| [how-to-export-sqlite-tables-to-create-statements](./skills/export-sqlite-dart/SKILL.md) | Learn how to export your entire SQLite database schema, including tables and indexes, into runnable CREATE statements at runtime using Flutter and the `sqlite3` package. | +| [using-fastlane-in-flutter-and-ci](./skills/fastlane-and-flutter/SKILL.md) | Automate Flutter app builds and deployments to both the App Store and Google Play using Fastlane with this step-by-step guide. | +| [lit-and-figma](./skills/figma-and-lit/SKILL.md) | Learn how to create a Figma plugin using Lit web components, including project setup, component creation, WASM integration, and building the final plugin for use in Figma. | +| [creating-your-first-flutter-project](./skills/first-flutter-project/SKILL.md) | Dive into the world of Flutter, Google's UI toolkit, and learn how to build cross-platform apps with ease using this introductory guide and accompanying "Flutter Take 5" video series. | +| [lit-and-flutter](./skills/flutter-and-lit/SKILL.md) | Learn how to embed a Lit web component directly within your Flutter app to leverage web-based UIs and features while accessing native device APIs for a powerful hybrid development approach. | +| [how-to-build-a-flutter-app-on-xcode-cloud](./skills/flutter-and-xcode-cloud/SKILL.md) | Learn how to set up Xcode Cloud to build and deploy your Flutter application to TestFlight and the App Store with this step-by-step guide. | +| [flutter-terminal-cheat-sheet](./skills/flutter-cheat-sheet/SKILL.md) | This post provides a handy collection of Flutter commands and scripts for web development, package creation, troubleshooting, testing, and more, streamlining your Flutter workflow. | +| [how-to-build-a-graph-database-with-flutter](./skills/flutter-graph-database/SKILL.md) | Learn how to build and utilize a graph database within your Flutter applications using SQLite and the Drift package to model relationships between data. | +| [multi-touch-canvas-with-flutter](./skills/flutter-multi-touch-canvas/SKILL.md) | Learn how to create a Flutter canvas with multi-touch support for panning, zooming, and object interaction, overcoming common gesture recognition conflicts. | +| [flutter-fastlane-one-click-beta](./skills/flutter-one-click-release/SKILL.md) | Deploy your Flutter app to the App Store and Google Play with ease using this step-by-step guide covering installation, project setup, Fastlane integration, and automated deployments with Automator. | +| [server-side-rendering-flutter-apps-with-rfw](./skills/flutter-ssr-rfw/SKILL.md) | Learn how to build a dynamic Flutter app using Server Side Rendering (SSR) with the rfw package, enabling UI updates driven by server logic and binary data exchange via HTTP. | | [flutter-control-and-screenshot](./skills/flutter_driver_control/SKILL.md) | Guide on how to control a Flutter app using flutter_driver via MCP and capture screenshots. | +| [how-to-manage-multiple-flutter-versions-with-git-worktrees-and-z](./skills/flutter_git-worktree-channels/SKILL.md) | Manage multiple Flutter versions efficiently using Git worktrees, eliminating the need for external version managers like FVM. | +| [host-your-flutter-project-as-a-rest-api](./skills/host-flutter-rest-api/SKILL.md) | Learn how to structure a Flutter project to reuse models and business logic across iOS, Android, Web, desktop platforms, and a REST API deployable to Google Cloud Run, enabling a single codebase for both client and server. | +| [building-a-html-element-sandbox-with-lit](./skills/html-code-sandbox/SKILL.md) | Learn how to build a Lit web component to create a dynamic HTML element sandbox with live updates, perfect for experimenting with and showcasing web components. | | [install-flutter-from-git](./skills/install-flutter-from-git/SKILL.md) | Install Flutter SDK via git clone and configure for all platforms | +| [draggable-dom-with-lit](./skills/lit-draggable-dom/SKILL.md) | Learn how to create an interactive, draggable DOM using a Lit web component with CSS transforms and slots, enabling you to manipulate HTML and SVG elements within a canvas-like environment. | +| [2d-or-3d-force-graph-with-lit](./skills/lit-force-graph/SKILL.md) | Learn how to create interactive 2D and 3D force graphs using Lit, a lightweight web component library, with this step-by-step tutorial. | +| [json-to-html-table-with-lit](./skills/lit-html-table/SKILL.md) | Learn how to create a dynamic HTML table from JSON data using a Lit web component, with examples for fetching data from a URL or using inline JSON, and the ability to make the table editable. | +| [lit-and-monaco-editor](./skills/lit-monaco-editor/SKILL.md) | Learn how to create a Lit web component that wraps the Monaco Editor (powering VSCode) to add a fully functional code editor to your web applications. | +| [building-a-rich-text-editor-with-lit](./skills/lit-rich-text-editor/SKILL.md) | Learn how to build a rich text editor using a Lit web component, complete with a toolbar for formatting text, links, and styles. | +| [lit-sheet-music](./skills/lit-sheet-music/SKILL.md) | Learn how to create a Lit web component that renders MusicXML using OpenSheetMusicDisplay, allowing you to display sheet music dynamically from a source attribute or inline XML. | +| [lit-and-vscode-extensions](./skills/lit-vscode-extension/SKILL.md) | Learn how to build a VSCode extension using a Lit web component, covering setup, template creation, component implementation, and extension activation. | +| [building-a-piano-with-flutter](./skills/making-a-piano/SKILL.md) | Build a Tiny Piano in Flutter: Learn how to create a fully functional, Flutter Create contest-winning piano app using just 5032 bytes of Dart code, complete with MIDI support and customizable features. | +| [migrating-my-blog-to-pocketbase](./skills/migrating-my-blog-to-pocketbase/SKILL.md) | This blog post chronicles a developer's fun and ongoing journey of migrating their personal website through various tech stacks, ultimately landing on a PocketBase and Coolify setup for simplified deployment and dynamic features. | +| [how-to-build-a-native-cross-platform-project-with-flutter](./skills/native-cross-platform-flutter/SKILL.md) | Learn how to import `dart:html` and `dart:io` in the same Flutter project to create cross-platform plugins that work seamlessly on mobile and web. | +| [how-to-do-offline-recommendations-with-sqlite-and-gemini](./skills/offline-vector-recommendations/SKILL.md) | Learn how to enhance your CMS like PocketBase with AI-powered content recommendations using text embeddings, SQLite, and k-nearest neighbor search for efficient and scalable related content suggestions. | +| [calling-the-palm-2-api-with-dart-and-flutter](./skills/palm-2-api-dart/SKILL.md) | Learn how to integrate the PaLM 2 API into your Flutter apps using Dart, including setting up API keys, creating prompt templates, and securely making API calls. | +| [how-to-deploy-pocketbase-to-cloud-run](./skills/pocketbase-cloudrun/SKILL.md) | Learn how to deploy PocketBase on Google Cloud Run using the new volume mounting feature, enabling scale-to-zero, infinite storage, and easy backups. | +| [how-to-build-a-webrtc-signal-server-with-pocketbase](./skills/pocketbase-webrtc-signal-server-js/SKILL.md) | Learn how to build a simple WebRTC video call application using PocketBase as a signaling server, enabling peer-to-peer communication with SQLite on the server and realtime updates via Server Sent Events. | +| [how-to-host-your-podcast-for-free-on-github-pages](./skills/podcast-github-pages/SKILL.md) | Launch your podcast for free by leveraging GitHub Pages, GitHub Actions, and a customizable podcast player template (fork this repo!) to share your stories and save on hosting costs. | +| [how-to-send-push-notifications-on-flutter-web-fcm](./skills/push-notifications-flutter-web/SKILL.md) | Learn how to implement Firebase Cloud Messaging (FCM) in your Flutter web app with this guide, covering service worker setup, helper methods, and testing to enable push notifications. | +| [the-perfect-brisket](./skills/recipes_the-perfect-brisket/SKILL.md) | Follow my evolving journey to the perfect smoked brisket with this recipe, meticulously updated after each low-and-slow, all-out cook. | +| [signals-and-flutter-hooks](./skills/signals-and-flutter-hooks/SKILL.md) | Explore state management in Flutter, from the basics of `setState` to advanced techniques using ValueNotifier, Signals, Flutter Hooks, and the new signals_hooks package for a reactive and efficient approach. | +| [flutter-infinite-canvas](./skills/snippets_flutter-infinite-canvas/SKILL.md) | Learn how to build an infinite, multi-touch canvas in Flutter using InteractiveViewer and CustomMultiChildLayout for a flexible and interactive user experience. | +| [flutter-input-output-preview](./skills/snippets_flutter-input-output-preview/SKILL.md) | Build responsive Flutter apps with a reusable `TwoPane` widget and an `InputOutputPreview` component for side-by-side code and preview display on both mobile and desktop. | +| [flutter-markdown-view-with-material-3](./skills/snippets_flutter-markdown-view-material-3/SKILL.md) | Learn how to customize the Flutter Markdown widget using Material 3 text and color styles for a visually appealing and consistent design. | +| [flutter-master-detail-view](./skills/snippets_flutter-master-detail-view/SKILL.md) | Learn how to implement a responsive Master-Detail interface in Flutter that adapts to different screen sizes, leveraging multi-column layouts on larger screens and pushing to detail screens on mobile. | +| [flutter-native-http-client](./skills/snippets_flutter-native-http-client/SKILL.md) | This blog post explores how to optimize HTTP client selection in Flutter applications based on the platform, using Cronet on Android and Cupertino's native client on iOS for improved performance and caching. | +| [flutter-stream-widget](./skills/snippets_flutter-stream-widget/SKILL.md) | Learn how to build dynamic Flutter UIs by directly using streams within your widget's build method, enabling reactive screen updates and more efficient data handling. | +| [lightweight-flutter-animations](./skills/snippets_lightweight-flutter-animations/SKILL.md) | Learn how to create a streamlined animation widget in Flutter that eliminates the need for `setState` by leveraging an abstract class and `SingleTickerProviderStateMixin` for efficient UI updates. | +| [material-3-to-material-2-theme-adapter](./skills/snippets_m3-to-m2-css-adapter/SKILL.md) | Learn how to seamlessly integrate Material Design 3's styling into your Material Design 2 components using CSS variable overrides. | +| [color-utilities-in-javascript](./skills/snippets_typescript-color-utilities/SKILL.md) | Explore helpful color utility functions, like RGB to HSL, HEX to RGB, and HSL to HEX, generated with the assistance of GitHub Copilot. | +| [ios-or-macos-lock-screen-nasa-image-of-the-day](./skills/snippets_workflow-nasa-image-of-day/SKILL.md) | Automate your daily dose of cosmic beauty by setting your lock or home screen to NASA's image of the day using Shortcuts and their public API. | +| [how-to-do-full-text-search-with-sqlite](./skills/sqlite_fts5/SKILL.md) | Learn how to supercharge your SQLite databases with full-text search capabilities using the built-in fts5 extension, enabling efficient and powerful querying with the `MATCH` keyword. | +| [using-sqlite-as-a-key-value-store](./skills/sqlite_key-value/SKILL.md) | Learn how to use SQLite as a simple and efficient key/value store for your applications, offering benefits like single-file data containment, attachment capabilities, and easy integration with tools like Drift. | +| [how-to-store-sqlite-as-nosql-store](./skills/sqlite_no-sql/SKILL.md) | Discover how to leverage SQLite's JSON support to build a NoSQL-like document store, complete with TTL-based expiration, within this powerful embedded database. | +| [sqlite-on-the-ui-thread](./skills/sqlite_ui-thread/SKILL.md) | Unlock the surprising speed of SQLite in Flutter for building responsive UIs, showcasing its ability to handle large datasets with synchronous queries and optimized configurations. | +| [file-based-routing-for-static-sites](./skills/static-site-file-based-routing/SKILL.md) | Learn how to create a multi-page static site with file-based routing using TypeScript, allowing for quick updates and easy content management. | diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f929cb7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,80 @@ +{ + "name": "skills-microsite", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "skills-microsite", + "version": "1.0.0", + "dependencies": { + "markdown-it": "^14.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a61c093 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "skills-microsite", + "version": "1.0.0", + "description": "Static site generator for skills microsite", + "main": "scripts/build_site.js", + "scripts": { + "build": "node scripts/build_site.js" + }, + "dependencies": { + "markdown-it": "^14.0.0" + } +} diff --git a/scripts/build_site.js b/scripts/build_site.js new file mode 100644 index 0000000..8aabbd2 --- /dev/null +++ b/scripts/build_site.js @@ -0,0 +1,322 @@ +const fs = require('fs'); +const path = require('path'); +const MarkdownIt = require('markdown-it'); + +const md = new MarkdownIt({ + html: true, + linkify: true, + typographer: true +}); + +const skillsDir = path.join(__dirname, '../skills'); +const outputDir = path.join(__dirname, '../_site'); +const baseHref = process.env.BASE_HREF || '/'; + +// Ensure output directory exists +if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); +} + +// Copy assets if any (none for now, but good practice) + +// Theme Colors & Styles +const css = ` +:root { + --bg-color: oklch(0.15 0.02 260); + --card-bg: oklch(0.2 0.03 260); + --text-primary: oklch(0.95 0.01 260); /* Pearl White */ + --text-secondary: oklch(0.7 0.02 260); /* Silver-ish */ + --accent-blue: oklch(0.6 0.2 250); /* Electric Blue */ + --accent-glow: oklch(0.6 0.2 250 / 0.5); + --border-color: oklch(0.3 0.05 260); + --shining-silver: oklch(0.85 0.01 260); +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: 'Outfit', sans-serif; + background-color: var(--bg-color); + color: var(--text-primary); + line-height: 1.6; + overflow-x: hidden; +} + +a { + color: var(--accent-blue); + text-decoration: none; + transition: all 0.3s ease; +} + +a:hover { + text-shadow: 0 0 10px var(--accent-glow); +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; +} + +header { + text-align: center; + margin-bottom: 3rem; + padding: 2rem 0; + border-bottom: 1px solid var(--border-color); + background: linear-gradient(180deg, rgba(0,0,0,0) 0%, var(--bg-color) 100%); +} + +h1 { + font-size: 3rem; + font-weight: 800; + background: linear-gradient(to right, var(--text-primary), var(--accent-blue)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 2px; +} + +h2, h3, h4, h5, h6 { + color: var(--shining-silver); + margin-top: 2rem; + margin-bottom: 1rem; +} + +/* Grid Layout */ +.skills-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 2rem; +} + +.skill-card { + background-color: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 1.5rem; + transition: transform 0.3s ease, box-shadow 0.3s ease; + display: flex; + flex-direction: column; + justify-content: space-between; + position: relative; + overflow: hidden; +} + +.skill-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 4px; + background: linear-gradient(90deg, var(--accent-blue), var(--shining-silver)); + opacity: 0; + transition: opacity 0.3s ease; +} + +.skill-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 30px -10px var(--accent-glow); + border-color: var(--accent-blue); +} + +.skill-card:hover::before { + opacity: 1; +} + +.skill-title { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 0.5rem; + color: var(--text-primary); +} + +.skill-desc { + color: var(--text-secondary); + font-size: 0.95rem; + margin-bottom: 1.5rem; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.btn { + display: inline-block; + padding: 0.5rem 1rem; + background: transparent; + border: 1px solid var(--accent-blue); + color: var(--accent-blue); + border-radius: 6px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + font-size: 0.8rem; + transition: all 0.3s ease; +} + +.btn:hover { + background: var(--accent-blue); + color: #fff; + box-shadow: 0 0 15px var(--accent-glow); +} + +/* Detail Page */ +.detail-content { + background-color: var(--card-bg); + padding: 3rem; + border-radius: 16px; + border: 1px solid var(--border-color); + box-shadow: 0 0 50px -20px #000; +} + +.detail-content img { + max-width: 100%; + border-radius: 8px; + margin: 1rem 0; + border: 1px solid var(--border-color); +} + +.detail-content pre { + background-color: #0d0d0d; + padding: 1rem; + border-radius: 8px; + overflow-x: auto; + border: 1px solid var(--border-color); + margin: 1.5rem 0; +} + +.back-link { + display: inline-block; + margin-bottom: 2rem; + color: var(--text-secondary); + font-weight: 600; +} + +.back-link:hover { + color: var(--accent-blue); + transform: translateX(-5px); +} +`; + +function parseFrontmatter(content) { + const match = content.match(/^---\n([\s\S]*?)\n---/); + if (!match) return { frontmatter: {}, body: content }; + + const frontmatter = {}; + const lines = match[1].split('\n'); + lines.forEach(line => { + const [key, ...valueParts] = line.split(':'); + if (key && valueParts.length > 0) { + frontmatter[key.trim()] = valueParts.join(':').trim(); + } + }); + + const body = content.replace(match[0], '').trim(); + return { frontmatter, body }; +} + +function renderPage(title, content, isIndex = false) { + return ` + + + + + ${title} + + + + + + + +
+ ${!isIndex ? `← Back to Skills` : ''} +
+

${isIndex ? 'Agent Skills' : title}

+ ${isIndex ? ` +

by Rody Davis

+
+ View on GitHub +
+ ` : ''} +
+
+ ${content} +
+
+ +`; +} + +function build() { + console.log('Building site...'); + + // Write CSS file + fs.writeFileSync(path.join(outputDir, 'style.css'), css); + console.log('Generated style.css'); + + const skills = []; + + // 1. Scan and Parse Skills - Recursively + function walk(dir) { + const list = fs.readdirSync(dir); + list.forEach(file => { + const filePath = path.join(dir, file); + const stat = fs.statSync(filePath); + if (stat.isDirectory()) { + walk(filePath); + } else if (file === 'SKILL.md') { + const rawContent = fs.readFileSync(filePath, 'utf8'); + const { frontmatter, body } = parseFrontmatter(rawContent); + const skillName = path.basename(path.dirname(filePath)); + + skills.push({ + name: frontmatter.name || skillName, + description: frontmatter.description || 'No description provided.', + path: skillName, // use folder name as path slug + content: body + }); + } + }); + } + + walk(skillsDir); + + // 2. Generate Skill Pages + skills.forEach(skill => { + const htmlContent = md.render(skill.content); + const pageHtml = renderPage(skill.name, htmlContent); + + // Create directory for the skill page (prettier URLs: /skill-name/index.html) + const skillOutputDir = path.join(outputDir, skill.path); + if (!fs.existsSync(skillOutputDir)) { + fs.mkdirSync(skillOutputDir, { recursive: true }); + } + + fs.writeFileSync(path.join(skillOutputDir, 'index.html'), pageHtml); + }); + + // 3. Generate Index Page + const cardsHtml = skills.map(skill => ` +
+
+

${skill.name}

+

${skill.description}

+
+ View Skill +
+ `).join(''); + + const indexHtml = renderPage('Agent Skills', cardsHtml, true); + fs.writeFileSync(path.join(outputDir, 'index.html'), indexHtml); + + console.log(`Build complete! Generated ${skills.length} skill pages.`); +} + +build(); diff --git a/scripts/prefix_image_paths.js b/scripts/prefix_image_paths.js new file mode 100644 index 0000000..7a93d52 --- /dev/null +++ b/scripts/prefix_image_paths.js @@ -0,0 +1,63 @@ +const fs = require('fs'); +const path = require('path'); + +const skillsDir = path.join(__dirname, '../skills'); +const dryRun = false; // Set to false to actually modify files +const prefix = 'https://rodydavis.com'; + +function walk(dir, fileList = []) { + if (!fs.existsSync(dir)) return fileList; + const files = fs.readdirSync(dir); + files.forEach(file => { + const filePath = path.join(dir, file); + const stat = fs.statSync(filePath); + if (stat.isDirectory()) { + walk(filePath, fileList); + } else { + if (file === 'SKILL.md') { + fileList.push(filePath); + } + } + }); + return fileList; +} + +console.log(`Scanning ${skillsDir} for relative image paths...`); +if (dryRun) console.log('--- DRY RUN MODE: No changes will be made ---'); + +const skillFiles = walk(skillsDir); +let changesCount = 0; + +skillFiles.forEach(file => { + const content = fs.readFileSync(file, 'utf8'); + + // Regex to find markdown images with relative paths starting with / + // Pattern: ![alt](url) + // We want to capture the whole link to be safe, or just the URL part. + // Let's replace `](/` with `](${prefix}/` + + // This regex looks for: + // 1. `![` (start of image) + // 2. `...` (alt text, non-greedy) + // 3. `](` (start of url) + // 4. `/` (path starting with slash) + // 5. `...` (rest of url) + // 6. `)` (end of url) + + // To safely replace, we can target the `](/` sequence that follows `![...]` + const regex = /(!\[.*?\])\((\/[^)]+)\)/g; + + if (regex.test(content)) { + console.log(`[${dryRun ? 'DRY RUN' : 'FIX'}] Found relative image path in ${path.relative(skillsDir, file)}`); + + if (!dryRun) { + const newContent = content.replace(regex, (match, alt, url) => { + return `${alt}(${prefix}${url})`; + }); + fs.writeFileSync(file, newContent); + } + changesCount++; + } +}); + +console.log(`\n${dryRun ? 'Found' : 'Fixed'} relative image paths in ${changesCount} files.`); diff --git a/scripts/remove_double_headings.js b/scripts/remove_double_headings.js new file mode 100644 index 0000000..622b46b --- /dev/null +++ b/scripts/remove_double_headings.js @@ -0,0 +1,54 @@ +const fs = require('fs'); +const path = require('path'); + +const skillsDir = path.join(__dirname, '../skills'); +const dryRun = false; // Set to false to actually modify files + +function walk(dir, fileList = []) { + if (!fs.existsSync(dir)) return fileList; + const files = fs.readdirSync(dir); + files.forEach(file => { + const filePath = path.join(dir, file); + const stat = fs.statSync(filePath); + if (stat.isDirectory()) { + walk(filePath, fileList); + } else { + if (file === 'SKILL.md') { + fileList.push(filePath); + } + } + }); + return fileList; +} + +console.log(`Scanning ${skillsDir} for double headings...`); +if (dryRun) console.log('--- DRY RUN MODE: No changes will be made ---'); + +const skillFiles = walk(skillsDir); +let changesCount = 0; + +skillFiles.forEach(file => { + const content = fs.readFileSync(file, 'utf8'); + + // Regex to find two identical H1 headers separated by newlines/whitespace + // Captures: + // Group 1: Start of string or previous newline + // Group 2: The Heading line (e.g. "# Title") + // Group 3: The separation (newlines) + // Group 4: The repeated Heading line (backreference to \2) + const regex = /(^|\n)(# [^\r\n]+)(\r?\n\s*)+(\2)/g; + + if (regex.test(content)) { + console.log(`[${dryRun ? 'DRY RUN' : 'FIX'}] Found double heading in ${path.relative(skillsDir, file)}`); + + if (!dryRun) { + // Replace with just the first heading (plus preceding newline) + // We drop the intermediate newlines and the second heading. + const newContent = content.replace(regex, '$1$2'); + fs.writeFileSync(file, newContent); + } + changesCount++; + } +}); + +console.log(`\n${dryRun ? 'Found' : 'Fixed'} double headings in ${changesCount} files.`); diff --git a/scripts/remove_markdown_links.js b/scripts/remove_markdown_links.js new file mode 100644 index 0000000..b431c12 --- /dev/null +++ b/scripts/remove_markdown_links.js @@ -0,0 +1,51 @@ +const fs = require('fs'); +const path = require('path'); + +const skillsDir = path.join(__dirname, '../skills'); +const dryRun = false; // Set to false to actually modify files + +function walk(dir, fileList = []) { + if (!fs.existsSync(dir)) return fileList; + const files = fs.readdirSync(dir); + files.forEach(file => { + const filePath = path.join(dir, file); + const stat = fs.statSync(filePath); + if (stat.isDirectory()) { + walk(filePath, fileList); + } else { + if (file === 'SKILL.md') { + fileList.push(filePath); + } + } + }); + return fileList; +} + +console.log(`Scanning ${skillsDir} for markdown links...`); +if (dryRun) console.log('--- DRY RUN MODE: No changes will be made ---'); + +const skillFiles = walk(skillsDir); +let changesCount = 0; + +skillFiles.forEach(file => { + const content = fs.readFileSync(file, 'utf8'); + + // Regex to find the specific link format: [](/api/posts/... "View as markdown") + // It seems to be on its own line usually, but we should target the exact string pattern. + // The pattern is roughly: \[\]\(/api/posts/[a-zA-Z0-9]+/markdown "View as markdown"\) + // We also want to handle potential surrounding whitespace/newlines if it's on a standalone line to leave it clean. + + const regex = /(\r?\n|^)\[\]\(\/api\/posts\/[a-zA-Z0-9]+\/markdown "View as markdown"\)(\r?\n|$)/g; + + if (regex.test(content)) { + console.log(`[${dryRun ? 'DRY RUN' : 'FIX'}] Found markdown link in ${path.relative(skillsDir, file)}`); + + if (!dryRun) { + const newContent = content.replace(regex, '$2'); // Keep one newline if it was surrounded by them, or just empty if at start/end + fs.writeFileSync(file, newContent); + } + changesCount++; + } +}); + +console.log(`\n${dryRun ? 'Found' : 'Fixed'} markdown links in ${changesCount} files.`); diff --git a/scripts/rename_skills.js b/scripts/rename_skills.js new file mode 100644 index 0000000..1e469c7 --- /dev/null +++ b/scripts/rename_skills.js @@ -0,0 +1,40 @@ +const fs = require('fs'); +const path = require('path'); + +const skillsDir = path.join(__dirname, '../skills'); +const prefixToRemove = 'rodydavis_com_posts_'; + +console.log(`Scanning ${skillsDir} for folders starting with "${prefixToRemove}"...`); + +if (!fs.existsSync(skillsDir)) { + console.error(`Skills directory not found at ${skillsDir}`); + process.exit(1); +} + +const files = fs.readdirSync(skillsDir); +let count = 0; + +files.forEach(file => { + const oldPath = path.join(skillsDir, file); + const stat = fs.statSync(oldPath); + + if (stat.isDirectory() && file.startsWith(prefixToRemove)) { + const newName = file.substring(prefixToRemove.length); + const newPath = path.join(skillsDir, newName); + + console.log(`Renaming: ${file} -> ${newName}`); + + try { + if (fs.existsSync(newPath)) { + console.warn(`WARNING: Destination ${newName} already exists. Skipping.`); + } else { + fs.renameSync(oldPath, newPath); + count++; + } + } catch (e) { + console.error(`ERROR renaming ${file}: ${e.message}`); + } + } +}); + +console.log(`\nRenamed ${count} directories.`); diff --git a/skills/astro-ssr-pocketbase-single-server/SKILL.md b/skills/astro-ssr-pocketbase-single-server/SKILL.md new file mode 100644 index 0000000..7559aec --- /dev/null +++ b/skills/astro-ssr-pocketbase-single-server/SKILL.md @@ -0,0 +1,305 @@ +--- +name: how-to-run-astro-ssr-and-pocketbase-on-the-same-server +description: Learn how to host PocketBase and an Astro SSR application on the same server, using PocketBase's Go integration and a reverse proxy to delegate requests to Astro for dynamic web content. +metadata: + url: https://rodydavis.com/posts/astro-ssr-pocketbase-single-server + last_modified: Tue, 03 Feb 2026 20:04:35 GMT +--- + +# How to Run Astro SSR and PocketBase on the Same Server + + +In this article I will show you how to host [PocketBase](https://pocketbase.io/) and [Astro in SSR](https://docs.astro.build/en/guides/server-side-rendering/) mode on the same server. PocketBase does let you [render templates](https://pocketbase.io/docs/go-rendering-templates/) on the server but requires [Go Templates](https://pkg.go.dev/text/template) or pre-building with Static Site Generation (SSG). + +> This could also be modified to use your web server or framework of choice ([Next.js](https://nextjs.org/docs/pages/building-your-application/rendering/server-side-rendering), [SvelteKit](https://kit.svelte.dev/docs/page-options), [Qwik](https://qwik.builder.io/), [Angular](https://angular.io/guide/ssr)). + +Before getting started make sure you have the latest version of [Node](https://nodejs.org/en/blog/announcements/v19-release-announce) and [Go](https://go.dev/doc/install) installed locally. + +## Getting started  + +In a terminal run the following to create the base project: + +``` +mkdir pocketbase_astro_ssr +cd pocketbase_astro_ssr +mkdir server +mkdir www +``` + +This will create the `server` and `www` folders in our project needed for both Astro and PocketBase. + +## Setting up the server  + +Create a file at `server/main.go` and update it with the following: + +``` +package main + +import ( + "log" + "net/http/httputil" + "net/url" + + "github.com/labstack/echo/v5" + "github.com/pocketbase/pocketbase" + "github.com/pocketbase/pocketbase/core" +) + +func main() { + app := pocketbase.New() + + app.OnBeforeServe().Add(func(e *core.ServeEvent) error { + proxy := httputil.NewSingleHostReverseProxy(&url.URL{ + Scheme: "http", + Host: "localhost:4321", + }) + e.Router.Any("/*", echo.WrapHandler(proxy)) + e.Router.Any("/", echo.WrapHandler(proxy)) + return nil + }) + + if err := app.Start(); err != nil { + log.Fatal(err) + } +} +``` + +Here we are extending [PocketBase with Go](https://pocketbase.io/docs/go-overview/) and taking advantage of the [Echo router](https://echo.labstack.com/docs/routing) integration and using a [reverse proxy](https://www.nginx.com/resources/glossary/reverse-proxy-server/#:~:text=A%20reverse%20proxy%20server%20is,traffic%20between%20clients%20and%20servers.) to handle all requests not defined by PocketBase already and delegating them to Astro. + +Next run the following in a terminal to install the dependencies: + +``` +go mod init server +go mod tidy +``` + +Now we can start the server and move on to the client: + +``` +go run main.go serve +``` + +You should see the following and note that this will run in debug mode so all the SQL statements will start to show: + +``` +2023/11/09 10:28:52 Server started at http://127.0.0.1:8090 +├─ REST API: http://127.0.0.1:8090/api/ +└─ Admin UI: http://127.0.0.1:8090/_/ +``` + +### Collections  + +Open up the Admin UI url and after creating a new admin user, create a new collection `items` and add the following metadata: + +Column Name + +Column Type + +Column Settings + +title + +Plain Text + +  + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/0n5a8cv931nc4d8/astro_ssr_1_l8se0qq5gx.png?thumb=) + +Then update the API Rules to allow read access for list and view. + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/w5769j576712b2q/astro_ssr_2_nbohwwy3lp.png?thumb=) + +> This is just for example purposes and on a production app you will rely on auth for ACLs + +Create 3 new records with placeholder data. + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/04aq788kp9vrr4r/astro_ssr_3_s8uxkyvvla.png?thumb=) + +## Creating the client  + +Now we can create the client that will be used to connect to PocketBase and serve all of the web traffic. + +Navigate to the `www` directory and run the following in a terminal: + +``` +npm create astro@latest +``` + +Follow the prompts and enter the following: + +Question + +Answer + +Where should we create your new project? + +. + +How would you like to start your new project? + +Empty + +Install dependencies? + +Yes + +Do you plan to write TypeScript? + +Yes + +How strict should TypeScript be? + +Strict + +Initialize a new git repository? + +No + +You can of course customize this as you need, but next we can install the dependencies needed by running the following in a terminal: + +``` +npm i -D @astrojs/node +npm i pocketbase +``` + +Next update `www/astro.config.mjs` and update it with the following: + +``` +import { defineConfig } from "astro/config"; +import nodejs from "@astrojs/node"; + +// https://astro.build/config +export default defineConfig({ + adapter: nodejs({ + mode: "standalone", + }), + output: "server", +}); +``` + +This will use Server Side Rendering (SSR) instead of Static Site Generation (SSG) when we run the web server. + +### UI  + +#### Layouts  + +We can start by creating a shared layout for all the routes. Create a file at `www/src/layouts/Root.astro` and update it with the following: + +``` +--- +interface Props { + title: string; +} + +const { title } = Astro.props; +--- + + + + + + + + {title} + + + + + +``` + +#### Routes  + +Now we can update the index `/` route by updating the following file `www/src/pages/index.astro`: + +``` +--- +import Root from "../layouts/Root.astro"; + +import PocketBase from "pocketbase"; + +const pb = new PocketBase("http://127.0.0.1:8090"); +const items = pb.collection("items"); +const records = await items.getFullList(); +--- + + +

Items

+ +
+``` + +This will call the `items` collection on the server and render it with 0 JS on the client. + +Next create a file `www/src/pages/[...slug].astro` and update it with the following: + +``` +--- +import Root from "../layouts/Root.astro"; + +import PocketBase from "pocketbase"; + +const slug = Astro.params.slug!; +const id = slug.split("/").pop()!; + +const pb = new PocketBase("http://127.0.0.1:8090"); +const items = pb.collection("items"); + +const records = await items.getList(1, 1, { + filter: `id = '${id}'`, +}); + +if (records.items.length === 0) { + return new Response("Not found", { status: 404 }); +} + +const {title} = records.items[0]; +--- + + + Back +

{title}

+
+``` + +This is almost like before but now we can return a proper `404` response if not found for an item. + +#### Running  + +Now we can run the web server with the following command: + +``` +npm run dev +``` + +You should see the following: + +``` +> dev +> astro dev + + 🚀 astro v3.4.4 started in 67ms + + ┃ Local http://localhost:4321/ + ┃ Network use --host to expose +``` + +Then if we open up the PocketBase url `http://127.0.0.1:8090` and you should see the following for the index route and detail routes: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/t8xy11r4nz16w56/astro_ssr_4_2jgteusxtt.png?thumb=) + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/mk5evib25fxj5wd/astro_ssr_5_3u7bekhf36.png?thumb=) + +## Conclusion  + +Now you can build a new binary for both the server and client and deploy them both on the same server instance. 🎉 + +You can find the final code [here](https://github.com/rodydavis/pocketbase_astro_ssr). \ No newline at end of file diff --git a/skills/async-preact-signal/SKILL.md b/skills/async-preact-signal/SKILL.md new file mode 100644 index 0000000..89cfa54 --- /dev/null +++ b/skills/async-preact-signal/SKILL.md @@ -0,0 +1,339 @@ +--- +name: async-preact-signals +description: Explore how to effectively manage asynchronous data with Preact Signals by creating a custom `asyncSignal` that handles loading, error, and data states without breaking the synchronous nature of signals. +metadata: + url: https://rodydavis.com/posts/async-preact-signal + last_modified: Tue, 03 Feb 2026 20:04:16 GMT +--- + +# Async Preact Signals + + +When working with [signals](https://github.com/preactjs/signals) in Javascript, it is very common to work with async data from [Promises](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise). + +## Async vs Sync + +But unlike other state management libraries, signals do not have an _asynchronous_ state graph and all values must be computed _synchronously_. + +When people first start using signals they want to simply add **async** to the function callback but this breaks how they work under the hood and leads to **undefined** behavior. ☹️ + +Async functions are a leaky abstraction and force you to handle them all the way up the graph. Async is also not always better and can have a [performance impact](https://madelinemiller.dev/blog/javascript-promise-overhead/). 😬 + +## Working with Promises + +We can still do so much with sync operations, and make it eaiser to work with common async patterns. + +For example when you make a **http** request using [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch), you want to return the data in the **Promise** and update some UI. + +``` +const el = document.querySelector('#output'); +let postId = '123'; +fetch(`/posts/${postId}`).then(res => res.json()).then(post => { + el.innerText = post.title; +}) +``` + +Now when we add signals we can rerun the fetch everytime the post id changes. + +``` +import { effect, signal } from "@preact/signals-core"; + +const el = document.querySelector('#output'); +const postId = signal( '123'); + +effect(() => { + fetch(`/posts/${postId.value}`).then(res => res.json()).then(post => { + el.innerText = post.title; + }); +}); +``` + +This is better, but now we need to handle stopping the previous request if the post id changes before the previous fetch completes. + +``` +import { effect, signal } from "@preact/signals-core"; + +const el = document.querySelector('#output'); +const postId = signal( '123'); +let controller; + +effect(() => { + if (controller) { + controller.abort(); + } + controller = new AbortController(); + const signal = controller.signal; + try { + fetch(`/posts/${postId.value}`, { signal }).then(res => res.json()).then(post => { + el.innerText = post.title; + }); + } catch (err) { + // todo: show error message + } +}); +``` + +But this still skips a lot of things we normally want to show like loading states and error states. + +``` +import { effect, signal, batch } from "@preact/signals-core"; + +const el = document.querySelector('#output'); +const postId = signal( '123'); +const postData = signal({}); +const errorMessage = signal(''); +const loading = signal(false); +let controller; + +effect(() => { + if (controller) { + controller.abort(); + } + controller = new AbortController(); + const signal = controller.signal; + batch(() => { + loading.value = true; + errorMessage.value = ''; + postData.value = {}; + }); + try { + fetch(`/posts/${postId.value}`, { signal }).then(res => res.json()).then(post => { + batch(() => { + postData.value = post; + loading.value = false; + }); + }); + } catch (err) { + errorMessage.value = err.message; + } +}); +effect(() => { + if (loading.value) { + el.innerText = 'Loading...'; + } else if (errorMessage.value) { + el.innerText = `Error: ${errorMessage.value}`; + } else { + el.innerText = postData.value.title; + } +}); +``` + +Now we can show the proper states, but this is only for one request... + +We could wrap this up in a class to reuse or create a new type of signal that can work with asynchronous data. + +## AsyncState + +We want to have a base class that we can make our loading states easily extend from: + +``` +export class AsyncState { + constructor() {} + + get value(): T | null { + return null; + } + + get requireValue(): T { + throw new Error("Value not set"); + } + + get error(): any { + return null; + } + + get isLoading(): boolean { + return false; + } + + get hasValue(): boolean { + return false; + } + + get hasError(): boolean { + return false; + } + + map(builders: { + onLoading: () => R; + onError: (error: any) => R; + onData: (data: T) => R; + }): R { + if (this.hasError) { + return builders.onError(this.error); + } + if (this.hasValue) { + return builders.onData(this.requireValue); + } + return builders.onLoading(); + } +} +``` + +> [This class](https://dartsignals.dev/async/state/) actually comes from a [Dart port of preact signals](https://github.com/rodydavis/signals.dart) I created. + +This allows us to easily check if there is an actual value, error or if it is loading. It also provides an easy builder method to map the state to another value. 🤩 + +### AsyncData + +The loading state extends **AsyncState** and passes the value in the constructor to the overriden methods. + +``` +export class AsyncData extends AsyncState { + private _value: T; + + constructor(value: T) { + super(); + this._value = value; + } + + get requireValue(): T { + return this._value; + } + + get hasValue(): boolean { + return true; + } + + toString() { + return `AsyncData{${this._value}}`; + } +} +``` + +### AsyncLoading + +For the loading state we override the methods like **AsyncData**. + +``` +export class AsyncLoading extends AsyncState { + get value(): T | null { + return null; + } + + get isLoading(): boolean { + return true; + } + + toString() { + return `AsyncLoading{}`; + } +} +``` + +### AsyncError + +For the error state we can pass an object of any type to return the error as value instead of throwing an exception (like Go). + +``` +export class AsyncError extends AsyncState { + private _error: any; + + constructor(error: any) { + super(); + this._error = error; + } + + get error(): any { + return this._error; + } + + get hasError(): boolean { + return true; + } + + toString() { + return `AsyncError{${this._error}}`; + } +} +``` + +## asyncSignal + +Now we the state classes created, we can create a function to create an asynchronous signal with all the logic we talked about earlier. + +We need to show the sync value at any time and have a way to abort previous requests. + +``` +export function asyncSignal( + cb: () => Promise +): ReadonlySignal> { + const loading = new AsyncLoading(); + const reset = Symbol("reset"); + const s = signal>(loading); + const c = computed>(cb); + let controller: AbortController | null; + let abortSignal: AbortSignal | null; + + function execute(cb: Promise, cancel: AbortSignal) { + (async () => { + s.value = loading; + try { + const result = await new Promise(async (resolve, reject) => { + if (cancel.aborted) { + reject(cancel.reason); + } + cancel.addEventListener("abort", () => { + reject(cancel.reason); + }); + try { + const result = await cb; + if (cancel.aborted) { + reject(cancel.reason); + return; + } + resolve(result); + } catch (error) { + reject(error); + } + }); + s.value = new AsyncData(result); + } catch (error) { + if (error === reset) { + s.value = loading; + } else { + s.value = new AsyncError(error); + } + } + })(); + } + + effect(() => { + if (controller != null) { + controller.abort(reset); + } + controller = new AbortController(); + abortSignal = controller.signal; + execute(c.value, abortSignal); + }); + + return s; +} +``` + +This makes it very easy to create multiple asynchronous signals and also use it anywhere else you have signals in the application like effects and computeds. + +``` +const el = document.querySelector('#output'); +const postId = signal('123'); + +const result = asyncSignal(() => fetch(`/posts/${postId.value}`).then(res => res.json())); + +effect(() => { + el.innerText = result.value.map({ + onLoading: () => 'Loading...', + onError: (err) => `Error: ${err}`, + onData: (post) => post.title, + }); +}); + +postId.value = '456'; +``` + +## Conclusion + +I have started a Preact Signals GitHub discussion [here](https://github.com/preactjs/signals/discussions/648) and you can find a gist with the [final source code here](https://gist.github.com/rodydavis/3b5266da2cc07f6574d425f5ce6e1e31). 🎉 + +This has made working with asynchronous data a lot eaiser to work with and would love to hear your thoughts about ways to improve it 👀 + +Also if you are curious about how Angular does asynchronous signals you can check out the [resource signal](https://angular.dev/guide/signals/resource) and the [computedFrom/Async signal](https://justangular.com/blog/building-computed-async-for-signals-in-angular). \ No newline at end of file diff --git a/skills/automate-flutter-apps/SKILL.md b/skills/automate-flutter-apps/SKILL.md new file mode 100644 index 0000000..57b7d75 --- /dev/null +++ b/skills/automate-flutter-apps/SKILL.md @@ -0,0 +1,172 @@ +--- +name: automate-flutter-app-releases +description: Automate your Flutter app releases to beta or production with this handy shell script that handles version bumping, formatting, cleaning, rebuilding, and deployment via Fastlane. +metadata: + url: https://rodydavis.com/posts/automate-flutter-apps + last_modified: Tue, 03 Feb 2026 20:04:27 GMT +--- + +# Automate Flutter App Releases + + +> **TLDR** You can find the script [here](https://gist.github.com/rodydavis/774b36e32d7efa882cca8dd16da6e74c). + +``` +#!/bin/bash + +echo "App Release Automator by @rodydavis" + +action="$1" +red=`tput setaf 1` +green=`tput setaf 2` +reset=`tput sgr0` + +if [ ${action} = "build" ]; then + + echo "${green}Generating built files.. ${reset}" + flutter packages pub run build_runner clean + flutter packages pub run build_runner build --delete-conflicting-outputs + + pub global activate pubspec_version + git commit -a -m "Build $(pubver bump patch)" + + echo "${green}Building Project...${reset}" + find . -name "*-e" -type f -delete + flutter format . + flutter clean + + echo "${green}Project Size: $(find . -name "*.dart" | xargs cat | wc -c)${reset}" + + echo "${green}Building APK...${reset}" + flutter build apk + + echo "${green}Builing IPA..${reset}" + cd ./ios && pod install && pod repo update && cd .. + flutter build ios + + git commit -a -m "Project Rebuilt" + + +elif [ ${action} = "beta" ]; then + + echo "${green}Generating built files..${reset}" + flutter packages pub run build_runner clean + flutter packages pub run build_runner build --delete-conflicting-outputs + + pub global activate pubspec_version + git commit -a -m "Beta $(pubver bump patch)" + + echo "${green}Building Project...${reset}" + find . -name "*-e" -type f -delete + flutter format . + flutter clean + + echo "${green}Project Size: $(find . -name "*.dart" | xargs cat | wc -c)${reset}" + + echo "${green}Building APK...${reset}" + flutter build apk + + echo "${green}Sending Android to Beta...${reset}" + cd ./android && fastlane beta && cd .. + + echo "${green}Builing IPA..${reset}" + flutter build ios + + echo "${green}Sending iOS to Beta..${reset}" + cd ./ios && fastlane beta && cd .. + + git commit -a -m "Sent to Beta" + + +elif [ ${action} = "release" ]; then + + echo "${green}Generating built files..${reset}" + flutter packages pub run build_runner clean + flutter packages pub run build_runner build --delete-conflicting-outputs + + pub global activate pubspec_version + git commit -a -m "Production $(pubver bump minor)" + + echo "${green}Building Project...${reset}" + find . -name "*-e" -type f -delete + flutter format . + flutter clean + + echo "${green}Project Size: $(find . -name "*.dart" | xargs cat | wc -c)${reset}" + + echo "${green}Building APK...${reset}" + flutter build apk + + echo "${green}Sending Android to Production...${reset}" + cd ./android && fastlane release && cd .. + + echo "${green}Builing IPA..${reset}" + flutter build ios + + echo "${green}Sending iOS to Production...${reset}" + cd ./ios && fastlane release && cd .. + + git commit -a -m "Sent to Production" + +fi + +echo "${green}Successfully completed${reset}" +``` + +Needed: + +* Fastlane setup in each directory +* build\_runner as a dependency +* Git Project in VCS + +Steps to Run: + +1. Download this file and put it at the root level of your flutter project +2. Open the terminal and navigate to your project location +3. Enter this command: `chmod +x release.sh` + +Usage + +* For beta: `./release.sh beta` +* For production: `./release.sh release` + +It will do the following: + +* Bump the version numbers if you are using the version in the `pubspec.yaml` +* Release the apps with Fastlane +* Format all Dart Files +* Clean Project +* Rebuild classes +* Add commit message + +Make your life easier and automate your builds to beta and production! + +## What you need  + +* [Fastlane](https://fastlane.tools/) setup in each directory +* [build\_runner](https://pub.dartlang.org/packages/build_runner) as a dependency +* Git Project in VCS + +## Initial Setup  + +* Download [this file](https://gist.github.com/rodydavis/774b36e32d7efa882cca8dd16da6e74c) +* Put it at the root level of your flutter project +* Open the terminal and navigate to your project location +* Enter this command: chmod +x release.sh + +## Usage  + +Now you can call this script! + +* For beta: `./release.sh beta` +* For production: `./release.sh release` + +## Overview  + +* Bump the version numbers if you are using the version in the pubspec.yaml +* Release the apps with Fastlane +* Format all Dart Files +* Clean Project +* Rebuild classes +* Add commit messages +* Updates Cocoa Pods \ No newline at end of file diff --git a/skills/codemirror-dynamic-theme/SKILL.md b/skills/codemirror-dynamic-theme/SKILL.md new file mode 100644 index 0000000..865918d --- /dev/null +++ b/skills/codemirror-dynamic-theme/SKILL.md @@ -0,0 +1,533 @@ +--- +name: dynamic-themes-with-codemirror +description: Learn how to create a Lit web component with CodeMirror, dynamically themed using Material Design's color utilities, for a customizable code editing experience. +metadata: + url: https://rodydavis.com/posts/codemirror-dynamic-theme + last_modified: Tue, 03 Feb 2026 20:04:24 GMT +--- + +# Dynamic Themes with CodeMirror + + +In this article I will go over how to set up a [Lit](https://lit.dev/) web component and use it to create a a code window that uses [CodeMirror](https://codemirror.net/) and apply a dynamic theme with [Material Design](https://material.io/). + +> **TLDR** The final source [here](https://github.com/rodydavis/codemirror-dynamic-theme) and an online [demo](https://rodydavis.github.io/codemirror-dynamic-theme/). + +## Prerequisites  + +* Vscode +* Node >= 16 +* Typescript + +## Getting Started  + +We can start off by navigating in terminal to the location of the project and run the following: + +``` +npm init @vitejs/app --template lit-ts +``` + +Then enter a project name `codemirror-dynamic-theme` and now open the project in vscode and install the dependencies: + +``` +cd codemirror-dynamic-theme +npm i lit codemirror @material/material-color-utilities +npm i -D @types/node @types/codemirror +code . +``` + +Update the `vite.config.ts` with the following: + +``` +import { defineConfig } from "vite"; +import { resolve } from "path"; + +export default defineConfig({ + base: "/codemirror-dynamic-theme/", + build: { + rollupOptions: { + input: { + main: resolve(__dirname, "index.html"), + }, + }, + }, +}); +``` + +## Template  + +Open up the `index.html` and update it with the following: + +``` + + + + + + + CodeMirror Dynamic Theme + + + + + + + +``` + +## Web Component  + +Before we update our component we need to rename `my-element.ts` to `code-window.ts` + +Open up `code-window.ts` and update it with the following: + +``` +import { html, css, LitElement, unsafeCSS } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { + applyTheme, + argbFromHex, + hexFromArgb, + themeFromSourceColor, +} from "@material/material-color-utilities"; +import CodeMirror from "codemirror"; +import codemirrorStyles from "codemirror/lib/codemirror.css"; + +@customElement("code-window") +export class CodeWindow extends LitElement { + static styles = css` + ${unsafeCSS(codemirrorStyles)} + + main { + width: 100vw; + height: 100vh; + background-color: var(--md-sys-color-background); + color: var(--md-sys-color-on-background); + --header-height: 48px; + --input-size: 32px; + } + + .toolbar { + height: var(--header-height); + background-color: var(--md-sys-color-primary-container); + color: var(--md-sys-color-on-primary-container); + display: flex; + align-items: center; + } + + .actions > * { + margin-left: 4px; + margin-right: 4px; + } + + .toolbar .title { + font-family: sans-serif; + font-size: 18px; + padding-left: 4px; + } + + .toolbar .actions { + display: flex; + align-items: center; + } + + .toolbar a { + padding: 0; + margin: 0; + padding-left: 8px; + padding-right: 8px; + display: flex; + align-items: center; + cursor: pointer; + } + + input[type="color"] { + width: calc(var(--input-size) * 2); + height: var(--input-size); + outline: none; + border: none; + border-radius: 50%; + background-color: var(--md-sys-color-primary-container); + } + input[type="color"]::-webkit-color-swatch-wrapper { + padding: 0; + } + input[type="color"]::-webkit-color-swatch { + border: none; + border-radius: var(--input-size); + border: var(--md-sys-color-outline) solid 1px; + } + + button { + border: none; + border-radius: 4px; + padding: 8px; + } + + .tertiary { + background-color: var(--md-sys-color-tertiary); + color: var(--md-sys-color-on-tertiary); + } + + .secondary { + background-color: var(--md-sys-color-secondary); + color: var(--md-sys-color-on-secondary); + } + + .spacer { + flex: 1; + } + + .editor { + height: calc(100% - var(--header-height)); + width: 100%; + } + `; + + @property() value = [ + `import {html, css, LitElement} from 'lit';`, + `import {customElement, property} from 'lit/decorators.js';`, + ``, + `@customElement('simple-greeting')`, + `export class SimpleGreeting extends LitElement {`, + ` static styles = css\`p { color: blue }\`;`, + ``, + ` @property()`, + ` name = 'Somebody';`, + ``, + ` render() {`, + ` return html\`

Hello, \${this.name}!

\`;`, + ` }`, + `}`, + ].join("\n"); + + @property() color = "#6750A4"; + @property({ type: Boolean }) dark = window.matchMedia( + "(prefers-color-scheme: dark)" + ).matches; + + render() { + return html`
+
+
${document.title}
+
+
+ + + +
+
+
+
`; + } + + firstUpdated() { + const root = this.shadowRoot!.querySelector(".editor") as HTMLElement; + const editor = CodeMirror(root, { + value: this.value, + mode: "javascript", + lineNumbers: true, + lineWrapping: true, + indentUnit: 4, + tabSize: 4, + indentWithTabs: true, + autofocus: true, + }); + console.debug(editor); + editor.setSize("100%", `100%`); + this.updateTheme(); + window + .matchMedia("(prefers-color-scheme: dark)") + .addEventListener("change", (e) => { + this.dark = e.matches; + this.updateTheme(); + }); + } + + private updateTheme() { + // TODO: Generate Theme + } + + private setColor(val: string) { + this.color = val; + this.updateTheme(); + } + + private onColor(e: Event) { + const target = e.target as HTMLInputElement; + this.setColor(target.value); + } + + private randomColor() { + const letters = "0123456789ABCDEF"; + let color = "#"; + for (let i = 0; i < 6; i++) { + color += letters[Math.floor(Math.random() * 16)]; + } + this.setColor(color); + } + + private toggleDark() { + this.dark = !this.dark; + this.updateTheme(); + } + +} + +declare global { + interface HTMLElementTagNameMap { + "code-window": CodeWindow; + } +} +``` + +Here we are setting up some of the editor basics to load in the styles needed for the basic layout. + +There are also few methods that handle updating of properties on the element such as `toggleDark` and `setColor`. When you run the application you should see the following: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/zpv7mxra15y855n/cm_1_vrf88lj26h.webp?thumb=) + +It doesn't look great yet, but now we can add a CodeMirror theme to import. Create a file `src/theme.ts` and update it with the following: + +``` +import { css } from "lit"; + +export const codeMirrorTheme = css` + .CodeMirror { + background-color: var(--md-sys-color-background); + color: var(--md-sys-color-on-background); + } + + .CodeMirror-gutters { + background: var(--md-sys-color-surface-variant); + color: var(--md-sys-color-on-surface-variant); + border: none; + } + + .CodeMirror-guttermarker, + .CodeMirror-guttermarker-subtle, + .CodeMirror-linenumber { + color: var(--md-sys-color-on-background); + } + + .CodeMirror-cursor { + border-left: 1px solid var(--md-sys-color-primary); + } + .cm-fat-cursor .CodeMirror-cursor { + background-color: var(--md-sys-color-background); + } + .cm-animate-fat-cursor { + background-color: var(--md-sys-color-background); + } + + div.CodeMirror-selected { + background: var(--md-sys-color-surface-variant); + } + + .CodeMirror-focused div.CodeMirror-selected { + background: var(--md-sys-color-surface-variant); + } + + .CodeMirror-line::selection, + .CodeMirror-line > span::selection, + .CodeMirror-line > span > span::selection { + background: var(--md-sys-color-surface-variant); + } + + .CodeMirror-line::-moz-selection, + .CodeMirror-line > span::-moz-selection, + .CodeMirror-line > span > span::-moz-selection { + background: var(--md-sys-color-surface-variant); + } + + .CodeMirror-activeline-background { + background: var(--md-sys-color-surface); + } + + .cm-keyword { + color: var(--md-custom-color-keyword) !important; + } + + .cm-operator { + color: var(--md-custom-color-operator) !important; + } + + .cm-variable-2 { + color: var(--md-custom-color-variable-2) !important; + } + + .cm-variable-3, + .cm-type { + color: var(--md-custom-color-variable-3) !important; + } + + .cm-builtin { + color: var(--md-custom-color-builtin) !important; + } + + .cm-atom { + color: var(--md-custom-color-atom) !important; + } + + .cm-number { + color: var(--md-custom-color-number) !important; + } + + .cm-def { + color: var(--md-custom-color-def) !important; + } + + .cm-string { + color: var(--md-custom-color-string) !important; + } + + .cm-string-2 { + color: var(--md-custom-color-string-2) !important; + } + + .cm-comment { + color: var(--md-custom-color-comment) !important; + } + + .cm-variable { + color: var(--md-custom-color-variable) !important; + } + + .cm-tag { + color: var(--md-custom-color-tag) !important; + } + + .cm-meta { + color: var(--md-custom-color-meta) !important; + } + + .cm-attribute { + color: var(--md-custom-color-attribute) !important; + } + + .cm-property { + color: var(--md-custom-color-property) !important; + } + + .cm-qualifier { + color: var(--md-custom-color-qualifier) !important; + } + + .cm-variable-3, + .cm-type { + color: var(--md-custom-color-variable-3) !important; + } + + .cm-error { + color: var(--md-sys-color-on-error); + background-color: var(--md-sys-color-error); + } + + .CodeMirror-matchingbracket { + text-decoration: underline; + color: var(--md-sys-color-on-surface); + } +`; +``` + +Here we are defining all the styles as [CSS Custom Properties](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties) so we can easily update them. + +Now import the theme and apply the styles in the component: + +``` +import { codeMirrorTheme } from "./theme"; + +@customElement("code-window") +export class CodeWindow extends LitElement { + static styles = css` + ${unsafeCSS(codemirrorStyles)} + ${codeMirrorTheme} + ... +``` + +Now we need to implement the `updateTheme` method in our element: + +``` +updateTheme() { + const source = this.color; + const dark = this.dark; + const target = this.shadowRoot!.querySelector("main") as HTMLElement; + const properties = [ + `--md-custom-color-keyword: #c75779;`, + `--md-custom-color-operator: #008800;`, + `--md-custom-color-variable: #90ccff;`, + `--md-custom-color-variable-2: #dd7700;`, + `--md-custom-color-variable-3: #3333bb;`, + `--md-custom-color-variable-3: #decb6b;`, + `--md-custom-color-builtin: #003388;`, + `--md-custom-color-atom: #bb4646;`, + `--md-custom-color-number: #b4c5ff;`, + `--md-custom-color-def: #82aaff;`, + `--md-custom-color-string: #ffb4a9;`, + `--md-custom-color-string-2: #ffb4a9;`, + `--md-custom-color-comment: #888888;`, + `--md-custom-color-tag: #000080;`, + `--md-custom-color-meta: #a9c7ff;`, + `--md-custom-color-attribute: #008080;`, + `--md-custom-color-property: #336699;`, + `--md-custom-color-qualifier: #690;`, + ]; + const customColors = properties.map((property) => { + const [key, value] = property.split(":"); + const name = key.trim().replace(/^--md-custom-color-/, ""); + const color = argbFromHex(value.trim().replace(";", "")); + return { + name, + value: color, + blend: true, + }; + }); + const theme = themeFromSourceColor(argbFromHex(source), customColors); + applyTheme(theme, { target, dark }); + for (const custom of theme.customColors) { + const name = custom.color.name; + const section = dark ? custom.dark : custom.light; + target.style.setProperty( + `--md-custom-color-${name}`, + hexFromArgb(section.color) + ); + } +} +``` + +Here we are using the [Material Color Utilities](https://github.com/material-foundation/material-color-utilities) package and generating a theme from a source color. We can take advantage of Custom Colors to blend the values to the theme. + +After the theme is generated we can apply them to the root element and have the custom properties update the editor. + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/4dms4387o49vrdq/cm_2_i38ka2mknd.webp?thumb=) + +Changing the source color can update the theme: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/5j8242y59z068c2/cm_3_h3mcbh0c3s.webp?thumb=) + +Changing the brightness can set the colors as well: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/154y3970c459zp1/cm_4_u36e81hjiw.webp?thumb=) + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/ydr425389axp4os/cm_5_2xo33gqwdh.webp?thumb=) + +## Conclusion  + +If you want to learn more about building with Lit you can read the docs [here](https://lit.dev/). + +The source for this example can be found [here](https://github.com/rodydavis/codemirror-dynamic-theme). + +If you want to check out dynamic themes for VSCode I created an extension [here](https://github.com/rodydavis/vscode-dynamic-theme). \ No newline at end of file diff --git a/skills/dart-html-web-components/SKILL.md b/skills/dart-html-web-components/SKILL.md new file mode 100644 index 0000000..f4b0a54 --- /dev/null +++ b/skills/dart-html-web-components/SKILL.md @@ -0,0 +1,234 @@ +--- +name: how-to-create-html-web-components-with-dart +description: Discover the power of Web Components and how to build them with both JavaScript and Dart for reusable, framework-agnostic UI elements. +metadata: + url: https://rodydavis.com/posts/dart-html-web-components + last_modified: Tue, 03 Feb 2026 20:04:14 GMT +--- + +# How to create HTML Web Components with Dart + + +I am a long time [Web Components](https://developer.mozilla.org/en-US/docs/Web/API/Web_components) fan (since helping DevRel [lit.dev](https://lit.dev/) and [Material Web Components](https://github.com/material-components/material-web)) and have also loved writing [Dart](https://dart.dev/) in both [Flutter](https://flutter.dev/) applications and full stack apps. + +Despite being [used at so many companies](https://arewebcomponentsathingyet.com/), Web Components have faced a lot of pushback from JavaScript developers that use frameworks to target the web. ☹️ + +What you may not realize is that the web has a way to create new HTML tags that can be used in **ANY** JS framework or place that returns HTML and you can progressively enchance applications. 🤩 + +Since they are custom HTML tags, if you swap implementations, you do not need to update where it is used and you can ship components a separate files [instead of one big bundle](https://world.hey.com/dhh/modern-web-apps-without-javascript-bundling-or-transpiling-a20f2755). + +Dart [used to support Web Components](https://github.com/dart-archive/web-components) at one point and was even used by a precursor to Lit in a product call [Polymer](https://github.com/polymer-dart). + +## Creating a Web Component in Javascript + +To create a web component in Javascript you just need to extend HTML element and provide callbacks for when the component is mounted. + +``` +class HelloWorld extends HTMLElement { + static observedAttributes = ["name"]; + + constructor() { + super(); + } + + update() { + this.innerHTML = `Hello: ${this.getAttribute('name')}`; + } + + connectedCallback() { + console.log("Custom element added to page."); + this. update(); + } + + disconnectedCallback() { + console.log("Custom element removed from page."); + } + + adoptedCallback() { + console.log("Custom element moved to new page."); + } + + attributeChangedCallback(name, oldValue, newValue) { + console.log(`Attribute ${name} has changed.`); + if (name === 'name') { + this. update(); + } + } +} + +customElements.define("hello-world", HelloWorld); +``` + +We can then use it in HTML like the following: + +``` + + + + + + +``` + +This works really well, and we don't even need a build step to create them! + +## Creating Web Components with Dart + +To create them on the Dart side we need to use the [js\_interop package](https://dart.dev/interop/js-interop/usage) and the new [web package](https://pub.dev/packages/web). + +We need to create a factory on the dart side that can create these JS classes without actually being able to create a class in the normal way (since JS and Dart classes are different). + +There is a great API [`Reflect.construct()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Reflect/construct) which allows us to take a normal function and invoke it class a class constructor. JavaScript did not always support native classes and was only added with [ES6](https://www.w3schools.com/js/js_es6.asp). + +By using this built in API, we can create the classes with just pure Dart: + +``` +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; + +import 'package:web/web.dart'; + +class WebComponent { + late T element; + final String extendsType = 'HTMLElement'; + + void connectedCallback() {} + + void disconnectedCallback() {} + + void adoptedCallback() {} + + void attributeChangedCallback( + String name, + String? oldValue, + String? newValue, + ) {} + + Iterable get observedAttributes => []; + + bool get formAssociated => false; + + ElementInternals? get internals => element['_internals'] as ElementInternals?; + set internals(ElementInternals? value) { + element['_internals'] = value; + } + + R getRoot() { + final hasShadow = element.shadowRoot != null; + return (hasShadow ? element.shadowRoot! : element) as R; + } + + static void define(String tag, WebComponent Function() create) { + final obj = _factory(create); + window.customElements.define(tag, obj); + } +} + +@JS('Reflect.construct') +external JSAny _reflectConstruct( + JSObject target, + JSAny args, + JSFunction constructor, +); + +final _instances = {}; + +JSFunction _factory(WebComponent Function() create) { + final base = create(); + final elemProto = globalContext[base.extendsType] as JSObject; + late JSAny obj; + + JSAny constructor() { + final args = [].jsify()!; + final self = _reflectConstruct(elemProto, args, obj as JSFunction); + final el = self as HTMLElement; + _instances.putIfAbsent(el, () => create()..element = el); + return self; + } + + obj = constructor.toJS; + obj = obj as JSObject; + + final observedAttributes = base.observedAttributes; + final formAssociated = base.formAssociated; + + obj['prototype'] = elemProto['prototype']; + obj['observedAttributes'] = observedAttributes.toList().jsify()!; + obj['formAssociated'] = formAssociated.jsify()!; + + final prototype = obj['prototype'] as JSObject; + prototype['connectedCallback'] = (HTMLElement instance) { + _instances[instance]?.connectedCallback(); + }.toJSCaptureThis; + prototype['disconnectedCallback'] = (HTMLElement instance) { + _instances[instance]?.disconnectedCallback(); + _instances.remove(instance); + }.toJSCaptureThis; + prototype['adoptedCallback'] = (HTMLElement instance) { + _instances[instance]?.adoptedCallback(); + }.toJSCaptureThis; + prototype['attributeChangedCallback'] = ( + HTMLElement instance, + String name, + String? oldName, + String? newName, + ) { + _instances[instance]?.attributeChangedCallback(name, oldName, newName); + }.toJSCaptureThis; + + return obj as JSFunction; +} +``` + +This may seem like a lot to digest, but that is ok. It simply does some JS magic to upgrade functions to classes and provide the correct callbacks to create the web components. + +> If you want a package that does this for you, [html\_web\_components](https://pub.dev/packages/html_web_components) is on pub.dev. + +To create a Web Component like we did before, we can just extend the class and define the component. + +``` +import 'package:html_web_components/html_web_components.dart'; + +class HelloWorld extends WebComponent { + @override + List observedAttributes = ['name']; + + void update() { + element.innerText = "Hello: ${element.getAttribute('name')}!"; + } + + @override + void connectedCallback() { + super.connectedCallback(); + update(); + } + + @override + void attributeChangedCallback( + String name, + String? oldValue, + String? newValue, + ) { + super.attributeChangedCallback(name, oldValue, newValue); + if (observedAttributes.contains(name)) { + update(); + } + } +} + +void main() { + WebComponent.define('hello-world', HelloWorld.new); +} +``` + +This should look very similar (that is the goal) and makes it so easy to publish the compoents or build a full web application with it. + +## Conclusion + +Web Components allow you to upgrade your client side interactivity while having the freedom to use server rendering to create the template files or just use a SPA on the frontend. You can take these components and use them in **ANY** JS frameworks! 🤯 + +I would highly suggest that you try it out for yourself before you write off Web Components. This is especially true for Flutter developers wanting an alternative to Flutter web (and even use with [Jaspr](https://pub.dev/packages/jaspr)). + +You can take advantage of Dart's great ecosystem of packages on [pub.dev](https://pub.dev/) and the ability to compile to WASM and JS. If you use a builder like [peanut](https://pub.dev/packages/peanut) it will even create the script that tries to load WASM and can fallback to JS for you 🔥 + +If you want to see the code, you can [find it on GitHub](https://github.com/rodydavis/dart-web-components). Reach out if you have any questions or want to show off something cool you built with them! \ No newline at end of file diff --git a/skills/dart_bitwise/SKILL.md b/skills/dart_bitwise/SKILL.md new file mode 100644 index 0000000..91e95c1 --- /dev/null +++ b/skills/dart_bitwise/SKILL.md @@ -0,0 +1,122 @@ +--- +name: how-to-do-bitwise-operations-in-dart +description: Explore Dart's bitwise operations for both integers and booleans, including AND, OR (inclusive & exclusive), NAND, NOR, and XNOR, with practical code examples. +metadata: + url: https://rodydavis.com/posts/dart/bitwise + last_modified: Tue, 03 Feb 2026 20:04:33 GMT +--- + +# How to do Bitwise operations in Dart + + +In Dart it is possible to do [Bitwise Operations](https://en.wikipedia.org/wiki/Bitwise_operation#:~:text=In%20computer%20programming%2C%20a%20bitwise,directly%20supported%20by%20the%20processor.) with **int** and **bool** types. + +## AND  + +Checks if the left and right side are both true. [Learn more](https://www.ibm.com/docs/en/xl-c-and-cpp-aix/16.1?topic=expressions-bitwise-operator). + +``` +// int +print(0 & 1); // 0 +print(1 & 0); // 0 +print(1 & 1); // 1 +print(0 & 0); // 0 + +// bool +print(false & true); // false +print(true & false); // false +print(true & true); // true +print(false & false); // false +``` + +## OR  + +### Inclusive  + +Checks if either the left or right side are true. [Learn more](https://www.ibm.com/docs/en/xl-c-and-cpp-aix/16.1?topic=be-logical-operator). + +``` +// int +print(0 | 1); // 1 +print(1 | 0); // 1 +print(1 | 1); // 1 +print(0 | 0); // 0 + +// bool +print(false | true); // true +print(true | false); // true +print(true | true); // true +print(false | false); // false +``` + +### Exclusive  + +Checks if both the left or right side are true but not both. [Learn more](https://www.ibm.com/docs/en/xl-c-and-cpp-aix/16.1?topic=expressions-bitwise-exclusive-operator). + +``` +// int +print(0 ^ 1); // 1 +print(1 ^ 0); // 1 +print(1 ^ 1); // 0 +print(0 ^ 0); // 0 + +// bool +print(false ^ true); // true +print(true ^ false); // true +print(true ^ true); // false +print(false ^ false); // false +``` + +## NAND  + +Negated AND operation. + +``` +// int +print(~(0 & 1) & 1); // 1 +print(~(1 & 0) & 1); // 1 +print(~(1 & 1) & 1); // 0 +print(~(0 & 0) & 1); // 1 + +// bool +print(!(false & true)); // true +print(!(true & false)); // true +print(!(true & true)); // false +print(!(false & false)); // true +``` + +## NOR  + +Negated inclusive OR operation. + +``` +// int +print(~(0 | 1) & 1); // 0 +print(~(1 | 0) & 1); // 0 +print(~(1 | 1) & 1); // 0 +print(~(0 | 0) & 1); // 1 + +// bool +print(!(false | true)); // false +print(!(true | false)); // false +print(!(true | true)); // false +print(!(false | false)); // true +``` + +## XNOR  + +Negated exclusive OR operation. + +``` +// int +print(~(0 ^ 1) & 1); // 0 +print(~(1 ^ 0) & 1); // 0 +print(~(1 ^ 1) & 1); // 1 +print(~(0 ^ 0) & 1); // 1 + +// bool +print(!(false ^ true)); // false +print(!(true ^ false)); // false +print(!(true ^ true)); // true +print(!(false ^ false)); // true +``` \ No newline at end of file diff --git a/skills/dart_function-invoking/SKILL.md b/skills/dart_function-invoking/SKILL.md new file mode 100644 index 0000000..ccea904 --- /dev/null +++ b/skills/dart_function-invoking/SKILL.md @@ -0,0 +1,49 @@ +--- +name: various-ways-to-invoke-functions-in-dart +description: Discover the surprising flexibility of calling Dart functions, including mixed positional and named arguments, the `.call` operator, and dynamic invocation with `Function.apply`. +metadata: + url: https://rodydavis.com/posts/dart/function-invoking + last_modified: Tue, 03 Feb 2026 20:04:32 GMT +--- + +# Various Ways to Invoke Functions in Dart + + +There are multiple ways to call a [Function](https://dart.dev/language/functions) in Dart. + +The examples below will assume the following function: + +``` +void myFunction(int a, int b, {int? c, int? d}) { + print((a, b, c, d)); +} +``` + +But recently I learned that you can call a functions positional arguments in any order mixed with the named arguments. 🤯 + +``` +myFunction(1, 2, c: 3, d: 4); +myFunction(1, c: 3, d: 4, 2); +myFunction(c: 3, d: 4, 1, 2); +myFunction(c: 3, 1, 2, d: 4); +``` + +In addition you can use the [`.call`](https://dart.dev/language/callable-objects) operator to invoke the function if you have a reference to it: + +``` +myFunction.call(1, 2, c: 3, d: 4); +``` + +You can also use [`Function.apply`](https://api.flutter.dev/flutter/dart-core/Function/apply.html) to dynamically invoke a function with a reference but it should be noted that it will effect js dart complication size and performance: + +``` +Function.apply(myFunction, [1, 2], {#c: 3, #d: 4}); +``` + +All of these methods print the following: + +``` +(1, 2, 3, 4) +``` + +## Demo \ No newline at end of file diff --git a/skills/dart_print-multiple-objects/SKILL.md b/skills/dart_print-multiple-objects/SKILL.md new file mode 100644 index 0000000..e504522 --- /dev/null +++ b/skills/dart_print-multiple-objects/SKILL.md @@ -0,0 +1,67 @@ +--- +name: how-to-print-multiple-objects-to-the-console-with-print-in-dart +description: Learn how to print multiple objects to the console in Dart using Records, offering a similar experience to JavaScript's `console.log()` functionality. +metadata: + url: https://rodydavis.com/posts/dart/print-multiple-objects + last_modified: Tue, 03 Feb 2026 20:04:33 GMT +--- + +# How to Print Multiple Objects to the Console with print() in Dart + + +If you are coming from JavaScript you may be used to printing multiple objects to the console with `console.log()`: + +``` +console.log('a', 1, 'b', 2); // a 1 b 2 +``` + +In Dart we can only print `Object?` to the console with [`print()`](https://api.dart.dev/stable/3.3.1/dart-core/print.html): + +``` +print(1); // 1 +print(null); // null +print({'a': 1, 'b': 2}); // {a: 1, b: 1} +``` + +But it is totally possible to print multiple objects too, we need to use [Records](https://dart.dev/language/records): + +``` +final number = 1; +final str = 'Hello World'; + +print((number, str)); + +print((DateTime.now(), str)); + +print((DateTime.now(), count: number, description: str)); + +print((DateTime.now(), StackTrace.current)); +``` + +Print the following: + +``` +(1, Hello World) +(2024-03-06 15:48:26.514, Hello World) +(2024-03-06 15:48:26.514, count: 1, description: Hello World) +(2024-03-06 15:48:26.514, Error + at get current [as current] (https://storage.googleapis.com/nnbd_artifacts/3.3.0/dart_sdk.js:139991:30) + at Object.main$0 [as main] (:52:94) + at Object.main$ [as main] (:44:10) + at :89:26 + at Object.execCb (https://dartpad.dev/require.js:5:16727) + at e.check (https://dartpad.dev/require.js:5:10499) + at e. (https://dartpad.dev/require.js:5:12915) + at https://dartpad.dev/require.js:5:1542 + at https://dartpad.dev/require.js:5:13376 + at each (https://dartpad.dev/require.js:5:1020) + at e.emit (https://dartpad.dev/require.js:5:13344) + at e.check (https://dartpad.dev/require.js:5:11058) + at e.enable (https://dartpad.dev/require.js:5:13242) + at e.init (https://dartpad.dev/require.js:5:9605) + at a (https://dartpad.dev/require.js:5:8305) + at Object.completeLoad (https://dartpad.dev/require.js:5:15962) + at HTMLScriptElement.onScriptLoad (https://dartpad.dev/require.js:5:16882)) +``` + +## Demo \ No newline at end of file diff --git a/skills/dart_truthy/SKILL.md b/skills/dart_truthy/SKILL.md new file mode 100644 index 0000000..ef27a91 --- /dev/null +++ b/skills/dart_truthy/SKILL.md @@ -0,0 +1,70 @@ +--- +name: check-if-an-object-is-truthy-in-dart +description: Learn how to extend Dart's functionality to implement JavaScript-style "truthy" checks for easier conditional logic and value evaluations. +metadata: + url: https://rodydavis.com/posts/dart/truthy + last_modified: Tue, 03 Feb 2026 20:04:34 GMT +--- + +# Check if an Object is Truthy in Dart + + +If you are coming from language like JavaScript you may be used to checking if an object is [truthy](https://developer.mozilla.org/en-US/docs/Glossary/Truthy). + +``` +if (true) +if ({}) +if ([]) +if (42) +if ("0") +if ("false") +if (new Date()) +if (-42) +if (12n) +if (3.14) +if (-3.14) +if (Infinity) +if (-Infinity) +``` + +In Dart you need to explicitly check if an object is not null, true/false or determine if the value is true based on the type. + +It is possible however to use Dart extensions to add the truthy capability. + +``` +extension on Object? { + bool get isTruthy => truthy(this); +} + +bool truthy(Object? val) { + if (val == null) return false; + if (val is bool) return val; + if (val is num && val == 0) return false; + if (val is String && (val == 'false' || val == '')) return false; + if (val is Iterable && val.isEmpty) return false; + if (val is Map && val.isEmpty) return false; + return true; +} +``` + +This will now make it possible for any object to be evaluated as a truthy value in if statements or value assignments. + +Prints the following: + +``` +(null, false) +(, false) +(false, false) +(true, true) +(0, false) +(1, true) +(false, false) +(true, true) +([], false) +([1, 2, 3], true) +({}, false) +({1, 2, 3}, true) +({a: 1, b: 2}, true) +``` + +## Demo \ No newline at end of file diff --git a/skills/deep-linking-flutter-web/SKILL.md b/skills/deep-linking-flutter-web/SKILL.md new file mode 100644 index 0000000..1397017 --- /dev/null +++ b/skills/deep-linking-flutter-web/SKILL.md @@ -0,0 +1,136 @@ +--- +name: deep-linking-for-flutter-web +description: Learn how to implement proper URL navigation in your Flutter application, including deep linking to specific pages, handling protected routes, and creating custom transitions for a seamless user experience. +metadata: + url: https://rodydavis.com/posts/deep-linking-flutter-web + last_modified: Tue, 03 Feb 2026 20:04:25 GMT +--- + +# Deep Linking for Flutter Web + + +In this article I will show you how to have proper URL navigation for your application. Open links to specific pages, protected routes and custom transitions. + +> **TLDR** The final source [here](https://github.com/rodydavis/flutter_deep_linking) and an online [demo](https://rodydavis.github.io/flutter_deep_linking/). + +## Setup  + +Create a new flutter project called “flutter\_deep\_linking” + +Open that folder up in VSCode. + +Update your “pubspec.yaml” with the following: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/r43ga1tjg6p8x6i/deep_1_qbot26kikl.webp?thumb=) + +## Step 1  + +Create a file at “lib/ui/home/screen.dart” and add the following: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/7r1l1i535220rwy/deep_2_8o7bk1h6ep.webp?thumb=) + +Update your “lib/main.dart” with the following: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/h42392iez6y3m89/deep_3_u638b3ccuy.webp?thumb=) + +Run your application and you should see the following: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/43sd7k55aq8yce8/deep_4_fk5zfnixom.webp?thumb=) + +## Step 2  + +Now we need to grab the url the user enters into the address bar. + +Create a folder at this location “lib/plugins/navigator” + +Create a file inside named: “web.dart” with the following: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/430154j9r710g4v/deep_5_r37mgvwx5q.webp?thumb=) + +Create a file inside named: “unsupported.dart” with the following: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/dczy2vb19nv6oo8/deep_6_xogk4ly3ze.webp?thumb=) + +Create a file inside named: “navigator.dart” with the following: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/lv0141dn57c869h/deep_7_atfvh3z2tt.webp?thumb=) + +Now go back to your “lib/main.dart” file and add the navigator: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/k75ny99q018o2y3/deep_8_4esmqtror6.webp?thumb=) + +> It’s important to import the navigator as shown as this will have the conditional import for web compiling. + +If you run the app now nothing should change. + +## Step 3  + +Now let’s add the proper routing. + +Create a new file “lib/ui/router.dart” and add the following: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/0y1ro5lf7h279w9/deep_9_xae1ebw89x.webp?thumb=) + +Also update “lib/main.dart” with the following: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/p7821ykg5on9lla/deep_10_ijxwpy9f4h.webp?thumb=) + +> Notice how we removed the “home” field for MaterialApp. This is because the router will handle everything. By default we will go home on “/” + +## Step 4  + +Now let’s add multiple screens to put this to the test! Add the following folders and files. + +Create a file “lib/ui/account/screen.dart” and add the following: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/c081tn3331n994h/deep_11_o9tev9myvc.webp?thumb=) + +Create a file “lib/ui/settings/screen.dart” and add the following: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/q117wlgum9kr8gn/deep_12_8gbj5hxdmq.webp?thumb=) + +Create a file “lib/ui/about/screen.dart” and add the following: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/az34574641hrj62/deep_13_aojf2nh3fj.webp?thumb=) + +Add the following to “lib/ui/router.dart”: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/8sa4176bo1f1c66/deep_14_9p3q4k5nqt.webp?thumb=) + +Now when you navigate to /about, /account and /settings you will go to the new pages! + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/o0w52xqyj19cg2w/deep_15_lahb8r2wyw.webp?thumb=) + +## Step 5  + +Now let’s tie into the browser navigation buttons! Update “lib/ui/home/screen.dart” with the following: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/eb89014ivx49f9c/deep_16_6ccpd3agpc.webp?thumb=) + +Now when you run the application and click on the settings icon it will launch the new screen as expected. But if you click your browsers back button it will go back to the home screen! + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/192l3evo16e287e/deep_17_rovfeoa2t1.webp?thumb=) + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/4zzn742z7lsg454/deep_18_75rxz9598q.webp?thumb=) + +## Step 6  + +These urls are great but what if you want to pass data such as an ID that is not known ahead of time? No worries! + +Update “lib/ui/account/screen.dart” with the following: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/93f2yzeb7qkc089/deep_19_qyhv82wxfe.webp?thumb=) + +Let’s update our “lib/ui/router.dart” with the following: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/ar68a9w9l596080/deep_20_i6mmwwv4bg.webp?thumb=) + +Now when you run your application and navigate to “/account/40” you will see the following: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/318co8rwm04s894/deep_21_ytsnzw349i.webp?thumb=) + +## Conclusion  + +Dynamic routes work great for Flutter web, you just need to know what to tweak! This package uses a forked version of fluro for some fixes I added but once the PRs is merged you can just use the regular package. Let me know what you think below and if there is a better way I am not seeing! + +Here is the final code: [https://github.com/rodydavis/flutter\_deep\_linking](https://github.com/rodydavis/flutter_deep_linking) \ No newline at end of file diff --git a/skills/display-html-in-flutter/SKILL.md b/skills/display-html-in-flutter/SKILL.md new file mode 100644 index 0000000..097677e --- /dev/null +++ b/skills/display-html-in-flutter/SKILL.md @@ -0,0 +1,89 @@ +--- +name: displaying-html-in-flutter +description: Easily display and interact with HTML content in your Flutter app using the `easy_web_view` package, which supports both web and mobile platforms. +metadata: + url: https://rodydavis.com/posts/display-html-in-flutter + last_modified: Tue, 03 Feb 2026 20:04:25 GMT +--- + +# Displaying HTML in Flutter + + +Sometimes you have content in HTML that needs to be displayed and interacted with in Flutter. + +Online Demo:[https://rodydavis.github.io/easy\_web\_view/#/](https://rodydavis.github.io/easy_web_view/#/) + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/2v4742s6b8wbnd1/html_1_yaak9yba8y.webp?thumb=) + +For those impatient I created a package for you to get all the following functionally and more here: [https://pub.dev/packages/easy\_web\_view](https://pub.dev/packages/easy_web_view) + +## Getting Started  + +Create a new flutter project named whatever you want. + +> If you plan on showing HTML content on iOS/Android you will need to add the following to your pubspec.yaml + +``` +dependencies: + webview_flutter: ^0.3.15+1 +``` + +## Web  + +Reference: [/lib/src/web.dart](https://github.com/rodydavis/easy_web_view/blob/master/lib/src/web.dart) + +To show html on Flutter web we need to use an HTMLElementView. This is a platform view that allows us to display native content. + +We first need to register the Element and add all the options we need. Here we are creating an iFrame element and setting the source based on if it is markdown, html or a url. + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/lc658z8810cfbdx/html_2_uowumu74gx.webp?thumb=) + +To display valid HTML you can set the src field to the following: + +``` +_src = "data:text/html;charset=utf-8," + Uri.encodeComponent("HTML_CONTENT_HERE"); +``` + +> For the package you can also pass markdown to the src and it will convert it for you. + +After you call the setup method it is now time to display your new platform view: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/i3k0c91499iuzcn/html_3_d2x4h481p0.webp?thumb=) + +You need to use the same viewType string as you registered for “registerViewFactory” method earlier. + +Finally you need to wrap it in a container or sized box with an explicit width and height! + +## Mobile  + +Reference: [https://github.com/rodydavis/easy\_web\_view/blob/master/lib/src/mobile.dart](https://github.com/rodydavis/easy_web_view/blob/master/lib/src/mobile.dart) + +Mobile setup should be easier. Let’s add a method for updating the url that we will pass to the web view. + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/53dyq4ko469eq77/html_4_can4qsn59m.webp?thumb=) + +Create the controller: + +``` +WebViewController _controller; +``` + +And when ever the src changes call this method: + +``` +_controller.loadUrl(_updateUrl(widget.src), headers: widget.headers); +``` + +Finally lets show the html in the widget tree: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/0n8wq5l5xw7gwa0/html_5_ue0rqxe1sr.webp?thumb=) + +## Conclusion  + +If you want to see a complete example and advanced use case view the source here: [https://github.com/rodydavis/easy\_web\_view](https://github.com/rodydavis/easy_web_view) + +And if you just want to have it all done for you use this package: [https://pub.dev/packages/easy\_web\_view](https://pub.dev/packages/easy_web_view) + +Feel free to make PRs if you have anything that could help make it better too (Or if you find bugs). + +When you show HTML this way you will find that you can interact, select text and work with it just like you would it it were a regular web page. If you are using the package you can also just pass embedded content or html elements too without needing a full html valid file (YouTube video for example). \ No newline at end of file diff --git a/skills/export-sqlite-dart/SKILL.md b/skills/export-sqlite-dart/SKILL.md new file mode 100644 index 0000000..eaa9026 --- /dev/null +++ b/skills/export-sqlite-dart/SKILL.md @@ -0,0 +1,144 @@ +--- +name: how-to-export-sqlite-tables-to-create-statements +description: Learn how to export your entire SQLite database schema, including tables and indexes, into runnable CREATE statements at runtime using Flutter and the `sqlite3` package. +metadata: + url: https://rodydavis.com/posts/export-sqlite-dart + last_modified: Tue, 03 Feb 2026 20:04:37 GMT +--- + +# How to Export SQLite Tables to CREATE Statements + + +In this article I will show you how to export all the tables and indexes in a [SQLite](https://www.sqlite.org/index.html) database to CREATE statements at runtime. + +## Getting started  + +Start by creating a new directory and [Flutter](https://flutter.dev/) project: + +``` +mkdir sqlite_introspect +cd sqlite_introspect +flutter create . +flutter pub add sqlite3 mustache_template +``` + +This will add the `sqlite3` package which uses FFI to call the native executable and mustache that we will use for templates later. + +## Creating the database  + +Creating the database can be done either in memory or based on a local file. For this example we will use in memory: + +``` +final Database db = sqlite3.openInMemory(); +``` + +Don't forget to dispose of the database after use: + +``` +db.dispose(); +``` + +## Defining the template  + +Since we will be using [Mustache](https://mustache.github.io/) we can define the variables that we will pass to the template as JSON. + +Create a `TableInfo` class that will store the fields and indexes: + +``` +class TableInfo { + final String name; + final List> fields; + final List> indexes; + + TableInfo({ + required this.name, + required this.fields, + required this.indexes, + }); + + Map toJson() { + return { + 'name': name, + 'fields': [ + for (var i = 0; i < fields.length; i++) + { + 'index': i, + 'table': name, + 'isLast': i == fields.length - 1, + ...fields[i], + }, + ], + 'indexes': [ + for (var i = 0; i < indexes.length; i++) + { + 'index': i, + 'table': name, + 'isLast': i == indexes.length - 1, + ...indexes[i], + }, + ], + }; + } +} +``` + +Now we can create the Mustache template used to build up the CREATE statements: + +``` +const template = ''' +{{#tables}} +CREATE TABLE {{name}} ( + {{#fields}} + {{name}} {{#type}} {{.}}{{/type}}{{#notnull}} NOT NULL{{/notnull}}{{#pk}} PRIMARY KEY{{/pk}}{{#dflt_value}} DEFAULT {{.}}{{/dflt_value}}{{^isLast}},{{/isLast}} + {{/fields}} +); +{{#indexes}} +CREATE {{#unique}} UNIQUE{{/unique}} {{name}} +ON {{table}}({{#values}} {{name}} {{/values}}{{^isLast}},{{/isLast}}); +{{/indexes}} +{{/tables}} +'''; +``` + +## Exporting the PRAGMA  + +Now we can export the [PRAGMA](https://www.sqlite.org/pragma.html) for the database by exporting the list of tables, querying the column information and indexes about each one. + +``` +final tables = []; +// Export table names +final tableNames = db + .select("SELECT name FROM sqlite_master WHERE type='table';") + .map((e) => e['name'] as String); +for (final t in tableNames) { + // Export column information + final info = db.select('PRAGMA table_info($t);'); + final tbl = TableInfo(name: t, fields: [], indexes: []); + for (final c in info) { + tbl.fields.add(c); + } + // Export index names + final indexList = db.select('PRAGMA index_list($t);'); + for (final index in indexList) { + final name = index['name'] as String; + // Export index information + final infos = db.select('PRAGMA index_info($name);'); + final indexValue = {...index, 'values': infos}; + tbl.indexes.add(indexValue); + } + tables.add(tbl); +} +``` + +## Rendering the template  + +Now take the tables we just exported and pass them to the mustache template to render: + +``` +final tml = Template(template); +final args = {"tables": tables.map((e) => e.toJson()).toList()}; +final str = tml.renderString(args); +print(str); +``` + +This will now print out all the tables and indexes as CREATE as valid SQL. 🎉 \ No newline at end of file diff --git a/skills/fastlane-and-flutter/SKILL.md b/skills/fastlane-and-flutter/SKILL.md new file mode 100644 index 0000000..441195c --- /dev/null +++ b/skills/fastlane-and-flutter/SKILL.md @@ -0,0 +1,205 @@ +--- +name: using-fastlane-in-flutter-and-ci +description: Automate Flutter app builds and deployments to both the App Store and Google Play using Fastlane with this step-by-step guide. +metadata: + url: https://rodydavis.com/posts/fastlane-and-flutter + last_modified: Tue, 03 Feb 2026 20:04:17 GMT +--- + +# Using Fastlane in Flutter and CI + + +Prerequisites: + +* Understand what [Fastlane](https://fastlane.tools/) is and how it works + +* Project builds correctly following these [docs](https://flutter.dev/docs/deployment/cd) + +* Android app setup in [Google Play Console](https://developer.android.com/distribute/console) + +* iOS app setup in [AppStore Connect](https://appstoreconnect.apple.com/) + +* [Flutter is installed](https://flutter.dev/docs/get-started/install) and your project is created + + +### Steps + +1. Open your Flutter project + +2. Run: cd ios + +3. Run: fastlane init and follow the prompts + +4. Replace the Fastfile contents with this: + + +``` +#!/bin/bash + +echo "App Release Automator by @rodydavis" + +action="$1" +red=`tput setaf 1` +green=`tput setaf 2` +reset=`tput sgr0` + +if [ ${action} = "build" ]; then + + echo "${green}Generating built files.. ${reset}" + flutter packages pub run build_runner clean + flutter packages pub run build_runner build --delete-conflicting-outputs + + pub global activate pubspec_version + git commit -a -m "Build $(pubver bump patch)" + + echo "${green}Building Project...${reset}" + find . -name "*-e" -type f -delete + flutter format . + flutter clean + + echo "${green}Project Size: $(find . -name "*.dart" | xargs cat | wc -c)${reset}" + + echo "${green}Building APK...${reset}" + flutter build apk + + echo "${green}Builing IPA..${reset}" + cd ./ios && pod install && pod repo update && cd .. + flutter build ios + + git commit -a -m "Project Rebuilt" + + +elif [ ${action} = "beta" ]; then + + echo "${green}Generating built files..${reset}" + flutter packages pub run build_runner clean + flutter packages pub run build_runner build --delete-conflicting-outputs + + pub global activate pubspec_version + git commit -a -m "Beta $(pubver bump patch)" + + echo "${green}Building Project...${reset}" + find . -name "*-e" -type f -delete + flutter format . + flutter clean + + echo "${green}Project Size: $(find . -name "*.dart" | xargs cat | wc -c)${reset}" + + echo "${green}Building APK...${reset}" + flutter build apk + + echo "${green}Sending Android to Beta...${reset}" + cd ./android && fastlane beta && cd .. + + echo "${green}Builing IPA..${reset}" + flutter build ios + + echo "${green}Sending iOS to Beta..${reset}" + cd ./ios && fastlane beta && cd .. + + git commit -a -m "Sent to Beta" + + +elif [ ${action} = "release" ]; then + + echo "${green}Generating built files..${reset}" + flutter packages pub run build_runner clean + flutter packages pub run build_runner build --delete-conflicting-outputs + + pub global activate pubspec_version + git commit -a -m "Production $(pubver bump minor)" + + echo "${green}Building Project...${reset}" + find . -name "*-e" -type f -delete + flutter format . + flutter clean + + echo "${green}Project Size: $(find . -name "*.dart" | xargs cat | wc -c)${reset}" + + echo "${green}Building APK...${reset}" + flutter build apk + + echo "${green}Sending Android to Production...${reset}" + cd ./android && fastlane release && cd .. + + echo "${green}Builing IPA..${reset}" + flutter build ios + + echo "${green}Sending iOS to Production...${reset}" + cd ./ios && fastlane release && cd .. + + git commit -a -m "Sent to Production" + +fi + +echo "${green}Successfully completed${reset}" +``` + +5. Run: cd .. && cd android + +6. Run: fastlane init and follow the prompts + +7. Replace the Fastfile contents with this: + + +``` +# Uncomment the line if you want fastlane to automatically update itself +# update_fastlane + +default_platform(:android) + +platform :android do + desc "Prepare and archive app" + lane :prepare do |options| + #bundle_install + Dir.chdir "../.." do + sh("flutter", "packages", "get") + sh("flutter", "clean") + sh("flutter", "build", "appbundle", "--release") + end + end + + desc "Push a new beta build to Google Play" + lane :beta do + prepare(release: false) + upload_to_play_store( + track: 'beta', + aab: "../build/app/outputs/bundle/release/app.aab" + ) + add_git_tag( + grouping: "fastlane-builds", + prefix: "v", + build_number: android_get_version_code + ) + push_to_git_remote + end + + desc "Push a new release build to the Google Play" + lane :release do + prepare(release: true) + upload_to_play_store( + track: 'production', + aab: "../build/app/outputs/bundle/release/app.aab" + ) + add_git_tag( + grouping: "release", + prefix: "v", + build_number: android_get_version_name + ) + push_to_git_remote + end +end +``` + +8. Run: fastlane add\_plugin versioning\_android and enter your password if needed + +9. Run: cd .. + + +Now you are ready to launch your app to beta! + +For ios run: cd ios && fastlane beta + +For android run: cd android && fastlane beta + +Stay tuned for an article soon where we use these fastlane sub folders for automating the releases on [Github Actions](https://github.com/features/actions) CI \ No newline at end of file diff --git a/skills/figma-and-lit/SKILL.md b/skills/figma-and-lit/SKILL.md new file mode 100644 index 0000000..adf87de --- /dev/null +++ b/skills/figma-and-lit/SKILL.md @@ -0,0 +1,459 @@ +--- +name: lit-and-figma +description: Learn how to create a Figma plugin using Lit web components, including project setup, component creation, WASM integration, and building the final plugin for use in Figma. +metadata: + url: https://rodydavis.com/posts/figma-and-lit + last_modified: Tue, 03 Feb 2026 20:04:20 GMT +--- + +# Lit and Figma + + +In this article I will go over how to set up a [Lit](https://lit.dev/) web component and use it to create a figma plugin. + +> **TLDR** You can find the final source [here](https://github.com/rodydavis/figma_lit_example). + +## Prerequisites  + +* Vscode +* Figma Desktop +* Node +* Typescript + +## Getting Started  + +We can start off by creating a empty directory and naming it with `snake_case` whatever we want. + +``` +mkdir figma_lit_example +cd figma_lit_example +``` + +### Web Setup  + +Now we are in the `figma_lit_example` directory and can setup Figma and Lit. Let's start with node. + +``` +npm init -y +``` + +This will setup the basics for a node project and install the packages we need. Now lets add some config files. Now open the `package.json` and replace it with the following: + +``` +{ + "name": "figma_lit_example", + "version": "1.0.0", + "description": "Lit Figma Plugin", + "dependencies": { + "lit": "^2.0.0-rc.1" + }, + "devDependencies": { + "@figma/plugin-typings": "^1.23.0", + "html-webpack-inline-source-plugin": "^1.0.0-beta.2", + "html-webpack-plugin": "^4.3.0", + "css-loader": "^5.2.4", + "ts-loader": "^8.0.0", + "typescript": "^4.2.4", + "url-loader": "^4.1.1", + "webpack": "^4.44.1", + "webpack-cli": "^4.6.0" + }, + "scripts": { + "dev": "npx webpack --mode=development --watch", + "copy": "mkdir -p lit-plugin && cp ./manifest.json ./lit-plugin/manifest.json && cp ./dist/ui.html ./lit-plugin/ui.html && cp ./dist/code.js ./lit-plugin/code.js", + "build": "npx webpack --mode=production && npm run copy", + "zip": "npm run build && zip -r lit-plugin.zip lit-plugin" + }, + "browserslist": [ + "last 1 Chrome versions" + ], + "keywords": [], + "author": "", + "license": "ISC" +} +``` + +This will add everything we need and add the scripts we need for development and production. Then run the following: + +``` +npm i +``` + +This will install everything we need to get started. Now we need to setup some config files. + +``` +touch tsconfig.json +touch webpack.config.ts +``` + +This will create 2 files. Now open up `tsconfig.json` and paste the following: + +``` +{ + "compilerOptions": { + "target": "es2017", + "module": "esNext", + "moduleResolution": "node", + "lib": ["es2017", "dom", "dom.iterable"], + "typeRoots": ["./node_modules/@types", "./node_modules/@figma"], + "declaration": true, + "sourceMap": true, + "inlineSources": true, + "noUnusedLocals": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "experimentalDecorators": true, + "skipLibCheck": true, + "strict": true, + "noImplicitAny": false, + "outDir": "./lib", + "baseUrl": "./packages", + "importHelpers": true, + "plugins": [ + { + "name": "ts-lit-plugin", + "rules": { + "no-unknown-tag-name": "error", + "no-unclosed-tag": "error", + "no-unknown-property": "error", + "no-unintended-mixed-binding": "error", + "no-invalid-boolean-binding": "error", + "no-expressionless-property-binding": "error", + "no-noncallable-event-binding": "error", + "no-boolean-in-attribute-binding": "error", + "no-complex-attribute-binding": "error", + "no-nullable-attribute-binding": "error", + "no-incompatible-type-binding": "error", + "no-invalid-directive-binding": "error", + "no-incompatible-property-type": "error", + "no-unknown-property-converter": "error", + "no-invalid-attribute-name": "error", + "no-invalid-tag-name": "error", + "no-unknown-attribute": "off", + "no-unknown-event": "off", + "no-unknown-slot": "off", + "no-invalid-css": "off" + } + } + ] + }, + "include": ["src/**/*.ts"], + "references": [] +} +``` + +This is a basic typescript config. Now open up `webpack.config.ts` and paste the following: + +``` +const HtmlWebpackInlineSourcePlugin = require("html-webpack-inline-source-plugin"); +const HtmlWebpackPlugin = require("html-webpack-plugin"); +const path = require("path"); + +module.exports = (env, argv) => ({ + mode: argv.mode === "production" ? "production" : "development", + devtool: argv.mode === "production" ? false : "inline-source-map", + entry: { + ui: "./src/ui.ts", + code: "./src/code.ts", + app: "./src/my-app.ts", + }, + module: { + rules: [ + { test: /\.tsx?$/, use: "ts-loader", exclude: /node_modules/ }, + { test: /\.css$/, use: ["style-loader", { loader: "css-loader" }] }, + { test: /\.(png|jpg|gif|webp|svg)$/, loader: "url-loader" }, + ], + }, + resolve: { extensions: [".ts", ".js"] }, + output: { + filename: "[name].js", + path: path.resolve(__dirname, "dist"), + }, + plugins: [ + new HtmlWebpackPlugin({ + template: path.resolve(__dirname, "ui.html"), + filename: "ui.html", + inject: true, + inlineSource: ".(js|css)$", + chunks: ["ui"], + }), + new HtmlWebpackInlineSourcePlugin(HtmlWebpackPlugin), + ], +}); +``` + +Now we need to create the ui for the plugin: + +``` +touch ui.html +``` + +Open up `/src/ui.html` and add the following: + +``` + +``` + +Now we need a manifest file for the figma plugin: + +``` +touch manifest.json +``` + +Open `manifest.json` and add the following: + +``` +{ + "name": "figma_lit_example", + "id": "973668777853442323", + "api": "1.0.0", + "main": "code.js", + "ui": "ui.html" +} +``` + +Now we need to create our web component: + +``` +mkdir src +cd src +touch my-app.ts +touch code.ts +touch ui.ts +cd .. +``` + +Open `/src/ui.ts` and paste the following: + +``` +import "./my-app"; +``` + +Open `/src/my-app.ts` and paste the following: + +``` +import { html, LitElement } from "lit"; +import { customElement, query } from "lit/decorators.js"; + +@customElement("my-app") +export class MyApp extends LitElement { + @property() amount = "5"; + @query("#count") countInput!: HTMLInputElement; + + render() { + return html` +
+

Rectangle Creator

+

Count:

+ + +
+ `; + } + + create() { + const count = parseInt(this.countInput.value, 10); + this.sendMessage("create-rectangles", { count }); + } + + cancel() { + this.sendMessage("cancel"); + } + + private sendMessage(type: string, content: Object = {}) { + const message = { pluginMessage: { type: type, ...content } }; + parent.postMessage(message, "*"); + } +} +``` + +Open `code.ts` and paste the following: + +``` +const options: ShowUIOptions = { + width: 250, + height: 200, +}; + +figma.showUI(__html__, options); + +figma.ui.onmessage = msg => { + switch (msg.type) { + case 'create-rectangles': + const nodes: SceneNode[] = []; + for (let i = 0; i < msg.count; i++) { + const rect = figma.createRectangle(); + rect.x = i * 150; + rect.fills = [{ type: 'SOLID', color: { r: 1, g: 0.5, b: 0 } }]; + figma.currentPage.appendChild(rect); + nodes.push(rect); + } + figma.currentPage.selection = nodes; + figma.viewport.scrollAndZoomIntoView(nodes); + break; + default: + break; + } + + figma.closePlugin(); +}; +``` + +## Building the Plugin  + +Now that we have all the code in place we can build the plugin and test it in Figma. + +``` +npm run build +``` + +#### Step 1  + +Download and open the desktop version of Figma. + +[https://www.figma.com/downloads/](https://www.figma.com/downloads/) + +#### Step 2  + +Open the menu and navigate to “Plugins > Manage plugins” + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/g21yec9885zhf71/f_1_kfp7k3aclm.webp?thumb=) + +#### Step 3  + +Click on the plus icon to add a local plugin. + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/7696z39708249f4/f_2_m4hgctvnry.webp?thumb=) + +Click on the box to link to an existing plugin to navigate to the `lit-plugin` folder that was created after the build process in your source code and select `manifest.json`. + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/bs328tzc9kk71b7/f_3_ufrd6v1644.webp?thumb=) + +#### Step 4  + +To run the plugin navigate to “Plugins > Development > figma\_lit\_example” to launch your plugin. + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/hpb8fmx3528jb5y/f_4_uoz37dtck3.webp?thumb=) + +#### Step 5  + +Now your plugin should launch and you can create 5 rectangles on the canvas. + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/pqsmqosgf24p9r6/f_5_496mr4f5wp.webp?thumb=) + +If everything worked you will have 5 new rectangles on the canvas focused by figma. + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/261h7xp06dm41eq/f_6_kp2j5tc6xw.webp?thumb=) + +## WASM Support  + +If there is a heavy computation that could benefit from running in [WebAssembly](https://webassembly.org/) the following will ensure that it is hardware accelerated when possible. + +Let's add [AssemblyScript](https://www.assemblyscript.org/) and some dependencies that will be used for loading the WASM into the figma ui. + +``` +npm i @assemblyscript/loader +npm i --D assemblyscript js-inline-wasm +npx asinit . +``` + +Confirm yes to the prompt to have it generate the project files and add the following to the scripts in `package.json`: + +``` +"asbuild:untouched": "asc assembly/index.ts --target debug", +"asbuild:optimized": "asc assembly/index.ts --target release", +"asbuild": "npm run asbuild:untouched && npm run asbuild:optimized", +"inlinewasm": "inlinewasm build/optimized.wasm --output src/wasm.ts", +``` + +The code that will be used for the WASM is in `/assembly/index.ts` and it should show the following: + +``` +// The entry file of your WebAssembly module. + +export function add(a: i32, b: i32): i32 { + return a + b; +} +``` + +Now let's build the wasm module: + +``` +npm run asbuild +``` + +For the wasm build to be ignored for git add the following to .gitignore: + +``` +build +``` + +This will generate the wasm and wat files in the build directory, but for figma to load them into the ui it needs to be inlined so run the following command to generate the js from the wasm file: + +``` +npm run inlinewasm +``` + +This should generate `src/wasm.ts` with the following: + +``` +const encoded = 'AGFzbQEAAAABBwFgAn9/AX8DAgEABQMBAAAHEAIDYWRkAAAGbWVtb3J5AgAKCQEHACAAIAFqCwAmEHNvdXJjZU1hcHBpbmdVUkwULi9vcHRpbWl6ZWQud2FzbS5tYXA='; +export default new Promise(resolve => { + const decoded = atob(encoded); + const len = decoded.length; + const bytes = new Uint8Array(len); + for (var i = 0; i < len; i++) { + bytes[i] = decoded.charCodeAt(i); + } + resolve(new Response(bytes, { status: 200, headers: { "Content-Type": "application/wasm" } })); +}); +``` + +Now open up the `/src/my-app.ts` and update with the following: + +``` +import { html, LitElement } from "lit"; +import { customElement, property, query } from "lit/decorators.js"; + +@customElement("my-app") +export class MyApp extends LitElement { + @property() amount = "5"; // <-- Pass in a value for the number of rectangles to create + @query("#count") countInput!: HTMLInputElement; + + render() { + return html` +
+

Rectangle Creator

+ +

Count:

+ ... +
+ `; + } + ... +} +``` + +This will let us pass in the amount of boxes to create externally. + +Now open `/src/ui.ts` and update it with the following: + +``` +import "./my-app"; + +import wasm from "./wasm"; // <-- Our WASM file to load + +WebAssembly.instantiateStreaming(wasm as Promise).then((obj) => { + // @ts-ignore + const value: number = obj.instance.exports.add(2, 4); + console.log("return from wasm", value); + const elem = document.querySelector('my-app')! as HTMLElement; + elem.setAttribute('amount', `${value}`); +}); +``` + +Now when we build the plugin and run it in figma the amount of boxes will be the result of calling into wasm! + +## Conclusion  + +If you want to learn more about building a plugin in Figma you can read more [here](https://www.figma.com/plugin-docs/intro/) and for Lit you can read the docs [here](https://lit.dev/). \ No newline at end of file diff --git a/skills/first-flutter-project/SKILL.md b/skills/first-flutter-project/SKILL.md new file mode 100644 index 0000000..e7dc75c --- /dev/null +++ b/skills/first-flutter-project/SKILL.md @@ -0,0 +1,396 @@ +--- +name: creating-your-first-flutter-project +description: Dive into the world of Flutter, Google's UI toolkit, and learn how to build cross-platform apps with ease using this introductory guide and accompanying "Flutter Take 5" video series. +metadata: + url: https://rodydavis.com/posts/first-flutter-project + last_modified: Tue, 03 Feb 2026 20:04:25 GMT +--- + +# Creating Your First Flutter Project + + +Flutter is a UI Toolkit from Google allowing you to create expressive and unique experiences unmatched on any platform. You can write your UI once and run it everywhere. Yes everywhere! Web, iOS, Android, Windows, Linux, MacOS, Raspberry PI and much more… + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/0g6tcax6u0r8q22/flutter_1_zxbnyzfn8p.webp?thumb=) + +If you prefer a video you can follow the YouTube series I am doing called “Flutter Take 5” where I explore topics that you encounter when building a Flutter application. I will also give you tips and tricks as I go through the series. + +Or this short: + +## What is Flutter  + +Flutter recently crossed React Native on Github and now has more than 2 million developers using Flutter to create applications. There are more than 50,000 apps on Google Play alone published with Flutter. + +[Learn about Flutter.](https://flutter.dev/) + +## Getting Started  + +Getting started is very easy once you get the SDK installed. After it is installed creating new applications, plugins and packages is lighting fast. Follow this guide to install Flutter: + +[How to install Flutter.](https://flutter.dev/docs/get-started/install) + +One nice thing about Flutter is that it is developed in the open as an open source project that anyone can contribute to. If there is something missing you can easily fork the repo and make a PR for the missing functionality. + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/39v6u1xoyr67izo/flutter_2_7906zvehq3.gif?thumb=) + +## Create the Project  + +Now that you have Flutter installed it is time to create your first (Of Many 😉) Flutter project! Open up your terminal and navigate to wherever you want the application folder to be created. Once you “cd” into the directory you can type the following: + +``` +flutter create my_awesome_project +``` + +You can replace “my\_awesome\_project” with whatever you want the project to be called. It is important to use snake\_case as it is the valid syntax for project names in dart. + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/y2s4ms1j87w2x5d/flutter_3_ex6m9fvb6h.gif?thumb=) + +Congratulations you just created your first project! + +## Open the Project  + +So you may be wondering what we just created so let us dive in to the details. You can open up you project in VSCode if you have it installed by typing the following into terminal: + +``` +cd my_awesome_project && code . +``` + +You can open up the folder in your favorite IDE if you prefer. Two important files to notice are the pubspec.yaml and lib/main.dart + +Your UI and Logic is located at “lib/main.dart” and you should see the following: + +``` +import 'package:flutter/material.dart'; + +void main() { + runApp(MyApp()); +} + +class MyApp extends StatelessWidget { + // This widget is the root of your application. + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + // This is the theme of your application. + // + // Try running your application with "flutter run". You'll see the + // application has a blue toolbar. Then, without quitting the app, try + // changing the primarySwatch below to Colors.green and then invoke + // "hot reload" (press "r" in the console where you ran "flutter run", + // or simply save your changes to "hot reload" in a Flutter IDE). + // Notice that the counter didn't reset back to zero; the application + // is not restarted. + primarySwatch: Colors.blue, + // This makes the visual density adapt to the platform that you run + // the app on. For desktop platforms, the controls will be smaller and + // closer together (more dense) than on mobile platforms. + visualDensity: VisualDensity.adaptivePlatformDensity, + ), + home: MyHomePage(title: 'Flutter Demo Home Page'), + ); + } +} + +class MyHomePage extends StatefulWidget { + MyHomePage({Key key, this.title}) : super(key: key); + + // This widget is the home page of your application. It is stateful, meaning + // that it has a State object (defined below) that contains fields that affect + // how it looks. + + // This class is the configuration for the state. It holds the values (in this + // case the title) provided by the parent (in this case the App widget) and + // used by the build method of the State. Fields in a Widget subclass are + // always marked "final". + + final String title; + + @override + _MyHomePageState createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + int _counter = 0; + + void _incrementCounter() { + setState(() { + // This call to setState tells the Flutter framework that something has + // changed in this State, which causes it to rerun the build method below + // so that the display can reflect the updated values. If we changed + // _counter without calling setState(), then the build method would not be + // called again, and so nothing would appear to happen. + _counter++; + }); + } + + @override + Widget build(BuildContext context) { + // This method is rerun every time setState is called, for instance as done + // by the _incrementCounter method above. + // + // The Flutter framework has been optimized to make rerunning build methods + // fast, so that you can just rebuild anything that needs updating rather + // than having to individually change instances of widgets. + return Scaffold( + appBar: AppBar( + // Here we take the value from the MyHomePage object that was created by + // the App.build method, and use it to set our appbar title. + title: Text(widget.title), + ), + body: Center( + // Center is a layout widget. It takes a single child and positions it + // in the middle of the parent. + child: Column( + // Column is also a layout widget. It takes a list of children and + // arranges them vertically. By default, it sizes itself to fit its + // children horizontally, and tries to be as tall as its parent. + // + // Invoke "debug painting" (press "p" in the console, choose the + // "Toggle Debug Paint" action from the Flutter Inspector in Android + // Studio, or the "Toggle Debug Paint" command in Visual Studio Code) + // to see the wireframe for each widget. + // + // Column has various properties to control how it sizes itself and + // how it positions its children. Here we use mainAxisAlignment to + // center the children vertically; the main axis here is the vertical + // axis because Columns are vertical (the cross axis would be + // horizontal). + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'You have pushed the button this many times:', + ), + Text( + '$_counter', + style: Theme.of(context).textTheme.headline4, + ), + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: _incrementCounter, + tooltip: 'Increment', + child: Icon(Icons.add), + ), // This trailing comma makes auto-formatting nicer for build methods. + ); + } +} +``` + +You can define any dependencies and plugins needed for the application at “pubspec.yaml” and you should see the following: + +``` +name: example +description: A new Flutter project. + +# The following line prevents the package from being accidentally published to +# pub.dev using `pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +version: 1.0.0+1 + +environment: + sdk: ">=2.7.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^0.1.3 + +dev_dependencies: + flutter_test: + sdk: flutter + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.webp + # - images/a_dot_ham.webp + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages +``` + +## Running the Project  + +Running the application is very easy too. While there are buttons in all the IDEs you can also run your project from the command line for quick testing. You can also configure [Flutter for Desktop](https://flutter.dev/desktop) and no need to wait for an emulator to warm up. Open your project and enter the following into terminal: + +``` +flutter run -d macos +``` + +Notice the “-d macos” as you can customize what device you want to run on. You should see the following in terminal: + +``` +Building macOS application... +Syncing files to device macOS... 141ms + +Flutter run key commands. +r Hot reload. 🔥🔥🔥 +R Hot restart. +h Repeat this help message. +d Detach (terminate "flutter run" but leave application running). +c Clear the screen +q Quit (terminate the application on the device). +An Observatory debugger and profiler on macOS is available at: [http://127.0.0.1:58932/f1Mspofty_k=/](http://127.0.0.1:58932/f1Mspofty_k=/) +Application finished. +``` + +You can also run multiple devices at the same time. You can find more info on the [Flutter Octopus here](https://github.com/flutter/flutter/wiki/Multi-device-debugging-in-VS-Code). If everything went well you should see the following application launch: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/195z98qz6wrk9b3/flutter_4_sh7qzq9ggv.webp?thumb=) + +It is a pretty basic application at this point but it is important to show how easy it is to change the state in the application. You can rebuild the UI just by calling “setState()”. + +## Testing the Project  + +Testing is one of the reasons I love Flutter so much and it is dead simple to run and write tests for the project. If you look at the file “test/widget\_test.dart” you should see the following: + +``` +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility that Flutter provides. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:example/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} +``` + +You can run these tests very easily. Open your project and type the following into the terminal: + +``` +flutter test +00:07 +1: All tests passed! +``` + +Just like that all your tests will run and you can catch any bugs you missed. + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/7r5n806p1jd1w3u/flutter_5_fr3u6tytdx.gif?thumb=) + +You can also generate code coverage for your applications easily by typing the following: + +``` +flutter test --coverage +``` + +This will generate a new file at “coverage/lcov.info” and will read the following: + +``` +SF:lib/main.dart +DA:3,0 +DA:4,0 +DA:9,1 +DA:11,1 +DA:13,1 +DA:27,1 +DA:29,1 +DA:35,2 +DA:48,1 +DA:49,1 +DA:55,1 +DA:56,2 +DA:62,2 +DA:66,1 +DA:74,1 +DA:75,1 +DA:78,3 +DA:80,1 +DA:83,1 +DA:99,1 +DA:100,1 +DA:103,1 +DA:104,2 +DA:105,3 +DA:110,1 +DA:111,1 +DA:113,1 +LF:27 +LH:25 +end_of_record +``` + +You can now easily create badges and graphs with the LCOV data. Here is a package that will make that easier: + +[test\_coverage | Dart Package](https://pub.dev/packages/test_coverage) + +## Conclusion  + +Flutter makes it possible to build applications very quickly that do not depend on web or mobile technologies. It can familiar to writing a game as you have to design all your own UI. You can find the final source code here: + +[Final source code.](https://github.com/rodydavis/flutter_take_5/tree/master/01_your_first_project) + +You can also find the Flutter source code here: + +[Flutter source code.](https://github.com/flutter/flutter) \ No newline at end of file diff --git a/skills/flutter-and-lit/SKILL.md b/skills/flutter-and-lit/SKILL.md new file mode 100644 index 0000000..426f04a --- /dev/null +++ b/skills/flutter-and-lit/SKILL.md @@ -0,0 +1,482 @@ +--- +name: lit-and-flutter +description: Learn how to embed a Lit web component directly within your Flutter app to leverage web-based UIs and features while accessing native device APIs for a powerful hybrid development approach. +metadata: + url: https://rodydavis.com/posts/flutter-and-lit + last_modified: Tue, 03 Feb 2026 20:04:19 GMT +--- + +# Lit and Flutter + + +In this article I will go over how to set up a [Lit](https://lit.dev/) web component and use it inline in the Flutter widget tree. + +> **TLDR** You can find the final source [here](https://github.com/rodydavis/flutter_hybrid_template). + +The reason you would want this integration is so you can take an existing web app, or just a single part of it and embed it in the widget tree. + +With it wrapped in Flutter you can call device APIs from event listeners on your web component. + +For example you may have an app that handles purchases, and now you can call the in app purchase API or other device specific features not available on the web. + +You also get a cross platform app that can be delivered to both Google Play and the App Store. + +The web component will receive new code each time you update your site, so you do not have to ship an update each time the web component changes. + +## Prerequisites  + +* Flutter SDK +* Xcode and Command Line Tools +* Android SDK +* Vscode +* Node +* Typescript + +## Getting Started  + +We can start off by creating a empty directory and naming it with `snake_case` whatever we want. + +``` +mkdir flutter_lit_example +cd flutter_lit_example +``` + +### Web Setup  + +Now we are in the `flutter_lit_example` directory and can setup Flutter and Lit. Let's start with node. + +``` +npm init -y +npm i lit +npm i -D typescript vite @types/node +``` + +This will setup the basics for a node project and install the packages we need. Now lets add some config files. + +``` +touch tsconfig.json +touch vite.config.ts +``` + +This will create 2 files. Now open up `tsconfig.json` and paste the following: + +``` +{ + "compilerOptions": { + "module": "esnext", + "lib": [ + "es2017", + "dom", + "dom.iterable" + ], + "types": [ + "vite/client" + ], + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./types", + "rootDir": "./src", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [] +} +``` + +This is a basic typescript config. Now open up `vite.config.ts` and paste the following: + +``` +import { defineConfig } from "vite"; +import { resolve } from "path"; + +// https://vitejs.dev/config/ +export default defineConfig({ + base: "/flutter_lit_example/", // TODO: Name of your github repo + build: { + outDir: "build/web", + rollupOptions: { + output: { + entryFileNames: `assets/[name].js`, + chunkFileNames: `assets/[name].js`, + assetFileNames: `assets/[name].[ext]`, + }, + input: { + main: resolve(__dirname, "index.html"), + // TODO: Create a new module for each component you want to embed + }, + }, + }, +}); +``` + +Now we need to create our web component: + +``` +mkdir src +cd src +touch my-app.ts +cd .. +``` + +Open `my-app.ts` and paste the following: + +``` +import { html, css, LitElement } from "lit"; +import { customElement, property } from "lit/decorators.js"; + +@customElement("my-app") +export class MyApp extends LitElement { + static styles = css` + p { + color: blue; + } + `; + + @property() + name = "Somebody"; + + render() { + return html`
+

Hello, ${this.name}!

+ +
`; + } +} +``` + +We need to create a `index.html` for our web app. + +``` +touch index.html +``` + +Open `index.html` and paste the following: + +``` + + + + + + + Example + + + + + + + +``` + +### Flutter Setup  + +Now that we have the basics setup for web we can move on to flutter. Let's create the project with the following: + +``` +flutter create --platforms=ios,android . +flutter packages get +``` + +Open up `pubspec.yaml` and update it with the following: + +``` +name: flutter_lit_example +description: A hybrid Flutter app. +publish_to: "none" +version: 1.0.0+1 + +environment: + sdk: ">=2.7.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + flutter_inappwebview: ^5.3.2 + +dev_dependencies: + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true +``` + +Make sure to get the packages again: + +``` +flutter packages get +``` + +Now we need to create the file that will wrap the web component. + +``` +cd lib +touch web_component.dart +cd .. +``` + +Open `web_component.dart` and paste the following: + +``` +import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; + +class WebComponent extends StatefulWidget { + const WebComponent({ + Key key, + @required this.name, + @required this.bundle, + this.attributes = const {}, + this.slot = '', + this.events = const [], + }) : super(key: key); + final String name, bundle; + final Map attributes; + final String slot; + final List events; + + @override + _WebComponentState createState() => _WebComponentState(); +} + +class _WebComponentState extends State { + InAppWebViewController controller; + final Map> _events = {}; + + String get source { + return ''' + + + + + + + + + + <${widget.name} ${widget.attributes.entries.map((e) => '${e.key}="${e.value}"').join(' ')}> + ${widget.slot} + + + + +'''; + } + + void _setup(InAppWebViewController controller) { + this.controller = controller; + this._setupEvents(); + } + + void _setupEvents() { + for (final event in _events.keys) { + controller.removeJavaScriptHandler(handlerName: event); + } + for (final event in widget.events) { + _addEvent(event); + } + } + + void _addEvent(EventCallback event) { + controller.addJavaScriptHandler( + handlerName: event.query, + callback: event.onPressed, + ); + _events[event.event] ??= []; + _events[event.event].add(event); + } + + @override + void didUpdateWidget(covariant WebComponent oldWidget) { + if (oldWidget.events != widget.events) { + _setupEvents(); + } + if (oldWidget.slot != widget.slot || + oldWidget.bundle != widget.bundle || + oldWidget.name != widget.name) { + controller.loadData(data: source); + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return InAppWebView( + initialData: InAppWebViewInitialData(data: source), + onWebViewCreated: _setup, + ); + } +} + +class EventCallback { + EventCallback({ + @required this.onPressed, + @required this.event, + this.query, + }); + final String query, event; + final dynamic Function(List args) onPressed; + + @override + String toString() => _source; + + String get _prefix => query != null && query.isNotEmpty + ? 'document.querySelector("$query")' + : 'document.body'; + + String get _source => [ + '$_prefix.addEventListener("$event", (e) => {', + ' window.flutter_inappwebview.callHandler("$query", e);', + '}, false);', + ].join('\n'); +} +``` + +Open `main.dart` and paste it with th following: + +``` +import 'package:flutter/material.dart'; + +import 'web_component.dart'; + +const WEBSITE_URL = 'https://rodydavis.github.io/flutter_lit_example/'; +const BUNDLE_PATH = 'assets/main.js'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + runApp(MyApp()); +} + +class MyApp extends StatelessWidget { + final title = 'Flutter Hybrid App'; + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: title, + theme: ThemeData(primarySwatch: Colors.blue), + home: MyHomePage(title: title), + ); + } +} + +class MyHomePage extends StatefulWidget { + MyHomePage({Key key, this.title}) : super(key: key); + + final String title; + + @override + _MyHomePageState createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: Builder( + builder: (context) => WebComponent( + name: 'my-app', + bundle: '$WEBSITE_URL/$BUNDLE_PATH', + attributes: { + 'name': widget.title, + }, + slot: '', + events: [ + EventCallback( + event: 'click', + query: '#my-button', + onPressed: (_) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Clicked!'))); + }, + ), + ], + ), + ), + ); + } +} +``` + +You will need to update `WEBSITE_URL` to have the url of the website where you will be deploying and `BUNDLE_URL` to the relative path to the js bundle. + +This will ensure auto updates with a new version rolls out and the cache it stale. This will also allow for offline support after the first time it is downloaded. + +## Running  + +Now we can run our application but it requires a few steps to get it all setup. + +To test and build our web app locally we will use [vite](https://github.com/vitejs/vite) and render the `index.html` + +``` +npm i +npm run dev +``` + +You should see the following: + +``` +vite v2.2.3 dev server running at: + +Local: http://localhost:3000/flutter_lit_example/ +Network: http://192.168.1.143:3000/flutter_lit_example/ + +ready in 311ms. +``` + +We can open the link `http://localhost:3000/flutter_lit_example/` to see our running web app and hot reload changes from `my-app.ts`. + +If you want to learn more about Lit you can read the docs [here](https://lit.dev/). + +Once you are happy with how it looks we can move on to Flutter to wrap it in a native app. This will give us access to native code if we wanted to use the in app purchase api or push notifications. + +Kill the terminal and run the following: + +``` +flutter packages get +flutter build ios +flutter build appbundle +flutter run +``` + +This should select a running device or prompt you to select one. Now that it is running on the device you can see we have two way communication with the Flutter app and the web component. + +## Conclusion  + +If you want to find the source code you can check it out [here](https://github.com/rodydavis/flutter_hybrid_template) otherwise thanks for reading and let me know if you have any questions! \ No newline at end of file diff --git a/skills/flutter-and-xcode-cloud/SKILL.md b/skills/flutter-and-xcode-cloud/SKILL.md new file mode 100644 index 0000000..62d6586 --- /dev/null +++ b/skills/flutter-and-xcode-cloud/SKILL.md @@ -0,0 +1,131 @@ +--- +name: how-to-build-a-flutter-app-on-xcode-cloud +description: Learn how to set up Xcode Cloud to build and deploy your Flutter application to TestFlight and the App Store with this step-by-step guide. +metadata: + url: https://rodydavis.com/posts/flutter-and-xcode-cloud + last_modified: Tue, 03 Feb 2026 20:04:22 GMT +--- + +# How to build a Flutter app on Xcode Cloud + + +In this article we are going to go over how to setup [Xcode Cloud](https://developer.apple.com/xcode-cloud/) to build your [Flutter](https://flutter.dev/) application for [TestFlight](https://developer.apple.com/testflight/) and the [AppStore](https://developer.apple.com/app-store/). + +## Step 1  + +Before we begin Flutter needs to be installed, and you can check by running the following: + +``` +flutter doctor -v +``` + +After it is installed we can run the following command to create and open our Flutter project (skip down to step 2 if adding to an existing app). + +``` +mkdir flutter_ci_example +cd flutter_ci_example +flutter create . +``` + +If you need more help with creating the first project you can check out my previous blog post [here](https://rodydavis.com/posts/first-flutter-project/). + +After the project is created open it in your favorite code editor. + +``` +code . +``` + +## Step 2  + +The generated files should look like the following: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/pl153678e69f0qk/x_1_qw8btmibvc.webp?thumb=) + +Create a new file at `ios/ci_scripts/ci_post_install.sh` and update it with the following: + +``` +#!/bin/sh + +# Install CocoaPods using Homebrew. +brew install cocoapods + +# Install Flutter +brew install --cask flutter + +# Run Flutter doctor +flutter doctor + +# Get packages +flutter packages get + +# Update generated files +flutter pub run build_runner build + +# Build ios app +flutter build ios --no-codesign +``` + +This is a file Xcode Cloud needs to run after the project is downloaded. We need to install [cocoapods](https://cocoapods.org/) for any plugins we are using and Flutter to prebuild our application. + +Then run the following command which will make the script executable: + +``` +chmod +x ios/ci_scripts/ci_post_clone.sh +``` + +## Step 3  + +Open up the iOS project in Xcode by right clicking on the iOS folder and selecting "Open in Xcode". + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/u0cveo6hhi22s90/x_2_i34bfwz1u0.webp?thumb=) + +You can also open the project by double clicking on the `ios/Runner.xcworkspace` file. + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/day67g90vkws5q3/x_3_80qu3qrdlp.webp?thumb=) + +Make sure you have the latest version of Xcode Cloud install and that you have [access to the beta](https://developer.apple.com/xcode-cloud/beta/). Create a new workflow by the menu `Product > Xcode Cloud > Create Workflow`: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/qfz8667e0y89hkh/x_4_xsrdzbddjh.webp?thumb=) + +Follow the flow to add the project and choose which type of build you want. + +Make sure to remove MacOS as a target in the workflow by selecting `Archive - MacOS` and the delete icon on the top right. + +If you want to build and release the MacOS app you will need to do that with another script in the macos folder and a workflow in that Xcode workspace. + +You can create the file `macos/ci_scripts/ci_post_clone.sh` and update it with the following: + +``` +#!/bin/sh + +# Install CocoaPods using Homebrew. +brew install cocoapods + +# Install Flutter +brew install --cask flutter + +# Run Flutter doctor +flutter doctor + +# Enable macos +flutter config --enable-macos-desktop + +# Get packages +flutter packages get + +# Update generated files +flutter pub run build_runner build + +# Build ios app +flutter build ios --no-codesign +``` + +If all goes well it will look like the following after a successful build: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/6ubskgc91jv51ha/x_5_zgz4x31cbp.webp?thumb=) + +## Conclusion  + +Flutter makes it ease to build and deploy to multiple platforms and Xcode Cloud takes care of the signing for Apple platforms. + +You can learn more about cd and flutter [here](https://docs.flutter.dev/deployment/cd). \ No newline at end of file diff --git a/skills/flutter-cheat-sheet/SKILL.md b/skills/flutter-cheat-sheet/SKILL.md new file mode 100644 index 0000000..74f1e69 --- /dev/null +++ b/skills/flutter-cheat-sheet/SKILL.md @@ -0,0 +1,104 @@ +--- +name: flutter-terminal-cheat-sheet +description: This post provides a handy collection of Flutter commands and scripts for web development, package creation, troubleshooting, testing, and more, streamlining your Flutter workflow. +metadata: + url: https://rodydavis.com/posts/flutter-cheat-sheet + last_modified: Tue, 03 Feb 2026 20:04:23 GMT +--- + +# Flutter Terminal Cheat Sheet + + +## Run Flutter web with SKIA + +``` +flutter run -d web --release --dart-define=FLUTTER_WEB_USE_SKIA=true +``` + +## Run Flutter web with Canvas Kit + +``` +flutter run -d chrome --release --dart-define=FLUTTER_WEB_USE_EXPERIMENTAL_CANVAS_TEXT=true +``` + +## Build your Flutter web app to Github Pages to the docs folder + +``` +flutter build web && rm -rf ./docs && mkdir ./docs && cp -a ./build/web/. ./docs/ +``` + +## Clean rebuild CocoaPods + +``` +cd ios && pod deintegrate && pod cache clean —all && pod install && cd .. +``` + +> Sometimes with firebase you need to run: `pod update Firebase` + +## Create Dart package with Example + +``` +flutter create -t plugin . && flutter create -i swift -a kotlin --androidx example +``` + +## Watch Build Files + +``` +flutter packages pub run build_runner watch -—delete-conflicting-outputs +``` + +## Generate Build Files + +``` +flutter packages pub run build_runner build -—delete-conflicting-outputs +``` + +## Build Bug Report + +``` +flutter run —bug-report +``` + +## Flutter generate test coverage + +``` +flutter test --coverage && genhtml -o coverage coverage/lcov.info +``` + +## Rebuild Flutter Cache + +``` +flutter pub pub cache repair +``` + +## Clean every flutter project + +``` +find . -name "pubspec.yaml" -exec $SHELL -c ' + echo "Done. Cleaning all projects." + for i in "$@" ; do + DIR=$(dirname "${i}") + echo "Cleaning ${DIR}..." + (cd "$DIR" && flutter clean >/dev/null 2>&1) + done + echo "DONE!" +' {} + +``` + +## Conditional Export/Import + +``` +export 'unsupported.dart' + if (dart.library.html) 'web.dart' + if (dart.library.io) 'mobile.dart'; +``` + +## Kill Dart Running + +``` +killall -9 dart +``` + +## Flutter scripts  + +Add all the scripts to your `pubspec.yaml` with [flutter\_scripts](https://pub.dev/packages/flutter_scripts). \ No newline at end of file diff --git a/skills/flutter-graph-database/SKILL.md b/skills/flutter-graph-database/SKILL.md new file mode 100644 index 0000000..4f4bce2 --- /dev/null +++ b/skills/flutter-graph-database/SKILL.md @@ -0,0 +1,864 @@ +--- +name: how-to-build-a-graph-database-with-flutter +description: Learn how to build and utilize a graph database within your Flutter applications using SQLite and the Drift package to model relationships between data. +metadata: + url: https://rodydavis.com/posts/flutter-graph-database + last_modified: Tue, 03 Feb 2026 20:04:36 GMT +--- + +# How to build a graph database with Flutter + + +In this article I will go over how to create and use a graph database with [Flutter](https://flutter.dev/). + +**TLDR** The final source [here](https://github.com/rodydavis/flutter_graph_database) and an online [demo](https://rodydavis.github.io/flutter_graph_database/). + +## Prerequisites  + +Flutter installed and setup (Refer to this [article](https://rodydavis.com/posts/first-flutter-project/) if you need help). + +Basic knowledge of [SQLite](https://www.sqlite.org/index.html). + +Basic knowledge of Graph Databases (Refer to this [video](https://www.youtube.com/watch?v=GekQqFZm7mA) if you need to learn more). + +## Overview  + +First of all, why do we need a graph database when other storage options exist? + +Why not use key value stores, document stores, or relational databases? + +Well, the answer is that it depends on the problem you are trying to solve. + +Graph databases are great for modeling relationships between data. + +A couple examples: + +* A social network app can model the relationships between users and posts +* A game can model the relationships between players and items +* A blog can model the relationships between posts and comments + +The possibilities are endless. + +Instead of storing data in a table for each collection we store the data as a graph in a nodes and edges table with some additional extensions in SQLite to make it easier. + +Here is a [page](https://www.hytradboi.com/2022/simple-graph-sqlite-as-probably-the-only-graph-database-youll-ever-need) that goes in to detail about it and showcases what we are trying to build. + +## Getting Started  + +First we need to create a new Flutter project. + +``` +mkdir flutter_graph_database +cd flutter_graph_database +flutter create . +``` + +After the project is created open it in your favorite code editor. + +``` +code . +``` + +## Creating the Database  + +We are going to use the [drift](hhttps://pub.dev/packages/drift) package to create the database. + +Update the **pubspec.yaml** file with the following: + +``` +name: flutter_graph_database +description: A new Flutter package project. +version: 0.0.1 +publish_to: none + +environment: + sdk: ">=2.19.0-238.0.dev <3.0.0" + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + drift: ^2.1.0 + sqlite3_flutter_libs: ^0.5.5 + http: ^0.13.5 + path_provider: ^2.0.0 + path: ^1.8.2 + sqlite3: ^1.7.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + build_runner: ^2.2.0 + drift_dev: ^2.1.0 + +flutter: +``` + +### Database Connection  + +Next we need to create the database. + +Create a new file at **lib/database/connection/unsupported.dart** and update it with the following: + +``` +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; + +DatabaseConnection connect( + String dbName, { + bool useWebWorker = false, + bool logStatements = false, +}) { + return DatabaseConnection(NativeDatabase.memory( + logStatements: logStatements, + )); +} +``` + +Create a new file at **lib/database/connection/native.dart** and update it with the following: + +``` +import 'dart:io'; +import 'dart:isolate'; + +import 'package:drift/drift.dart'; +import 'package:drift/isolate.dart'; +import 'package:drift/native.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as p; + +DatabaseConnection connect( + String dbName, { + bool useWebWorker = false, + bool logStatements = false, +}) { + return DatabaseConnection.delayed(Future.sync(() async { + final appDir = await getApplicationDocumentsDirectory(); + final dbPath = p.join(appDir.path, dbName); + + final receiveDriftIsolate = ReceivePort(); + await Isolate.spawn(_entrypointForDriftIsolate, + _IsolateStartRequest(receiveDriftIsolate.sendPort, dbPath)); + + final driftIsolate = await receiveDriftIsolate.first as DriftIsolate; + return driftIsolate.connect(); + })); +} + +class _IsolateStartRequest { + final SendPort talkToMain; + final String databasePath; + + _IsolateStartRequest(this.talkToMain, this.databasePath); +} + +void _entrypointForDriftIsolate(_IsolateStartRequest request) { + final databaseImpl = NativeDatabase( + File(request.databasePath), + logStatements: false, + ); + + final driftServer = DriftIsolate.inCurrent( + () => DatabaseConnection(databaseImpl), + ); + + request.talkToMain.send(driftServer); +} +``` + +Create a new file at **lib/database/connection/web.dart** and update it with the following: + +``` +import 'dart:async'; + +// ignore: avoid_web_libraries_in_flutter +import 'dart:html'; + +import 'package:drift/drift.dart'; +import 'package:drift/remote.dart'; +import 'package:drift/web.dart'; +import 'package:drift/wasm.dart'; +import 'package:http/http.dart' as http; +import 'package:sqlite3/wasm.dart'; + +DatabaseConnection connect( + String dbName, { + bool useWebWorker = false, + bool logStatements = false, +}) { + if (useWebWorker) { + final worker = SharedWorker('shared_worker.dart.js'); + return remote(worker.port!.channel()); + } else { + return DatabaseConnection.delayed(Future.sync(() async { + final response = await http.get(Uri.parse('sqlite3.wasm')); + final fs = await IndexedDbFileSystem.open(dbName: '/db/'); + final path = '/drift/db/$dbName'; + final sqlite3 = await WasmSqlite3.load( + response.bodyBytes, + SqliteEnvironment(fileSystem: fs), + ); + final databaseImpl = WasmDatabase( + sqlite3: sqlite3, + path: path, + fileSystem: fs, // <- this is required but not documented + logStatements: logStatements, + ); + return DatabaseConnection(databaseImpl); + })); + } +} +``` + +Create a new file at **lib/database/connection/connection.dart** and update it with the following: + +``` +export 'unsupported.dart' + if (dart.library.js) 'web.dart' + if (dart.library.ffi) 'native.dart'; +``` + +### Database SQL Files  + +#### Schema  + +Create a new file at **lib/database/sql/schema.drift** and update it with the following: + +``` +CREATE TABLE IF NOT EXISTS nodes ( + body TEXT, + id TEXT GENERATED ALWAYS AS (json_extract(body, '$.id')) VIRTUAL NOT NULL UNIQUE +); + +CREATE INDEX IF NOT EXISTS id_idx ON nodes(id); + +CREATE TABLE IF NOT EXISTS edges ( + source TEXT, + target TEXT, + properties TEXT, + UNIQUE(source, target, properties) ON CONFLICT REPLACE, + FOREIGN KEY(source) REFERENCES nodes(id), + FOREIGN KEY(target) REFERENCES nodes(id) +); + +CREATE INDEX IF NOT EXISTS source_idx ON edges(source); +CREATE INDEX IF NOT EXISTS target_idx ON edges(target); +``` + +> The ID column is a virtual column that is generated from the body column. This is done so that we can query the database by ID without having to parse the JSON body column. + +#### Queries  + +Create a new file at **lib/database/sql/queries.drift** and update it with the following: + +``` +import 'schema.drift'; + +getAllNodes: + SELECT * FROM nodes; +getAllEdges: + SELECT * FROM edges; +``` + +#### Delete Edge  + +Create a new file at **lib/database/sql/delete-edge.drift** and update it with the following: + +``` +import 'schema.drift'; + +deleteEdge: + DELETE FROM edges + WHERE source = ? OR target = ?; +``` + +#### Delete Node  + +Create a new file at **lib/database/sql/delete-node.drift** and update it with the following: + +``` +import 'schema.drift'; + +deleteNode: + DELETE FROM nodes + WHERE id = ?; +``` + +#### Insert Edge  + +Create a new file at **lib/database/sql/insert-edge.drift** and update it with the following: + +``` +import 'schema.drift'; + +insertEdge(:source as TEXT, :target as TEXT, :body as TEXT): + INSERT INTO edges VALUES(:source, :target, json(:body)); +``` + +#### Search Edges Inbound  + +Create a new file at **lib/database/sql/search-edges-inbound.drift** and update it with the following: + +``` +import 'schema.drift'; + +searchEdgesInbound: + SELECT * FROM edges + WHERE source = ?; +``` + +#### Search Edges Outbound  + +Create a new file at **lib/database/sql/search-edges-outbound.drift** and update it with the following: + +``` +import 'schema.drift'; + +searchEdgesOutbound: + SELECT * FROM edges + WHERE target = ?; +``` + +#### Search Edges  + +Create a new file at **lib/database/sql/search-edges.drift** and update it with the following: + +``` +import 'schema.drift'; + +searchEdges: + SELECT * FROM edges WHERE source = ? + UNION + SELECT * FROM edges WHERE target = ?; +``` + +#### Search Node By ID  + +Create a new file at **lib/database/sql/search-node-by-id.drift** and update it with the following: + +``` +import 'schema.drift'; + +searchNodeById: + SELECT body FROM nodes + WHERE id = ?; +``` + +#### Search Node  + +Create a new file at **lib/database/sql/search-node.drift** and update it with the following: + +``` +import 'schema.drift'; + +-- Create a text index of entries, see https://www.sqlite.org/fts5.html#external_content_tables +CREATE VIRTUAL TABLE node_entries USING fts5 ( + body, + content=nodes, + content_rowid=id +); + +-- Triggers to keep entries and fts5 index in sync. +CREATE TRIGGER nodes_insert AFTER INSERT ON nodes BEGIN + INSERT INTO node_entries(rowid, body) VALUES (new.id, new.body); +END; + +CREATE TRIGGER nodes_delete AFTER DELETE ON nodes BEGIN + INSERT INTO node_entries(node_entries, rowid, body) VALUES ('delete', old.id, old.body); +END; + +CREATE TRIGGER nodes_update AFTER UPDATE ON nodes BEGIN + INSERT INTO node_entries(node_entries, rowid, body) VALUES ('delete', new.id, new.body); + INSERT INTO node_entries(rowid, body) VALUES (new.id, new.body); +END; + +-- Full text search query. +searchNode: SELECT r.** FROM node_entries + INNER JOIN nodes r ON r.id = node_entries.rowid + WHERE node_entries MATCH :query + ORDER BY rank; +``` + +> Here we are using the [fts5](https://www.sqlite.org/fts5.html) extension to create a full text search index. This is a very powerful feature that allows us to search for nodes by their body text. + +#### Traverse Inbound  + +Create a new file at **lib/database/sql/traverse-inbound.drift** and update it with the following: + +``` +import 'schema.drift'; + +traverseInbound(:source AS TEXT): + WITH RECURSIVE traverse(id) AS ( + SELECT :source + UNION + SELECT source FROM edges JOIN traverse ON target = id +) SELECT id FROM traverse; +``` + +#### Traverse Outbound  + +Create a new file at **lib/database/sql/traverse-outbound.drift** and update it with the following: + +``` +import 'schema.drift'; + +traverseOutbound(:source AS TEXT): + WITH RECURSIVE traverse(id) AS ( + SELECT :source + UNION + SELECT target FROM edges JOIN traverse ON source = id +) SELECT id FROM traverse; +``` + +#### Traverse Bodies Inbound  + +Create a new file at **lib/database/sql/traverse-with-bodies-inbound.drift** and update it with the following: + +``` +import 'schema.drift'; + +traverseWithBodiesInbound(:source AS TEXT): + WITH RECURSIVE traverse(x, y, obj) AS ( + SELECT :source, '()', '{}' + UNION + SELECT id, '()', body FROM nodes JOIN traverse ON id = x + UNION + SELECT source, '<-', properties FROM edges JOIN traverse ON target = x +) SELECT x, y, obj FROM traverse; +``` + +#### Traverse Bodies Outbound  + +Create a new file at **lib/database/sql/traverse-with-bodies-outbound.drift** and update it with the following: + +``` +import 'schema.drift'; + +traverseWithBodiesOutbound(:source AS TEXT): + WITH RECURSIVE traverse(x, y, obj) AS ( + SELECT :source, '()', '{}' + UNION + SELECT id, '()', body FROM nodes JOIN traverse ON id = x + UNION + SELECT target, '->', properties FROM edges JOIN traverse ON source = x +) SELECT x, y, obj FROM traverse; +``` + +#### Traverse Bodies  + +Create a new file at **lib/database/sql/traverse-bodies.drift** and update it with the following: + +``` +import 'schema.drift'; + +traverseWithBodies(:source AS TEXT): + WITH RECURSIVE traverse(x, y, obj) AS ( + SELECT :source, '()', '{}' + UNION + SELECT id, '()', body FROM nodes JOIN traverse ON id = x + UNION + SELECT source, '<-', properties FROM edges JOIN traverse ON target = x + UNION + SELECT target, '->', properties FROM edges JOIN traverse ON source = x +) SELECT x, y, obj FROM traverse; +``` + +#### Traverse  + +Create a new file at **lib/database/sql/traverse.drift** and update it with the following: + +``` +import 'schema.drift'; + +traverse(:source AS TEXT): + WITH RECURSIVE traverse(id) AS ( + SELECT :source + UNION + SELECT source FROM edges JOIN traverse ON target = id + UNION + SELECT target FROM edges JOIN traverse ON source = id +) SELECT id FROM traverse; +``` + +#### Update Node  + +Create a new file at **lib/database/sql/update-node.drift** and update it with the following: + +``` +import 'schema.drift'; + +updateNode: + UPDATE nodes SET body = json(?) + WHERE id = ?; +``` + +### Database Setup  + +Create a new file at **lib/database/database.dart** and update it with the following: + +``` +import 'dart:convert'; + +import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart'; + +import 'connection/connection.dart' as impl; + +part 'database.g.dart'; + +@DriftDatabase(include: { + 'sql/schema.drift', + 'sql/queries.drift', + 'sql/delete-edge.drift', + 'sql/delete-node.drift', + 'sql/insert-edge.drift', + 'sql/insert-node.drift', + 'sql/search-edges-inbound.drift', + 'sql/search-edges-outbound.drift', + 'sql/search-edges.drift', + 'sql/search-node-by-id.drift', + 'sql/search-node.drift', + 'sql/traverse-inbound.drift', + 'sql/traverse-outbound.drift', + 'sql/traverse-with-bodies-inbound.drift', + 'sql/traverse-with-bodies-outbound.drift', + 'sql/traverse-with-bodies.drift', + 'sql/traverse.drift', + 'sql/update-node.drift', +}) +class GraphDatabase extends _$GraphDatabase { + GraphDatabase({ + String dbName = 'graph_db.db', + DatabaseConnection? connection, + bool useWebWorker = false, + bool logStatements = false, + }) : super.connect( + connection ?? + impl.connect( + dbName, + useWebWorker: useWebWorker, + logStatements: logStatements, + ), + ); + + @override + int get schemaVersion => 1; + + /// Helper method to add graph data from json + Future addGraphData( + Map data, { + bool shouldBatch = false, + }) { + return transaction(() async { + try { + final localNodes = data['nodes'] as List; + final localEdges = data['edges'] as List; + // Update nodes + for (final node in localNodes) { + final id = node['id'] as String?; + if (id != null) { + final current = await searchNodeById(id).getSingleOrNull(); + final body = jsonEncode(node); + if (current != null) { + await updateNode(id, body); + } else { + await insertNode(body); + } + } + } + // Update edges + for (final edge in localEdges) { + final source = edge['from'] ?? edge['source'] as String?; + final target = edge['to'] ?? edge['target'] as String?; + if (source != null && target != null) { + final body = jsonEncode(edge); + await insertEdge(source, target, body); + } + } + } catch (e) { + debugPrint('Error adding graph data: $e'); + } + }); + } + + Future deleteAll() { + return transaction(() async { + try { + await deleteAllEdges(); + await deleteAllNodes(); + } catch (e) { + debugPrint('Error clearing graph data: $e'); + } + }); + } + + Future deleteAllEdges() { + return transaction(() async { + final edges = await getAllEdges().get(); + for (final edge in edges) { + await deleteEdge(edge.source, edge.target); + } + }); + } + + Future deleteAllNodes() { + return transaction(() async { + final nodes = await getAllNodes().get(); + for (final node in nodes) { + await deleteNode(node.id); + } + }); + } +} +``` + +Create a new file at **build.yaml** and update it with the following: + +``` +targets: + $default: + sources: + - lib/** + - web/** + - "tool/**" + - pubspec.yaml + - lib/$lib$ + - $package$ + builders: + drift_dev: + options: + sql: + dialect: sqlite + options: + version: "3.38" + modules: + - json1 + - fts5 + generate_connect_constructor: true + apply_converters_on_variables: true + generate_values_in_copy_with: true + scoped_dart_components: true +``` + +Now run the following command to generate the database files: + +``` +flutter pub run build_runner build --delete-conflicting-outputs +``` + +## Connecting to the Database  + +Add a new dependency to your **pubspec.yaml** file: + +``` +flutter pub add graphview +``` + +This will be used for the graph visualization. + +Create a new file at **lib/main.dart** and update it with the following: + +``` +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_graph_database/flutter_graph_database.dart' as db; +import 'package:graphview/GraphView.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Graph Database', + debugShowCheckedModeBanner: false, + theme: ThemeData.dark(), + home: const Example(), + ); + } +} + +class Example extends StatefulWidget { + const Example({Key? key}) : super(key: key); + + @override + State createState() => _ExampleState(); +} + +class _ExampleState extends State { + final database = db.GraphDatabase(); + Graph graph = Graph(); + Algorithm builder = FruchtermanReingoldAlgorithm(); + + final nodes = {}; + bool loaded = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => loadData()); + } + + @override + void reassemble() { + super.reassemble(); + // Needed to reset graph on hot reload + loadData(); + } + + void setLoadedState(bool value) { + if (mounted) { + setState(() { + loaded = value; + }); + } + } + + Future addDummyData() async { + // Load example data + try { + // Optionally reset data + await database.deleteAll(); + // Add example data to database + await database.addGraphData({ + "nodes": [ + {"id": '1', "label": 'circle'}, + {"id": '2', "label": 'ellipse'}, + {"id": '3', "label": 'database'}, + {"id": '4', "label": 'box'}, + {"id": '5', "label": 'diamond'}, + {"id": '6', "label": 'dot'}, + {"id": '7', "label": 'square'}, + {"id": '8', "label": 'triangle'}, + {"id": '9', "label": "star"}, + ], + "edges": [ + {"from": '1', "to": '2'}, + {"from": '2', "to": '3'}, + {"from": '2', "to": '4'}, + {"from": '2', "to": '5'}, + {"from": '5', "to": '6'}, + {"from": '5', "to": '7'}, + {"from": '6', "to": '8'}, + {"from": '2', "to": '8'}, + {"from": '1', "to": '8'}, + {"from": '1', "to": '7'}, + {"from": '1', "to": '6'}, + {"from": '1', "to": '5'}, + {"from": '1', "to": '4'}, + {"from": '1', "to": '3'}, + {"from": '1', "to": '9'}, + {"from": '9', "to": '8'}, + {"from": '9', "to": '5'}, + {"from": '9', "to": '3'}, + ] + }); + loadData(); + } catch (e) { + debugPrint('Error loading example data: $e'); + } + } + + Future loadData() async { + setLoadedState(false); + + final nodeMap = {}; + this.nodes.clear(); + graph = Graph(); + builder = FruchtermanReingoldAlgorithm(); + + // Load graph data + final nodes = await database.getAllNodes().get(); + final edges = await database.getAllEdges().get(); + + for (final node in nodes) { + final newNode = Node.Id(node.id); + nodeMap[node.id] = newNode; + this.nodes[node.id] = node; + graph.addNode(newNode); + } + for (final edge in edges) { + final source = nodeMap[edge.source]; + final target = nodeMap[edge.target]; + if (source != null && target != null) { + graph.addEdge(source, target); + } + } + + setLoadedState(true); + } + + Widget buildNode(Node node) { + final dbNode = nodes[node.key!.value]; + final data = jsonDecode(dbNode?.body ?? '{}') as Map; + final label = data['label'] ?? ''; + return SizedBox( + width: 80, + height: 80, + child: Center( + child: Text( + label, + textAlign: TextAlign.center, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Flutter Graph Database'), + actions: [ + IconButton( + icon: const Icon(Icons.restore), + onPressed: addDummyData, + ), + ], + ), + body: !loaded + ? const Center(child: CircularProgressIndicator()) + : nodes.isEmpty + ? const Center(child: Text('No Data Loaded')) + : LayoutBuilder(builder: (context, dimens) { + return SizedBox.expand( + child: InteractiveViewer( + constrained: false, + boundaryMargin: EdgeInsets.symmetric( + horizontal: dimens.maxWidth * 0.75, + vertical: dimens.maxHeight * 0.75, + ), + minScale: 0.01, + maxScale: 5.6, + child: GraphView( + key: UniqueKey(), + graph: graph, + algorithm: builder, + paint: Paint() + ..color = Colors.green + ..strokeWidth = 1 + ..style = PaintingStyle.stroke, + builder: buildNode, + ), + ), + ); + }), + ); + } +} +``` + +When you run the flutter app you should see the following: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/br82944o71nzm81/graph_flutter_final_yikh8roctq.webp?thumb=) + +## Conclusion  + +If you want to learn more about building a graph database in Flutter, check out the [source code](https://github.com/rodydavis/flutter_graph_database). \ No newline at end of file diff --git a/skills/flutter-multi-touch-canvas/SKILL.md b/skills/flutter-multi-touch-canvas/SKILL.md new file mode 100644 index 0000000..18a3352 --- /dev/null +++ b/skills/flutter-multi-touch-canvas/SKILL.md @@ -0,0 +1,1833 @@ +--- +name: multi-touch-canvas-with-flutter +description: Learn how to create a Flutter canvas with multi-touch support for panning, zooming, and object interaction, overcoming common gesture recognition conflicts. +metadata: + url: https://rodydavis.com/posts/flutter-multi-touch-canvas + last_modified: Tue, 03 Feb 2026 20:04:17 GMT +--- + +# Multi-touch Canvas with Flutter + + +If you ever wanted to create a canvas in [Flutter](https://flutter.dev/) that needs to be panned in any direction and allow zoom then you also probably tried to create a [MultiGestureRecognizer](https://api.flutter.dev/flutter/gestures/MultiDragGestureRecognizer-class.html) or under a [GestureDetector](https://api.flutter.dev/flutter/widgets/GestureDetector-class.html) added onPanUpdate and onScaleUpdate and received an error because both can not work at the same time. Even if you have to GestureDetectors then you will still find it does not work how you want and one will always win. + +> **TLDR** The final source [here](https://github.com/rodydavis/flutter_multi_touch_canvas) and an online [demo](https://rodydavis.github.io/flutter_multi_touch_canvas/). + +This is the canvas rendering logic used in [https://widget.studio](https://widget.studio/) + +## Multi Touch Goal  + +* Pan the canvas with two or more fingers +* Zoom the canvas with two fingers only (Pinch/Zoom) +* Single finger will interact with canvas object and detect selection +* Bonus trackpad support with similar results + +In order to achieve this we need to use a Listener for the trackpad events and raw touch interactions and [RawKeyboardListener](https://api.flutter.dev/flutter/widgets/RawKeyboardListener-class.html) for keyboard shortcuts. + +## Part 1 - Project Setup  + +Open your terminal and type the following: + +``` +mkdir flutter_multi_touch +cd flutter_multi_touch +flutter create . +code . +``` + +The last line is optional and if you have VSCode installed. The command will open the directory inside VSCode. + +## Part 2 - Boilerplate  + +* Remove all comments +* Remove extra empty lines +* Update UI + +Right now when you run the project you will have this UI. + +Create a new file located at `ui/home/screen.dart` and add the following: + +``` +import 'package:flutter/material.dart'; + +class HomeScreen extends StatelessWidget { + const HomeScreen({Key key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container(); + } +} +``` + +Update `main.dart` with the following: + +``` +import 'package:flutter/material.dart'; + +import 'ui/home/screen.dart'; + +void main() => runApp(MyApp()); + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'Flutter Demo', + theme: ThemeData( + primarySwatch: Colors.blue, + visualDensity: VisualDensity.adaptivePlatformDensity, + ), + darkTheme: ThemeData.dark().copyWith( + visualDensity: VisualDensity.adaptivePlatformDensity, + ), + home: HomeScreen(), + ); + } +} +``` + +You will now have a black screen when you run the application. + +## Part 3 - Creating the Controller  + +Now we want to create a class that will act as our controller on the canvas. +Create a new file at `src/controllers/canvas.dart` and add the following to start: + +``` +import 'dart:async'; + +/// Control the canvas and the objects on it +class CanvasController { + // Controller for the stream output + final _controller = StreamController(); + // Reference to the stream to update the UI + Stream get stream => _controller.stream; + // Emit a new event to rebuild the UI + void add([CanvasController val]) => _controller.add(val ?? this); + // Stop the stream and finish + void close() => _controller.close(); + // Start the stream + void init() => add(); +} +``` + +Update the home screen with the following: + +``` +import 'package:flutter/material.dart'; + +import '../../src/controllers/canvas.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({Key key}) : super(key: key); + + @override + _HomeScreenState createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + final _controller = CanvasController(); + + @override + void initState() { + _controller.init(); + super.initState(); + } + + @override + void dispose() { + _controller.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: _controller.stream, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Scaffold( + appBar: AppBar(), + body: Center(child: CircularProgressIndicator()), + ); + } + final instance = snapshot.data; + return Scaffold( + appBar: AppBar(), + body: Stack( + children: [ + Positioned( + top: 20, + left: 20, + width: 100, + height: 100, + child: Container(color: Colors.red), + ) + ], + ), + ); + }); + } +} +``` + +Here we are just adding the basics to rebuild when the controller changes or the screen is finished. We are using a stateful widget here because we want to dispose of the controller and load it only once. We are also using a stack because thats all we need under the hood. After a quick hot restart you should have the following view. + +## Part 4 - Adding Canvas Objects  + +Now we need to create the class for the objects that will be stored on the canvas. Create a new file at `src/classes/canvas_object.dart` and add the following: + +``` +import 'dart:ui'; + +class CanvasObject { + final double dx; + final double dy; + final double width; + final double height; + final T child; + + CanvasObject({ + this.dx = 0, + this.dy = 0, + this.width = 100, + this.height = 100, + this.child, + }); + + CanvasObject copyWith({ + double dx, + double dy, + double width, + double height, + T child, + }) { + return CanvasObject( + dx: dx ?? this.dx, + dy: dy ?? this.dy, + width: width ?? this.width, + height: height ?? this.height, + child: child ?? this.child, + ); + } + + Size get size => Size(width, height); + Offset get offset => Offset(dx, dy); + Rect get rect => offset & size; +} +``` + +We are using a generic here to not depend on flutter or material in the class. Update the controller with the following: + +``` +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../classes/canvas_object.dart'; + +/// Control the canvas and the objects on it +class CanvasController { + /// Controller for the stream output + final _controller = StreamController(); + + /// Reference to the stream to update the UI + Stream get stream => _controller.stream; + + /// Emit a new event to rebuild the UI + void add([CanvasController val]) => _controller.add(val ?? this); + + /// Stop the stream and finish + void close() => _controller.close(); + + /// Start the stream + void init() => add(); + + // -- Canvas Objects -- + + final List> _objects = []; + + /// Current Objects on the canvas + List> get objects => _objects; + + /// Add an object to the canvas + void addObject(CanvasObject value) => _update(() { + _objects.add(value); + }); + + /// Add an object to the canvas + void updateObject(int i, CanvasObject value) => _update(() { + _objects[i] = value; + }); + + /// Remove an object from the canvas + void removeObject(int i) => _update(() { + _objects.removeAt(i); + }); + + void _update(void Function() action) { + action(); + add(this); + } +} +``` + +We are just adding the objects to the canvas and removing them if needed. Update the home screen with the following to use these new objects: + +``` +import 'package:flutter/material.dart'; + +import '../../src/classes/canvas_object.dart'; +import '../../src/controllers/canvas.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({Key key}) : super(key: key); + + @override + _HomeScreenState createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + final _controller = CanvasController(); + + @override + void initState() { + _controller.init(); + _dummyData(); + super.initState(); + } + + void _dummyData() { + _controller.addObject( + CanvasObject( + dx: 20, + dy: 20, + width: 100, + height: 100, + child: Container(color: Colors.red), + ), + ); + } + + @override + void dispose() { + _controller.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: _controller.stream, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Scaffold( + appBar: AppBar(), + body: Center(child: CircularProgressIndicator()), + ); + } + final instance = snapshot.data; + return Scaffold( + appBar: AppBar(), + body: Stack( + children: [ + for (final object in instance.objects) + Positioned( + top: object.dy, + left: object.dx, + width: object.width, + height: object.height, + child: object.child, + ) + ], + ), + ); + }); + } +} +``` + +The UI is thee same as before but now is dynamic and we have access to the Stack children and position of each child. + +## Part 5 - Capture the Input  + +We need to capture the input of the MultiGestureRecognizer, GestureDetector and RawKeyboardListener. Update the canvas controller with the following: + +``` +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../classes/canvas_object.dart'; + +/// Control the canvas and the objects on it +class CanvasController { + /// Controller for the stream output + final _controller = StreamController(); + + /// Reference to the stream to update the UI + Stream get stream => _controller.stream; + + /// Emit a new event to rebuild the UI + void add([CanvasController val]) => _controller.add(val ?? this); + + /// Stop the stream and finish + void close() { + _controller.close(); + focusNode.dispose(); + } + + /// Start the stream + void init() => add(); + + // -- Canvas Objects -- + + final List> _objects = []; + + /// Current Objects on the canvas + List> get objects => _objects; + + /// Add an object to the canvas + void addObject(CanvasObject value) => _update(() { + _objects.add(value); + }); + + /// Add an object to the canvas + void updateObject(int i, CanvasObject value) => _update(() { + _objects[i] = value; + }); + + /// Remove an object from the canvas + void removeObject(int i) => _update(() { + _objects.removeAt(i); + }); + + /// Focus node for listening for keyboard shortcuts + final focusNode = FocusNode(); + + /// Raw events from keys pressed + void rawKeyEvent(BuildContext context, RawKeyEvent key) {} + + /// Called every time a new finger touches the screen + void addTouch(int pointer, Offset offsetVal, Offset globalVal) {} + + /// Called when any of the fingers update position + void updateTouch(int pointer, Offset offsetVal, Offset globalVal) {} + + /// Called when a finger is removed from the screen + void removeTouch(int pointer) {} + + /// Checks if the shift key on the keyboard is pressed + bool shiftPressed = false; + + /// Scale of the canvas + double get scale => _scale; + double _scale = 1; + set scale(double value) => _update(() { + _scale = value; + }); + + /// Max possible scale + static const double maxScale = 3.0; + /// Min possible scale + static const double minScale = 0.2; + /// How much to scale the canvas in increments + static const double scaleAdjust = 0.05; + + /// Current offset of the canvas + Offset get offset => _offset; + Offset _offset = Offset.zero; + set offset(Offset value) => _update(() { + _offset = value; + }); + + void _update(void Function() action) { + action(); + add(this); + } +} +``` + +Update the home screen with the following: + +``` +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import '../../src/classes/canvas_object.dart'; +import '../../src/controllers/canvas.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({Key key}) : super(key: key); + + @override + _HomeScreenState createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + final _controller = CanvasController(); + + @override + void initState() { + _controller.init(); + _dummyData(); + super.initState(); + } + + void _dummyData() { + _controller.addObject( + CanvasObject( + dx: 20, + dy: 20, + width: 100, + height: 100, + child: Container(color: Colors.red), + ), + ); + } + + @override + void dispose() { + _controller.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: _controller.stream, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Scaffold( + appBar: AppBar(), + body: Center(child: CircularProgressIndicator()), + ); + } + final instance = snapshot.data; + return Scaffold( + appBar: AppBar(), + body: Listener( + behavior: HitTestBehavior.opaque, + onPointerSignal: (details) { + if (details is PointerScrollEvent) { + GestureBinding.instance.pointerSignalResolver + .register(details, (event) { + if (event is PointerScrollEvent) { + if (_controller.shiftPressed) { + double zoomDelta = (-event.scrollDelta.dy / 300); + _controller.scale = _controller.scale + zoomDelta; + } else { + _controller.offset = + _controller.offset - event.scrollDelta; + } + } + }); + } + }, + onPointerMove: (details) { + _controller.updateTouch( + details.pointer, + details.localPosition, + details.position, + ); + }, + onPointerDown: (details) { + _controller.addTouch( + details.pointer, + details.localPosition, + details.position, + ); + }, + onPointerUp: (details) { + _controller.removeTouch(details.pointer); + }, + onPointerCancel: (details) { + _controller.removeTouch(details.pointer); + }, + child: RawKeyboardListener( + autofocus: true, + focusNode: _controller.focusNode, + onKey: (key) => _controller.rawKeyEvent(context, key), + child: Stack( + children: [ + for (final object in instance.objects) + Positioned( + top: object.dy, + left: object.dx, + width: object.width, + height: object.height, + child: object.child, + ) + ], + ), + ), + ), + ); + }); + } +} +``` + +All we are doing now is just mapping the inputs of the UI to the actions in the controller. Feel free to look through the comments if you are curious how each one works. Running the application should still just show the red square. + +## Part 5 - Canvas Offset and Scale  + +Now we want to start moving the canvas. Let’s first tackle the offset as scale will take a different approach. Update the home screen with the following: + +``` +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import '../../src/classes/canvas_object.dart'; +import '../../src/controllers/canvas.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({Key key}) : super(key: key); + + @override + _HomeScreenState createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + final _controller = CanvasController(); + + @override + void initState() { + _controller.init(); + _dummyData(); + super.initState(); + } + + void _dummyData() { + _controller.addObject( + CanvasObject( + dx: 20, + dy: 20, + width: 100, + height: 100, + child: Container(color: Colors.red), + ), + ); + } + + @override + void dispose() { + _controller.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: _controller.stream, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Scaffold( + appBar: AppBar(), + body: Center(child: CircularProgressIndicator()), + ); + } + final instance = snapshot.data; + return Scaffold( + appBar: AppBar(), + body: Listener( + behavior: HitTestBehavior.opaque, + onPointerSignal: (details) { + if (details is PointerScrollEvent) { + GestureBinding.instance.pointerSignalResolver + .register(details, (event) { + if (event is PointerScrollEvent) { + if (_controller.shiftPressed) { + double zoomDelta = (-event.scrollDelta.dy / 300); + _controller.scale = _controller.scale + zoomDelta; + } else { + _controller.offset = + _controller.offset - event.scrollDelta; + } + } + }); + } + }, + onPointerMove: (details) { + _controller.updateTouch( + details.pointer, + details.localPosition, + details.position, + ); + }, + onPointerDown: (details) { + _controller.addTouch( + details.pointer, + details.localPosition, + details.position, + ); + }, + onPointerUp: (details) { + _controller.removeTouch(details.pointer); + }, + onPointerCancel: (details) { + _controller.removeTouch(details.pointer); + }, + child: RawKeyboardListener( + autofocus: true, + focusNode: _controller.focusNode, + onKey: (key) => _controller.rawKeyEvent(context, key), + child: SizedBox.expand( + child: Stack( + children: [ + for (final object in instance.objects) + AnimatedPositioned.fromRect( + duration: const Duration(milliseconds: 50), + rect: object.rect.adjusted( + _controller.offset, + _controller.scale, + ), + child: FittedBox( + fit: BoxFit.fill, + child: SizedBox.fromSize( + size: object.size, + child: object.child, + ), + ), + ) + ], + ), + ), + ), + ), + ); + }); + } +} + +extension RectUtils on Rect { + Rect adjusted(Offset offset, double scale) { + final left = (this.left + offset.dx) * scale; + final top = (this.top + offset.dy) * scale; + final width = this.width * scale; + final height = this.height * scale; + return Rect.fromLTWH(left, top, width, height); + } +} +``` + +Now when you use your trackpad to pan with two fingers you will see the red square move. We now need to add finger support too. You may notice the FittedBox and that will come in as soon as we add scaling. + +Now if we move the square off the screen we may need to bring it back. We can add a reset button to the AppBar. Add the following to the canvas controller: + +``` + static const double _scaleDefault = 1; + static const Offset _offsetDefault = Offset.zero; + + void reset() { + scale = _scaleDefault; + offset = _offsetDefault; + } +``` + +Update the home screen with the following: + +``` +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import '../../src/classes/canvas_object.dart'; +import '../../src/controllers/canvas.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({Key key}) : super(key: key); + + @override + _HomeScreenState createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + final _controller = CanvasController(); + + @override + void initState() { + _controller.init(); + _dummyData(); + super.initState(); + } + + void _dummyData() { + _controller.addObject( + CanvasObject( + dx: 20, + dy: 20, + width: 100, + height: 100, + child: Container(color: Colors.red), + ), + ); + } + + @override + void dispose() { + _controller.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: _controller.stream, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Scaffold( + appBar: AppBar(), + body: Center(child: CircularProgressIndicator()), + ); + } + final instance = snapshot.data; + return Scaffold( + appBar: AppBar( + actions: [ + IconButton( + tooltip: 'Reset the Scale and Offset', + icon: Icon(Icons.restore), + onPressed: _controller.reset, + ), + ], + ), + body: Listener( + behavior: HitTestBehavior.opaque, + onPointerSignal: (details) { + if (details is PointerScrollEvent) { + GestureBinding.instance.pointerSignalResolver + .register(details, (event) { + if (event is PointerScrollEvent) { + if (_controller.shiftPressed) { + double zoomDelta = (-event.scrollDelta.dy / 300); + _controller.scale = _controller.scale + zoomDelta; + } else { + _controller.offset = + _controller.offset - event.scrollDelta; + } + } + }); + } + }, + onPointerMove: (details) { + _controller.updateTouch( + details.pointer, + details.localPosition, + details.position, + ); + }, + onPointerDown: (details) { + _controller.addTouch( + details.pointer, + details.localPosition, + details.position, + ); + }, + onPointerUp: (details) { + _controller.removeTouch(details.pointer); + }, + onPointerCancel: (details) { + _controller.removeTouch(details.pointer); + }, + child: RawKeyboardListener( + autofocus: true, + focusNode: _controller.focusNode, + onKey: (key) => _controller.rawKeyEvent(context, key), + child: SizedBox.expand( + child: Stack( + children: [ + for (final object in instance.objects) + AnimatedPositioned.fromRect( + duration: const Duration(milliseconds: 50), + rect: object.rect.adjusted( + _controller.offset, + _controller.scale, + ), + child: FittedBox( + fit: BoxFit.fill, + child: SizedBox.fromSize( + size: object.size, + child: object.child, + ), + ), + ) + ], + ), + ), + ), + ), + ); + }); + } +} + +extension RectUtils on Rect { + Rect adjusted(Offset offset, double scale) { + final left = (this.left + offset.dx) * scale; + final top = (this.top + offset.dy) * scale; + final width = this.width * scale; + final height = this.height * scale; + return Rect.fromLTWH(left, top, width, height); + } +} +``` + +Now when you press the reset button the canvas animates back to the default offset and scale. + +While we are here we can add actions for zoom in/out and connect them to the controller. Add the following to the canvas controller: + +``` + void zoomIn() { + scale += scaleAdjust; + } + + void zoomOut() { + scale -= scaleAdjust; + } +``` + +Add the following to the AppBar actions: + +``` +IconButton( + tooltip: 'Zoom In', + icon: Icon(Icons.zoom_in), + onPressed: _controller.zoomIn, + ), +IconButton( + tooltip: 'Zoom Out', + icon: Icon(Icons.zoom_out), + onPressed: _controller.zoomOut, +), +``` + +Now when you run the application you can easily zoom in/out. + +## Part 6 - Keyboard Shortcuts  + +Now we need to capture the keyboard events so we can move the canvas with the arrow keys and scale with +/- keys. Update the controller with the following: + +``` +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../classes/canvas_object.dart'; + +/// Control the canvas and the objects on it +class CanvasController { + /// Controller for the stream output + final _controller = StreamController(); + + /// Reference to the stream to update the UI + Stream get stream => _controller.stream; + + /// Emit a new event to rebuild the UI + void add([CanvasController val]) => _controller.add(val ?? this); + + /// Stop the stream and finish + void close() { + _controller.close(); + focusNode.dispose(); + } + + /// Start the stream + void init() => add(); + + // -- Canvas Objects -- + + final List> _objects = []; + + /// Current Objects on the canvas + List> get objects => _objects; + + /// Add an object to the canvas + void addObject(CanvasObject value) => _update(() { + _objects.add(value); + }); + + /// Add an object to the canvas + void updateObject(int i, CanvasObject value) => _update(() { + _objects[i] = value; + }); + + /// Remove an object from the canvas + void removeObject(int i) => _update(() { + _objects.removeAt(i); + }); + + /// Focus node for listening for keyboard shortcuts + final focusNode = FocusNode(); + + /// Raw events from keys pressed + void rawKeyEvent(BuildContext context, RawKeyEvent key) { + // Scale keys + if (key.isKeyPressed(LogicalKeyboardKey.minus)) { + zoomOut(); + } + if (key.isKeyPressed(LogicalKeyboardKey.equal)) { + zoomIn(); + } + // Directional Keys + if (key.isKeyPressed(LogicalKeyboardKey.arrowLeft)) { + offset = offset + Offset(offsetAdjust, 0.0); + } + if (key.isKeyPressed(LogicalKeyboardKey.arrowRight)) { + offset = offset + Offset(-offsetAdjust, 0.0); + } + if (key.isKeyPressed(LogicalKeyboardKey.arrowUp)) { + offset = offset + Offset(0.0, offsetAdjust); + } + if (key.isKeyPressed(LogicalKeyboardKey.arrowDown)) { + offset = offset + Offset(0.0, -offsetAdjust); + } + + _shiftPressed = key.isShiftPressed; + + /// Update Controller Instance + add(this); + } + + /// Called every time a new finger touches the screen + void addTouch(int pointer, Offset offsetVal, Offset globalVal) {} + + /// Called when any of the fingers update position + void updateTouch(int pointer, Offset offsetVal, Offset globalVal) {} + + /// Called when a finger is removed from the screen + void removeTouch(int pointer) {} + + /// Checks if the shift key on the keyboard is pressed + bool get shiftPressed => _shiftPressed; + bool _shiftPressed = false; + + /// Scale of the canvas + double get scale => _scale; + double _scale = 1; + set scale(double value) => _update(() { + _scale = value; + }); + + /// Max possible scale + static const double maxScale = 3.0; + + /// Min possible scale + static const double minScale = 0.2; + + /// How much to scale the canvas in increments + static const double scaleAdjust = 0.05; + + /// How much to shift the canvas in increments + static const double offsetAdjust = 15; + + /// Current offset of the canvas + Offset get offset => _offset; + Offset _offset = Offset.zero; + set offset(Offset value) => _update(() { + _offset = value; + }); + + static const double _scaleDefault = 1; + static const Offset _offsetDefault = Offset.zero; + + /// Reset the canvas zoom and offset + void reset() { + scale = _scaleDefault; + offset = _offsetDefault; + } + + /// Zoom in the canvas + void zoomIn() { + scale += scaleAdjust; + } + + /// Zoom out the canvas + void zoomOut() { + scale -= scaleAdjust; + } + + void _update(void Function() action) { + action(); + add(this); + } +} +``` + +Now when you run the application you can control the zoom and pan with just a keyboard. This could be useful for a fallback input that would work on a TV for example… + +If you want to see if it is actually scaling proportionally then add the following the home screen: + +``` +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import '../../src/classes/canvas_object.dart'; +import '../../src/controllers/canvas.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({Key key}) : super(key: key); + + @override + _HomeScreenState createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + final _controller = CanvasController(); + + @override + void initState() { + _controller.init(); + _dummyData(); + super.initState(); + } + + void _dummyData() { + _controller.addObject( + CanvasObject( + dx: 20, + dy: 20, + width: 100, + height: 100, + child: Container(color: Colors.red), + ), + ); + _controller.addObject( + CanvasObject( + dx: 80, + dy: 60, + width: 100, + height: 200, + child: Container(color: Colors.green), + ), + ); + _controller.addObject( + CanvasObject( + dx: 100, + dy: 40, + width: 100, + height: 50, + child: Container(color: Colors.blue), + ), + ); + } + + @override + void dispose() { + _controller.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: _controller.stream, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Scaffold( + appBar: AppBar(), + body: Center(child: CircularProgressIndicator()), + ); + } + final instance = snapshot.data; + return Scaffold( + appBar: AppBar( + actions: [ + FocusScope( + canRequestFocus: false, + child: IconButton( + tooltip: 'Zoom In', + icon: Icon(Icons.zoom_in), + onPressed: _controller.zoomIn, + ), + ), + FocusScope( + canRequestFocus: false, + child: IconButton( + tooltip: 'Zoom Out', + icon: Icon(Icons.zoom_out), + onPressed: _controller.zoomOut, + ), + ), + FocusScope( + canRequestFocus: false, + child: IconButton( + tooltip: 'Reset the Scale and Offset', + icon: Icon(Icons.restore), + onPressed: _controller.reset, + ), + ), + ], + ), + body: Listener( + behavior: HitTestBehavior.opaque, + onPointerSignal: (details) { + if (details is PointerScrollEvent) { + GestureBinding.instance.pointerSignalResolver + .register(details, (event) { + if (event is PointerScrollEvent) { + if (_controller.shiftPressed) { + double zoomDelta = (-event.scrollDelta.dy / 300); + _controller.scale = _controller.scale + zoomDelta; + } else { + _controller.offset = + _controller.offset - event.scrollDelta; + } + } + }); + } + }, + onPointerMove: (details) { + _controller.updateTouch( + details.pointer, + details.localPosition, + details.position, + ); + }, + onPointerDown: (details) { + _controller.addTouch( + details.pointer, + details.localPosition, + details.position, + ); + }, + onPointerUp: (details) { + _controller.removeTouch(details.pointer); + }, + onPointerCancel: (details) { + _controller.removeTouch(details.pointer); + }, + child: RawKeyboardListener( + autofocus: true, + focusNode: _controller.focusNode, + onKey: (key) => _controller.rawKeyEvent(context, key), + child: SizedBox.expand( + child: Stack( + children: [ + for (final object in instance.objects) + AnimatedPositioned.fromRect( + duration: const Duration(milliseconds: 50), + rect: object.rect.adjusted( + _controller.offset, + _controller.scale, + ), + child: FittedBox( + fit: BoxFit.fill, + child: SizedBox.fromSize( + size: object.size, + child: object.child, + ), + ), + ) + ], + ), + ), + ), + ), + ); + }); + } +} + +extension RectUtils on Rect { + Rect adjusted(Offset offset, double scale) { + final left = (this.left + offset.dx) * scale; + final top = (this.top + offset.dy) * scale; + final width = this.width * scale; + final height = this.height * scale; + return Rect.fromLTWH(left, top, width, height); + } +} + +``` + +You can zoom and the blocks all scale correctly and pan around. + +Just press the reset button to start over. + +## Part 7 - Multi Touch Input  + +Now time for the fingers. For this you will need a touchscreen device to test. You can plug in your phone or if you have a touch screen computer you can run the web version. Update the controller with following: + +``` +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../classes/canvas_object.dart'; +import '../classes/rect_points.dart'; + +/// Control the canvas and the objects on it +class CanvasController { + /// Controller for the stream output + final _controller = StreamController(); + + /// Reference to the stream to update the UI + Stream get stream => _controller.stream; + + /// Emit a new event to rebuild the UI + void add([CanvasController val]) => _controller.add(val ?? this); + + /// Stop the stream and finish + void close() { + _controller.close(); + focusNode.dispose(); + } + + /// Start the stream + void init() => add(); + + // -- Canvas Objects -- + + final List> _objects = []; + + /// Current Objects on the canvas + List> get objects => _objects; + + /// Add an object to the canvas + void addObject(CanvasObject value) => _update(() { + _objects.add(value); + }); + + /// Add an object to the canvas + void updateObject(int i, CanvasObject value) => _update(() { + _objects[i] = value; + }); + + /// Remove an object from the canvas + void removeObject(int i) => _update(() { + _objects.removeAt(i); + }); + + /// Focus node for listening for keyboard shortcuts + final focusNode = FocusNode(); + + /// Raw events from keys pressed + void rawKeyEvent(BuildContext context, RawKeyEvent key) { + // Scale keys + if (key.isKeyPressed(LogicalKeyboardKey.minus)) { + zoomOut(); + } + if (key.isKeyPressed(LogicalKeyboardKey.equal)) { + zoomIn(); + } + // Directional Keys + if (key.isKeyPressed(LogicalKeyboardKey.arrowLeft)) { + offset = offset + Offset(offsetAdjust, 0.0); + } + if (key.isKeyPressed(LogicalKeyboardKey.arrowRight)) { + offset = offset + Offset(-offsetAdjust, 0.0); + } + if (key.isKeyPressed(LogicalKeyboardKey.arrowUp)) { + offset = offset + Offset(0.0, offsetAdjust); + } + if (key.isKeyPressed(LogicalKeyboardKey.arrowDown)) { + offset = offset + Offset(0.0, -offsetAdjust); + } + + _shiftPressed = key.isShiftPressed; + _metaPressed = key.isMetaPressed; + + /// Update Controller Instance + add(this); + } + + /// Trigger Shift Press + void shiftSelect() { + _shiftPressed = true; + } + + /// Trigger Meta Press + void metaSelect() { + _metaPressed = true; + } + + final Map _pointerMap = {}; + + /// Number of inputs currently on the screen + int get touchCount => _pointerMap.values.length; + + /// Marquee selection on the canvas + RectPoints get marquee => _marquee; + RectPoints _marquee; + + /// Dragging a canvas object + bool get isMovingCanvasObject => _isMovingCanvasObject; + bool _isMovingCanvasObject = false; + + final List _selectedObjects = []; + List get selectedObjectsIndices => _selectedObjects; + List> get selectedObjects => + _selectedObjects.map((i) => _objects[i]).toList(); + bool isObjectSelected(int i) => _selectedObjects.contains(i); + + /// Called every time a new input touches the screen + void addTouch(int pointer, Offset offsetVal, Offset globalVal) { + _pointerMap[pointer] = offsetVal; + + if (shiftPressed) { + final pt = (offsetVal / scale) - (offset); + _marquee = RectPoints(pt, pt); + } + + /// Update Controller Instance + add(this); + } + + /// Called when any of the inputs update position + void updateTouch(int pointer, Offset offsetVal, Offset globalVal) { + if (_marquee != null) { + // Update New Widget Rect + final _pts = _marquee; + final a = _pointerMap.values.first; + _pointerMap[pointer] = offsetVal; + final b = _pointerMap.values.first; + final delta = (b - a) / scale; + _pts.end = _pts.end + delta; + _marquee = _pts; + final _rect = Rect.fromPoints(_pts.start, _pts.end); + _selectedObjects.clear(); + for (var i = 0; i < _objects.length; i++) { + if (_rect.overlaps(_objects[i].rect)) { + _selectedObjects.add(i); + } + } + } else if (touchCount == 1) { + // Widget Move + _isMovingCanvasObject = true; + final a = _pointerMap.values.first; + _pointerMap[pointer] = offsetVal; + final b = _pointerMap.values.first; + if (_selectedObjects.isEmpty) return; + for (final idx in _selectedObjects) { + final widget = _objects[idx]; + final delta = (b - a) / scale; + final _newOffset = widget.offset + delta; + _objects[idx] = widget.copyWith(dx: _newOffset.dx, dy: _newOffset.dy); + } + } else if (touchCount == 2) { + // Scale and Rotate Update + _isMovingCanvasObject = false; + final _rectA = _getRectFromPoints(_pointerMap.values.toList()); + _pointerMap[pointer] = offsetVal; + final _rectB = _getRectFromPoints(_pointerMap.values.toList()); + final _delta = _rectB.center - _rectA.center; + final _newOffset = offset + (_delta / scale); + offset = _newOffset; + final aDistance = (_rectA.topLeft - _rectA.bottomRight).distance; + final bDistance = (_rectB.topLeft - _rectB.bottomRight).distance; + final change = (bDistance / aDistance); + scale = scale * change; + } else { + // Pan Update + _isMovingCanvasObject = false; + final _rectA = _getRectFromPoints(_pointerMap.values.toList()); + _pointerMap[pointer] = offsetVal; + final _rectB = _getRectFromPoints(_pointerMap.values.toList()); + final _delta = _rectB.center - _rectA.center; + offset = offset + (_delta / scale); + } + _pointerMap[pointer] = offsetVal; + + /// Update Controller Instance + add(this); + } + + /// Called when a input is removed from the screen + void removeTouch(int pointer) { + _pointerMap.remove(pointer); + + if (touchCount < 1) { + _isMovingCanvasObject = false; + } + if (_marquee != null) { + _marquee = null; + _shiftPressed = false; + } + + /// Update Controller Instance + add(this); + } + + void selectObject(int i) => _update(() { + if (!_metaPressed) { + _selectedObjects.clear(); + } + _selectedObjects.add(0); + final item = _objects.removeAt(i); + _objects.insert(0, item); + }); + + /// Checks if the shift key on the keyboard is pressed + bool get shiftPressed => _shiftPressed; + bool _shiftPressed = false; + + /// Checks if the meta key on the keyboard is pressed + bool get metaPressed => _metaPressed; + bool _metaPressed = false; + + /// Scale of the canvas + double get scale => _scale; + double _scale = 1; + set scale(double value) => _update(() { + if (value <= minScale) { + value = minScale; + } else if (value >= maxScale) { + value = maxScale; + } + _scale = value; + }); + + /// Max possible scale + static const double maxScale = 3.0; + + /// Min possible scale + static const double minScale = 0.2; + + /// How much to scale the canvas in increments + static const double scaleAdjust = 0.05; + + /// How much to shift the canvas in increments + static const double offsetAdjust = 15; + + /// Current offset of the canvas + Offset get offset => _offset; + Offset _offset = Offset.zero; + set offset(Offset value) => _update(() { + _offset = value; + }); + + static const double _scaleDefault = 1; + static const Offset _offsetDefault = Offset.zero; + + /// Reset the canvas zoom and offset + void reset() { + scale = _scaleDefault; + offset = _offsetDefault; + } + + /// Zoom in the canvas + void zoomIn() { + scale += scaleAdjust; + } + + /// Zoom out the canvas + void zoomOut() { + scale -= scaleAdjust; + } + + void _update(void Function() action) { + action(); + add(this); + } + + Rect _getRectFromPoints(List offsets) { + if (offsets.length == 2) { + return Rect.fromPoints(offsets.first, offsets.last); + } + final dxs = offsets.map((e) => e.dx).toList(); + final dys = offsets.map((e) => e.dy).toList(); + double left = _minFromList(dxs); + double top = _minFromList(dys); + double bottom = _maxFromList(dys); + double right = _maxFromList(dxs); + return Rect.fromLTRB(left, top, right, bottom); + } + + double _minFromList(List values) { + double value = double.infinity; + for (final item in values) { + value = math.min(item, value); + } + return value; + } + + double _maxFromList(List values) { + double value = -double.infinity; + for (final item in values) { + value = math.max(item, value); + } + return value; + } +} +``` + +Add a new file `src/classes/rect_points.dart` and add the following: + +``` +import 'dart:ui'; + +class RectPoints { + RectPoints(this.start, this.end); + + Offset start, end; + + Rect get rect => Rect.fromPoints(start, end); +} +``` + +Update the `main.dart` with the following: + +``` +import 'package:flutter/material.dart'; + +import 'ui/home/screen.dart'; + +void main() => runApp(MyApp()); + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'Flutter Demo', + theme: ThemeData( + primarySwatch: Colors.blue, + accentColor: Colors.red, + visualDensity: VisualDensity.adaptivePlatformDensity, + ), + darkTheme: ThemeData.dark().copyWith( + visualDensity: VisualDensity.adaptivePlatformDensity, + ), + home: HomeScreen(), + ); + } +} +``` + +Update the home screen with the following: + +``` +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +import '../../src/classes/canvas_object.dart'; +import '../../src/controllers/canvas.dart'; + +class HomeScreen extends StatefulWidget { + const HomeScreen({Key key}) : super(key: key); + + @override + _HomeScreenState createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + final _controller = CanvasController(); + + @override + void initState() { + _controller.init(); + _dummyData(); + super.initState(); + } + + void _dummyData() { + _controller.addObject( + CanvasObject( + dx: 20, + dy: 20, + width: 100, + height: 100, + child: Container(color: Colors.red), + ), + ); + _controller.addObject( + CanvasObject( + dx: 80, + dy: 60, + width: 100, + height: 200, + child: Container(color: Colors.green), + ), + ); + _controller.addObject( + CanvasObject( + dx: 100, + dy: 40, + width: 100, + height: 50, + child: Container(color: Colors.blue), + ), + ); + } + + @override + void dispose() { + _controller.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: _controller.stream, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Scaffold( + appBar: AppBar(), + body: Center(child: CircularProgressIndicator()), + ); + } + final instance = snapshot.data; + return Scaffold( + appBar: AppBar( + actions: [ + FocusScope( + canRequestFocus: false, + child: IconButton( + tooltip: 'Selection', + icon: Icon(Icons.select_all), + color: instance.shiftPressed + ? Theme.of(context).accentColor + : null, + onPressed: _controller.shiftSelect, + ), + ), + FocusScope( + canRequestFocus: false, + child: IconButton( + tooltip: 'Meta Key', + color: instance.metaPressed + ? Theme.of(context).accentColor + : null, + icon: Icon(Icons.category), + onPressed: _controller.metaSelect, + ), + ), + FocusScope( + canRequestFocus: false, + child: IconButton( + tooltip: 'Zoom In', + icon: Icon(Icons.zoom_in), + onPressed: _controller.zoomIn, + ), + ), + FocusScope( + canRequestFocus: false, + child: IconButton( + tooltip: 'Zoom Out', + icon: Icon(Icons.zoom_out), + onPressed: _controller.zoomOut, + ), + ), + FocusScope( + canRequestFocus: false, + child: IconButton( + tooltip: 'Reset the Scale and Offset', + icon: Icon(Icons.restore), + onPressed: _controller.reset, + ), + ), + ], + ), + body: Listener( + behavior: HitTestBehavior.opaque, + onPointerSignal: (details) { + if (details is PointerScrollEvent) { + GestureBinding.instance.pointerSignalResolver + .register(details, (event) { + if (event is PointerScrollEvent) { + if (_controller.shiftPressed) { + double zoomDelta = (-event.scrollDelta.dy / 300); + _controller.scale = _controller.scale + zoomDelta; + } else { + _controller.offset = + _controller.offset - event.scrollDelta; + } + } + }); + } + }, + onPointerMove: (details) { + _controller.updateTouch( + details.pointer, + details.localPosition, + details.position, + ); + }, + onPointerDown: (details) { + _controller.addTouch( + details.pointer, + details.localPosition, + details.position, + ); + }, + onPointerUp: (details) { + _controller.removeTouch(details.pointer); + }, + onPointerCancel: (details) { + _controller.removeTouch(details.pointer); + }, + child: RawKeyboardListener( + autofocus: true, + focusNode: _controller.focusNode, + onKey: (key) => _controller.rawKeyEvent(context, key), + child: SizedBox.expand( + child: Stack( + children: [ + for (var i = 0; i < instance.objects.length; i++) + Positioned.fromRect( + rect: instance.objects[i].rect.adjusted( + _controller.offset, + _controller.scale, + ), + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: instance.isObjectSelected(i) + ? Colors.grey + : Colors.transparent, + )), + child: GestureDetector( + onTapDown: (_) => _controller.selectObject(i), + child: FittedBox( + fit: BoxFit.fill, + child: SizedBox.fromSize( + size: instance.objects[i].size, + child: instance.objects[i].child, + ), + ), + ), + ), + ), + if (instance?.marquee != null) + Positioned.fromRect( + rect: instance.marquee.rect + .adjusted(instance.offset, instance.scale), + child: Container( + color: Colors.blueAccent.withOpacity(0.3), + ), + ), + ], + ), + ), + ), + ), + ); + }); + } +} + +extension RectUtils on Rect { + Rect adjusted(Offset offset, double scale) { + final left = (this.left + offset.dx) * scale; + final top = (this.top + offset.dy) * scale; + final width = this.width * scale; + final height = this.height * scale; + return Rect.fromLTWH(left, top, width, height); + } +} +``` + +Now you can move any object on the canvas just by clicking and dragging. You can zoom with 2 fingers and pan with 2 or 3 fingers. If you hold down the shift key then you can use a marquee to select multiple and if you hold down the meta/command key then you can select multiple by tapping each. + +## Conclusion  + +If you are on a device without a keyboard you can tap the new icons to turn on the keyboard key actions. When the object is selected there is a grey border. + +Now you can add any widget to the canvas and pan and zoom! \ No newline at end of file diff --git a/skills/flutter-one-click-release/SKILL.md b/skills/flutter-one-click-release/SKILL.md new file mode 100644 index 0000000..b97b9cc --- /dev/null +++ b/skills/flutter-one-click-release/SKILL.md @@ -0,0 +1,179 @@ +--- +name: flutter-fastlane-one-click-beta +description: Deploy your Flutter app to the App Store and Google Play with ease using this step-by-step guide covering installation, project setup, Fastlane integration, and automated deployments with Automator. +metadata: + url: https://rodydavis.com/posts/flutter-one-click-release + last_modified: Tue, 03 Feb 2026 20:04:23 GMT +--- + +# Flutter + Fastlane (One Click Beta) + + +## 1\. Install Flutter  + +[Download Flutter](https://flutter.io/get-started/install/) + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/7g4od2ae671i5x5/ff_1_u67ip3pbk5.webp?thumb=) + +## 2\. Create new Flutter Project  + +If you are pretty new to Flutter you can check out [this useful guide](https://flutter.io/get-started/codelab/) on how to create a new project step by step. + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/iik1kh354fx05xw/ff_2_9dljl4hgqq.webp?thumb=) + +## 3\. Create App in iTunes Connect  + +If you are not familiar with iTunes Connect, check out [this article](https://clearbridgemobile.com/how-to-submit-an-app-to-the-app-store/) for getting started and setting up your first app for the App Store. + +## 4\. Create App in Google Play  + +Setting up an app in the Google Play Console can be tricky, make sure to check out the [official reference](https://support.google.com/googleplay/android-developer/answer/113469?hl=en-GB) and [this guide](https://medium.com/mindorks/upload-your-first-android-app-on-play-store-step-by-step-ee0de9123ac0) if you are having trouble. + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/3q8a93oi3ligf6k/ff_3_1z55io2svg.webp?thumb=) + +## 5\. Navigate to Project > ios and Setup Fastlane  + +[Reference](https://docs.fastlane.tools/getting-started/ios/setup/) + +## 6\. Navigate to Project > android and Setup Fastlane  + +[Reference](https://docs.fastlane.tools/getting-started/android/setup/) + +## 7\. Update Fastlane Fastfiles for iOS and Android and Change accordingly for each platform  + +* Make sure to change "YOUR PROJECT PATH" to the path to your project in Finder. + +* Only copy the correct platform code for each Fastfile. For example, `default_platform(:ios)` for iOS and \`default\_platform(:android)1st for Android. + + +``` +update_fastlane + +default_platform(:ios) + +platform :ios do + desc "Push a new beta build to TestFlight" + lane :beta do + increment_build_number(xcodeproj: "Runner.xcodeproj") + build_app(workspace: "Runner.xcworkspace", scheme: "Runner") + upload_to_testflight(skip_waiting_for_build_processing: true) + end + desc "Push a new release build to the App Store" + lane :release do + increment_build_number(xcodeproj: "Runner.xcodeproj") + build_app(workspace: "Runner.xcworkspace", scheme: "Runner") + upload_to_app_store(submit_for_review: true, + automatic_release: true, + skip_screenshots: true, + force: true, + skip_waiting_for_build_processing: true) + end +end + + + +//YOUR PROJECT PATH > android > fastlane > Fastfile +default_platform(:android) + +platform :android do + desc "Runs all the tests" + lane :test do + gradle(task: "test") + end + + desc "Submit a new Build to Beta" + lane :beta do + gradle(task: 'clean') + increment_version_code + sh "cd YOUR PROJECT PATH && flutter build apk" + upload_to_play_store( + track: 'beta', + apk: '../build/app/outputs/apk/release/app-release.apk', + skip_upload_screenshots: true, + skip_upload_images: true + ) + # crashlytics + end + + desc "Deploy a new version to the Google Play" + lane :deploy do + gradle(task: 'clean') + increment_version_code + sh "cd YOUR PROJECT PATH && flutter build apk" + upload_to_play_store( + track: 'production', + apk: '../build/app/outputs/apk/release/app-release.apk', + skip_upload_screenshots: true, + skip_upload_images: true + ) + end +end +``` + +* For Android `increment_version_code` install here. + +Sometimes it will fail and you will need to run: + +`bundle exec fastlane add_plugin increment_version_code` + +* For iOS `increment_build_number` set up Generic Versioning by enabling the agvtool. + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/92iv8ah5ixw392g/ff_4_39myyg3bzm.gif?thumb=) + +[Source](https://medium.com/xcblog/agvtool-automating-ios-build-and-version-numbers-454cab6f1bbe) + +## 8\. Metadata (Optional)  + +* For iOS you can have Fastlane download all your apps existing metadata including screenshots from iTunes Connect. In terminal navigate to the project and run. + +`fastlane deliver download_metadata && fastlane deliver download_screenshots` + +* For Android you can use [Fastlane Supply](https://docs.fastlane.tools/actions/supply/). + +## 9\. Open Automator  + +Right now everything is working just by the command line. If you navigate to your project in terminal by adding "cd " and dragging in the project folder and hitting Enter, you can type "cd ios && fastlane beta" or "cd android && fastlane beta" and both will run fastlane. + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/7342x4kyyu21oju/ff_5_1esbyy658n.gif?thumb=) + +If you want to be able to submit your app to Google Play and the App Store with one click we will be using [Automator](http://www.applegazette.com/os-x/getting-started-automator-workflows-mac/). Create a new Automator Application. And Search for "Ask for Confirmation" and "Run AppleScript" and drag in. + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/b4o4x6v458qa7a5/ff_6_krrntoi3wl.webp?thumb=) + +Here is the Script for beta and release. You will need to create a Automator Application for both Beta and Release for each app you want automated. Save it where ever you want and create an Alias to be but on the Desktop. + +* Make sure to change **YOUR PROJECT PATH** to the path to your project in Finder + +Hint: I have my automator application save in the Github Repo of my project for versioning and easy access for different projects. + +``` +//Beta +on run {input, parameters} + tell application "Terminal" + activate + do script "cd YOUR PROJECT PATH/android && fastlane beta && cd YOUR PROJECT PATH/ios && fastlane beta" + end tell + tell application "System Events" + try + set visible of application process "Terminal" to false + end try + end tell +end run + +//Release +on run {input, parameters} + tell application "Terminal" + activate + do script "cd YOUR PROJECT PATH/android && fastlane deploy && cd YOUR PROJECT PATH/ios && fastlane release" + end tell + tell application "System Events" + try + set visible of application process "Terminal" to false + end try + end tell +end run +``` + +## 10\. Try It Out!  + +Everything should be working now. If you double click on the automator application you should get a confirmation pop up to release the app. The Script will run terminal in the background and you can stay focused on developing awesome flutter applications. If you want to see the progress on fastlane uploading your apps you can click on the terminal icon and the terminal window will reappear. Thanks for reading and please reach out for any questions you have! \ No newline at end of file diff --git a/skills/flutter-ssr-rfw/SKILL.md b/skills/flutter-ssr-rfw/SKILL.md new file mode 100644 index 0000000..b63c33e --- /dev/null +++ b/skills/flutter-ssr-rfw/SKILL.md @@ -0,0 +1,1951 @@ +--- +name: server-side-rendering-flutter-apps-with-rfw +description: Learn how to build a dynamic Flutter app using Server Side Rendering (SSR) with the rfw package, enabling UI updates driven by server logic and binary data exchange via HTTP. +metadata: + url: https://rodydavis.com/posts/flutter-ssr-rfw + last_modified: Tue, 03 Feb 2026 20:04:17 GMT +--- + +# Server Side Rendering Flutter Apps with RFW + + +This post will guide you how to build a Flutter app that takes advantage of Server Side Rendering (SSR) and being able to update UI dynamically. + +> If you are new to flutter you can follow this [post](https://rodydavis.com/posts/first-flutter-project) on getting started + +This technique will use the [rfw](https://pub.dev/packages/rfw) package on the server and client to send binary data via HTTP requests. + +> **TLDR** You can find the final source [here](https://github.com/rodydavis/flutter_ssr). + +## Getting started  + +Create a new directory called `flutter_ssr` and navigate to it in terminal or open it up in your favorite IDE. + +## Approaches to updates  + +If you are in the mobile world then you know how challenging it can be to get all your users on the latest version. Even just having an API or database schema can be very hard to update because of users on older versions (sometimes due to OS limitations). + +### Code Push  + +[Shorebird](https://shorebird.dev/) takes an interesting approach to delivering updates to the users via Code Push and will update the apps live. This does have an advantage since it will update the UI and logic but what if the content update was only intended for a specific user or set of users? + +### Latest version supported  + +Another approach is to simply have an SLO/Policy that you only support X number of recent releases and that the app will not work on older versions. + +For something like this on mobile you would use [upgrader](https://pub.dev/packages/upgrader) via [AppCast](https://sparkle-project.org/about/) or [in\_app\_update](https://pub.dev/packages/in_app_update) to use Google Play APIs to update the app in the background or prevent using until updated. + +This has an advantage to know that users will be on the latest or no be supported and allow you to target newer APIs and roll updates easier. This does mean that users will be frustrated by updates more and that older devices may not be supported. + +### Server driven updates  + +Not all apps need to render data on the server and sometimes when building an offline first application you want to do everything local first, but this is for when you need to build a server first application. Here are some examples and use cases: + +* Bank accounts +* Airline / Hotel / Car booking +* Chat applications (Instant messaging) +* Marketing and AB testing +* Database first applications + +Each of these examples does not mean they are server only and in many cases you want to still cache the data locally to still offer a great offline experience. + +With Flutter you are building a runtime that you are shipping to the user as a Single Page Application (SPA) on the web and a mobile/desktop app on the stores. This means you need to ship all the logic and UI for every update. + +This has an advantage for doing more logic on the server and potentially really heavy requests are done server side and just the rendered UI is sent to the client. The client can still cache the response and allow for offline viewing too. These disadvantage here is that the client is expected to communicate with the server at some point and may not be suitable for offline only applications. + +## Remote Flutter Widgets (RFW)  + +The Flutter team has a package for creating widgets on the client and server and sending data necessary to connect them. This package is called [rfw](https://pub.dev/packages/rfw). + +> While it is possible to ship logic in addition to UI as WASM that is out of scope for this post + +The rfw package uses a text format that can be compiled to binary and be used to represent state and dispatch events. + +``` +import core; +import material; + +widget MaterialShop = Scaffold( + appBar: AppBar( + title: Text(text: ['Products']), + ), + body: ListView( + children: [ + ...for product in data.server.games: + Product(product: product) + ], + ), +); + +widget Product = ListTile( + title: Text(text: args.product.name), + onTap: event 'shop.productSelect' { name: args.product.name, path: args.product.link }, +); +``` + +Multiple widgets can be defined and it may even look similar to the Dart API you are used to in Flutter but it is not quite the same. + +There are not logic branching blocks or conditional rendering but rather is a stateless format capable of updating every frame if needed. + +Take the following Flutter counter app that is generated when you create a new project: + +``` +class MyHomePage extends StatefulWidget { + final String title; + + const MyHomePage({ + Key? key, + required this.title, + }) : super(key: key); + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + int _counter = 0; + + void _incrementCounter() { + setState(() { + _counter++; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + title: Text(widget.title), + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'You have pushed the button this many times:', + ), + Text( + '$_counter', + style: Theme.of(context).textTheme.headlineMedium, + ), + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: _incrementCounter, + tooltip: 'Increment', + child: const Icon(Icons.add), + ), + ); + } +} +``` + +We could represent that in rfw like this: + +``` +import widgets; +import material; + +widget root = Scaffold( + appBar: AppBar( + title: Text(text: ['Counter Example']), + centerTitle: true, + backgroundColor: data.colorScheme.inversePrimary, + ), + body: Center( + child: Column( + mainAxisAlignment: "center", + children: [ + Text(text: ["You have pushed the button this many times:"]), + Text( + text: [data.counter.value], + style: { + fontSize: 20.0, + }, + ), + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: event "click" {}, + tooltip: ["Increment"], + child: Icon( + icon: 0xe047, + fontFamily: 'MaterialIcons', + ), + ), +); +``` + +You may have noticed some arrays for strings and different ways of defining widgets. That is by design and the API will not be 100% with the Flutter SDK, but with the limitation comes different tradeoffs. + +You can define any custom widgets in your application and only uses the UI you have defined in your design system and the server will only be able to generate UI that you expect. + +It is also possible to create all the UI in the text format with rows, columns, containers and more. + +## Setting up the server  + +For this example we will be using [dart\_frog](https://dartfrog.vgv.dev/docs/overview) to create the server application. + +In the directory that you created earlier run the following commands: + +``` +dart_frog create server +flutter pub add rfw +``` + +This will generate the server boilerplate for us and add the correct dependencies. + +> Feel free to delete the test directory for now or update it later to check for the correct response. + +Navigate to `/server/routes/index.dart` and update the file with the following: + +``` +import 'package:dart_frog/dart_frog.dart'; +import 'package:rfw/formats.dart'; + +Response onRequest(RequestContext context) { + var count = context.request.headers['COUNTER_VALUE'] ?? '0'; + + if (context.request.method == HttpMethod.post) { + count = (int.parse(count) + 1).toString(); + } + + return Response.bytes( + body: encodeLibraryBlob(parseLibraryFile(template)), + headers: {'COUNTER_VALUE': count}, + ); +} + +const template = ''' +import widgets; +import material; + +widget root = Scaffold( + appBar: AppBar( + title: Text(text: ['Counter Example']), + centerTitle: true, + backgroundColor: data.colorScheme.inversePrimary, + ), + body: Center( + child: Column( + mainAxisAlignment: "center", + children: [ + Text(text: ["You have pushed the button this many times:"]), + Text( + text: [data.counter.value], + style: { + fontSize: 20.0, + }, + ), + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: event "click" {}, + tooltip: ["Increment"], + child: Icon( + icon: 0xe047, + fontFamily: 'MaterialIcons', + ), + ), +); +'''; +``` + +This takes the rfw text format we defined earlier and adds it to a string template. + +Using that template we can call `encodeLibraryBlob(parseLibraryFile(template))` to create a binary representation of the text format. + +We are also checking for the `COUNTER_VALUE` from the header to send state to/from the client. Since servers are stateless or easier to scale when they are this will help with not needing a type of session storage or context. + +If you navigate inside the `server` directly you can start the dev server that will be needed for the next step: + +``` +dart_frog dev +``` + +You should see the following: + +``` +✓ Running on http://localhost:8080 +``` + +## Setting up the client  + +In a new terminal tab you can navigate to the root of the directory and run the following commands: + +``` +flutter create app +flutter pub add rfw http +``` + +This will generate the counter app boilerplate and add the correct dependencies for us. + +The rfw package comes with **core** and **material** widgets but for this example we will be adding them manually to show how they are being called and created. + +Create and update the following file located att `app/lib/rfw/decoders.dart`: + +``` +import 'package:flutter/material.dart'; +import 'package:rfw/rfw.dart'; + +class CustomArgumentDecoders { + static ButtonStyle? outlinedButtonStyle( + DataSource source, + List key, + BuildContext context, + ) { + if (!source.isMap(key)) { + return null; + } + return OutlinedButton.styleFrom( + foregroundColor: ArgumentDecoders.color(source, ['foregroundColor']), + backgroundColor: ArgumentDecoders.color(source, ['backgroundColor']), + disabledForegroundColor: + ArgumentDecoders.color(source, [...key, 'disabledForegroundColor']), + disabledBackgroundColor: + ArgumentDecoders.color(source, [...key, 'disabledBackgroundColor']), + shadowColor: ArgumentDecoders.color(source, [...key, 'shadowColor']), + surfaceTintColor: + ArgumentDecoders.color(source, [...key, 'surfaceTintColor']), + elevation: source.v(['elevation']), + textStyle: ArgumentDecoders.textStyle(source, [...key, 'textStyle']), + padding: ArgumentDecoders.edgeInsets(source, [...key, 'padding']), + minimumSize: CustomArgumentDecoders.size(source, [...key, 'minimumSize']), + fixedSize: CustomArgumentDecoders.size(source, [...key, 'fixedSize']), + maximumSize: CustomArgumentDecoders.size(source, [...key, 'maximumSize']), + side: ArgumentDecoders.borderSide(source, [...key, 'side']), + shape: CustomArgumentDecoders.outlinedBorder(source, [...key, 'shape']), + enabledMouseCursor: CustomArgumentDecoders.mouseCursor( + source, [...key, 'enabledMouseCursor']), + disabledMouseCursor: CustomArgumentDecoders.mouseCursor( + source, [...key, 'disabledMouseCursor']), + visualDensity: + ArgumentDecoders.visualDensity(source, [...key, 'visualDensity']), + tapTargetSize: ArgumentDecoders.enumValue( + MaterialTapTargetSize.values, + source, + [...key, 'tapTargetSize']) ?? + MaterialTapTargetSize.shrinkWrap, + animationDuration: ArgumentDecoders.duration( + source, [...key, 'animationDuration'], context), + enableFeedback: source.v([...key, 'enableFeedback']), + alignment: ArgumentDecoders.alignment(source, [...key, 'alignment']), + ); + } + + static ButtonStyle? filledButtonStyle( + DataSource source, + List key, + BuildContext context, + ) { + if (!source.isMap(key)) { + return null; + } + return FilledButton.styleFrom( + foregroundColor: ArgumentDecoders.color(source, ['foregroundColor']), + backgroundColor: ArgumentDecoders.color(source, ['backgroundColor']), + disabledForegroundColor: + ArgumentDecoders.color(source, [...key, 'disabledForegroundColor']), + disabledBackgroundColor: + ArgumentDecoders.color(source, [...key, 'disabledBackgroundColor']), + shadowColor: ArgumentDecoders.color(source, [...key, 'shadowColor']), + surfaceTintColor: + ArgumentDecoders.color(source, [...key, 'surfaceTintColor']), + elevation: source.v(['elevation']), + textStyle: ArgumentDecoders.textStyle(source, [...key, 'textStyle']), + padding: ArgumentDecoders.edgeInsets(source, [...key, 'padding']), + minimumSize: CustomArgumentDecoders.size(source, [...key, 'minimumSize']), + fixedSize: CustomArgumentDecoders.size(source, [...key, 'fixedSize']), + maximumSize: CustomArgumentDecoders.size(source, [...key, 'maximumSize']), + side: ArgumentDecoders.borderSide(source, [...key, 'side']), + shape: CustomArgumentDecoders.outlinedBorder(source, [...key, 'shape']), + enabledMouseCursor: CustomArgumentDecoders.mouseCursor( + source, [...key, 'enabledMouseCursor']), + disabledMouseCursor: CustomArgumentDecoders.mouseCursor( + source, [...key, 'disabledMouseCursor']), + visualDensity: + ArgumentDecoders.visualDensity(source, [...key, 'visualDensity']), + tapTargetSize: ArgumentDecoders.enumValue( + MaterialTapTargetSize.values, + source, + [...key, 'tapTargetSize']) ?? + MaterialTapTargetSize.shrinkWrap, + animationDuration: ArgumentDecoders.duration( + source, [...key, 'animationDuration'], context), + enableFeedback: source.v([...key, 'enableFeedback']), + alignment: ArgumentDecoders.alignment(source, [...key, 'alignment']), + ); + } + + static ButtonStyle? textButtonStyle( + DataSource source, + List key, + BuildContext context, + ) { + if (!source.isMap(key)) { + return null; + } + return TextButton.styleFrom( + foregroundColor: ArgumentDecoders.color(source, ['foregroundColor']), + backgroundColor: ArgumentDecoders.color(source, ['backgroundColor']), + disabledForegroundColor: + ArgumentDecoders.color(source, [...key, 'disabledForegroundColor']), + disabledBackgroundColor: + ArgumentDecoders.color(source, [...key, 'disabledBackgroundColor']), + shadowColor: ArgumentDecoders.color(source, [...key, 'shadowColor']), + surfaceTintColor: + ArgumentDecoders.color(source, [...key, 'surfaceTintColor']), + elevation: source.v(['elevation']), + textStyle: ArgumentDecoders.textStyle(source, [...key, 'textStyle']), + padding: ArgumentDecoders.edgeInsets(source, [...key, 'padding']), + minimumSize: CustomArgumentDecoders.size(source, [...key, 'minimumSize']), + fixedSize: CustomArgumentDecoders.size(source, [...key, 'fixedSize']), + maximumSize: CustomArgumentDecoders.size(source, [...key, 'maximumSize']), + side: ArgumentDecoders.borderSide(source, [...key, 'side']), + shape: CustomArgumentDecoders.outlinedBorder(source, [...key, 'shape']), + enabledMouseCursor: CustomArgumentDecoders.mouseCursor( + source, [...key, 'enabledMouseCursor']), + disabledMouseCursor: CustomArgumentDecoders.mouseCursor( + source, [...key, 'disabledMouseCursor']), + visualDensity: + ArgumentDecoders.visualDensity(source, [...key, 'visualDensity']), + tapTargetSize: ArgumentDecoders.enumValue( + MaterialTapTargetSize.values, + source, + [...key, 'tapTargetSize']) ?? + MaterialTapTargetSize.shrinkWrap, + animationDuration: ArgumentDecoders.duration( + source, [...key, 'animationDuration'], context), + enableFeedback: source.v([...key, 'enableFeedback']), + alignment: ArgumentDecoders.alignment(source, [...key, 'alignment']), + ); + } + + static EdgeInsets? edgeInsets(DataSource source, List key) { + if (!source.isMap(key)) { + return null; + } + final all = source.v([...key, 'all']); + if (all != null) return EdgeInsets.all(all); + final vertical = source.v([...key, 'vertical']); + final horizontal = source.v([...key, 'horizontal']); + if (vertical != null || horizontal != null) { + return EdgeInsets.symmetric( + vertical: vertical ?? 0, + horizontal: horizontal ?? 0, + ); + } + final top = source.v([...key, 'top']); + final bottom = source.v([...key, 'bottom']); + final left = source.v([...key, 'left']); + final right = source.v([...key, 'right']); + return EdgeInsets.only( + top: top ?? 0, + bottom: bottom ?? 0, + left: left ?? 0, + right: right ?? 0, + ); + } + + static Size? size(DataSource source, List key) { + if (!source.isMap(key)) { + return null; + } + return Size( + source.v([...key, 'width']) ?? 0.0, + source.v([...key, 'height']) ?? 0.0, + ); + } + + static MouseCursor? mouseCursor(DataSource source, List key) { + if (!source.isMap(key)) { + return null; + } + final type = source.v([...key, 'type']); + final value = source.v([...key, 'value']); + if (type == 'system') { + switch (value) { + case 'alias': + return SystemMouseCursors.alias; + case 'none': + return SystemMouseCursors.none; + case 'basic': + return SystemMouseCursors.basic; + case 'click': + return SystemMouseCursors.click; + case 'forbidden': + return SystemMouseCursors.forbidden; + case 'wait': + return SystemMouseCursors.wait; + case 'progress': + return SystemMouseCursors.progress; + case 'contextMenu': + return SystemMouseCursors.contextMenu; + case 'help': + return SystemMouseCursors.help; + case 'text': + return SystemMouseCursors.text; + case 'verticalText': + return SystemMouseCursors.verticalText; + case 'cell': + return SystemMouseCursors.cell; + case 'precise': + return SystemMouseCursors.precise; + case 'move': + return SystemMouseCursors.move; + case 'grab': + return SystemMouseCursors.grab; + case 'grabbing': + return SystemMouseCursors.grabbing; + case 'noDrop': + return SystemMouseCursors.noDrop; + case 'alias': + return SystemMouseCursors.alias; + case 'copy': + return SystemMouseCursors.copy; + case 'disappearing': + return SystemMouseCursors.disappearing; + case 'allScroll': + return SystemMouseCursors.allScroll; + case 'resizeLeftRight': + return SystemMouseCursors.resizeLeftRight; + case 'resizeUpDown': + return SystemMouseCursors.resizeUpDown; + case 'resizeUpLeftDownRight': + return SystemMouseCursors.resizeUpLeftDownRight; + case 'resizeUpRightDownLeft': + return SystemMouseCursors.resizeUpRightDownLeft; + case 'resizeUp': + return SystemMouseCursors.resizeUp; + case 'resizeDown': + return SystemMouseCursors.resizeDown; + case 'resizeLeft': + return SystemMouseCursors.resizeLeft; + case 'resizeRight': + return SystemMouseCursors.resizeRight; + case 'resizeUpLeft': + return SystemMouseCursors.resizeUpLeft; + case 'resizeUpRight': + return SystemMouseCursors.resizeUpRight; + case 'resizeDownLeft': + return SystemMouseCursors.resizeDownLeft; + case 'resizeDownRight': + return SystemMouseCursors.resizeDownRight; + case 'resizeColumn': + return SystemMouseCursors.resizeColumn; + case 'resizeRow': + return SystemMouseCursors.resizeRow; + case 'zoomIn': + return SystemMouseCursors.zoomIn; + case 'zoomOut': + return SystemMouseCursors.zoomOut; + default: + } + } + return null; + } + + static LinearBorderEdge? linearBorderEdge( + DataSource source, List key) { + if (!source.isMap(key)) { + return null; + } + return LinearBorderEdge( + size: source.v([...key, 'size']) ?? 1.0, + alignment: source.v([...key, 'alignment']) ?? 0.0, + ); + } + + static OutlinedBorder? outlinedBorder(DataSource source, List key) { + if (!source.isMap(key)) { + return null; + } + final type = source.v([...key, 'type']); + switch (type) { + case "RoundedRectangleBorder": + return RoundedRectangleBorder( + side: ArgumentDecoders.borderSide(source, [...key, 'side']) ?? + BorderSide.none, + borderRadius: + ArgumentDecoders.borderRadius(source, [...key, 'borderRadius']) ?? + BorderRadius.zero, + ); + case "BeveledRectangleBorder": + return BeveledRectangleBorder( + side: ArgumentDecoders.borderSide(source, [...key, 'side']) ?? + BorderSide.none, + borderRadius: + ArgumentDecoders.borderRadius(source, [...key, 'borderRadius']) ?? + BorderRadius.zero, + ); + case "ContinuousRectangleBorder": + return ContinuousRectangleBorder( + side: ArgumentDecoders.borderSide(source, [...key, 'side']) ?? + BorderSide.none, + borderRadius: + ArgumentDecoders.borderRadius(source, [...key, 'borderRadius']) ?? + BorderRadius.zero, + ); + case "CircleBorder": + return CircleBorder( + side: ArgumentDecoders.borderSide(source, [...key, 'side']) ?? + BorderSide.none, + eccentricity: source.v([...key, 'eccentricity']) ?? 0.0, + ); + case "LinearBorder": + return LinearBorder( + side: ArgumentDecoders.borderSide(source, [...key, 'side']) ?? + BorderSide.none, + start: CustomArgumentDecoders.linearBorderEdge( + source, [...key, 'start']), + end: CustomArgumentDecoders.linearBorderEdge(source, [...key, 'end']), + top: CustomArgumentDecoders.linearBorderEdge(source, [...key, 'top']), + bottom: CustomArgumentDecoders.linearBorderEdge( + source, [...key, 'bottom']), + ); + case "OvalBorder": + return OvalBorder( + side: ArgumentDecoders.borderSide(source, [...key, 'side']) ?? + BorderSide.none, + eccentricity: source.v([...key, 'eccentricity']) ?? 0.0, + ); + case "StadiumBorder": + return StadiumBorder( + side: ArgumentDecoders.borderSide(source, [...key, 'side']) ?? + BorderSide.none, + ); + case "StarBorder": + return StarBorder( + side: ArgumentDecoders.borderSide(source, [...key, 'side']) ?? + BorderSide.none, + points: source.v([...key, 'points']) ?? 5.0, + innerRadiusRatio: + source.v([...key, 'innerRadiusRatio']) ?? 0.4, + pointRounding: source.v([...key, 'pointRounding']) ?? 0.0, + valleyRounding: source.v([...key, 'valleyRounding']) ?? 0.0, + rotation: source.v([...key, 'rotation']) ?? 0.0, + squash: source.v([...key, 'squash']) ?? 0.0, + ); + default: + break; + } + return null; + } +} +``` + +This creates the custom decoders needed for the widgets we are about to define. + +### Defining the core library  + +Create and update the following file located at `app/lib/rfw/core.dart`: + +``` +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:rfw/rfw.dart'; + +LocalWidgetLibrary createCoreWidgets() => + LocalWidgetLibrary(_coreWidgetsDefinitions); + +Map get _coreWidgetsDefinitions => + { + 'AnimationDefaults': (BuildContext context, DataSource source) { + return AnimationDefaults( + duration: ArgumentDecoders.duration(source, ['duration'], context), + curve: ArgumentDecoders.curve(source, ['curve'], context), + child: source.child(['child']), + ); + }, + 'Align': (BuildContext context, DataSource source) { + return AnimatedAlign( + duration: ArgumentDecoders.duration(source, ['duration'], context), + curve: ArgumentDecoders.curve(source, ['curve'], context), + alignment: ArgumentDecoders.alignment(source, ['alignment']) ?? + Alignment.center, + widthFactor: source.v(['widthFactor']), + heightFactor: source.v(['heightFactor']), + onEnd: source.voidHandler(['onEnd']), + child: source.optionalChild(['child']), + ); + }, + 'AspectRatio': (BuildContext context, DataSource source) { + return AspectRatio( + aspectRatio: source.v(['aspectRatio']) ?? 1.0, + child: source.optionalChild(['child']), + ); + }, + 'Center': (BuildContext context, DataSource source) { + return Center( + widthFactor: source.v(['widthFactor']), + heightFactor: source.v(['heightFactor']), + child: source.optionalChild(['child']), + ); + }, + 'ColoredBox': (BuildContext context, DataSource source) { + return ColoredBox( + color: ArgumentDecoders.color(source, ['color']) ?? + const Color(0xFF000000), + child: source.optionalChild(['child']), + ); + }, + 'Column': (BuildContext context, DataSource source) { + return Column( + mainAxisAlignment: ArgumentDecoders.enumValue( + MainAxisAlignment.values, source, ['mainAxisAlignment']) ?? + MainAxisAlignment.start, + mainAxisSize: ArgumentDecoders.enumValue( + MainAxisSize.values, source, ['mainAxisSize']) ?? + MainAxisSize.max, + crossAxisAlignment: ArgumentDecoders.enumValue( + CrossAxisAlignment.values, source, ['crossAxisAlignment']) ?? + CrossAxisAlignment.center, + textDirection: ArgumentDecoders.enumValue( + TextDirection.values, source, ['textDirection']), + verticalDirection: ArgumentDecoders.enumValue( + VerticalDirection.values, source, ['verticalDirection']) ?? + VerticalDirection.down, + textBaseline: ArgumentDecoders.enumValue( + TextBaseline.values, source, ['textBaseline']), + children: source.childList(['children']), + ); + }, + 'Container': (BuildContext context, DataSource source) { + return AnimatedContainer( + duration: ArgumentDecoders.duration(source, ['duration'], context), + curve: ArgumentDecoders.curve(source, ['curve'], context), + alignment: ArgumentDecoders.alignment(source, ['alignment']), + padding: ArgumentDecoders.edgeInsets(source, ['padding']), + color: ArgumentDecoders.color(source, ['color']), + decoration: ArgumentDecoders.decoration(source, ['decoration']), + foregroundDecoration: + ArgumentDecoders.decoration(source, ['foregroundDecoration']), + width: source.v(['width']), + height: source.v(['height']), + constraints: ArgumentDecoders.boxConstraints(source, ['constraints']), + margin: ArgumentDecoders.edgeInsets(source, ['margin']), + transform: ArgumentDecoders.matrix(source, ['transform']), + transformAlignment: + ArgumentDecoders.alignment(source, ['transformAlignment']), + clipBehavior: ArgumentDecoders.enumValue( + Clip.values, source, ['clipBehavior']) ?? + Clip.none, + onEnd: source.voidHandler(['onEnd']), + child: source.optionalChild(['child']), + ); + }, + 'DefaultTextStyle': (BuildContext context, DataSource source) { + return AnimatedDefaultTextStyle( + duration: ArgumentDecoders.duration(source, ['duration'], context), + curve: ArgumentDecoders.curve(source, ['curve'], context), + style: ArgumentDecoders.textStyle(source, ['style']) ?? + const TextStyle(), + textAlign: ArgumentDecoders.enumValue( + TextAlign.values, source, ['textAlign']), + softWrap: source.v(['softWrap']) ?? true, + overflow: ArgumentDecoders.enumValue( + TextOverflow.values, source, ['overflow']) ?? + TextOverflow.clip, + maxLines: source.v(['maxLines']), + textWidthBasis: ArgumentDecoders.enumValue( + TextWidthBasis.values, source, ['textWidthBasis']) ?? + TextWidthBasis.parent, + textHeightBehavior: ArgumentDecoders.textHeightBehavior( + source, ['textHeightBehavior']), + onEnd: source.voidHandler(['onEnd']), + child: source.child(['child']), + ); + }, + 'Directionality': (BuildContext context, DataSource source) { + return Directionality( + textDirection: ArgumentDecoders.enumValue( + TextDirection.values, source, ['textDirection']) ?? + TextDirection.ltr, + child: source.child(['child']), + ); + }, + 'Expanded': (BuildContext context, DataSource source) { + return Expanded( + flex: source.v(['flex']) ?? 1, + child: source.child(['child']), + ); + }, + 'FittedBox': (BuildContext context, DataSource source) { + return FittedBox( + fit: ArgumentDecoders.enumValue( + BoxFit.values, source, ['fit']) ?? + BoxFit.contain, + alignment: ArgumentDecoders.alignment(source, ['alignment']) ?? + Alignment.center, + clipBehavior: ArgumentDecoders.enumValue( + Clip.values, source, ['clipBehavior']) ?? + Clip.none, + child: source.optionalChild(['child']), + ); + }, + 'FractionallySizedBox': (BuildContext context, DataSource source) { + return FractionallySizedBox( + alignment: ArgumentDecoders.alignment(source, ['alignment']) ?? + Alignment.center, + widthFactor: source.v(['widthFactor']), + heightFactor: source.v(['heightFactor']), + child: source.child(['child']), + ); + }, + 'GestureDetector': (BuildContext context, DataSource source) { + return GestureDetector( + onTap: source.voidHandler(['onTap']), + onTapDown: source.handler(['onTapDown'], + (VoidCallback trigger) => (TapDownDetails details) => trigger()), + onTapUp: source.handler(['onTapUp'], + (VoidCallback trigger) => (TapUpDetails details) => trigger()), + onTapCancel: source.voidHandler(['onTapCancel']), + onDoubleTap: source.voidHandler(['onDoubleTap']), + onLongPress: source.voidHandler(['onLongPress']), + behavior: ArgumentDecoders.enumValue( + HitTestBehavior.values, source, ['behavior']), + child: source.optionalChild(['child']), + ); + }, + 'GridView': (BuildContext context, DataSource source) { + return GridView.builder( + scrollDirection: ArgumentDecoders.enumValue( + Axis.values, source, ['scrollDirection']) ?? + Axis.vertical, + reverse: source.v(['reverse']) ?? false, + primary: source.v(['primary']), + shrinkWrap: source.v(['shrinkWrap']) ?? false, + padding: ArgumentDecoders.edgeInsets(source, ['padding']), + gridDelegate: + ArgumentDecoders.gridDelegate(source, ['gridDelegate']) ?? + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2), + itemBuilder: (BuildContext context, int index) => + source.child(['children', index]), + itemCount: source.length(['children']), + addAutomaticKeepAlives: + source.v(['addAutomaticKeepAlives']) ?? true, + addRepaintBoundaries: + source.v(['addRepaintBoundaries']) ?? true, + addSemanticIndexes: source.v(['addSemanticIndexes']) ?? true, + cacheExtent: source.v(['cacheExtent']), + semanticChildCount: source.v(['semanticChildCount']), + dragStartBehavior: ArgumentDecoders.enumValue( + DragStartBehavior.values, source, ['dragStartBehavior']) ?? + DragStartBehavior.start, + keyboardDismissBehavior: + ArgumentDecoders.enumValue( + ScrollViewKeyboardDismissBehavior.values, + source, + ['keyboardDismissBehavior']) ?? + ScrollViewKeyboardDismissBehavior.manual, + restorationId: source.v(['restorationId']), + clipBehavior: ArgumentDecoders.enumValue( + Clip.values, source, ['clipBehavior']) ?? + Clip.hardEdge, + ); + }, + 'Icon': (BuildContext context, DataSource source) { + return Icon( + ArgumentDecoders.iconData(source, []) ?? Icons.flutter_dash, + size: source.v(['size']), + color: ArgumentDecoders.color(source, ['color']), + semanticLabel: source.v(['semanticLabel']), + textDirection: ArgumentDecoders.enumValue( + TextDirection.values, source, ['textDirection']), + ); + }, + 'IconTheme': (BuildContext context, DataSource source) { + return IconTheme( + data: ArgumentDecoders.iconThemeData(source, []) ?? + const IconThemeData(), + child: source.child(['child']), + ); + }, + 'IntrinsicHeight': (BuildContext context, DataSource source) { + return IntrinsicHeight( + child: source.optionalChild(['child']), + ); + }, + 'IntrinsicWidth': (BuildContext context, DataSource source) { + return IntrinsicWidth( + stepWidth: source.v(['width']), + stepHeight: source.v(['height']), + child: source.optionalChild(['child']), + ); + }, + 'Image': (BuildContext context, DataSource source) { + return Image( + image: ArgumentDecoders.imageProvider(source, []) ?? + const AssetImage('error.png'), + semanticLabel: source.v(['semanticLabel']), + excludeFromSemantics: + source.v(['excludeFromSemantics']) ?? false, + width: source.v(['width']), + height: source.v(['height']), + color: ArgumentDecoders.color(source, ['color']), + colorBlendMode: ArgumentDecoders.enumValue( + BlendMode.values, source, ['blendMode']), + fit: ArgumentDecoders.enumValue( + BoxFit.values, source, ['fit']), + alignment: ArgumentDecoders.alignment(source, ['alignment']) ?? + Alignment.center, + repeat: ArgumentDecoders.enumValue( + ImageRepeat.values, source, ['repeat']) ?? + ImageRepeat.noRepeat, + centerSlice: ArgumentDecoders.rect(source, ['centerSlice']), + matchTextDirection: source.v(['matchTextDirection']) ?? false, + gaplessPlayback: source.v(['gaplessPlayback']) ?? false, + isAntiAlias: source.v(['isAntiAlias']) ?? false, + filterQuality: ArgumentDecoders.enumValue( + FilterQuality.values, source, ['filterQuality']) ?? + FilterQuality.low, + ); + }, + 'ListBody': (BuildContext context, DataSource source) { + return ListBody( + mainAxis: ArgumentDecoders.enumValue( + Axis.values, source, ['mainAxis']) ?? + Axis.vertical, + reverse: source.v(['reverse']) ?? false, + children: source.childList(['children']), + ); + }, + 'ListView': (BuildContext context, DataSource source) { + return ListView.builder( + scrollDirection: ArgumentDecoders.enumValue( + Axis.values, source, ['scrollDirection']) ?? + Axis.vertical, + reverse: source.v(['reverse']) ?? false, + primary: source.v(['primary']), + shrinkWrap: source.v(['shrinkWrap']) ?? false, + padding: ArgumentDecoders.edgeInsets(source, ['padding']), + itemExtent: source.v(['itemExtent']), + prototypeItem: source.optionalChild(['prototypeItem']), + itemCount: source.length(['children']), + itemBuilder: (BuildContext context, int index) => + source.child(['children', index]), + clipBehavior: ArgumentDecoders.enumValue( + Clip.values, source, ['clipBehavior']) ?? + Clip.hardEdge, + addAutomaticKeepAlives: + source.v(['addAutomaticKeepAlives']) ?? true, + addRepaintBoundaries: + source.v(['addRepaintBoundaries']) ?? true, + addSemanticIndexes: source.v(['addSemanticIndexes']) ?? true, + cacheExtent: source.v(['cacheExtent']), + semanticChildCount: source.v(['semanticChildCount']), + dragStartBehavior: ArgumentDecoders.enumValue( + DragStartBehavior.values, source, ['dragStartBehavior']) ?? + DragStartBehavior.start, + keyboardDismissBehavior: + ArgumentDecoders.enumValue( + ScrollViewKeyboardDismissBehavior.values, + source, + ['keyboardDismissBehavior']) ?? + ScrollViewKeyboardDismissBehavior.manual, + restorationId: source.v(['restorationId']), + ); + }, + 'Opacity': (BuildContext context, DataSource source) { + return AnimatedOpacity( + duration: ArgumentDecoders.duration(source, ['duration'], context), + curve: ArgumentDecoders.curve(source, ['curve'], context), + opacity: source.v(['opacity']) ?? 0.0, + onEnd: source.voidHandler(['onEnd']), + alwaysIncludeSemantics: + source.v(['alwaysIncludeSemantics']) ?? true, + ); + }, + 'Padding': (BuildContext context, DataSource source) { + return AnimatedPadding( + duration: ArgumentDecoders.duration(source, ['duration'], context), + curve: ArgumentDecoders.curve(source, ['curve'], context), + padding: ArgumentDecoders.edgeInsets(source, ['padding']) ?? + EdgeInsets.zero, + onEnd: source.voidHandler(['onEnd']), + child: source.optionalChild(['child']), + ); + }, + 'Placeholder': (BuildContext context, DataSource source) { + return Placeholder( + color: ArgumentDecoders.color(source, ['color']) ?? + const Color(0xFF455A64), + strokeWidth: source.v(['strokeWidth']) ?? 2.0, + fallbackWidth: source.v(['placeholderWidth']) ?? 400.0, + fallbackHeight: source.v(['placeholderHeight']) ?? 400.0, + ); + }, + 'Positioned': (BuildContext context, DataSource source) { + return AnimatedPositionedDirectional( + duration: ArgumentDecoders.duration(source, ['duration'], context), + curve: ArgumentDecoders.curve(source, ['curve'], context), + start: source.v(['start']), + top: source.v(['top']), + end: source.v(['end']), + bottom: source.v(['bottom']), + width: source.v(['width']), + height: source.v(['height']), + onEnd: source.voidHandler(['onEnd']), + child: source.child(['child']), + ); + }, + 'Rotation': (BuildContext context, DataSource source) { + return AnimatedRotation( + duration: ArgumentDecoders.duration(source, ['duration'], context), + curve: ArgumentDecoders.curve(source, ['curve'], context), + turns: source.v(['turns']) ?? 0.0, + alignment: (ArgumentDecoders.alignment(source, ['alignment']) ?? + Alignment.center) + .resolve(Directionality.of(context)), + filterQuality: ArgumentDecoders.enumValue( + FilterQuality.values, source, ['filterQuality']), + onEnd: source.voidHandler(['onEnd']), + child: source.optionalChild(['child']), + ); + }, + 'Row': (BuildContext context, DataSource source) { + return Row( + mainAxisAlignment: ArgumentDecoders.enumValue( + MainAxisAlignment.values, source, ['mainAxisAlignment']) ?? + MainAxisAlignment.start, + mainAxisSize: ArgumentDecoders.enumValue( + MainAxisSize.values, source, ['mainAxisSize']) ?? + MainAxisSize.max, + crossAxisAlignment: ArgumentDecoders.enumValue( + CrossAxisAlignment.values, source, ['crossAxisAlignment']) ?? + CrossAxisAlignment.center, + textDirection: ArgumentDecoders.enumValue( + TextDirection.values, source, ['textDirection']), + verticalDirection: ArgumentDecoders.enumValue( + VerticalDirection.values, source, ['verticalDirection']) ?? + VerticalDirection.down, + textBaseline: ArgumentDecoders.enumValue( + TextBaseline.values, source, ['textBaseline']), + children: source.childList(['children']), + ); + }, + 'SafeArea': (BuildContext context, DataSource source) { + return SafeArea( + left: source.v(['left']) ?? true, + top: source.v(['top']) ?? true, + right: source.v(['right']) ?? true, + bottom: source.v(['bottom']) ?? true, + minimum: (ArgumentDecoders.edgeInsets(source, ['minimum']) ?? + EdgeInsets.zero) + .resolve(Directionality.of(context)), + maintainBottomViewPadding: + source.v(['maintainBottomViewPadding']) ?? false, + child: source.child(['child']), + ); + }, + 'Scale': (BuildContext context, DataSource source) { + return AnimatedScale( + duration: ArgumentDecoders.duration(source, ['duration'], context), + curve: ArgumentDecoders.curve(source, ['curve'], context), + scale: source.v(['scale']) ?? 1.0, + alignment: (ArgumentDecoders.alignment(source, ['alignment']) ?? + Alignment.center) + .resolve(Directionality.of(context)), + filterQuality: ArgumentDecoders.enumValue( + FilterQuality.values, source, ['filterQuality']), + onEnd: source.voidHandler(['onEnd']), + child: source.optionalChild(['child']), + ); + }, + 'SingleChildScrollView': (BuildContext context, DataSource source) { + return SingleChildScrollView( + scrollDirection: ArgumentDecoders.enumValue( + Axis.values, source, ['scrollDirection']) ?? + Axis.vertical, + reverse: source.v(['reverse']) ?? false, + padding: ArgumentDecoders.edgeInsets(source, ['padding']), + primary: source.v(['primary']) ?? true, + dragStartBehavior: ArgumentDecoders.enumValue( + DragStartBehavior.values, source, ['dragStartBehavior']) ?? + DragStartBehavior.start, + clipBehavior: ArgumentDecoders.enumValue( + Clip.values, source, ['clipBehavior']) ?? + Clip.hardEdge, + restorationId: source.v(['restorationId']), + keyboardDismissBehavior: + ArgumentDecoders.enumValue( + ScrollViewKeyboardDismissBehavior.values, + source, + ['keyboardDismissBehavior']) ?? + ScrollViewKeyboardDismissBehavior.manual, + child: source.optionalChild(['child']), + ); + }, + 'SizedBox': (BuildContext context, DataSource source) { + return SizedBox( + width: source.v(['width']), + height: source.v(['height']), + child: source.optionalChild(['child']), + ); + }, + 'SizedBoxExpand': (BuildContext context, DataSource source) { + return SizedBox.expand( + child: source.optionalChild(['child']), + ); + }, + 'SizedBoxShrink': (BuildContext context, DataSource source) { + return SizedBox.shrink( + child: source.optionalChild(['child']), + ); + }, + 'Spacer': (BuildContext context, DataSource source) { + return Spacer( + flex: source.v(['flex']) ?? 1, + ); + }, + 'Stack': (BuildContext context, DataSource source) { + return Stack( + alignment: ArgumentDecoders.alignment(source, ['alignment']) ?? + AlignmentDirectional.topStart, + textDirection: ArgumentDecoders.enumValue( + TextDirection.values, source, ['textDirection']), + fit: ArgumentDecoders.enumValue( + StackFit.values, source, ['fit']) ?? + StackFit.loose, + clipBehavior: ArgumentDecoders.enumValue( + Clip.values, source, ['clipBehavior']) ?? + Clip.hardEdge, + children: source.childList(['children']), + ); + }, + 'Text': (BuildContext context, DataSource source) { + String? text = source.v(['text']); + if (text == null) { + final StringBuffer builder = StringBuffer(); + final int count = source.length(['text']); + for (int index = 0; index < count; index += 1) { + builder.write(source.v(['text', index]) ?? ''); + } + text = builder.toString(); + } + return Text( + text, + style: ArgumentDecoders.textStyle(source, ['style']), + strutStyle: ArgumentDecoders.strutStyle(source, ['strutStyle']), + textAlign: ArgumentDecoders.enumValue( + TextAlign.values, source, ['textAlign']), + textDirection: ArgumentDecoders.enumValue( + TextDirection.values, source, ['textDirection']), + locale: ArgumentDecoders.locale(source, ['locale']), + softWrap: source.v(['softWrap']), + overflow: ArgumentDecoders.enumValue( + TextOverflow.values, source, ['overflow']), + textScaleFactor: source.v(['textScaleFactor']), + maxLines: source.v(['maxLines']), + semanticsLabel: source.v(['semanticsLabel']), + textWidthBasis: ArgumentDecoders.enumValue( + TextWidthBasis.values, source, ['textWidthBasis']), + textHeightBehavior: ArgumentDecoders.textHeightBehavior( + source, ['textHeightBehavior']), + ); + }, + 'Wrap': (BuildContext context, DataSource source) { + return Wrap( + direction: ArgumentDecoders.enumValue( + Axis.values, source, ['direction']) ?? + Axis.horizontal, + alignment: ArgumentDecoders.enumValue( + WrapAlignment.values, source, ['alignment']) ?? + WrapAlignment.start, + spacing: source.v(['spacing']) ?? 0.0, + runAlignment: ArgumentDecoders.enumValue( + WrapAlignment.values, source, ['runAlignment']) ?? + WrapAlignment.start, + runSpacing: source.v(['runSpacing']) ?? 0.0, + crossAxisAlignment: ArgumentDecoders.enumValue( + WrapCrossAlignment.values, source, ['crossAxisAlignment']) ?? + WrapCrossAlignment.start, + textDirection: ArgumentDecoders.enumValue( + TextDirection.values, source, ['textDirection']), + verticalDirection: ArgumentDecoders.enumValue( + VerticalDirection.values, source, ['verticalDirection']) ?? + VerticalDirection.down, + clipBehavior: ArgumentDecoders.enumValue( + Clip.values, source, ['clipBehavior']) ?? + Clip.none, + children: source.childList(['children']), + ); + }, + }; +``` + +This created all the core widgets that are not apart of a design system. Keeping these separate will let you update the design system separately than the core widgets. + +### Defining the Material library  + +Create and update the following file located at `app/lib/rfw/material.dart`: + +``` +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:rfw/rfw.dart'; + +import 'decoders.dart'; + +LocalWidgetLibrary createMaterialWidgets() => + LocalWidgetLibrary(_materialWidgetsDefinitions); + +Map get _materialWidgetsDefinitions => + { + 'AboutListTile': (context, source) { + return AboutListTile( + icon: source.optionalChild(['icon']), + applicationName: source.v(['applicationName']), + applicationVersion: source.v(['applicationVersion']), + applicationIcon: source.optionalChild(['applicationIcon']), + applicationLegalese: source.v(['applicationLegalese']), + aboutBoxChildren: source.childList(['aboutBoxChildren']), + dense: source.v(['dense']), + child: source.optionalChild(['child']), + ); + }, + 'AppBar': (context, source) { + return AppBar( + leading: source.optionalChild(['leading']), + automaticallyImplyLeading: + source.v(['automaticallyImplyLeading']) ?? true, + title: source.optionalChild(['title']), + actions: source.childList(['actions']), + elevation: source.v(['elevation']), + shadowColor: ArgumentDecoders.color(source, ['shadowColor']), + shape: ArgumentDecoders.shapeBorder(source, ['shape']), + backgroundColor: ArgumentDecoders.color(source, ['backgroundColor']), + foregroundColor: ArgumentDecoders.color(source, ['foregroundColor']), + iconTheme: ArgumentDecoders.iconThemeData(source, ['iconTheme']), + actionsIconTheme: + ArgumentDecoders.iconThemeData(source, ['actionsIconTheme']), + primary: source.v(['primary']) ?? true, + centerTitle: source.v(['centerTitle']), + excludeHeaderSemantics: + source.v(['excludeHeaderSemantics']) ?? false, + titleSpacing: source.v(['titleSpacing']), + toolbarOpacity: source.v(['toolbarOpacity']) ?? 1.0, + toolbarHeight: source.v(['toolbarHeight']), + leadingWidth: source.v(['leadingWidth']), + toolbarTextStyle: + ArgumentDecoders.textStyle(source, ['toolbarTextStyle']), + titleTextStyle: + ArgumentDecoders.textStyle(source, ['titleTextStyle']), + ); + }, + 'ButtonBar': (context, source) { + return ButtonBar( + alignment: ArgumentDecoders.enumValue( + MainAxisAlignment.values, source, ['alignment']) ?? + MainAxisAlignment.start, + mainAxisSize: ArgumentDecoders.enumValue( + MainAxisSize.values, source, ['mainAxisSize']) ?? + MainAxisSize.max, + buttonMinWidth: source.v(['buttonMinWidth']), + buttonHeight: source.v(['buttonHeight']), + buttonPadding: ArgumentDecoders.edgeInsets(source, ['buttonPadding']), + buttonAlignedDropdown: + source.v(['buttonAlignedDropdown']) ?? false, + layoutBehavior: ArgumentDecoders.enumValue( + ButtonBarLayoutBehavior.values, source, ['layoutBehavior']), + overflowDirection: ArgumentDecoders.enumValue( + VerticalDirection.values, source, ['overflowDirection']), + overflowButtonSpacing: source.v(['overflowButtonSpacing']), + children: source.childList(['children']), + ); + }, + 'Card': (context, source) { + return Card( + color: ArgumentDecoders.color(source, ['color']), + shadowColor: ArgumentDecoders.color(source, ['shadowColor']), + elevation: source.v(['elevation']), + shape: ArgumentDecoders.shapeBorder(source, ['shape']), + borderOnForeground: source.v(['borderOnForeground']) ?? true, + margin: ArgumentDecoders.edgeInsets(source, ['margin']), + clipBehavior: ArgumentDecoders.enumValue( + Clip.values, source, ['clipBehavior']) ?? + Clip.none, + semanticContainer: source.v(['semanticContainer']) ?? true, + child: source.optionalChild(['child']), + ); + }, + 'CircularProgressIndicator': (context, source) { + return CircularProgressIndicator( + value: source.v(['value']), + color: ArgumentDecoders.color(source, ['color']), + backgroundColor: ArgumentDecoders.color(source, ['backgroundColor']), + strokeWidth: source.v(['strokeWidth']) ?? 4.0, + semanticsLabel: source.v(['semanticsLabel']), + semanticsValue: source.v(['semanticsValue']), + ); + }, + 'Divider': (context, source) { + return Divider( + height: source.v(['height']), + thickness: source.v(['thickness']), + indent: source.v(['indent']), + endIndent: source.v(['endIndent']), + color: ArgumentDecoders.color(source, ['color']), + ); + }, + 'Drawer': (context, source) { + return Drawer( + elevation: source.v(['elevation']) ?? 16.0, + semanticLabel: source.v(['semanticLabel']), + child: source.optionalChild(['child']), + ); + }, + 'DrawerHeader': (context, source) { + return DrawerHeader( + duration: ArgumentDecoders.duration(source, ['duration'], context), + curve: ArgumentDecoders.curve(source, ['curve'], context), + decoration: ArgumentDecoders.decoration(source, ['decoration']), + margin: ArgumentDecoders.edgeInsets(source, ['margin']) ?? + const EdgeInsets.only(bottom: 8.0), + padding: ArgumentDecoders.edgeInsets(source, ['padding']) ?? + const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 8.0), + child: source.optionalChild(['child']), + ); + }, + 'ElevatedButton': (context, source) { + return ElevatedButton( + onPressed: source.voidHandler(['onPressed']), + onLongPress: source.voidHandler(['onLongPress']), + autofocus: source.v(['autofocus']) ?? false, + clipBehavior: ArgumentDecoders.enumValue( + Clip.values, source, ['clipBehavior']) ?? + Clip.none, + child: source.child(['child']), + ); + }, + 'InkWell': (context, source) { + return InkWell( + onTap: source.voidHandler(['onTap']), + onDoubleTap: source.voidHandler(['onDoubleTap']), + onLongPress: source.voidHandler(['onLongPress']), + onTapDown: source.handler(['onTapDown'], + (VoidCallback trigger) => (TapDownDetails details) => trigger()), + onTapCancel: source.voidHandler(['onTapCancel']), + radius: source.v(['radius']), + borderRadius: ArgumentDecoders.borderRadius(source, ['borderRadius']) + ?.resolve(Directionality.of(context)), + customBorder: ArgumentDecoders.shapeBorder(source, ['customBorder']), + enableFeedback: source.v(['enableFeedback']) ?? true, + excludeFromSemantics: + source.v(['excludeFromSemantics']) ?? false, + autofocus: source.v(['autofocus']) ?? false, + child: source.optionalChild(['child']), + ); + }, + 'LinearProgressIndicator': (context, source) { + return LinearProgressIndicator( + value: source.v(['value']), + color: ArgumentDecoders.color(source, ['color']), + backgroundColor: ArgumentDecoders.color(source, ['backgroundColor']), + minHeight: source.v(['minHeight']), + semanticsLabel: source.v(['semanticsLabel']), + semanticsValue: source.v(['semanticsValue']), + ); + }, + 'ListTile': (context, source) { + return ListTile( + leading: source.optionalChild(['leading']), + title: source.optionalChild(['title']), + subtitle: source.optionalChild(['subtitle']), + trailing: source.optionalChild(['trailing']), + isThreeLine: source.v(['isThreeLine']) ?? false, + dense: source.v(['dense']), + visualDensity: + ArgumentDecoders.visualDensity(source, ['visualDensity']), + shape: ArgumentDecoders.shapeBorder(source, ['shape']), + contentPadding: + ArgumentDecoders.edgeInsets(source, ['contentPadding']), + enabled: source.v(['enabled']) ?? true, + onTap: source.voidHandler(['onTap']), + onLongPress: source.voidHandler(['onLongPress']), + selected: source.v(['selected']) ?? false, + focusColor: ArgumentDecoders.color(source, ['focusColor']), + hoverColor: ArgumentDecoders.color(source, ['hoverColor']), + autofocus: source.v(['autofocus']) ?? false, + tileColor: ArgumentDecoders.color(source, ['tileColor']), + selectedTileColor: + ArgumentDecoders.color(source, ['selectedTileColor']), + enableFeedback: source.v(['enableFeedback']), + horizontalTitleGap: source.v(['horizontalTitleGap']), + minVerticalPadding: source.v(['minVerticalPadding']), + minLeadingWidth: source.v(['minLeadingWidth']), + ); + }, + 'Scaffold': (context, source) { + final Widget? appBarWidget = source.optionalChild(['appBar']); + final List persistentFooterButtons = + source.childList(['persistentFooterButtons']); + return Scaffold( + appBar: appBarWidget == null + ? null + : PreferredSize( + preferredSize: Size.fromHeight( + source.v(['bottomHeight']) ?? 56.0), + child: appBarWidget, + ), + body: source.optionalChild(['body']), + floatingActionButton: source.optionalChild(['floatingActionButton']), + persistentFooterButtons: + persistentFooterButtons.isEmpty ? null : persistentFooterButtons, + drawer: source.optionalChild(['drawer']), + endDrawer: source.optionalChild(['endDrawer']), + bottomNavigationBar: source.optionalChild(['bottomNavigationBar']), + bottomSheet: source.optionalChild(['bottomSheet']), + backgroundColor: ArgumentDecoders.color(source, ['backgroundColor']), + resizeToAvoidBottomInset: + source.v(['resizeToAvoidBottomInset']), + primary: source.v(['primary']) ?? true, + drawerDragStartBehavior: + ArgumentDecoders.enumValue( + DragStartBehavior.values, + source, + ['drawerDragStartBehavior']) ?? + DragStartBehavior.start, + extendBody: source.v(['extendBody']) ?? false, + extendBodyBehindAppBar: + source.v(['extendBodyBehindAppBar']) ?? false, + drawerScrimColor: + ArgumentDecoders.color(source, ['drawerScrimColor']), + drawerEdgeDragWidth: source.v(['drawerEdgeDragWidth']), + drawerEnableOpenDragGesture: + source.v(['drawerEnableOpenDragGesture']) ?? true, + endDrawerEnableOpenDragGesture: + source.v(['endDrawerEnableOpenDragGesture']) ?? true, + restorationId: source.v(['restorationId']), + ); + }, + 'VerticalDivider': (context, source) { + return VerticalDivider( + width: source.v(['width']), + thickness: source.v(['thickness']), + indent: source.v(['indent']), + endIndent: source.v(['endIndent']), + color: ArgumentDecoders.color(source, ['color']), + ); + }, + 'FilledButton': (context, source) { + return FilledButton( + onPressed: source.voidHandler(['onPressed']), + onLongPress: source.voidHandler(['onLongPress']), + autofocus: source.v(['autofocus']) ?? false, + clipBehavior: ArgumentDecoders.enumValue( + Clip.values, source, ['clipBehavior']) ?? + Clip.none, + style: CustomArgumentDecoders.filledButtonStyle( + source, ['style'], context), + child: source.child(['child']), + ); + }, + 'FilledButtonIcon': (context, source) { + return FilledButton.icon( + onPressed: source.voidHandler(['onPressed']), + onLongPress: source.voidHandler(['onLongPress']), + autofocus: source.v(['autofocus']) ?? false, + clipBehavior: ArgumentDecoders.enumValue( + Clip.values, source, ['clipBehavior']) ?? + Clip.none, + style: CustomArgumentDecoders.filledButtonStyle( + source, ['style'], context), + icon: source.child(['icon']), + label: source.child(['label']), + ); + }, + 'FilledButtonTonal': (context, source) { + return FilledButton.tonal( + onPressed: source.voidHandler(['onPressed']), + onLongPress: source.voidHandler(['onLongPress']), + autofocus: source.v(['autofocus']) ?? false, + clipBehavior: ArgumentDecoders.enumValue( + Clip.values, source, ['clipBehavior']) ?? + Clip.none, + style: CustomArgumentDecoders.filledButtonStyle( + source, ['style'], context), + child: source.child(['child']), + ); + }, + 'FilledButtonTonalIcon': (context, source) { + return FilledButton.tonalIcon( + onPressed: source.voidHandler(['onPressed']), + onLongPress: source.voidHandler(['onLongPress']), + autofocus: source.v(['autofocus']) ?? false, + clipBehavior: ArgumentDecoders.enumValue( + Clip.values, source, ['clipBehavior']) ?? + Clip.none, + style: CustomArgumentDecoders.filledButtonStyle( + source, ['style'], context), + icon: source.child(['icon']), + label: source.child(['label']), + ); + }, + 'TextButton': (context, source) { + return TextButton( + onPressed: source.voidHandler(['onPressed']), + onLongPress: source.voidHandler(['onLongPress']), + autofocus: source.v(['autofocus']) ?? false, + clipBehavior: ArgumentDecoders.enumValue( + Clip.values, source, ['clipBehavior']) ?? + Clip.none, + style: CustomArgumentDecoders.textButtonStyle( + source, ['style'], context), + child: source.child(['child']), + ); + }, + 'TextButtonIcon': (context, source) { + return TextButton.icon( + onPressed: source.voidHandler(['onPressed']), + onLongPress: source.voidHandler(['onLongPress']), + autofocus: source.v(['autofocus']) ?? false, + clipBehavior: ArgumentDecoders.enumValue( + Clip.values, source, ['clipBehavior']) ?? + Clip.none, + style: CustomArgumentDecoders.textButtonStyle( + source, ['style'], context), + icon: source.child(['icon']), + label: source.child(['label']), + ); + }, + 'OutlinedButton': (context, source) { + return OutlinedButton( + onPressed: source.voidHandler(['onPressed']), + onLongPress: source.voidHandler(['onLongPress']), + autofocus: source.v(['autofocus']) ?? false, + clipBehavior: ArgumentDecoders.enumValue( + Clip.values, source, ['clipBehavior']) ?? + Clip.none, + style: CustomArgumentDecoders.outlinedButtonStyle( + source, ['style'], context), + child: source.child(['child']), + ); + }, + 'OutlinedButtonIcon': (context, source) { + return OutlinedButton.icon( + onPressed: source.voidHandler(['onPressed']), + onLongPress: source.voidHandler(['onLongPress']), + autofocus: source.v(['autofocus']) ?? false, + clipBehavior: ArgumentDecoders.enumValue( + Clip.values, source, ['clipBehavior']) ?? + Clip.none, + style: CustomArgumentDecoders.outlinedButtonStyle( + source, ['style'], context), + icon: source.child(['icon']), + label: source.child(['label']), + ); + }, + 'FloatingActionButton': (context, source) { + return FloatingActionButton( + onPressed: source.voidHandler(['onPressed']), + tooltip: source.v(['tooltip']), + foregroundColor: ArgumentDecoders.color(source, ['foregroundColor']), + backgroundColor: ArgumentDecoders.color(source, ['backgroundColor']), + focusColor: ArgumentDecoders.color(source, ['focusColor']), + hoverColor: ArgumentDecoders.color(source, ['hoverColor']), + splashColor: ArgumentDecoders.color(source, ['splashColor']), + heroTag: source.v(['heroTag']), + elevation: source.v(['elevation']), + focusElevation: source.v(['focusElevation']), + hoverElevation: source.v(['hoverElevation']), + highlightElevation: source.v(['highlightElevation']), + disabledElevation: source.v(['disabledElevation']), + mini: source.v(['mini']) ?? false, + shape: ArgumentDecoders.shapeBorder(source, ['shape']), + clipBehavior: ArgumentDecoders.enumValue( + Clip.values, source, ['clipBehavior']) ?? + Clip.none, + autofocus: source.v(['autofocus']) ?? false, + mouseCursor: + CustomArgumentDecoders.mouseCursor(source, ['mouseCursor']), + materialTapTargetSize: + ArgumentDecoders.enumValue( + MaterialTapTargetSize.values, + source, + ['materialTapTargetSize']), + isExtended: source.v(['isExtended']) ?? false, + enableFeedback: source.v(['enableFeedback']), + child: source.child(['child']), + ); + }, + 'FloatingActionButtonExtended': (context, source) { + return FloatingActionButton.extended( + onPressed: source.voidHandler(['onPressed']), + tooltip: source.v(['tooltip']), + foregroundColor: ArgumentDecoders.color(source, ['foregroundColor']), + backgroundColor: ArgumentDecoders.color(source, ['backgroundColor']), + focusColor: ArgumentDecoders.color(source, ['focusColor']), + hoverColor: ArgumentDecoders.color(source, ['hoverColor']), + splashColor: ArgumentDecoders.color(source, ['splashColor']), + heroTag: source.v(['heroTag']), + elevation: source.v(['elevation']), + focusElevation: source.v(['focusElevation']), + hoverElevation: source.v(['hoverElevation']), + highlightElevation: source.v(['highlightElevation']), + disabledElevation: source.v(['disabledElevation']), + shape: ArgumentDecoders.shapeBorder(source, ['shape']), + clipBehavior: ArgumentDecoders.enumValue( + Clip.values, source, ['clipBehavior']) ?? + Clip.none, + autofocus: source.v(['autofocus']) ?? false, + mouseCursor: + CustomArgumentDecoders.mouseCursor(source, ['mouseCursor']), + materialTapTargetSize: + ArgumentDecoders.enumValue( + MaterialTapTargetSize.values, + source, + ['materialTapTargetSize']), + isExtended: source.v(['isExtended']) ?? true, + enableFeedback: source.v(['enableFeedback']), + label: source.child(['label']), + icon: source.child(['icon']), + ); + }, + 'FloatingActionButtonSmall': (context, source) { + return FloatingActionButton.small( + onPressed: source.voidHandler(['onPressed']), + tooltip: source.v(['tooltip']), + foregroundColor: ArgumentDecoders.color(source, ['foregroundColor']), + backgroundColor: ArgumentDecoders.color(source, ['backgroundColor']), + focusColor: ArgumentDecoders.color(source, ['focusColor']), + hoverColor: ArgumentDecoders.color(source, ['hoverColor']), + splashColor: ArgumentDecoders.color(source, ['splashColor']), + heroTag: source.v(['heroTag']), + elevation: source.v(['elevation']), + focusElevation: source.v(['focusElevation']), + hoverElevation: source.v(['hoverElevation']), + highlightElevation: source.v(['highlightElevation']), + disabledElevation: source.v(['disabledElevation']), + shape: ArgumentDecoders.shapeBorder(source, ['shape']), + clipBehavior: ArgumentDecoders.enumValue( + Clip.values, source, ['clipBehavior']) ?? + Clip.none, + autofocus: source.v(['autofocus']) ?? false, + mouseCursor: + CustomArgumentDecoders.mouseCursor(source, ['mouseCursor']), + materialTapTargetSize: + ArgumentDecoders.enumValue( + MaterialTapTargetSize.values, + source, + ['materialTapTargetSize']), + enableFeedback: source.v(['enableFeedback']), + child: source.child(['child']), + ); + }, + 'FloatingActionButtonLarge': (context, source) { + return FloatingActionButton.large( + onPressed: source.voidHandler(['onPressed']), + tooltip: source.v(['tooltip']), + foregroundColor: ArgumentDecoders.color(source, ['foregroundColor']), + backgroundColor: ArgumentDecoders.color(source, ['backgroundColor']), + focusColor: ArgumentDecoders.color(source, ['focusColor']), + hoverColor: ArgumentDecoders.color(source, ['hoverColor']), + splashColor: ArgumentDecoders.color(source, ['splashColor']), + heroTag: source.v(['heroTag']), + elevation: source.v(['elevation']), + focusElevation: source.v(['focusElevation']), + hoverElevation: source.v(['hoverElevation']), + highlightElevation: source.v(['highlightElevation']), + disabledElevation: source.v(['disabledElevation']), + shape: ArgumentDecoders.shapeBorder(source, ['shape']), + clipBehavior: ArgumentDecoders.enumValue( + Clip.values, source, ['clipBehavior']) ?? + Clip.none, + autofocus: source.v(['autofocus']) ?? false, + mouseCursor: + CustomArgumentDecoders.mouseCursor(source, ['mouseCursor']), + materialTapTargetSize: + ArgumentDecoders.enumValue( + MaterialTapTargetSize.values, + source, + ['materialTapTargetSize']), + enableFeedback: source.v(['enableFeedback']), + child: source.child(['child']), + ); + }, + 'InteractiveViewer': (context, source) { + return InteractiveViewer( + clipBehavior: ArgumentDecoders.enumValue( + Clip.values, source, ['clipBehavior']) ?? + Clip.none, + alignPanAxis: source.v(['alignPanAxis']) ?? false, + panAxis: ArgumentDecoders.enumValue( + PanAxis.values, source, ['panAxis']) ?? + PanAxis.free, + boundaryMargin: + CustomArgumentDecoders.edgeInsets(source, ['boundaryMargin']) ?? + EdgeInsets.zero, + constrained: source.v(['constrained']) ?? true, + maxScale: source.v(['maxScale']) ?? 2.5, + minScale: source.v(['minScale']) ?? 0.8, + interactionEndFrictionCoefficient: + source.v(['interactionEndFrictionCoefficient']) ?? + 0.0000135, + panEnabled: source.v(['panEnabled']) ?? true, + scaleEnabled: source.v(['scaleEnabled']) ?? true, + scaleFactor: source.v(['scaleFactor']) ?? 0.8, + alignment: + ArgumentDecoders.alignment(source, ['alignment']) as Alignment?, + trackpadScrollCausesScale: + source.v(['trackpadScrollCausesScale']) ?? false, + child: source.child(['child']), + ); + }, + }; +``` + +In this example we are using the [Material](https://m3.material.io/) widgets but this could be the [fluent\_ui](https://pub.dev/packages/fluent_ui) or [macos\_ui](https://pub.dev/packages/macos_ui) package set of components or even your custom design system. + +Now that we have defined the core of rfw we can start to add the logic and UI for our app. + +> The rfw package includes material and core directly so you can simply import it directly and not need to define like this. + +### Connecting to the server  + +Create and update the following file located at `app/lib/main.dart`: + +``` +import 'package:flutter/material.dart'; + +import 'network.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter SSR Example', + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + useMaterial3: true, + ), + home: const NetworkExample(), + ); + } +} +``` + +Next create and update the following file located at `app/lib/network.dart`: + +``` +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; + +import 'package:rfw/rfw.dart'; + +import 'rfw/material.dart' as m; +import 'rfw/core.dart' as c; + +class NetworkExample extends StatefulWidget { + const NetworkExample({super.key}); + + @override + State createState() => _NetworkExampleState(); +} + +class _NetworkExampleState extends State { + final Runtime _runtime = Runtime(); + final DynamicContent _data = DynamicContent(); + bool loaded = false; + int count = 0; + final route = Uri.parse('http://localhost:8080/'); + + @override + void initState() { + super.initState(); + _update(); + } + + @override + void reassemble() { + super.reassemble(); + _update(); + } + + static const coreName = LibraryName(['widgets']); + static const materialName = LibraryName(['material']); + static const remoteName = LibraryName(['remote']); + + void _update() async { + _runtime.update(coreName, c.createCoreWidgets()); + _runtime.update(materialName, m.createMaterialWidgets()); + await fetchWidget(); + if (mounted) setState(() => loaded = true); + } + + Future fetchWidget() async { + final res = await http.get(route, headers: { + 'COUNTER_VALUE': count.toString(), + }); + if (res.statusCode == 200) { + count = int.tryParse(res.headers['counter_value'].toString()) ?? count; + _data.update('counter', {'value': '$count'}); + _runtime.update(remoteName, decodeLibraryBlob(res.bodyBytes)); + } + } + + void onEvent(String name, DynamicMap arguments) async { + debugPrint('user triggered event "$name" with data: $arguments'); + if (name == 'click') { + final res = await http.post(route, headers: { + 'COUNTER_VALUE': count.toString(), + }); + if (res.statusCode == 200) { + count = int.tryParse(res.headers['counter_value'].toString()) ?? count; + _data.update('counter', {'value': '$count'}); + _runtime.update(remoteName, decodeLibraryBlob(res.bodyBytes)); + } + } + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + _data.update('colorScheme', { + 'inversePrimary': colors.inversePrimary.value, + 'inverseSurface': colors.inverseSurface.value, + 'onInverseSurface': colors.onInverseSurface.value, + 'primary': colors.primary.value, + 'onPrimary': colors.onPrimary.value, + 'primaryContainer': colors.primaryContainer.value, + 'onPrimaryContainer': colors.onPrimaryContainer.value, + 'secondary': colors.secondary.value, + 'onSecondary': colors.onSecondary.value, + 'secondaryContainer': colors.secondaryContainer.value, + 'onSecondaryContainer': colors.onSecondaryContainer.value, + 'tertiary': colors.tertiary.value, + 'onTertiary': colors.onTertiary.value, + 'tertiaryContainer': colors.tertiaryContainer.value, + 'onTertiaryContainer': colors.onTertiaryContainer.value, + 'error': colors.error.value, + 'onError': colors.onError.value, + 'errorContainer': colors.errorContainer.value, + 'onErrorContainer': colors.onErrorContainer.value, + 'background': colors.background.value, + 'onBackground': colors.onBackground.value, + 'surface': colors.surface.value, + 'onSurface': colors.onSurface.value, + 'outline': colors.outline.value, + 'outlineVariant': colors.outlineVariant.value, + 'scrim': colors.scrim.value, + 'shadow': colors.shadow.value, + }); + if (!loaded) { + return const Center(child: CircularProgressIndicator()); + } + const root = FullyQualifiedWidgetName(remoteName, 'root'); + return Container( + color: Theme.of(context).scaffoldBackgroundColor, + child: RemoteWidget( + runtime: _runtime, + data: _data, + widget: root, + onEvent: onEvent, + ), + ); + } +} +``` + +There is a lot going on here but simply we are connecting to our server and defining the local widgets we created and creating a local counter state that is used to send to the server and update per the response. + +Because the `DynamicContent _data` can be updated every frame we are setting the local colors from the apps current theme int he build method: + +``` +_data.update('colorScheme', { + 'inversePrimary': colors.inversePrimary.value, + 'inverseSurface': colors.inverseSurface.value, + 'onInverseSurface': colors.onInverseSurface.value, + 'primary': colors.primary.value, + 'onPrimary': colors.onPrimary.value, + 'primaryContainer': colors.primaryContainer.value, + 'onPrimaryContainer': colors.onPrimaryContainer.value, + 'secondary': colors.secondary.value, + 'onSecondary': colors.onSecondary.value, + 'secondaryContainer': colors.secondaryContainer.value, + 'onSecondaryContainer': colors.onSecondaryContainer.value, + 'tertiary': colors.tertiary.value, + 'onTertiary': colors.onTertiary.value, + 'tertiaryContainer': colors.tertiaryContainer.value, + 'onTertiaryContainer': colors.onTertiaryContainer.value, + 'error': colors.error.value, + 'onError': colors.onError.value, + 'errorContainer': colors.errorContainer.value, + 'onErrorContainer': colors.onErrorContainer.value, + 'background': colors.background.value, + 'onBackground': colors.onBackground.value, + 'surface': colors.surface.value, + 'onSurface': colors.onSurface.value, + 'outline': colors.outline.value, + 'outlineVariant': colors.outlineVariant.value, + 'scrim': colors.scrim.value, + 'shadow': colors.shadow.value, + }); +``` + +When we first load the UI we want to make a `GET` request to get the latest from the server or fallback to the latest in cache (or even `rootBundle`): + +``` +Future fetchWidget() async { + final res = await http.get(route, headers: { + 'COUNTER_VALUE': count.toString(), + }); + if (res.statusCode == 200) { + count = int.tryParse(res.headers['counter_value'].toString()) ?? count; + _data.update('counter', {'value': '$count'}); + _runtime.update(remoteName, decodeLibraryBlob(res.bodyBytes)); + } + } +``` + +Setting and reading the headers will allow us to send state to the server and respond on updates. + +We can also respond to events in the UI and trigger requests: + +``` +void onEvent(String name, DynamicMap arguments) async { + debugPrint('user triggered event "$name" with data: $arguments'); + if (name == 'click') { + final res = await http.post(route, headers: { + 'COUNTER_VALUE': count.toString(), + }); + if (res.statusCode == 200) { + count = int.tryParse(res.headers['counter_value'].toString()) ?? count; + _data.update('counter', {'value': '$count'}); + _runtime.update(remoteName, decodeLibraryBlob(res.bodyBytes)); + } + } + } +``` + +Here we are using the response of the `POST` request to update the UI but we could also call the `fetchWidget` method again to get the latest and use the headers to update the data. + +To run the application simply run using `flutter run` and make sure if you use MacOS desktop target to set the correct network permissions. + +If all goes well you should see the following: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/66f9r3p1wf2d318/r_1_phzhlfqsnj.webp?thumb=) + +This will trigger network requests for each button press and the UI will reflect the logic done on the server. + +You could also call a database and update the UI based on the response. + +## Conclusion  + +There is a lot more we can do with this example but after doing a deep dive on the format I thought it would be useful for others to understand and see some examples. + +The final code can be found [here](https://github.com/rodydavis/flutter_ssr). + +If you have any questions reach out to me on [Twitter](https://twitter.com/rodydavis) or [Github](https://github.com/rodydavis)! \ No newline at end of file diff --git a/skills/flutter_git-worktree-channels/SKILL.md b/skills/flutter_git-worktree-channels/SKILL.md new file mode 100644 index 0000000..d68f842 --- /dev/null +++ b/skills/flutter_git-worktree-channels/SKILL.md @@ -0,0 +1,76 @@ +--- +name: how-to-manage-multiple-flutter-versions-with-git-worktrees-and-z +description: Manage multiple Flutter versions efficiently using Git worktrees, eliminating the need for external version managers like FVM. +metadata: + url: https://rodydavis.com/posts/flutter/git-worktree-channels + last_modified: Tue, 03 Feb 2026 20:04:34 GMT +--- + +# How to Manage Multiple Flutter Versions with Git Worktrees and ZSH + + +If you have been using [Flutter](https://flutter.dev/) for any length of time then you probably have needed to use multiple flutter versions across multiple projects. + +In the past I used to use [FVM](https://fvm.app/) (Flutter Version Management) which is similar to [NVM](https://github.com/nvm-sh/nvm) (Node Version Manager) in the JS world. + +I wanted a solution that only relied on [Git](https://git-scm.com/), and started using [worktrees](https://git-scm.com/docs/git-worktree) to manage the Flutter channels. + +## Download the SDK  + +Check out the flutter repo in a known directory, in this case I will download it to `~/Developer/`: + +``` +git clone https://github.com/flutter/flutter ~/Developer/flutter +``` + +## Add Flutter Channels  + +Now we can add the branches we want to track: + +``` +cd ~/Developer/flutter +git checkout origin/dev +git worktree add ../flutter-stable stable +git worktree add ../flutter-beta beta +git worktree add ../flutter-master master +``` + +We need to checkout the dev channel to allow us to create the worktree for the master branch. This will keep the `flutter` directory separate so we can work on PRs and apply local changes. + +After this runs we should have 4 directories: `flutter`, `flutter-master`, `flutter-beta` and `flutter-stable`. + +## Add ZSH Alias for each Channel  + +Now we need a way to reference each SDK on the fly with an [alias in ZSH](https://github.com/rothgar/mastering-zsh/blob/master/docs/helpers/aliases.md). Add the following to `~/.zshrc`: + +``` +alias flutter-master='~/Developer/flutter-master/bin/flutter' +alias dart-master='~/Developer/flutter-master/bin/dart' + +alias flutter-beta='~/Developer/flutter-beta/bin/flutter' +alias dart-beta='~/Developer/flutter-beta/bin/dart' + +alias flutter-stable='~/Developer/flutter-stable/bin/flutter' +alias dart-stable='~/Developer/flutter-stable/bin/dart' +``` + +## Conclusion  + +After reopening the terminal, you can verify it is working by running (or add any channel we added above): + +``` +flutter-master doctor +dart-master --version + +flutter-stable doctor +dart-stable --version +``` + +You can update any of the channels by navigating to the directory of the worktree for the given channel and pulling changes like any other Git repo. + +``` +cd ~/Developer/flutter-master +git checkout origin/master +``` + +Git worktrees are just a way to checkout multiple branches as separate folders instead of needing to stash changes. \ No newline at end of file diff --git a/skills/host-flutter-rest-api/SKILL.md b/skills/host-flutter-rest-api/SKILL.md new file mode 100644 index 0000000..5fc9b11 --- /dev/null +++ b/skills/host-flutter-rest-api/SKILL.md @@ -0,0 +1,696 @@ +--- +name: host-your-flutter-project-as-a-rest-api +description: Learn how to structure a Flutter project to reuse models and business logic across iOS, Android, Web, desktop platforms, and a REST API deployable to Google Cloud Run, enabling a single codebase for both client and server. +metadata: + url: https://rodydavis.com/posts/host-flutter-rest-api + last_modified: Tue, 03 Feb 2026 20:04:22 GMT +--- + +# Host your Flutter Project as a REST API + + +After you build your flutter project you may want to reuse the models and business logic from your lib folder. I will show you how to go about setting up the project to have iOS, Android, Web, Windows, MacOS, Linux and a REST API interface with one project. The REST API can also be deploy to Google Cloud Run for Dart everywhere. + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/4uq151209yp9i27/r_1_gl7e2erkta.gif?thumb=) + +> One Codebase for Client and Sever. + +This will allow you to expose your Dart models as a REST API and run your business logic from your lib folder while the application runs the models as they are. [Here](https://github.com/rodydavis/shared_dart) is the final project. + +## Setting Up  + +As with any Flutter project I am going to assume that you already have [Flutter](https://flutter.dev/) installed on your machine and that you can create a project. This is a intermediate level difficulty so read on if you are up to the challenge. You will also need to know the basics of [Docker](https://www.docker.com/). + +## Why one project?  + +It may not be obvious but when building complex applications you will at some point have a server and an application that calls that server. [Firebase](https://firebase.google.com/) is an excellent option for doing this and I use it in almost all my projects. [Firebase Functions](https://firebase.google.com/products/functions/) are really powerful but you are limited by Javascript or Typescript. What if you could use the same packages that you are using in the Flutter project, or better yet what if they both used the same? + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/102isxx047c7165/r_2_t53hqci5ox.gif?thumb=) + +When you have a server project and a client project that communicate over a rest api or client sdk like Firebase then you will run into the problem that the server has models of objects stored and the client has models of the objects that are stored. This can lead to a serious mismatch when it changed without you knowing. GraphQL helps a lot with this since you define the model that you recieve. This approach allows your business logic to be always up to date for both the client and server. + +## Client Setup  + +The first step is to just build your application. The only difference that we will make is keeping the UI and business logic separate. When starting out with Flutter it can be very easy to throw all the logic into the screen and calling setState when the data changes. Even the application when creating a new Flutter project does this. That's why [choosing a state management solution](https://flutter.dev/docs/development/data-and-backend/state-mgmt/options) is so important. + +To make things clean and concise we will make 2 folders in our lib folder. + +* ui for all Flutter Widgets and Screens + +* src for all business logic, classes, models and utility functions + + +This will leave us with main.dart being only the entry point into our client application. + +``` +import 'package:flutter/material.dart'; + +import 'plugins/desktop/desktop.dart'; +import 'ui/home/screen.dart'; + +void main() { + runApp(MyApp()); +} + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + theme: ThemeData.light(), + darkTheme: ThemeData.dark(), + home: HomeScreen(), + ); + } +} +``` + +Let’s Start by making a tab bar for the 2 screens. Create a file in the folder ui/home/screen.dart and add the following: + +``` +import 'package:flutter/material.dart'; + +import '../counter/screen.dart'; +import '../todo/screen.dart'; + +class HomeScreen extends StatefulWidget { + @override + _HomeScreenState createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + int _currentIndex = 0; + +@override + Widget build(BuildContext context) { + return Scaffold( + body: IndexedStack( + index: _currentIndex, + children: [ + CounterScreen(), + TodosScreen(), + ], + ), + bottomNavigationBar: BottomNavigationBar( + currentIndex: _currentIndex, + onTap: (val) { + if (mounted) + setState(() { + _currentIndex = val; + }); + }, + type: BottomNavigationBarType.fixed, + items: [ + BottomNavigationBarItem( + icon: Icon(Icons.add), + title: Text('Counter'), + ), + BottomNavigationBarItem( + icon: Icon(Icons.list), + title: Text('Todos'), + ), + ], + ), + ); + } +} +``` + +This is just a basic screen and should look very normal. + +### Counter Example + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/4pyuti80j9deb0w/r_3_pwj25p3nfr.webp?thumb=) + +Now create a file ui/counter/screen.dart and add the following: + +``` +import 'package:flutter/material.dart'; +import 'package:shared_dart/src/models/counter.dart'; + +class CounterScreen extends StatefulWidget { + @override + _CounterScreenState createState() => _CounterScreenState(); +} + +class _CounterScreenState extends State { + CounterModel _counterModel = CounterModel(); + +void _incrementCounter() { + setState(() { + // This call to setState tells the Flutter framework that something has + // changed in this State, which causes it to rerun the build method below + // so that the display can reflect the updated values. If we changed + // _counter without calling setState(), then the build method would not be + // called again, and so nothing would appear to happen. + _counterModel.add(); + }); + } + +@override + Widget build(BuildContext context) { + // This method is rerun every time setState is called, for instance as done + // by the _incrementCounter method above. + // + // The Flutter framework has been optimized to make rerunning build methods + // fast, so that you can just rebuild anything that needs updating rather + // than having to individually change instances of widgets. + return Scaffold( + appBar: AppBar( + // Here we take the value from the MyCounterPage object that was created by + // the App.build method, and use it to set our appbar title. + title: Text('Counter Screen'), + ), + body: Center( + // Center is a layout widget. It takes a single child and positions it + // in the middle of the parent. + child: Column( + // Column is also a layout widget. It takes a list of children and + // arranges them vertically. By default, it sizes itself to fit its + // children horizontally, and tries to be as tall as its parent. + // + // Invoke "debug painting" (press "p" in the console, choose the + // "Toggle Debug Paint" action from the Flutter Inspector in Android + // Studio, or the "Toggle Debug Paint" command in Visual Studio Code) + // to see the wireframe for each widget. + // + // Column has various properties to control how it sizes itself and + // how it positions its children. Here we use mainAxisAlignment to + // center the children vertically; the main axis here is the vertical + // axis because Columns are vertical (the cross axis would be + // horizontal). + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'You have pushed the button this many times:', + ), + Text( + '${_counterModel.count}', + style: Theme.of(context).textTheme.display1, + ), + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: _incrementCounter, + tooltip: 'Increment', + child: Icon(Icons.add), + ), // This trailing comma makes auto-formatting nicer for build methods. + ); + } +} +``` + +This is the default counter app you get when you create a Flutter application but with one change, it uses `CounterModel` to hold the logic. + +Create the counter model at src/models/counter.dart and add the following: + +``` +class CounterModel { + CounterModel(); + + int _count = 0; + + int get count => _count; + + void add() => _count++; + + void subtract() => _count--; + + void set(int val) => _count = val; +} +``` + +As you can see it is really easy to expose only what we want to while still having complete flexibility. You could use provider here if you choose, or even bloc and/or streams. + +### Todo Example + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/eir0lt42913ca5d/r_4_4cia0ajhj0.webp?thumb=) + +Lets create a file at ui/todos/screen.dart and add the following: + +``` +import 'package:flutter/material.dart'; + +import '../../src/classes/todo.dart'; +import '../../src/models/todos.dart'; + +class TodosScreen extends StatefulWidget { + @override + _TodosScreenState createState() => _TodosScreenState(); +} + +class _TodosScreenState extends State { + final _model = TodosModel(); + List _todos; + +@override + void initState() { + _model.getList().then((val) { + if (mounted) + setState(() { + _todos = val; + }); + }); + super.initState(); + } + +@override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Todos Screen'), + ), + body: Builder( + builder: (_) { + if (_todos != null) { + return ListView.builder( + itemCount: _todos.length, + itemBuilder: (context, index) { + final _item = _todos[index]; + return ListTile( + title: Text(_item.title), + subtitle: Text(_item.completed ? 'Completed' : 'Pending'), + ); + }, + ); + } + return Center( + child: CircularProgressIndicator(), + ); + }, + ), + ); + } +} +``` + +You will see that we have the logic in TodosModel and uses the class ToDo for toJson and fromJson. + +Create a file at the location src/classes/todo.dart and add the following: + +``` +// To parse this JSON data, do +// +// final toDo = toDoFromJson(jsonString); + +import 'dart:convert'; + +List toDoFromJson(String str) => List.from(json.decode(str).map((x) => ToDo.fromJson(x))); + +String toDoToJson(List data) => json.encode(List.from(data.map((x) => x.toJson()))); + +class ToDo { + int userId; + int id; + String title; + bool completed; + +ToDo({ + this.userId, + this.id, + this.title, + this.completed, + }); + +factory ToDo.fromJson(Map json) => ToDo( + userId: json["userId"], + id: json["id"], + title: json["title"], + completed: json["completed"], + ); + +Map toJson() => { + "userId": userId, + "id": id, + "title": title, + "completed": completed, + }; +} +``` + +and create the model src/models/todo.dart and add the following: + +``` +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:shared_dart/src/classes/todo.dart' as t; + +class TodosModel { + final kTodosUrl = '[https://jsonplaceholder.typicode.com/todos'](https://jsonplaceholder.typicode.com/todos'); + +Future> getList() async { + final _response = await http.get(kTodosUrl); + if (_response != null) { + final _todos = t.toDoFromJson(_response.body); + if (_todos != null) { + return _todos; + } + } + return []; + } + +Future getItem(int id) async { + final _response = await http.get('$kTodosUrl/$id'); + if (_response != null) { + final _todo = t.ToDo.fromJson(json.decode(_response.body)); + if (_todo != null) { + return _todo; + } + } + return null; + } +} +``` + +Here we just get dummy data from a url that emits json and convert them to our classes. This is an example I want to show with networking. There is only one place that fetches the data. + +### Run the Project (Web) + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/r7724ye890a5030/r_5_cpppxwnavj.webp?thumb=) + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/fah27s6gg8isui1/r_6_7bhqlwxjfs.webp?thumb=) + +As you can see when you run your project on chrome you will get the same application that you got on mobile. Even the networking is working in the web. You can call the model and retrieve the list just like you would expect. + +## Server Setup + +> Now time for the magic.. + +In the root of the project folder create a file Dockerfile and add the following: + +``` +# Use Google's official Dart image. +# [https://hub.docker.com/r/google/dart-runtime/](https://hub.docker.com/r/google/dart-runtime/) +FROM google/dart-runtime +``` + +Create another file at the root called service.yaml and add the following: + +``` +apiVersion: serving.knative.dev/v1 + kind: Service + metadata: + name: PROJECT_NAME + namespace: default + spec: + template: + spec: + containers: + - image: docker.io/YOUR_DOCKER_NAME/PROJECT_NAME + env: + - name: TARGET + value: "PROJECT_NAME v1" +``` + +Replace PROJECT\_NAME with your project name, mine is shared-dart for this example. + +You will also need to replace YOUR\_DOCKER\_NAME with your docker username so the container can be deployed correctly. + +Update your pubspec.yaml with the following: + +``` +name: shared_dart +description: A new Flutter project. +publish_to: none +version: 1.0.0+1 + +environment: + sdk: ">=2.1.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + shelf: ^0.7.3 + cupertino_icons: ^0.1.2 + http: ^0.12.0+2 + +dev_dependencies: + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true +``` + +The important package here is shelf as it allows us to run a http server with dart. + +Create a folder in the root of the project called bin then add a file server.dart and replace it with the following: + +``` +import 'dart:io'; + +import 'package:shelf/shelf.dart' as shelf; +import 'package:shelf/shelf_io.dart' as io; + +import 'src/routing.dart'; + +void main() { + final handler = const shelf.Pipeline() + .addMiddleware(shelf.logRequests()) + .addHandler(RouteUtils.handler); + +final port = int.tryParse(Platform.environment['PORT'] ?? '8080'); + final address = InternetAddress.anyIPv4; + +io.serve(handler, address, port).then((server) { + server.autoCompress = true; + print('Serving at [http://${server.address.host}:${server.port}'](http://${server.address.host}:${server.port}')); + }); +} +``` + +This will tell the container what port to listen for and how to handle the requests. + +Create a folder src in the bin folder and add a file routing.dart and replace the contents with the following: + +``` +import 'dart:async'; + +import 'package:shelf/shelf.dart' as shelf; + +import 'controllers/index.dart'; +import 'result.dart'; + +class RouteUtils { + static FutureOr handler(shelf.Request request) { + var component = request.url.pathSegments.first; + var handler = _handlers(request)[component]; + if (handler == null) return shelf.Response.notFound(null); + return handler; + } + +static Map> _handlers( + shelf.Request request) { + return { + 'info': ServerResponse('Info', body: { + "version": 'v1.0.0', + "status": "ok", + }).ok(), + 'counter': CounterController().result(request), + 'todos': TodoController().result(request), + }; + } +} +``` + +There is still nothing imported from our main project but you will start to see some similarities. Here we specify controllers for todos and counter url paths. + +``` +'counter': CounterController().result(request), +'todos': TodoController().result(request), +``` + +that means any url with the following:[https://mydomain.com/todos](https://mydomain.com/todos) , [https://mydomain.com/todos](https://mydomain.com/todos)/1 + +will get routed to the TodoController to handle the request. + +> This is also the first time I found out about FutureOr. It allows you to return a sync or async function. + +And important part about build a REST API is having a consistent response body, so here we can create a wrapper that adds fields we always want to return, like the status of the call, a message and the body. + +Create a file at src/result.dart and add the following: + +``` +import 'dart:convert'; + +import 'package:shelf/shelf.dart' as shelf; + +class ServerResponse { + final String message; + final dynamic body; + final StatusType type; + +ServerResponse( + this.message, { + this.type = StatusType.success, + this.body, + }); + +Map toJson() { + return { + "status": type.toString().replaceAll('StatusType.', ''), + "message": message, + "body": body ?? '', + }; + } + +String toJsonString() { + return json.encode(toJson()); + } + +shelf.Response ok() { + return shelf.Response.ok( + toJsonString(), + headers: { + 'Content-Type': 'application/json', + }, + ); + } +} + +enum StatusType { success, error } + +abstract class ResponseImpl { + Future result(shelf.Request request); +} +``` + +This will always return json and the fields that we want to show. You could also include your paging meta data here. + +Create a file in at the location src/controllers/counter.dart and add the following: + +``` +import 'package:shared_dart/src/models/counter.dart'; +import 'package:shelf/shelf.dart' as shelf; + +import '../result.dart'; + +class CounterController implements ResponseImpl { + const CounterController(); + +@override + Future result(shelf.Request request) async { + final _model = CounterModel(); + final _params = request.url.queryParameters; + if (_params != null) { + final _val = int.tryParse(_params['count'] ?? '0'); + _model.set(_val); + } else { + _model.add(); + } + return ServerResponse('Info', body: { + "counter": _model.count, + }).ok(); + } +} +``` + +You will see the import to the lib folder of the root project. Since it shares the pubspec.yaml all the packages can be shared. You can import the CounterModel that we created earlier. + +Create a file in at the location src/controllers/todos.dart and add the following: + +``` +import 'package:shared_dart/src/models/todos.dart'; +import 'package:shelf/src/request.dart'; + +import 'package:shelf/src/response.dart'; + +import '../result.dart'; + +class TodoController implements ResponseImpl { + @override + Future result(Request request) async { + final _model = TodosModel(); + if (request.url.pathSegments.length > 1) { + final _id = int.tryParse(request.url.pathSegments[1] ?? '1'); + final _todo = await _model.getItem(_id); + return ServerResponse('Todo Item', body: _todo).ok(); + } + final _todos = await _model.getList(); + return ServerResponse( + 'List Todos', + body: _todos.map((t) => t.toJson()).toList(), + ).ok(); + } +} +``` + +Just like before we are importing the TodosModel model from the lib folder. + +For convenience add a file at the location src/controllers/index.dart and add the following: + +``` +export 'counter.dart'; +export 'todo.dart'; +``` + +This will make it easier to import all the controllers. + +## Run the Project (Server)  + +If you are using [VSCode](https://code.visualstudio.com/) then you will need to update your launch.json with the following: + +``` +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: [https://go.microsoft.com/fwlink/?linkid=830387](https://go.microsoft.com/fwlink/?linkid=830387) + "version": "0.2.0", + "configurations": [ + { + "name": "Client", + "request": "launch", + "type": "dart", + "program": "lib/main.dart" + }, + { + "name": "Server", + "request": "launch", + "type": "dart", + "program": "bin/server.dart" + } + ] +} +``` + +Now when you hit run with Server selected you will see the output: + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/284wx84cvj1o5ab/r_7_fph074ovtl.webp?thumb=) + +You can navigate to this in a browser but you can also work with this in [Postman](https://www.getpostman.com/). + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/lhu678e1j962725/r_8_3jwm0aouc0.webp?thumb=) + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/8wpb4q7m67mrk79/r_9_ry6xjpbpbx.webp?thumb=) + +Just by adding to the url todos and todos/1 it will return different responses. + +For the counter model we can use query parameters too! + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/66rky1456ce104z/r_10_ec9e6yhbxc.webp?thumb=) + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/h6k76rdm32ig7bx/r_11_puha19uiln.webp?thumb=) + +Just by adding ?count=22 it will update the model with the input. + +> Keep in mind this is running your Dart code from you lib folder in your Flutter project without needing the Flutter widgets! + +As a side benefit we can also run this project on Desktop. Check out the final project for the desktop folders needed from [Flutter Desktop Embedding](https://github.com/google/flutter-desktop-embedding). + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/khoz26gjf79b6ro/r_12_2rv9jnqeo0.webp?thumb=) + +![](https://rodydavis.com/_/../api/files/pbc_2708086759/w65r9llws7e710z/r_13_k7amx8s1d5.webp?thumb=) + +## Conclusion  + +Now if you wanted to deploy the container to Cloud Run you could with the following command: + +gcloud builds submit — tag gcr.io/YOUR\_GOOGLE\_PROJECT\_ID/PROJECT\_NAME . + +Replace PROJECT\_NAME with your project name, mine is shared-dart for this example. + +You will also need to replace YOUR\_GOOGLE\_PROJECT\_ID with your Google Cloud Project ID. You can create one [here](https://cloud.google.com/cloud-build/docs/quickstart-docker). + +Again the final project source code is [here](https://github.com/rodydavis/shared_dart). Let me know your thoughts! \ No newline at end of file diff --git a/skills/html-code-sandbox/SKILL.md b/skills/html-code-sandbox/SKILL.md new file mode 100644 index 0000000..0505bdd --- /dev/null +++ b/skills/html-code-sandbox/SKILL.md @@ -0,0 +1,934 @@ +--- +name: building-a-html-element-sandbox-with-lit +description: Learn how to build a Lit web component to create a dynamic HTML element sandbox with live updates, perfect for experimenting with and showcasing web components. +metadata: + url: https://rodydavis.com/posts/html-code-sandbox + last_modified: Tue, 03 Feb 2026 20:04:27 GMT +--- + +# Building a HTML Element Sandbox with Lit + + +In this article I will go over how to set up a [Lit](https://lit.dev/) web component and use it to create a HTML Element sandbox that can be used to update a live component. + +> **TLDR** The final source [here](https://github.com/rodydavis/html-element-sandbox) and an online [demo](https://rodydavis.github.io/html-element-sandbox/). + +## Prerequisites  + +* Vscode +* Node >= 16 +* Typescript + +## Getting Started  + +We can start off by navigating in terminal to the location of the project and run the following: + +``` +npm init @vitejs/app --template lit-ts +``` + +Then enter a project name `html-element-sandbox` and now open the project in vscode and install the dependencies: + +``` +cd html-element-sandbox +npm i lit +npm i -D @types/node +code . +``` + +Update the `vite.config.ts` with the following: + +``` +import { defineConfig } from "vite"; +import { resolve } from "path"; + +export default defineConfig({ + base: "/html-element-sandbox/", + build: { + lib: { + entry: "src/html-element-sandbox.ts", + formats: ["es"], + }, + rollupOptions: { + input: { + main: resolve(__dirname, "index.html"), + }, + }, + }, +}); +``` + +## Template  + +Open up the `index.html` and update it with the following: + +``` + + + + + + + HTML Element Sandbox + + + + + + +
+ + + + + + + + + +
+
+ + +``` + +Here we are defining the markup we want to use in our sandbox. We are using the `html-element-sandbox` component to create a sandbox for our HTML Element. + +``` + +``` + +Each knob is defined by an `id` and a `name`. The `id` is used to identify the knob in the `template` and the `name` is used to display the knob in the UI. + +``` + +``` + +For the element inside the `template` we use `knob-*` attributes to get the values of the knobs and set the attributes, CSS style or text content. + +``` + +
+ + + +
+ + + + +
+ +``` + +A single knob can point to multiple elements: + +``` + + +
+ + + + + + + + + + + + + + + + +
+
+``` + +A `style` and `script` can be added to load extra content into the sandbox (e.g. a `script` to load a web component). + +## Web Component  + +Before we update our component we need to rename `my-element.ts` to `html-element-sandbox.ts` + +Open up `html-element-sandbox.ts` and update it with the following: + +``` +import { css, html, LitElement } from "lit"; +import { customElement, state } from "lit/decorators.js"; + +import "./knobs/boolean"; +import "./knobs/string"; +import "./knobs/number"; +import "./knobs/color"; +import "./knobs/list"; +import "./knobs/group"; +import { KnobValue } from "./knobs/base"; +import { BooleanKnob } from "./knobs/boolean"; + +export const tagName = "html-element-sandbox"; + +@customElement(tagName) +export class HTMLElementSandbox extends LitElement { + static styles = css` + main { + --knobs-width: 300px; + --code-height: calc(100% * 0.4); + --mobile-height: 350px; + display: grid; + grid-template-areas: "preview" "knobs" "code"; + grid-template-columns: 100%; + grid-template-rows: var(--mobile-height) auto auto; + height: 100%; + width: 100%; + } + #preview { + grid-area: preview; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border-bottom: 1px solid #272727; + background-color: whitesmoke; + } + @media (min-width: 600px) { + main { + grid-template-areas: + "preview knobs" + "code knobs"; + grid-template-columns: calc(100% - var(--knobs-width)) var( + --knobs-width + ); + grid-template-rows: calc(100% - var(--code-height)) var(--code-height); + } + #preview { + border-bottom: none; + } + slot[name="knobs"] { + overflow-y: auto; + } + pre { + overflow-y: scroll; + } + } + section { + flex: 1; + } + slot[name="knobs"] { + grid-area: knobs; + display: flex; + flex-direction: column; + border-left: 1px solid #000; + } + slot[name="code"] { + grid-area: code; + } + pre { + margin: 0; + font-family: Monaco, Courier, monospace; + padding: 16px; + background-color: #272727; + color: #c8c8c8; + } + code { + font-size: 0.8rem; + white-space: pre-wrap; + } + `; + + @state() code = ""; + + render() { + return html`
+
+ +
+ + +
${this.code}
+
+
`; + } + + firstUpdated() { + this.init(); + } + + init() { + this.setUpKnobs(); + this.code = this.getCode(); + // Update the code every time a knob value changes + this.addEventListener("value", () => { + this.code = this.getCode(); + }); + } + + setUpKnobs() { + const root = this.shadowRoot!; + const preview = root.getElementById("preview")!; + const template = this.querySelector("template"); + if (template) { + const div = document.createElement("div"); + div.appendChild(template.content.cloneNode(true)); + // Text Knobs (knob-text) + div.querySelectorAll("[knob-text]").forEach((el) => { + const elemId = el.getAttribute("knob-text") || ""; + const knob = this.querySelector(`#${elemId}`); + if (knob && knob instanceof KnobValue) { + knob.addEventListener("value", () => { + const val = knob.value; + el.textContent = val; + }); + el.addEventListener("input", (e) => { + const target = e.target as HTMLElement; + knob.value = target.textContent; + }); + knob.init(); + } + }); + div.querySelectorAll("*").forEach((el) => { + const attrs = el.attributes; + for (let i = 0; i < attrs.length; i++) { + const attr = attrs[i]; + const attrName = attr.name; + // CSS Knobs (knob-css-*) + if (attrName.startsWith("knob-css-")) { + const cssKey = attrName.replace("knob-css-", ""); + const knob = this.querySelector(`#${attr.value}`); + if ( + knob && + knob instanceof KnobValue && + el instanceof HTMLElement + ) { + knob.addEventListener("value", () => { + const val = knob.value; + if (knob.hasAttribute("suffix")) { + // Add suffix to the value (e.g. px) + el.style.setProperty( + cssKey, + val + knob.getAttribute("suffix") + ); + } else { + // No suffix, just set the value + el.style.setProperty(cssKey, val); + } + }); + knob.init(); + } + } + // Attribute Knobs (knob-attr-*) + if (attrName.startsWith("knob-attr-")) { + const attrKey = attrName.replace("knob-attr-", ""); + const knob = this.querySelector(`#${attr.value}`); + if (knob && knob instanceof KnobValue) { + knob.addEventListener("value", () => { + const val = knob.value; + if (knob instanceof BooleanKnob) { + if (val) { + //