diff --git a/CHANGELOG.md b/CHANGELOG.md index 06aa65a0..82cc9f99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,20 @@ All notable changes to this project will be documented in this file. ### BREAKING - **`state()` and `count()` return a copy, and every factory implements `Factory newFactory()`.** They used to mutate the factory and return it, so `final f = UserFactory(); f.state({...}); f.make()` carried the state, and two branches off one base leaked into each other. Dart cannot construct "the same subclass" on its own, so the new abstract hook answers with the subclass constructor (`Factory newFactory() => UserFactory();`). Named states move to an extension on `Factory`, since a method on the subclass is out of reach after the first `state()` or `count()`; the old `state({...}) as UserFactory` cast would now throw. No factory subclass exists in this package's `lib/`, `test/` or `example/`; the `make:factory` stub and `doc/database/seeding.md` show the new shape. (`lib/src/database/seeding/factory.dart`, `assets/stubs/factory.stub`, `doc/database/seeding.md`) +- **`MagicTest.init()` resets the Gate, the Translator and the DateManager between tests.** It now calls `Gate.flush()` in `setUp` and `tearDown`, and `DateManager.reset()` plus `Translator.reset()` in `tearDown`, so an ability, a loaded catalogue or a locale no longer leaks into the next test. A suite under `MagicTest.init()` that defines abilities or loads translations in `setUpAll` loses them after the first test and must move that work to `setUp`. (`lib/src/testing/magic_test.dart`, `doc/testing/getting-started.md`) ### Added - **`Model.unguarded()`, `Model.unguard()`, `Model.reguard()` and `Model.isUnguarded`, Laravel's mass-assignment switch.** Inside `Model.unguarded(() => ...)` every `fill()` keeps every key, whatever `fillable` and `guarded` say; the guard comes back when the callback returns or throws, and a nested call leaves the outer scope unguarded. The callback must be synchronous, since an async one would run its later `fill()` calls guarded, and an assertion says so, now checked whether or not the call nests inside an outer `unguard()`/`unguarded()` scope. Outside it, `fill()` behaves exactly as before, `strict: true` included; `fromMap()` and `setRawAttributes()` are untouched. (`lib/src/database/eloquent/model.dart`) - **`Factory.raw()`**, the merged definition and states without a model: always a `List>`, one map per model, a single map when no `count()` was set. (`lib/src/database/seeding/factory.dart`) - **`Carbon.setTestNow([testNow])` and `Carbon.hasTestNow()`, Laravel's frozen-clock testing helper.** `Carbon.now([timezone])` returns the frozen instant while one is set (timezone conversion still applies on top of it), `isToday()`, `isYesterday()`, `isTomorrow()`, `isFuture()`, `isPast()` and argument-less `diffForHumans()` measure against it, and `Carbon.setTestNow()` with no argument (or `null`) clears the freeze. A test that seeds an app's clock now has a Laravel-parity seam instead of threading a fake `DateTime` through every call site. (`lib/src/support/carbon.dart`, `doc/digging-deeper/carbon.md`, `skills/magic-framework/`) +- **`Number`, `Str`, `Arr`, and `Cast`, Laravel's Support helpers ported to the subset magic needs.** `Number` formats values, currency, percentages, file sizes, and abbreviations under a resolved locale (`Number.percentage(99.95, precision: 2, locale: 'tr')` -> `'%99,95'`); `Str.upper`/`Str.lower`/`Str.initials` apply the Turkish/Azerbaijani dotted-i rule `String.toUpperCase()`/`toLowerCase()` get wrong; `Arr.get`/`has`/`set`/`dot` walk a dotted path through a nested `Map`; `Cast` reads a loosely-typed wire value (`stringOr`, `intOr`, `numOrNull`, `boolOr`, `idOrNull`, ...) as a specific type, degrading to a fallback instead of throwing. All four are `abstract final class` static namespaces with no shared base. (`lib/src/support/number.dart`, `lib/src/support/str.dart`, `lib/src/support/arr.dart`, `lib/src/support/cast.dart`, `doc/digging-deeper/helpers.md`, `skills/magic-framework/`) +- **`Carbon.shortDiffForHumans([other])`, a compact ladder for dense tables.** Steps seconds through years (`'14m ago'`, `'1mo ago'`, `'5m from now'`, `'Just now'` under one second), resolving each unit and wrapper through the `Lang` catalogue (`time.units_short.*`, `time.ago`, `time.from_now`, `time.just_now`) with an English literal fallback when no catalogue is loaded. (`lib/src/support/carbon.dart`, `doc/digging-deeper/carbon.md`, `skills/magic-framework/`) +- **`ValidatesRequests.validateRequest`/`validateRequestAsync`, running a `FormRequest` through a controller's own error bag.** `FormRequest.validate()` returns only the rule-filtered payload and never touches `validationErrors`, so a controller calling it directly skipped clearing stale errors, populating per-field errors, and repainting the form. The new methods run the same authorize/prepare sequence but validate through the mixin's `validate()` and return the FULL prepared map. `validateRequest` runs sync rules only (an `AsyncRule` is skipped); `validateRequestAsync` awaits `Validator.validateAsync()` so an `AsyncRule` actually runs. (`lib/src/concerns/validates_requests.dart`, `doc/digging-deeper/validation.md`, `skills/magic-framework/`) +- **`CollapsesIndexedErrorKeys`, an opt-in `ValidatesRequests.errorFieldFor` override for a list field's indexed error keys.** A backend validating a list returns one wire key per element (`items.0.name`); a form with a single error slot per field has nowhere to put a per-index message. Mixed in on top of `ValidatesRequests`, it collapses such a key to its field name (`items.0.name` -> `name`), keeping the FIRST message when two indexed keys collapse onto the same field. Opt-in because the default keeping raw keys is a behaviour existing controllers already depend on. (`lib/src/concerns/validates_requests.dart`, `doc/digging-deeper/validation.md`, `skills/magic-framework/`) +- **`RefetchesOnMount` and `SubmitsOnce`, two view-layer mixins closing gaps in magic's singleton-controller and async-submit model.** `RefetchesOnMount`, mixed onto a `MagicStatefulViewState`, fire-and-forget refetches a controller's data on every mount (not only the first, since controllers fire `onInit` once per instance for the app's lifetime); `SubmitsOnce`, mixed onto a form's `State`, guards a submit handler against a second tap while the first write is in flight, resetting in a `finally` so a throwing submit re-arms the button. (`lib/src/ui/refetches_on_mount.dart`, `lib/src/ui/submits_once.dart`, `doc/basics/views.md`, `skills/magic-framework/`) +- **`Env.filled(key, fallback)` and `Env.getOrFail(key)`, guards for a value a blank silently corrupts.** `Env.get`/`env()` only fall back when a key is entirely absent, so a present-but-blank or quote-only value silently resolved to `''` and has shipped as a blank browser tab title and a link pointing at a path with no origin. `Env.filled` treats absent, blank, and quote-only the same way, stripping one wrapping quote pair and surrounding whitespace from a present value. `Env.getOrFail` mirrors Laravel's `Env::getOrFail`, throwing a `StateError` only when the key is entirely missing. (`lib/src/foundation/env.dart`, `doc/getting-started/configuration.md`, `skills/magic-framework/`) +- **`MagicTest.loadTranslations(locale, {directory})`, a real translation catalogue for a widget test.** Reads `/.json` off disk, flattens it with the app's own flatten rule (`JsonAssetLoader.flatten`, now public), and installs it before awaiting the translator's load, so `trans()` resolves real catalogue strings in a test instead of rendering the raw dotted key. (`lib/src/testing/magic_test.dart`, `doc/testing/getting-started.md`, `skills/magic-framework/`) ### Changed @@ -21,6 +29,7 @@ All notable changes to this project will be documented in this file. ### Fixed - **The `AbilityCallback` doc listed `bool callback(Model user)` as valid.** The gate always calls `callback(user, arguments)`, so that shape throws and the ability is denied; the doc now shows `(Model user, [dynamic arg])`. (`lib/src/auth/gate_manager.dart`) +- **`env('KEY', fallback)` returned the literal two-character string `'""'`/`"''"` for `KEY=""`/`KEY=''`, not the default and not an empty string.** `flutter_dotenv`'s own parser needs at least one character inside the quotes to strip them, so a present-but-quote-only value passed through unquoted. `Env.get` now trims a value that is exactly `'""'` or `"''"` down to `''`, matching Laravel's `Env::get` (`laravel-framework/src/Illuminate/Support/Env.php:271`): an explicit empty value is `''`, never treated as absent. (`lib/src/foundation/env.dart`, `doc/getting-started/configuration.md`, `skills/magic-framework/`) ## [0.0.21] - 2026-09-24 diff --git a/assets/stubs/install/lang_en.stub b/assets/stubs/install/lang_en.stub index 754064cb..54a3e71c 100644 --- a/assets/stubs/install/lang_en.stub +++ b/assets/stubs/install/lang_en.stub @@ -14,5 +14,19 @@ "min": "The :attribute must be at least :min characters.", "max": "The :attribute may not be greater than :max characters.", "confirmed": "The :attribute confirmation does not match." + }, + "time": { + "just_now": "Just now", + "ago": ":time ago", + "from_now": ":time from now", + "units_short": { + "second": ":counts", + "minute": ":countm", + "hour": ":counth", + "day": ":countd", + "week": ":countw", + "month": ":countmo", + "year": ":county" + } } } diff --git a/doc/basics/views.md b/doc/basics/views.md index 6d302645..fe91cadb 100644 --- a/doc/basics/views.md +++ b/doc/basics/views.md @@ -8,6 +8,8 @@ Views extend `MagicView` or `MagicStatefulView` to give each screen a typed cont - [Stateful Views](#stateful-views) - [Form Handling](#form-handling) - [Rendering Async State](#rendering-async-state) +- [Refetching Data on Mount](#refetching-on-mount) +- [Guarding a Submit Against a Double Tap](#submits-once) - [Responsive Views](#responsive-views) - [Generating Views](#generating-views) @@ -195,6 +197,50 @@ Widget build(BuildContext context) { Each callback is optional. Magic provides sensible defaults if omitted. + +## Refetching Data on Mount + +Controllers are Type-keyed singletons and fire `onInit` ONCE per controller instance, not once per view mount, so a controller that loads its data in `onInit` fetches on the first view that resolves it and never again for the lifetime of the app. Navigating away and back re-renders the same cached rows, which reads as stale or fabricated data rather than as a stale screen. + +Mix `RefetchesOnMount` onto a `MagicStatefulViewState` and point `refetch` at a load method your controller defines (`ensureFresh` below). That method must JOIN a load already in flight rather than start a second one: the mount that creates the controller has already started the same load from `onInit`, so a refetch that always fires a new request sends every request twice. + +```dart +class _ItemsListViewState + extends MagicStatefulViewState + with RefetchesOnMount { + @override + Future refetch() => controller.ensureFresh(); +} +``` + +The refetch is fire-and-forget: `build()` renders the cached data immediately and the view rebuilds once the fresh data lands, so a mount never blocks on the network. Have the load keep its last-known-good data on failure, so a failed refetch leaves the screen as it was. Keep a separate method for a refresh after a mutation: it must not join an older in-flight request, or it returns a snapshot without the row the user just created. + + +## Guarding a Submit Against a Double Tap + +An `async` submit handler wired straight to a button (`onTap: _onSubmit`) leaves nothing disabling the button for the duration of the await, so a double tap fires the write twice. On a create path that is not idempotent, two taps create two records. + +Mix `SubmitsOnce` onto the form's `State`, route the handler through `submitOnce`, and feed `isSubmitting` to the button's `isLoading`: + +```dart +class _RegisterFormState extends State with SubmitsOnce { + Future _onSubmit() async { + await Http.post('/register', data: form.data); + } + + @override + Widget build(BuildContext context) { + return WButton( + isLoading: isSubmitting, + onTap: () => submitOnce(_onSubmit), + child: WText(trans('auth.register')), + ); + } +} +``` + +Feeding `isSubmitting` to the button's `isLoading` is what actually blocks the second tap: a well-behaved button computes `isInteractive = !isLoading && !disabled` and passes `null` for `onTap` when that is false, so the spinner and the guard are the same switch. A throwing submit re-arms the button rather than leaving it spinning forever, since the reset runs in a `finally`. + ## Responsive Views diff --git a/doc/digging-deeper/carbon.md b/doc/digging-deeper/carbon.md index f07c75e3..4cb7d324 100644 --- a/doc/digging-deeper/carbon.md +++ b/doc/digging-deeper/carbon.md @@ -8,6 +8,7 @@ Carbon is Magic's date and time utility, inspired by PHP's Carbon library, offer - [Manipulation](#manipulation) - [Comparison](#comparison) - [Human Readable](#human-readable) +- [Short Human Readable](#short-human-readable) - [Timezone Support](#timezone-support) - [Testing](#testing) - [Model Integration](#model-integration) @@ -189,6 +190,34 @@ date1.diffForHumans(date2); // "5 days before" date2.diffForHumans(date1); // "5 days after" ``` + +## Short Human Readable + +`shortDiffForHumans([other])` is a compact ladder (seconds, minutes, hours, days, weeks, months, years, each threshold exclusive of the next) for dense tables and list rows where `diffForHumans()` reads too wide. It measures against Carbon's clock (frozen while `Carbon.setTestNow` is set), or against `other` when given: + +```dart +createdAt.shortDiffForHumans(); // "14m ago" +dueAt.shortDiffForHumans(); // "5m from now" +``` + +A gap under one second reads `"Just now"`; a future instant reads `":time from now"` instead of `":time ago"`. Months are truncated 30-day buckets, so a 45-day gap reads `"1mo ago"`, not `"1 month, 15 days ago"`. + +Every unit and wrapper resolves through the `Lang` catalogue when present (`time.units_short.`, `time.ago`, `time.from_now`, `time.just_now`), falling back to the English literal (`:counts`, `:countm`, `:counth`, `:countd`, `:countw`, `:countmo`, `:county`, `:time ago`, `:time from now`, `Just now`) when no catalogue is loaded or the key is missing: + +```json +{ + "time": { + "units_short": { "minute": ":count dk" }, + "ago": ":time önce" + } +} +``` + +```dart +// With the catalogue above loaded for 'tr': +event.shortDiffForHumans(); // "14 dk önce" +``` + ## Timezone Support diff --git a/doc/digging-deeper/helpers.md b/doc/digging-deeper/helpers.md new file mode 100644 index 00000000..1797ddee --- /dev/null +++ b/doc/digging-deeper/helpers.md @@ -0,0 +1,120 @@ +# Helpers + +`Str`, `Number`, and `Arr` are static namespace helpers modelled on Laravel's Support layer, and `Cast` is magic's own, together covering locale-aware casing, locale-aware number formatting, dot-path map access, and defensive type reading for loosely-typed wire data. + +- [Str](#str) +- [Number](#number) +- [Arr](#arr) +- [Cast](#cast) +- [Composing Arr and Cast](#composing-arr-and-cast) + + +## Str + +`Str.upper` and `Str.lower` are locale-aware replacements for `String.toUpperCase()`/`toLowerCase()`, which get Turkish and Azerbaijani wrong: those alphabets distinguish a dotted `i` from a dotless `ı`, a pair Dart's own mapping conflates. Every method defaults its `locale` to `Lang.current.languageCode`, and accepts a full tag (`tr_TR`, `tr-TR`) as well as a bare language code. + +```dart +Str.upper('çalışan izleyiciler', locale: 'tr'); // 'ÇALIŞAN İZLEYİCİLER' +Str.upper('istanbul', locale: 'en'); // 'ISTANBUL' + +Str.lower('İSTANBUL', locale: 'tr'); // 'istanbul' +Str.lower('IŞIK', locale: 'tr'); // 'ışık' +``` + +`Str.initials(value, {limit, capitalize, locale})` takes the first letter of each whitespace-separated word. `limit` (greater than 0) keeps only the first N words; `capitalize` routes the result through `Str.upper` under `locale`. A blank or null `value` returns `''`. + +```dart +Str.initials('ismail kaya', limit: 2, capitalize: true, locale: 'tr'); // 'İK' +``` + + +## Number + +`Number` formats numeric values with a locale's own grouping, decimal, and currency conventions, resolving `locale` (or `Lang.current` when omitted) through `Intl.verifiedLocale`; an unknown locale falls back to `'en'` instead of throwing inside a `build` method. + +```dart +Number.format(1234567.891, maxPrecision: 3, locale: 'tr'); // '1.234.567,891' +Number.format(1234567.891, maxPrecision: 3, locale: 'en'); // '1,234,567.891' +``` + +`Number.currency(amount, {code, precision, locale})` is built on `NumberFormat.simpleCurrency`, not `NumberFormat.currency`: the latter renders the bare ISO code with no symbol (`'TRY1.234,50'`), while `simpleCurrency` resolves the locale's own symbol. + +```dart +Number.currency(1234.5, code: 'TRY', locale: 'tr'); // '₺1.234,50' +``` + +`Number.percentage(value, {precision, maxPrecision, locale})` takes a 0-100 input like Laravel's `Number::percentage()`, not intl's native 0-1 fraction, so a caller never has to remember to divide by 100 first. + +```dart +Number.percentage(99.95, precision: 2, locale: 'tr'); // '%99,95' +Number.percentage(99.95, precision: 2, locale: 'en'); // '99.95%' +``` + +`Number.fileSize(bytes, {precision, maxPrecision, locale})` steps by 1024 (B, KB, MB, GB, TB, PB) and formats the final number through `Number.format`. + +```dart +Number.fileSize(1536, maxPrecision: 1, locale: 'en'); // '1.5 KB' +``` + +`Number.abbreviate(value, {precision, maxPrecision, locale})` compacts a value with the locale's own unit letters (`Mn`, `B` for Turkish; `M`, `K` for English). + +```dart +Number.abbreviate(1500000, maxPrecision: 1, locale: 'tr'); // '1,5 Mn' +``` + + +## Arr + +`Arr` carries dot-path access into a nested `Map`, mirroring Laravel's `Arr::get` / `has` / `set` / `dot`. + +```dart +Arr.get({'a': {'b': [10, 20]}}, 'a.b.1'); // 20 +Arr.has({'a': {'b': 1}}, 'a.b'); // true + +final map = {}; +Arr.set(map, 'a.b.c', 1); // {'a': {'b': {'c': 1}}} + +Arr.dot({'a': {'b': 1}}); // {'a.b': 1} +``` + +An exact key wins over walking the path: a map that happens to hold a literal `'a.b'` key reads that value rather than descending into `map['a']['b']`. A numeric path segment indexes into a `List` at that position. `Arr.get` answers the given fallback (`null` by default) when the path is unreachable. + +**`Arr` carries no typed accessors.** There is no `Arr.getString`, `Arr.getInt`, or similar; typing a value read off a path is [Cast](#cast)'s job, composed at the call site. + + +## Cast + +`Cast` reads a loosely-typed wire value (a field out of a nested map or a pivot row that has not gone through the ORM's own coercion) as a specific Dart type, degrading to a fallback instead of throwing. Use it on a value pulled out of a decoded JSON map, not on a model attribute, since the ORM already coerces those through `get`. + +```dart +Cast.stringOr('hello', 'fallback'); // 'hello' +Cast.stringOr(42, 'fallback'); // 'fallback' +Cast.stringOrNull(42); // null + +Cast.intOr(3.9, 0); // 3 +Cast.intOr('3', 0); // 3, the one reader that parses a numeric string +Cast.intOr('abc', 0); // 0 + +Cast.intOrNull('3'); // null, unlike intOr +Cast.numOrNull(3.5); // 3.5 +Cast.doubleOrNull(3); // 3.0 + +Cast.boolOr('yes', false); // false, only a real bool passes +Cast.boolOrNull(false); // false + +Cast.idOrNull(42); // '42', a num stringifies rather than degrading +Cast.idOrNull('abc-123'); // 'abc-123' +Cast.idOrNull(true); // null +``` + +`Cast.intOr` is the one reader that parses a numeric string, because the fields it serves are orders and durations, and silently falling back on `"3"` would sort a list wrongly or shorten a delay. Every other reader (`numOrNull`, `doubleOrNull`, `intOrNull`) leaves a numeric string as unreadable, so the caller's own `?? fallback` decides what it means rather than a number appearing on a chart from a value the backend was not supposed to send. `Cast.idOrNull` stringifies a number rather than answering null, because a primary key can be a uuid or a bigint depending on backend configuration, and reading an int id as null would corrupt a save-diff that branches on a null id to mean "create this row". + + +## Composing Arr and Cast + +`Arr.get` performs no type check of its own; compose it with `Cast` at the call site for a typed read off a nested payload: + +```dart +final priority = Cast.intOr(Arr.get(payload, 'meta.priority'), 0); +final label = Cast.stringOrNull(Arr.get(payload, 'meta.label')); +``` diff --git a/doc/digging-deeper/validation.md b/doc/digging-deeper/validation.md index b146a8cd..c92bc9af 100644 --- a/doc/digging-deeper/validation.md +++ b/doc/digging-deeper/validation.md @@ -9,6 +9,7 @@ Magic provides a client-side validation system that integrates with Flutter form - [The Url Rule](#the-url-rule) - [Custom Messages](#custom-messages) - [Form Requests](#form-request) +- [Validating a Form Request Through a Controller](#validate-request) - [Server-Side Validation](#server-side-validation) - [Async Validation](#async-rules) - [Custom Rules](#custom-rules) @@ -287,6 +288,46 @@ try { Pairs cleanly with `Model.fill(payload, strict: true)` so mass-assignment catches schema drift at the boundary. + +## Validating a Form Request Through a Controller + +`FormRequest.validate()` filters its return value to the keys declared in `rules()` and never touches a controller's error bag, so calling it directly skips clearing stale errors before a resubmit, populating per-field errors on failure, and repainting the form. A controller with the `ValidatesRequests` mixin calls `validateRequest` instead, which runs the same authorize/prepare sequence but routes through the mixin's own error bag and returns the FULL prepared map, not the rule-filtered one, so a write can still send fields the backend needs but no rule constrains: + +```dart +class MonitorController extends MagicController with ValidatesRequests { + Future store(Map data) async { + try { + final payload = validateRequest(StoreMonitorRequest(Auth.user()!), data); + await Http.post('/monitors', data: payload); + } on AuthorizationException { + Magic.error('Error', 'You do not have permission to create monitors.'); + } on ValidationException catch (e) { + // validationErrors is already populated; UI has already rebuilt. + } + } +} +``` + +`validateRequest` throws `AuthorizationException` when `FormRequest.authorize()` returns `false`, before any field is touched, and runs only synchronous rules (an `AsyncRule` in the request's `rules()` is skipped). Use `validateRequestAsync` when a rule needs to `await`; it has the same contract, validated through `Validator.validateAsync()` so an `AsyncRule` actually runs: + +```dart +final payload = await validateRequestAsync(SlugUniqueRequest(), data); +``` + +### CollapsesIndexedErrorKeys + +A backend that validates a list field returns one wire key per element (`items.0.name`, `items.1.name`), but a form with a single error slot per field, not per element, has nowhere to put a per-index message. `ValidatesRequests.errorFieldFor` keeps a wire key as-is by default, which is what an existing controller already depends on; mix in `CollapsesIndexedErrorKeys` on top to collapse an indexed key down to its field name instead: + +```dart +class ItemsController extends MagicController + with ValidatesRequests, CollapsesIndexedErrorKeys {} + +// A 422 response carrying {"errors": {"items.0.name": ["The items.0.name field is required."]}} +// populates controller.validationErrors as {"name": "The items.0.name field is required."} +``` + +Two wire keys that collapse onto the same field (two failing elements of the same list) keep the FIRST message; the later one is dropped rather than overwriting it. A key addressing a distinct sub-key rather than a list element (`credentials.token`) is left whole, since a form with a separate error slot per sub-key needs each one kept. + ## Server-Side Validation diff --git a/doc/getting-started/configuration.md b/doc/getting-started/configuration.md index 149d6797..cc96e228 100644 --- a/doc/getting-started/configuration.md +++ b/doc/getting-started/configuration.md @@ -7,6 +7,7 @@ Magic's configuration system lets you load, read, write, and inspect application - [Environment Variable Types](#environment-variable-types) - [Determining the Current Environment](#determining-the-current-environment) - [Accessing Configuration Values](#accessing-configuration-values) +- [Guarding Against a Blank Value](#guarding-against-a-blank-value) - [Retrieving All Values](#retrieving-all-values) - [Array Configuration](#array-configuration) - [Removing Configuration](#removing-configuration) @@ -85,6 +86,8 @@ All variables in your `.env` files are parsed as strings. The `env()` helper aut | `empty` / `(empty)` | `''` (empty string) | | Numeric strings | Parsed as `int` or `double` | +`KEY=""` and `KEY=''` resolve to `''`, not to the `env()` default: an explicitly present but empty value is not the same as an absent key, matching Laravel's `Env::get`. A blank value reaching a browser tab title or a link as `Monitor | ""` or as a path with no origin is the failure mode this distinguishes; see [Guarding Against a Blank Value](#guarding-against-a-blank-value) for the helper that treats blank as absent instead. + ### Determining the Current Environment @@ -355,3 +358,21 @@ Magic.reload(); > [!TIP] > For production apps, consider using `Config.getOrFail()` for critical values to catch missing configuration early during startup. + + +## Guarding Against a Blank Value + +`Env.get`/`env()` only fall back to the given default when the key is entirely missing; a key that is present but blank resolves to `''`. `Env.filled(key, fallback)` treats an absent, blank, or quote-only value the same way, all resolving to `fallback`, and strips one wrapping pair of quotes plus surrounding whitespace from a present value (an inner apostrophe survives; an unbalanced quote is left alone): + +```dart +Env.filled('WEB_URL', 'https://app.example.com'); // falls back on '', '""', "''", or a missing key +Env.filled('APP_NAME', 'My App'); +``` + +Use it for any value that becomes a URL, a title, or anything else a blank string silently corrupts rather than visibly breaks. + +`Env.getOrFail(key)` mirrors Laravel's `Env::getOrFail`: it throws a `StateError` when `key` is absent entirely, and still returns `''` for a key that is present but empty, matching `Env.get`'s own Laravel-parity handling of an explicit empty value. + +```dart +final apiKey = Env.getOrFail('API_KEY'); // throws StateError when API_KEY is not set at all +``` diff --git a/doc/testing/getting-started.md b/doc/testing/getting-started.md index 4b5b36ae..ae1c43a6 100644 --- a/doc/testing/getting-started.md +++ b/doc/testing/getting-started.md @@ -51,8 +51,10 @@ void main() { `MagicTest.init()` handles: - `setUpAll`: `TestWidgetsFlutterBinding.ensureInitialized()` -- `setUp`: `MagicApp.reset()` + `Magic.flush()` -- `tearDown`: `Magic.flush()` +- `setUp`: `MagicApp.reset()` + `Magic.flush()` + `Gate.flush()` +- `tearDown`: `Magic.flush()` + `Gate.flush()` + `DateManager.reset()` + `Translator.reset()`, so a loaded catalogue or locale does not carry into the next test + +`Gate` is a process static that `Magic.flush()` does not clear on its own, so without the extra call an ability defined in one test leaks into every later test in the same file. For integration tests that need a full `Magic.init()` lifecycle, use `MagicTest.boot()` instead: @@ -85,6 +87,17 @@ Map get testDatabaseConfig => { }; ``` +### Loading a Translation Catalogue + +A widget test that renders `trans()` output needs a real catalogue loaded first; without one, `trans()` renders the raw dotted key. `MagicTest.loadTranslations(locale, {directory})` reads `/.json` off disk (default `assets/lang`, the app's own catalogue location), flattens it the same way the app's own loader does, and installs it before awaiting the translator's load: + +```dart +await MagicTest.loadTranslations('tr'); +expect(trans('auth.login'), 'Giriş yap'); +``` + +Point `directory` at a temp directory with a small fixture json to test one key in isolation without touching the app's real catalogue. Loading never requires a fallback-locale file on disk: the translator only warns and falls back to an empty map when the fallback catalogue fails to load. + ## Writing Tests diff --git a/example/test/support_helpers_test.dart b/example/test/support_helpers_test.dart new file mode 100644 index 00000000..a3ee9829 --- /dev/null +++ b/example/test/support_helpers_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; + +/// Usage of magic's Support helpers as an app writes them. +/// +/// Runnable with `flutter test` from `example/`; CI runs only the package's +/// own suite, so this file is checked by hand when an API it calls changes. +void main() { + test('Number and Str format for the reader\'s locale', () { + expect( + Number.format(1234567.891, maxPrecision: 3, locale: 'tr'), + '1.234.567,891', + ); + expect(Number.percentage(99.95, precision: 2, locale: 'en'), '99.95%'); + expect(Str.upper('istanbul', locale: 'tr'), 'İSTANBUL'); + expect(Str.initials('ada lovelace', capitalize: true), 'AL'); + }); + + test('Arr reads a path and Cast types what it found', () { + final Map payload = { + 'meta': {'priority': '3', 'label': 42}, + }; + + expect(Cast.intOr(Arr.get(payload, 'meta.priority'), 0), 3); + expect(Cast.stringOrNull(Arr.get(payload, 'meta.label')), isNull); + }); + + test('shortDiffForHumans reads against Carbon\'s clock', () { + final Carbon now = Carbon.parse('2026-01-01 12:00:00'); + Carbon.setTestNow(now); + addTearDown(Carbon.setTestNow); + + expect(now.subMinutes(14).shortDiffForHumans(), '14m ago'); + }); + + testWidgets('SubmitsOnce drops a second tap while the first is in flight', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: _SaveButton())); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + await tester.tap(find.byType(ElevatedButton)); + await tester.pumpAndSettle(); + + expect(_SaveButtonState.saves, 1); + }); +} + +class _SaveButton extends StatefulWidget { + const _SaveButton(); + + @override + State<_SaveButton> createState() => _SaveButtonState(); +} + +class _SaveButtonState extends State<_SaveButton> + with SubmitsOnce<_SaveButton> { + static int saves = 0; + + Future _save() async { + saves++; + await Future.delayed(const Duration(milliseconds: 50)); + } + + @override + Widget build(BuildContext context) { + return ElevatedButton( + onPressed: isSubmitting ? null : () => submitOnce(_save), + child: const Text('Save'), + ); + } +} diff --git a/lib/magic.dart b/lib/magic.dart index b0d130cc..d1b4987a 100644 --- a/lib/magic.dart +++ b/lib/magic.dart @@ -50,6 +50,10 @@ export 'src/support/carbon.dart'; export 'src/support/carbon_extension.dart'; export 'src/support/date_manager.dart'; export 'src/helpers/date_helpers.dart'; +export 'src/support/number.dart'; +export 'src/support/str.dart'; +export 'src/support/cast.dart'; +export 'src/support/arr.dart'; // Routing export 'src/routing/magic_platform_page.dart'; @@ -93,6 +97,8 @@ export 'src/ui/magic_selector.dart'; export 'src/ui/magic_feedback.dart'; export 'src/ui/magic_view_registry.dart'; export 'src/ui/magic_view.dart'; +export 'src/ui/refetches_on_mount.dart'; +export 'src/ui/submits_once.dart'; export 'src/ui/magic_paginated_list_view.dart'; export 'src/ui/magic_form.dart'; export 'src/ui/magic_form_data.dart'; diff --git a/lib/src/concerns/validates_requests.dart b/lib/src/concerns/validates_requests.dart index 3c9bd98c..61d45bfe 100644 --- a/lib/src/concerns/validates_requests.dart +++ b/lib/src/concerns/validates_requests.dart @@ -1,7 +1,9 @@ import '../http/magic_controller.dart'; import '../network/magic_response.dart'; import '../validation/contracts/rule.dart'; +import '../validation/exceptions/authorization_exception.dart'; import '../validation/exceptions/validation_exception.dart'; +import '../validation/form_request.dart'; import '../validation/validator.dart'; /// Interface for checking and clearing validation errors. @@ -131,6 +133,78 @@ mixin ValidatesRequests on MagicController implements HasValidationErrors { } } + /// Run a [FormRequest] through this mixin's error bag instead of + /// `FormRequest.validate()`. + /// + /// `FormRequest.validate()` filters its return value to the keys declared + /// in [FormRequest.rules] and never touches [validationErrors], so a + /// controller calling it directly would neither clear stale errors before + /// a resubmit, populate per-field errors on failure, nor repaint the form. + /// [validateRequest] runs the same authorize/prepare sequence but hands the + /// prepared payload to this mixin's own [validate], which clears, + /// populates and repaints exactly as a controller-authored rule map would, + /// and then returns the FULL prepared map (not the rule-filtered one), so a + /// write path can still send along fields the backend needs but no rule + /// constrains. + /// + /// Throws [AuthorizationException] when [FormRequest.authorize] returns + /// `false`, before any field is touched. Otherwise a `ValidationException` + /// propagates after populating [validationErrors]. + /// + /// Runs only synchronous [Rule]s: any [AsyncRule] in [request]'s rules is + /// skipped (see [Validator.validate]). Use [validateRequestAsync] when a + /// rule needs to await. + Map validateRequest( + FormRequest request, + Map data, + ) { + if (!request.authorize()) { + throw const AuthorizationException(); + } + + final prepared = request.prepared(data); + validate(prepared, request.rules()); + + return prepared; + } + + /// Same contract as [validateRequest], but validates through + /// [Validator.validateAsync] so any [AsyncRule] in [request]'s rules + /// actually runs. + /// + /// Throws [AuthorizationException] when [FormRequest.authorize] returns + /// `false`. Otherwise clears [validationErrors], validates + /// [FormRequest.prepared]'s output against [FormRequest.rules], and either + /// returns the full prepared map or populates [validationErrors] and + /// rethrows `ValidationException`, mirroring [validate]'s own try/catch. + Future> validateRequestAsync( + FormRequest request, + Map data, + ) async { + if (!request.authorize()) { + throw const AuthorizationException(); + } + + final prepared = request.prepared(data); + + // Clear previous errors and notify UI + validationErrors = {}; + refreshUI(); + + final validator = Validator.make(prepared, request.rules()); + + try { + await validator.validateAsync(); + } on ValidationException catch (e) { + // Populate errors and notify UI + validationErrors = Map.from(e.errors); + refreshUI(); + rethrow; + } + + return prepared; + } + /// Set validation errors from an API response. /// /// Use this to show server-side validation errors (422 responses) @@ -150,11 +224,33 @@ mixin ValidatesRequests on MagicController implements HasValidationErrors { /// WText(controller.getError('email')!, className: 'text-red-500'), /// ``` void setErrorsFromResponse(MagicResponse response) { + setErrorsFromMap(response.errors); + } + + /// Map a raw wire validation key to the form field it should report on. + /// + /// Identity by default, so [setErrorsFromMap] and [setErrorsFromResponse] + /// keep raw dot-notation keys (e.g. `items.0.name`) as-is; changing that + /// default would change behaviour for every existing app that mixes in + /// [ValidatesRequests]. Mix in [CollapsesIndexedErrorKeys] on top when a + /// form cannot address a specific list element and needs a numeric index + /// collapsed onto its parent field instead. + String errorFieldFor(String wireKey) => wireKey; + + /// Set validation errors from a raw wire error map, keyed by [errorFieldFor]. + /// + /// Clears any previous errors, then for each entry maps its key through + /// [errorFieldFor] and keeps the FIRST message reported for that field: two + /// wire keys that collapse onto the same field (e.g. two failing elements + /// of the same list) do not overwrite each other. An entry with an empty + /// message list is skipped. Always calls [refreshUI], including when + /// [errors] is empty, so the UI repaints and clears stale errors. + void setErrorsFromMap(Map> errors) { validationErrors = {}; - for (final entry in response.errors.entries) { - if (entry.value.isNotEmpty) { - validationErrors[entry.key] = entry.value.first; - } + for (final entry in errors.entries) { + if (entry.value.isEmpty) continue; + final field = errorFieldFor(entry.key); + validationErrors.putIfAbsent(field, () => entry.value.first); } refreshUI(); } @@ -282,3 +378,56 @@ mixin ValidatesRequests on MagicController implements HasValidationErrors { } } } + +/// Opt-in [ValidatesRequests.errorFieldFor] override that collapses an +/// indexed wire validation key onto the form field it should report on. +/// +/// A backend that validates a list field (`items.0.name`, `items.1.name`) +/// returns one wire key per element, but a form with a single error slot per +/// field (not per element) has nowhere to put a per-index message. Mix this +/// in on top of [ValidatesRequests] to collapse such keys down to their +/// field name (`items.0.name` -> `name`). It is opt-in rather than the +/// default because [ValidatesRequests.errorFieldFor] keeping raw keys is a +/// behaviour existing controllers already depend on. +mixin CollapsesIndexedErrorKeys on ValidatesRequests { + @override + String errorFieldFor(String wireKey) => _collapseIndexedKey(wireKey); +} + +/// Collapses a wire validation key onto the form field it should report on. +/// +/// 1. Drop a trailing element index (`ok_values.0` -> `ok_values`); a single +/// remaining segment is already the answer. +/// 2. What is left addresses a distinct SUB-KEY, not a list element, when its +/// second segment is not itself numeric: `credentials.token` stays whole +/// because a form with a separate error slot per sub-key +/// (`credentials.url`, `credentials.secret`, ...) needs each one kept. +/// 3. A second segment that IS numeric marks a collection wrapper +/// (`items.....`), so the key collapses onto its last +/// remaining segment, the one field the form actually renders, rather +/// than the wrapper name a plain `.split('.').first` would give. +String _collapseIndexedKey(String key) { + final segments = key.split('.'); + + int end = segments.length; + while (end > 1 && _isNumericSegment(segments[end - 1])) { + end--; + } + final trimmed = segments.sublist(0, end); + + if (trimmed.length == 1) { + return trimmed.first; + } + + if (_isNumericSegment(trimmed[1])) { + return trimmed.last; + } + + return trimmed.join('.'); +} + +/// Whether [segment] is a non-empty run of ASCII digits, i.e. a list index +/// rather than a field name. +bool _isNumericSegment(String segment) { + return segment.isNotEmpty && int.tryParse(segment) != null; +} diff --git a/lib/src/foundation/env.dart b/lib/src/foundation/env.dart index 84de1add..6dd6831a 100644 --- a/lib/src/foundation/env.dart +++ b/lib/src/foundation/env.dart @@ -131,6 +131,16 @@ class Env { return defaultValue as T; } + // Laravel parity: `flutter_dotenv` leaves `KEY=""` and `KEY=''` as the + // literal two-character string (its parser needs one char inside the + // quotes to strip them). Laravel's `Env::get` returns '' for both + // (`laravel-framework/src/Illuminate/Support/Env.php:271`); treat the + // value as an explicit empty string here too, never as "absent". + final String trimmedValue = value.trim(); + if (trimmedValue == '""' || trimmedValue == "''") { + value = ''; + } + // Type casting based on T if (T == bool) { return (['true', '1', 'yes'].contains(value.toLowerCase())) as T; @@ -186,6 +196,56 @@ class Env { } } + /// Get a STRING env value, treating a blank or quote-only value as absent. + /// + /// `Env.get`/`env()` only fall back to [fallback] when [key] is entirely + /// missing; a key that is present but empty resolves to `''`. In a deployed + /// app that shows up as a blank `APP_NAME` rendering `Monitor | ""` in a + /// browser tab, or a blank `WEB_URL` pointing a link at a path with no + /// origin, so this is the guard for values a blank silently corrupts: + /// absent, blank, or a + /// quote-only value all resolve to [fallback], and a present value has one + /// wrapping pair of quotes and surrounding whitespace stripped (an inner + /// apostrophe survives; an unbalanced quote is left alone). + static String filled(String key, String fallback) { + final String value = _unwrapQuotes(get(key)); + + return value.isEmpty ? _unwrapQuotes(fallback) : value; + } + + /// Get a required env value or throw. + /// + /// Mirrors Laravel's `Env::getOrFail`. Throws [StateError] when [key] is + /// absent entirely; a key present but empty resolves to `''`, matching + /// [get]'s Laravel-parity handling of an explicit empty value. + static String getOrFail(String key) { + if (!has(key)) { + throw StateError('Environment variable [$key] has no value.'); + } + + return get(key, ''); + } + + /// Strips one WRAPPING pair of quotes and surrounding whitespace from a raw + /// `.env` value. + /// + /// Only the boundary pair goes, never every quote in the string: a value + /// may legitimately carry an apostrophe (`K="Anıl's Monitor"`), and + /// stripping every quote would silently rewrite it. An unbalanced quote is + /// left alone, so a malformed line stays visibly malformed. + static String _unwrapQuotes(String? value) { + if (value == null) return ''; + + final String trimmed = value.trim(); + if (trimmed.length < 2) return trimmed; + + final String first = trimmed[0]; + final bool wrapped = + (first == '"' || first == "'") && trimmed.endsWith(first); + + return wrapped ? trimmed.substring(1, trimmed.length - 1).trim() : trimmed; + } + /// Reset the Env state (for testing). static void reset() { _isLoaded = false; diff --git a/lib/src/localization/loaders/json_asset_loader.dart b/lib/src/localization/loaders/json_asset_loader.dart index ad592a1c..3afca7d3 100644 --- a/lib/src/localization/loaders/json_asset_loader.dart +++ b/lib/src/localization/loaders/json_asset_loader.dart @@ -68,13 +68,13 @@ class JsonAssetLoader implements TranslationLoader { Future> load(Locale locale) async { try { final json = await _loadJson(locale.languageCode); - return _flatten(json); + return flatten(json); } catch (e) { // Try fallback locale if (locale.languageCode != fallbackLocale) { try { final json = await _loadJson(fallbackLocale); - return _flatten(json); + return flatten(json); } catch (fallbackError) { if (Magic.bound('log')) { Log.warning( @@ -137,11 +137,16 @@ class JsonAssetLoader implements TranslationLoader { /// Flatten nested JSON keys for O(1) lookup. /// + /// Public so a caller building its own [TranslationLoader] (see + /// `MagicTest.loadTranslations`) can flatten with the exact same rule the + /// translator's own loader uses, rather than growing a second copy that + /// silently drifts from this one. + /// /// Example: /// ```dart - /// {'auth': {'failed': 'Error'}} -> {'auth.failed': 'Error'} + /// JsonAssetLoader.flatten({'auth': {'failed': 'Error'}}); // {'auth.failed': 'Error'} /// ``` - Map _flatten( + static Map flatten( Map json, [ String prefix = '', ]) { @@ -151,7 +156,7 @@ class JsonAssetLoader implements TranslationLoader { final key = prefix.isEmpty ? entry.key : '$prefix.${entry.key}'; if (entry.value is Map) { - result.addAll(_flatten(entry.value as Map, key)); + result.addAll(flatten(entry.value as Map, key)); } else { result[key] = entry.value; } diff --git a/lib/src/support/arr.dart b/lib/src/support/arr.dart new file mode 100644 index 00000000..f23f7724 --- /dev/null +++ b/lib/src/support/arr.dart @@ -0,0 +1,125 @@ +import 'cast.dart'; + +/// Static namespace for dot-path access into a nested `Map`, +/// mirroring Laravel's `Arr::get` / `has` / `set` / `dot`. +/// +/// This carries only path operations, deliberately: there is no typed reader +/// here (an `intOr`, a `stringOrNull`). Typing a value read off a path is +/// [Cast]'s job, composed at the call site: `Cast.intOr(Arr.get(m, 'a.b'), 0)`. +/// `Arr.get` performs no type check of its own; it returns exactly what the +/// wire held at that path. +abstract final class Arr { + const Arr._(); + + /// Reads the value at the dotted [path] in [map], answering [fallback] + /// when the path cannot be reached. + /// + /// A null [path] answers [map] itself. An exact key equal to [path] wins + /// over walking the path, so a map that happens to hold a literal + /// `'a.b'` key reads that value rather than descending into `map['a']['b']`. + /// A numeric path segment indexes into a `List` at that position. + /// + /// This performs no type check: the caller composes with [Cast] for a + /// typed read, e.g. `Cast.intOr(Arr.get(m, 'a.b'), 0)`. + static Object? get( + Map? map, + String? path, [ + Object? fallback, + ]) { + if (map == null) return fallback; + if (path == null) return map; + if (map.containsKey(path)) return map[path]; + + Object? current = map; + for (final String segment in path.split('.')) { + final Object? next = _step(current, segment); + if (next == _notFound) return fallback; + current = next; + } + return current; + } + + /// True when the dotted [path] can be reached in [map], even when the + /// value found there is null. + static bool has(Map? map, String path) { + if (map == null || map.isEmpty) return false; + if (map.containsKey(path)) return true; + + Object? current = map; + for (final String segment in path.split('.')) { + final Object? next = _step(current, segment); + if (next == _notFound) return false; + current = next; + } + return true; + } + + /// Writes [value] at the dotted [path] in [map], creating an intermediate + /// `Map` for every missing (or non-map) segment along the + /// way. + static void set(Map map, String path, Object? value) { + final List segments = path.split('.'); + Map current = map; + + for (int i = 0; i < segments.length - 1; i++) { + final String segment = segments[i]; + final Object? existing = current[segment]; + if (existing is Map) { + current = existing; + } else { + final Map created = {}; + current[segment] = created; + current = created; + } + } + + current[segments.last] = value; + } + + /// Flattens a nested [map] into a single-level map whose keys are the + /// dotted paths to each leaf, prefixed with [prepend]. + /// + /// An empty nested map is itself kept as a leaf rather than dropped, so + /// flattening and re-nesting a map that happens to hold `{}` somewhere + /// does not silently lose that key. + static Map dot( + Map map, { + String prepend = '', + }) { + final Map result = {}; + + for (final MapEntry entry in map.entries) { + final String key = '$prepend${entry.key}'; + final Object? value = entry.value; + + if (value is Map && value.isNotEmpty) { + result.addAll(dot(value, prepend: '$key.')); + } else { + result[key] = value; + } + } + + return result; + } + + /// Sentinel distinguishing "the segment was not reachable" from a + /// genuinely-present null value while walking a path. + static const Object _notFound = Object(); + + /// Steps one dotted [segment] into [current], which is either a + /// `Map` or a `List`. Answers [_notFound] when [segment] + /// cannot be resolved against [current]'s shape. + static Object? _step(Object? current, String segment) { + if (current is Map) { + return current.containsKey(segment) ? current[segment] : _notFound; + } + if (current is List) { + final int? index = int.tryParse(segment); + if (index == null || index < 0 || index >= current.length) { + return _notFound; + } + return current[index]; + } + return _notFound; + } +} diff --git a/lib/src/support/carbon.dart b/lib/src/support/carbon.dart index 1a7ca52f..cbef82c6 100644 --- a/lib/src/support/carbon.dart +++ b/lib/src/support/carbon.dart @@ -1,6 +1,7 @@ import 'package:jiffy/jiffy.dart'; import 'package:timezone/timezone.dart' as tz; +import '../facades/lang.dart'; import 'date_manager.dart'; /// The Carbon Class - Laravel-Style Date Wrapper. @@ -447,6 +448,114 @@ class Carbon implements Comparable { return _engine.from(_clock()); } + /// Get a compact, localized human-readable difference (e.g. `"14m ago"`, + /// `"2h ago"`, `"5m from now"`). + /// + /// Measures against [other] when given, or Carbon's own clock (honouring + /// [setTestNow]) otherwise. Unlike [diffForHumans], the wording comes from + /// the `time` translation group (`time.units_short.*`, `time.ago`, + /// `time.from_now`, `time.just_now`): a catalogue entry wins when present, + /// and the English literal shipped in `lang_en.stub` is the fallback when + /// it is not (`Translator.has`, `localization/translator.dart:298`). + /// + /// The magnitude ladder truncates, never rounds: a 45-day gap reads + /// `"1mo ago"` because 45 days is one whole 30-day month, not two. + /// + /// ```dart + /// createdAt.shortDiffForHumans(); // "14m ago" + /// dueAt.shortDiffForHumans(); // "5m from now" + /// ``` + String shortDiffForHumans([Carbon? other]) { + final DateTime reference = other?.toDateTime ?? _clock().dateTime; + final Duration elapsed = reference.difference(toDateTime); + final int totalSeconds = elapsed.inSeconds.abs(); + + if (totalSeconds < 1) { + return _shortLocalized('time.just_now', 'Just now', const {}); + } + + final String unitText = _shortUnitText(totalSeconds); + final bool isFuture = elapsed.isNegative; + final String wrapperKey = isFuture ? 'time.from_now' : 'time.ago'; + final String wrapperFallback = isFuture ? ':time from now' : ':time ago'; + + return _shortLocalized(wrapperKey, wrapperFallback, {'time': unitText}); + } + + /// The `:count`-substituted unit text for [shortDiffForHumans]'s ladder, + /// stepping second -> minute -> hour -> day -> week -> month -> year on + /// the absolute [totalSeconds], each threshold exclusive of the next. + String _shortUnitText(int totalSeconds) { + const int minute = 60; + const int hour = 60 * minute; + const int day = 24 * hour; + const int week = 7 * day; + const int month = 30 * day; + const int year = 365 * day; + + final String unit; + final int count; + final String fallback; + + if (totalSeconds < minute) { + unit = 'second'; + count = totalSeconds; + fallback = ':counts'; + } else if (totalSeconds < hour) { + unit = 'minute'; + count = totalSeconds ~/ minute; + fallback = ':countm'; + } else if (totalSeconds < day) { + unit = 'hour'; + count = totalSeconds ~/ hour; + fallback = ':counth'; + } else if (totalSeconds < week) { + unit = 'day'; + count = totalSeconds ~/ day; + fallback = ':countd'; + } else if (totalSeconds < month) { + unit = 'week'; + count = totalSeconds ~/ week; + fallback = ':countw'; + } else if (totalSeconds < year && totalSeconds ~/ month < 12) { + unit = 'month'; + count = totalSeconds ~/ month; + fallback = ':countmo'; + } else { + unit = 'year'; + // 360-364 days are twelve 30-day months but under a 365-day year; they + // read as one year rather than "12mo" or "0y". + count = totalSeconds < year ? 1 : totalSeconds ~/ year; + fallback = ':county'; + } + + return _shortLocalized('time.units_short.$unit', fallback, { + 'count': '$count', + }); + } + + /// Resolves [key] through the catalogue when present, or [fallback] + /// otherwise, then applies [replace] the same way [Translator.get] does + /// (`:name` substitution, no word boundary). + /// + /// Reads the raw template with `Lang.has`/`trans` rather than trusting + /// `trans(key)`'s own key-echo, since a translated sentence that happened + /// to equal its own key would otherwise be misread as missing. + String _shortLocalized( + String key, + String fallback, + Map replace, + ) { + final String template = Lang.has(key) ? trans(key) : fallback; + + var result = template; + for (final entry in replace.entries) { + result = result.replaceAll(':${entry.key}', entry.value); + } + + return result; + } + /// Get difference in days. int diffInDays(Carbon other) { return _engine.diff(other._engine, unit: Unit.day).toInt(); diff --git a/lib/src/support/cast.dart b/lib/src/support/cast.dart new file mode 100644 index 00000000..2dc86fb9 --- /dev/null +++ b/lib/src/support/cast.dart @@ -0,0 +1,80 @@ +// Type readers for a value read out of a NESTED wire map. +// +// A model's own attributes go through the ORM's `get`, which coerces. A +// field read out of a sub-object or a pivot row does not, and `m['k'] as +// String?` is a hard cast: it throws when the backend sends another type. +// Every call site these serve sits in a getter a widget build reads, so one +// wrong-typed field takes a whole screen down instead of blanking one line. + +/// Static namespace for reading a loosely-typed wire value as a specific +/// Dart type, degrading to a fallback instead of throwing. +/// +/// Every reader here is total: it never throws, and it never assumes the +/// value already has the type it asks for. Use it on a value pulled out of a +/// decoded JSON map, not on a model attribute (the ORM already coerces those). +abstract final class Cast { + const Cast._(); + + /// Reads [value] as a String, answering [fallback] when it is anything + /// else. + static String stringOr(Object? value, String fallback) => + value is String ? value : fallback; + + /// Reads [value] as a String, answering null when it is anything else. + static String? stringOrNull(Object? value) => value is String ? value : null; + + /// Reads [value] as an int, answering [fallback] when it cannot be one. + /// + /// A numeric string parses rather than degrading, because the fields this + /// serves are orders and durations: silently reading `"3"` as [fallback] + /// would sort a list wrongly or shorten a delay, which is a quieter failure + /// than the cast it replaces. + static int intOr(Object? value, int fallback) { + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value) ?? fallback; + return fallback; + } + + /// Reads [value] as a number, answering null when it is not one. + /// + /// A numeric string does NOT parse here, unlike [intOr]. The fields this + /// serves are measurements and money, where the caller's own `?? fallback` + /// decides what an unreadable value means, and quietly inventing a number + /// from a string the backend was not supposed to send would put a made-up + /// figure on a chart. + static num? numOrNull(Object? value) => value is num ? value : null; + + /// Reads [value] as an int, answering null when it is not a number. + static int? intOrNull(Object? value) => value is num ? value.toInt() : null; + + /// Reads [value] as a double, answering null when it is not a number. + /// + /// Checks `is num`, never `is double`: a JSON `3` decodes as a double on + /// web and an int on the VM, so gating on `is double` would silently drop + /// the whole-number case on one platform and not the other. + static double? doubleOrNull(Object? value) => + value is num ? value.toDouble() : null; + + /// Reads [value] as a bool, answering [fallback] when it is anything else. + static bool boolOr(Object? value, bool fallback) => + value is bool ? value : fallback; + + /// Reads [value] as a bool, answering null when it is anything else. + static bool? boolOrNull(Object? value) => value is bool ? value : null; + + /// Reads [value] as a record id, answering null when it is neither a + /// string nor a number. + /// + /// A number STRINGIFIES rather than degrading to null, and that is the + /// whole point of having this separate from [stringOrNull]. A primary key + /// can be a uuid or a bigint depending on backend configuration, so an int + /// id is a configuration away rather than a malformed payload. Reading one + /// as null would keep the screen up and then corrupt the save: an editor's + /// save-diff branches on a null id to mean "create this row", so an + /// existing row would come back duplicated. + static String? idOrNull(Object? value) { + if (value is String) return value; + if (value is num) return value.toString(); + return null; + } +} diff --git a/lib/src/support/number.dart b/lib/src/support/number.dart new file mode 100644 index 00000000..8a2ca976 --- /dev/null +++ b/lib/src/support/number.dart @@ -0,0 +1,192 @@ +import 'package:intl/intl.dart'; + +import '../facades/lang.dart'; + +/// Locale-aware number formatting, modelled on Laravel's `Number` helper +/// (`Illuminate\Support\Number`). +/// +/// Every method resolves its locale ONCE per call: [locale] when given, +/// otherwise `Lang.current` (a [Locale], read as `Lang.current.toString()`). +/// The resolved tag is verified against intl's own locale table through +/// `Intl.verifiedLocale`, so a locale intl does not ship (`'zz'`, which +/// `NumberFormat` would otherwise throw on) silently falls back to `'en'` +/// instead of crashing inside a `build` method. +abstract final class Number { + Number._(); + + /// Resolves [locale] (or `Lang.current`) to a tag intl recognises, + /// falling back to `'en'` for anything intl has no data for. + static String _resolveLocale(String? locale) { + final String requested = locale ?? Lang.current.toString(); + return Intl.verifiedLocale( + requested, + NumberFormat.localeExists, + onFailure: (_) => 'en', + ) ?? + 'en'; + } + + /// Formats [value] with the locale's grouping and decimal marks. + /// + /// [precision] fixes the fraction digits exactly; [maxPrecision] caps them + /// while allowing fewer; passing neither uses intl's own default. Passing + /// `grouped: false` drops the thousands separator entirely (no grouping + /// character is introduced), which is the mode a caller doing its own + /// magnitude handling (an abbreviated metric, for example) wants. + /// + /// ```dart + /// Number.format(1234567.891, maxPrecision: 3, locale: 'tr'); // '1.234.567,891' + /// Number.format(1234567.891, maxPrecision: 3, locale: 'en'); // '1,234,567.891' + /// ``` + static String format( + num value, { + int? precision, + int? maxPrecision, + bool grouped = true, + String? locale, + }) { + final NumberFormat formatter = NumberFormat.decimalPattern( + _resolveLocale(locale), + ); + _applyPrecision( + formatter, + precision: precision, + maxPrecision: maxPrecision, + ); + if (!grouped) { + formatter.turnOffGrouping(); + } + return formatter.format(value); + } + + /// Formats [amount] as currency in [code], built on + /// `NumberFormat.simpleCurrency` rather than `NumberFormat.currency`: the + /// latter renders the bare ISO code with no symbol and no space + /// (`'TRY1.234,50'`), while `simpleCurrency` resolves the locale's own + /// symbol (`'₺1.234,50'`). The symbol comes from intl's CLDR data, so it + /// follows the intl version the app resolves: intl 0.20.2 renders the lira + /// as `TL`, 0.20.3 and later as `₺`. + /// + /// ```dart + /// Number.currency(1234.5, code: 'TRY', locale: 'tr'); // '₺1.234,50' + /// Number.currency(1234.5, code: 'USD', locale: 'en'); // '$1,234.50' + /// ``` + static String currency( + num amount, { + String code = 'USD', + int? precision, + String? locale, + }) { + final NumberFormat formatter = NumberFormat.simpleCurrency( + locale: _resolveLocale(locale), + name: code, + ); + if (precision != null) { + formatter.minimumFractionDigits = precision; + formatter.maximumFractionDigits = precision; + } + return formatter.format(amount); + } + + /// Formats [value] as a percentage, taking a 0-100 input like Laravel's + /// `Number::percentage()` rather than intl's native 0-1 fraction: the + /// division happens internally so a caller never has to remember to divide + /// by 100 first, and never gets `%100` for a 99.95% reading. + /// + /// [precision] fixes the fraction digits; [maxPrecision] caps them while + /// allowing fewer. + /// + /// ```dart + /// Number.percentage(99.95, precision: 2, locale: 'tr'); // '%99,95' + /// Number.percentage(99.95, precision: 2, locale: 'en'); // '99.95%' + /// ``` + static String percentage( + num value, { + int precision = 0, + int? maxPrecision, + String? locale, + }) { + final NumberFormat formatter = NumberFormat.percentPattern( + _resolveLocale(locale), + ); + _applyPrecision( + formatter, + precision: precision, + maxPrecision: maxPrecision, + ); + return formatter.format(value / 100); + } + + /// Formats [bytes] as a human-readable file size (B, KB, MB, GB, TB, PB), + /// stepping by 1024 like Laravel's `Number::fileSize()`. The final number + /// still goes through [format], so the decimal mark is locale-correct. + /// + /// ```dart + /// Number.fileSize(1536, maxPrecision: 1, locale: 'en'); // '1.5 KB' + /// ``` + static String fileSize( + num bytes, { + int precision = 0, + int? maxPrecision, + String? locale, + }) { + const List units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + + num size = bytes; + int unitIndex = 0; + while (size.abs() / 1024 > 0.9 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + final String formatted = format( + size, + precision: precision, + maxPrecision: maxPrecision, + locale: locale, + ); + return '$formatted ${units[unitIndex]}'; + } + + /// Abbreviates [value] with the locale's compact unit letters (`Mn`, `B` + /// for Turkish; `M`, `K` for English), built on `NumberFormat.compact`. + /// + /// ```dart + /// Number.abbreviate(1500000, maxPrecision: 1, locale: 'tr'); // '1,5 Mn' + /// Number.abbreviate(1500000, maxPrecision: 1, locale: 'en'); // '1.5M' + /// ``` + static String abbreviate( + num value, { + int precision = 0, + int? maxPrecision, + String? locale, + }) { + final NumberFormat formatter = NumberFormat.compact( + locale: _resolveLocale(locale), + ); + _applyPrecision( + formatter, + precision: precision, + maxPrecision: maxPrecision, + ); + return formatter.format(value); + } + + /// Sets [formatter]'s fraction digits from [precision]/[maxPrecision] + /// following the same rule across every method above: [precision] fixes + /// minimum and maximum to the same value, [maxPrecision] alone leaves the + /// minimum at 0 and caps the maximum, and neither leaves intl's default. + static void _applyPrecision( + NumberFormat formatter, { + int? precision, + int? maxPrecision, + }) { + if (maxPrecision != null) { + formatter.minimumFractionDigits = 0; + formatter.maximumFractionDigits = maxPrecision; + } else if (precision != null) { + formatter.minimumFractionDigits = precision; + formatter.maximumFractionDigits = precision; + } + } +} diff --git a/lib/src/support/str.dart b/lib/src/support/str.dart new file mode 100644 index 00000000..cc8e24c2 --- /dev/null +++ b/lib/src/support/str.dart @@ -0,0 +1,81 @@ +import '../facades/lang.dart'; + +/// Locale-aware string casing and initials, Laravel's `Str` helper ported to +/// the subset magic needs. +/// +/// `String.toUpperCase()`/`toLowerCase()` are locale-independent and get +/// Turkish and Azerbaijani wrong: their alphabet distinguishes a dotted `i` +/// from a dotless `ı`, a pair Dart's mapping conflates. Every method here +/// defaults its locale to [Lang.current.languageCode]. +abstract final class Str { + /// Language codes whose alphabet distinguishes a dotted from a dotless `i`. + static const Set _dottedILanguages = {'tr', 'az'}; + + /// Uppercases [value] under [locale] (default: [Lang.current]). + /// + /// In `tr`/`az`, `i` is mapped to `İ` and `ı` to `I` before the general + /// uppercase call; every other letter (`ş`, `ğ`, `ö`, `ü`, `ç`) casts + /// correctly on its own. + static String upper(String value, {String? locale}) { + if (!_usesDottedI(locale)) return value.toUpperCase(); + + return value.replaceAll('i', 'İ').replaceAll('ı', 'I').toUpperCase(); + } + + /// Lowercases [value] under [locale] (default: [Lang.current]). + /// + /// `İ` is mapped to a plain `i` first in EVERY locale, not only `tr`/`az`: + /// on the web the JS engine lowercases `İ` to `i` plus a combining dot + /// (U+0307), which is invisible but changes the string's length. In + /// `tr`/`az`, `I` is additionally mapped to `ı` before the general + /// lowercase call, and that swap has to run AFTER the `İ` swap and BEFORE + /// `toLowerCase()`: reversing either order turns `İ` into `ı` instead of + /// `i`, or leaves `I` indistinguishable from an `i` that never needed + /// correcting. + static String lower(String value, {String? locale}) { + String prepared = value.replaceAll('İ', 'i'); + if (_usesDottedI(locale)) { + prepared = prepared.replaceAll('I', 'ı'); + } + + return prepared.toLowerCase(); + } + + /// Whether [locale] (a language code or a full tag such as `tr_TR` or + /// `tr-TR`; default [Lang.current]) belongs to a dotted-i language. + static bool _usesDottedI(String? locale) { + final String tag = locale ?? Lang.current.languageCode; + + return _dottedILanguages.contains(tag.split(RegExp('[_-]')).first); + } + + /// The first letter of each whitespace-separated word in [value]. + /// + /// Mirrors Laravel's `Str::initials($value, $capitalize)`: [limit] (when + /// greater than 0, an addition Laravel has no equivalent for) keeps only + /// the first [limit] words, and [capitalize] uppercases the result through + /// [upper] under [locale]. An empty or null [value] returns `''`. + static String initials( + String? value, { + int limit = 0, + bool capitalize = false, + String? locale, + }) { + final String trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) return ''; + + List words = trimmed.split(RegExp(r'\s+')); + if (limit > 0 && words.length > limit) { + words = words.sublist(0, limit); + } + + final String result = words + .where((String word) => word.isNotEmpty) + // First code point, not first UTF-16 unit, so an emoji-led word does + // not yield a lone surrogate (Laravel reads it with mb_substr). + .map((String word) => String.fromCharCode(word.runes.first)) + .join(); + + return capitalize ? upper(result, locale: locale) : result; + } +} diff --git a/lib/src/testing/magic_test.dart b/lib/src/testing/magic_test.dart index 3ffd3be0..d6905fb4 100644 --- a/lib/src/testing/magic_test.dart +++ b/lib/src/testing/magic_test.dart @@ -1,3 +1,7 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui'; + import 'package:flutter_test/flutter_test.dart'; import 'package:magic/magic.dart'; @@ -18,8 +22,12 @@ class MagicTest { /// /// Registers: /// - `setUpAll`: `TestWidgetsFlutterBinding.ensureInitialized()` - /// - `setUp`: `MagicApp.reset()` + `Magic.flush()` - /// - `tearDown`: `Magic.flush()` + /// - `setUp`: `MagicApp.reset()` + `Magic.flush()` + `Gate.flush()` + /// - `tearDown`: `Magic.flush()` + `Gate.flush()` + /// + /// `Gate` is a process static (`facades/gate.dart`) that `Magic.flush()` + /// does not clear, so without the extra call an ability defined in one + /// test would leak into every later test in the same file. static void init() { setUpAll(() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -27,12 +35,46 @@ class MagicTest { setUp(() { MagicApp.reset(); Magic.flush(); + Gate.flush(); }); tearDown(() { Magic.flush(); + Gate.flush(); + // loadTranslations() installs a loader and a locale on the Translator + // singleton, which Magic.flush() does not reach; without this the next + // test formats numbers and dates in that locale without saying so. + // DateManager goes with it: it subscribes to the translator once on + // boot, so a booted DateManager outliving the disposed translator would + // stop following Lang.setLocale in every later test. + DateManager.reset(); + Translator.reset(); }); } + /// Load a real translation catalogue from disk for a widget test. + /// + /// Reads `/.json`, flattens it with + /// [JsonAssetLoader.flatten] (the same rule the app's own loader uses), + /// and installs a [TranslationLoader] serving that map through + /// [Translator.setLoader] before awaiting [Translator.load]. `trans()` + /// resolves real catalogue strings afterward instead of rendering raw + /// dotted keys, which is what happens when no loader has ever run in a + /// widget test. + /// + /// [directory] defaults to `assets/lang`, the app's own catalogue + /// location, so a test can point at a temp directory with a small + /// fixture json instead. Loading [locale] never requires a fallback-locale + /// file on disk: `Translator.load` only warns and falls back to an empty + /// map when the fallback catalogue fails to load + /// (`translator.dart:_loadFallbackFor`). + static Future loadTranslations( + String locale, { + String directory = 'assets/lang', + }) async { + Translator.instance.setLoader(_DirectoryTranslationLoader(directory)); + await Translator.instance.load(Locale(locale)); + } + /// Bootstrap Magic with test configuration. /// /// Use when you need full Magic.init() with test configs. @@ -47,3 +89,24 @@ class MagicTest { await Magic.init(envFileName: envFileName, configs: configs); } } + +/// Reads `/.json` straight off disk with `dart:io`. +/// +/// [JsonAssetLoader] reads through `rootBundle`, which only resolves paths +/// declared as Flutter assets. A test fixture written to a temp directory is +/// not one, so this loader bypasses the asset bundle entirely; the flatten +/// rule stays identical via [JsonAssetLoader.flatten]. +class _DirectoryTranslationLoader implements TranslationLoader { + /// The directory a locale's `.json` file is read from. + final String directory; + + /// Create a loader rooted at [directory]. + const _DirectoryTranslationLoader(this.directory); + + @override + Future> load(Locale locale) async { + final file = File('$directory/${locale.languageCode}.json'); + final json = jsonDecode(await file.readAsString()) as Map; + return JsonAssetLoader.flatten(json); + } +} diff --git a/lib/src/ui/refetches_on_mount.dart b/lib/src/ui/refetches_on_mount.dart new file mode 100644 index 00000000..974fb3cf --- /dev/null +++ b/lib/src/ui/refetches_on_mount.dart @@ -0,0 +1,64 @@ +import 'dart:async' show unawaited; + +import 'package:magic/magic.dart'; + +/// Refetches a view's data every time the view mounts. +/// +/// ## Why this exists +/// +/// magic caches controllers as Type-keyed singletons and fires `onInit` ONCE per +/// controller instance, not once per view mount. A controller that loads its data +/// in `onInit` therefore fetches on the first view that resolves it and never +/// again for the lifetime of the app, so navigating away and back re-renders the +/// same cached rows. +/// +/// On a data-heavy screen that reads as fabricated data rather than as +/// staleness: a list view can hold a count and a set of rows from the first +/// load while the backend has since served a different count and a row +/// created moments earlier, and only a hard reload clears it. A realtime path +/// that happens to broadcast an update can mask the gap, but that can be +/// hours apart in a quiet app, so it does not close the gap. +/// +/// ## Usage +/// +/// Mix onto a [MagicStatefulViewState] and point [refetch] at a load method +/// your controller defines: +/// +/// ```dart +/// class _ItemsListViewState +/// extends MagicStatefulViewState +/// with RefetchesOnMount { +/// @override +/// Future refetch() => controller.ensureFresh(); +/// } +/// ``` +/// +/// `ensureFresh` here is YOUR method, not a framework one, and its contract is +/// what makes the mixin safe: it must JOIN a load that is already in flight +/// rather than start a second one. The mount that creates the controller has +/// already started the same load from `onInit`, so a refetch that always +/// fires a new request sends every request twice on the first mount. Keep a +/// separate method for a refresh after a mutation, which must NOT join an +/// older in-flight request, or it hands back a snapshot without the row the +/// caller just created. +/// +/// The refetch is fire-and-forget: `build()` renders the cached data immediately +/// and the view rebuilds when the fresh data lands, so a mount never blocks on +/// the network. Have the load keep its last-known-good data on failure, so a +/// failed refetch leaves the screen as it was instead of flickering into an +/// empty state. +mixin RefetchesOnMount< + C extends MagicController, + W extends MagicStatefulView +> + on MagicStatefulViewState { + @override + void initState() { + super.initState(); + unawaited(refetch()); + } + + /// The refetch to run on each mount: a controller load that joins an + /// in-flight request rather than starting a second one. + Future refetch(); +} diff --git a/lib/src/ui/submits_once.dart b/lib/src/ui/submits_once.dart new file mode 100644 index 00000000..b2b86031 --- /dev/null +++ b/lib/src/ui/submits_once.dart @@ -0,0 +1,60 @@ +import 'package:flutter/widgets.dart'; + +/// Guards a form's submit against a second tap while the first write is in +/// flight. +/// +/// ## Why this exists +/// +/// A submit handler is `async`, and wiring it straight to a button +/// (`onPressed: _onSubmit`) leaves nothing disabling the button for the +/// duration of the await, so a double tap fires the write twice. On a create +/// path that is not idempotent: two taps create two records instead of one, +/// each counting against whatever limit or downstream side effect the create +/// carries. On an edit path it is the same write twice, which is harmless but +/// still a request nobody asked for. +/// +/// A double tap is not a hypothetical on web, where it costs nothing. +/// +/// This is a mixin rather than a flag invented once per form, because the +/// second and third form that need it would otherwise have forgotten it. +/// +/// ## Usage +/// +/// Mix onto the form's [State], route the handler through [submitOnce], and feed +/// [isSubmitting] to the button's `isLoading`. That last part is what actually +/// blocks the tap: a well-behaved button computes +/// `isInteractive = !isLoading && !disabled` and passes `null` for `onTap` +/// when it is false, so the spinner and the guard are the same switch. +/// +/// ```dart +/// ElevatedButton( +/// onPressed: isSubmitting ? null : () => submitOnce(_onSubmit), +/// child: Text(trans('...')), +/// ) +/// ``` +mixin SubmitsOnce on State { + bool _submitting = false; + + /// Whether a submit is currently in flight. + bool get isSubmitting => _submitting; + + /// Runs [body] unless a submit is already in flight, in which case the call is + /// dropped. + /// + /// The reset runs in a `finally` so a throwing submit re-arms the button + /// instead of leaving it spinning forever, and it is deliberately NOT a + /// `catch`: swallowing the error here would hide a failure the handler's own + /// error path is responsible for surfacing. The [mounted] check matters + /// because a successful submit usually navigates away, so this state is often + /// gone by the time [body] returns. + Future submitOnce(Future Function() body) async { + if (_submitting) return; + + setState(() => _submitting = true); + try { + await body(); + } finally { + if (mounted) setState(() => _submitting = false); + } + } +} diff --git a/skills/magic-framework/SKILL.md b/skills/magic-framework/SKILL.md index 88b5d3f7..4f1dae21 100644 --- a/skills/magic-framework/SKILL.md +++ b/skills/magic-framework/SKILL.md @@ -2,10 +2,10 @@ name: magic-framework description: "Write correct, idiomatic code in a Flutter app that depends on the `magic` framework (Laravel-inspired: IoC container, 18 facades, Eloquent-style ORM, service providers, reactive controllers, GoRouter routing, validation, auth, broadcasting). Use whenever code imports `package:magic/magic.dart` or `package:magic/testing.dart`, or the work touches Magic.init, MagicApp, a facade (Auth/Http/Cache/DB/Echo/Event/Gate/Config/Lang/Launch/Log/Pick/MagicRoute/Schema/Session/Storage/Vault/Crypt), a Model, MagicController, a MagicView, MagicFormData, FormRequest, a ServiceProvider, a migration, or the artisan make:* CLI. UI styling is Wind (separate wind-ui skill). Do NOT use for plain Flutter or Wind-only work with no magic import." when_to_use: "Use proactively when editing or scaffolding a magic app: Magic.init / a facade / a Model / a MagicController or MagicView / a form (MagicFormData, FormRequest, Validator) / a ServiceProvider / a route or MagicMiddleware / a migration / MagicStateMixin + RxStatus + fetchList / Session flash + old() + trans() / testing with MagicTest + Http.fake/Auth.fake / the artisan make:* CLI / the magic_deeplink, magic_notifications, magic_social_auth, magic_starter, magic_payments, or magic_devtools plugins. Trigger even when the user does not say the word 'magic'. Do NOT trigger for plain Flutter or Wind-only UI with no package:magic import." -version: 0.1.46 +version: 0.1.47 --- - + # Magic Framework @@ -245,6 +245,30 @@ await user.save(); Rules: `Required`, `Email`, `Min(n)`, `Max(n)`, `Confirmed`, `Same(other)`, `Accepted`, `In(values)` (primitives), `InList(values, {caseInsensitive, wire})` (enums), `Unique(endpoint, {field, debounce})`. Async rules implement `AsyncRule.passesAsync`; run them with `Validator.make(data, rules).validateAsync()`. `Unique` debounces (400ms default), passes on network error, discards stale calls; swap the backend with `.via(resolver)`. +A controller with `ValidatesRequests` should call `validateRequest`/`validateRequestAsync` rather than `FormRequest.validate()` directly: the latter never touches `validationErrors`, so a controller-side error bag, a repaint, and a stale-error clear on resubmit are all skipped. Both run the same authorize/prepare sequence and return the FULL prepared map, not the rule-filtered one; `validateRequestAsync` is the one to reach for when `rules()` contains an `AsyncRule`. + +```dart +final payload = validateRequest(StoreUserRequest(), form.data); // throws Authorization/ValidationException, populates validationErrors +``` + +Mix `CollapsesIndexedErrorKeys` on top of `ValidatesRequests` to collapse a backend's indexed list-validation key (`items.0.name`) onto its field name (`name`) for a form with one error slot per field, not per element. + +### Support helpers + +`Number`/`Str`/`Arr`/`Cast` (`lib/src/support/`) are static namespaces, no facade or IoC binding needed: + +```dart +Number.currency(1234.5, code: 'TRY', locale: 'tr'); // '₺1.234,50' +Str.upper('istanbul', locale: 'tr'); // 'İSTANBUL', dotted-i aware +Cast.intOr(Arr.get(payload, 'meta.priority'), 0); // Arr does no type check; compose with Cast +``` + +`RefetchesOnMount` (mix onto a `MagicStatefulViewState`, point `refetch` at a controller load that joins an in-flight request instead of starting a second one) and `SubmitsOnce` (mix onto a form's `State`, route the handler through `submitOnce`, feed `isSubmitting` to the button's `isLoading`) close the gaps singleton controllers (fire `onInit` once per instance, not per mount) and async submit handlers (nothing disables the button mid-await by default) leave open. + +`Env.filled(key, fallback)` treats an absent, blank, or quote-only `.env` value the same way, all resolving to `fallback` (`Env.get`/`env()` only fall back on a fully absent key). `Env.getOrFail(key)` throws a `StateError` only when the key is missing entirely. `Carbon.shortDiffForHumans([other])` is the compact-ladder sibling of `diffForHumans()` for dense tables (`'14m ago'`, `'1mo ago'`). + +Full reference: `${CLAUDE_SKILL_DIR}/references/secondary-systems.md` (Support helpers, Carbon, Env) and `doc/digging-deeper/helpers.md`, `doc/digging-deeper/validation.md`, `doc/basics/views.md`, `doc/getting-started/configuration.md`. + ### Routing + resource ```dart diff --git a/skills/magic-framework/references/secondary-systems.md b/skills/magic-framework/references/secondary-systems.md index ef7e9dfe..8355996e 100644 --- a/skills/magic-framework/references/secondary-systems.md +++ b/skills/magic-framework/references/secondary-systems.md @@ -4,6 +4,8 @@ Complete reference for Magic framework utility systems: Cache, Events, Logging, ## Contents +- [Support Helpers (Number, Str, Arr, Cast)](#support-helpers) +- [Environment Variables (Env)](#environment-variables-env) - [Cache System](#cache-system) - [Event Dispatcher](#event-dispatcher) - [Logging Manager](#logging-manager) @@ -18,6 +20,65 @@ Complete reference for Magic framework utility systems: Cache, Events, Logging, - [Broadcasting](#broadcasting) - [Key Gotchas](#key-gotchas) +## Support Helpers (Number, Str, Arr, Cast) + +Four `abstract final class` static namespaces under `lib/src/support/`, no facade, no IoC binding, no shared base class between them. Full doc page with tr/en examples: `doc/digging-deeper/helpers.md`. + +### Number + +Locale-aware number formatting. Every method resolves `locale` (or `Lang.current` when omitted) through `Intl.verifiedLocale`, falling back to `'en'` for a locale intl has no data for. + +| Method | Description | +|:-------|:------------| +| `Number.format(value, {precision, maxPrecision, grouped, locale})` | Locale grouping/decimal marks. `grouped: false` drops the thousands separator. | +| `Number.currency(amount, {code, precision, locale})` | Built on `simpleCurrency`, not `currency`: resolves the locale's symbol (`'₺1.234,50'`), not a bare ISO code. | +| `Number.percentage(value, {precision, maxPrecision, locale})` | Takes a 0-100 input like Laravel, not intl's native 0-1 fraction. | +| `Number.fileSize(bytes, {precision, maxPrecision, locale})` | Steps by 1024 (B/KB/MB/GB/TB/PB). | +| `Number.abbreviate(value, {precision, maxPrecision, locale})` | Compacts with the locale's own unit letters (`Mn`/`B` tr, `M`/`K` en). | + +### Str + +Locale-aware casing. `String.toUpperCase()`/`toLowerCase()` get Turkish/Azerbaijani wrong (dotted `i` vs dotless `ı`); `Str.upper`/`Str.lower` correct for it, defaulting `locale` to `Lang.current.languageCode` and accepting a full tag (`tr_TR`, `tr-TR`). + +| Method | Description | +|:-------|:------------| +| `Str.upper(value, {locale})` / `Str.lower(value, {locale})` | Dotted-i aware casing. `İ` maps to a plain `i` in EVERY locale (not only tr/az) to avoid the web's combining-dot lowercase. | +| `Str.initials(value, {limit, capitalize, locale})` | First letter of each whitespace-separated word; `limit` keeps only the first N words. | + +### Arr + +Dot-path access into a nested `Map`, mirroring Laravel's `Arr::get`/`has`/`set`/`dot`. Carries NO typed accessors; compose with `Cast`. + +| Method | Description | +|:-------|:------------| +| `Arr.get(map, path, [fallback])` | An exact key wins over walking the path; a numeric segment indexes into a `List`. | +| `Arr.has(map, path)` | True even for a reachable `null` leaf. | +| `Arr.set(map, path, value)` | Creates intermediate maps for a missing or non-map segment. | +| `Arr.dot(map, {prepend})` | Flattens to dotted-key leaves; an empty nested map is kept as its own leaf. | + +### Cast + +Total, throw-free readers for a loosely-typed wire value (a nested-map field, not a model attribute, which the ORM already coerces via `get`). + +| Method | Numeric string? | Notes | +|:-------|:-----------------|:------| +| `Cast.stringOr(v, fallback)` / `stringOrNull(v)` | n/a | | +| `Cast.intOr(v, fallback)` | **Parses** | The one reader that parses a numeric string (orders/durations: a silent fallback would misorder a list). | +| `Cast.intOrNull(v)` / `numOrNull(v)` / `doubleOrNull(v)` | Does NOT parse | Checks `is num`, never `is double` (JSON `3` decodes as double on web, int on VM). | +| `Cast.boolOr(v, fallback)` / `boolOrNull(v)` | n/a | | +| `Cast.idOrNull(v)` | Stringifies a `num` | A pk can be uuid or bigint; reading an int id as null would corrupt a save-diff. | + +## Environment Variables (Env) + +`Env`/`env()` mimic Laravel's `env()` helper over `flutter_dotenv`. Full doc page: `doc/getting-started/configuration.md`. + +`Env.get(key, [defaultValue])`/`env(key, [defaultValue])` only fall back to `defaultValue` when `key` is entirely ABSENT; a key present but blank resolves to `''` (Laravel parity: `KEY=""`/`KEY=''` also resolve to `''`, not the two-character literal `flutter_dotenv`'s own parser would otherwise leave in place). + +| Method | Description | +|:-------|:------------| +| `Env.filled(key, fallback)` | Treats absent, blank, AND quote-only the same way, all resolving to `fallback`. Strips one wrapping quote pair + surrounding whitespace from a present value (an inner apostrophe survives). Use for anything that becomes a URL, a title, or a link. | +| `Env.getOrFail(key)` | Throws `StateError` only when `key` is entirely absent; still returns `''` for a present-but-empty value. | + ## Cache System The Cache system provides a unified key-value caching API with TTL (time-to-live) support. Backed by the `CacheManager` and resolved via the `Cache` facade. @@ -674,6 +735,7 @@ Laravel-style fluent date wrapper around Jiffy for parsing, formatting, and mani | `toTimeString()` | — | `String` | HH:mm:ss. | | `toDateTimeString()` | — | `String` | yyyy-MM-dd HH:mm:ss. | | `diffForHumans([other])` | `Carbon? other` | `String` | Human-readable diff (e.g., "2 hours ago"). | +| `shortDiffForHumans([other])` | `Carbon? other` | `String` | Compact ladder for dense tables/list rows: seconds through years (`'14m ago'`, `'1mo ago'` for a truncated 30-day month, `'5m from now'`, `'Just now'` under 1s). Each unit and wrapper resolves through `Lang` (`time.units_short.*`, `time.ago`, `time.from_now`, `time.just_now`) with an English literal fallback. | #### Comparison & Checking diff --git a/test/concerns/validates_requests_form_request_test.dart b/test/concerns/validates_requests_form_request_test.dart new file mode 100644 index 00000000..c9d7a1e4 --- /dev/null +++ b/test/concerns/validates_requests_form_request_test.dart @@ -0,0 +1,251 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; + +/// Bare [ValidatesRequests] host, nothing else. +class _TestController extends MagicController with ValidatesRequests {} + +/// Same host with the opt-in indexed-key collapse layered on top. +class _CollapsingController extends MagicController + with ValidatesRequests, CollapsesIndexedErrorKeys {} + +/// [FormRequest] whose [rules] cover only `name`, so `validateRequest` must +/// return the full payload (including the unruled `extra` key) rather than +/// the filtered map `FormRequest.validate` would produce. +class _TestRequest extends FormRequest { + const _TestRequest({this.allowed = true}); + + final bool allowed; + + @override + bool authorize() => allowed; + + @override + Map> rules() => { + 'name': [Required()], + }; +} + +/// A [FormRequest] whose [prepared] injects a derived key, so the test can +/// pin that `validateRequest` validates and returns THIS output, not the +/// raw input passed in. +class _PreparingRequest extends FormRequest { + const _PreparingRequest(); + + @override + Map> rules() => { + 'name': [Required()], + 'slug': [Required()], + }; + + @override + Map prepared(Map data) => { + ...data, + 'slug': (data['name'] as String? ?? '').toLowerCase(), + }; +} + +/// An [AsyncRule] double that always fails, so `validateRequestAsync` can be +/// pinned against a rule the synchronous `validate` path would never run. +class _AlwaysFailsAsync extends AsyncRule { + @override + Future passesAsync( + String attribute, + dynamic value, + Map data, + ) async => false; + + @override + String message() => 'validation.custom_async'; +} + +/// A [FormRequest] whose only rule is an [AsyncRule], so a sync `validate` +/// call would pass unconditionally (see [AsyncRule.passes]) while +/// `validateRequestAsync` correctly fails it. +class _AsyncRequest extends FormRequest { + const _AsyncRequest(); + + @override + Map> rules() => { + 'email': [_AlwaysFailsAsync()], + }; +} + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + }); + + group('validateRequest', () { + test('unauthorized request throws AuthorizationException', () { + final controller = _TestController(); + + expect( + () => controller.validateRequest(const _TestRequest(allowed: false), { + 'name': 'Payment API', + }), + throwsA(isA()), + ); + expect(controller.validationErrors, isEmpty); + }); + + test('a failed rule populates validationErrors and rethrows', () { + final controller = _TestController(); + + expect( + () => controller.validateRequest(const _TestRequest(), {'name': ''}), + throwsA(isA()), + ); + expect(controller.validationErrors, containsPair('name', isA())); + expect(controller.validationErrors['name'], isNotEmpty); + }); + + test('success returns the full map, including keys without rules', () { + final controller = _TestController(); + + final result = controller.validateRequest(const _TestRequest(), { + 'name': 'Payment API', + 'extra': 'not covered by any rule', + }); + + expect(result, { + 'name': 'Payment API', + 'extra': 'not covered by any rule', + }); + }); + + test('prepared() output is what gets validated and returned', () { + final controller = _TestController(); + + final result = controller.validateRequest(const _PreparingRequest(), { + 'name': 'Payment API', + }); + + expect(result, {'name': 'Payment API', 'slug': 'payment api'}); + }); + }); + + group('validateRequestAsync', () { + test('unauthorized request throws AuthorizationException', () async { + final controller = _TestController(); + + await expectLater( + () => controller.validateRequestAsync( + const _TestRequest(allowed: false), + {'name': 'Payment API'}, + ), + throwsA(isA()), + ); + expect(controller.validationErrors, isEmpty); + }); + + test( + 'a failed AsyncRule populates validationErrors and rethrows', + () async { + final controller = _TestController(); + + await expectLater( + () => controller.validateRequestAsync(const _AsyncRequest(), { + 'email': 'someone@example.com', + }), + throwsA(isA()), + ); + expect( + controller.validationErrors, + containsPair('email', isA()), + ); + }, + ); + + test('success returns the full prepared map', () async { + final controller = _TestController(); + + final result = await controller.validateRequestAsync( + const _PreparingRequest(), + {'name': 'Payment API'}, + ); + + expect(result, {'name': 'Payment API', 'slug': 'payment api'}); + }); + }); + + group('setErrorsFromResponse key handling', () { + test('an indexed key keeps its raw form by default', () { + final controller = _TestController(); + final response = MagicResponse( + data: { + 'errors': { + 'items.0.name': ['The items.0.name field is required.'], + }, + }, + statusCode: 422, + ); + + controller.setErrorsFromResponse(response); + + expect(controller.validationErrors, { + 'items.0.name': 'The items.0.name field is required.', + }); + }); + + test( + 'the same response collapses to the field name through CollapsesIndexedErrorKeys', + () { + final controller = _CollapsingController(); + final response = MagicResponse( + data: { + 'errors': { + 'items.0.name': ['The items.0.name field is required.'], + }, + }, + statusCode: 422, + ); + + controller.setErrorsFromResponse(response); + + expect(controller.validationErrors, { + 'name': 'The items.0.name field is required.', + }); + }, + ); + + test('an empty error list is skipped', () { + final controller = _TestController(); + final response = MagicResponse( + data: { + 'errors': { + 'name': [], + 'email': ['The email field is required.'], + }, + }, + statusCode: 422, + ); + + controller.setErrorsFromResponse(response); + + expect(controller.validationErrors, { + 'email': 'The email field is required.', + }); + }); + + test( + 'two keys that collapse onto the same field keep the FIRST message', + () { + final controller = _CollapsingController(); + final response = MagicResponse( + data: { + 'errors': { + 'items.0.name': ['first message'], + 'items.1.name': ['second message'], + }, + }, + statusCode: 422, + ); + + controller.setErrorsFromResponse(response); + + expect(controller.validationErrors, {'name': 'first message'}); + }, + ); + }); +} diff --git a/test/foundation/env_test.dart b/test/foundation/env_test.dart new file mode 100644 index 00000000..ac9647c3 --- /dev/null +++ b/test/foundation/env_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; + +void main() { + group('Env', () { + setUp(() async { + // `Env.load()` fails to find a bundled `.env` asset under the test + // runner, which is fine: the catch branch still flips `_isLoaded` to + // true, so `Env.get` reaches the real `dotenv` instance instead of + // falling back to the (empty) fallback map. Repopulate `dotenv` + // directly afterwards so the test exercises the real parser, not a + // stub of it. + await Env.load(); + }); + + tearDown(() { + Env.reset(); + }); + + test( + 'get() treats a double-quote-only value as empty, not the default', + () { + dotenv.loadFromString(envString: 'KEY=""'); + + expect(env('KEY', 'd'), ''); + }, + ); + + test( + 'get() treats a single-quote-only value as empty, not the default', + () { + dotenv.loadFromString(envString: "KEY=''"); + + expect(env('KEY', 'd'), ''); + }, + ); + + test('get() treats a blank value as empty, not the default', () { + dotenv.loadFromString(envString: 'KEY='); + + expect(env('KEY', 'd'), ''); + }); + + test('filled() falls back to the default when the key is absent', () { + dotenv.loadFromString(envString: 'OTHER=x'); + + expect(Env.filled('KEY', 'd'), 'd'); + }); + + test('filled() falls back to the default for a blank value', () { + dotenv.loadFromString(envString: 'KEY='); + + expect(Env.filled('KEY', 'd'), 'd'); + }); + + test( + 'filled() falls back to the default for a double-quote-only value', + () { + dotenv.loadFromString(envString: 'KEY=""'); + + expect(Env.filled('KEY', 'd'), 'd'); + }, + ); + + test('filled() falls back to the default for a whitespace-only value', () { + dotenv.loadFromString(envString: 'KEY=" "'); + + expect(Env.filled('KEY', 'd'), 'd'); + }); + + test( + 'filled() strips one wrapping quote pair and keeps an inner apostrophe', + () { + dotenv.loadFromString(envString: 'K="Anıl\'s Monitor"'); + + expect(Env.filled('K', 'd'), "Anıl's Monitor"); + }, + ); + + test('getOrFail() throws StateError for an absent key', () { + dotenv.loadFromString(envString: 'OTHER=x'); + + expect( + () => Env.getOrFail('MISSING'), + throwsA( + isA().having( + (StateError e) => e.message, + 'message', + 'Environment variable [MISSING] has no value.', + ), + ), + ); + }); + + test( + 'getOrFail() returns an empty string for a present but empty value', + () { + dotenv.loadFromString(envString: 'KEY='); + + expect(Env.getOrFail('KEY'), ''); + }, + ); + }); +} diff --git a/test/support/arr_test.dart b/test/support/arr_test.dart new file mode 100644 index 00000000..95a43113 --- /dev/null +++ b/test/support/arr_test.dart @@ -0,0 +1,136 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/src/support/arr.dart'; + +/// What this pins: [Arr]'s dot-path semantics match Laravel's `Arr::get` / +/// `has` / `set` / `dot`, including the two precedence rules that are easy to +/// get backwards: an exact key wins over a walked path, and a numeric segment +/// indexes into a [List]. +void main() { + group('Arr.get', () { + test('walks a dotted path through nested maps and lists', () { + expect( + Arr.get({ + 'a': { + 'b': [10, 20], + }, + }, 'a.b.1'), + 20, + ); + }); + + test('an exact key containing dots wins over walking the path', () { + expect( + Arr.get({ + 'a.b': 1, + 'a': {'b': 2}, + }, 'a.b'), + 1, + ); + }); + + test('answers the fallback when the path is unreachable', () { + expect(Arr.get({}, 'x', 'd'), 'd'); + }); + + test('answers null fallback by default', () { + expect(Arr.get({}, 'x'), isNull); + }); + + test('a null path answers the whole map', () { + final map = {'a': 1}; + expect(Arr.get(map, null), same(map)); + }); + + test('a null map answers the fallback', () { + expect(Arr.get(null, 'a', 'd'), 'd'); + }); + + test('a non-numeric segment against a List answers the fallback', () { + expect( + Arr.get( + { + 'a': [1, 2], + }, + 'a.x', + 'd', + ), + 'd', + ); + }); + }); + + group('Arr.has', () { + test('true for a reachable dotted path', () { + expect( + Arr.has({ + 'a': {'b': 1}, + }, 'a.b'), + isTrue, + ); + }); + + test('false for an unreachable path', () { + expect(Arr.has({}, 'a.b'), isFalse); + }); + + test('true for a null leaf value that is genuinely present', () { + expect( + Arr.has({ + 'a': {'b': null}, + }, 'a.b'), + isTrue, + ); + }); + }); + + group('Arr.set', () { + test('creates nested maps along the path', () { + final map = {}; + Arr.set(map, 'a.b.c', 1); + expect(map, { + 'a': { + 'b': {'c': 1}, + }, + }); + }); + + test('overwrites a non-map value blocking the path', () { + final map = {'a': 1}; + Arr.set(map, 'a.b', 2); + expect(map, { + 'a': {'b': 2}, + }); + }); + + test('a single-segment path sets the top-level key', () { + final map = {}; + Arr.set(map, 'a', 1); + expect(map, {'a': 1}); + }); + }); + + group('Arr.dot', () { + test('flattens a nested map with dotted keys', () { + expect( + Arr.dot({ + 'a': {'b': 1}, + }), + {'a.b': 1}, + ); + }); + + test('prepends a given prefix', () { + expect( + Arr.dot({'b': 1}, prepend: 'a.'), + {'a.b': 1}, + ); + }); + + test('keeps an empty nested map as a leaf rather than dropping it', () { + expect( + Arr.dot({'a': {}}), + {'a': {}}, + ); + }); + }); +} diff --git a/test/support/carbon_short_diff_test.dart b/test/support/carbon_short_diff_test.dart new file mode 100644 index 00000000..70c454a5 --- /dev/null +++ b/test/support/carbon_short_diff_test.dart @@ -0,0 +1,120 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart' show MagicApp, Magic; +import 'package:magic/src/localization/contracts/translation_loader.dart'; +import 'package:magic/src/localization/translator.dart'; +import 'package:magic/src/support/carbon.dart'; + +/// A loader that returns exactly the flat, already-dotted keys it is given, +/// so a test can seed the translator without going through JSON nesting. +class _FlatLoader implements TranslationLoader { + const _FlatLoader(this._locales); + + final Map> _locales; + + @override + Future> load(Locale locale) async { + return Map.from(_locales[locale.languageCode] ?? {}); + } +} + +void main() { + group('Carbon.shortDiffForHumans', () { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Translator.reset(); + }); + + tearDown(() { + Carbon.setTestNow(); + Translator.reset(); + }); + + test('T-14 min reads "14m ago" with no catalogue loaded', () { + final t = Carbon.create(year: 2024, month: 3, day: 15, hour: 10); + Carbon.setTestNow(t); + + final event = t.subMinutes(14); + expect(event.shortDiffForHumans(), '14m ago'); + }); + + test('T-2 h reads "2h ago"', () { + final t = Carbon.create(year: 2024, month: 3, day: 15, hour: 10); + Carbon.setTestNow(t); + + final event = t.subHours(2); + expect(event.shortDiffForHumans(), '2h ago'); + }); + + test('T-45 d reads "1mo ago" (30-day months, truncated)', () { + final t = Carbon.create(year: 2024, month: 3, day: 15, hour: 10); + Carbon.setTestNow(t); + + final event = t.subDays(45); + expect(event.shortDiffForHumans(), '1mo ago'); + }); + + test('T-362 d reads "1y ago", never "12mo ago"', () { + final t = Carbon.create(year: 2024, month: 3, day: 15, hour: 10); + Carbon.setTestNow(t); + + expect(t.subDays(362).shortDiffForHumans(), '1y ago'); + }); + + test('T+5 min reads "5m from now"', () { + final t = Carbon.create(year: 2024, month: 3, day: 15, hour: 10); + Carbon.setTestNow(t); + + final event = t.addMinutes(5); + expect(event.shortDiffForHumans(), '5m from now'); + }); + + test('T-0.5 s reads "Just now"', () { + final t = Carbon.create(year: 2024, month: 3, day: 15, hour: 10); + Carbon.setTestNow(t); + + final event = t.subtract(const Duration(milliseconds: 500)); + expect(event.shortDiffForHumans(), 'Just now'); + }); + + test('T-10 d reads "1w ago"', () { + final t = Carbon.create(year: 2024, month: 3, day: 15, hour: 10); + Carbon.setTestNow(t); + + final event = t.subDays(10); + expect(event.shortDiffForHumans(), '1w ago'); + }); + + test('measures against an explicit other rather than the clock', () { + // No frozen clock at all; the reference is the explicit `other`. + final reference = Carbon.create(year: 2024, month: 3, day: 15, hour: 10); + final event = reference.subMinutes(14); + + expect(event.shortDiffForHumans(reference), '14m ago'); + }); + + test( + 'a Turkish catalogue localizes both the unit and the wrapper', + () async { + Translator.instance.setLoader( + const _FlatLoader({ + 'tr': { + 'time.units_short.minute': ':count dk', + 'time.ago': ':time önce', + }, + }), + ); + Translator.instance.setFallbackLocale(const Locale('en')); + await Translator.instance.load(const Locale('tr')); + + final t = Carbon.create(year: 2024, month: 3, day: 15, hour: 10); + Carbon.setTestNow(t); + + final event = t.subMinutes(14); + expect(event.shortDiffForHumans(), '14 dk önce'); + }, + ); + }); +} diff --git a/test/support/cast_test.dart b/test/support/cast_test.dart new file mode 100644 index 00000000..143b5f11 --- /dev/null +++ b/test/support/cast_test.dart @@ -0,0 +1,121 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/src/support/cast.dart'; + +/// What this pins: every [Cast] reader degrades a wrong-typed wire value to +/// its stated fallback instead of throwing, and the one deliberate asymmetry +/// ([Cast.intOr] alone parses a numeric string) holds. +void main() { + group('Cast.stringOr', () { + test('reads a String', () { + expect(Cast.stringOr('hello', 'fallback'), 'hello'); + }); + + test('falls back on a non-String', () { + expect(Cast.stringOr(42, 'fallback'), 'fallback'); + expect(Cast.stringOr(null, 'fallback'), 'fallback'); + }); + }); + + group('Cast.stringOrNull', () { + test('reads a String', () { + expect(Cast.stringOrNull('hello'), 'hello'); + }); + + test('answers null on a non-String', () { + expect(Cast.stringOrNull(42), isNull); + expect(Cast.stringOrNull(null), isNull); + }); + }); + + group('Cast.intOr', () { + test('reads a num', () { + expect(Cast.intOr(3, 0), 3); + expect(Cast.intOr(3.9, 0), 3); + }); + + test('parses a numeric string, unlike every other reader here', () { + expect(Cast.intOr('3', 0), 3); + }); + + test('falls back on an unparseable string or other type', () { + expect(Cast.intOr('abc', 0), 0); + expect(Cast.intOr(null, 0), 0); + expect(Cast.intOr(true, 0), 0); + }); + }); + + group('Cast.intOrNull', () { + test('reads a num', () { + expect(Cast.intOrNull(3), 3); + expect(Cast.intOrNull(3.9), 3); + }); + + test('does NOT parse a numeric string', () { + expect(Cast.intOrNull('3'), isNull); + }); + + test('answers null on anything else', () { + expect(Cast.intOrNull(null), isNull); + expect(Cast.intOrNull(true), isNull); + }); + }); + + group('Cast.numOrNull', () { + test('reads a num', () { + expect(Cast.numOrNull(3), 3); + expect(Cast.numOrNull(3.5), 3.5); + }); + + test('does NOT parse a numeric string', () { + expect(Cast.numOrNull('3'), isNull); + }); + }); + + group('Cast.doubleOrNull', () { + test('reads a num and converts to double', () { + expect(Cast.doubleOrNull(3), 3.0); + expect(Cast.doubleOrNull(3.5), 3.5); + }); + + test('answers null on a non-num', () { + expect(Cast.doubleOrNull('3'), isNull); + expect(Cast.doubleOrNull(null), isNull); + }); + }); + + group('Cast.boolOr', () { + test('reads a bool', () { + expect(Cast.boolOr(true, false), isTrue); + }); + + test('falls back on a non-bool', () { + expect(Cast.boolOr('yes', false), isFalse); + expect(Cast.boolOr(null, true), isTrue); + }); + }); + + group('Cast.boolOrNull', () { + test('reads a bool', () { + expect(Cast.boolOrNull(false), isFalse); + }); + + test('answers null on a non-bool', () { + expect(Cast.boolOrNull('yes'), isNull); + }); + }); + + group('Cast.idOrNull', () { + test('reads a String as is', () { + expect(Cast.idOrNull('abc-123'), 'abc-123'); + }); + + test('stringifies a num', () { + expect(Cast.idOrNull(42), '42'); + }); + + test('answers null on anything else', () { + expect(Cast.idOrNull(null), isNull); + expect(Cast.idOrNull(true), isNull); + }); + }); +} diff --git a/test/support/exports_test.dart b/test/support/exports_test.dart new file mode 100644 index 00000000..d90e2aee --- /dev/null +++ b/test/support/exports_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; + +/// Pins that the new Support surface (Number, Str, Cast, Arr), the two UI +/// mixins (RefetchesOnMount, SubmitsOnce), CollapsesIndexedErrorKeys, +/// Env.filled and Carbon.shortDiffForHumans all reach a consumer through +/// `package:magic/magic.dart` alone, not only through `package:magic/src/...`. +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + }); + + test('Number, Str, Cast, Arr resolve through the public barrel', () { + expect(Number.format(1234, locale: 'en'), '1,234'); + expect(Str.upper('istanbul', locale: 'en'), 'ISTANBUL'); + expect(Cast.intOr('3', 0), 3); + expect(Arr.get({'a': 1}, 'a'), 1); + }); + + test('RefetchesOnMount and SubmitsOnce mixins are exported', () { + expect(RefetchesOnMount, isNotNull); + expect(SubmitsOnce, isNotNull); + }); + + test('CollapsesIndexedErrorKeys is exported alongside ValidatesRequests', () { + expect(CollapsesIndexedErrorKeys, isNotNull); + }); + + test('Env.filled resolves an absent key to the fallback', () { + expect(Env.filled('MISSING_EXPORTS_TEST_KEY', 'fallback'), 'fallback'); + }); + + test('Carbon.shortDiffForHumans measures against an explicit other', () { + final reference = Carbon.create(year: 2024, month: 3, day: 15, hour: 10); + final event = reference.subMinutes(14); + + expect(event.shortDiffForHumans(reference), '14m ago'); + }); +} diff --git a/test/support/number_test.dart b/test/support/number_test.dart new file mode 100644 index 00000000..3cc6280f --- /dev/null +++ b/test/support/number_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/src/support/number.dart'; + +void main() { + group('Number.format', () { + test('applies maxPrecision and the locale grouping/decimal marks (tr)', () { + expect( + Number.format(1234567.891, maxPrecision: 3, locale: 'tr'), + '1.234.567,891', + ); + }); + + test('applies maxPrecision and the locale grouping/decimal marks (en)', () { + expect( + Number.format(1234567.891, maxPrecision: 3, locale: 'en'), + '1,234,567.891', + ); + }); + + test('grouped: false drops the thousands separator', () { + expect(Number.format(1234, locale: 'tr', grouped: false), '1234'); + }); + + test('precision fixes both minimum and maximum fraction digits', () { + expect(Number.format(0.5, precision: 2, locale: 'tr'), '0,50'); + }); + }); + + group('Number.currency', () { + test( + 'builds on simpleCurrency, not currency, so tr gets a symbol not an ISO code', + () { + expect(Number.currency(1234.5, code: 'TRY', locale: 'tr'), '₺1.234,50'); + }, + ); + }); + + group('Number.percentage', () { + test('takes a 0-100 input like Laravel and divides internally (tr)', () { + expect(Number.percentage(99.95, precision: 2, locale: 'tr'), '%99,95'); + }); + + test('takes a 0-100 input like Laravel and divides internally (en)', () { + expect(Number.percentage(99.95, precision: 2, locale: 'en'), '99.95%'); + }); + }); + + group('Number.abbreviate', () { + test('compacts with the locale unit letters (tr)', () { + // intl's compact pattern separates the number and unit letter with a + // non-breaking space (U+00A0), not a regular space. + expect( + Number.abbreviate(1500000, maxPrecision: 1, locale: 'tr'), + '1,5 Mn', + ); + }); + }); + + group('Number.fileSize', () { + test('steps by 1024 and appends the locale-formatted number', () { + expect(Number.fileSize(1536, maxPrecision: 1, locale: 'en'), '1.5 KB'); + }); + }); + + group('Number locale fallback', () { + test('an unknown locale falls back to en instead of throwing', () { + expect(() => Number.format(1, locale: 'zz'), returnsNormally); + }); + }); +} diff --git a/test/support/str_test.dart b/test/support/str_test.dart new file mode 100644 index 00000000..70add8b3 --- /dev/null +++ b/test/support/str_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/src/support/str.dart'; + +void main() { + group('Str.upper', () { + test('tr maps the dotted and dotless i before uppercasing', () { + expect( + Str.upper('çalışan izleyiciler', locale: 'tr'), + 'ÇALIŞAN İZLEYİCİLER', + ); + }); + + test('en uppercases plainly, with no Turkish dotting', () { + expect(Str.upper('istanbul', locale: 'en'), 'ISTANBUL'); + }); + }); + + group('Str.lower', () { + test('tr maps I to dotless ı before lowercasing', () { + expect(Str.lower('İSTANBUL', locale: 'tr'), 'istanbul'); + }); + + test('tr lowercases a dotless I run correctly', () { + expect(Str.lower('IŞIK', locale: 'tr'), 'ışık'); + }); + + test('every locale maps İ to a plain i with no combining mark', () { + final String result = Str.lower('İ', locale: 'en'); + expect(result, 'i'); + expect(result.length, 1); + }); + }); + + group('Str.initials', () { + test( + 'limit keeps only the first N words and capitalize routes through Str.upper', + () { + expect( + Str.initials('ismail kaya', limit: 2, capitalize: true, locale: 'tr'), + 'İK', + ); + }, + ); + + test('a blank value returns an empty string', () { + expect(Str.initials(' '), ''); + }); + }); + + test('initials read a whole code point, so an emoji stays intact', () { + expect(Str.initials('😀 team'), '😀t'); + }); + + test('a full locale tag resolves to its language code', () { + expect(Str.upper('istanbul', locale: 'tr_TR'), 'İSTANBUL'); + expect(Str.lower('IŞIK', locale: 'tr-TR'), 'ışık'); + }); +} diff --git a/test/testing/magic_test_test.dart b/test/testing/magic_test_test.dart index a4c5b80e..b33a920c 100644 --- a/test/testing/magic_test_test.dart +++ b/test/testing/magic_test_test.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:magic/magic.dart'; import 'package:magic/testing.dart'; @@ -123,4 +126,80 @@ void main() { expect(FakeNetworkDriver, isNotNull); }); }); + + // --------------------------------------------------------------------------- + // 7. MagicTest.init() resets Gate between tests + // --------------------------------------------------------------------------- + + group('MagicTest.init() resets Gate between tests', () { + MagicTest.init(); + + test('first test: defines an ability', () { + Gate.define('gate-isolation-key', (user, arguments) => true); + + expect(Gate.has('gate-isolation-key'), isTrue); + }); + + test('second test: ability from first test is gone', () { + expect(Gate.has('gate-isolation-key'), isFalse); + }); + }); + + // --------------------------------------------------------------------------- + // 8. MagicTest.loadTranslations() loads a catalogue for trans() + // --------------------------------------------------------------------------- + + group('MagicTest.loadTranslations() loads a catalogue for trans()', () { + MagicTest.init(); + + test( + 'nested key resolves after loading a temp directory catalogue', + () async { + final tempDir = await Directory.systemTemp.createTemp( + 'magic_test_translations_', + ); + addTearDown(() => tempDir.delete(recursive: true)); + + final trFile = File('${tempDir.path}/tr.json'); + await trFile.writeAsString( + jsonEncode({ + 'a': {'b': 'merhaba'}, + }), + ); + + await MagicTest.loadTranslations('tr', directory: tempDir.path); + + expect(trans('a.b'), 'merhaba'); + }, + ); + + test('the next test starts on the default locale again', () { + expect(Lang.current.languageCode, 'en'); + expect(trans('a.b'), 'a.b'); + }); + }); + + // --------------------------------------------------------------------------- + // 9. MagicTest.init() resets DateManager with the Translator + // --------------------------------------------------------------------------- + + group('MagicTest.init() resets DateManager with the Translator', () { + MagicTest.init(); + + test( + 'first test: boots DateManager against this test\'s translator', + () async { + await DateManager.instance.boot(); + + expect(DateManager.instance.isBooted, isTrue); + }, + ); + + test('second test: DateManager is fresh, so it re-subscribes on boot', () { + // A booted singleton surviving into this test would keep its listener on + // the translator the previous tearDown disposed, and Lang.setLocale would + // stop reaching Carbon's locale. + expect(DateManager.instance.isBooted, isFalse); + }); + }); } diff --git a/test/ui/refetches_on_mount_test.dart b/test/ui/refetches_on_mount_test.dart new file mode 100644 index 00000000..3400e253 --- /dev/null +++ b/test/ui/refetches_on_mount_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; + +/// A controller that counts how often its data was fetched, and (like a real +/// list-backed controller) loads once from magic's `onInit`. +class _CountingController extends MagicController { + int fetches = 0; + + @override + void onInit() { + super.onInit(); + reload(); + } + + Future reload() async { + fetches++; + } +} + +class _CountingView extends MagicStatefulView<_CountingController> { + const _CountingView(); + + @override + State<_CountingView> createState() => _CountingViewState(); +} + +class _CountingViewState + extends MagicStatefulViewState<_CountingController, _CountingView> + with RefetchesOnMount<_CountingController, _CountingView> { + @override + void initState() { + Magic.findOrPut(_CountingController.new); + super.initState(); + } + + @override + Future refetch() => controller.reload(); + + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} + +/// The same view WITHOUT the mixin, present to prove the mixin is what makes +/// the difference rather than some property of the harness. +class _PlainView extends MagicStatefulView<_CountingController> { + const _PlainView(); + + @override + State<_PlainView> createState() => _PlainViewState(); +} + +class _PlainViewState + extends MagicStatefulViewState<_CountingController, _PlainView> { + @override + void initState() { + Magic.findOrPut(_CountingController.new); + super.initState(); + } + + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + }); + + tearDown(() { + MagicApp.reset(); + Magic.flush(); + }); + + testWidgets('a remount refetches, so re-entering a route is never stale', ( + tester, + ) async { + // magic caches controllers as Type-keyed singletons and fires `onInit` once + // per controller INSTANCE, so without this mixin the second mount serves the + // data fetched by the first. + await tester.pumpWidget(const _CountingView()); + await tester.pump(); + + final _CountingController controller = Magic.find<_CountingController>(); + final int afterFirstMount = controller.fetches; + expect(afterFirstMount, greaterThan(0)); + + // Unmount, then mount the same view type again: the container hands back the + // SAME controller instance, so `onInit` does not fire a second time. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + await tester.pumpWidget(const _CountingView()); + await tester.pump(); + + expect( + Magic.find<_CountingController>().fetches, + greaterThan(afterFirstMount), + reason: + 'the second mount must fetch again, not serve the first ' + 'mount\'s data', + ); + }); + + testWidgets('without the mixin a remount does NOT refetch', (tester) async { + // Pins the mechanism the mixin works around, so a future change to magic's + // controller lifecycle that makes `onInit` fire per mount shows up here as a + // failure rather than leaving the mixin silently redundant. + await tester.pumpWidget(const _PlainView()); + await tester.pump(); + + final int afterFirstMount = Magic.find<_CountingController>().fetches; + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + await tester.pumpWidget(const _PlainView()); + await tester.pump(); + + expect( + Magic.find<_CountingController>().fetches, + equals(afterFirstMount), + reason: + 'onInit fires once per controller instance, which is exactly why ' + 'RefetchesOnMount exists', + ); + }); +} diff --git a/test/ui/submits_once_test.dart b/test/ui/submits_once_test.dart new file mode 100644 index 00000000..c5635561 --- /dev/null +++ b/test/ui/submits_once_test.dart @@ -0,0 +1,125 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/src/ui/submits_once.dart'; + +/// A form-shaped harness that deliberately does NOT wire [SubmitsOnce.isSubmitting] +/// to its button. +/// +/// This is the case the mixin's own early return exists for. A properly wired +/// button drops its `onTap` while loading, so the tap never arrives in the +/// first place, which makes the two guards indistinguishable through the UI: a +/// test that taps a properly wired button passes with either one alone. +/// Testing the early return therefore means removing the outer guard, which is +/// also the realistic failure mode, a caller wiring the handler and forgetting +/// the loading prop. +class _UnwiredForm extends StatefulWidget { + final Future Function() onSubmit; + + const _UnwiredForm({required this.onSubmit}); + + @override + State<_UnwiredForm> createState() => _UnwiredFormState(); +} + +class _UnwiredFormState extends State<_UnwiredForm> + with SubmitsOnce<_UnwiredForm> { + @override + Widget build(BuildContext context) { + // `opaque` because the child is an empty box: the default `deferToChild` + // has nothing to hit test against, so the tap would never arrive. + // + // The `catchError` stands in for what a real caller does with a failed + // write (surfacing it as a toast). It is here so the throw does not escape + // as an unhandled async error and derail the test; what is under test is + // that the mixin re-arms afterwards, not who reports the failure. + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => submitOnce(widget.onSubmit).catchError((Object _) {}), + child: const SizedBox(width: 100, height: 40), + ); + } +} + +void main() { + testWidgets('a second call during the flight is dropped', (tester) async { + var submits = 0; + final Completer inFlight = Completer(); + + await tester.pumpWidget( + _UnwiredForm( + onSubmit: () async { + submits++; + await inFlight.future; + }, + ), + ); + + await tester.tap(find.byType(SizedBox)); + await tester.pump(); + await tester.tap(find.byType(SizedBox)); + await tester.pump(); + + expect(submits, 1, reason: 'the in-flight guard must drop the second call'); + + inFlight.complete(); + await tester.pumpAndSettle(); + }); + + testWidgets('the guard re-arms once the flight completes', (tester) async { + var submits = 0; + Completer inFlight = Completer(); + + await tester.pumpWidget( + _UnwiredForm( + onSubmit: () async { + submits++; + await inFlight.future; + }, + ), + ); + + await tester.tap(find.byType(SizedBox)); + await tester.pump(); + inFlight.complete(); + await tester.pumpAndSettle(); + + // A dropped second call must not become a permanently dead button: the + // operator who fixed a validation error and tapped again gets their submit. + inFlight = Completer(); + await tester.tap(find.byType(SizedBox)); + await tester.pump(); + + expect(submits, 2); + + inFlight.complete(); + await tester.pumpAndSettle(); + }); + + testWidgets('a throwing submit re-arms instead of spinning forever', ( + tester, + ) async { + // The reset lives in a `finally` for this: a submit that throws must leave + // the button usable. The throw itself still propagates, because swallowing + // it here would hide a failure the handler is responsible for surfacing. + var submits = 0; + + await tester.pumpWidget( + _UnwiredForm( + onSubmit: () async { + submits++; + throw StateError('write failed'); + }, + ), + ); + + await tester.tap(find.byType(SizedBox)); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(SizedBox)); + await tester.pumpAndSettle(); + + expect(submits, 2, reason: 'a failed submit must not lock the button'); + }); +}