diff --git a/.claude/agents/component-visual-reviewer.md b/.claude/agents/component-visual-reviewer.md index 5cdf522..8d3039b 100644 --- a/.claude/agents/component-visual-reviewer.md +++ b/.claude/agents/component-visual-reviewer.md @@ -18,21 +18,30 @@ You receive: - `screenshot_light`: path to a JPEG/PNG screenshot of the component in light mode - `screenshot_dark`: path to a JPEG/PNG screenshot of the component in dark mode -- `design_md`: path to the DESIGN.md file (default: `magic_example/DESIGN.md`) +- `design_md`: path to the DESIGN.md file (default: `DESIGN.md`, repo-relative like every other path in this file) - `component`: name of the component or screen being reviewed --- ## PROCESS -### 1. Read DESIGN.md +### 1. Load the design system from disk, before looking at anything -Read the `design_md` file to load: -- The `colors` section: light/dark hex values for every semantic token role. -- The `typography` section: font family, sizes, weights, line-heights. -- The `rounded` section: corner radius values. -- The `spacing` section: scale values. -- The `components` section: token bindings for specific components. +**Every expected value comes from a file you read in this step, never from memory and never from an +example in this document.** Where a constant quoted here disagrees with a file you read, THE FILE +WINS and the constant is stale: say so in your output, because it means this reviewer needs updating. + +Read, all of them, from the repository root: + +| File | What it gives you | +|---|---| +| `DESIGN.md` (the `design_md` argument) | the `colors`, `typography`, `rounded`, and `spacing` sections; the light/dark hex per role, type scale, radii, spacing. Then the body, which carries the component conventions and the deliberate exceptions | +| `lib/config/wind_theme.g.dart` | what `design:sync` actually emitted. `DESIGN.md` may declare a token this table does not carry, and a declared-but-unemitted token silently does nothing | +| `lib/config/wind_theme.dart` | the `supplementAliases` map, the hand-authored token families `design:sync` does not generate (see DESIGN.md's "Custom token families" section) | +| `.claude/rules/design.md` | the 17-token alias table and the anti-pattern table. Every row is a measured defect that already shipped here, and it is the highest-value part of your checklist | + +A hex you cannot find in `wind_theme.g.dart` is probably legitimate and probably in the supplement. +A hex you cannot find in either file is a violation. ### 2. Read the screenshots @@ -47,19 +56,25 @@ Read both screenshots visually. Identify: ### 3. Check the component source (optional but preferred) -If the component source is accessible, read it to confirm token usage: +If the component source is accessible, read it to confirm token usage. Paths are relative to the +repository root, which is the magic_example project itself: ```bash -find /Users/anilcan/Code/fluttersdk/lib/ui/components -name "*.dart" | xargs grep -l "" +find lib/ui/components -name "*.dart" | xargs grep -l "" ``` Look for raw `Color(0xFF...)`, `Colors.*`, or hardcoded pixel margins that indicate a token bypass. ```bash -grep -rn "Color(0x\|Colors\." /Users/anilcan/Code/fluttersdk/lib/ui/components// -grep -rn "SizedBox(height: [0-9]\|SizedBox(width: [0-9]" /Users/anilcan/Code/fluttersdk/lib/ui/components// +grep -rn "Color(0x\|Colors\." lib/ui/components// +grep -rn "SizedBox(height: [0-9]\|SizedBox(width: [0-9]" lib/ui/components// ``` +An earlier version of this file hardcoded an absolute path one segment short of the project (missing +the `magic_example/` segment). That directory did not exist, so the grep matched nothing and every +review silently passed this step. If a command here returns nothing, confirm the path resolves before +concluding the component is clean. + --- ## SCORING DIMENSIONS diff --git a/.claude/rules/design.md b/.claude/rules/design.md index d6108dd..c9e3d34 100644 --- a/.claude/rules/design.md +++ b/.claude/rules/design.md @@ -9,7 +9,7 @@ These rules apply whenever you touch any file under `lib/`. They complement `CLA ## Atomic Component Folder Contract -Every component in the `magic_starter` generic library lives in a 4-file atomic folder: +Every component in this app's own `lib/ui/components/` library lives in a 4-file atomic folder: ``` lib/ui/components// @@ -52,7 +52,7 @@ final myRecipe = WindRecipe( - Emission order is always: `base ++ variant (definition order) ++ compound ++ caller`. Never sort or deduplicate. - Pass variant values as strings matching the map keys. Pass `null` to clear a default. - The caller `className` argument appends last; it can override variant output at the same granularity. -- Import `WindRecipe` via `package:magic/magic.dart` inside `magic_starter` files (it re-exports the wind barrel). Direct `package:fluttersdk_wind/...` imports trip `depend_on_referenced_packages`. +- Import `WindRecipe` via `package:magic/magic.dart` inside this app's files (it re-exports the wind barrel). Direct `package:fluttersdk_wind/...` imports trip `depend_on_referenced_packages`. ## Token-Only Rule diff --git a/.claude/rules/flutter-app.md b/.claude/rules/flutter-app.md new file mode 100644 index 0000000..4938703 --- /dev/null +++ b/.claude/rules/flutter-app.md @@ -0,0 +1,36 @@ +--- +paths: + - "lib/**" + - "test/**" +--- + +# The Flutter app + +Applies to `lib/` and `test/`. Colours, the component folder contract and the anti-pattern table live in `.claude/rules/design.md`, which loads alongside this file. + +## The two skills are the standard, and this file is only where we differ + +`magic-framework` and `wind-ui` define how code on this stack is written, and copies of both sit at `.github/skills/` so a reviewer with only this checkout has them too. Load them before the first line of Dart rather than working from memory. This file does not restate them; it carries what this app does differently, and what has not been built yet. + +## One controller exists, and it is the one to copy + +`lib/app/controllers/dashboard_controller.dart` paired with `lib/resources/views/dashboard_view.dart` is the single worked instance of the framework's controller and view pattern in this repo. Read it before adding a second; its docblock carries the reasoning, not just the shape. `lib/resources/views/welcome_view.dart` is still a plain `StatelessWidget` reading `Config.get('app.name', ...)` directly, which is fine for a screen with no state and no identity in it. + +Follow the skill's definition rather than inventing a shape here: + +- A controller is a `MagicController` resolved through a canonical `static X get instance => Magic.findOrPut(X.new);`, notifying through `refreshUI()` rather than calling `notifyListeners()` directly. +- A view pairs with it as `MagicStatefulView` / `MagicStatefulViewState`. Do not pass a controller through a view's constructor; nothing then resets it between logins or tests. +- A controller holding anything that belongs to the current identity implements `SessionScopedController`. `SessionScopeSync.attach()` (`lib/app/providers/app_service_provider.dart:81`) resets every registered one on login and team switch; `onInit` alone cannot cover this, because it runs once per controller lifetime rather than once per session. Skip it and a team switch leaves the previous tenant's data on screen until the app restarts. +- No app shell under `lib/ui/layouts/`. `lib/routes/app.dart:16` already mounts `magic_starter`'s `layout.app` through `MagicRoute.group(layout: ...)`; a second shell competes with it and decays. + +## Routes register in `boot()`, not `register()` + +`RouteServiceProvider.boot()` (`lib/app/providers/route_service_provider.dart`) calls `registerAppRoutes()` and the starter route registrars, then registers the dev-only preview catalog, all inside `boot()`. That is deliberate here: the preview registration must land before `MagicRouter` locks its route table on first build, and `boot()` is the phase both dev tooling and the app routes share. Do not move route registration into `register()` on the assumption that is the framework default; it is not what this repo does, and the comment at that call site explains why. + +## Config-plus-factory is the wiring shape for a new subsystem + +A subsystem gets its own `lib/config/.dart` exposing a single `Map get Config => {...}` getter, then a `() => Config` entry added to the `configFactories` list in `lib/main.dart`. `lib/config/localization.dart` and `lib/config/notifications.dart` are the two current instances (wired at `lib/main.dart:39-41`); read either before adding a third. Every value goes through `env()` with an explicit fallback rather than requiring a `.env` entry, and each non-obvious default carries a comment saying why that default and not another (see `notifications.dart`'s push section). This is the only place a subsystem is configured; do not scatter its options across the provider that consumes it. + +## Generated files, never hand-edited + +`lib/config/wind_theme.g.dart` (`design:sync`), `lib/_previews.g.dart` (`previews:refresh`), `lib/app/_plugins.g.dart` and `lib/app/commands/_index.g.dart` (`commands:refresh`). Regenerate through the dispatcher command named in parentheses; a hand edit is overwritten on the next run and diverges from `analysis_options.yaml`'s strict-mode expectations in the meantime. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e5f6bdd..08a8e0a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -17,7 +17,7 @@ That override file is also why a green local run can be a red CI: with it, this ## Stack -- Flutter >=3.27.0, Dart >=3.6.0. +- Dart `sdk: ^3.12.2` (`pubspec.yaml:22`), with no separate Flutter version floor declared. - `magic` (framework: IoC container, ORM, auth, routing over `go_router`), `magic_starter` (auth, profile, teams, notifications, 13 opt-in features), `fluttersdk_wind` (utility-first styling through `className`), `magic_devtools` (dev-only preview catalog and dusk integration). - A Laravel backend under `backend/` as the API counterpart. @@ -50,7 +50,7 @@ A green suite is the floor, not the finish line. Anything a person clicks gets d ## Off-limits -- Generated files are regenerated, never edited: `docs/component-registry.md` (`bin/sync-registry`), `.github/skills/{magic-framework,wind-ui}/SKILL.md` (`bin/sync-skills`, each carrying the hash CI checks it against), `lib/config/wind_theme.g.dart` (`design:sync`), `lib/preview/_previews.g.dart` (`previews:refresh`), `lib/app/commands/_index.g.dart` (`commands:refresh`), `.artisan/plugins.json`, and everything `bin/sync-instructions` writes under `.github/`. +- Generated files are regenerated, never edited: `docs/component-registry.md` (`bin/sync-registry`), `.github/skills/{magic-framework,wind-ui}/SKILL.md` (`bin/sync-skills`, each carrying the hash CI checks it against), `lib/config/wind_theme.g.dart` (`design:sync`), `lib/_previews.g.dart` (`previews:refresh`), `lib/app/commands/_index.g.dart` (`commands:refresh`), `.artisan/plugins.json`, and everything `bin/sync-instructions` writes under `.github/`. - `backend/vendor/`, `build/`, `.dart_tool/`. - The fluttersdk packages are separate repositories. Reading them is expected; changing one is a PR in that repo under its own rules. `design:sync`, `design:lint`, `make:component`, and `previews:refresh` are `magic`'s commands, not this project's, and there is no `magic_example:artisan`. @@ -64,6 +64,10 @@ App components live in `lib/ui/components//` as a four-file atomic folder, Regeneration commands, all through the dispatcher: `dart run bin/dispatcher.dart design:sync`, `design:lint`, `previews:refresh`, `make:component [--variants=intent,size] [--slots]`. +## Mirroring the boilerplate + +This repo is the fork source for real products in the ecosystem. `uptizm/AGENTS.md` carries the sending half of this policy and names `../magic_example` as the boilerplate it was forked from. Not every fork carries it yet (depools does not), so a fork that has sent nothing back is a gap to close rather than an exemption. A structural change proven in one of those products (a rule, a skill, the component contract, tooling like `bin/check`) comes back here as its own PR, in the same piece of work that proved it out. Product code (a fork's domain models, screens, billing wiring) does not travel; only the pattern does. When a PR against this repo cites a fork as the reason for a change, that is the mechanism working as intended, not scope creep to push back on. + ## Where the instructions live This file is canonical. Everything else either points at it or is generated from it: diff --git a/.github/instructions/design.instructions.md b/.github/instructions/design.instructions.md index 0fcd62f..e8ffc5b 100644 --- a/.github/instructions/design.instructions.md +++ b/.github/instructions/design.instructions.md @@ -10,7 +10,7 @@ These rules apply whenever you touch any file under `lib/`. They complement `.gi ## Atomic Component Folder Contract -Every component in the `magic_starter` generic library lives in a 4-file atomic folder: +Every component in this app's own `lib/ui/components/` library lives in a 4-file atomic folder: ``` lib/ui/components// @@ -53,7 +53,7 @@ final myRecipe = WindRecipe( - Emission order is always: `base ++ variant (definition order) ++ compound ++ caller`. Never sort or deduplicate. - Pass variant values as strings matching the map keys. Pass `null` to clear a default. - The caller `className` argument appends last; it can override variant output at the same granularity. -- Import `WindRecipe` via `package:magic/magic.dart` inside `magic_starter` files (it re-exports the wind barrel). Direct `package:fluttersdk_wind/...` imports trip `depend_on_referenced_packages`. +- Import `WindRecipe` via `package:magic/magic.dart` inside this app's files (it re-exports the wind barrel). Direct `package:fluttersdk_wind/...` imports trip `depend_on_referenced_packages`. ## Token-Only Rule diff --git a/.github/instructions/flutter-app.instructions.md b/.github/instructions/flutter-app.instructions.md new file mode 100644 index 0000000..8acbe87 --- /dev/null +++ b/.github/instructions/flutter-app.instructions.md @@ -0,0 +1,36 @@ +--- +applyTo: "lib/**,test/**" +--- + + + +# The Flutter app + +Applies to `lib/` and `test/`. Colours, the component folder contract and the anti-pattern table live in `.github/instructions/design.instructions.md`, which loads alongside this file. + +## The two skills are the standard, and this file is only where we differ + +`magic-framework` and `wind-ui` define how code on this stack is written, and copies of both sit at `.github/skills/` so a reviewer with only this checkout has them too. Load them before the first line of Dart rather than working from memory. This file does not restate them; it carries what this app does differently, and what has not been built yet. + +## One controller exists, and it is the one to copy + +`lib/app/controllers/dashboard_controller.dart` paired with `lib/resources/views/dashboard_view.dart` is the single worked instance of the framework's controller and view pattern in this repo. Read it before adding a second; its docblock carries the reasoning, not just the shape. `lib/resources/views/welcome_view.dart` is still a plain `StatelessWidget` reading `Config.get('app.name', ...)` directly, which is fine for a screen with no state and no identity in it. + +Follow the skill's definition rather than inventing a shape here: + +- A controller is a `MagicController` resolved through a canonical `static X get instance => Magic.findOrPut(X.new);`, notifying through `refreshUI()` rather than calling `notifyListeners()` directly. +- A view pairs with it as `MagicStatefulView` / `MagicStatefulViewState`. Do not pass a controller through a view's constructor; nothing then resets it between logins or tests. +- A controller holding anything that belongs to the current identity implements `SessionScopedController`. `SessionScopeSync.attach()` (`lib/app/providers/app_service_provider.dart:81`) resets every registered one on login and team switch; `onInit` alone cannot cover this, because it runs once per controller lifetime rather than once per session. Skip it and a team switch leaves the previous tenant's data on screen until the app restarts. +- No app shell under `lib/ui/layouts/`. `lib/routes/app.dart:16` already mounts `magic_starter`'s `layout.app` through `MagicRoute.group(layout: ...)`; a second shell competes with it and decays. + +## Routes register in `boot()`, not `register()` + +`RouteServiceProvider.boot()` (`lib/app/providers/route_service_provider.dart`) calls `registerAppRoutes()` and the starter route registrars, then registers the dev-only preview catalog, all inside `boot()`. That is deliberate here: the preview registration must land before `MagicRouter` locks its route table on first build, and `boot()` is the phase both dev tooling and the app routes share. Do not move route registration into `register()` on the assumption that is the framework default; it is not what this repo does, and the comment at that call site explains why. + +## Config-plus-factory is the wiring shape for a new subsystem + +A subsystem gets its own `lib/config/.dart` exposing a single `Map get Config => {...}` getter, then a `() => Config` entry added to the `configFactories` list in `lib/main.dart`. `lib/config/localization.dart` and `lib/config/notifications.dart` are the two current instances (wired at `lib/main.dart:39-41`); read either before adding a third. Every value goes through `env()` with an explicit fallback rather than requiring a `.env` entry, and each non-obvious default carries a comment saying why that default and not another (see `notifications.dart`'s push section). This is the only place a subsystem is configured; do not scatter its options across the provider that consumes it. + +## Generated files, never hand-edited + +`lib/config/wind_theme.g.dart` (`design:sync`), `lib/_previews.g.dart` (`previews:refresh`), `lib/app/_plugins.g.dart` and `lib/app/commands/_index.g.dart` (`commands:refresh`). Regenerate through the dispatcher command named in parentheses; a hand edit is overwritten on the next run and diverges from `analysis_options.yaml`'s strict-mode expectations in the meantime. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c29c6b5..9caa88c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,19 @@ jobs: with: channel: stable cache: true + # bin/check's run_lockfile never runs in CI, because CI does not invoke + # bin/check at all: it runs the individual tools. Without this step the + # only thing standing between a path-poisoned lock and main is whether a + # developer chose to run the gate locally, and the symptom in CI would be + # a pub resolution error naming a directory nobody recognises. Before + # `flutter pub get`, since that would rewrite the evidence. + - name: Committed lock is hosted-only + run: | + if grep -qE 'source: (path|git)' pubspec.lock; then + echo "pubspec.lock carries local sibling paths. Regenerate it with pubspec_overrides.yaml moved aside." >&2 + grep -nE 'source: (path|git)' pubspec.lock >&2 + exit 1 + fi - name: Resolve dependencies run: flutter pub get - name: Analyze @@ -117,3 +130,57 @@ jobs: - uses: actions/checkout@v7 - name: Scan for raw color literals run: bin/design-tokens + + # No sibling checkouts and no pubspec_overrides.yaml (gitignored, never checked + # out here): this is the exact graph `flutter pub add` gives a fork. Before Step + # 1, pubspec.yaml pinned `magic_notifications: ^0.0.2`, which does not intersect + # `magic_starter` alpha.26's own `^0.2.0` requirement, so pub silently walked the + # starter back to alpha.24 and `flutter pub get` still exited 0. A resolve-only + # job would have stayed green through that regression; the version assertion + # below is what actually observes it. + published: + name: Published graph (hosted resolution) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + - name: Resolve against pub.dev + run: flutter pub get + # The lock is TRACKED, so a resolve that rewrites it means the committed + # lock disagreed with pubspec.yaml. CI would otherwise repair that in + # place and inspect the repaired copy, so the drift never surfaces and a + # fork keeps cloning a lock nothing has verified. bin/check's run_lockfile + # cannot see this: it reads the index on a developer's machine, and there + # is no index here. + - name: Assert the committed lock matches what pub.dev resolves + run: git diff --exit-code -- pubspec.lock + - name: Analyze against the published siblings + run: flutter analyze + # `flutter pub get` exits 0 on a downgrade as long as SOME version in the + # constraint range resolves; it does not care which one. Reading the lock's + # own hosted version is the only way to tell the intended release apart + # from a silent walk-back to an older prerelease, which is what this repo + # shipped for several releases: a `magic_notifications` constraint stopped + # intersecting the starter's own requirement and pub quietly chose + # alpha.24 instead of failing. + # + # The floor is READ from pubspec.yaml rather than written here. A literal + # would turn this job red on the next routine bump, in a file whose author + # has no reason to look, and the failure would name a version nobody typed. + - name: Assert magic_starter did not resolve below its constraint floor + run: | + floor=$(grep -E '^[[:space:]]+magic_starter:' pubspec.yaml | sed -E 's/.*\^//') + resolved=$(awk '/^ magic_starter:/{f=1; next} f && /^ version:/{print $2; exit} f && /^ [a-zA-Z]/{exit}' pubspec.lock | tr -d '"') + echo "constraint floor: ${floor:-} | resolved: ${resolved:-}" + if [ -z "$floor" ] || [ -z "$resolved" ]; then + echo "could not read the floor from pubspec.yaml or the version from pubspec.lock; the assertion below would pass vacuously" >&2 + exit 1 + fi + if [ "$(printf '%s\n%s\n' "$floor" "$resolved" | sort -V | head -1)" != "$floor" ]; then + echo "magic_starter resolved to '$resolved', which is BELOW the '$floor' floor in pubspec.yaml. Some constraint stopped intersecting what that release requires, and pub picked an older one rather than failing." >&2 + exit 1 + fi diff --git a/.gitignore b/.gitignore index 5a973bf..493316d 100644 --- a/.gitignore +++ b/.gitignore @@ -56,13 +56,18 @@ pubspec_overrides.yaml # Local QA report (not part of the published example) /REPORT.md -# pubspec.lock is NOT committed here, unlike a normal application. This project -# is a template that gets forked, and its lock can only ever be generated with -# pubspec_overrides.yaml active, so every committed lock encodes -# `path: "../magic"` and friends: paths that do not exist in a fork. A fork's -# first `flutter pub get` writes a correct lock against the hosted constraints -# in pubspec.yaml. -pubspec.lock +# pubspec.lock IS committed here, reversing this project's earlier policy of +# ignoring it. That argument was correct about the failure mode (a lock generated +# with pubspec_overrides.yaml active encodes `source: path` entries carrying the +# ABSOLUTE sibling paths that file holds, which exist on exactly one machine) +# but drew the wrong conclusion from it: the fix +# for a lock generated the WRONG way is to generate it the right way, not to +# stop tracking it and hand every fork a first build that resolves whatever +# the caret ranges in pubspec.yaml happen to solve to on the day it is cloned +# rather than what CI actually proved green. `run_lockfile` in bin/check is +# what makes tracking safe: it fails the STAGED lock when it carries a +# `source: path` entry, so a lock generated with the overrides live never +# reaches main. # Worktrees Claude Code creates for parallel sessions and isolated subagents. # Only this subdirectory: the rest of `.claude/` is tracked, because the rules diff --git a/AGENTS.md b/AGENTS.md index e40b809..5b06b49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ That override file is also why a green local run can be a red CI: with it, this ## Stack -- Flutter >=3.27.0, Dart >=3.6.0. +- Dart `sdk: ^3.12.2` (`pubspec.yaml:22`), with no separate Flutter version floor declared. - `magic` (framework: IoC container, ORM, auth, routing over `go_router`), `magic_starter` (auth, profile, teams, notifications, 13 opt-in features), `fluttersdk_wind` (utility-first styling through `className`), `magic_devtools` (dev-only preview catalog and dusk integration). - A Laravel backend under `backend/` as the API counterpart. @@ -48,7 +48,7 @@ A green suite is the floor, not the finish line. Anything a person clicks gets d ## Off-limits -- Generated files are regenerated, never edited: `docs/component-registry.md` (`bin/sync-registry`), `.github/skills/{magic-framework,wind-ui}/SKILL.md` (`bin/sync-skills`, each carrying the hash CI checks it against), `lib/config/wind_theme.g.dart` (`design:sync`), `lib/preview/_previews.g.dart` (`previews:refresh`), `lib/app/commands/_index.g.dart` (`commands:refresh`), `.artisan/plugins.json`, and everything `bin/sync-instructions` writes under `.github/`. +- Generated files are regenerated, never edited: `docs/component-registry.md` (`bin/sync-registry`), `.github/skills/{magic-framework,wind-ui}/SKILL.md` (`bin/sync-skills`, each carrying the hash CI checks it against), `lib/config/wind_theme.g.dart` (`design:sync`), `lib/_previews.g.dart` (`previews:refresh`), `lib/app/commands/_index.g.dart` (`commands:refresh`), `.artisan/plugins.json`, and everything `bin/sync-instructions` writes under `.github/`. - `backend/vendor/`, `build/`, `.dart_tool/`. - The fluttersdk packages are separate repositories. Reading them is expected; changing one is a PR in that repo under its own rules. `design:sync`, `design:lint`, `make:component`, and `previews:refresh` are `magic`'s commands, not this project's, and there is no `magic_example:artisan`. @@ -62,6 +62,10 @@ App components live in `lib/ui/components//` as a four-file atomic folder, Regeneration commands, all through the dispatcher: `dart run bin/dispatcher.dart design:sync`, `design:lint`, `previews:refresh`, `make:component [--variants=intent,size] [--slots]`. +## Mirroring the boilerplate + +This repo is the fork source for real products in the ecosystem. `uptizm/AGENTS.md` carries the sending half of this policy and names `../magic_example` as the boilerplate it was forked from. Not every fork carries it yet (depools does not), so a fork that has sent nothing back is a gap to close rather than an exemption. A structural change proven in one of those products (a rule, a skill, the component contract, tooling like `bin/check`) comes back here as its own PR, in the same piece of work that proved it out. Product code (a fork's domain models, screens, billing wiring) does not travel; only the pattern does. When a PR against this repo cites a fork as the reason for a change, that is the mechanism working as intended, not scope creep to push back on. + ## Where the instructions live This file is canonical. Everything else either points at it or is generated from it: diff --git a/README.md b/README.md index 8af42ec..46a5b44 100644 --- a/README.md +++ b/README.md @@ -17,42 +17,77 @@ never ships and is not part of the fork. ## Forking this app -Follow these steps in order; each one depends on the previous. - -1. **Rename the package.** Update `name:` in `pubspec.yaml`, then rename every - Dart `import 'package:magic_example/...'` under `lib/` and `test/` to the - new package name. Update the platform bundle identifiers too: - `android/app/build.gradle.kts` (`namespace` and `applicationId`, currently - `com.fluttersdk.magic_example`) and the iOS - `PRODUCT_BUNDLE_IDENTIFIER` entries in - `ios/Runner.xcodeproj/project.pbxproj` (currently `com.fluttersdk.magicExample`). -2. **Edit `.env`.** Set `APP_NAME` and point `API_URL` at the new backend, then - run `magic key:generate` if the app uses the `Crypt` facade. `.env` is - COMMITTED here and bundled as a Flutter asset in `pubspec.yaml`, which is - deliberate on both counts: `flutter_dotenv` can only load it on web when it is - a bundled asset, and a bundled asset that does not exist fails - `flutter build`, so gitignoring it would make every fresh clone of this - template unbuildable. It holds public client values only. A Flutter bundle - ships to every user's device and can be read out of it, so real secrets - belong on the backend, never here. `.env.example` stays as the key list. -3. **Set bundle ids and app icons.** Reuse the identifiers you set in step 1 - for the platform bundle/app ids, then replace the launcher icons under - `android/app/src/main/res/mipmap-*` and `ios/Runner/Assets.xcassets/AppIcon.appiconset/`. -4. **Edit `DESIGN.md`**, then regenerate the theme: - `dart run bin/dispatcher.dart design:sync`. This rewrites - `lib/config/wind_theme.g.dart`; never hand-edit that file. -5. **Delete `pubspec_overrides.yaml`.** It exists only to wire this app to - sibling packages under active development inside the `fluttersdk` - workspace; a fork living outside that workspace has no sibling checkouts - to point at and must resolve every `magic` package from pub.dev via the - plain `pubspec.yaml` constraints. Deleting it (it is gitignored, so it was - never committed) is what makes `flutter pub get` resolve purely hosted. - `bin/check` guards that file for the workspace case, so once it is gone run - the gate as `CHECK_ALLOW_HOSTED=1 bin/check`, which is the supported way to - say "hosted is what I meant". -6. **Refresh the preview catalog:** `dart run bin/dispatcher.dart previews:refresh`. - -After these six steps, `flutter pub get` should resolve against pub.dev alone, +Run the rename command first, it rewrites every platform identity site the +app carries. Then finish the handful of things it deliberately leaves for you. + +### 1. Rename the app + + dart run bin/dispatcher.dart app:rename --name= --org= --display="" + +Add `--dry-run` first to list every file it would touch without writing +anything. It refuses to run while writing against a dirty worktree, and +running it twice with the same arguments is a no-op. + +It owns every identity site measured in this app: the Dart package name and +every `import 'package:magic_example/...'` site under `lib/`, `test/` and +`bin/`; the Android namespace, +`applicationId`, display name, and the Kotlin package directory move +(`android/app/src/main/kotlin/com/fluttersdk/magic_example/` moves to the new +package path); the iOS bundle identifier and display name in `project.pbxproj` +and `Info.plist`; the macOS bundle identifier and display name across +`AppInfo.xcconfig`, `project.pbxproj`, and `Runner.xcscheme`, plus the +`TEST_HOST` the macOS `RunnerTests` target launches against (macOS's own +`Info.plist` needs no edit, it reads `$(PRODUCT_BUNDLE_IDENTIFIER)` and +`$(PRODUCT_NAME)`; three product-reference labels in +`macos/.../project.pbxproj` keep the old name and are cosmetic, Xcode +regenerates them); the Windows `CMakeLists.txt`, `Runner.rc`, and `main.cpp`; +the Linux `CMakeLists.txt` and `my_application.cc`; the web `manifest.json` +and `index.html` title; the `APP_NAME` key in `.env` (not its values, see step +2); `lib/main.dart`; the app name reference in `DESIGN.md` (not its tokens, +see step 4); and `.github/dependabot.yml`. + +### 2. Edit `.env` values + +`app:rename` only rewrites the `APP_NAME` key; it does not touch `API_URL` or +any other value. Point `API_URL` at the new backend, then run +`dart run bin/dispatcher.dart key:generate` if the app uses the `Crypt` facade. `.env` is COMMITTED +here and bundled as a Flutter asset in `pubspec.yaml`, which is deliberate on +both counts: `flutter_dotenv` can only load it on web when it is a bundled +asset, and a bundled asset that does not exist fails `flutter build`, so +gitignoring it would make every fresh clone of this template unbuildable. It +holds public client values only. A Flutter bundle ships to every user's +device and can be read out of it, so real secrets belong on the backend, +never here. `.env.example` stays as the key list. + +### 3. Replace the launcher icons + +`app:rename` does not generate icons. Replace them under +`android/app/src/main/res/mipmap-*` and +`ios/Runner/Assets.xcassets/AppIcon.appiconset/`. + +### 4. Edit `DESIGN.md`, then regenerate the theme + +`app:rename` only updates the app name reference in `DESIGN.md`; it does not +touch the design tokens. Edit the colors, typography, spacing, and radii, +then run `dart run bin/dispatcher.dart design:sync`. This rewrites +`lib/config/wind_theme.g.dart`; never hand-edit that file. + +### 5. Delete `pubspec_overrides.yaml` + +It exists only to wire this app to sibling packages under active development +inside the `fluttersdk` workspace; a fork living outside that workspace has no +sibling checkouts to point at and must resolve every `magic` package from +pub.dev via the plain `pubspec.yaml` constraints. Deleting it (it is +gitignored, so it was never committed) is what makes `flutter pub get` +resolve purely hosted. `bin/check` guards that file for the workspace case, so +once it is gone run the gate as `CHECK_ALLOW_HOSTED=1 bin/check`, which is the +supported way to say "hosted is what I meant". + +### 6. Refresh the preview catalog + +`dart run bin/dispatcher.dart previews:refresh`. + +After these steps, `flutter pub get` should resolve against pub.dev alone, `flutter analyze` and `flutter test` should stay clean, and `/preview` in a debug build should reflect the new `DESIGN.md`. diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d29021..bf8d421 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -7,6 +7,15 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml linter: diff --git a/assets/lang/en.json b/assets/lang/en.json index be2bb06..6f7489e 100644 --- a/assets/lang/en.json +++ b/assets/lang/en.json @@ -91,6 +91,10 @@ "privacy_policy": "Privacy Policy", "legal_and": "and" }, + "dashboard": { + "loading": "Loading your dashboard...", + "welcome_back": "Welcome back, :name" + }, "errors": { "unexpected": "An unexpected error occurred. Please try again.", "network_error": "Network connection failed. Please check your internet connection and try again.", @@ -186,6 +190,28 @@ }, "timezone": { "subtitle": "Set the timezone used for displaying dates and times." + }, + "titles": { + "appearance": "Appearance", + "forgot_password": "Forgot Password", + "language": "Language", + "login": "Sign In", + "newsletter": "Newsletter", + "notification_preferences": "Notification Preferences", + "notifications": "Notifications", + "otp": "Verification Code", + "password": "Password", + "profile": "Profile", + "register": "Create Account", + "reset_password": "Reset Password", + "sessions": "Active Sessions", + "settings": "Settings", + "team_create": "Create Team", + "team_invitation": "Team Invitation", + "team_settings": "Team Settings", + "timezone": "Timezone", + "two_factor": "Two-Factor Authentication", + "two_factor_challenge": "Two-Factor Challenge" } }, "nav": { @@ -208,7 +234,12 @@ "no_preferences": "No notification preferences available.", "channel_email": "Email", "channel_in_app": "In-App", - "channel_push": "Push" + "channel_push": "Push", + "channel_sms": "SMS", + "delete": "Delete notification", + "delete_confirm_message": "This notification will be permanently removed.", + "delete_confirm_title": "Delete notification?", + "delete_failed": "Failed to delete notification. Please try again." }, "profile": { "browser_sessions": "Browser Sessions", diff --git a/assets/lang/tr.json b/assets/lang/tr.json new file mode 100644 index 0000000..5e9b1c0 --- /dev/null +++ b/assets/lang/tr.json @@ -0,0 +1,352 @@ +{ + "common": { + "welcome": "Hoş geldiniz: ", + "loading": "Yükleniyor...", + "save": "Kaydet", + "cancel": "İptal", + "confirm": "Onayla", + "delete": "Sil", + "edit": "Düzenle", + "go_to_dashboard": "Panele Git", + "off": "Kapalı", + "on": "Açık", + "remove": "Kaldır", + "toggle_theme": "Temayı değiştir", + "unknown": "Bilinmiyor", + "upload": "Yükle", + "user": "Kullanıcı", + "done": "Tamam", + "error_occurred": "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.", + "page_of": ":current / :total", + "upgrade": "Yükselt", + "upgrade_available_on": ":plan ve üzeri planlarda kullanılabilir.", + "upgrade_dialog_not_now": "Şimdi değil" + }, + "validation": { + "required": ":attribute alanı zorunludur.", + "email": ":attribute geçerli bir e-posta adresi olmalıdır.", + "min": ":attribute en az :min karakter olmalıdır.", + "max": ":attribute en fazla :max karakter olabilir.", + "confirmed": ":attribute onayı eşleşmiyor.", + "accepted": ":attribute kabul edilmelidir.", + "same": ":attribute ve :other eşleşmelidir." + }, + "app": { + "name": "Uygulamam" + }, + "attributes": { + "current_password": "Mevcut Şifre", + "email": "E-posta Adresi", + "name": "Ad", + "new_password": "Yeni Şifre", + "password": "Şifre", + "password_confirmation": "Şifreyi Onayla", + "phone": "Telefon Numarası", + "phone_country": "Ülke Kodu", + "role": "Rol", + "timezone": "Saat Dilimi" + }, + "auth": { + "already_have_account": "Zaten bir hesabınız var mı?", + "authentication_code": "Kimlik Doğrulama Kodu", + "back_to_login": "Girişe Dön", + "challenge_failed": "İki faktörlü doğrulama başarısız oldu. Lütfen tekrar deneyin.", + "dont_have_account": "Hesabınız yok mu?", + "forgot_password": "Şifrenizi mi unuttunuz?", + "forgot_password_subtitle": "Sıfırlama bağlantısı almak için e-postanızı girin", + "forgot_password_title": "Şifremi Unuttum", + "invalid_response": "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.", + "login_failed": "Giriş başarısız oldu. Lütfen bilgilerinizi kontrol edin.", + "login_subtitle": "Hesabınıza giriş yapın", + "login_title": "Giriş Yap", + "logout": "Çıkış Yap", + "or_continue_with": "Veya şununla devam et", + "password_reset_failed": "Şifre sıfırlanamadı. Lütfen tekrar deneyin.", + "password_reset_success": "Şifreniz sıfırlandı. Artık giriş yapabilirsiniz.", + "profile": "Profil", + "recovery_code": "Kurtarma Kodu", + "register_failed": "Kayıt başarısız oldu. Lütfen tekrar deneyin.", + "register_subtitle": "Başlamak için hesabınızı oluşturun", + "register_title": "Hesap Oluştur", + "remember_me": "Beni hatırla", + "reset_link_failed": "Sıfırlama bağlantısı gönderilemedi. Lütfen tekrar deneyin.", + "reset_link_sent": "E-postanıza bir şifre sıfırlama bağlantısı gönderildi.", + "reset_password_button": "Şifreyi Sıfırla", + "reset_password_subtitle": "Yeni şifrenizi girin", + "reset_password_title": "Şifreyi Sıfırla", + "send_reset_link": "Sıfırlama Bağlantısı Gönder", + "sign_in": "Giriş Yap", + "sign_in_with": ":provider ile giriş yap", + "sign_up": "Kaydol", + "sign_up_with": ":provider ile kaydol", + "signed_in_as": "Şu hesapla giriş yapıldı", + "two_factor_challenge": "İki Faktörlü Doğrulama", + "two_factor_code_description": "Kimlik doğrulama uygulamanızdaki kodu girerek hesabınıza erişimi onaylayın.", + "two_factor_recovery_description": "Acil durum kurtarma kodlarınızdan birini girerek hesabınıza erişimi onaylayın.", + "use_authentication_code": "Kimlik doğrulama kodu kullan", + "use_recovery_code": "Kurtarma kodu kullan", + "verify": "Doğrula", + "agree_to_legal": "Hesap oluşturarak şunları kabul etmiş olursunuz:", + "terms_of_service": "Kullanım Koşulları", + "privacy_policy": "Gizlilik Politikası", + "legal_and": "ve" + }, + "dashboard": { + "loading": "Panonuz yükleniyor...", + "welcome_back": "Tekrar hoş geldiniz, :name" + }, + "errors": { + "unexpected": "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.", + "network_error": "Ağ bağlantısı başarısız oldu. Lütfen internet bağlantınızı kontrol edip tekrar deneyin.", + "network_timeout": "İstek zaman aşımına uğradı. Lütfen daha sonra tekrar deneyin." + }, + "fields": { + "email_placeholder": "siz@ornek.com", + "name_placeholder": "Adınızı ve soyadınızı girin", + "password_confirmation_placeholder": "Şifrenizi tekrar girin", + "password_placeholder": "En az 8 karakter", + "phone_country_placeholder": "Ülke kodu seçin", + "phone_placeholder": "+905301234567", + "otp_placeholder": "123456" + }, + "magic_starter": { + "auth": { + "continue_as_guest": "Misafir Olarak Devam Et", + "guest_login_error": "Misafir olarak devam edilemedi. Lütfen tekrar deneyin." + }, + "email_verification": { + "resend_button": "Doğrulama E-postasını Yeniden Gönder", + "section_title": "E-posta Doğrulama", + "send_error": "Doğrulama e-postası gönderilemedi. Lütfen tekrar deneyin.", + "sent": "Doğrulama e-postası başarıyla gönderildi.", + "unverified_description": "E-posta adresiniz henüz doğrulanmadı. Hesabınızı güvence altına almak için lütfen doğrulayın.", + "unverified_title": "E-posta adresi doğrulanmadı", + "verified": "E-posta adresiniz doğrulandı." + }, + "guest_upgrade": { + "button": "Hesabı Yükselt", + "description": "Verilerinizi korumak ve tüm özelliklerin kilidini açmak için kalıcı bir hesap oluşturun.", + "title": "Misafir Hesabınızı Yükseltin" + }, + "nav": { + "settings": "Ayarlar" + }, + "notifications": { + "fetch_error": "Bildirim tercihleri yüklenemedi.", + "preferences_title": "Bildirim Tercihleri" + }, + "newsletter": { + "fetch_error": "Bülten tercihleri yüklenemedi.", + "section_description": "Ürün güncellemelerini ve duyuruları e-posta ile alın.", + "section_title": "Bülten Tercihleri", + "subscribe_label": "Ürün güncellemelerine ve haberlere abone ol", + "subscribed_status": "Şu anda abonesiniz.", + "toggle_button": "Tercihleri Kaydet", + "toggle_label": "Bülten Al", + "unsubscribed_status": "Abone değilsiniz.", + "update_error": "Bülten tercihleri güncellenemedi." + }, + "otp": { + "back_button": "Geri", + "code_label": "Doğrulama Kodu", + "code_subtitle": "Telefon numaranıza gönderilen doğrulama kodunu girin.", + "code_title": "Telefon Numarasını Doğrula", + "phone_subtitle": "Doğrulama kodu almak için telefon numaranızı girin.", + "phone_title": "Telefon Doğrulama", + "resend_link": "Kodu yeniden gönder", + "send_code_button": "Kod Gönder", + "send_error": "Doğrulama kodu gönderilemedi. Lütfen tekrar deneyin.", + "verify_button": "Kodu Doğrula", + "verify_error": "Kod doğrulanamadı. Lütfen tekrar deneyin." + }, + "appearance": { + "dark": "Koyu", + "dark_description": "Her zaman koyu temayı kullan.", + "light": "Açık", + "light_description": "Her zaman açık temayı kullan.", + "section_footer": "Sistem seçeneği cihazınızın görünüm ayarlarını izler.", + "subtitle": "Tercih ettiğiniz renk şemasını seçin.", + "system": "Sistem", + "system_description": "Sistem görünüm ayarını izle.", + "title": "Görünüm" + }, + "language": { + "subtitle": "Uygulama genelinde kullanılan dili seçin." + }, + "profile": { + "delete_account": { + "button": "Hesabı Sil", + "description": "Hesabınızı ve ilişkili tüm verilerinizi kalıcı olarak silin. Bu işlem geri alınamaz.", + "guest_upgrade_description": "Verilerinizi yönetmek veya silmek için misafir hesabınızı tam bir hesaba dönüştürün.", + "guest_upgrade_title": "Hesap Yükseltmesi Gerekli", + "password_label": "Şifrenizi onaylayın", + "title": "Hesabı Sil" + } + }, + "settings": { + "account_section": "Hesap", + "preferences_section": "Tercihler", + "security_section": "Güvenlik" + }, + "timezone": { + "subtitle": "Tarih ve saatlerin görüntülenmesinde kullanılan saat dilimini ayarlayın." + }, + "titles": { + "appearance": "Görünüm", + "forgot_password": "Şifremi unuttum", + "language": "Dil", + "login": "Giriş yap", + "newsletter": "Bülten", + "notification_preferences": "Bildirim tercihleri", + "notifications": "Bildirimler", + "otp": "Doğrulama kodu", + "password": "Şifre", + "profile": "Profil", + "register": "Hesap oluştur", + "reset_password": "Şifre sıfırla", + "sessions": "Aktif oturumlar", + "settings": "Ayarlar", + "team_create": "Takım oluştur", + "team_invitation": "Takım daveti", + "team_settings": "Takım ayarları", + "timezone": "Saat dilimi", + "two_factor": "İki adımlı doğrulama", + "two_factor_challenge": "Doğrulama adımı" + } + }, + "nav": { + "dashboard": "Panel", + "profile": "Profil", + "settings": "Ayarlar", + "system": "Sistem" + }, + "notifications": { + "empty": "Bildirim yok", + "list_subtitle": "Bildirimlerinizi görüntüleyin ve yönetin", + "load_failed": "Bildirimler yüklenemedi", + "mark_all_read": "Tümünü okundu olarak işaretle", + "preferences_description": "Bildirimleri nasıl ve ne zaman alacağınızı yönetin", + "preferences_title": "Bildirim Tercihleri", + "settings": "Bildirim Ayarları", + "title": "Bildirimler", + "view_all": "Tüm bildirimleri görüntüle", + "badge_overflow": "9+", + "no_preferences": "Kullanılabilir bildirim tercihi yok.", + "channel_email": "E-posta", + "channel_in_app": "Uygulama İçi", + "channel_push": "Anlık Bildirim", + "channel_sms": "SMS", + "delete": "Bildirimi sil", + "delete_confirm_message": "Bu bildirim kalıcı olarak silinecek.", + "delete_confirm_title": "Bildirim silinsin mi?", + "delete_failed": "Bildirim silinemedi. Lütfen tekrar deneyin." + }, + "profile": { + "browser_sessions": "Tarayıcı Oturumları", + "browser_sessions_description": "Diğer tarayıcı ve cihazlardaki etkin oturumlarınızı yönetin ve çıkış yapın.", + "confirm_password": "Şifreyi Onayla", + "confirm_password_description": "Devam etmeden önce lütfen şifrenizi onaylayın.", + "copy_recovery_codes": "Kurtarma Kodlarını Kopyala", + "current_device": "Mevcut cihaz", + "delete_failed": "Hesap silinemedi. Lütfen tekrar deneyin.", + "extended_information": "Genişletilmiş Bilgiler", + "language_label": "Dil", + "logout_other_sessions": "Diğer Tarayıcı Oturumlarından Çıkış Yap", + "no_active_sessions": "Etkin oturum bulunamadı.", + "other_sessions_revoke_error": "Diğer tarayıcı oturumları iptal edilemedi.", + "password_update_failed": "Şifre güncellenemedi. Lütfen mevcut şifrenizi kontrol edin.", + "password_updated": "Şifre başarıyla güncellendi.", + "phone_country_label": "Ülke Kodu", + "phone_label": "Telefon Numarası", + "photo_delete_failed": "Profil fotoğrafı kaldırılamadı.", + "photo_deleted": "Profil fotoğrafı kaldırıldı.", + "photo_requirements": "JPG, GIF veya PNG. En fazla 1MB.", + "photo_update_failed": "Profil fotoğrafı güncellenemedi.", + "photo_updated": "Profil fotoğrafı başarıyla güncellendi.", + "profile_information": "Profil Bilgileri", + "profile_photo": "Profil Fotoğrafı", + "revoke": "İptal Et", + "session_revoke_error": "Tarayıcı oturumu iptal edilemedi.", + "sessions_fetch_error": "Tarayıcı oturumları yüklenemedi.", + "settings": "Profil Ayarları", + "settings_subtitle": "Hesap bilgilerinizi ve tercihlerinizi yönetin", + "timezone_label": "Saat Dilimi", + "timezone_search": "Saat dilimi ara", + "timezone_select": "Saat dilimi seç", + "two_factor_authentication": "İki Faktörlü Doğrulama", + "two_factor_code_label": "Kimlik Doğrulama Kodu", + "two_factor_code_placeholder": "6 haneli kodu girin", + "two_factor_confirm": "Onayla", + "two_factor_confirm_failed": "İki faktörlü doğrulama onaylanamadı.", + "two_factor_disable": "Devre Dışı Bırak", + "two_factor_disable_failed": "İki faktörlü doğrulama devre dışı bırakılamadı.", + "two_factor_disabled_description": "İki faktörlü doğrulama hesabınız için şu anda devre dışı.", + "two_factor_enable": "Etkinleştir", + "two_factor_enable_failed": "İki faktörlü doğrulama etkinleştirilemedi.", + "two_factor_enabled": "İki faktörlü doğrulama etkinleştirildi.", + "two_factor_enabled_description": "Hesabınız iki faktörlü doğrulama ile korunuyor.", + "two_factor_manual_entry": "Manuel giriş anahtarı", + "two_factor_recovery_codes_description": "Bu kurtarma kodlarını güvenli bir yerde saklayın. Kimlik doğrulama cihazınızı kaybederseniz hesabınıza erişmek için kullanılabilirler.", + "two_factor_recovery_codes_fetch_failed": "Kurtarma kodları yüklenemedi.", + "two_factor_recovery_codes_regenerate_failed": "Kurtarma kodları yeniden oluşturulamadı.", + "two_factor_regenerate_codes": "Kurtarma Kodlarını Yeniden Oluştur", + "two_factor_setup_description": "Kimlik doğrulama uygulamanızla QR kodunu tarayın ve kurulumu onaylamak için oluşturulan kodu girin.", + "two_factor_show_recovery_codes": "Kurtarma Kodlarını Göster", + "update_failed": "Profil güncellenemedi. Lütfen tekrar deneyin.", + "update_password": "Şifreyi Güncelle", + "updated": "Profil başarıyla güncellendi.", + "copy_recovery_codes_success": "Kurtarma kodları panoya kopyalandı.", + "two_factor_auth": "İki Faktörlü Doğrulama", + "two_factor": { + "copy_codes": "Tüm Kodları Kopyala", + "invalid_code": "Girilen iki faktörlü doğrulama kodu geçersizdi." + } + }, + "teams": { + "accept_invitation": "Daveti Kabul Et", + "accept_invitation_subtitle": "Bu daveti kabul ederek bir takıma katılın", + "accept_invite_failed": "Davet kabul edilemedi.", + "cancel_invite_failed": "Davet iptal edilemedi.", + "cancel_invite_label": "Daveti İptal Et", + "confirm_cancel_invite": "Bu daveti iptal etmek istediğinizden emin misiniz?", + "confirm_remove_member": "Bu üyeyi takımdan kaldırmak istediğinizden emin misiniz?", + "create_failed": "Takım oluşturulamadı.", + "create_team": "Yeni Takım Oluştur", + "create_team_subtitle": "Başkalarıyla iş birliği yapmak için yeni bir takım oluşturun", + "created": "Takım başarıyla oluşturuldu.", + "current_members": "Mevcut Üyeler", + "feature_disabled": "Takım özellikleri bu uygulama için etkinleştirilmemiş.", + "general_settings": "Genel", + "invite_accepted": "Takıma başarıyla katıldınız.", + "invite_canceled": "Davet başarıyla iptal edildi.", + "invite_failed": "Davet gönderilemedi.", + "invite_member": "Üye Davet Et", + "invite_sent": "Davet başarıyla gönderildi.", + "member_remove_failed": "Üye kaldırılamadı.", + "member_removed": "Üye takımdan kaldırıldı", + "no_invitations": "Bekleyen davet yok", + "no_members": "Henüz takım üyesi yok", + "no_team_selected": "Takım seçilmedi.", + "pending": "Beklemede", + "pending_invitations": "Bekleyen Davetler", + "remove_member_label": "Üyeyi Kaldır", + "role_admin": "Yönetici", + "role_member": "Üye", + "select_team": "Bir takım seçin", + "send_invite": "Davet Gönder", + "settings": "Takım Ayarları", + "settings_subtitle": "Takım adınızı, üyelerinizi ve davetlerinizi yönetin", + "switch_failed": "Takım değiştirilemedi.", + "team": "Takım", + "team_name": "Takım Adı", + "update_failed": "Takım güncellenemedi.", + "updated": "Takım başarıyla güncellendi." + }, + "time": { + "days_ago": ":days gün önce", + "hours_ago": ":hours sa önce", + "just_now": "Az önce", + "minutes_ago": ":minutes dk önce", + "date_format": ":day/:month/:year" + } +} diff --git a/bin/check b/bin/check index 2ccd92c..8f7a26c 100755 --- a/bin/check +++ b/bin/check @@ -182,6 +182,51 @@ require_local_siblings() { exit 1 } +# The committed pubspec.lock has to be the HOSTED-only resolution. A lock generated +# while pubspec_overrides.yaml is active encodes the sibling working trees, which +# resolve on this machine and nowhere else, so committing one breaks CI and the +# deploy at once. A local `pub get` rewrites the lock that way constantly (the +# overrides put sibling paths back in it every time), which is why this is a gate +# rather than a note: leave the resulting dirty working copy unstaged, since only +# the STAGED content is checked here. +# +# This guard and `require_local_siblings` above read opposite states on purpose +# and do not wedge each other: that one demands pubspec_overrides.yaml be PRESENT +# in the working tree, this one demands the STAGED lock carry no path from it. +# Regenerating the lock hosted-only never removes the overrides file itself +# (only moves it aside and back), so both hold at once during ordinary local work. +run_lockfile() { + local staged + staged=$(git show :pubspec.lock 2>/dev/null) || { + echo "pubspec.lock is not tracked; commit the hosted-only lock" >&2 + return 1 + } + # A positive control before the negative test. `grep -q` exits 1 on EMPTY + # input exactly as it does on clean input, so a staged lock that is empty or + # truncated would sail through the check below reporting "no local paths". + # The whole job of this gate is to notice a bad lock, and a zero-byte one is + # the worst kind. + if [ "$(printf '%s' "$staged" | grep -c 'sdks:')" -eq 0 ]; then + echo "pubspec.lock is staged but carries no 'sdks:' block, so it is empty" >&2 + echo "or truncated rather than clean. Regenerate it hosted-only." >&2 + return 1 + fi + # `source: path` rather than a relative `path: "../"`. The overrides file holds + # ABSOLUTE paths on purpose, so a poisoned lock reads `path: "/Users/..."` with + # `relative: false` and a relative-path pattern would match it zero times. The + # source line is the categorical test and covers both forms. + # `git` as well as `path`: bin/parse-overrides-test.py:78-91 covers a git + # override as a supported shape, and one resolves to `source: git` with a + # ref nobody else can fetch, which is the same class of unbuildable lock. + if printf '%s' "$staged" | grep -qE 'source: (path|git)'; then + echo "pubspec.lock encodes local sibling paths. Regenerate it hosted-only:" >&2 + echo " mv pubspec_overrides.yaml /tmp/ && flutter pub get && git add pubspec.lock" >&2 + echo " mv /tmp/pubspec_overrides.yaml ." >&2 + return 1 + fi + echo "lockfile: hosted-only, no local paths" +} + bootstrap_ignored_files require_local_siblings require_backend_vendor @@ -252,6 +297,7 @@ echo wants flutter && start "flutter-analyze" run_flutter_analyze wants flutter && start "design-tokens" run_design_tokens wants flutter && start "registry" run_registry +wants flutter && start "lockfile" run_lockfile wants flutter && start "overrides-parser" run_overrides_parser wants backend && start "backend-pint" run_backend_pint diff --git a/bin/design-tokens b/bin/design-tokens index 03df81a..301a33d 100755 --- a/bin/design-tokens +++ b/bin/design-tokens @@ -30,8 +30,8 @@ # equally raw-color routes and are NOT scanned. Zero occurrences in # `lib/` today; add a rule here the day one appears. # - A triple-quoted Dart string is not tracked by the comment scanner, so a -# `//` or `/*` inside one is read as a comment. See `strip_comments`: the -# self-check there turns that from a silent truncation into a loud fallback. +# `//` or `/*` inside one would be read as a comment. Rather than parse them, +# a file containing `'''` or `"""` is scanned RAW: see `strip_comments`. # - A hardcoded pixel value, a missing `dark:` pair, or reaching for a new # component instead of an existing one: none of that is a regex-checkable # shape. This job is a floor, not the whole of "design-first". @@ -93,15 +93,25 @@ done # - A `/*` inside a string literal (a URL, a regex, a glob) does the same thing. # # Strings are tracked for `'` and `"` with backslash escapes. Dart's triple-quoted -# strings are NOT tracked, and that gap is covered by the invariant below rather -# than by more parsing. +# strings are NOT tracked, and there are two guards for that rather than one, +# because a single self-check does not cover both ways this can go wrong. # -# THE SELF-CHECK: `flutter analyze` guarantees every file here compiles, so an -# unterminated `/*` at end of file is impossible in real Dart. If block mode is -# still open when the file ends, this function mis-parsed, and it says so on stderr -# instead of returning a confidently truncated file. The caller then falls back to -# scanning the raw source, which can only over-report. A checker that goes quiet is -# worse than one that goes loud. +# THE SELF-CHECK catches the OPEN case. `flutter analyze` guarantees every file here +# compiles, so an unterminated `/*` at end of file is impossible in real Dart. If +# block mode is still open when the file ends, this function mis-parsed, and it says +# so on stderr and exits 3 instead of returning a confidently truncated file. +# +# It does NOT catch the CLOSED case: a `/*` inside a `'''...'''` body that some +# later ordinary `*/` closes leaves `in_block` at 0 by end of file, so nothing +# fires and the whole intervening region is dropped in silence. Same for a `//` +# inside a triple-quoted body, which truncates that line. So the caller ALSO +# refuses to trust a stripped file that contains `'''` or `"""` at all, and +# rescans it raw. One `grep -q` buys the guarantee that parsing triple quotes +# properly would. +# +# Both guards fail in the same direction on purpose. Over-reporting a comment as a +# violation costs someone a minute; under-reporting costs the gate its whole +# purpose. A checker that goes quiet is worse than one that goes loud. strip_comments() { awk -v path="$1" ' { @@ -206,10 +216,16 @@ while IFS= read -r -d '' file; do is_allowlisted "$rel" && continue stripped="$(strip_comments "$rel" < "$file")" - # Exit 3 is the stripper saying it lost track (see its header). Scan the RAW - # file instead: over-reporting a comment as a violation costs someone a minute, - # and under-reporting costs the gate its whole purpose. - if [ "$?" -eq 3 ]; then + stripped_status="$?" + + # Two reasons to distrust the strip and rescan raw, both failing toward + # over-reporting (see `strip_comments`): exit 3 is the stripper saying it lost + # track of an unterminated block, and a triple-quoted string is a construct it + # does not parse at all. `stripped_status` is captured on the line immediately + # after the assignment on purpose; read four lines down, any command inserted + # between them would silently disable the fallback and restore the exact quiet + # truncation this exists to prevent. + if [ "$stripped_status" -eq 3 ] || grep -q "'''\|\"\"\"" "$file"; then stripped="$(cat "$file")" fi diff --git a/docs/verification-loop.md b/docs/verification-loop.md index a785bc5..b8f3c22 100644 --- a/docs/verification-loop.md +++ b/docs/verification-loop.md @@ -67,9 +67,9 @@ idempotent and preserves any other server entry. It is committed in the fast shape because that is what the fast path is for: measured here, `./bin/fsa list` is 0.63s against 5.21s for `dart run :dispatcher list`, and a dusk walk pays that per command rather than -once. `bin/fsa` keys its build cache on `pubspec.lock`, which this repo does not -track, so its first run after a clone rebuilds the binary; that clone needs -`flutter pub get` before either route works, which is already the first step. +once. `bin/fsa` keys its build cache on `pubspec.lock`, and a fresh clone now +carries that lock, so the cache key is already correct before the first +`flutter pub get`, which is still the first step either route needs. Boot the backend, then the app: @@ -128,9 +128,53 @@ keeps laying out at the old width, and everything renders doubled and clipped. whole page, so an overlap check reads true on every page including unchanged ones. Look at the screenshot. +## 4. Reading a system that is already running + +The three layers above run locally against code you just wrote, and they fail +loudly. Answering a question about a system that is ALREADY running, whether in +production or in a live local stack, fails quietly instead: nothing goes red, you +get a number, and the number is wrong. Rule the harness out before filing a defect. + +- **Read the identifier, never guess it.** A field or translation key that looks + right by naming convention is not the same as one you confirmed exists. A missing + column reads back as null and a missing translation key echoes itself, and + neither is distinguishable from genuinely empty data. Confirm the identifier + against the schema or the source file, not against what the name implies. +- **Take a before/after boundary from the artifact, not from your estimate of when + you acted.** A count bounded by "the N minutes since I made the change" can + include events that happened before the fix actually landed; a file's own + modification time is the real boundary. +- **The machine's clock and the app's clock can disagree.** A server running on + local time beside an app running on UTC (or the reverse) makes a timestamp + comparison read as hours of downtime when it is minutes of clock skew. Print + both clocks in the same command before drawing a conclusion from a timestamp. +- **A count command's exit status can look like failure when the count is a + correct zero.** `grep -c` exits non-zero on a zero count, and a fallback + triggered by that exit code masks a genuine, correct measurement. +- **A value assembled once and cached does not pick up a later change to the + thing it was built from.** A rendered string or composed view built before a + locale switch, a config change, or a data update stays stale until it is + rebuilt; rebuild the artifact fresh before concluding a change did not take + effect. +- **Reading a value at the wrong point in a request pipeline attributes to the + app what a layer in front of it did.** A header or scheme set by a reverse + proxy, read by hitting the app server directly instead of through the proxy, + reads as unset or wrong. +- **A 404 is not a regression until the route is confirmed to exist right now**, + not from memory of when it was added. +- **A single timeout is not evidence of a wall.** When a system enforces a + shared budget across retries, a later call inheriting a smaller remaining + budget is not evidence of new instability. Repeat the measurement before + concluding anything from one slow or failed call. + ## What counts as evidence A claim needs the artifact behind it: the `bin/check` summary, the screenshot pair, the snapshot or the response body. "Should work" and "green locally" are not evidence, and neither is a passing test that could not have failed. Screenshots and snapshots go under `.ac/evidence/`. + +A claim about a running system needs one thing more: the reading has to survive +section 4. A count is only evidence once its boundary comes from the artifact, an +identifier only once it was read rather than guessed, and a single timeout is +never evidence of a wall. diff --git a/lib/app/commands/_index.g.dart b/lib/app/commands/_index.g.dart index 8da1c7c..4f42ac9 100644 --- a/lib/app/commands/_index.g.dart +++ b/lib/app/commands/_index.g.dart @@ -4,4 +4,9 @@ import 'package:fluttersdk_artisan/artisan.dart'; -List get commands => []; +import 'app_rename_command.dart'; + +List get commands => [ + AppRenameCommand(), +]; + diff --git a/lib/app/commands/app_rename_command.dart b/lib/app/commands/app_rename_command.dart new file mode 100644 index 0000000..b9488e6 --- /dev/null +++ b/lib/app/commands/app_rename_command.dart @@ -0,0 +1,874 @@ +import 'dart:io'; + +import 'package:fluttersdk_artisan/artisan.dart'; + +/// `app:rename` rewrites this app's identity across every platform folder. +/// +/// A fork of this boilerplate carries three identity facets, each spread over a +/// different file format: the Dart package name (`magic_example`), the reverse +/// DNS organisation prefix (`com.fluttersdk`) and the human display name +/// (`Magic Example`). Doing that by hand means a Gradle Kotlin DSL string, an +/// Xcode build setting, a Windows resource file, two CMake `set()` calls, a +/// Linux C constant, a JSON manifest, an HTML meta tag and a Kotlin package +/// DIRECTORY, which is why the published `rename` and `package_rename` packages +/// do not cover it: neither rewrites Dart `package:` imports. +/// +/// Every rewrite is anchored on the setting name rather than on a bare search +/// and replace, so the command is idempotent: a second run with the same +/// arguments recomputes the same value and changes nothing. +class AppRenameCommand extends ArtisanCommand { + /// [root] is the project directory to operate on. It defaults to the process + /// working directory, which is the project root under + /// `dart run bin/dispatcher.dart`; tests pass a fixture directory instead. + AppRenameCommand({Directory? root}) : _root = root ?? Directory.current; + + final Directory _root; + + /// Dart package names: a lowercase identifier. Enforced BEFORE any path is + /// built, because this value becomes the last segment of the Kotlin package + /// directory and an unchecked `../..` there escapes the project. + static final RegExp _namePattern = RegExp(r'^[a-z][a-z0-9_]*$'); + + /// Reverse DNS with at least two segments, each starting with a letter. Same + /// reason as [_namePattern]: every segment becomes a directory level under + /// `android/app/src/main/kotlin/`. + static final RegExp _orgPattern = RegExp( + r'^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$', + ); + + /// The display name lands inside XML attributes, a JSON string, an HTML + /// title, three C string literals and a single-quoted Dart literal. The + /// rejected characters are exactly the ones that would break out of one of + /// those quoting contexts. + /// `#` and a tab are here for a different reason than the quoting + /// metacharacters: they do not break a literal, they get EATEN. The display + /// name is written unquoted into `.env`, and `flutter_dotenv` strips + /// `#[^'"]*$` as a trailing comment, so `--display="Acme #1"` leaves the + /// running app reading `Acme` while this command's own re-read of `.env` + /// still sees the whole string, which breaks idempotency silently. A tab + /// does the same to the JSON string in `web/manifest.json`. + static final RegExp _displayPattern = RegExp(r'''^[^"'\\<>&$#\t\r\n]+$'''); + + @override + String get signature => + 'app:rename {--name= : New Dart package name, e.g. acme_app} ' + '{--org= : New reverse DNS prefix, e.g. com.acme} ' + '{--display= : New human display name, e.g. Acme App} ' + '{--dry-run : List every change and write nothing}'; + + @override + String get description => + 'Rewrite the app identity (package name, bundle id, display name) ' + 'across every platform.'; + + @override + CommandBoot get boot => CommandBoot.none; + + @override + Future handle(ArtisanContext ctx) async { + final bool dryRun = ctx.input.option('dry-run') == true; + + // 1. Validate the requested values before anything reads or builds a path. + final String? name = _stringOption(ctx, 'name'); + final String? org = _stringOption(ctx, 'org'); + final String? display = _stringOption(ctx, 'display'); + + if (name == null && org == null && display == null) { + ctx.output.error( + 'app:rename needs at least one of --name, --org or --display.', + ); + return 1; + } + if (name != null && !_namePattern.hasMatch(name)) { + ctx.output.error( + 'app:rename refused --name="$name": it must match ' + '${_namePattern.pattern} (a lowercase Dart package identifier). ' + 'This value becomes a Kotlin package directory.', + ); + return 1; + } + if (org != null && !_orgPattern.hasMatch(org)) { + ctx.output.error( + 'app:rename refused --org="$org": it must match ${_orgPattern.pattern} ' + '(reverse DNS, at least two dot-separated lowercase segments). ' + 'Every segment becomes a Kotlin package directory level.', + ); + return 1; + } + if (display != null && !_displayPattern.hasMatch(display)) { + ctx.output.error( + 'app:rename refused --display="$display": it may not contain any of ' + r'''" ' \ < > & $ ''' + 'or a line break, because it is written into XML, JSON, HTML, C and ' + 'Dart string literals.', + ); + return 1; + } + + // 2. Read the identity the tree currently carries. Deriving it instead of + // hardcoding `magic_example` is what lets the command run a second time + // on an already-renamed fork. + final _Identity? current = _readCurrentIdentity(ctx); + if (current == null) return 1; + + // The CURRENT identity is read out of the tree, so it is no more trusted + // than the flags above: it reaches `kotlinPath`, and from there a read, a + // write and a `deleteSync(recursive: true)`. `org` survives a hostile value + // because `kotlinPath` splits it on `.`, but `package` is appended whole, + // so a `pubspec.yaml` carrying `name: ../../../tmp/x` with a matching + // gradle namespace would build a move endpoint outside the project. Held to + // the same patterns rather than trusted for having come off disk. + if (!_namePattern.hasMatch(current.package)) { + ctx.output.error( + 'app:rename refused the package name it read from pubspec.yaml ' + '("${current.package}"): it must match ${_namePattern.pattern}. ' + 'This value becomes a Kotlin package directory.', + ); + return 1; + } + if (!_orgPattern.hasMatch(current.org)) { + ctx.output.error( + 'app:rename refused the org it read from android/app/build.gradle.kts ' + '("${current.org}"): it must match ${_orgPattern.pattern}. ' + 'Every segment becomes a Kotlin package directory level.', + ); + return 1; + } + + final target = _Identity( + package: name ?? current.package, + org: org ?? current.org, + display: display ?? current.display, + ); + + // 3. Refuse a dirty worktree, but only when actually writing. A dry run has + // to stay usable in the middle of unrelated work. + if (!dryRun) { + final String? dirty = _dirtyWorktreeReason(); + if (dirty != null) { + ctx.output.error( + 'app:rename refused to write: $dirty\n' + 'This command rewrites files across every platform folder and moves ' + 'a Kotlin package directory; git is the only undo. Commit or stash ' + 'first, or pass --dry-run.', + ); + return 1; + } + } + + // 4. Build the whole plan before touching anything, so a dry run and a real + // run report from the same data. + final plan = _buildPlan(current, target); + + _report(ctx, current, target, plan, dryRun: dryRun); + + if (dryRun) return 0; + + _apply(ctx, plan); + return 0; + } + + // --------------------------------------------------------------------------- + // Reading the current identity + // --------------------------------------------------------------------------- + + /// Derives the identity from three anchored sites. Returns null and reports + /// on stderr when the tree does not carry one; a partial guess here would + /// silently produce a half-renamed project. + _Identity? _readCurrentIdentity(ArtisanContext ctx) { + final pubspec = _read('pubspec.yaml'); + if (pubspec == null) { + ctx.output.error( + 'app:rename found no pubspec.yaml under ${_root.path}. ' + 'Run it from the project root.', + ); + return null; + } + final packageMatch = RegExp( + r'^name:\s*(\S+)\s*$', + multiLine: true, + ).firstMatch(pubspec); + if (packageMatch == null) { + ctx.output.error('app:rename found no `name:` line in pubspec.yaml.'); + return null; + } + final package = packageMatch.group(1)!; + + final gradle = _read('android/app/build.gradle.kts'); + if (gradle == null) { + ctx.output.error( + 'app:rename found no android/app/build.gradle.kts; it is the source ' + 'for the current organisation prefix.', + ); + return null; + } + final namespaceMatch = RegExp( + r'^\s*namespace = "([^"]*)"\s*$', + multiLine: true, + ).firstMatch(gradle); + if (namespaceMatch == null) { + ctx.output.error( + 'app:rename found no `namespace = "..."` in ' + 'android/app/build.gradle.kts.', + ); + return null; + } + final namespace = namespaceMatch.group(1)!; + if (!namespace.endsWith('.$package')) { + ctx.output.error( + 'app:rename refused: the Android namespace "$namespace" does not end ' + 'in ".$package", so the organisation prefix cannot be separated from ' + 'the package name. Align android/app/build.gradle.kts with ' + 'pubspec.yaml first.', + ); + return null; + } + final org = namespace.substring(0, namespace.length - package.length - 1); + + // The display name is read from .env rather than DESIGN.md because .env is + // the value the running app reads through `Config.get('app.name')`, and + // pubspec.yaml declares it as a bundled asset so it is always present. + final env = _read('.env'); + if (env == null) { + ctx.output.error( + 'app:rename found no .env; APP_NAME there is the current display name.', + ); + return null; + } + final displayMatch = RegExp( + r'^APP_NAME=(.*)$', + multiLine: true, + ).firstMatch(env); + if (displayMatch == null) { + ctx.output.error('app:rename found no `APP_NAME=` line in .env.'); + return null; + } + + return _Identity( + package: package, + org: org, + display: displayMatch.group(1)!.trim(), + ); + } + + // --------------------------------------------------------------------------- + // The dirty-worktree guard + // --------------------------------------------------------------------------- + + /// Returns a human reason when the tree holds changes a rename would bury, + /// or null when writing is safe. + String? _dirtyWorktreeReason() { + final probe = Process.runSync('git', const [ + 'rev-parse', + '--is-inside-work-tree', + ], workingDirectory: _root.path); + // Not a git worktree at all: there is nothing for the guard to protect. + if (probe.exitCode != 0) return null; + + final status = Process.runSync('git', const [ + 'status', + '--porcelain', + ], workingDirectory: _root.path); + if (status.exitCode != 0) { + return 'git status failed (${status.stderr.toString().trim()}).'; + } + + final blocking = []; + for (final line in status.stdout.toString().split('\n')) { + if (line.trim().isEmpty) continue; + final code = line.substring(0, 2); + final path = line.substring(3).trim(); + // Untracked files are not at risk: nothing this command writes replaces + // them, and git can still restore everything it tracks. + if (code == '??') continue; + // pubspec.lock is tracked here and PERMANENTLY dirty by design: the index + // holds the hosted-only resolution while a local `flutter pub get` + // rewrites the working copy with sibling paths. Blocking on it would make + // the command unusable in this workspace. + if (path == 'pubspec.lock') continue; + blocking.add(line); + } + if (blocking.isEmpty) return null; + + return 'the worktree has ${blocking.length} uncommitted change(s):\n' + '${blocking.join('\n')}'; + } + + // --------------------------------------------------------------------------- + // Planning + // --------------------------------------------------------------------------- + + _RenamePlan _buildPlan(_Identity from, _Identity to) { + final changed = <_PlannedFile>[]; + final unchanged = []; + final absent = []; + + // Merge by path before planning, because the two generators overlap and the + // loop below re-reads each path from DISK. `lib/main.dart` carries an + // identity rule (the MagicApplication title) and is also scanned for a + // `package:` self-import, so in a fork that has one it appears twice; the + // second pass would start from the original source again and `_apply` would + // write it last, discarding the title rewrite while the report still listed + // the file as changed. This repository cannot reach that case, since its + // own `main.dart` imports relatively, which is exactly why it needs to be + // structural rather than left to a test fixture that mirrors this tree. + final merged = >{}; + for (final rewrite in [ + ..._identityRewrites(from, to), + ..._dartImportRewrites(from, to), + ]) { + merged.putIfAbsent(rewrite.path, () => <_Rule>[]).addAll(rewrite.rules); + } + + for (final entry in merged.entries) { + final rewrite = _FileRewrite(entry.key, entry.value); + final source = _read(rewrite.path); + if (source == null) { + absent.add(rewrite.path); + continue; + } + var updated = source; + for (final rule in rewrite.rules) { + updated = updated.replaceAllMapped(rule.pattern, rule.replace); + } + if (updated == source) { + unchanged.add(rewrite.path); + continue; + } + changed.add( + _PlannedFile(rewrite.path, updated, _changedLines(source, updated)), + ); + } + + return _RenamePlan( + changed: changed, + unchanged: unchanged, + absent: absent, + move: _planKotlinMove(from, to), + ); + } + + /// The Kotlin package is a DIRECTORY whose path mirrors the package + /// declaration, so a rename that only rewrites the `package` line leaves + /// Gradle looking for the old identity (flutter/flutter#55318). Moving the + /// directory is the load-bearing half of the Android rename. + _PlannedMove? _planKotlinMove(_Identity from, _Identity to) { + final source = 'android/app/src/main/kotlin/${from.kotlinPath}'; + final destination = 'android/app/src/main/kotlin/${to.kotlinPath}'; + if (source == destination) return null; + if (!Directory(_absolute(source)).existsSync()) return null; + return _PlannedMove(source, destination); + } + + List<_FileRewrite> _identityRewrites(_Identity from, _Identity to) { + final oldAppleId = RegExp.escape(from.appleId); + + return <_FileRewrite>[ + _FileRewrite('pubspec.yaml', [ + _Rule( + RegExp(r'^name:\s*\S+\s*$', multiLine: true), + (_) => 'name: ${to.package}', + ), + ]), + _FileRewrite('.env', [ + _Rule( + RegExp(r'^APP_NAME=.*$', multiLine: true), + (_) => 'APP_NAME=${to.display}', + ), + ]), + _FileRewrite('DESIGN.md', [ + // The prose body names the app; there is no setting to anchor on, so + // the old display name is the anchor. It runs BEFORE the frontmatter + // rule: when the new name contains the old one ("Magic" -> "Magic + // Example"), running it second would rewrite the already-correct + // frontmatter into "Magic Example Example". + _Rule(_wordBounded(from.display), (_) => to.display), + _Rule( + RegExp(r'^name: .*$', multiLine: true), + (_) => 'name: ${to.display}', + ), + ]), + _FileRewrite('.github/dependabot.yml', [ + _Rule( + RegExp(r'^(# Dependabot config for [^/\s]+/)\S+$', multiLine: true), + (m) => '${m[1]}${to.package}', + ), + ]), + _FileRewrite('lib/main.dart', [ + _Rule( + RegExp(r"(MagicApplication\(title: ')[^']*(')"), + (m) => '${m[1]}${to.display}${m[2]}', + ), + ]), + + // Android. + _FileRewrite('android/app/build.gradle.kts', [ + _Rule( + RegExp(r'^(\s*)namespace = "[^"]*"$', multiLine: true), + (m) => '${m[1]}namespace = "${to.androidId}"', + ), + _Rule( + RegExp(r'^(\s*)applicationId = "[^"]*"$', multiLine: true), + (m) => '${m[1]}applicationId = "${to.androidId}"', + ), + ]), + _FileRewrite('android/app/src/main/AndroidManifest.xml', [ + _Rule( + RegExp(r'android:label="[^"]*"'), + (_) => 'android:label="${to.display}"', + ), + ]), + _FileRewrite( + 'android/app/src/main/kotlin/${from.kotlinPath}/MainActivity.kt', + [ + _Rule( + RegExp(r'^package .*$', multiLine: true), + (_) => 'package ${to.androidId}', + ), + ], + ), + + // iOS. macos/Runner/Info.plist deliberately has no entry: it reads + // $(PRODUCT_BUNDLE_IDENTIFIER) and $(PRODUCT_NAME) from the xcconfig. + _FileRewrite('ios/Runner/Info.plist', [ + _Rule( + RegExp( + r'(CFBundleDisplayName\s*)[^<]*()', + ), + (m) => '${m[1]}${to.display}${m[2]}', + ), + _Rule( + RegExp(r'(CFBundleName\s*)[^<]*()'), + (m) => '${m[1]}${to.package}${m[2]}', + ), + ]), + // A pbxproj is a nested property list where the same token appears in + // object ids, comments and build phases. Anchoring on the setting name + // AND the current value is what keeps a rename from mangling the file; + // the `([^;\n]*)` tail preserves suffixes such as `.RunnerTests`. + _FileRewrite('ios/Runner.xcodeproj/project.pbxproj', [ + _Rule( + RegExp('PRODUCT_BUNDLE_IDENTIFIER = $oldAppleId([^;\n]*);'), + (m) => 'PRODUCT_BUNDLE_IDENTIFIER = ${to.appleId}${m[1]};', + ), + ]), + + // macOS. + _FileRewrite('macos/Runner/Configs/AppInfo.xcconfig', [ + _Rule( + RegExp(r'^PRODUCT_NAME = .*$', multiLine: true), + (_) => 'PRODUCT_NAME = ${to.package}', + ), + _Rule( + RegExp(r'^PRODUCT_BUNDLE_IDENTIFIER = .*$', multiLine: true), + (_) => 'PRODUCT_BUNDLE_IDENTIFIER = ${to.appleId}', + ), + _Rule( + RegExp(r'^PRODUCT_COPYRIGHT = .*$', multiLine: true), + (m) => m[0]!.replaceAll(from.org, to.org), + ), + ]), + _FileRewrite('macos/Runner.xcodeproj/project.pbxproj', [ + _Rule( + RegExp('PRODUCT_BUNDLE_IDENTIFIER = $oldAppleId([^;\n]*);'), + (m) => 'PRODUCT_BUNDLE_IDENTIFIER = ${to.appleId}${m[1]};', + ), + // The RunnerTests target's host application, and the one line in this + // file that is functional rather than cosmetic: leave it and a renamed + // fork's macOS test target cannot launch, because it looks for a bundle + // no longer produced. Anchored on the setting name AND the current + // value, the same shape as the bundle-id rule above, so it cannot + // touch an object id or a comment. There is no iOS counterpart on + // purpose: `ios/.../project.pbxproj` names `Runner.app` there, which is + // not the package name and does not move. + _Rule( + RegExp( + 'TEST_HOST = "\\\$\\(BUILT_PRODUCTS_DIR\\)/' + '${RegExp.escape(from.package)}\\.app/([^"]*)"', + ), + (m) => + 'TEST_HOST = "\$(BUILT_PRODUCTS_DIR)/${to.package}.app/' + '${m[1]!.replaceAll(from.package, to.package)}"', + ), + ]), + _FileRewrite( + 'macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme', + [ + _Rule( + RegExp(r'BuildableName = "[^"]*\.app"'), + (_) => 'BuildableName = "${to.package}.app"', + ), + ], + ), + + // Windows. + _FileRewrite('windows/CMakeLists.txt', [ + _Rule( + RegExp(r'^project\([^\s)]+ LANGUAGES CXX\)$', multiLine: true), + (_) => 'project(${to.package} LANGUAGES CXX)', + ), + _Rule( + RegExp(r'^set\(BINARY_NAME "[^"]*"\)$', multiLine: true), + (_) => 'set(BINARY_NAME "${to.package}")', + ), + ]), + _FileRewrite('windows/runner/main.cpp', [ + _Rule( + RegExp(r'(window\.Create\(L")[^"]*(")'), + (m) => '${m[1]}${to.display}${m[2]}', + ), + ]), + _FileRewrite('windows/runner/Runner.rc', [ + _rcValue('CompanyName', (_) => to.org), + _rcValue('FileDescription', (_) => to.display), + _rcValue('InternalName', (_) => to.package), + _rcValue('LegalCopyright', (v) => v.replaceAll(from.org, to.org)), + _rcValue('OriginalFilename', (_) => '${to.package}.exe'), + _rcValue('ProductName', (_) => to.display), + ]), + + // Linux. + _FileRewrite('linux/CMakeLists.txt', [ + _Rule( + RegExp(r'^set\(BINARY_NAME "[^"]*"\)$', multiLine: true), + (_) => 'set(BINARY_NAME "${to.package}")', + ), + _Rule( + RegExp(r'^set\(APPLICATION_ID "[^"]*"\)$', multiLine: true), + (_) => 'set(APPLICATION_ID "${to.androidId}")', + ), + ]), + _FileRewrite('linux/runner/my_application.cc', [ + _Rule( + RegExp(r'(gtk_header_bar_set_title\(header_bar, ")[^"]*(")'), + (m) => '${m[1]}${to.display}${m[2]}', + ), + _Rule( + RegExp(r'(gtk_window_set_title\(window, ")[^"]*(")'), + (m) => '${m[1]}${to.display}${m[2]}', + ), + ]), + + // Web. + _FileRewrite('web/manifest.json', [ + _Rule( + RegExp(r'("name":\s*")[^"]*(")'), + (m) => '${m[1]}${to.display}${m[2]}', + ), + _Rule( + RegExp(r'("short_name":\s*")[^"]*(")'), + (m) => '${m[1]}${to.display}${m[2]}', + ), + ]), + _FileRewrite('web/index.html', [ + _Rule( + RegExp( + r'( '${m[1]}${to.display}${m[2]}', + ), + _Rule( + RegExp(r'[^<]*'), + (_) => '${to.display}', + ), + ]), + ]; + } + + /// Matches [text] as a whole word so a display name that is a prefix of a + /// longer word ("App" inside "Application") is left alone. The boundary is + /// dropped when the name does not start and end on a word character, because + /// `\b` next to punctuation would then never match. + RegExp _wordBounded(String text) { + final escaped = RegExp.escape(text); + final wordEdge = RegExp(r'^\w.*\w$|^\w$', dotAll: true); + return wordEdge.hasMatch(text) ? RegExp('\\b$escaped\\b') : RegExp(escaped); + } + + /// One `VALUE "", "" "\0"` entry of a Windows resource file. + _Rule _rcValue(String key, String Function(String current) value) { + return _Rule( + RegExp('(VALUE "$key", ")([^"]*)(")'), + (m) => '${m[1]}${value(m[2]!)}${m[3]}', + ); + } + + /// Dart `package:/` imports. Scanned rather than listed, because a fork + /// adds its own and a stale hardcoded list would leave the app uncompilable. + List<_FileRewrite> _dartImportRewrites(_Identity from, _Identity to) { + final needle = 'package:${from.package}/'; + final replacement = 'package:${to.package}/'; + final out = <_FileRewrite>[]; + for (final dir in const ['lib', 'test', 'bin']) { + final directory = Directory(_absolute(dir)); + if (!directory.existsSync()) continue; + for (final entity in directory.listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) continue; + if (!entity.readAsStringSync().contains(needle)) continue; + out.add( + _FileRewrite(_relative(entity.path), [ + _Rule(RegExp(RegExp.escape(needle)), (_) => replacement), + ]), + ); + } + } + out.sort((a, b) => a.path.compareTo(b.path)); + return out; + } + + // --------------------------------------------------------------------------- + // Reporting and applying + // --------------------------------------------------------------------------- + + void _report( + ArtisanContext ctx, + _Identity from, + _Identity to, + _RenamePlan plan, { + required bool dryRun, + }) { + final out = ctx.output; + out.writeln( + dryRun ? 'app:rename (dry run, nothing is written)' : 'app:rename', + ); + out.writeln(' package ${from.package} -> ${to.package}'); + out.writeln(' org ${from.org} -> ${to.org}'); + out.writeln(' display ${from.display} -> ${to.display}'); + out.writeln(''); + + out.writeln('changed (${plan.changed.length}):'); + for (final file in plan.changed) { + out.writeln(' ${file.path} (${file.changedLines} line(s))'); + } + out.writeln('unchanged (${plan.unchanged.length}):'); + for (final path in plan.unchanged) { + out.writeln(' $path'); + } + if (plan.absent.isNotEmpty) { + out.writeln('absent (${plan.absent.length}):'); + for (final path in plan.absent) { + out.writeln(' $path'); + } + } + + final move = plan.move; + out.writeln('kotlin package directory:'); + out.writeln( + move == null + ? ' already at android/app/src/main/kotlin/${to.kotlinPath}' + : ' ${move.source} -> ${move.destination}', + ); + + out.writeln(''); + out.writeln('not owned by app:rename, do these by hand:'); + out.writeln(' - launcher icons on every platform'); + out.writeln( + ' - macos/Runner.xcodeproj/project.pbxproj still names ' + '"${from.package}.app" in its PBXFileReference and group entries, which ' + 'are cosmetic labels Xcode regenerates. TEST_HOST and the bundle ids ARE ' + 'rewritten, so the RunnerTests target still launches; `flutter build ' + 'macos` was never affected either way, since the shell phase derives the ' + 'app filename from \$PRODUCT_NAME at build time', + ); + out.writeln(' - README.md and AGENTS.md prose'); + out.writeln( + ' - the remaining .env values, then ' + '`dart run bin/dispatcher.dart design:sync`', + ); + } + + void _apply(ArtisanContext ctx, _RenamePlan plan) { + // Files first: MainActivity.kt is rewritten at its old path, then the whole + // directory moves underneath it. + for (final file in plan.changed) { + File(_absolute(file.path)).writeAsStringSync(file.content); + } + + final move = plan.move; + if (move != null) { + final source = Directory(_absolute(move.source)); + final destination = Directory(_absolute(move.destination)); + destination.createSync(recursive: true); + for (final entity in source.listSync()) { + final name = _relative(entity.path).split('/').last; + final String into = '${destination.path}/$name'; + // Subdirectories move too. This loop used to skip every non-File and + // the `deleteSync(recursive: true)` below then destroyed what it + // skipped, silently. The boilerplate's own package holds only + // MainActivity.kt so nothing here could catch it, but a fork with a + // real Android package almost certainly has subpackages + // (`receivers/`, `workers/`), and losing them to a rename is not a + // failure anyone would connect back to this command. + if (entity is File) { + entity.renameSync(into); + } else if (entity is Directory) { + entity.renameSync(into); + } else { + throw StateError( + 'app:rename found ${entity.runtimeType} at ${entity.path} inside ' + 'the Kotlin package directory and will not move it. Move or ' + 'remove it by hand, then re-run.', + ); + } + } + source.deleteSync(recursive: true); + _pruneEmptyParents(source.parent, 'android/app/src/main/kotlin'); + } + + if (plan.changed.isEmpty && move == null) { + ctx.output.success( + 'Nothing to do: the tree already carries this identity.', + ); + return; + } + + ctx.output.success( + 'Renamed. ${plan.changed.length} file(s) rewritten' + '${move == null ? '' : ', 1 package directory moved'}. ' + 'Run `flutter pub get` before anything else: the Dart package name ' + 'changed, so the current .dart_tool resolution is stale.', + ); + } + + /// Removes the organisation directories the move emptied. Git does not track + /// empty directories, so leaving them behind would make a fresh clone of the + /// fork differ from the machine the rename ran on. + void _pruneEmptyParents(Directory directory, String stopAtRelative) { + final stop = _absolute(stopAtRelative); + var current = directory; + while (current.path != stop && + current.existsSync() && + current.listSync().isEmpty) { + final parent = current.parent; + current.deleteSync(); + current = parent; + } + } + + // --------------------------------------------------------------------------- + // Small helpers + // --------------------------------------------------------------------------- + + String? _stringOption(ArtisanContext ctx, String name) { + final value = ctx.input.option(name); + if (value is! String) return null; + final trimmed = value.trim(); + return trimmed.isEmpty ? null : trimmed; + } + + String _absolute(String relative) => '${_root.path}/$relative'; + + String _relative(String absolute) { + final prefix = '${_root.path}/'; + return absolute.startsWith(prefix) + ? absolute.substring(prefix.length) + : absolute; + } + + String? _read(String relative) { + final file = File(_absolute(relative)); + return file.existsSync() ? file.readAsStringSync() : null; + } + + /// Every rule is line-local, so the line count never shifts and an + /// index-wise comparison is exact. + int _changedLines(String before, String after) { + final a = before.split('\n'); + final b = after.split('\n'); + var count = 0; + for (var i = 0; i < a.length && i < b.length; i++) { + if (a[i] != b[i]) count++; + } + return count + (a.length - b.length).abs(); + } +} + +/// The three identity facets plus everything derived from them. +class _Identity { + const _Identity({ + required this.package, + required this.org, + required this.display, + }); + + /// Dart package name, e.g. `acme_app`. + final String package; + + /// Reverse DNS organisation prefix, e.g. `com.acme`. + final String org; + + /// Human display name, e.g. `Acme App`. + final String display; + + /// Android `namespace` / `applicationId` and the Linux GTK application id. + /// Android keeps the underscored form; the underscore is significant to + /// plugin discovery (flutter/flutter#55318). + String get androidId => '$org.$package'; + + /// Apple bundle identifier. Apple's tooling rejects underscores in a bundle + /// id, so the package name is camel cased here and only here. + String get appleId => '$org.$_camelPackage'; + + /// Directory path under `android/app/src/main/kotlin/`. + String get kotlinPath => '${org.split('.').join('/')}/$package'; + + String get _camelPackage { + final parts = package.split('_'); + return parts.first + + parts + .skip(1) + .where((p) => p.isNotEmpty) + .map((p) => p[0].toUpperCase() + p.substring(1)) + .join(); + } +} + +typedef _Replacer = String Function(Match match); + +/// One anchored rewrite inside a file. +class _Rule { + const _Rule(this.pattern, this.replace); + + final RegExp pattern; + final _Replacer replace; +} + +/// Every rule that applies to one file, keyed by its project-relative path. +class _FileRewrite { + const _FileRewrite(this.path, this.rules); + + final String path; + final List<_Rule> rules; +} + +/// A file whose rewritten content is ready to be written. +class _PlannedFile { + const _PlannedFile(this.path, this.content, this.changedLines); + + final String path; + final String content; + final int changedLines; +} + +/// The Kotlin package directory move. +class _PlannedMove { + const _PlannedMove(this.source, this.destination); + + final String source; + final String destination; +} + +/// The complete rename, computed before anything is written. +class _RenamePlan { + const _RenamePlan({ + required this.changed, + required this.unchanged, + required this.absent, + required this.move, + }); + + final List<_PlannedFile> changed; + final List unchanged; + final List absent; + final _PlannedMove? move; +} diff --git a/lib/app/controllers/dashboard_controller.dart b/lib/app/controllers/dashboard_controller.dart new file mode 100644 index 0000000..258db45 --- /dev/null +++ b/lib/app/controllers/dashboard_controller.dart @@ -0,0 +1,84 @@ +import 'package:magic/magic.dart'; +import 'package:magic_starter/magic_starter.dart'; + +import '../models/user.dart'; + +/// State behind the dashboard's greeting: the display name of whoever is +/// currently authenticated. +/// +/// **This is the first controller in the app, so it is the pattern the other +/// screens copy.** Its shape follows `depools`' `ProductController` and +/// `magic_starter`'s own controllers: a [MagicController] resolved once +/// through [Magic.findOrPut], and [MagicStateMixin] carrying loading/success +/// so [DashboardView] renders both from one source instead of jumping +/// straight to content. +/// +/// ### Why a controller for a screen with no mutations +/// +/// [DashboardView] greets the CURRENT identity, which makes it session-scoped +/// whether or not a backend sits behind it. `SessionScopeSync.attach()` +/// (`app_service_provider.dart:81`) resets every registered +/// [SessionScopedController] on login and team switch; before this class +/// nothing implemented the contract, so that call ran and had nothing to +/// reset. Without [resetForSession], a team switch would leave the previous +/// tenant's name on screen until the app restarted (see +/// [SessionScopedController]'s own docblock for why `onInit` alone cannot +/// catch this: it runs once per controller lifetime, not once per session). +/// +/// ### What "loading" honestly represents here +/// +/// This boilerplate ships no dashboard endpoint, so there is no network call +/// to await. The single `await` in [load] is not a `Future.delayed` standing +/// in for one: it is the same yield every other controller in this codebase +/// gets for free from its first `await Http.get(...)`, kept so the shape a +/// fork copies is the shape a real fetch needs. +/// +/// Be precise about what that yield does and does not buy, because the first +/// version of this docblock overclaimed it. Awaiting an already-completed +/// future resumes on the MICROTASK queue, and microtasks drain before the +/// scheduler paints, so no frame is ever rendered between [setEmpty] and +/// [setSuccess] on the [resetForSession] path. The yield orders the two +/// notifications; it does not produce a visible cleared state. With a real +/// `await Http.get(...)` in [load] that changes on its own, because a network +/// round trip does cross a frame boundary. A fork wiring a real fetch +/// replaces the body of [load] without touching [onInit] or [resetForSession]. +class DashboardController extends MagicController + with MagicStateMixin + implements SessionScopedController { + /// The shared instance, resolved once and reused for the app's lifetime. + static DashboardController get instance => + Magic.findOrPut(DashboardController.new); + + @override + void onInit() { + super.onInit(); + load(); + } + + /// Resolves the greeting name for the currently authenticated user. + Future load() async { + setLoading(); + + // The yield described in the class docblock above: there is no I/O to + // await yet, only the framework-standard boundary a real fetch would + // occupy. + await Future.value(); + + final String? name = User.current.name; + + setSuccess(name != null && name.trim().isNotEmpty ? name : 'there'); + } + + /// Clears the previous session's greeting and resolves the new one. + /// + /// Called on login and team switch, never on logout (see + /// [SessionScopedController.resetForSession]'s own contract: a logout only + /// routes to the login screen, which never reads this controller). A plain + /// [load] would leave the previous name on screen until the refetch + /// resolves, which is exactly the wrong default across an identity change. + @override + Future resetForSession() async { + setEmpty(); + await load(); + } +} diff --git a/lib/config/example_status_tokens.dart b/lib/config/example_status_tokens.dart new file mode 100644 index 0000000..6d16979 --- /dev/null +++ b/lib/config/example_status_tokens.dart @@ -0,0 +1,59 @@ +/// Status-vocabulary tokens `design:sync` does not emit, merged into +/// `WindThemeData`'s alias map alongside `supplementAliases` in +/// `wind_theme.dart`. +/// +/// DESIGN.md's "Custom token families (supplement)" section documents this +/// exact mechanism: `design:sync` emits `bg-success` and `bg-warning` from +/// its `colors` block, but no `info` role at all and no `text-on-*` foreground +/// for either `success` or `warning` (compare `bg-destructive`, which DOES get +/// a `text-on-destructive` peer). Those gaps are hand-authored here, in the +/// same `' dark:'` className-string shape as `supplementAliases`, +/// and merged in `lib/config/wind_theme.dart` (not `lib/main.dart`, so the +/// token guard in `test/config/` can ask the same theme the app runs on). +/// +/// ### Every foreground here flips by mode, and that is not a style choice +/// +/// The generated fills do not keep a constant lightness across modes: +/// `bg-success` goes `#15803D` light to the LIGHTER `#16A34A` dark, while +/// `bg-warning` goes `#D97706` light to the DARKER `#B45309` dark. So the +/// foreground that clears WCAG AA (4.5:1 for normal text) is white on one side +/// and near-black on the other, in opposite directions for the two roles. A +/// single foreground for both modes fails AA on one of them every time, which +/// is what the first version of this file shipped: white on `#16A34A` measures +/// 3.30 and near-black on `#B45309` measures 3.53. +/// +/// Measured ratios, all against the fills actually in play: +/// +/// text-on-info #FFFFFF on #0369A1 5.93 #111827 on #0EA5E9 6.40 +/// text-on-success #FFFFFF on #15803D 5.02 #111827 on #16A34A 5.38 +/// text-on-warning #111827 on #D97706 5.57 #FFFFFF on #B45309 5.02 +/// +/// Recompute these before changing any hex here or any `success`/`warning` +/// entry in DESIGN.md; the two files are coupled through these pairs and +/// `design:sync` regenerates one of them. See +/// `docs/design-culture/accessibility-wcag.md`. +const Map exampleStatusAliases = { + // bg-info: an informational tone. DESIGN.md's `colors` block defines no + // `info` role at all, so `design:sync` has nothing to emit for it. Sky blue, + // distinct from both the violet brand and the indigo accent so an info + // banner does not read as a brand action. The light fill is sky-700 rather + // than sky-600 because white on sky-600 measures 4.10 and misses AA. + 'bg-info': 'bg-[#0369A1] dark:bg-[#0EA5E9]', + + // text-on-info: the foreground that sits ON the solid `bg-info` fill, the + // same role `text-on-primary`/`text-on-destructive` carry for their own + // backgrounds. White on the dark light-mode fill, near-black on the lighter + // dark-mode one. + 'text-on-info': 'text-[#FFFFFF] dark:text-[#111827]', + + // text-on-success: `design:sync` emits `bg-success` but no foreground peer + // for it (unlike `bg-destructive`, which gets `text-on-destructive`). The + // dark-mode fill `#16A34A` is the LIGHTER of the two, so it takes the dark + // foreground. + 'text-on-success': 'text-[#FFFFFF] dark:text-[#111827]', + + // text-on-warning: the same gap as `success`, flipped. Amber `#D97706` is + // too light for white, and the dark-mode `#B45309` is dark enough that white + // is the only one of the two that clears AA there. + 'text-on-warning': 'text-[#111827] dark:text-[#FFFFFF]', +}; diff --git a/lib/config/localization.dart b/lib/config/localization.dart new file mode 100644 index 0000000..8099812 --- /dev/null +++ b/lib/config/localization.dart @@ -0,0 +1,57 @@ +import 'package:magic/magic.dart'; + +/// Localization configuration. +/// +/// Wires `LocalizationServiceProvider`, which reads only `localization.*` (see +/// its own docblock). Without this file every key the provider reads falls +/// through to its built-in default of `en`, and `assets/lang/tr.json` is never +/// loaded even though `magic_starter.supported_locales` already lists `tr` +/// (see `lib/config/magic_starter.dart`); this file is what makes that list +/// true. See: https://magic.fluttersdk.com/docs/localization +Map get localizationConfig => { + 'localization': { + /// The default locale for the application. + 'locale': env('APP_LOCALE', 'en'), + + /// The fallback locale when a translation is not found. magic's + /// `Translator` REPLACES its sentence map on load rather than merging it + /// with the fallback, so a partial `tr.json` would surface as raw keys + /// (`profile.settings`) rather than English prose; `tr.json` is kept a + /// complete mirror of `en.json`'s key set for exactly this reason. + 'fallback_locale': 'en', + + /// List of supported locales. Add a locale here only once its JSON file + /// under `assets/lang/` covers every key `en.json` does; the translator's + /// replace-not-merge behavior above makes a partial file worse than + /// leaving the locale out. + 'supported_locales': ['en', 'tr'], + + /// Auto-detect locale from device/browser on app start. + /// + /// Off, deliberately, and not because auto-detection is wrong: a device + /// set to a locale this boilerplate does not ship (`de_DE`, `fr_FR`) has + /// no tested path here, and the translator's replace-not-merge sentence + /// map is exactly the mechanism that turns an untested locale into raw + /// keys on every screen. Flip this on once a fork's `supported_locales` + /// covers every locale its users actually have. + 'auto_detect_locale': false, + + /// Path to translation JSON files, matching the `assets:` entry in + /// `pubspec.yaml`. + 'path': 'assets/lang', + + /// Default IANA timezone for date operations, used when detection is + /// disabled or fails. + 'timezone': 'UTC', + + /// Auto-detect timezone from device on app start. Magic reads the real + /// platform IANA identifier (through `flutter_timezone`) and its + /// `LocalizationServiceProvider` boots `DateManager` itself, so nothing + /// needs feeding in by hand. When no valid zone resolves, the `timezone` + /// above stays in effect rather than a guess. + 'auto_detect_timezone': true, + + /// Default date format pattern. + 'date_format': 'MMMM do yyyy', + }, +}; diff --git a/lib/config/magic_starter.dart b/lib/config/magic_starter.dart index 9bad64f..4ddd6fe 100644 --- a/lib/config/magic_starter.dart +++ b/lib/config/magic_starter.dart @@ -15,10 +15,11 @@ Map get magicStarterConfig => { 'sessions': true, 'phone_otp': false, 'newsletter': false, - 'notifications': false, + 'notifications': true, 'email_verification': true, 'guest_auth': false, 'timezones': false, + 'billing': false, }, 'auth': {'email': true, 'phone': false}, 'defaults': {'locale': 'en', 'timezone': 'UTC'}, diff --git a/lib/config/notifications.dart b/lib/config/notifications.dart new file mode 100644 index 0000000..7339ccd --- /dev/null +++ b/lib/config/notifications.dart @@ -0,0 +1,74 @@ +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:magic/magic.dart'; + +/// Notifications configuration. +/// +/// Wires `magic_notifications`' `NotificationServiceProvider`, which reads +/// only `notifications.*` (see its own docblock). An absent push driver or +/// app id is a supported build (push stays quiet, the database channel still +/// works), so both read through [env] with an empty-string fallback rather +/// than requiring a `.env` entry. +/// See: https://magic.fluttersdk.com/docs/notifications +Map get notificationsConfig => { + 'notifications': { + 'push': { + // The only driver `magic_notifications` ships. Read through [env] + // rather than hardcoded so a consumer that registers a driver of its + // own with `Notify.extend` can select it without a code change here. + 'driver': env('NOTIFICATIONS_PUSH_DRIVER', 'onesignal'), + + // Public by design: the Web SDK needs this client-side to open its own + // socket, and it carries no send capability. The OneSignal REST API + // key that CAN send notifications is server-only and never belongs in + // a bundled `.env`. Empty by default; a fork adds its own app id. + 'app_id': env('ONESIGNAL_APP_ID', ''), + + 'notify_button_enabled': false, + + // ---------------------------------------------------------------------- + // The permission posture: ask once where a gesture already justifies it, + // never spend the one-shot browser prompt on an unprompted page load. + // ---------------------------------------------------------------------- + + // Ask on login, but only where the ask is a dialog somebody expects. + // + // The package raises the OS request once per launch when an identity is + // declared, and on mobile that is honest: a person signing in expects to + // be asked, and the platform renders the dialog directly behind the + // sign-in that explains what it is for. + // + // On the web the same call either does nothing or does harm. MDN's + // "Using the Notifications API" guide: browsers disallow a permission + // request that is not triggered by a user gesture, so an automatic + // request at login either goes nowhere (Firefox, Safari) or shows a + // system prompt the operator did not ask for (Chrome), and a dismissal + // there pushes the origin toward being blocked outright. `denied` is a + // state no code can recover, so this stays off on web and leans on the + // in-app reminder below, whose button is a real gesture instead. + 'auto_request_on_login': !kIsWeb, + + // How often the in-app reminder may re-ask a device that declined. + // + // `0` and an absent key both mean NEVER, which is a real default rather + // than a neutral one: a device that never gets asked again never comes + // back. 20 rather than 24 walks the reminder across the day instead of + // pinning it to the moment the user was already busy enough to decline. + 'reprompt_after_hours': 20, + + // Keep the route back on a device the OS prompt is spent on. Doubles as + // `canOpenPlatformSettings`, turning a blocked permission into a control + // on iOS and Android rather than a dead end. + 'fallback_to_settings': true, + }, + 'database': { + 'enabled': true, + 'polling_interval': 30, // seconds + }, + 'soft_prompt': { + // The app's own reminder, and on the web the ONLY thing that ever asks: + // `auto_request_on_login` is off there, so switching this off would mean + // a browser is never asked for push at all. + 'enabled': true, + }, + }, +}; diff --git a/lib/config/wind_theme.dart b/lib/config/wind_theme.dart index e12c8ff..fb1935f 100644 --- a/lib/config/wind_theme.dart +++ b/lib/config/wind_theme.dart @@ -1,5 +1,6 @@ import 'package:magic/magic.dart'; +import 'example_status_tokens.dart'; import 'wind_theme.g.dart'; /// The app's Wind theme, assembled in ONE place. @@ -17,7 +18,11 @@ import 'wind_theme.g.dart'; WindThemeData buildWindTheme() { return WindThemeData( colors: designColors, - aliases: {...designAliases, ...supplementAliases}, + aliases: { + ...designAliases, + ...supplementAliases, + ...exampleStatusAliases, + }, ); } diff --git a/lib/main.dart b/lib/main.dart index 9515c45..425b677 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,8 @@ import 'config/cache.dart'; import 'config/logging.dart'; import 'config/broadcasting.dart'; import 'config/deeplink.dart'; +import 'config/localization.dart'; +import 'config/notifications.dart'; import 'config/wind_theme.dart'; import 'package:flutter/foundation.dart' show kDebugMode; import 'package:magic_devtools/magic_devtools.dart'; @@ -37,6 +39,8 @@ void main() async { () => loggingConfig, () => broadcastingConfig, () => deeplinkConfig, + () => localizationConfig, + () => notificationsConfig, () => magicStarterConfig, ], ); diff --git a/lib/resources/views/dashboard_view.dart b/lib/resources/views/dashboard_view.dart index fed17a0..a985272 100644 --- a/lib/resources/views/dashboard_view.dart +++ b/lib/resources/views/dashboard_view.dart @@ -10,21 +10,35 @@ import 'package:magic_starter/magic_starter.dart' MSUpgradeNudge, UpgradePrompt; +import '../../app/controllers/dashboard_controller.dart'; + /// Dashboard view: the default landing page after successful authentication. /// /// Design-first: every surface and text color flows through the semantic /// alias tokens (`bg-surface`, `text-fg`, ...) so it tracks DESIGN.md in both /// light and dark. The quick-link tiles compose the shared [MSCard] component. /// +/// Reads the greeting name through [DashboardController], the app's first +/// controller: the outer card renders the controller's loading state while +/// the identity resolves and its loaded state once the name is in, rather +/// than jumping straight to content (see the controller's own docblock for +/// what "loading" honestly means on a screen with no backend). +/// /// The "AI Insights" banner below the quick links is a DEMO of the /// [UpgradePrompt] seam, not a real billing integration: a fork with no plan /// gating can delete the whole "3. Plan-gate demo" block, and a fork that /// does gate features can copy the pattern into a real controller's non-2xx /// branch (`UpgradePrompt.showIfGated(response)`). -class DashboardView extends StatelessWidget { +class DashboardView extends MagicStatefulView { /// Creates the [DashboardView]. const DashboardView({super.key}); + @override + State createState() => _DashboardViewState(); +} + +class _DashboardViewState + extends MagicStatefulViewState { static const _iconHero = Icons.auto_awesome; static const _iconDocs = Icons.menu_book; static const _iconGitHub = Icons.code; @@ -43,112 +57,154 @@ class DashboardView extends StatelessWidget { /// them sends `?upgrade=Pro` where the catalog expects `pro`. static const _demoRequiredPlanLabel = 'Pro'; + /// Shared by the loading and loaded branches so the card cannot drift. + /// + /// These were duplicated inline in both builders and already differed by + /// indentation, which means the two strings were not even byte-identical: + /// wind collapses the whitespace so they rendered the same, and a change to + /// one would have silently stopped matching the other. + static const String _cardClass = ''' + rounded-2xl bg-surface-container + border border-color-border + p-6 lg:p-8 flex flex-col items-center + '''; + + static const String _heroClass = ''' + w-20 h-20 rounded-2xl + flex items-center justify-center + bg-primary + '''; + + static const Widget _hero = WDiv( + className: _heroClass, + child: WIcon(_iconHero, className: 'text-4xl text-on-primary'), + ); + + @override + void initState() { + // Registers the controller before the base class resolves it, matching + // the canonical pairing (see DashboardController's docblock): this view + // is the controller's only backer, so nothing else would ever create it. + DashboardController.instance; + super.initState(); + } + @override Widget build(BuildContext context) { final appName = Config.get('app.name', 'My App') ?? 'My App'; return WDiv( className: 'w-full max-w-[480px] md:max-w-4xl mx-auto p-4 lg:p-8', - child: WDiv( - className: ''' - rounded-2xl bg-surface-container - border border-color-border - p-6 lg:p-8 flex flex-col items-center - ''', - children: [ - // 1. Hero. - WDiv( - className: ''' - w-20 h-20 rounded-2xl - flex items-center justify-center - bg-primary - ''', - child: const WIcon( - _iconHero, - className: 'text-4xl text-on-primary', - ), - ), - const WSpacer(className: 'h-6'), - MSTypography( - appName, - variant: TypographyVariant.h2, - className: 'text-center', - ), - const WSpacer(className: 'h-2'), - const MSTypography( - 'Built with Magic Starter', - variant: TypographyVariant.caption, - ), + child: controller.renderState( + (name) => _buildLoaded(appName: appName, greetingName: name), + onLoading: _buildSkeleton(), + ), + ); + } - const WSpacer(className: 'h-8'), + /// The card's shape while [DashboardController] resolves the greeting. + /// + /// Same outer proportions as the loaded card so the layout does not jump + /// once the name lands; only the hero icon and a status line render. + Widget _buildSkeleton() { + return WDiv( + className: _cardClass, + children: [ + _hero, + const WSpacer(className: 'h-6'), + MSTypography( + trans('dashboard.loading'), + variant: TypographyVariant.caption, + ), + ], + ); + } - // 2. Quick-link cards. - WDiv( - className: 'w-full grid grid-cols-1 md:grid-cols-3 gap-3', - children: [ - _buildLinkCard( - icon: _iconDocs, - title: 'Documentation', - description: 'Read the Magic Framework docs to get started.', - url: 'https://magic.fluttersdk.com', - ), - _buildLinkCard( - icon: _iconGitHub, - title: 'GitHub', - description: - 'Star the repo, report issues, or contribute code.', - url: 'https://github.com/fluttersdk/magic', - ), - _buildLinkCard( - icon: _iconCli, - title: 'CLI Commands', - description: - 'Run `magic --help` to see all available commands.', - url: 'https://magic.fluttersdk.com/cli', - ), - ], - ), + /// The card's content once [DashboardController] has resolved a greeting. + Widget _buildLoaded({required String appName, required String greetingName}) { + return WDiv( + className: _cardClass, + children: [ + // 1. Hero. + _hero, + const WSpacer(className: 'h-6'), + MSTypography( + appName, + variant: TypographyVariant.h2, + className: 'text-center', + ), + const WSpacer(className: 'h-2'), + MSTypography( + trans('dashboard.welcome_back', {'name': greetingName}), + variant: TypographyVariant.caption, + ), - const WSpacer(className: 'h-8'), - - // 3. Plan-gate demo: shows the one intended way a gated action is - // surfaced (see UpgradePrompt's own docblock), never a bare error - // toast. `onUpgrade` calls UpgradePrompt.startUpgrade, which routes to - // MagicStarterConfig.billingRoute() with the required tier attached. - // - // In THIS app that tap does nothing visible: only `/` is registered - // (lib/routes/app.dart), the starter ships no billing view, and - // magic's router logs "Route not found" and stays put. That is the - // honest state of a demo, not a bug: a fork registers its own billing - // route (or points magic_starter.routes.billing at one) and the same - // call starts working. Display-only otherwise, with no network call - // and no fake response, since the point is the wiring. - MSUpgradeNudge( - message: 'AI-powered insights are available on the Pro plan.', - requiredPlan: _demoRequiredPlanLabel, - onUpgrade: () => UpgradePrompt.startUpgrade(_demoRequiredPlanId), - ), + const WSpacer(className: 'h-8'), - const WSpacer(className: 'h-8'), + // 2. Quick-link cards. + WDiv( + className: 'w-full grid grid-cols-1 md:grid-cols-3 gap-3', + children: [ + _buildLinkCard( + icon: _iconDocs, + title: 'Documentation', + description: 'Read the Magic Framework docs to get started.', + url: 'https://magic.fluttersdk.com', + ), + _buildLinkCard( + icon: _iconGitHub, + title: 'GitHub', + description: 'Star the repo, report issues, or contribute code.', + url: 'https://github.com/fluttersdk/magic', + ), + _buildLinkCard( + icon: _iconCli, + title: 'CLI Commands', + description: 'Run `magic --help` to see all available commands.', + url: 'https://magic.fluttersdk.com/cli', + ), + ], + ), - // 4. Footer. - WDiv( - className: 'flex flex-row items-center justify-center gap-1', - children: [ - const WText('Made with', className: 'text-xs text-fg-muted'), - const WIcon(_iconHeart, className: 'text-xs text-destructive'), - const WText('by', className: 'text-xs text-fg-muted'), - WAnchor( - onTap: () => Launch.url('https://anilcancakir.com'), - child: const WText( - 'Anılcan Çakır', - className: 'text-xs font-medium text-fg', - ), + const WSpacer(className: 'h-8'), + + // 3. Plan-gate demo: shows the one intended way a gated action is + // surfaced (see UpgradePrompt's own docblock), never a bare error + // toast. `onUpgrade` calls UpgradePrompt.startUpgrade, which routes to + // MagicStarterConfig.billingRoute() with the required tier attached. + // + // In THIS app that tap does nothing visible: only `/` is registered + // (lib/routes/app.dart), the starter ships no billing view, and + // magic's router logs "Route not found" and stays put. That is the + // honest state of a demo, not a bug: a fork registers its own billing + // route (or points magic_starter.routes.billing at one) and the same + // call starts working. Display-only otherwise, with no network call + // and no fake response, since the point is the wiring. + MSUpgradeNudge( + message: 'AI-powered insights are available on the Pro plan.', + requiredPlan: _demoRequiredPlanLabel, + onUpgrade: () => UpgradePrompt.startUpgrade(_demoRequiredPlanId), + ), + + const WSpacer(className: 'h-8'), + + // 4. Footer. + WDiv( + className: 'flex flex-row items-center justify-center gap-1', + children: [ + const WText('Made with', className: 'text-xs text-fg-muted'), + const WIcon(_iconHeart, className: 'text-xs text-destructive'), + const WText('by', className: 'text-xs text-fg-muted'), + WAnchor( + onTap: () => Launch.url('https://anilcancakir.com'), + child: const WText( + 'Anılcan Çakır', + className: 'text-xs font-medium text-fg', ), - ], - ), - ], - ), + ), + ], + ), + ], ); } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 111bae6..1d7441a 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -13,6 +13,7 @@ import flutter_secure_storage_darwin import flutter_timezone import flutter_web_auth_2 import google_sign_in_ios +import purchases_flutter import share_plus import shared_preferences_foundation import url_launcher_macos @@ -27,6 +28,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin")) FlutterWebAuth2Plugin.register(with: registry.registrar(forPlugin: "FlutterWebAuth2Plugin")) FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) + PurchasesFlutterPlugin.register(with: registry.registrar(forPlugin: "PurchasesFlutterPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..2c05652 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1335 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + app_links: + dependency: transitive + description: + name: app_links + sha256: f8db46d2ea9ff6f3a37191a7fd5b7813da1253ace8c32c1b9eadace41d1188ea + url: "https://pub.dev" + source: hosted + version: "7.2.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + archive: + dependency: transitive + description: + name: archive + sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 + url: "https://pub.dev" + source: hosted + version: "4.2.0" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + asn1lib: + dependency: transitive + description: + name: asn1lib + sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" + url: "https://pub.dev" + source: hosted + version: "1.6.5" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e + url: "https://pub.dev" + source: hosted + version: "1.1.3" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: cfd4f5f575a49c5f10ca856e9846073f1e6c3ee94912377eea5f6cefc5272941 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + dart_mcp: + dependency: transitive + description: + name: dart_mcp + sha256: "852b51da915d679be8d051e2e3b6c68ed19fe685f1c676bb35272a2d73ceac7a" + url: "https://pub.dev" + source: hosted + version: "0.5.2" + dbus: + dependency: transitive + description: + name: dbus + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 + url: "https://pub.dev" + source: hosted + version: "0.7.15" + desktop_webview_window: + dependency: transitive + description: + name: desktop_webview_window + sha256: b6fdae2cbf9571879b1761c12f27facaf82e22d0bdc74d049907c2a09a432957 + url: "https://pub.dev" + source: hosted + version: "0.3.0" + dio: + dependency: transitive + description: + name: dio + sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1" + url: "https://pub.dev" + source: hosted + version: "5.11.1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + encrypt: + dependency: transitive + description: + name: encrypt + sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" + url: "https://pub.dev" + source: hosted + version: "5.0.3" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + faker: + dependency: transitive + description: + name: faker + sha256: "544c34e9e1d322824156d5a8d451bc1bb778263b892aded24ec7ba77b0706624" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_picker: + dependency: transitive + description: + name: file_picker + sha256: "29cc1fdb20613876cc7afc529738c1c0f11a9ca159b010edad0c566ac330847e" + url: "https://pub.dev" + source: hosted + version: "11.0.3" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab + url: "https://pub.dev" + source: hosted + version: "0.9.4+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 + url: "https://pub.dev" + source: hosted + version: "0.9.5+1" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec + url: "https://pub.dev" + source: hosted + version: "0.9.3+6" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_dotenv: + dependency: transitive + description: + name: flutter_dotenv + sha256: d41da11fb497314fbf89811ec30af02d1d898b47980a129f0a8c0a1720460ba2 + url: "https://pub.dev" + source: hosted + version: "6.0.1" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" + flutter_secure_storage: + dependency: transitive + description: + name: flutter_secure_storage + sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" + url: "https://pub.dev" + source: hosted + version: "10.3.1" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" + url: "https://pub.dev" + source: hosted + version: "0.3.2" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_timezone: + dependency: transitive + description: + name: flutter_timezone + sha256: "869677426fde92dbe170fb7d2d4929f2a8343c2f5f62f08b0bb64f908630b073" + url: "https://pub.dev" + source: hosted + version: "5.1.0" + flutter_web_auth_2: + dependency: transitive + description: + name: flutter_web_auth_2 + sha256: a7655829251ee63aae64a748f8512f36670d8eba4657b0055cdf5ef304a9d164 + url: "https://pub.dev" + source: hosted + version: "5.1.0" + flutter_web_auth_2_platform_interface: + dependency: transitive + description: + name: flutter_web_auth_2_platform_interface + sha256: ba0fbba55bffb47242025f96852ad1ffba34bc451568f56ef36e613612baffab + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + fluttersdk_artisan: + dependency: "direct main" + description: + name: fluttersdk_artisan + sha256: "15f77c2e57663c75358d4ae6e6b72c74b53a8340478522d75449141bbcef3385" + url: "https://pub.dev" + source: hosted + version: "0.0.14" + fluttersdk_dusk: + dependency: "direct main" + description: + name: fluttersdk_dusk + sha256: cb67f106452e2e655a9f5e3345c6c04b3c39fa81f1247eaa37cf2fa0b45c878c + url: "https://pub.dev" + source: hosted + version: "0.0.13" + fluttersdk_telescope: + dependency: "direct main" + description: + name: fluttersdk_telescope + sha256: "2496ce77e12fc0d4053c3e204d9cc2ec6872ec7be7acf3de8c18b5a114e272a7" + url: "https://pub.dev" + source: hosted + version: "0.0.5" + fluttersdk_wind: + dependency: transitive + description: + name: fluttersdk_wind + sha256: "7374b032db392aa092e0e05466ec3f510b32f84d0179de66691d3d022a65d65f" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + fluttersdk_wind_diagnostics_contracts: + dependency: transitive + description: + name: fluttersdk_wind_diagnostics_contracts + sha256: "3c302f7504c9e8037072b4d2be2c266fb5488aa907eda1ce9321f1ee8556c972" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + glob: + dependency: transitive + description: + name: glob + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + go_router: + dependency: transitive + description: + name: go_router + sha256: d7a3576cb312649eaa51f2356450aed686085fb58fcdebda5b359aa951eef7ea + url: "https://pub.dev" + source: hosted + version: "17.5.0" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + google_sign_in: + dependency: transitive + description: + name: google_sign_in + sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: c403315d87aba1f815a0a401093f97808cab6cb2bdd11ca431ff8587fd7f1c00 + url: "https://pub.dev" + source: hosted + version: "7.2.17" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: "50ab85d3a732227807bb871a5df0dfaa09005f24b00b6f775f500c7b62b12d7e" + url: "https://pub.dev" + source: hosted + version: "6.3.3" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + gtk: + dependency: transitive + description: + name: gtk + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f + url: "https://pub.dev" + source: hosted + version: "2.2.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "1976370a4df3091bb0f72409c187ad1f9132a818bc6b95ca59c0bae1c75c688e" + url: "https://pub.dev" + source: hosted + version: "4.9.2" + image_picker: + dependency: transitive + description: + name: image_picker + sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "1c0c38790306fda4ed774095620444333e56a2b6bc8fc98f3a35c9398781cf54" + url: "https://pub.dev" + source: hosted + version: "0.8.13+22" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: ee3885b6fcd71958fbc79770dd194c63371439d536d69c47b279171a486482ae + url: "https://pub.dev" + source: hosted + version: "0.8.13+7" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: transitive + description: + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" + source: hosted + version: "0.20.3" + jiffy: + dependency: transitive + description: + name: jiffy + sha256: cabced5ddea4612f5e0c498df065ee8a1bd810f7ed757db910e95d2aba80161d + url: "https://pub.dev" + source: hosted + version: "6.4.5" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" + json_rpc_2: + dependency: transitive + description: + name: json_rpc_2 + sha256: "82dfd37d3b2e5030ae4729e1d7f5538cbc45eb1c73d618b9272931facac3bec1" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logger: + dependency: transitive + description: + name: logger + sha256: "2a0dc097e7b01d942475bdd552356db2d0f768b05540bd4b2b53f1840f2239a7" + url: "https://pub.dev" + source: hosted + version: "2.8.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + magic: + dependency: "direct main" + description: + name: magic + sha256: "6ed2efc9e09e3803c4dd180d80050eba7981325c3496e21ddb6620b25e11b3e0" + url: "https://pub.dev" + source: hosted + version: "0.0.9" + magic_deeplink: + dependency: "direct main" + description: + name: magic_deeplink + sha256: "6aa5ea94fdc926ee06958d27a3bcc8f632d17b55bfa56fdfc77e290f6728b6a0" + url: "https://pub.dev" + source: hosted + version: "0.0.3" + magic_devtools: + dependency: "direct main" + description: + name: magic_devtools + sha256: aa7337320708cc5864a3514299a4a6049c59c977526128f14d0fd7653b8e8519 + url: "https://pub.dev" + source: hosted + version: "0.0.4" + magic_notifications: + dependency: "direct main" + description: + name: magic_notifications + sha256: "9ec9464b88ff628881a26870a2b6a45fba901acf515be451230d0691f2b7f97c" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + magic_payments: + dependency: transitive + description: + name: magic_payments + sha256: a3ec0cfd8b6e7421e629d003d4da5f9b8ece0510f96e0e000024e3f43b9ee5de + url: "https://pub.dev" + source: hosted + version: "0.0.2" + magic_social_auth: + dependency: "direct main" + description: + name: magic_social_auth + sha256: "37ade352950ce84923a20a6549fd861a0928d3f8efc00c818ba38eef74b3772e" + url: "https://pub.dev" + source: hosted + version: "0.0.3" + magic_starter: + dependency: "direct main" + description: + name: magic_starter + sha256: "86915e3dd2bec379e0ab69c829c14ebdd185b68f6bb9a714bf1d06b9d2acdef0" + url: "https://pub.dev" + source: hosted + version: "0.0.1-alpha.26" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" + source: hosted + version: "1.19.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" + more: + dependency: transitive + description: + name: more + sha256: e252628d2183cc09539b686abfbd9d8302675959b89a2a8146f5f4baca6ac5ba + url: "https://pub.dev" + source: hosted + version: "4.7.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "9d233b6f2d9c52e1a2b5fbe70451d2c10ac674d3bb419d0ec8de14989d437c26" + url: "https://pub.dev" + source: hosted + version: "0.19.4" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: ad56fd53a78ff6b1472fa59ff2a4e8b8ccabafc586fc263a1dfad0b99b5553e3 + url: "https://pub.dev" + source: hosted + version: "9.6.0" + onesignal_flutter: + dependency: transitive + description: + name: onesignal_flutter + sha256: "712e241ee08c6ef41ea8743e4983179b28c46f5f4010c047b360b3d3aa687e8b" + url: "https://pub.dev" + source: hosted + version: "5.6.8" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.dev" + source: hosted + version: "3.9.1" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" + url: "https://pub.dev" + source: hosted + version: "5.0.6" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + purchases_flutter: + dependency: transitive + description: + name: purchases_flutter + sha256: a96cb101e072f8b1f9ba744ac455903e60adcfdf0680cbac57651f0949cda23a + url: "https://pub.dev" + source: hosted + version: "10.11.0" + quiver: + dependency: transitive + description: + name: quiver + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + record_use: + dependency: transitive + description: + name: record_use + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + share_plus: + dependency: transitive + description: + name: share_plus + sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa" + url: "https://pub.dev" + source: hosted + version: "12.0.2" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + url: "https://pub.dev" + source: hosted + version: "2.4.28" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "4c7fe79840389aaeaf05fd093f795b631b5a98e2bd28d54e555c100f4a9c7a1c" + url: "https://pub.dev" + source: hosted + version: "3.5.2" + sqlite3_flutter_libs: + dependency: transitive + description: + name: sqlite3_flutter_libs + sha256: "3ed7553eee7bb368f8950f58ba29f634e06e813c029aff6a0d60862b96de8454" + url: "https://pub.dev" + source: hosted + version: "0.6.0+eol" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + url: "https://pub.dev" + source: hosted + version: "1.12.2" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + timezone: + dependency: transitive + description: + name: timezone + sha256: "981d1020d6ef8fe1e7b3de5054e5b25579ae7c403d7734adc508ffc47668e9cb" + url: "https://pub.dev" + source: hosted + version: "0.11.1" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" + url: "https://pub.dev" + source: hosted + version: "6.3.33" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" + url: "https://pub.dev" + source: hosted + version: "6.4.2" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" + url: "https://pub.dev" + source: hosted + version: "3.2.3" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" + url: "https://pub.dev" + source: hosted + version: "3.2.6" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935" + url: "https://pub.dev" + source: hosted + version: "1.2.3" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + window_to_front: + dependency: transitive + description: + name: window_to_front + sha256: "14fad8984db4415e2eeb30b04bb77140b180e260d6cb66b26de126a8657a9241" + url: "https://pub.dev" + source: hosted + version: "0.0.4" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" + source: hosted + version: "3.1.4" + yaml_edit: + dependency: transitive + description: + name: yaml_edit + sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488" + url: "https://pub.dev" + source: hosted + version: "2.2.4" +sdks: + dart: ">=3.12.2 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index f1cac1c..4fe4ba7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,28 +35,31 @@ dependencies: # copied outside this workspace resolves without any sibling checkout. # In-workspace development overrides these to local path checkouts via the # gitignored pubspec_overrides.yaml; that file never ships in the fork. - magic: ^0.0.6 - magic_deeplink: ^0.0.2 - magic_notifications: ^0.0.2 - magic_social_auth: ^0.0.2 - magic_starter: ^0.0.1-alpha.17 + magic: ^0.0.9 + magic_deeplink: ^0.0.3 + magic_notifications: ^0.2.0 + magic_social_auth: ^0.0.3 + magic_starter: ^0.0.1-alpha.26 # Dev-tooling, imported by lib/main.dart under kDebugMode (release tree-shakes). - magic_devtools: ^0.0.2 - fluttersdk_dusk: ^0.0.9 - fluttersdk_telescope: ^0.0.4 + magic_devtools: ^0.0.4 + fluttersdk_dusk: ^0.0.13 + fluttersdk_telescope: ^0.0.5 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 - fluttersdk_artisan: ^0.0.9 - - # Pin as a real dependency, not a workspace-only override: magic's own - # file_picker constraint (">=11.0.2 <12.0.0-0") already excludes the - # 12.0.0-beta prerelease, but a fork resolving purely from pub.dev has no - # dependency_overrides safety net, so pin it here too. The beta's non-null - # saveFile params break magic. - file_picker: ^11.0.2 + fluttersdk_artisan: ^0.0.14 + + # No file_picker pin here on purpose. Nothing in this app imports it; it + # arrives through magic's Pick facade, so magic's own constraint is the only + # one that should decide the version. Pinning it here as a second opinion + # bought nothing (published magic 0.0.9 already caps it at <12.0.0-0) and + # cost real breakage: magic's master moved to ^12.2.0 ahead of a release, and + # a pin of ^11.0.2 does not intersect that, so version solving failed outright + # against local sibling checkouts and bin/check could not run at all. Leaving + # it out means the resolved version follows whichever magic is in play, and + # the day magic publishes its file_picker 12 port nothing here needs editing. dev_dependencies: flutter_test: @@ -77,6 +80,7 @@ flutter: assets: - assets/lang/en.json + - assets/lang/tr.json # Runtime env. Must be bundled so flutter_dotenv can load it on web; # without this the app silently falls back to Env defaults # (BROADCAST_CONNECTION=null) and realtime never connects. `.env` is diff --git a/test/app/commands/app_rename_command_test.dart b/test/app/commands/app_rename_command_test.dart new file mode 100644 index 0000000..4caba10 --- /dev/null +++ b/test/app/commands/app_rename_command_test.dart @@ -0,0 +1,627 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:magic_example/app/commands/app_rename_command.dart'; + +/// Every identity site `app:rename` owns. The fixture COPIES these out of the +/// real project rather than inventing stand-ins, so a change to any platform +/// file that breaks an anchor fails here instead of in a fork. +const List _fixtureFiles = [ + 'pubspec.yaml', + '.env', + 'DESIGN.md', + '.github/dependabot.yml', + 'lib/main.dart', + 'bin/dispatcher.dart', + 'test/config/wind_token_resolution_test.dart', + 'test/ui/components/recipes_test.dart', + 'android/app/build.gradle.kts', + 'android/app/src/main/AndroidManifest.xml', + 'android/app/src/main/kotlin/com/fluttersdk/magic_example/MainActivity.kt', + 'ios/Runner/Info.plist', + 'ios/Runner.xcodeproj/project.pbxproj', + 'macos/Runner/Info.plist', + 'macos/Runner/Configs/AppInfo.xcconfig', + 'macos/Runner.xcodeproj/project.pbxproj', + 'macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme', + 'windows/CMakeLists.txt', + 'windows/runner/Runner.rc', + 'windows/runner/main.cpp', + 'linux/CMakeLists.txt', + 'linux/runner/my_application.cc', + 'web/manifest.json', + 'web/index.html', +]; + +const String _oldKotlinDir = + 'android/app/src/main/kotlin/com/fluttersdk/magic_example'; +const String _newKotlinDir = 'android/app/src/main/kotlin/com/acme/acme_app'; + +void main() { + final temporaries = []; + + tearDown(() { + for (final directory in temporaries) { + if (directory.existsSync()) directory.deleteSync(recursive: true); + } + temporaries.clear(); + }); + + Directory fixture({bool asGitRepository = false}) { + final root = Directory.systemTemp.createTempSync('app_rename_'); + temporaries.add(root); + for (final relative in _fixtureFiles) { + final destination = File('${root.path}/$relative'); + destination.parent.createSync(recursive: true); + destination.writeAsStringSync(File(relative).readAsStringSync()); + } + if (asGitRepository) { + _git(root, const ['init', '--initial-branch=main']); + _git(root, const ['add', '-A']); + _git(root, const [ + '-c', + 'user.email=test@example.com', + '-c', + 'user.name=Test', + 'commit', + '-m', + 'fixture', + ]); + } + return root; + } + + group('app:rename dry run', () { + test('names every identity site and writes nothing', () async { + final root = fixture(); + final before = _snapshot(root); + + final result = await _run( + root, + name: 'acme_app', + org: 'com.acme', + display: 'Acme App', + dryRun: true, + ); + + expect(result.code, 0); + expect(_snapshot(root), before); + expect(Directory('${root.path}/$_oldKotlinDir').existsSync(), isTrue); + expect(Directory('${root.path}/$_newKotlinDir').existsSync(), isFalse); + + for (final relative in _fixtureFiles) { + if (relative == 'macos/Runner/Info.plist') continue; + expect( + result.output, + contains(relative), + reason: '$relative is missing from the dry-run report', + ); + } + expect(result.output, contains('$_oldKotlinDir -> $_newKotlinDir')); + }); + + test('stays usable while the worktree is dirty', () async { + final root = fixture(asGitRepository: true); + File('${root.path}/pubspec.yaml').writeAsStringSync( + '${File('${root.path}/pubspec.yaml').readAsStringSync()}\n# scratch\n', + ); + + final result = await _run( + root, + name: 'acme_app', + org: 'com.acme', + display: 'Acme App', + dryRun: true, + ); + + expect(result.code, 0); + expect(result.output, isNot(contains('refused to write'))); + }); + }); + + group('app:rename apply', () { + test('rewrites every identity site', () async { + final root = fixture(); + + final result = await _run( + root, + name: 'acme_app', + org: 'com.acme', + display: 'Acme App', + ); + expect(result.code, 0); + + String read(String relative) => + File('${root.path}/$relative').readAsStringSync(); + + expect(read('pubspec.yaml'), contains('name: acme_app')); + expect(read('.env'), contains('APP_NAME=Acme App')); + expect(read('DESIGN.md'), contains('name: Acme App')); + expect(read('DESIGN.md'), isNot(contains('Magic Example'))); + expect( + read('.github/dependabot.yml'), + contains('# Dependabot config for fluttersdk/acme_app'), + ); + expect(read('lib/main.dart'), contains("title: 'Acme App'")); + + expect( + read('android/app/build.gradle.kts'), + contains('namespace = "com.acme.acme_app"'), + ); + expect( + read('android/app/build.gradle.kts'), + contains('applicationId = "com.acme.acme_app"'), + ); + expect( + read('android/app/src/main/AndroidManifest.xml'), + contains('android:label="Acme App"'), + ); + + expect(read('ios/Runner/Info.plist'), contains('Acme App<')); + expect(read('ios/Runner/Info.plist'), contains('acme_app<')); + // Apple bundle ids reject underscores, so the package name is camel cased + // for the Apple platforms and only there. + expect( + read('ios/Runner.xcodeproj/project.pbxproj'), + contains('PRODUCT_BUNDLE_IDENTIFIER = com.acme.acmeApp;'), + ); + expect( + read('macos/Runner/Configs/AppInfo.xcconfig'), + allOf( + contains('PRODUCT_NAME = acme_app'), + contains('PRODUCT_BUNDLE_IDENTIFIER = com.acme.acmeApp'), + contains('PRODUCT_COPYRIGHT = '), + contains('com.acme.'), + ), + ); + expect( + read('macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme'), + contains('BuildableName = "acme_app.app"'), + ); + // macos/Runner/Info.plist reads $(PRODUCT_BUNDLE_IDENTIFIER) and + // $(PRODUCT_NAME) from the xcconfig, so a rename must leave it alone. + expect( + read('macos/Runner/Info.plist'), + File('macos/Runner/Info.plist').readAsStringSync(), + ); + + expect( + read('windows/CMakeLists.txt'), + allOf( + contains('project(acme_app LANGUAGES CXX)'), + contains('set(BINARY_NAME "acme_app")'), + ), + ); + expect( + read('windows/runner/main.cpp'), + contains('window.Create(L"Acme App"'), + ); + expect( + read('windows/runner/Runner.rc'), + allOf( + contains('VALUE "CompanyName", "com.acme"'), + contains('VALUE "FileDescription", "Acme App"'), + contains('VALUE "InternalName", "acme_app"'), + contains('VALUE "OriginalFilename", "acme_app.exe"'), + contains('VALUE "ProductName", "Acme App"'), + contains('2026 com.acme.'), + ), + ); + + expect( + read('linux/CMakeLists.txt'), + allOf( + contains('set(BINARY_NAME "acme_app")'), + contains('set(APPLICATION_ID "com.acme.acme_app")'), + ), + ); + expect( + read('linux/runner/my_application.cc'), + contains('gtk_header_bar_set_title(header_bar, "Acme App")'), + ); + + expect(read('web/manifest.json'), contains('"name": "Acme App"')); + expect(read('web/manifest.json'), contains('"short_name": "Acme App"')); + expect(read('web/index.html'), contains('Acme App')); + + expect( + read('bin/dispatcher.dart'), + contains('package:acme_app/app/commands/_index.g.dart'), + ); + expect( + read('test/ui/components/recipes_test.dart'), + contains('package:acme_app/ui/components/tag/tag.recipe.dart'), + ); + + // Nothing may still carry the old identity anywhere this command owns. + for (final relative in _fixtureFiles) { + if (relative == 'macos/Runner/Info.plist') continue; + if (relative == 'macos/Runner.xcodeproj/project.pbxproj') continue; + // MainActivity.kt lives under the directory the rename just moved. + final path = relative.replaceFirst(_oldKotlinDir, _newKotlinDir); + expect( + read(path), + isNot(contains('magic_example')), + reason: '$path still carries the old package name', + ); + } + }); + + test( + 'moves the Kotlin package directory, not just its package line', + () async { + final root = fixture(); + + await _run( + root, + name: 'acme_app', + org: 'com.acme', + display: 'Acme App', + ); + + expect(Directory('${root.path}/$_oldKotlinDir').existsSync(), isFalse); + // The emptied organisation levels go too: git does not track empty + // directories, so leaving them would make a fresh clone differ. + expect( + Directory( + '${root.path}/android/app/src/main/kotlin/com/fluttersdk', + ).existsSync(), + isFalse, + ); + expect( + File( + '${root.path}/$_newKotlinDir/MainActivity.kt', + ).readAsStringSync(), + contains('package com.acme.acme_app'), + ); + }, + ); + + test( + 'touches only anchored PRODUCT_BUNDLE_IDENTIFIER lines in a pbxproj', + () async { + final root = fixture(); + const decoys = + '\t\t\t\t/* com.fluttersdk.magicExample is only a comment */\n' + '\t\t\t\tINFOPLIST_KEY_CFBundleName = com.fluttersdk.magicExample;\n'; + final pbxproj = File( + '${root.path}/ios/Runner.xcodeproj/project.pbxproj', + ); + pbxproj.writeAsStringSync('${pbxproj.readAsStringSync()}$decoys'); + + await _run( + root, + name: 'acme_app', + org: 'com.acme', + display: 'Acme App', + ); + + final updated = pbxproj.readAsStringSync(); + expect(updated, contains(decoys)); + expect( + updated, + isNot(contains('PRODUCT_BUNDLE_IDENTIFIER = com.fluttersdk')), + ); + // The `.RunnerTests` suffix rides along instead of being flattened. + expect( + updated, + contains('PRODUCT_BUNDLE_IDENTIFIER = com.acme.acmeApp.RunnerTests;'), + ); + }, + ); + + test('a second run with the same arguments changes nothing', () async { + final root = fixture(); + + await _run(root, name: 'acme_app', org: 'com.acme', display: 'Acme App'); + final afterFirst = _snapshot(root); + + final second = await _run( + root, + name: 'acme_app', + org: 'com.acme', + display: 'Acme App', + ); + + expect(second.code, 0); + expect(second.output, contains('changed (0)')); + expect(_snapshot(root), afterFirst); + }); + + test('a new display name containing the old one applies exactly once', () async { + // The hazard the DESIGN.md prose rule is ordered against: that rule is + // the one unanchored rewrite in the set, so with the rules in the + // obvious order "Magic Example" to "Magic Example Deluxe" rewrites the + // already-correct frontmatter a second time and yields + // "Magic Example Deluxe Deluxe". Nothing else exercised it. + final root = fixture(); + + final result = await _run(root, display: 'Magic Example Deluxe'); + + expect(result.code, 0); + final design = File('${root.path}/DESIGN.md').readAsStringSync(); + expect(design, contains('Magic Example Deluxe')); + expect(design, isNot(contains('Magic Example Deluxe Deluxe'))); + expect(design, isNot(contains('Deluxe Deluxe'))); + }); + + test('runs with --display alone, leaving package and org untouched', () async { + // The likeliest fork invocation after the first rename, and the case + // where `from.package == to.package`, so every package-keyed rule must + // be a no-op rather than a rewrite-to-itself that reports a change. + final root = fixture(); + final pubspecBefore = File('${root.path}/pubspec.yaml').readAsStringSync(); + + final result = await _run(root, display: 'Acme App'); + + expect(result.code, 0); + expect(File('${root.path}/pubspec.yaml').readAsStringSync(), pubspecBefore); + expect( + File('${root.path}/android/app/build.gradle.kts').readAsStringSync(), + contains('com.fluttersdk.magic_example'), + ); + }); + + test('a file matched by two generators keeps both rewrites', () async { + // lib/main.dart is the one path the identity rules and the Dart-import + // scan can both claim: it carries the MagicApplication title AND, in a + // fork that self-imports, a `package:/` line. THIS repo escapes the + // collision only because its own main.dart imports relatively, so the + // fixture has to introduce the self-import to reach the case a fork hits. + // + // Before the plan merged rewrites by path, the second entry re-read the + // file from disk and `_apply` wrote it last, so the import rewrite landed + // and the title rewrite was discarded while the report still counted the + // file as changed. + final root = fixture(); + final main = File('${root.path}/lib/main.dart'); + main.writeAsStringSync( + "import 'package:magic_example/config/app.dart';\n" + "${main.readAsStringSync()}", + ); + + final result = await _run( + root, + name: 'acme_app', + org: 'com.acme', + display: 'Acme App', + ); + expect(result.code, 0); + + final rewritten = main.readAsStringSync(); + expect( + rewritten, + contains("import 'package:acme_app/config/app.dart';"), + reason: 'the package import was not rewritten', + ); + expect( + rewritten, + contains("MagicApplication(title: 'Acme App'"), + reason: 'the display-name rewrite was discarded by the import rewrite', + ); + expect(rewritten, isNot(contains('magic_example'))); + }); + + test('rewrites the macOS RunnerTests host application', () async { + // TEST_HOST is the only functional line in the macOS project file. Left + // stale it points at a bundle the renamed fork no longer produces, and + // the RunnerTests target cannot launch. The assertion covers both + // occurrences of the package inside the value, not just the .app. + final root = fixture(); + + await _run(root, name: 'acme_app', org: 'com.acme', display: 'Acme App'); + + final pbx = File( + '${root.path}/macos/Runner.xcodeproj/project.pbxproj', + ).readAsStringSync(); + expect(pbx, contains(r'TEST_HOST = "$(BUILT_PRODUCTS_DIR)/acme_app.app/')); + expect(pbx, isNot(contains(r'/magic_example.app/'))); + expect( + pbx, + isNot(contains(r'$(BUNDLE_EXECUTABLE_FOLDER_PATH)/magic_example"')), + ); + }); + }); + + group('app:rename refusals', () { + test('rejects a --name that is not a Dart package identifier', () async { + for (final invalid in const ['Acme App', '../evil', 'com.acme', '9app']) { + final root = fixture(); + final before = _snapshot(root); + + final result = await _run( + root, + name: invalid, + org: 'com.acme', + display: 'Acme App', + ); + + expect(result.code, 1, reason: 'accepted --name=$invalid'); + expect(result.output, contains('app:rename refused --name')); + expect(_snapshot(root), before); + } + }); + + test('rejects an --org that is not reverse DNS', () async { + for (final invalid in const ['acme', '../../etc', 'com..acme', 'Com.A']) { + final root = fixture(); + final before = _snapshot(root); + + final result = await _run( + root, + name: 'acme_app', + org: invalid, + display: 'Acme App', + ); + + expect(result.code, 1, reason: 'accepted --org=$invalid'); + expect(result.output, contains('app:rename refused --org')); + expect(_snapshot(root), before); + } + }); + + test( + 'rejects a --display that would break out of a string literal', + () async { + for (final invalid in const [ + 'Acme "App"', + r'Acme\App', + 'A', + r'$App', + ]) { + final root = fixture(); + final before = _snapshot(root); + + final result = await _run( + root, + name: 'acme_app', + org: 'com.acme', + display: invalid, + ); + + expect(result.code, 1, reason: 'accepted --display=$invalid'); + expect(result.output, contains('app:rename refused --display')); + expect(_snapshot(root), before); + } + }, + ); + + test('refuses when no identity option is given', () async { + final root = fixture(); + + final result = await _run(root); + + expect(result.code, 1); + expect( + result.output, + contains('needs at least one of --name, --org or --display'), + ); + }); + + test('refuses to write into a dirty worktree', () async { + final root = fixture(asGitRepository: true); + final env = File('${root.path}/.env'); + env.writeAsStringSync('${env.readAsStringSync()}\nSCRATCH=1\n'); + final before = _snapshot(root); + + final result = await _run( + root, + name: 'acme_app', + org: 'com.acme', + display: 'Acme App', + ); + + expect(result.code, 1); + expect(result.output, contains('app:rename refused to write')); + expect(result.output, contains('uncommitted change')); + expect(_snapshot(root), before); + }); + + test( + 'does not count a modified pubspec.lock or an untracked file as dirty', + () async { + final root = fixture(asGitRepository: true); + // pubspec.lock is tracked and permanently modified in this workspace: the + // index holds the hosted-only resolution, a local `flutter pub get` + // rewrites the working copy with sibling paths. + File('${root.path}/pubspec.lock').writeAsStringSync('# committed\n'); + _git(root, const ['add', 'pubspec.lock']); + _git(root, const [ + '-c', + 'user.email=test@example.com', + '-c', + 'user.name=Test', + 'commit', + '-m', + 'lock', + ]); + File('${root.path}/pubspec.lock').writeAsStringSync('# local paths\n'); + File('${root.path}/scratch.txt').writeAsStringSync('untracked\n'); + + final result = await _run( + root, + name: 'acme_app', + org: 'com.acme', + display: 'Acme App', + ); + + expect(result.code, 0); + expect(result.output, isNot(contains('refused to write'))); + expect( + File('${root.path}/pubspec.yaml').readAsStringSync(), + contains('name: acme_app'), + ); + }, + ); + + test( + 'refuses when the Android namespace does not match the package name', + () async { + final root = fixture(); + final gradle = File('${root.path}/android/app/build.gradle.kts'); + gradle.writeAsStringSync( + gradle.readAsStringSync().replaceFirst( + 'namespace = "com.fluttersdk.magic_example"', + 'namespace = "com.fluttersdk.somethingelse"', + ), + ); + final before = _snapshot(root); + + final result = await _run( + root, + name: 'acme_app', + org: 'com.acme', + display: 'Acme App', + ); + + expect(result.code, 1); + expect(result.output, contains('does not end in ".magic_example"')); + expect(_snapshot(root), before); + }, + ); + }); +} + +/// Invokes the command against [root] and returns its exit code plus the whole +/// buffered output (errors included, prefixed with `[ERROR]`). +Future<({int code, String output})> _run( + Directory root, { + String? name, + String? org, + String? display, + bool dryRun = false, +}) async { + final output = BufferedOutput(); + final code = await AppRenameCommand(root: root).handle( + ArtisanContext.bare( + MapInput({ + 'name': ?name, + 'org': ?org, + 'display': ?display, + 'dry-run': dryRun, + }), + output, + ), + ); + return (code: code, output: output.content); +} + +/// Path to content for every file under [root], excluding git's own storage. +Map _snapshot(Directory root) { + final snapshot = {}; + for (final entity in root.listSync(recursive: true)) { + if (entity is! File) continue; + final relative = entity.path.substring(root.path.length + 1); + if (relative.startsWith('.git/')) continue; + snapshot[relative] = entity.readAsStringSync(); + } + return snapshot; +} + +void _git(Directory root, List arguments) { + final result = Process.runSync('git', arguments, workingDirectory: root.path); + if (result.exitCode != 0) { + throw StateError('git ${arguments.join(' ')} failed: ${result.stderr}'); + } +} diff --git a/test/config/locale_parity_test.dart b/test/config/locale_parity_test.dart new file mode 100644 index 0000000..f974de2 --- /dev/null +++ b/test/config/locale_parity_test.dart @@ -0,0 +1,59 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// Guards the invariant that every shipped locale carries the SAME key set. +/// +/// magic's `Translator` REPLACES its sentence map on load rather than merging +/// it with the fallback, so a key present in `en.json` and missing from +/// `tr.json` does not fall back to English: it renders the raw key path +/// (`magic_starter.titles.login`) on screen. That failure is silent at build +/// time and only visible by driving the app in the missing locale, which is +/// why it gets a test rather than a review habit. +/// +/// The check is exact equality, not a subset, because the reverse direction is +/// just as wrong: a key only `tr.json` has is dead weight that no English +/// screen can ever reach, and it usually means a translation was copied from a +/// product app that has a feature this one does not. +void main() { + Set flatten(Map node, [String prefix = '']) { + final Set keys = {}; + node.forEach((String key, dynamic value) { + keys.add('$prefix$key'); + if (value is Map) { + keys.addAll(flatten(value, '$prefix$key.')); + } + }); + return keys; + } + + Set keysOf(String locale) { + final File file = File('assets/lang/$locale.json'); + return flatten(jsonDecode(file.readAsStringSync()) as Map); + } + + test('every supported locale carries the same key set as en', () { + // Mirrors `localization.supported_locales` in lib/config/localization.dart. + // Adding a locale there without adding it here leaves the new file + // unguarded, which is the state this test exists to end. + const List locales = ['tr']; + + final Set english = keysOf('en'); + expect(english, isNotEmpty, reason: 'assets/lang/en.json parsed empty'); + + for (final String locale in locales) { + final Set translated = keysOf(locale); + expect( + translated.difference(english), + isEmpty, + reason: '$locale.json has keys en.json does not', + ); + expect( + english.difference(translated), + isEmpty, + reason: '$locale.json is missing keys, which render as raw key paths', + ); + } + }); +}