ux: bulk WIP — UxPlugin→XPlugin rename + new anim/core/navi/reactive packages

Catch-all commit for outstanding pre-existing local changes. Mixes
several themes that would normally be split:

- Rename: UxPlugin → XPlugin across iOS, macOS, Android registrants.
- New top-level packages under lib/src/: anim/ (animated values,
  panes, sheets, dock, measured), core/ (Emitter, ReactiveBuilder
  scaffolding, presenter/widget/value/dispose primitives), navi/
  (Screen/ScreenStack/Router/hero/transitions), reactive/.
- Edits across existing plugins (clipboard, crash, file, gallery,
  keyboard, scanner, sensor, url) to align with the new core.
- Test updates and CHANGELOG/README touches accompanying the above.
This commit is contained in:
agra
2026-05-21 08:58:07 +03:00
parent a508aca2bb
commit d68a2978eb
83 changed files with 5006 additions and 275 deletions

156
lib/src/navi/hero.dart Normal file
View File

@@ -0,0 +1,156 @@
import 'package:flutter/material.dart';
/// A shared-element widget that animates between two positions
/// when the [ScreenStack] pushes or pops a screen.
///
/// Place [ScreenHero] widgets with the same [tag] on different screens.
/// When a transition occurs, the hero "flies" from the old position
/// to the new one inside an [Overlay].
class ScreenHero extends StatefulWidget {
const ScreenHero({
super.key,
required this.tag,
required this.child,
this.createRectTween,
});
/// Identifier used to match heroes across pages.
final Object tag;
/// The widget to display (and animate during flight).
final Widget child;
/// Optional custom tween for the Rect interpolation.
final CreateRectTween? createRectTween;
@override
State<ScreenHero> createState() => ScreenHeroState();
}
class ScreenHeroState extends State<ScreenHero> {
Size? _placeholderSize;
void startFlight() {
final box = context.findRenderObject() as RenderBox?;
if (box == null || !box.hasSize) return;
setState(() => _placeholderSize = box.size);
}
void endFlight() {
if (!mounted) return;
setState(() => _placeholderSize = null);
}
Rect? get globalRect {
final box = context.findRenderObject() as RenderBox?;
if (box == null || !box.hasSize) return null;
return MatrixUtils.transformRect(
box.getTransformTo(null),
Offset.zero & box.size,
);
}
@override
Widget build(BuildContext context) {
if (_placeholderSize != null) {
return SizedBox.fromSize(size: _placeholderSize);
}
return widget.child;
}
}
/// Registry + flight controller, owned by [ScreenStackState].
class ScreenHeroController {
final Map<Object, ScreenHeroState> _heroes = {};
void register(Object tag, ScreenHeroState state) => _heroes[tag] = state;
void unregister(Object tag, ScreenHeroState state) {
if (_heroes[tag] == state) _heroes.remove(tag);
}
/// Snapshot current hero Rects. Call before the page list changes.
Map<Object, Rect> snapshot() {
final result = <Object, Rect>{};
for (final entry in _heroes.entries) {
final rect = entry.value.globalRect;
if (rect != null) result[entry.key] = rect;
}
return result;
}
/// Start flights for any heroes whose position changed between
/// [before] snapshot and the current state.
void maybeStartFlights({
required Map<Object, Rect> before,
required Animation<double> animation,
required OverlayState overlay,
}) {
for (final tag in before.keys) {
final hero = _heroes[tag];
if (hero == null) continue;
final fromRect = before[tag]!;
final toRect = hero.globalRect;
if (toRect == null) continue;
if (fromRect == toRect) continue;
_startFlight(
tag: tag,
hero: hero,
fromRect: fromRect,
toRect: toRect,
animation: animation,
overlay: overlay,
);
}
}
void _startFlight({
required Object tag,
required ScreenHeroState hero,
required Rect fromRect,
required Rect toRect,
required Animation<double> animation,
required OverlayState overlay,
}) {
hero.startFlight();
final createTween = hero.widget.createRectTween;
final rectTween = createTween != null
? createTween(fromRect, toRect)
: RectTween(begin: fromRect, end: toRect);
final child = hero.widget.child;
OverlayEntry? entry;
void onEnd(AnimationStatus status) {
if (status == AnimationStatus.completed ||
status == AnimationStatus.dismissed) {
entry?.remove();
entry = null;
hero.endFlight();
animation.removeStatusListener(onEnd);
}
}
animation.addStatusListener(onEnd);
entry = OverlayEntry(
builder: (context) => AnimatedBuilder(
animation: animation,
builder: (context, _) {
final rect = rectTween.evaluate(animation);
if (rect == null) return const SizedBox.shrink();
return Positioned(
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
child: IgnorePointer(child: child),
);
},
),
);
overlay.insert(entry!);
}
}

385
lib/src/navi/router.dart Normal file
View File

@@ -0,0 +1,385 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:ux/src/core/dispose.dart';
import 'package:ux/src/core/emitter.dart';
import 'package:ux/src/core/functional.dart';
import 'package:ux/src/core/list_emitter.dart';
import 'package:ux/src/core/store/store.dart';
import 'package:ux/src/core/uri.dart';
import 'package:ux/src/navi/screen_host.dart';
import 'package:ux/src/navi/screen_stack.dart';
import 'package:ux/src/navi/screen.dart';
/// Generic stack-based screen router.
///
/// Manages a [home] screen and a [stack] of pushed screens, rendering them
/// via [ScreenStack]. Subclass and override [pages] to add guards
/// (e.g. auth checks) before the default screen list.
class XRouter extends BackButtonDispatcher
with Dispose, ScreenShell
implements ScreenHost {
XRouter({required this.home, this.routeParser}) {
home.parentHost = this;
_updateActive();
}
final Screen home;
final RouteInformationParser<Object>? routeParser;
late final stack = ListEmitter<Screen>()..disposeBy(this);
late final delegate = XRouterDelegate(router: this);
late final backDispatcher = XRouterBack(router: this);
/// The screen most recently fired `onActive`. Used to de-duplicate and
/// pair active/inactive transitions when the topmost visible screen
/// changes.
Screen? _active;
/// Recomputes the topmost visible screen and fires onInactive/onActive
/// when it has changed. Topmost is the last non-popped entry in [pages],
/// which already expands ScreenShell hosting. Also re-applies the
/// topmost screen's [Screen.supportedOrientations] to the OS, mirroring
/// UIKit's per-VC `supportedInterfaceOrientations` unwind on pop.
void _updateActive() {
Screen? top;
for (final page in pages.reversed) {
if (!page.popped) {
top = page;
break;
}
}
if (identical(top, _active)) return;
_active?.onInactive();
_active = top;
top?.onActive();
SystemChrome.setPreferredOrientations(
(top?.supportedOrientations ?? DeviceOrientation.values).toList(),
);
}
Screen? get currentConfiguration => stack.lastOrNull ?? home;
Iterable<Screen> _expand(Screen page) sync* {
yield page;
if (page is ScreenShell) {
for (final screen in (page as ScreenShell).pages) {
if (!screen.popped) yield screen;
}
}
}
List<Screen> get pages => [
..._expand(home),
for (final page in stack) ..._expand(page),
];
late final _overlayEntry = OverlayEntry(builder: _buildContent);
bool get canPop {
if (stack.isNotEmpty) return true;
if (home is ScreenShell) {
for (final screen in (home as ScreenShell).pages) {
if (!screen.popped) return true;
}
}
return false;
}
void _updateSystemBack() {
SystemNavigator.setFrameworkHandlesBack(canPop);
}
Widget _buildContent(BuildContext context) {
// [pages] (not the [home]/[stack] fields) so subclasses can
// override for gating — e.g. swap home to an auth screen.
final p = pages;
return MediaQuery.removeViewInsets(
context: context,
removeBottom: true,
child: Material(
child: NotificationListener<NavigationNotification>(
onNotification: (_) => true, // absorb — router manages system back directly
child: ScreenStack(
home: p.first,
stack: p.skip(1).toList(),
),
),
),
);
}
Widget build(BuildContext context) {
_overlayEntry.markNeedsBuild();
return Overlay(initialEntries: [_overlayEntry]);
}
void popAll() {
while (stack.isNotEmpty) {
stack.last.pop();
}
if (home is ScreenShell) {
for (final screen in (home as ScreenShell).pages.toList()) {
screen.pop();
}
}
}
/// Push [page] onto the router.
///
/// If an `==`-equal page is already mounted (shell-hosted by [home] or
/// anywhere on [stack]), pops every stack entry above it and returns
/// that existing page's future — the caller awaits until the mounted
/// page is popped. No duplicate is created, no fresh instance is
/// mounted. For identity-equal screens (no `==` override), this dedup
/// path is unreachable; behavior matches the pre-dedup semantics.
///
/// The type parameter `T` on the awaiter may differ from the mounted
/// page's original `T`; the returned future is cast. Pushing an equal
/// page of a different `T` is undefined.
Future<T?> push<T>(Screen<T> page) async {
FocusManager.instance.primaryFocus?.unfocus();
if (home == page) {
popAll();
return SynchronousFuture(null);
}
final existing = _findMounted(page);
if (existing != null) {
while (stack.isNotEmpty && !identical(stack.last, existing)) {
stack.last.pop();
}
delegate.notifyListeners();
_updateActive();
return existing.future as Future<T?>;
}
final top = stack.lastOrNull ?? home;
if (top case final ScreenShell host when host.accept(page)) {
page.parentHost = this;
page.detach = () {
delegate.notifyListeners();
_updateActive();
};
page.removed = () {
host.remove(page);
Dispose.object(page);
};
page.onPush();
delegate.notifyListeners();
_updateActive();
return page.future;
}
page.parentHost = this;
page.detach = () {
FocusManager.instance.primaryFocus?.unfocus();
stack.removeWhere((p) => identical(p, page));
delegate.notifyListeners();
_updateActive();
};
page.removed = () => Dispose.object(page);
page.onPush();
stack.add(page);
delegate.notifyListeners();
_updateActive();
return page.future;
}
/// Find an already-mounted screen equal to [page], skipping popped
/// entries. Scans shell-hosted children of [home] first, then the
/// router stack bottom→top.
Screen? _findMounted(Screen page) {
if (home is ScreenShell) {
for (final hosted in (home as ScreenShell).pages) {
if (hosted.popped) continue;
if (identical(hosted, page) || hosted == page) return hosted;
}
}
for (final entry in stack) {
if (entry.popped) continue;
if (identical(entry, page) || entry == page) return entry;
}
return null;
}
Future<void> setNewRoutePath(Screen? configuration) {
if (configuration == null || configuration == home) {
popAll();
return SynchronousFuture(null);
}
return push(configuration);
}
}
class XRouterBack extends RootBackButtonDispatcher {
XRouterBack({required this.router});
final XRouter router;
@override
Future<bool> didPopRoute() async {
for (final page in router.stack.reversed) {
if (page.handleBack()) return true;
}
if (router.home.handleBack()) return true;
final page = router.stack.lastOrNull;
if (page != null) {
page.pop();
return true;
}
return super.didPopRoute();
}
}
class XRouterDelegate extends RouterDelegate<Screen> with Emitter {
XRouterDelegate({required this.router});
final XRouter router;
@override
Widget build(BuildContext context) => router.build(context);
@override
Future<bool> popRoute() => router.backDispatcher.didPopRoute();
@override
Future<void> setNewRoutePath(Screen? configuration) async =>
router.setNewRoutePath(configuration);
@override
void notifyListeners() {
super.notifyListeners();
router._updateSystemBack();
}
@override
Screen? get currentConfiguration => router.currentConfiguration;
}
/// Mixin for screens that can host other screens (e.g. a tab host showing
/// a detail screen alongside the tab bar).
///
/// When a screen is pushed, the router asks the current top screen's shell
/// whether to [accept] it. If accepted, the screen is managed by the shell
/// instead of the router's main stack.
mixin ScreenShell {
/// Whether this shell wants to own [screen]. Return `true` to intercept
/// the push — the screen will not go onto the router stack.
bool accept(Screen screen) => false;
/// Called after a hosted screen's exit animation completes.
void remove(Screen screen) {}
/// The screens currently hosted by this shell. The router flattens
/// these into the [ScreenStack] alongside the main stack.
Iterable<Screen> get pages => const [];
}
/// Mixin for screens that support deep-linking via a URL.
mixin Deeplink<T> on Screen<T> {
String get restoreUrl;
}
/// A [RouteInformationParser] that converts URIs into [Screen]s
/// using a [UriParser], and restores URLs from [Deeplink] screens.
class XRouteParser extends RouteInformationParser<Object> {
XRouteParser({required this.parser, this.normalize});
final UriParser<Screen, dynamic> parser;
final Uri Function(Uri)? normalize;
Screen? parse(Uri? uri) {
if (uri == null) return null;
if (normalize != null) uri = normalize!(uri);
return parser.parse(uri, null);
}
@override
Future<Object> parseRouteInformation(RouteInformation routeInformation) async {
return parse(routeInformation.uri) as Object;
}
@override
RouteInformation? restoreRouteInformation(Object configuration) {
return (configuration is Deeplink ? configuration.restoreUrl : null)
?.pipe(Uri.tryParse)
?.pipe((uri) => RouteInformation(uri: uri));
}
}
/// The root widget for a ux app.
///
/// Resolves a [XRouter] from the [Store] and sets up theming,
/// localization, scroll behavior, and back-button dispatching —
/// without pulling in Flutter's [Navigator] or [MaterialApp].
class XApp extends StatelessWidget {
const XApp({
super.key,
required this.router,
this.title = '',
this.theme,
this.debugShowCheckedModeBanner = true,
this.localizationsDelegates,
this.supportedLocales = const [Locale('en')],
this.scrollBehavior,
this.overlayKey,
});
final XRouter router;
final String title;
final ThemeData? theme;
final bool debugShowCheckedModeBanner;
final Iterable<LocalizationsDelegate<dynamic>>? localizationsDelegates;
final Iterable<Locale> supportedLocales;
final ScrollBehavior? scrollBehavior;
final GlobalKey<OverlayState>? overlayKey;
@override
Widget build(BuildContext context) {
final data = theme ?? ThemeData();
return Title(
title: title,
color: data.primaryColor,
child: MediaQuery.fromView(
view: View.of(context),
child: Localizations(
locale: supportedLocales.first,
delegates: [
...?localizationsDelegates,
DefaultMaterialLocalizations.delegate,
DefaultWidgetsLocalizations.delegate,
],
child: AnimatedTheme(
data: data,
child: ScrollConfiguration(
behavior: scrollBehavior ?? const MaterialScrollBehavior(),
child: Overlay(
key: overlayKey,
initialEntries: [
OverlayEntry(
builder: (_) => Shortcuts(
shortcuts: WidgetsApp.defaultShortcuts,
child: Actions(
actions: WidgetsApp.defaultActions,
child: DefaultTextEditingShortcuts(
child: Router(
routeInformationParser: router.routeParser,
routerDelegate: router.delegate,
backButtonDispatcher: router.backDispatcher,
),
),
),
),
),
],
),
),
),
),
),
);
}
}

136
lib/src/navi/screen.dart Normal file
View File

@@ -0,0 +1,136 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:ux/src/navi/screen_host.dart';
import 'package:ux/src/navi/transitions.dart';
/// A full-screen destination that can be pushed onto a [ScreenStack]
/// and managed by a [XRouter].
///
/// Provides its own content via [buildPresenter] and controls
/// how it transitions in/out via [buildTransition].
mixin Screen<T> {
// === rendering ===
/// Identity tag used by [ScreenStack] to reconcile a `home` slot:
/// same [runtimeType] + same [key] → same surface, slot State
/// preserved. Has no effect on stack entries (diffed by `==`).
Key? get key => null;
/// Build the page content.
Widget buildPresenter(BuildContext context);
/// Wrap [child] (the output of [buildPresenter]) in a transition
/// driven by [animation] (0→1 on push, 1→0 on pop).
///
/// Override to customise. The default is a platform slide.
Widget buildTransition(
BuildContext context,
Animation<double> animation,
Widget child,
) => ScreenTransitions.platformSlide(context, animation, child);
/// Duration of the enter/exit animation.
Duration get transitionDuration => const Duration(milliseconds: 300);
/// Whether this screen is a modal (dialog, bottom sheet, etc.).
bool get isModal => false;
/// Whether the iOS edge-swipe gesture should be enabled for this page.
/// Set to `false` for bottom sheets and overlays that dismiss differently.
bool get canSwipeBack => true;
/// Device orientations this screen supports while it is the topmost
/// visible page. The router applies the topmost screen's value via
/// [SystemChrome.setPreferredOrientations] on each active-screen
/// change, mirroring UIKit's per-`UIViewController.supportedInterfaceOrientations`.
/// Override to force portrait/landscape on a specific page (e.g. a camera).
Iterable<DeviceOrientation> get supportedOrientations =>
DeviceOrientation.values;
// === navigation ===
/// The host that pushed this screen — set by the host on push.
/// Mirrors UIKit's `UIViewController.navigationController`.
/// `null` until pushed.
///
/// Pages typically don't read this directly; use [host] instead,
/// which is the right target for "push another page from here".
ScreenHost? parentHost;
/// The host that pushes from inside this screen's body should
/// target. Defaults to [parentHost] — regular screens push
/// siblings onto their own host. `SheetScreen` overrides this to
/// route into its own nested stack so pages pushed from inside a
/// sheet body land within the sheet rather than escaping it.
ScreenHost? get host => parentHost;
/// Whether the back button / swipe should pop this page.
bool get canPop => true;
/// Custom back-button handling. Return `true` to consume the event.
bool handleBack() => false;
/// Called after the page is pushed onto the router.
void onPush() {}
/// Called when this screen becomes the topmost visible screen in the
/// router. Fires once after push, and again whenever it becomes topmost
/// after a screen above it pops. Always paired with a later [onInactive].
void onActive() {}
/// Called when another screen is pushed on top, or right before this one
/// is popped. Mirror of [onActive].
void onInactive() {}
// === pop lifecycle ===
/// Whether this page has been popped (explicitly removed).
bool popped = false;
/// Set by the router when pushing. Called during [pop] to remove the page
/// from the router stack and notify the delegate.
VoidCallback? detach;
/// Set by the router when pushing. Called from [onRemoved] after the exit
/// animation completes for final cleanup (disposal).
VoidCallback? removed;
Completer<T?>? _completer;
/// A future that completes with the result when the page is popped.
Future<T?> get future {
_completer ??= Completer<T?>();
return _completer!.future;
}
/// Remove this page. Marks as popped, detaches from the router,
/// and completes the [future] with [result].
void pop([T? result]) {
popped = true;
onDetach();
if (_completer?.isCompleted != true) {
_completer?.complete(result);
}
}
/// Called during [pop] before the future is completed.
/// Override for cleanup that must happen before the exit animation.
@mustCallSuper
void onDetach() {
detach?.call();
detach = null;
}
/// Called after the exit animation completes and the page is fully removed.
/// Override for cleanup that should happen after the page is off screen.
@mustCallSuper
void onRemoved() {
if (popped) {
removed?.call();
removed = null;
}
}
}

View File

@@ -0,0 +1,15 @@
import 'package:ux/src/navi/screen.dart';
/// A target for [Screen]-level pushes. Implementations include
/// [XRouter] (the global navigator) and `_SheetNestedHost` (the
/// inner stack of a `SheetScreen`).
///
/// Mirrors UIKit's pattern where every `UIViewController` knows its
/// `navigationController`: pages route via the host they were pushed
/// onto rather than walking ancestor stacks or looking up global
/// state.
abstract class ScreenHost {
/// Push [page] onto this host's stack. Future completes when
/// [page] is popped (with the popped result, or `null` on dismiss).
Future<R?> push<R>(Screen<R> page);
}

View File

@@ -0,0 +1,427 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show PredictiveBackEvent;
import 'package:ux/src/core/functional.dart';
import 'package:ux/src/navi/hero.dart';
import 'package:ux/src/navi/screen.dart';
/// A declarative screen stack with animated transitions.
///
/// Two slots with different reconciliation contracts:
///
/// - [home] — `Widget.canUpdate`-style match (`runtimeType` +
/// [Screen.key]). A fresh instance of the same type/key updates the
/// home slot in place; State below is preserved. Different type or
/// key tears it down. Home never animates in/out, never pops.
/// - [stack] — `==`-based diff. Equal Screens reorder, distinct
/// Screens get new slots with enter transitions and pop animations.
class ScreenStack extends StatefulWidget {
const ScreenStack({
super.key,
required this.home,
this.stack = const [],
this.onRemoved,
});
final Screen home;
final List<Screen> stack;
final ValueChanged<Screen>? onRemoved;
@override
State<ScreenStack> createState() => ScreenStackState();
static ScreenStackState of(BuildContext context) {
return context.findAncestorStateOfType<ScreenStackState>()!;
}
static ScreenStackState? maybeOf(BuildContext context) {
return context.findAncestorStateOfType<ScreenStackState>();
}
}
class ScreenStackState extends State<ScreenStack> with TickerProviderStateMixin {
late ScreenSlot _homeSlot;
final List<ScreenSlot> _entries = [];
final ScreenHeroController heroController = ScreenHeroController();
final FocusScopeNode _outerScope = FocusScopeNode(debugLabel: 'ScreenStack');
bool _canPop = false;
ScreenSlot? _focusedSlot;
@override
void initState() {
super.initState();
_homeSlot = _createHomeSlot(widget.home);
for (final page in widget.stack) {
_entries.add(_createEntry(page, animated: false));
}
_updateCanPop();
_scheduleTopFocusHandoff();
}
/// Hand focus to the current top slot's scope on the next frame.
/// Mirrors what [Navigator] does via `setFirstFocus` on push: without
/// it, a freshly-pushed page's `autofocus: true` TextField ends up
/// focused but never becomes the active scope, so the keyboard token
/// is never consumed and the system keyboard doesn't open.
void _scheduleTopFocusHandoff() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final top = _topSlot;
if (top == _focusedSlot) return;
_focusedSlot = top;
_outerScope.setFirstFocus(top.focusScope);
});
}
void _updateCanPop() {
final canPop = _entries.any((e) => !e.removing);
if (canPop == _canPop) return;
_canPop = canPop;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) NavigationNotification(canHandlePop: canPop).dispatch(context);
});
}
@override
void didUpdateWidget(ScreenStack oldWidget) {
super.didUpdateWidget(oldWidget);
_updateHome(oldWidget.home, widget.home);
_diffStack(oldWidget.stack, widget.stack);
}
void _updateHome(Screen oldHome, Screen newHome) {
if (identical(oldHome, newHome)) return;
if (oldHome.runtimeType == newHome.runtimeType &&
oldHome.key == newHome.key) {
_homeSlot.page = newHome;
return;
}
final old = _homeSlot;
old.controller.dispose();
old.focusScope.dispose();
if (identical(_focusedSlot, old)) _focusedSlot = null;
_homeSlot = _createHomeSlot(newHome);
_scheduleTopFocusHandoff();
}
void _diffStack(List<Screen> oldStack, List<Screen> newStack) {
final oldSet = <Screen>{...oldStack};
final newSet = <Screen>{...newStack};
final hasChanges = oldStack.length != newStack.length ||
oldSet.any((p) => !newSet.contains(p)) ||
newSet.any((p) => !oldSet.contains(p));
// Snapshot only on actual mutation; otherwise every host-driven
// rebuild would schedule a post-frame flight check.
final heroSnapshot = hasChanges ? heroController.snapshot() : null;
for (final entry in _entries.toList()) {
if (entry.removing) continue;
if (!newSet.contains(entry.page)) {
_removeEntry(entry);
}
}
for (final page in newStack) {
if (!oldSet.contains(page)) {
_entries.add(_createEntry(page, animated: true));
}
}
// Reorder to match newStack, keeping removing entries in their
// original relative position so an exiting child stays above its
// parent, not behind it.
final ordered = <ScreenSlot>[];
for (final page in newStack) {
ordered.add(_entries.firstWhere((e) => e.page == page && !e.removing));
}
final result = <ScreenSlot>[];
int oi = 0;
for (final entry in _entries) {
if (entry.removing) {
result.add(entry);
} else if (oi < ordered.length) {
result.add(ordered[oi++]);
}
}
while (oi < ordered.length) {
result.add(ordered[oi++]);
}
_entries
..clear()
..addAll(result);
if (heroSnapshot != null && heroSnapshot.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final topEntry = _entries.lastWhereOrNull((e) => !e.removing);
if (topEntry == null) return;
final overlay = Overlay.maybeOf(context);
if (overlay == null) return;
heroController.maybeStartFlights(
before: heroSnapshot,
animation: topEntry.controller,
overlay: overlay,
);
});
}
_updateCanPop();
_scheduleTopFocusHandoff();
setState(() {});
}
ScreenSlot _createHomeSlot(Screen home) {
final controller = AnimationController(
vsync: this,
duration: Duration.zero,
value: 1.0,
);
return ScreenSlot(page: home, controller: controller);
}
ScreenSlot _createEntry(Screen page, {required bool animated}) {
final controller = AnimationController(
vsync: this,
duration: page.transitionDuration,
value: animated ? 0.0 : 1.0,
);
final entry = ScreenSlot(page: page, controller: controller);
if (animated) controller.forward();
return entry;
}
void _removeEntry(ScreenSlot entry) {
entry.removing = true;
entry.controller.reverse().then((_) {
if (!mounted) return;
_entries.remove(entry);
entry.controller.dispose();
entry.focusScope.dispose();
if (_focusedSlot == entry) _focusedSlot = null;
entry.page.onRemoved();
widget.onRemoved?.call(entry.page);
_scheduleTopFocusHandoff();
setState(() {});
});
}
@override
void dispose() {
_homeSlot.controller.dispose();
_homeSlot.focusScope.dispose();
for (final entry in _entries) {
entry.controller.dispose();
entry.focusScope.dispose();
}
_outerScope.dispose();
super.dispose();
}
/// Topmost live slot — the last non-removing pushed entry if any,
/// else home. Used for focus handoff.
ScreenSlot get _topSlot =>
_entries.lastWhereOrNull((e) => !e.removing) ?? _homeSlot;
@override
Widget build(BuildContext context) {
final top = _entries.lastWhereOrNull((e) => !e.removing);
return Actions(
actions: {
DismissIntent: CallbackAction<DismissIntent>(
onInvoke: (_) {
final focus = FocusManager.instance.primaryFocus;
if (focus != null && focus.context != null &&
focus.context!.findAncestorWidgetOfExactType<EditableText>() != null) {
focus.unfocus();
return null;
}
if (top != null && top.page.isModal) {
top.page.pop();
}
return null;
},
),
},
child: FocusScope(
node: _outerScope,
child: Stack(
fit: StackFit.passthrough,
children: [
// Anchored on the slot's GlobalKey so a slot replacement
// unmounts the old subtree — without it, Flutter would
// reconcile inner widgets by type and preserve their State.
KeyedSubtree(key: _homeSlot.key, child: _homeSlot.build(context)),
for (final entry in _entries)
ScreenBackHandler(
key: entry.key,
entry: entry,
enabled: entry == top && _canPop,
onPop: () => entry.page.pop(),
),
],
),
),
);
}
}
/// A slot in the [ScreenStack] that holds a [Screen] and manages its
/// transition animation.
class ScreenSlot {
ScreenSlot({required this.page, required this.controller});
Screen page;
final AnimationController controller;
final key = GlobalKey();
final contentKey = GlobalKey();
// Each slot owns its own FocusScope so pushed pages start with a clean
// focus state — the outer ScreenStack scope calls `setFirstFocus` on this
// node when the slot becomes top, matching Navigator's per-route scope
// handoff. Without per-slot scopes, a new page's `autofocus: true`
// TextField silently no-ops (the outgoing page's focus still occupies the
// shared scope's `_focusedChildren`), and without `setFirstFocus` the
// keyboard token is never consumed so no keyboard appears.
final focusScope = FocusScopeNode();
bool removing = false;
Widget build(BuildContext context) {
final child = page is Listenable
? ListenableBuilder(
listenable: page as Listenable,
builder: (context, _) => page.buildPresenter(context),
)
: page.buildPresenter(context);
return AnimatedBuilder(
animation: controller,
child: FocusScope(node: focusScope, child: child),
builder: (context, child) =>
page.buildTransition(context, controller, child!),
);
}
}
/// Wraps the top screen entry and handles Android predictive back gestures.
/// Drives the entry's [AnimationController] in response to gesture progress.
class ScreenBackHandler extends StatefulWidget {
const ScreenBackHandler({
super.key,
required this.entry,
required this.enabled,
required this.onPop,
});
final ScreenSlot entry;
final bool enabled;
final VoidCallback onPop;
@override
State<ScreenBackHandler> createState() => ScreenBackHandlerState();
}
class ScreenBackHandlerState extends State<ScreenBackHandler>
with WidgetsBindingObserver {
bool _gestureInProgress = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
bool handleStartBackGesture(PredictiveBackEvent backEvent) {
if (!widget.enabled || backEvent.isButtonEvent) return false;
_gestureInProgress = true;
widget.entry.controller.value = 1.0 - backEvent.progress;
return true;
}
@override
void handleUpdateBackGestureProgress(PredictiveBackEvent backEvent) {
if (!_gestureInProgress) return;
widget.entry.controller.value = 1.0 - backEvent.progress;
}
@override
void handleCommitBackGesture() {
if (!_gestureInProgress) return;
_gestureInProgress = false;
widget.onPop();
}
@override
void handleCancelBackGesture() {
if (!_gestureInProgress) return;
_gestureInProgress = false;
widget.entry.controller.animateTo(1.0,
duration: const Duration(milliseconds: 150), curve: Curves.easeOut);
}
// ── iOS edge swipe ──
bool _swiping = false;
double _swipeStart = 0;
static const _edgeWidth = 40.0;
void _onHorizontalDragUpdate(DragUpdateDetails d) {
if (!widget.enabled) return;
if (!_swiping) {
_swiping = true;
_swipeStart = 0;
}
final width = context.size?.width ?? 400;
final progress = ((d.localPosition.dx - _swipeStart) / width).clamp(0.0, 1.0);
widget.entry.controller.value = 1.0 - progress;
}
void _onHorizontalDragEnd(DragEndDetails d) {
if (!_swiping) return;
_swiping = false;
if (widget.entry.controller.value < 0.5 || d.velocity.pixelsPerSecond.dx > 300) {
widget.onPop();
} else {
widget.entry.controller.animateTo(1.0,
duration: const Duration(milliseconds: 150), curve: Curves.easeOut);
}
}
bool get _isIOSLike {
final platform = Theme.of(context).platform;
return platform == TargetPlatform.iOS || platform == TargetPlatform.macOS;
}
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.passthrough,
children: [
widget.entry.build(context),
// Left edge swipe strip — sits on top to catch horizontal drags
// without competing with the page's scroll views.
if (_isIOSLike && widget.enabled && widget.entry.page.canSwipeBack)
Positioned(
left: 0,
top: 0,
bottom: 0,
width: _edgeWidth,
child: GestureDetector(
onHorizontalDragUpdate: _onHorizontalDragUpdate,
onHorizontalDragEnd: _onHorizontalDragEnd,
behavior: HitTestBehavior.translucent,
),
),
],
);
}
}

View File

@@ -0,0 +1,182 @@
import 'package:flutter/widgets.dart';
import 'package:ux/src/core/dispose.dart';
import 'package:ux/src/core/list_emitter.dart';
import 'package:ux/src/navi/screen.dart';
import 'package:ux/src/navi/screen_host.dart';
import 'package:ux/src/navi/screen_stack.dart';
import 'package:ux/src/navi/transitions.dart';
import 'package:ux/src/anim/sheet.dart';
/// Screen mixin that presents its content as a half-sheet with full
/// telegram-style physics (drag-to-expand, drag-to-dismiss with spring
/// snap, scroll coordination via [SheetController]) and a nested
/// navigation stack inside the modal — same pattern as telegram-iOS's
/// `AttachmentController` hosting a `NavigationContainer`.
///
/// The screen-stack's transition animation is set to instant
/// (`ScreenTransitions.none`) — the [Sheet] widget self-manages its
/// own slide-up entry / exit using its internal animation controller.
/// This keeps the route layer thin while preserving the full physics
/// model from `~/projects/ciao/app/lib/widgets/sheet.dart`.
///
/// Inside the sheet, [host] resolves to this sheet's own
/// [nestedHost], so `host?.push(SomePage())` from the body lands in
/// a nested `ScreenStack` rendered inside the sheet's bounds (slides
/// in from the right). The original parent host that pushed this
/// sheet is reachable as [parentHost] for rare escape cases.
///
/// Typical use:
///
/// ```dart
/// class MyPage with Screen<Result>, SheetScreen<Result>, Emitter {
/// @override
/// double get sheetCollapsedExtent => 0.55;
///
/// @override
/// Widget buildSheetBody(BuildContext context) => /* page content */;
/// }
///
/// // Pushing within the sheet:
/// final picked = await host?.push(SubPage());
/// ```
mixin SheetScreen<T> on Screen<T> {
/// Initial height as a fraction of the viewport (0.01.0).
/// Defaults to 0.55 — telegram's standard half-sheet.
double get sheetCollapsedExtent => 0.55;
/// Whether the backdrop tap and drag-down past threshold dismiss
/// the sheet. When false, only programmatic [pop] closes it.
bool get sheetIsDismissible => true;
/// When true, the sheet enters at full screen and never collapses.
/// Drag-to-dismiss is gated to a higher distance threshold.
bool get sheetIsFullSize => false;
/// Backdrop scrim color. Sheet fades alpha from 0 (offscreen) to
/// `barrierColor.a` (fully collapsed).
Color get sheetBarrierColor => const Color(0x8C000000);
/// Sheet background. Override for theme-driven colors.
Color? get sheetBackgroundColor => null;
/// Top-corner radius. Bottom corners shrink toward 0 as the sheet
/// expands.
double get sheetBorderRadius => 16.0;
/// Scroll-coordination controller. Inner scroll views attach via
/// `SheetController.attach(scrollController)` and use
/// `controller.physics` so the sheet drag and the list scroll
/// hand off cleanly at scroll-top.
late final SheetController sheetController = SheetController();
/// Override to provide the sheet's content. Renders below the
/// drag-handle pill (`SheetThumb`) as the home of the sheet's
/// nested navigation stack.
Widget buildSheetBody(BuildContext context);
/// Inner navigation host for this sheet's nested stack. Pushes
/// from inside the sheet body land here.
late final _SheetNestedHost _nestedHost = _SheetNestedHost();
/// Public-typed view of the sheet's nested host.
ScreenHost get nestedHost => _nestedHost;
// ── Screen<T> overrides ────────────────────────────────────────────
/// Pushes from inside this sheet body target the nested stack,
/// not the parent host that pushed the sheet. Use [parentHost]
/// (inherited from `Screen`) to escape the modal — rare.
@override
ScreenHost? get host => _nestedHost;
@override
Widget buildTransition(
BuildContext context,
Animation<double> animation,
Widget child,
) =>
ScreenTransitions.none(context, animation, child);
/// Zero-duration route transition — the [Sheet] widget self-manages
/// its slide-up entry via its own internal animation controller.
@override
Duration get transitionDuration => Duration.zero;
@override
Widget buildPresenter(BuildContext context) {
return Sheet(
controller: sheetController,
collapsedExtent: sheetCollapsedExtent,
isFullSize: sheetIsFullSize,
isDismissible: sheetIsDismissible,
barrierColor: sheetBarrierColor,
backgroundColor: sheetBackgroundColor,
borderRadius: sheetBorderRadius,
onDismiss: pop,
// Fresh `_SheetBody` per rebuild is fine — `ScreenStack`
// reconciles `home` by type+key.
child: ListenableBuilder(
listenable: _nestedHost.pages,
builder: (context, _) => ScreenStack(
home: _SheetBody(this),
stack: [..._nestedHost.pages],
),
),
);
}
/// System back / iOS edge swipe pops the innermost nested page
/// first. With the nested stack empty, falls through to the
/// default `handleBack` (which pops the sheet itself).
@override
bool handleBack() {
if (_nestedHost.pages.isNotEmpty) {
_nestedHost.pages.last.pop();
return true;
}
return super.handleBack();
}
@override
void onRemoved() {
super.onRemoved();
// Tear down any still-mounted nested entries when the sheet is
// dismissed (drag, backdrop tap, programmatic pop).
for (final page in _nestedHost.pages.toList()) {
page.pop();
}
_nestedHost.dispose();
}
}
class _SheetNestedHost with Dispose implements ScreenHost {
final pages = ListEmitter<Screen>();
@override
Future<R?> push<R>(Screen<R> page) {
page.parentHost = this;
page.detach = () => pages.removeWhere((p) => identical(p, page));
page.removed = () => Dispose.object(page);
page.onPush();
pages.add(page);
return page.future;
}
@override
void dispose() {
pages.dispose();
super.dispose();
}
}
/// Adapter that exposes [SheetScreen.buildSheetBody] as the nested
/// `ScreenStack`'s `home`.
class _SheetBody with Screen<void> {
_SheetBody(this.sheet);
final SheetScreen sheet;
@override
Widget buildPresenter(BuildContext context) =>
sheet.buildSheetBody(context);
}

View File

@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
/// Built-in transition builders for use in [Screen.buildTransition].
abstract final class ScreenTransitions {
/// Slide from right (push) / slide to right (pop).
static Widget platformSlide(
BuildContext context,
Animation<double> animation,
Widget child,
) {
final position = Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut));
return SlideTransition(position: position, child: child);
}
/// Simple opacity fade.
static Widget fade(
BuildContext context,
Animation<double> animation,
Widget child,
) {
return FadeTransition(
opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut),
child: child,
);
}
/// Slide up from the bottom edge.
static Widget slideUp(
BuildContext context,
Animation<double> animation,
Widget child,
) {
final position = Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut));
return SlideTransition(position: position, child: child);
}
/// Fade on wide screens, slide on narrow. Stable widget tree — no
/// unmount/remount when the screen width crosses the threshold.
static Widget responsive(
BuildContext context,
Animation<double> animation,
Widget child, {
double breakpoint = 600,
}) {
final wide = MediaQuery.sizeOf(context).width >= breakpoint;
final curved = CurvedAnimation(parent: animation, curve: Curves.easeOut);
final isExiting = animation.status == AnimationStatus.reverse;
final position = Tween<Offset>(
begin: wide ? Offset.zero : const Offset(1, 0),
end: Offset.zero,
).animate(curved);
return FadeTransition(
opacity: wide && !isExiting ? curved : const AlwaysStoppedAnimation(1.0),
child: SlideTransition(position: position, child: child),
);
}
/// No animation — instant swap.
static Widget none(
BuildContext context,
Animation<double> animation,
Widget child,
) => child;
}