Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ All notable changes to this project will be documented in this file.
### Fixed

- **`MagicFeedback` toasts now show in Scaffold-less (Wind-only) views instead of throwing or silently doing nothing.** `Magic.error` / `Magic.success` / `MagicFeedback.info` routed through `ScaffoldMessenger.of(context).showSnackBar`, which asserts `_scaffolds.isNotEmpty` when no Material `Scaffold` hosts the view. In a Wind-built screen (no `Scaffold`) that assertion escaped the caller's own `try/catch` and stalled the flow. Toast delivery now goes through the Navigator overlay, read from `navigatorKey.currentState.overlay` (NOT `Overlay.maybeOf`, which sits above that overlay), as a single non-interactive auto-dismissing bottom entry that replaces the previous one and degrades to a logged warning when no overlay is available (never throwing). The `Magic.error` / `success` / `toast` API surface is unchanged; only the delivery path is. Touches `lib/src/ui/magic_feedback.dart`; covered by `test/ui/magic_feedback_test.dart`.
- **`MagicFeedback` overlay toasts render clean text and degrade without throwing.** The overlay toast content was not wrapped in a `Material`, so its text inherited the root fallback `DefaultTextStyle` (the yellow debug double-underline); it now sits under a transparent `Material`, matching the dialog / loading builders. The unused `backgroundColor` / `color` parameters on `showSnackbar` (the overlay path never applied them) are removed, and the degrade-path warnings now log through `Log` only when the `log` service is bound (falling back to `debugPrint`), so feedback triggered before `Magic.init` binds logging degrades instead of throwing `Service [log] is not registered`. Touches `lib/src/ui/magic_feedback.dart`; covered by `test/ui/magic_feedback_test.dart`.
- **`TitleManager` treats a blank title suffix as absent.** A `null` suffix was already skipped, but an empty or whitespace suffix (e.g. an unset `APP_NAME` resolving to `""`) still produced `"Route | "` with a trailing separator and an empty tail. A blank suffix is now treated as absent (via `_withSuffix`), so the browser tab shows just the route title. Touches `lib/src/routing/title_manager.dart`; covered by `test/routing/title_manager_test.dart`.

## [0.0.4] - 2026-07-08

Expand Down
25 changes: 17 additions & 8 deletions lib/src/routing/title_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -132,29 +132,38 @@ class TitleManager {
///
/// The resolved title is passed through [trans] lazily at read time (a plain
/// literal passes through unchanged); the suffix stays literal and is never
/// translated. When an override or route title is active and a suffix is
/// present, the result is `"$title$_separator$suffix"`. When the resolved
/// title falls back to the application title, the suffix is omitted. When no
/// title is available, an empty string is returned.
/// translated. When an override or route title is active and a NON-EMPTY
/// suffix is present, the result is `"$title$_separator$suffix"`. A null or
/// blank suffix (e.g. an unset `APP_NAME`) is treated as absent, so the title
/// never trails a dangling separator (`"Home | "`) or an empty suffix. When
/// the resolved title falls back to the application title, the suffix is
/// omitted. When no title is available, an empty string is returned.
String get effectiveTitle {
final overrideTitle = _overrideTitle;
if (overrideTitle != null) {
final resolved = trans(overrideTitle);
final suffix = _suffix;
return suffix != null ? '$resolved$_separator$suffix' : resolved;
return _withSuffix(resolved);
}

final routeTitle = _routeTitle;
if (routeTitle != null) {
final resolved = trans(routeTitle);
final suffix = _suffix;
return suffix != null ? '$resolved$_separator$suffix' : resolved;
return _withSuffix(resolved);
}

final appTitle = _appTitle;
return appTitle != null ? trans(appTitle) : '';
}

/// Glues the [_suffix] onto [title] with the [_separator], or returns [title]
/// unchanged when the suffix is null or blank (so a missing app name never
/// leaves a dangling `"$title$separator"`).
String _withSuffix(String title) {
final suffix = _suffix;
if (suffix == null || suffix.trim().isEmpty) return title;
return '$title$_separator$suffix';
}

// ---------------------------------------------------------------------------
// Reset (Testing)
// ---------------------------------------------------------------------------
Expand Down
39 changes: 32 additions & 7 deletions lib/src/ui/magic_feedback.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:fluttersdk_wind/fluttersdk_wind.dart';

import '../foundation/magic.dart';
import '../routing/magic_router.dart';
import '../facades/config.dart';
import '../facades/log.dart';
Expand Down Expand Up @@ -51,11 +52,9 @@ class MagicFeedback {
String message, {
String type = 'info', // Added
Duration? duration, // Modified
Color? backgroundColor,
Color? color,
}) {
if (_context == null) {
Log.warning('MagicFeedback: Cannot show snackbar - context not mounted');
_warn('MagicFeedback: Cannot show snackbar - context not mounted');
return;
}

Expand Down Expand Up @@ -119,7 +118,7 @@ class MagicFeedback {
bool barrierDismissible = true,
}) {
if (_context == null) {
Log.warning('MagicFeedback: Cannot show dialog - context not mounted');
_warn('MagicFeedback: Cannot show dialog - context not mounted');
return Future.value(null);
}

Expand Down Expand Up @@ -263,7 +262,7 @@ class MagicFeedback {
/// Show a loading dialog.
static void showLoading({String? message}) {
if (_context == null) {
Log.warning('MagicFeedback: Cannot show loading - context not mounted');
_warn('MagicFeedback: Cannot show loading - context not mounted');
return;
}

Expand Down Expand Up @@ -397,7 +396,7 @@ class MagicFeedback {
final OverlayState? overlay =
MagicRouter.instance.navigatorKey.currentState?.overlay;
if (overlay == null) {
Log.warning('MagicFeedback: no Overlay available - toast skipped');
_warn('MagicFeedback: no Overlay available - toast skipped');
return;
}

Expand All @@ -422,7 +421,16 @@ class MagicFeedback {
duration: const Duration(milliseconds: 180),
builder: (_, value, child) =>
Opacity(opacity: value, child: child),
child: content,
// A transparent Material gives the overlay subtree a real
// DefaultTextStyle. Without it the toast text inherits the
// root fallback style (the yellow double-underline debug
// decoration), since the Navigator's Overlay sits above the
// route's Material. Matches the snackbar/dialog/loading
// builders, which already wrap their content in a Material.
child: Material(
type: MaterialType.transparency,
child: content,
),
),
),
),
Expand Down Expand Up @@ -458,6 +466,23 @@ class MagicFeedback {
}
}

/// Logs a degradation warning through [Log] when the `log` service is bound,
/// falling back to [debugPrint] otherwise.
///
/// Feedback can be triggered before `Magic.init` binds the logging service
/// (early startup, or a test that never registers it) or during teardown;
/// calling [Log.warning] then throws a "Service [log] is not registered"
/// error into the caller. Guarding on [Magic.bound] keeps the degrade path
/// from turning a missing snackbar into a crash, without silently dropping
/// the diagnostic.
static void _warn(String message) {
if (Magic.bound('log')) {
Log.warning(message);
} else {
debugPrint(message);
}
}

/// Parse Wind color class to Flutter Color.
static Color _parseWindColor(String className) {
if (className.contains('blue')) return const Color(0xFF3B82F6);
Expand Down
16 changes: 16 additions & 0 deletions test/routing/title_manager_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,22 @@ void main() {
expect(TitleManager.instance.effectiveTitle, 'App');
});

test('a blank suffix is treated as absent (no dangling separator)', () {
// A misconfigured app name (empty or whitespace, e.g. an unset
// APP_NAME) must not leave a trailing separator like "Dashboard - " on
// the route title.
TitleManager.configure();
TitleManager.instance
..setAppTitle('App')
..setRouteTitle('Dashboard')
..setSuffix('');

expect(TitleManager.instance.effectiveTitle, 'Dashboard');

TitleManager.instance.setSuffix(' ');
expect(TitleManager.instance.effectiveTitle, 'Dashboard');
});

test(
'suffix not applied when raw title is null — returns empty string',
() {
Expand Down
11 changes: 11 additions & 0 deletions test/ui/magic_feedback_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,15 @@ void main() {
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});

test('degrades without throwing when no context and the log service is '
'unbound', () {
// No harness pumped (so no navigator context) and `log` is unbound (setUp
// only flushes). The degrade path must fall back to debugPrint, not throw
// "Service [log] is not registered" into the caller.
expect(
() => MagicFeedback.error('No context', 'should not throw'),
returnsNormally,
);
});
}
Loading