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

View File

@@ -1,9 +1,9 @@
### 0.9.0 ### 0.9.0
- `UxScanner`: new platform-view widget for QR-code (and forward-compat - `XScanner`: new platform-view widget for QR-code (and forward-compat
barcode) scanning. iOS uses `AVCaptureMetadataOutput` (no extra pod); barcode) scanning. iOS uses `AVCaptureMetadataOutput` (no extra pod);
Android uses CameraX preview + ZXing decoder Android uses CameraX preview + ZXing decoder
(`com.google.zxing:core:3.5.3`, ~470 KB jar, no Play Services dep). (`com.google.zxing:core:3.5.3`, ~470 KB jar, no Play Services dep).
`UxScannerPermission.requestCamera()` requests OS permission first; `XScannerPermission.requestCamera()` requests OS permission first;
decoded payloads stream through `EventChannel('ux/scanner/events')`. decoded payloads stream through `EventChannel('ux/scanner/events')`.
### 0.8.0 ### 0.8.0
@@ -17,7 +17,7 @@
log-then-rethrow is deduped via an `Expando` mark so crash handlers don't log-then-rethrow is deduped via an `Expando` mark so crash handlers don't
double-report. Override the `captureCrashes` hook to customise or pass double-report. Override the `captureCrashes` hook to customise or pass
`() {}` to opt out. `() {}` to opt out.
- `UxKeyboard`: adaptive-learning debug output now uses `Log.tag('KB').d` - `XKeyboard`: adaptive-learning debug output now uses `Log.tag('KB').d`
instead of `print`, lazy-built so the formatted line is only constructed instead of `print`, lazy-built so the formatted line is only constructed
when debug level is enabled. when debug level is enabled.
@@ -30,11 +30,11 @@
order; every other op is O(1). order; every other op is O(1).
### 0.6.0 ### 0.6.0
- `UxFile`: new module for handing files to the OS - `XFile`: new module for handing files to the OS
- `UxFile.share(path, {title, mimeType, sourceRect})` — iOS `UIActivityViewController`, - `XFile.share(path, {title, mimeType, sourceRect})` — iOS `UIActivityViewController`,
macOS `NSSharingServicePicker`, Android `Intent.ACTION_SEND` via FileProvider. macOS `NSSharingServicePicker`, Android `Intent.ACTION_SEND` via FileProvider.
`sourceRect` anchors the popover on iPad / macOS. `sourceRect` anchors the popover on iPad / macOS.
- `UxFile.open(path, {mimeType})` — in-app preview where possible: - `XFile.open(path, {mimeType})` — in-app preview where possible:
iOS `QLPreviewController`, macOS `QLPreviewPanel` (preview on top of the app, iOS `QLPreviewController`, macOS `QLPreviewPanel` (preview on top of the app,
no foreground loss), Android `Intent.ACTION_VIEW` with MIME inference from no foreground loss), Android `Intent.ACTION_VIEW` with MIME inference from
`MimeTypeMap` + a fallback set of text/code extensions so unknown types `MimeTypeMap` + a fallback set of text/code extensions so unknown types
@@ -44,14 +44,14 @@
- Android: ships a FileProvider under `${applicationId}.ux.fileprovider` - Android: ships a FileProvider under `${applicationId}.ux.fileprovider`
scoped to `ux_share/` in the app cache — host apps don't need manifest scoped to `ux_share/` in the app cache — host apps don't need manifest
plumbing. plumbing.
- iOS: `UxWindow` helper (`keyWindow` / `topViewController`) in - iOS: `XWindow` helper (`keyWindow` / `topViewController`) in
`NativePlugin.swift`, shared between keyboard and file plugins. `NativePlugin.swift`, shared between keyboard and file plugins.
### 0.5.0 ### 0.5.0
- `UxSensor.orientation`: accelerometer-driven physical device rotation, - `XSensor.orientation`: accelerometer-driven physical device rotation,
independent of the OS auto-rotate setting or UI orientation lock independent of the OS auto-rotate setting or UI orientation lock
- `AppInfo`: version + buildNumber surface - `AppInfo`: version + buildNumber surface
- `UxKeyboard`: focus tracking integration - `XKeyboard`: focus tracking integration
### 0.4.0 ### 0.4.0
- `package:ux/testing.dart`: new entry point for test-only utilities - `package:ux/testing.dart`: new entry point for test-only utilities
@@ -63,17 +63,17 @@
`flutter test --update-goldens` regenerates text goldens too. `flutter test --update-goldens` regenerates text goldens too.
### 0.3.0 ### 0.3.0
- `UxKeyboard`: fix interactive dismiss race conditions — keyboard height no longer - `XKeyboard`: fix interactive dismiss race conditions — keyboard height no longer
gets stuck when rapidly dismissing and re-focusing gets stuck when rapidly dismissing and re-focusing
- `UxKeyboard`: handle zero-duration keyboard notifications (instant snap) - `XKeyboard`: handle zero-duration keyboard notifications (instant snap)
- `UxKeyboard`: gate adaptive learning debug output behind `kDebugMode` - `XKeyboard`: gate adaptive learning debug output behind `kDebugMode`
- Example: rewrite as chat UI demonstrating `ListenableBuilder`, scroll freeze, - Example: rewrite as chat UI demonstrating `ListenableBuilder`, scroll freeze,
and interactive dismiss and interactive dismiss
- Example: modernize Android project (v2 embedding, AGP 8.7, Gradle 8.11) - Example: modernize Android project (v2 embedding, AGP 8.7, Gradle 8.11)
### 0.2.0 ### 0.2.0
- `UxKeyboard`: sampled native animation curves (iOS & Android) with adaptive learning - `XKeyboard`: sampled native animation curves (iOS & Android) with adaptive learning
- `UxKeyboard`: interactive dismiss via pan gesture - `XKeyboard`: interactive dismiss via pan gesture
- Android: keyboard height tracking via JNI/FFI bridge - Android: keyboard height tracking via JNI/FFI bridge
### 0.1.1 ### 0.1.1

View File

@@ -2,12 +2,12 @@
A Flutter toolkit for building fluid, native-feeling UIs. A Flutter toolkit for building fluid, native-feeling UIs.
## UxKeyboard ## XKeyboard
Frame-accurate keyboard height tracking for iOS and Android, with interactive dismiss. Frame-accurate keyboard height tracking for iOS and Android, with interactive dismiss.
Flutter's built-in `MediaQuery.viewInsets.bottom` lags behind the actual keyboard position Flutter's built-in `MediaQuery.viewInsets.bottom` lags behind the actual keyboard position
and doesn't support interactive dismiss. `UxKeyboard` reads the keyboard height directly and doesn't support interactive dismiss. `XKeyboard` reads the keyboard height directly
from the native layer via FFI — zero channel latency, every frame. from the native layer via FFI — zero channel latency, every frame.
### Features ### Features
@@ -21,7 +21,7 @@ from the native layer via FFI — zero channel latency, every frame.
### Quick start ### Quick start
```dart ```dart
final keyboard = UxKeyboard.instance; final keyboard = XKeyboard.instance;
// Enable swipe-to-dismiss. trackingInset is the height of your input bar. // Enable swipe-to-dismiss. trackingInset is the height of your input bar.
keyboard.enableInteractiveDismiss(trackingInset: 56); keyboard.enableInteractiveDismiss(trackingInset: 56);
@@ -66,7 +66,7 @@ Scaffold(
| Member | Description | | Member | Description |
|---|---| |---|---|
| `UxKeyboard.instance` | Singleton instance | | `XKeyboard.instance` | Singleton instance |
| `.height` | Current keyboard height in logical pixels | | `.height` | Current keyboard height in logical pixels |
| `.systemHeight` | Last system-reported keyboard height | | `.systemHeight` | Last system-reported keyboard height |
| `.isOpen` | Whether the keyboard is visible | | `.isOpen` | Whether the keyboard is visible |
@@ -78,7 +78,7 @@ Scaffold(
### Key points ### Key points
- Set `resizeToAvoidBottomInset: false` on your `Scaffold` — otherwise Flutter's built-in - Set `resizeToAvoidBottomInset: false` on your `Scaffold` — otherwise Flutter's built-in
resize fights with `UxKeyboard` resize fights with `XKeyboard`
- Use `MediaQuery.viewPaddingOf(context).bottom` for the safe area (not `paddingOf`, which - Use `MediaQuery.viewPaddingOf(context).bottom` for the safe area (not `paddingOf`, which
is consumed by `Scaffold`) is consumed by `Scaffold`)
- Use `max(keyboardHeight, safeBottom)` for bottom padding — the keyboard height includes - Use `max(keyboardHeight, safeBottom)` for bottom padding — the keyboard height includes

View File

@@ -1,4 +1,4 @@
// Native data detection for UxUrl on Android. Synchronous, callable via dart:ffi. // Native data detection for XUrl on Android. Synchronous, callable via dart:ffi.
// //
// Exports two symbols: // Exports two symbols:
// uint8_t* ux_match_url(const uint16_t* utf16, int32_t len, int32_t* out_size); // uint8_t* ux_match_url(const uint16_t* utf16, int32_t len, int32_t* out_size);
@@ -24,7 +24,7 @@
#include <string> #include <string>
#include <vector> #include <vector>
#define UX_LOG_TAG "UxUrl" #define UX_LOG_TAG "XUrl"
#define UX_LOGE(...) __android_log_print(ANDROID_LOG_ERROR, UX_LOG_TAG, __VA_ARGS__) #define UX_LOGE(...) __android_log_print(ANDROID_LOG_ERROR, UX_LOG_TAG, __VA_ARGS__)
namespace { namespace {

View File

@@ -26,7 +26,7 @@ import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
/// `MediaStore` bridge for `UxGallery` — paginated photo + video /// `MediaStore` bridge for `XGallery` — paginated photo + video
/// queries via `ContentResolver`, cell-sized thumbnails via /// queries via `ContentResolver`, cell-sized thumbnails via
/// `ContentResolver.loadThumbnail` (API 29+), and stream-copy file /// `ContentResolver.loadThumbnail` (API 29+), and stream-copy file
/// resolution into the app cache so `dart:io` can read what the /// resolution into the app cache so `dart:io` can read what the

View File

@@ -14,7 +14,7 @@ class UrlPlugin : NativePlugin, MethodChannel.MethodCallHandler {
init { init {
// Trigger JNI_OnLoad in libux.so so the FFI shim's // Trigger JNI_OnLoad in libux.so so the FFI shim's
// android.util.Patterns bindings are cached before the first // android.util.Patterns bindings are cached before the first
// UxUrl.match call from Dart. // XUrl.match call from Dart.
System.loadLibrary("ux") System.loadLibrary("ux")
} }
} }

View File

@@ -5,7 +5,7 @@ import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.swipelab.ux.camera.CameraPlugin import io.swipelab.ux.camera.CameraPlugin
class UxPlugin : FlutterPlugin, ActivityAware { class XPlugin : FlutterPlugin, ActivityAware {
private val plugins: List<NativePlugin> = listOf( private val plugins: List<NativePlugin> = listOf(
KeyboardPlugin(), KeyboardPlugin(),
SensorPlugin(), SensorPlugin(),

View File

@@ -13,7 +13,7 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner
import io.flutter.view.TextureRegistry import io.flutter.view.TextureRegistry
/// One per `UxCameraController` on the Dart side. Owns CameraX's /// One per `XCameraController` on the Dart side. Owns CameraX's
/// [ProcessCameraProvider] binding, the [PreviewSink] (texture + /// [ProcessCameraProvider] binding, the [PreviewSink] (texture +
/// SurfaceProvider), the [PhotoCapture] use-case, and the per-instance /// SurfaceProvider), the [PhotoCapture] use-case, and the per-instance
/// [CustomLifecycleOwner] that drives them. /// [CustomLifecycleOwner] that drives them.

View File

@@ -32,7 +32,7 @@ class VideoCapture {
val useCase: androidx.camera.video.VideoCapture<Recorder> = val useCase: androidx.camera.video.VideoCapture<Recorder> =
androidx.camera.video.VideoCapture.Builder(recorder) androidx.camera.video.VideoCapture.Builder(recorder)
// Raw sensor capture — selfie videos are "as others see // Raw sensor capture — selfie videos are "as others see
// you"; the preview-only mirror lives in UxCameraPreview. // you"; the preview-only mirror lives in XCameraPreview.
.setMirrorMode(MirrorMode.MIRROR_MODE_OFF) .setMirrorMode(MirrorMode.MIRROR_MODE_OFF)
.build() .build()

View File

@@ -6,7 +6,7 @@ import FlutterMacOS
#endif #endif
import Foundation import Foundation
/// One per `UxCameraController` on the Dart side. Owns its /// One per `XCameraController` on the Dart side. Owns its
/// `AVCaptureSession`, the texture-backed preview pipeline, photo /// `AVCaptureSession`, the texture-backed preview pipeline, photo
/// output, audio + video data outputs, and the /// output, audio + video data outputs, and the
/// [VideoRecorder] when a recording is in flight. Multiple instances /// [VideoRecorder] when a recording is in flight. Multiple instances
@@ -526,7 +526,7 @@ final class CameraInstance {
// front camera raw sensor feed at capture, mirror as a // front camera raw sensor feed at capture, mirror as a
// playback decision. // playback decision.
if let videoConn = videoDataOutput?.connection(with: .video) { if let videoConn = videoDataOutput?.connection(with: .video) {
videoConn.applyUxCaptureOrientation( videoConn.applyXCaptureOrientation(
lockedOrientation ?? orientation.current lockedOrientation ?? orientation.current
) )
if videoConn.isVideoMirroringSupported { if videoConn.isVideoMirroringSupported {
@@ -558,7 +558,7 @@ final class CameraInstance {
private func applyVideoOrientationOnPreview(_ next: DeviceOrientationFlutter) { private func applyVideoOrientationOnPreview(_ next: DeviceOrientationFlutter) {
videoDataOutput?.connection(with: .video)? videoDataOutput?.connection(with: .video)?
.applyUxCaptureOrientation(next) .applyXCaptureOrientation(next)
} }
private func emit(_ extras: [String: Any]) { private func emit(_ extras: [String: Any]) {

View File

@@ -108,7 +108,7 @@ enum CaptureDevice {
/// One row of the discovery result. `lens` and `sensorOrientation` /// One row of the discovery result. `lens` and `sensorOrientation`
/// match the wire shape expected by Dart's /// match the wire shape expected by Dart's
/// `MethodChannelUxCameraBackend.availableCameras()`. /// `MethodChannelXCameraBackend.availableCameras()`.
struct DiscoveredCamera { struct DiscoveredCamera {
let device: AVCaptureDevice let device: AVCaptureDevice
let lens: String let lens: String

View File

@@ -18,7 +18,7 @@ public enum DeviceOrientationFlutter: String {
case landscapeRight case landscapeRight
/// Parse a wire string. Returns `.portraitUp` for unknown inputs /// Parse a wire string. Returns `.portraitUp` for unknown inputs
/// (matches the Dart-side fallback in `MethodChannelUxCameraBackend`). /// (matches the Dart-side fallback in `MethodChannelXCameraBackend`).
public static func parse(_ raw: String?) -> DeviceOrientationFlutter { public static func parse(_ raw: String?) -> DeviceOrientationFlutter {
return DeviceOrientationFlutter(rawValue: raw ?? "") ?? .portraitUp return DeviceOrientationFlutter(rawValue: raw ?? "") ?? .portraitUp
} }

View File

@@ -49,7 +49,7 @@ final class PhotoOutput {
// JPEG); macOS is a no-op (desktop cams are physically // JPEG); macOS is a no-op (desktop cams are physically
// landscape, any rotation skews the photo). See // landscape, any rotation skews the photo). See
// `AVCaptureConnection+iOS.swift` / `+macOS.swift`. // `AVCaptureConnection+iOS.swift` / `+macOS.swift`.
connection.applyUxCaptureOrientation(orientation) connection.applyXCaptureOrientation(orientation)
// The recorded photo carries no mirror; mirroring is a // The recorded photo carries no mirror; mirroring is a
// preview-only concern. // preview-only concern.
if connection.isVideoMirroringSupported { if connection.isVideoMirroringSupported {
@@ -79,7 +79,7 @@ final class PhotoOutput {
// snapshot defaults cleanly. No-op on macOS (the // snapshot defaults cleanly. No-op on macOS (the
// extension method is empty there). // extension method is empty there).
self?.avOutput.connection(with: .video)? self?.avOutput.connection(with: .video)?
.applyUxCaptureOrientation(.portraitUp) .applyXCaptureOrientation(.portraitUp)
self?.inFlight = nil self?.inFlight = nil
DispatchQueue.main.async { completion(result) } DispatchQueue.main.async { completion(result) }
} }

View File

@@ -8,7 +8,7 @@ void main() => runApp(MaterialApp(
home: ChatScreen(), home: ChatScreen(),
)); ));
/// Demonstrates UxKeyboard in a chat UI: /// Demonstrates XKeyboard in a chat UI:
/// - Frame-accurate keyboard height tracking (no Flutter viewInsets lag) /// - Frame-accurate keyboard height tracking (no Flutter viewInsets lag)
/// - Interactive dismiss (swipe the keyboard down like iMessage) /// - Interactive dismiss (swipe the keyboard down like iMessage)
/// - Scroll freeze while the user is panning the keyboard /// - Scroll freeze while the user is panning the keyboard
@@ -20,7 +20,7 @@ class ChatScreen extends StatefulWidget {
} }
class _ChatScreenState extends State<ChatScreen> { class _ChatScreenState extends State<ChatScreen> {
final _keyboard = UxKeyboard.instance; final _keyboard = XKeyboard.instance;
final _textController = TextEditingController(); final _textController = TextEditingController();
final _messages = List.generate( final _messages = List.generate(
30, 30,
@@ -54,8 +54,8 @@ class _ChatScreenState extends State<ChatScreen> {
// Disable Flutter's built-in resize — we handle it ourselves. // Disable Flutter's built-in resize — we handle it ourselves.
return Scaffold( return Scaffold(
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
appBar: AppBar(title: Text('UxKeyboard Chat')), appBar: AppBar(title: Text('XKeyboard Chat')),
// ListenableBuilder rebuilds only when UxKeyboard notifies (height changes). // ListenableBuilder rebuilds only when XKeyboard notifies (height changes).
body: ListenableBuilder( body: ListenableBuilder(
listenable: _keyboard, listenable: _keyboard,
builder: (context, _) { builder: (context, _) {

View File

@@ -8,5 +8,5 @@ import Foundation
import ux import ux
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
UxPlugin.register(with: registry.registrar(forPlugin: "UxPlugin")) XPlugin.register(with: registry.registrar(forPlugin: "XPlugin"))
} }

View File

@@ -510,7 +510,7 @@ packages:
path: ".." path: ".."
relative: true relative: true
source: path source: path
version: "0.9.0" version: "0.10.0"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:

View File

@@ -5,7 +5,7 @@ import 'package:ux_example/main.dart';
void main() { void main() {
testWidgets('ChatScreen renders', (WidgetTester tester) async { testWidgets('ChatScreen renders', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(home: ChatScreen())); await tester.pumpWidget(MaterialApp(home: ChatScreen()));
expect(find.text('UxKeyboard Chat'), findsOneWidget); expect(find.text('XKeyboard Chat'), findsOneWidget);
expect(find.text('Type a message...'), findsOneWidget); expect(find.text('Type a message...'), findsOneWidget);
}); });
} }

View File

@@ -8,7 +8,7 @@ import AVFoundation
/// the result. The macOS counterpart in /// the result. The macOS counterpart in
/// `macos/Classes/Camera/AVCaptureConnection+macOS.swift` is a no-op. /// `macos/Classes/Camera/AVCaptureConnection+macOS.swift` is a no-op.
extension AVCaptureConnection { extension AVCaptureConnection {
func applyUxCaptureOrientation(_ orientation: DeviceOrientationFlutter) { func applyXCaptureOrientation(_ orientation: DeviceOrientationFlutter) {
if isVideoOrientationSupported { if isVideoOrientationSupported {
videoOrientation = orientation.avVideoOrientation videoOrientation = orientation.avVideoOrientation
} }

View File

@@ -7,7 +7,7 @@ import UIKit
public class FilePlugin: NSObject, NativePlugin { public class FilePlugin: NSObject, NativePlugin {
private var channel: FlutterMethodChannel? private var channel: FlutterMethodChannel?
private var previewDataSource: FilePreviewDataSource? private var previewDataSource: FilePreviewDataSource?
private var pickerDelegate: UxDocumentPickerDelegate? private var pickerDelegate: XDocumentPickerDelegate?
private struct ScopedEntry { private struct ScopedEntry {
let url: URL let url: URL
@@ -91,7 +91,7 @@ public class FilePlugin: NSObject, NativePlugin {
let path = args["path"] as? String else { let path = args["path"] as? String else {
return result(FlutterError(code: "bad_args", message: "path is required", details: nil)) return result(FlutterError(code: "bad_args", message: "path is required", details: nil))
} }
guard let topVC = UxWindow.topViewController else { guard let topVC = XWindow.topViewController else {
return result(FlutterError(code: "no_view", message: "no top view controller", details: nil)) return result(FlutterError(code: "no_view", message: "no top view controller", details: nil))
} }
@@ -123,7 +123,7 @@ public class FilePlugin: NSObject, NativePlugin {
} }
private func handlePick(_ call: FlutterMethodCall, result: @escaping FlutterResult) { private func handlePick(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
guard let topVC = UxWindow.topViewController else { guard let topVC = XWindow.topViewController else {
return result(FlutterError(code: "no_view", message: "no top view controller", details: nil)) return result(FlutterError(code: "no_view", message: "no top view controller", details: nil))
} }
let args = call.arguments as? [String: Any] let args = call.arguments as? [String: Any]
@@ -136,7 +136,7 @@ public class FilePlugin: NSObject, NativePlugin {
// cold starts. // cold starts.
let picker = UIDocumentPickerViewController(documentTypes: utis, in: .open) let picker = UIDocumentPickerViewController(documentTypes: utis, in: .open)
picker.allowsMultipleSelection = false picker.allowsMultipleSelection = false
let delegate = UxDocumentPickerDelegate(result: result) { [weak self] in let delegate = XDocumentPickerDelegate(result: result) { [weak self] in
self?.pickerDelegate = nil self?.pickerDelegate = nil
} }
// The delegate is weak on UIDocumentPickerViewController; keep a strong ref. // The delegate is weak on UIDocumentPickerViewController; keep a strong ref.
@@ -180,7 +180,7 @@ public class FilePlugin: NSObject, NativePlugin {
let path = args["path"] as? String else { let path = args["path"] as? String else {
return result(FlutterError(code: "bad_args", message: "path is required", details: nil)) return result(FlutterError(code: "bad_args", message: "path is required", details: nil))
} }
guard let topVC = UxWindow.topViewController else { guard let topVC = XWindow.topViewController else {
return result(FlutterError(code: "no_view", message: "no top view controller", details: nil)) return result(FlutterError(code: "no_view", message: "no top view controller", details: nil))
} }
@@ -322,7 +322,7 @@ fileprivate func mimeFromExtension(_ ext: String) -> String? {
/// we briefly start access to read attributes + create a bookmark, then /// we briefly start access to read attributes + create a bookmark, then
/// stop access. Persisted bookmark lets Dart re-acquire scope later via /// stop access. Persisted bookmark lets Dart re-acquire scope later via
/// `beginScopedAccess`. /// `beginScopedAccess`.
private final class UxDocumentPickerDelegate: NSObject, UIDocumentPickerDelegate { private final class XDocumentPickerDelegate: NSObject, UIDocumentPickerDelegate {
let result: FlutterResult let result: FlutterResult
let onDone: () -> Void let onDone: () -> Void
private var settled = false private var settled = false

View File

@@ -3,7 +3,7 @@ import Photos
import PhotosUI import PhotosUI
import UIKit import UIKit
/// `Photos.framework` bridge for `UxGallery` paginated asset queries, /// `Photos.framework` bridge for `XGallery` paginated asset queries,
/// cell-sized thumbnails via `PHCachingImageManager`, and on-demand /// cell-sized thumbnails via `PHCachingImageManager`, and on-demand
/// file resolution into the app cache. /// file resolution into the app cache.
public class GalleryPlugin: NSObject, NativePlugin, PHPhotoLibraryChangeObserver, FlutterStreamHandler { public class GalleryPlugin: NSObject, NativePlugin, PHPhotoLibraryChangeObserver, FlutterStreamHandler {
@@ -101,7 +101,7 @@ public class GalleryPlugin: NSObject, NativePlugin, PHPhotoLibraryChangeObserver
// Completion handler signals dismissal; reload is driven by // Completion handler signals dismissal; reload is driven by
// `ux/gallery/changes` via the library observer (fires after // `ux/gallery/changes` via the library observer (fires after
// iOS commits the new subset). // iOS commits the new subset).
if #available(iOS 15, *), let vc = UxWindow.topViewController { if #available(iOS 15, *), let vc = XWindow.topViewController {
PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: vc) { _ in PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: vc) { _ in
// Apple's docs: the completion handler runs on "an // Apple's docs: the completion handler runs on "an
// arbitrary serial dispatch queue". Flutter method- // arbitrary serial dispatch queue". Flutter method-
@@ -110,7 +110,7 @@ public class GalleryPlugin: NSObject, NativePlugin, PHPhotoLibraryChangeObserver
result(nil) result(nil)
} }
} }
} else if #available(iOS 14, *), let vc = UxWindow.topViewController { } else if #available(iOS 14, *), let vc = XWindow.topViewController {
PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: vc) PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: vc)
result(nil) result(nil)
} else { } else {

View File

@@ -268,7 +268,7 @@ public class KeyboardPlugin: NSObject, NativePlugin {
} }
private func setupPanGesture() { private func setupPanGesture() {
guard let window = UxWindow.keyWindow else { return } guard let window = XWindow.keyWindow else { return }
let view: UIView = window.rootViewController?.view ?? window let view: UIView = window.rootViewController?.view ?? window
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan))

View File

@@ -5,7 +5,7 @@ public protocol NativePlugin {
func register(with registrar: FlutterPluginRegistrar) func register(with registrar: FlutterPluginRegistrar)
} }
public enum UxWindow { public enum XWindow {
public static var keyWindow: UIWindow? { public static var keyWindow: UIWindow? {
if let w = UIApplication.shared.delegate?.window ?? nil { return w } if let w = UIApplication.shared.delegate?.window ?? nil { return w }
for scene in UIApplication.shared.connectedScenes { for scene in UIApplication.shared.connectedScenes {

View File

@@ -1,7 +1,7 @@
import Flutter import Flutter
import UIKit import UIKit
public class UxPlugin: NSObject, FlutterPlugin { public class XPlugin: NSObject, FlutterPlugin {
private static var plugins: [NativePlugin] = [] private static var plugins: [NativePlugin] = []
public static func register(with registrar: FlutterPluginRegistrar) { public static func register(with registrar: FlutterPluginRegistrar) {

View File

@@ -1,4 +1,4 @@
// Native data detection for UxUrl. Synchronous, callable via dart:ffi. // Native data detection for XUrl. Synchronous, callable via dart:ffi.
// //
// Exports two symbols: // Exports two symbols:
// uint8_t* ux_match_url(const uint16_t* utf16, int32_t len, int32_t* out_size); // uint8_t* ux_match_url(const uint16_t* utf16, int32_t len, int32_t* out_size);
@@ -24,13 +24,13 @@ static const uint32_t kKindWeb = 0;
static const uint32_t kKindEmail = 1; static const uint32_t kKindEmail = 1;
static const uint32_t kKindPhone = 2; static const uint32_t kKindPhone = 2;
@interface UxUrlRawMatch : NSObject @interface XUrlRawMatch : NSObject
@property (nonatomic) int32_t start; @property (nonatomic) int32_t start;
@property (nonatomic) int32_t end; @property (nonatomic) int32_t end;
@property (nonatomic) uint32_t kind; @property (nonatomic) uint32_t kind;
@property (nonatomic, copy) NSData *urlUtf8; @property (nonatomic, copy) NSData *urlUtf8;
@end @end
@implementation UxUrlRawMatch @implementation XUrlRawMatch
@end @end
static NSDataDetector *ux_url_data_detector(void) { static NSDataDetector *ux_url_data_detector(void) {
@@ -76,7 +76,7 @@ uint8_t *ux_match_url(const uint16_t *utf16, int32_t len, int32_t *out_size) {
if (text.length == 0) return NULL; if (text.length == 0) return NULL;
NSRange whole = NSMakeRange(0, text.length); NSRange whole = NSMakeRange(0, text.length);
NSMutableArray<UxUrlRawMatch *> *raws = [NSMutableArray array]; NSMutableArray<XUrlRawMatch *> *raws = [NSMutableArray array];
NSDataDetector *detector = ux_url_data_detector(); NSDataDetector *detector = ux_url_data_detector();
if (detector != nil) { if (detector != nil) {
@@ -130,7 +130,7 @@ uint8_t *ux_match_url(const uint16_t *utf16, int32_t len, int32_t *out_size) {
} }
if (url.length == 0) return; if (url.length == 0) return;
UxUrlRawMatch *m = [[UxUrlRawMatch alloc] init]; XUrlRawMatch *m = [[XUrlRawMatch alloc] init];
m.start = (int32_t)r.location; m.start = (int32_t)r.location;
m.end = (int32_t)(r.location + r.length); m.end = (int32_t)(r.location + r.length);
m.kind = kind; m.kind = kind;
@@ -152,7 +152,7 @@ uint8_t *ux_match_url(const uint16_t *utf16, int32_t len, int32_t *out_size) {
if (r.location == NSNotFound || r.length == 0) return; if (r.location == NSNotFound || r.length == 0) return;
NSString *substr = [text substringWithRange:r]; NSString *substr = [text substringWithRange:r];
NSString *withScheme = [@"http://" stringByAppendingString:substr]; NSString *withScheme = [@"http://" stringByAppendingString:substr];
UxUrlRawMatch *m = [[UxUrlRawMatch alloc] init]; XUrlRawMatch *m = [[XUrlRawMatch alloc] init];
m.start = (int32_t)r.location; m.start = (int32_t)r.location;
m.end = (int32_t)(r.location + r.length); m.end = (int32_t)(r.location + r.length);
m.kind = kKindWeb; m.kind = kKindWeb;
@@ -164,7 +164,7 @@ uint8_t *ux_match_url(const uint16_t *utf16, int32_t len, int32_t *out_size) {
if (raws.count == 0) return NULL; if (raws.count == 0) return NULL;
// Sort: start asc, then length desc, then kind desc (phone > email > web on tie). // Sort: start asc, then length desc, then kind desc (phone > email > web on tie).
[raws sortUsingComparator:^NSComparisonResult(UxUrlRawMatch *a, UxUrlRawMatch *b) { [raws sortUsingComparator:^NSComparisonResult(XUrlRawMatch *a, XUrlRawMatch *b) {
if (a.start != b.start) return a.start < b.start ? NSOrderedAscending : NSOrderedDescending; if (a.start != b.start) return a.start < b.start ? NSOrderedAscending : NSOrderedDescending;
int32_t la = a.end - a.start; int32_t la = a.end - a.start;
int32_t lb = b.end - b.start; int32_t lb = b.end - b.start;
@@ -174,10 +174,10 @@ uint8_t *ux_match_url(const uint16_t *utf16, int32_t len, int32_t *out_size) {
}]; }];
// Greedy de-overlap. // Greedy de-overlap.
NSMutableArray<UxUrlRawMatch *> *kept = [NSMutableArray arrayWithCapacity:raws.count]; NSMutableArray<XUrlRawMatch *> *kept = [NSMutableArray arrayWithCapacity:raws.count];
int32_t lastEnd = 0; int32_t lastEnd = 0;
BOOL haveAny = NO; BOOL haveAny = NO;
for (UxUrlRawMatch *m in raws) { for (XUrlRawMatch *m in raws) {
if (haveAny && m.start < lastEnd) continue; if (haveAny && m.start < lastEnd) continue;
[kept addObject:m]; [kept addObject:m];
lastEnd = m.end; lastEnd = m.end;
@@ -185,7 +185,7 @@ uint8_t *ux_match_url(const uint16_t *utf16, int32_t len, int32_t *out_size) {
} }
NSUInteger total = 4; NSUInteger total = 4;
for (UxUrlRawMatch *m in kept) { for (XUrlRawMatch *m in kept) {
total += 16 + (NSUInteger)m.urlUtf8.length; total += 16 + (NSUInteger)m.urlUtf8.length;
} }
@@ -194,7 +194,7 @@ uint8_t *ux_match_url(const uint16_t *utf16, int32_t len, int32_t *out_size) {
uint32_t cnt = (uint32_t)kept.count; uint32_t cnt = (uint32_t)kept.count;
memcpy(buf, &cnt, 4); memcpy(buf, &cnt, 4);
NSUInteger off = 4; NSUInteger off = 4;
for (UxUrlRawMatch *m in kept) { for (XUrlRawMatch *m in kept) {
int32_t start = m.start; int32_t start = m.start;
int32_t end = m.end; int32_t end = m.end;
uint32_t kind = m.kind; uint32_t kind = m.kind;

View File

@@ -0,0 +1,61 @@
import 'package:flutter/widgets.dart';
class AnimatedColor extends StatefulWidget {
const AnimatedColor({
required this.color,
required this.builder,
this.child,
this.curve = Curves.linear,
this.duration = const Duration(milliseconds: 200),
super.key,
});
final Curve curve;
final Duration duration;
final Color? color;
final ValueWidgetBuilder<Color?> builder;
final Widget? child;
@override
State<AnimatedColor> createState() => _AnimatedColorState();
}
class _AnimatedColorState extends State<AnimatedColor>
with SingleTickerProviderStateMixin {
late final AnimationController _animationController;
late Animation<Color?> _colorAnimation;
@override
void initState() {
super.initState();
_animationController =
AnimationController(duration: widget.duration, vsync: this);
_colorAnimation =
ColorTween(begin: widget.color, end: widget.color).animate(
CurvedAnimation(parent: _animationController, curve: widget.curve),
);
}
@override
void didUpdateWidget(AnimatedColor oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.color != widget.color) {
_colorAnimation =
ColorTween(begin: oldWidget.color, end: widget.color).animate(
CurvedAnimation(parent: _animationController, curve: widget.curve),
);
_animationController.forward(from: 0);
}
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _colorAnimation,
builder: (context, child) =>
widget.builder(context, _colorAnimation.value, child),
);
}
}

View File

@@ -0,0 +1,61 @@
import 'package:flutter/widgets.dart';
class AnimatedDouble extends StatefulWidget {
const AnimatedDouble({
super.key,
required this.builder,
required this.start,
required this.end,
this.curve = Curves.linear,
this.duration = const Duration(milliseconds: 200),
this.child,
});
final ValueWidgetBuilder<double> builder;
final Widget? child;
final double start;
final double end;
final Duration duration;
final Curve curve;
@override
State<AnimatedDouble> createState() => _AnimatedDoubleState();
}
class _AnimatedDoubleState extends State<AnimatedDouble>
with SingleTickerProviderStateMixin {
late final AnimationController controller;
@override
void initState() {
super.initState();
controller = AnimationController.unbounded(
vsync: this, value: widget.start, duration: widget.duration);
if (widget.end != widget.start) {
controller.animateTo(widget.end);
}
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
void didUpdateWidget(covariant AnimatedDouble oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.end != oldWidget.end) {
controller.animateTo(widget.end);
}
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: controller,
builder: widget.builder,
child: widget.child,
);
}
}

118
lib/src/anim/dock.dart Normal file
View File

@@ -0,0 +1,118 @@
import 'package:flutter/widgets.dart';
abstract class Dock {
static Widget top({
required Widget child,
double? left = 0,
double? right = 0,
double? top = 0,
double? bottom,
double? height,
double? width,
}) {
return Positioned(
top: top,
left: left,
right: right,
width: width,
height: height,
child: child,
);
}
static Widget bottom({
required Widget child,
double? left = 0,
double? right = 0,
double? top,
double? bottom = 0,
double? width,
double? height,
}) {
return Positioned(
top: top,
left: left,
right: right,
bottom: bottom,
width: width,
height: height,
child: child,
);
}
static Widget bottomRight({
required Widget child,
double? left,
double? right = 0,
double? top,
double? bottom = 0,
double? width,
double? height,
}) {
return Positioned(
top: top,
left: left,
right: right,
bottom: bottom,
width: width,
height: height,
child: child,
);
}
static Widget topRight({
required Widget child,
double? left,
double? right = 0,
double? top = 0,
double? bottom,
double? width,
double? height,
}) {
return Positioned(
top: top,
left: left,
right: right,
bottom: bottom,
width: width,
height: height,
child: child,
);
}
static Widget topLeft({
required Widget child,
double? left = 0,
double? right,
double? top = 0,
double? bottom,
double? width,
double? height,
}) {
return Positioned(
top: top,
left: left,
right: right,
bottom: bottom,
width: width,
height: height,
child: child,
);
}
static Widget fill({
required Widget child,
double? left,
double? right,
double? top,
double? bottom,
}) {
return Positioned.fill(
left: left,
right: right,
bottom: bottom,
top: top,
child: child,
);
}
}

View File

@@ -0,0 +1,42 @@
import 'package:flutter/widgets.dart';
class Measured extends StatefulWidget {
const Measured({
required this.child,
this.onSize,
super.key,
});
final Widget child;
final VoidCallback? onSize;
@override
State<Measured> createState() => _MeasuredState();
}
class _MeasuredState extends State<Measured> {
@override
void initState() {
super.initState();
report();
}
@override
Widget build(BuildContext context) {
return NotificationListener<SizeChangedLayoutNotification>(
onNotification: handleSizeChanged,
child: SizeChangedLayoutNotifier(
child: widget.child,
),
);
}
void report() {
widget.onSize?.call();
}
bool handleSizeChanged(SizeChangedLayoutNotification notification) {
report();
return false;
}
}

384
lib/src/anim/pane.dart Normal file
View File

@@ -0,0 +1,384 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/rendering.dart';
import 'package:ux/src/core/core.dart';
/// WIP
abstract class Pane with Emitter {
Pane();
final Map<Object?, Widget> widgets = {};
final Map<Object?, Element> elements = {};
final Map<Object?, RenderBox> renderBoxes = {};
// ready to manage children
PaneElement? element;
PaneRender? renderer;
bool get needsCompositing => renderer?.needsCompositing ?? false;
Size get size => renderer!.size;
BoxConstraints get constraints => renderer!.constraints;
void markNeedsLayout() => renderer?.markNeedsLayout();
void markNeedsPaint() => renderer?.markNeedsPaint();
void attachPipeline(PipelineOwner owner) {
for (final child in renderBoxes.values) {
if (child.owner == null) {
child.attach(owner);
}
}
}
void detachPipeline() {
for (final child in renderBoxes.values) {
child.detach();
}
}
void visitChildrenRender(RenderObjectVisitor visitor) =>
renderBoxes.values.forEach(visitor);
void setupParentData(covariant RenderObject child, covariant Object? slot);
List<DiagnosticsNode> debugDescribeChildren() => const [];
void debugFillProperties(DiagnosticPropertiesBuilder properties) {}
void applyPaintTransform(covariant RenderObject child, Matrix4 transform) {}
bool hitTestChildren(BoxHitTestResult result, Offset position) => false;
bool hitTestSelf(Offset position) => false;
/// [constraints] are available
/// [size] must be set
void performLayout();
void paint(PaintingContext context, Offset offset);
void insertRenderObjectChild(
PaneElement paneElement, covariant RenderBox child, Object? slot) {
setupParentData(child, slot);
renderBoxes[slot] = child;
renderer?.adoptChild(child);
}
void moveRenderObjectChild(PaneElement paneElement, RenderObject child,
Object? oldSlot, Object? newSlot) {}
void removeRenderObjectChild(
PaneElement paneElement, RenderObject child, Object? slot) {
renderBoxes.remove(slot);
renderer?.dropChild(child);
}
void visitChildrenElement(ElementVisitor visitor) =>
elements.values.forEach(visitor);
Widget itemBuilder(BuildContext context, Object? slot);
RenderBox? invokeLayoutUpsert(Object? slot) {
if (element == null) return null;
RenderBox? child = renderBoxes[slot];
if (child != null) return child;
renderer?.invokeLayoutCallback((constraints) {
child = buildChild(slot);
});
return child;
}
void invokeLayoutRemove(Object? slot) {
if (element == null) return;
renderer?.invokeLayoutCallback((constraints) {
removeSlotElement(slot);
});
}
void removeSlotElement(Object? slot) {
final element = this.element;
if (element == null) return;
elements.remove(slot)?.pipe(element.deactivateChild);
widgets.remove(slot);
}
void mountElement(Element? parent, Object? newSlot, PaneElement element) {
this.element = element;
}
void unmountElement() {
for (final child in renderBoxes.values) {
renderer?.dropChild(child);
}
renderBoxes.clear();
elements.clear();
widgets.clear();
element = null;
}
RenderBox? buildChild(Object? slot) {
final element = this.element;
if (element == null) return null;
element.owner!.buildScope(element, () {
Widget newWidget;
try {
newWidget = itemBuilder(element, slot);
} catch (e) {
return;
}
final oldElement = elements[slot];
final oldWidget = widgets[slot];
if (oldElement == null) {
final newElement = element.inflateWidget(newWidget, slot);
elements[slot] = newElement;
widgets[slot] = newWidget;
} else if (oldWidget != null && Widget.canUpdate(oldWidget, newWidget)) {
oldElement.update(newWidget);
widgets[slot] = newWidget;
} else {
element.deactivateChild(oldElement);
final newElement = element.inflateWidget(newWidget, slot);
elements[slot] = newElement;
widgets[slot] = newWidget;
}
});
return elements[slot]?.renderObject as RenderBox?;
}
void forgetChildElement(Element child) {
elements.removeWhere((_, value) => value == child);
}
}
class PaneView extends StatelessWidget {
const PaneView({
required this.controller,
required this.scrollController,
this.scrollPhysics,
super.key,
});
final Pane controller;
final ScrollController scrollController;
final ScrollPhysics? scrollPhysics;
Widget buildViewport(BuildContext context, ViewportOffset offset) {
return PaneViewport(controller: controller);
}
@override
Widget build(BuildContext context) {
return Scrollable(
controller: scrollController,
physics: scrollPhysics,
viewportBuilder: buildViewport,
);
}
}
class PaneViewport extends RenderObjectWidget {
const PaneViewport({
required this.controller,
super.key,
});
final Pane controller;
@override
RenderObjectElement createElement() {
return PaneElement(this, controller);
}
@override
RenderObject createRenderObject(BuildContext context) {
return PaneRender(controller: controller);
}
@override
void updateRenderObject(
BuildContext context, covariant PaneRender renderObject) {
renderObject.controller = controller;
}
}
class PaneElement extends RenderObjectElement {
PaneElement(super.widget, this._controller);
Pane _controller;
Pane get controller => _controller;
@override
void insertRenderObjectChild(
covariant RenderBox child, covariant Object? slot) {
controller.insertRenderObjectChild(this, child, slot);
}
@override
void moveRenderObjectChild(covariant RenderObject child,
covariant Object? oldSlot, covariant Object? newSlot) {
controller.moveRenderObjectChild(this, child, oldSlot, newSlot);
}
@override
void removeRenderObjectChild(
covariant RenderObject child, covariant Object? slot) {
controller.removeRenderObjectChild(this, child, slot);
}
@override
void visitChildren(ElementVisitor visitor) =>
controller.visitChildrenElement(visitor);
@override
void deactivateChild(Element child) => super.deactivateChild(child);
@override
void mount(Element? parent, Object? newSlot) {
super.mount(parent, newSlot);
controller.mountElement(parent, newSlot, this);
}
@override
void unmount() {
controller.unmountElement();
super.unmount();
}
@override
void update(covariant PaneViewport newWidget) {
final oldController = _controller;
final newController = newWidget.controller;
if (oldController != newController) {
oldController.unmountElement();
_controller = newController;
newController.mountElement(null, null, this);
}
super.update(newWidget);
}
@override
void forgetChild(Element child) {
controller.forgetChildElement(child);
super.forgetChild(child);
}
@override
Element inflateWidget(Widget newWidget, Object? newSlot) =>
super.inflateWidget(newWidget, newSlot);
@override
PaneRender get renderObject => super.renderObject as PaneRender;
@override
PaneViewport get widget => super.widget as PaneViewport;
}
class PaneRender extends RenderBox {
PaneRender({
required Pane controller,
}) : _controller = controller;
Pane _controller;
Pane get controller => _controller;
set controller(Pane value) {
if (_controller == value) return;
if (attached) {
_controller.detachPipeline();
_controller.renderer = null;
}
_controller = value;
if (attached) {
_controller.attachPipeline(owner!);
_controller.renderer = this;
markNeedsLayout();
}
}
@override
void attach(PipelineOwner owner) {
super.attach(owner);
_controller.attachPipeline(owner);
_controller.renderer = this;
}
@override
void detach() {
super.detach();
_controller.detachPipeline();
_controller.renderer = null;
}
@override
void adoptChild(RenderObject child) => super.adoptChild(child);
@override
void dropChild(RenderObject child) => super.dropChild(child);
@override
void setupParentData(covariant RenderObject child) {
//no-op
}
@override
bool get sizedByParent => true;
@override
void performResize() {
size = constraints.biggest;
}
@override
void performLayout() {
if (controller.renderer == null) return;
controller.performLayout();
}
@override
void paint(PaintingContext context, Offset offset) {
if (controller.renderer == null) return;
controller.paint(context, offset);
}
@override
bool hitTestSelf(Offset position) => controller.hitTestSelf(position);
@override
void invokeLayoutCallback<T extends Constraints>(
LayoutCallback<T> callback) =>
super.invokeLayoutCallback(callback);
@override
bool hitTestChildren(
BoxHitTestResult result, {
required Offset position,
}) =>
controller.hitTestChildren(result, position);
@override
void visitChildren(RenderObjectVisitor visitor) =>
controller.visitChildrenRender(visitor);
@override
void applyPaintTransform(RenderObject child, Matrix4 transform) {
assert(child.parent == this);
controller.applyPaintTransform(child, transform);
}
@override
List<DiagnosticsNode> debugDescribeChildren() =>
controller.debugDescribeChildren();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
controller.debugFillProperties(properties);
}
}

668
lib/src/anim/sheet.dart Normal file
View File

@@ -0,0 +1,668 @@
import 'dart:math';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
const Duration _kSheetAnimation = Duration(milliseconds: 300);
/// Controller for coordinating scroll views with a [Sheet].
///
/// Child scroll views should register their `ScrollController` using
/// [attach] when they become active and [detach] when they're no longer
/// active. This lets the sheet coordinate gestures with the active
/// scroll view (so e.g. dragging down on a list at scroll-top moves
/// the sheet down rather than overscrolling the list).
///
/// Use [physics] on the active scroll view so user-input scroll is
/// blocked while the sheet is mid-drag.
class SheetController {
ScrollController? _activeScrollController;
ScrollController? get activeScrollController => _activeScrollController;
/// Whether scrolling should be enabled for the active scroll view.
/// Sheet sets this to false while it's mid-pan from collapsed.
bool scrollEnabled = true;
ScrollPhysics get physics => SheetScrollPhysics(controller: this);
Future<void> Function()? _dismissCallback;
/// Animate the sheet offscreen. Returns a future that completes
/// when the dismissal is done.
Future<void> animateDismiss() async {
await _dismissCallback?.call();
}
/// Normalized position when dismiss was triggered (0 = offscreen,
/// 1 = collapsed). Used by host route adapters to start a
/// physics-based exit animation from the gesture's current position.
double? dismissPosition;
/// Normalized velocity (units / second) when dismiss was triggered.
double? dismissVelocity;
void setDismissState(double position, double velocity) {
dismissPosition = position;
dismissVelocity = velocity;
}
void clearDismissState() {
dismissPosition = null;
dismissVelocity = null;
}
/// Read by host route adapters to fetch the sheet's current
/// normalized position when `pop()` is called directly (no gesture).
double Function()? getCurrentPosition;
/// Set by the host's dismiss adapter — tells the Sheet to defer to
/// the route animation for position rather than its own animator.
bool isPopping = false;
void attach(ScrollController controller) {
_activeScrollController = controller;
}
void detach(ScrollController controller) {
if (_activeScrollController == controller) {
_activeScrollController = null;
}
}
double get scrollOffset {
final sc = _activeScrollController;
if (sc == null || !sc.hasClients) return 0.0;
return sc.offset;
}
}
/// Scroll physics that coordinate with a [SheetController].
/// When `controller.scrollEnabled` is false, scrolling is blocked
/// (the sheet itself is consuming the gesture). Clamping behavior —
/// no overscroll bounce.
class SheetScrollPhysics extends ScrollPhysics {
const SheetScrollPhysics({required this.controller, super.parent});
final SheetController controller;
@override
SheetScrollPhysics applyTo(ScrollPhysics? ancestor) {
return SheetScrollPhysics(
controller: controller,
parent: buildParent(ancestor),
);
}
@override
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
if (!controller.scrollEnabled) return 0.0;
return super.applyPhysicsToUserOffset(position, offset);
}
@override
double applyBoundaryConditions(ScrollMetrics position, double value) {
if (!controller.scrollEnabled) return value - position.pixels;
if (value < position.minScrollExtent) {
return value - position.minScrollExtent;
}
if (value > position.maxScrollExtent) {
return value - position.maxScrollExtent;
}
return 0.0;
}
@override
Simulation? createBallisticSimulation(
ScrollMetrics position,
double velocity,
) {
if (!controller.scrollEnabled) return null;
return super.createBallisticSimulation(position, velocity);
}
}
/// Bottom sheet with telegram-style behavior:
/// - Entry: slides up from the bottom edge to the collapsed extent.
/// - Drag down past threshold → calls [onDismiss] (host pops; reverse
/// animation plays).
/// - Drag up from collapsed → expands to fullscreen.
/// - Drag down from expanded with scroll at top → collapses.
/// - Spring physics for snap (telegram damping ≈ 124 / 0.45s).
/// - Scroll coordination via [SheetController]: inner list scrolls
/// freely when expanded + scrolled, sheet drags when collapsed or at
/// scroll-top.
///
/// When [routeAnimation] is provided, the Sheet defers to it for the
/// entry / exit position so the host's `Route` (or a `Screen`)
/// can hero-animate. With null [routeAnimation] the Sheet self-manages
/// entry / exit via its own [AnimationController].
class Sheet extends StatefulWidget {
const Sheet({
super.key,
required this.child,
this.controller,
this.routeAnimation,
this.collapsedExtent = 0.6,
this.isFullSize = false,
this.isDismissible = true,
this.onDismiss,
this.barrierColor = Colors.black54,
this.backgroundColor,
this.borderRadius = 16.0,
});
final Widget child;
final SheetController? controller;
/// When provided, the sheet position is driven by this animation.
/// 0.0 = offscreen (bottom), 1.0 = collapsed.
final Animation<double>? routeAnimation;
final double collapsedExtent;
final bool isFullSize;
final bool isDismissible;
final VoidCallback? onDismiss;
final Color barrierColor;
final Color? backgroundColor;
/// Corner radius of the sheet (top corners always; bottom corners
/// shrink toward 0 as the sheet expands).
final double borderRadius;
@override
State<Sheet> createState() => SheetState();
}
class SheetState extends State<Sheet> with SingleTickerProviderStateMixin {
late AnimationController _animController;
bool get _hasRouteAnimation => widget.routeAnimation != null;
bool _isExpanded = false;
/// Visual offset: 0 at rest, negative when dragged down (sheet smaller).
double _boundsOriginY = 0.0;
/// Animated top during internal expand/collapse transitions.
/// When null, position is computed from state instead.
double? _animatedTop;
// Pan gesture state (mirrors telegram's panGestureArguments).
double? _panTopInset;
double _panOffset = 0.0;
bool _expandedDuringPan = false;
/// Last gesture velocity normalized to "screen heights / second".
double _lastNormalizedVelocity = 0.0;
bool _isDismissing = false;
double get _screenHeight => MediaQuery.sizeOf(context).height;
/// Distance from screen top to the sheet's collapsed edge.
double get _edgeTopInset => _screenHeight * (1.0 - widget.collapsedExtent);
double get _restTopOffset => _isExpanded ? 0.0 : _edgeTopInset;
/// Position from route animation (offscreen → collapsed).
/// Maps animation 0→1 to screenHeight→edgeTopInset.
double? get _routeAnimatedTop {
final anim = widget.routeAnimation;
if (anim == null) return null;
return _screenHeight + (_edgeTopInset - _screenHeight) * anim.value;
}
@override
void initState() {
super.initState();
_animController = AnimationController(vsync: this);
widget.controller?._dismissCallback = _animateDismiss;
widget.controller?.getCurrentPosition = _getCurrentNormalizedPosition;
widget.routeAnimation?.addListener(_onRouteAnimationUpdate);
if (!_hasRouteAnimation) {
_animatedTop = double.infinity; // start offscreen
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _animateToCollapsed();
});
}
}
void _onRouteAnimationUpdate() {
if (mounted) setState(() {});
}
/// Current normalized position, used by the host's route adapter
/// to drive a physics-based exit animation.
double _getCurrentNormalizedPosition() {
final currentTop = _animatedTop ?? (_restTopOffset - _boundsOriginY);
return ((_screenHeight - currentTop) / (_screenHeight - _edgeTopInset))
.clamp(0.0, double.infinity);
}
@override
void dispose() {
widget.routeAnimation?.removeListener(_onRouteAnimationUpdate);
widget.controller?._dismissCallback = null;
widget.controller?.getCurrentPosition = null;
_animController.dispose();
super.dispose();
}
Future<void> _animateDismiss() {
if (_hasRouteAnimation) {
widget.onDismiss?.call();
return Future<void>.value();
}
return _playDismissAnimation();
}
/// Slide the sheet offscreen and pin it there. Pure visual — no
/// [widget.onDismiss] notification, no [_isDismissing] flag.
/// Callers ([_dismiss] for gesture / backdrop, [_animateDismiss]
/// for [SheetController.animateDismiss]) layer their own pre- /
/// post-actions on top.
///
/// The trailing pin to `_screenHeight` defends against the tail
/// frame between the animator's own `_animatedTop = null` cleanup
/// and the caller's pop / unmount — without it that frame paints
/// at `_restTopOffset` (the pre-dismiss collapsed / expanded
/// position) and flashes.
Future<void> _playDismissAnimation() {
return _animateWithEaseInOut(
targetTop: _screenHeight,
duration: _kSheetAnimation,
).then((_) {
if (mounted) setState(() => _animatedTop = _screenHeight);
});
}
double get _scrollOffset => widget.controller?.scrollOffset ?? 0.0;
// ── Pan handlers ───────────────────────────────────────────────────
void _onPanStart(DragStartDetails details) {
_animController.stop();
_panTopInset = _isExpanded ? 0.0 : _edgeTopInset;
_panOffset = 0.0;
_expandedDuringPan = false;
if (!_isExpanded) {
widget.controller?.scrollEnabled = false;
}
}
void _onPanUpdate(DragUpdateDetails details) {
final topInset = _panTopInset;
if (topInset == null) return;
final delta = details.delta.dy;
final contentOffset = _scrollOffset;
final translation = _panOffset + delta;
const epsilon = 1.0;
if (_isExpanded && !_expandedDuringPan) {
// EXPANDED — drag down only moves the sheet if we're at scroll-top.
if (contentOffset <= epsilon && delta > 0) {
_panOffset = translation;
final boundsOriginY = min(0.0, -translation);
widget.controller?.scrollEnabled = false;
setState(() => _boundsOriginY = boundsOriginY);
} else if (_boundsOriginY < 0 && delta < 0) {
// Sheet dragged down, now dragging back up — return to expanded.
_panOffset = translation;
final boundsOriginY = min(0.0, -translation);
widget.controller?.scrollEnabled = boundsOriginY >= 0;
setState(() => _boundsOriginY = boundsOriginY);
} else if (contentOffset > epsilon) {
// Mid-scroll — let the inner list handle it.
} else {
widget.controller?.scrollEnabled = true;
}
} else {
// COLLAPSED (or expanding mid-pan).
_panOffset = translation;
if (translation < 0) {
// Dragging UP.
final expandAmount = -translation;
final maxExpand = _edgeTopInset;
if (expandAmount > maxExpand) {
setState(() => _animatedTop = 0);
_expandedDuringPan = true;
widget.controller?.scrollEnabled = true;
} else {
widget.controller?.scrollEnabled = false;
_expandedDuringPan = false;
setState(() => _animatedTop = _edgeTopInset - expandAmount);
}
} else {
// Dragging DOWN — shrink sheet.
final boundsOriginY = min(0.0, -translation);
setState(() {
_animatedTop = null;
_boundsOriginY = boundsOriginY;
});
_expandedDuringPan = false;
widget.controller?.scrollEnabled = false;
}
}
}
void _onPanEnd(DragEndDetails details) {
final topInset = _panTopInset;
if (topInset == null) return;
_panTopInset = null;
_panOffset = 0.0;
widget.controller?.scrollEnabled = true;
final contentOffset = _scrollOffset;
var velocity = details.velocity.pixelsPerSecond.dy;
if ((_isExpanded || _expandedDuringPan) && contentOffset > 0.1) {
velocity = 0.0;
}
_lastNormalizedVelocity = -velocity / _screenHeight;
final edgeTopInset = _edgeTopInset;
final currentTop = _animatedTop ?? (_restTopOffset - _boundsOriginY);
final thresholdOffset = widget.isFullSize ? 180.0 : 60.0;
if (widget.isDismissible) {
final pastThreshold = currentTop > edgeTopInset + thresholdOffset;
final pastCollapsedWithVelocity =
currentTop > edgeTopInset && velocity > 300.0;
final fastFlingFromExpanded = (_isExpanded || _expandedDuringPan) &&
contentOffset <= 0.1 &&
velocity > 1800.0;
if (pastThreshold || pastCollapsedWithVelocity || fastFlingFromExpanded) {
_expandedDuringPan = false;
_dismiss();
return;
}
}
if (_isExpanded || _expandedDuringPan) {
if ((velocity > 300.0 || currentTop > edgeTopInset / 2.0) &&
!widget.isFullSize) {
// COLLAPSE.
final fromTop = currentTop;
_animatedTop = fromTop;
_boundsOriginY = 0.0;
_isExpanded = false;
_expandedDuringPan = false;
final initialVelocity =
velocity.abs() / max(1.0, (edgeTopInset - fromTop).abs());
_animateWithSpring(
targetTop: edgeTopInset,
initialVelocity: initialVelocity,
fromTop: fromTop,
);
} else {
// STAY EXPANDED.
_animatedTop = currentTop;
_boundsOriginY = 0.0;
_isExpanded = true;
_expandedDuringPan = false;
_animateWithEaseInOut(targetTop: 0.0, fromTop: currentTop);
}
} else {
if (velocity < -300.0 || currentTop < edgeTopInset / 2.0) {
// EXPAND.
final fromTop = currentTop;
_animatedTop = fromTop;
_boundsOriginY = 0.0;
_isExpanded = true;
final initialVelocity = velocity.abs() / max(1.0, fromTop.abs());
_animateWithSpring(
targetTop: 0.0,
initialVelocity: initialVelocity,
fromTop: fromTop,
);
} else {
// STAY COLLAPSED.
_animatedTop = currentTop;
_boundsOriginY = 0.0;
_animateWithEaseInOut(targetTop: edgeTopInset, fromTop: currentTop);
}
}
}
// ── Animations ─────────────────────────────────────────────────────
void _animateToCollapsed() {
_isExpanded = false;
_animatedTop = _screenHeight;
_boundsOriginY = 0.0;
_animateWithEaseInOut(targetTop: _edgeTopInset, fromOffscreen: true);
}
void _dismiss() {
if (_hasRouteAnimation) {
final currentTop = _animatedTop ?? (_restTopOffset - _boundsOriginY);
final normalizedPosition =
((_screenHeight - currentTop) / (_screenHeight - _edgeTopInset))
.clamp(0.0, double.infinity);
widget.controller
?.setDismissState(normalizedPosition, _lastNormalizedVelocity);
_isDismissing = true;
_isExpanded = false;
_boundsOriginY = 0.0;
_animatedTop = null;
widget.onDismiss?.call();
} else {
_isDismissing = true;
_playDismissAnimation().then((_) {
if (mounted) widget.onDismiss?.call();
});
}
}
/// Spring animation tuned to feel like telegram's damping ≈ 124 / 0.45s.
Future<void> _animateWithSpring({
required double targetTop,
required double initialVelocity,
double? fromTop,
}) {
_animController.stop();
final currentTop = fromTop ?? (_restTopOffset - _boundsOriginY);
_animatedTop = currentTop;
_boundsOriginY = 0.0;
const spring = SpringDescription(mass: 1.0, stiffness: 500.0, damping: 30.0);
final springVelocity =
initialVelocity * (targetTop - currentTop).sign;
final normalizedSpring = SpringSimulation(
spring,
0.0,
1.0,
springVelocity / max(1e-3, (targetTop - currentTop).abs()),
);
late void Function() listener;
listener = () {
final t = _animController.value;
final newTop = currentTop + (targetTop - currentTop) * t;
if (mounted) {
setState(() => _animatedTop = newTop.clamp(0.0, _screenHeight));
}
};
_animController.addListener(listener);
return _animController.animateWith(normalizedSpring).then((_) {
_animController.removeListener(listener);
if (mounted) setState(() => _animatedTop = null);
});
}
/// EaseInOut animation, telegram default 0.3s.
Future<void> _animateWithEaseInOut({
required double targetTop,
Duration duration = _kSheetAnimation,
bool fromOffscreen = false,
double? fromTop,
}) {
_animController.stop();
final startTop = fromOffscreen
? _screenHeight
: (fromTop ?? (_restTopOffset - _boundsOriginY));
_animatedTop = startTop;
_boundsOriginY = 0.0;
late void Function() listener;
listener = () {
final t = Curves.easeInOut.transform(_animController.value);
if (mounted) {
setState(() => _animatedTop = startTop + (targetTop - startTop) * t);
}
};
_animController.duration = duration;
_animController.addListener(listener);
_animController.forward(from: 0.0);
return _animController.animateTo(1.0).then((_) {
_animController.removeListener(listener);
if (mounted) setState(() => _animatedTop = null);
});
}
@override
Widget build(BuildContext context) {
final screenSize = MediaQuery.sizeOf(context);
final screenHeight = screenSize.height;
final screenWidth = screenSize.width;
final isPopping = widget.controller?.isPopping ?? false;
final double topOffset;
if ((_isDismissing || isPopping) && _hasRouteAnimation) {
topOffset = (_routeAnimatedTop ?? _restTopOffset).clamp(0.0, screenHeight);
} else if (_animatedTop != null) {
topOffset = _animatedTop!.clamp(0.0, screenHeight);
} else if (_hasRouteAnimation && !_isExpanded && _boundsOriginY == 0.0) {
topOffset = (_routeAnimatedTop ?? _restTopOffset).clamp(0.0, screenHeight);
} else {
topOffset = (_restTopOffset - _boundsOriginY).clamp(0.0, screenHeight);
}
final safeAreaTop = MediaQuery.paddingOf(context).top;
final edgeTopInset = _edgeTopInset;
final clampedTopOffset = topOffset.clamp(safeAreaTop, screenHeight);
final expansionRange = edgeTopInset - safeAreaTop;
final expansionProgress = expansionRange > 0
? ((edgeTopInset - clampedTopOffset) / expansionRange).clamp(0.0, 1.0)
: 0.0;
const collapsedPadding = 6.0;
final sheetPadding = collapsedPadding * (1.0 - expansionProgress);
final collapsedHeight = screenHeight - edgeTopInset;
final cornerRadius =
(widget.borderRadius - sheetPadding).clamp(0.0, widget.borderRadius);
final barrierProgress =
(1.0 - clampedTopOffset / edgeTopInset).clamp(0.0, 1.0);
final sheetLeft = sheetPadding;
final sheetTop = clampedTopOffset;
final sheetWidth = screenWidth - sheetPadding * 2;
final sheetHeight =
max(collapsedHeight, screenHeight - clampedTopOffset) - sheetPadding;
final animValue = widget.routeAnimation?.value ?? 1.0;
return Stack(
children: [
Positioned.fill(
child: GestureDetector(
onTap: widget.isDismissible ? _dismiss : null,
behavior: HitTestBehavior.opaque,
child: ColoredBox(
color: widget.barrierColor.withValues(
alpha: widget.barrierColor.a * barrierProgress * animValue,
),
),
),
),
Positioned(
left: sheetLeft,
top: sheetTop,
width: sheetWidth,
height: sheetHeight,
child: RawGestureDetector(
gestures: <Type, GestureRecognizerFactory<GestureRecognizer>>{
_SheetPanRecognizer:
GestureRecognizerFactoryWithHandlers<_SheetPanRecognizer>(
_SheetPanRecognizer.new,
(r) => r
..onStart = _onPanStart
..onUpdate = _onPanUpdate
..onEnd = _onPanEnd,
),
},
child: Material(
color: widget.backgroundColor ??
Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.vertical(
top: Radius.circular(widget.borderRadius),
bottom: Radius.circular(cornerRadius),
),
clipBehavior: Clip.hardEdge,
elevation: 8,
child: Column(
children: [
const SheetThumb(),
Expanded(child: widget.child),
],
),
),
),
),
],
);
}
}
/// Vertical-only pan recognizer that always wins the arena. Lets a
/// drag-to-expand on the sheet beat sibling recognizers (e.g. a tap
/// recognizer in the title bar).
class _SheetPanRecognizer extends VerticalDragGestureRecognizer {
@override
void rejectGesture(int pointer) => acceptGesture(pointer);
}
/// Centered pill drag-handle. Default for the top edge of a [Sheet].
class SheetThumb extends StatelessWidget {
const SheetThumb({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Center(
child: Container(
height: 5,
width: 36,
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2.5),
),
),
),
);
}
}

View File

@@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart'
show defaultTargetPlatform, TargetPlatform; show defaultTargetPlatform, TargetPlatform;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'camera.dart' show UxCameraController, UxCameraLens, UxCameraValue; import 'camera.dart' show XCameraController, XCameraLens, XCameraValue;
/// Renders the live preview for [controller] into a [Texture]. Sizes /// Renders the live preview for [controller] into a [Texture]. Sizes
/// itself to the parent — wrap in `AspectRatio` / `FittedBox` / `Hero` /// itself to the parent — wrap in `AspectRatio` / `FittedBox` / `Hero`
@@ -10,21 +10,21 @@ import 'camera.dart' show UxCameraController, UxCameraLens, UxCameraValue;
/// ///
/// While the controller is not yet initialized, this falls back to a /// While the controller is not yet initialized, this falls back to a
/// transparent placeholder. The widget rebuilds on every /// transparent placeholder. The widget rebuilds on every
/// `UxCameraValue` change, so once the native session starts /// `XCameraValue` change, so once the native session starts
/// producing frames the texture appears automatically. /// producing frames the texture appears automatically.
/// ///
/// Front-camera preview is auto-mirrored here (the analog of /// Front-camera preview is auto-mirrored here (the analog of
/// telegram-iOS's `CameraPreviewView.mirroring` property), so the /// telegram-iOS's `CameraPreviewView.mirroring` property), so the
/// recorded MP4 + captured JPEG carry the raw sensor feed while the /// recorded MP4 + captured JPEG carry the raw sensor feed while the
/// on-screen preview still reads as a natural mirror to the user. /// on-screen preview still reads as a natural mirror to the user.
class UxCameraPreview extends StatelessWidget { class XCameraPreview extends StatelessWidget {
const UxCameraPreview({super.key, required this.controller}); const XCameraPreview({super.key, required this.controller});
final UxCameraController controller; final XCameraController controller;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ValueListenableBuilder<UxCameraValue>( return ValueListenableBuilder<XCameraValue>(
valueListenable: controller, valueListenable: controller,
builder: (context, value, _) { builder: (context, value, _) {
final id = controller.textureId; final id = controller.textureId;
@@ -47,7 +47,7 @@ class UxCameraPreview extends StatelessWidget {
// the phone tilt (because we're flipping the axis CameraX // the phone tilt (because we're flipping the axis CameraX
// rotated around). // rotated around).
if (defaultTargetPlatform != TargetPlatform.android && if (defaultTargetPlatform != TargetPlatform.android &&
value.description.lens == UxCameraLens.front) { value.description.lens == XCameraLens.front) {
child = Transform.flip(flipX: true, child: child); child = Transform.flip(flipX: true, child: child);
} }
return child; return child;

View File

@@ -3,8 +3,8 @@ import 'package:flutter/services.dart';
/// OS clipboard access for shapes Flutter's [Clipboard] doesn't cover. /// OS clipboard access for shapes Flutter's [Clipboard] doesn't cover.
/// Right now this is image bytes — the system text path is already /// Right now this is image bytes — the system text path is already
/// handled by the SDK's `Clipboard.getData(Clipboard.kTextPlain)`. /// handled by the SDK's `Clipboard.getData(Clipboard.kTextPlain)`.
class UxClipboard { class XClipboard {
UxClipboard._(); XClipboard._();
static const _channel = MethodChannel('ux/clipboard'); static const _channel = MethodChannel('ux/clipboard');

View File

@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'dispose.dart';
/// Signature for the widget building function used by [BlocBuilder].
typedef BlocBuilderDelegate<T> = Widget Function(
BuildContext context,
T bloc,
Widget? child,
);
/// Signature for the create callback that instantiates the bloc once.
typedef BlocBuilderCreateDelegate<T> = T Function(
BuildContext context,
);
/// Lightweight lifecycle helper: creates a bloc/object once and disposes it
/// if it implements [Disposable] or [ChangeNotifier]. Does NOT listen / rebuild
/// automatically use when you only need construction & disposal wiring.
class BlocBuilder<T> extends StatefulWidget {
const BlocBuilder({
/// NOTE: if the create returns a Disposable/ChangeNotifier the `dispose` will be called
/// when the [BlocBuilder] is disposed
required this.create,
required this.builder,
this.child,
super.key,
});
/// Creates the bloc
final BlocBuilderCreateDelegate<T> create;
/// Builds the widget given the created bloc
final BlocBuilderDelegate<T> builder;
final Widget? child;
@override
State<BlocBuilder<T>> createState() => _BlocBuilderState<T>();
}
class _BlocBuilderState<T> extends State<BlocBuilder<T>> {
late T bloc;
@override
void initState() {
super.initState();
bloc = widget.create(context);
}
/// Disposes underlying bloc if it supports disposal semantics.
void _dispose(BlocBuilder<T> widget) {
final bloc = this.bloc;
switch (bloc) {
case Disposable():
bloc.dispose();
case ChangeNotifier():
bloc.dispose();
}
}
@override
void dispose() {
_dispose(widget);
super.dispose();
}
@override
Widget build(BuildContext context) {
return widget.builder(
context,
bloc,
widget.child,
);
}
}

18
lib/src/core/core.dart Normal file
View File

@@ -0,0 +1,18 @@
export 'bloc_builder.dart';
export 'debouncer.dart';
export 'dispose.dart';
export 'emitter.dart';
export 'functional.dart';
export 'late.dart';
export 'list_emitter.dart';
export 'presenter.dart';
export 'publisher.dart';
export 'range.dart';
export 'store/async_init.dart';
export 'store/store.dart';
export 'store/store_provider.dart';
export 'subscription.dart';
export 'tasks.dart';
export 'uri.dart';
export 'value.dart';
export 'widget.dart';

View File

@@ -0,0 +1,35 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
const kDefaultDebounce = const Duration(milliseconds: 100);
/// Utility that delays execution of the last scheduled callback until
/// [duration] has elapsed without a new schedule.
class Debouncer {
Debouncer({
this.duration = kDefaultDebounce,
});
final Duration duration;
Timer? timer;
/// Schedules [f] to run after [duration]; resets the timer if called again.
void run(FutureOr<void> Function() f, {Duration? duration}) {
timer?.cancel();
timer = Timer(duration ?? this.duration, f);
}
/// Cancels a pending scheduled callback, if any.
void cancel() => timer?.cancel();
}
/// Returns a closure that debounces calls to [f] by [duration]. Reinvocation
/// resets the timer. Useful for text search, resize, etc.
VoidCallback debounce(
FutureOr<void> Function() f, [
Duration duration = kDefaultDebounce,
]) {
final debouncer = Debouncer(duration: duration);
return () => debouncer.run(f);
}

67
lib/src/core/dispose.dart Normal file
View File

@@ -0,0 +1,67 @@
import 'package:flutter/foundation.dart';
import 'functional.dart';
/// Basic disposable contract.
abstract class Disposable {
bool get disposed;
void dispose();
}
/// Mixin that aggregates disposal callbacks. Call [addDispose] to register
/// cleanups; they execute in reverse insertion order when [dispose] is called.
mixin class Dispose implements Disposable {
/// Registered callbacks executed (reversed) on [dispose].
final List<VoidCallback> _dispose = [];
void _assertNotDisposed() {
assert(() {
if (disposed) {
throw FlutterError(
'A ${runtimeType} was used after being disposed.\n'
'Once you have called dispose() on a ${runtimeType}, it '
'can no longer be used.',
);
}
return true;
}());
}
/// Registers a disposal [callback]. Ignored if already disposed.
void addDispose(VoidCallback callback) {
_assertNotDisposed();
if (!disposed) {
_dispose.add(callback);
}
}
@override
@mustCallSuper
void dispose() {
_assertNotDisposed();
_disposed = true;
_dispose
..reversed.forEach(call)
..clear();
}
bool _disposed = false;
@override
bool get disposed => _disposed;
static void object(Object? object) {
if (object is Disposable) {
object.dispose();
} else if (object is ChangeNotifier) {
object.dispose();
}
}
}
extension DisposableDisposerExtension on Disposable {
/// Adds this object's [dispose] to another [Dispose] collector.
void disposeBy(Dispose disposer) => disposer.addDispose(dispose);
}

189
lib/src/core/emitter.dart Normal file
View File

@@ -0,0 +1,189 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'dispose.dart';
/// Core reactive mixin combining a private [ChangeNotifier] with disposal.
/// Extend / mix into classes that need manual `notifyListeners()` control.
mixin class Emitter implements Dispose, Listenable {
final Dispose _dispose = Dispose();
final _ChangeNotifier _notifier = _ChangeNotifier();
@override
@mustCallSuper
void addDispose(VoidCallback callback) => _dispose.addDispose(callback);
@override
@mustCallSuper
void addListener(VoidCallback listener) {
_notifier.addListener(listener);
}
@override
@mustCallSuper
void removeListener(VoidCallback listener) {
_notifier.removeListener(listener);
}
/// Notifies all registered listeners.
@protected
void notifyListeners() => _notifier.notifyListeners();
@override
@mustCallSuper
void dispose() {
_dispose.dispose();
_notifier.dispose();
}
@override
bool get disposed => _dispose.disposed;
/// Whether there is at least one active listener.
bool get hasListeners => _notifier.hasListeners;
/// Creates a derived [LazyEmitter] recomputed when any [sources] fire.
/// Emits only if the newly computed value differs from the cached value.
static LazyEmitter<T> map<T>(List<Listenable> sources, ValueGetter<T> fn) {
final notifier = LazyEmitter(fn);
subscribe(sources, notifier.notifyListeners).disposeBy(notifier);
return notifier;
}
/// handy way to addListener to a [ValueNotifier] and return the disposer callback
static VoidCallback subscribe(
Iterable<Listenable> sources,
VoidCallback callback,
) {
final subscriptions = sources.fold(
<VoidCallback>[],
(a, e) => a..add(e.subscribe(callback)),
);
return () {
for (final subscription in subscriptions) {
subscription();
}
};
}
/// Wraps [callback] so it is coalesced & scheduled in a microtask.
static VoidCallback scheduled(VoidCallback callback) {
var targetVersion = 0;
var currentVersion = 0;
return () {
if (targetVersion == currentVersion) {
targetVersion++;
scheduleMicrotask(() {
targetVersion = ++currentVersion;
callback();
});
}
};
}
}
/// [ScheduledEmitter] allows listeners to be notified post frame.
/// Usage: enables notifications during a widget's `setState` without re-entrancy.
/// Not generally recommended—prefer standard emit patterns unless necessary.
class ScheduledEmitter with Emitter {
ScheduledEmitter();
late final VoidCallback _scheduledNotifyListeners =
Emitter.scheduled(super.notifyListeners);
@override
void notifyListeners() => _scheduledNotifyListeners();
}
/// Exposes [notifyListeners] publicly (no protection) for advanced use cases.
class PublicEmitter extends Emitter {
@override
void notifyListeners() => super.notifyListeners();
}
class _ChangeNotifier extends ChangeNotifier {
@override
void notifyListeners() => super.notifyListeners();
@override
bool get hasListeners => super.hasListeners;
}
/// Mutable value holder emitting when [value] changes (shallow equality).
/// Mutable value holder emitting when [value] changes (shallow equality).
///
/// {@tool snippet}
/// ```dart
/// final counter = ValueEmitter<int>(0);
/// counter.addListener(() => print(counter.value));
/// counter.value++;
/// ```
/// {@end-tool}
class ValueEmitter<T> with Emitter implements ValueNotifier<T> {
ValueEmitter(this._value);
T _value;
@override
T get value => _value;
@override
set value(T newValue) {
if (_value == newValue) {
return;
}
_value = newValue;
notifyListeners();
}
}
/// Lazily computes its value via [fn]. Recomputes on [update] if listeners
/// are attached and the value actually changes.
///
/// {@tool snippet}
/// ```dart
/// final a = ValueEmitter(1);
/// final b = ValueEmitter(2);
/// final sum = Emitter.map([a, b], () => a.value + b.value);
/// sum.addListener(() => print('sum: ${sum.value}'));
/// a.value = 10; b.value = 5; // triggers recompute
/// ```
/// {@end-tool}
class LazyEmitter<T> with Emitter implements ValueListenable<T> {
LazyEmitter(this.fn);
final ValueGetter<T> fn;
T? _lastValue;
@override
T get value => _lastValue ??= fn();
@override
void notifyListeners() {
final newValue = fn();
if (_lastValue != newValue) {
_lastValue = newValue;
super.notifyListeners();
}
}
}
extension ListenableExtension on Listenable {
/// Adds [callback] as listener and returns a function to remove it.
VoidCallback subscribe(VoidCallback callback) {
addListener(callback);
return () => removeListener(callback);
}
}
extension StreamSubscriptionExtension<T> on Stream<T> {
VoidCallback subscribe(ValueChanged<T> callback) {
return listen(callback).cancel;
}
}
extension VoidCallbackExtension on VoidCallback {
/// Adds this callback to a [Dispose] aggregator for later invocation.
void disposeBy(Dispose disposer) => disposer.addDispose(this);
}

View File

@@ -0,0 +1,32 @@
const kEpsilon = 0.001;
/// Generic function taking no params returning [T].
typedef Callback<T> = T Function();
/// Truthy test used across selection APIs.
typedef Predicate<T> = bool Function(T e);
extension ObjectFunctional<T> on T {
/// Functional pipe: `value.pipe(fn)` -> `fn(value)`.
R pipe<R>(R Function(T e) e) => e(this);
}
/// Identity helper (returns input unchanged).
T self<T>(T e) => e;
/// Executes a zeroarg [Callback].
void call<T>(Callback<T> e) => e();
bool True() => true;
bool False() => false;
extension IterableLastWhereOrNull<T> on Iterable<T> {
T? lastWhereOrNull(bool Function(T) test) {
T? result;
for (final element in this) {
if (test(element)) result = element;
}
return result;
}
}

141
lib/src/core/late.dart Normal file
View File

@@ -0,0 +1,141 @@
/// [Late] as in value is resolved later, but that may fail after loading.
/// We tend to have quite a few cases where a set of tree states (error,
/// loading, value) for pages.
sealed class Late<T> {
factory Late.value(T value) => LateValue<T>(value);
factory Late.loading() => const LateLoading();
factory Late.error([dynamic error]) => LateError<T>(error);
}
extension LateExtension<T> on Late<T> {
bool get isLoading => this is LateLoading;
bool get isError => this is LateError;
bool get isValue => this is LateValue;
T? get value => this.isValue ? (this as LateValue).value : null;
R map<R>({
required R Function(LateValue<T> e) value,
required R Function(LateError<T> e) error,
required R Function(LateLoading<T> e) loading,
}) {
final e = this;
return switch (e) {
LateLoading() => loading(e),
LateError() => error(e),
LateValue() => value(e),
};
}
R maybeMap<R>({
required R Function() orElse,
R Function(LateValue<T> e)? value,
R Function(LateError<T> e)? error,
R Function(LateLoading<T> e)? loading,
}) {
final e = this;
return switch (e) {
LateLoading() => loading == null ? orElse() : loading(e),
LateError() => error == null ? orElse() : error(e),
LateValue() => value == null ? orElse() : value(e),
};
}
R? mapOrNull<R>({
R Function(LateValue<T> e)? value,
R Function(LateError<T> e)? error,
R Function(LateLoading<T> e)? loading,
}) {
final e = this;
return switch (e) {
LateLoading() => loading?.call(e),
LateError() => error?.call(e),
LateValue() => value?.call(e),
};
}
R when<R>({
required R Function() loading,
required R Function(dynamic error) error,
required R Function(T value) value,
}) {
final e = this;
return switch (e) {
LateLoading() => loading(),
LateError() => error(e.error),
LateValue() => value(e.value),
};
}
R? whenOrNull<R>({
R Function()? loading,
R Function([dynamic error])? error,
R Function(T value)? value,
}) {
final e = this;
return switch (e) {
LateLoading() => loading?.call(),
LateError() => error?.call(e.error),
LateValue() => value?.call(e.value),
};
}
R maybeWhen<R>({
required R Function() orElse,
R Function()? loading,
R Function([dynamic error])? error,
R Function(T value)? value,
}) {
final e = this;
return switch (e) {
LateLoading() => loading == null ? orElse() : loading(),
LateError() => error == null ? orElse() : error(e.error),
LateValue() => value == null ? orElse() : value(e.value),
};
}
}
class LateError<T> implements Late<T> {
const LateError([this.error]);
final dynamic error;
@override
bool operator ==(Object other) =>
other is LateError &&
other.runtimeType == runtimeType &&
other.error == error;
@override
int get hashCode => Object.hashAll([error]);
}
class LateLoading<T> implements Late<T> {
const LateLoading();
@override
bool operator ==(Object other) =>
other is LateLoading && other.runtimeType == runtimeType;
@override
int get hashCode => 0;
}
class LateValue<T> implements Late<T> {
const LateValue(this.value);
final T value;
@override
bool operator ==(Object other) =>
other is LateValue &&
other.runtimeType == runtimeType &&
other.value == value;
@override
int get hashCode => Object.hashAll([value]);
}

View File

@@ -0,0 +1,291 @@
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'emitter.dart';
/// A `List<T>` wrapper that emits change notifications for all mutating
/// operations. Nonmutating queries delegate to the underlying list.
/// A `List<T>` wrapper that emits change notifications for all mutating
/// operations. Nonmutating queries delegate to the underlying list.
///
/// {@tool snippet}
/// ```dart
/// final list = ListEmitter<int>();
/// list.addListener(() => print('len: ${list.length}'));
/// list.add(1); // prints len: 1
/// ```
/// {@end-tool}
class ListEmitter<T> with Emitter implements List<T>, ValueListenable<List<T>> {
List<T> _list;
ListEmitter([Iterable<T>? items]) : _list = List.from(items ?? <T>[]);
@override
T get first => _list.first;
set first(T value) {
_list.first = value;
notifyListeners();
}
@override
T get last => _list.last;
set last(T value) {
_list.last = value;
notifyListeners();
}
@override
int get length => _list.length;
set length(int value) {
_list.length = value;
notifyListeners();
}
@override
List<T> operator +(List<T> other) {
return _list + other;
}
@override
T operator [](int index) => _list[index];
@override
void operator []=(int index, T value) {
_list[index] = value;
notifyListeners();
}
@override
void add(T value) {
_list.add(value);
notifyListeners();
}
@override
void addAll(Iterable<T> iterable) {
_list.addAll(iterable);
notifyListeners();
}
@override
bool any(bool Function(T element) test) => _list.any(test);
@override
Map<int, T> asMap() => _list.asMap();
@override
List<R> cast<R>() => _list.cast<R>();
@override
void clear() {
_list.clear();
notifyListeners();
}
@override
bool contains(Object? element) => _list.contains(element);
@override
T elementAt(int index) => _list.elementAt(index);
@override
bool every(bool Function(T element) test) => _list.every(test);
@override
Iterable<R> expand<R>(Iterable<R> f(T element)) => _list.expand<R>(f);
@override
void fillRange(int start, int end, [T? fillValue]) =>
_list.fillRange(start, end, fillValue);
@override
T firstWhere(bool Function(T element) test, {T Function()? orElse}) =>
_list.firstWhere(test, orElse: orElse);
@override
R fold<R>(R initialValue, R Function(R previousValue, T element) combine) =>
_list.fold(initialValue, combine);
@override
Iterable<T> followedBy(Iterable<T> other) => _list.followedBy(other);
@override
void forEach(void Function(T element) f) => _list.forEach(f);
@override
Iterable<T> getRange(int start, int end) => _list.getRange(start, end);
@override
int indexOf(T element, [int start = 0]) => _list.indexOf(element, start);
@override
int indexWhere(bool Function(T element) test, [int start = 0]) =>
_list.indexWhere(test, start);
@override
void insert(int index, T element) {
_list.insert(index, element);
notifyListeners();
}
@override
void insertAll(int index, Iterable<T> iterable) {
_list.insertAll(index, iterable);
notifyListeners();
}
@override
bool get isEmpty => _list.isEmpty;
@override
bool get isNotEmpty => _list.isNotEmpty;
@override
Iterator<T> get iterator => _list.iterator;
@override
String join([String separator = ""]) => _list.join(separator);
@override
int lastIndexOf(T element, [int? start]) => _list.lastIndexOf(element, start);
@override
int lastIndexWhere(bool Function(T element) test, [int? start]) =>
_list.lastIndexWhere(test, start);
@override
T lastWhere(bool Function(T element) test, {T Function()? orElse}) =>
_list.lastWhere(test, orElse: orElse);
@override
Iterable<R> map<R>(R Function(T e) f) => _list.map<R>(f);
@override
T reduce(T Function(T value, T element) combine) => _list.reduce(combine);
@override
bool remove(Object? value) {
final result = _list.remove(value);
if (result) notifyListeners();
return result;
}
@override
T removeAt(int index) {
final result = _list.removeAt(index);
notifyListeners();
return result;
}
@override
T removeLast() {
final result = _list.removeLast();
notifyListeners();
return result;
}
@override
void removeRange(int start, int end) {
_list.removeRange(start, end);
notifyListeners();
}
@override
void removeWhere(bool Function(T element) test) {
_list.removeWhere(test);
notifyListeners();
}
@override
void replaceRange(int start, int end, Iterable<T> replacement) {
_list.replaceRange(start, end, replacement);
notifyListeners();
}
/// Replaces entire contents with [replacement] (single notification).
void replaceWith(Iterable<T> replacement) {
_list.clear();
_list.addAll(replacement);
notifyListeners();
}
/// Swaps underlying list reference with [list] and notifies.
void swap(List<T> list) {
_list = list;
notifyListeners();
}
@override
void retainWhere(bool Function(T element) test) {
_list.retainWhere(test);
notifyListeners();
}
@override
Iterable<T> get reversed => _list.reversed;
@override
void setAll(int index, Iterable<T> iterable) {
_list.setAll(index, iterable);
notifyListeners();
}
@override
void setRange(int start, int end, Iterable<T> iterable, [int skipCount = 0]) {
_list.setRange(start, end, iterable, skipCount);
notifyListeners();
}
@override
void shuffle([Random? random]) {
_list.shuffle(random);
notifyListeners();
}
@override
T get single => _list.single;
@override
T singleWhere(bool Function(T element) test, {T Function()? orElse}) =>
_list.singleWhere(test, orElse: orElse);
@override
Iterable<T> skip(int count) => _list.skip(count);
@override
Iterable<T> skipWhile(bool Function(T value) test) => _list.skipWhile(test);
@override
void sort([int Function(T a, T b)? compare]) {
_list.sort(compare);
notifyListeners();
}
@override
List<T> sublist(int start, [int? end]) => _list.sublist(start, end);
@override
Iterable<T> take(int count) => _list.take(count);
@override
Iterable<T> takeWhile(bool Function(T value) test) => _list.takeWhile(test);
@override
List<T> toList({bool growable = true}) => _list.toList(growable: growable);
@override
Set<T> toSet() => _list.toSet();
@override
Iterable<T> where(bool Function(T element) test) => _list.where(test);
@override
Iterable<R> whereType<R>() => _list.whereType<R>();
@override
List<T> get value => _list;
}

View File

@@ -0,0 +1,5 @@
import 'package:flutter/widgets.dart';
mixin Presenter {
Widget buildPresenter(BuildContext context);
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/foundation.dart';
import 'dispose.dart';
import 'emitter.dart';
/// Minimal synchronous event bus for simple message fanout.
///
/// {@tool snippet}
/// ```dart
/// sealed class AppEvent {}
/// class SignedIn extends AppEvent { SignedIn(this.user); final String user; }
/// final bus = Publisher<AppEvent>();
/// bus.on<SignedIn>((e) => print('Hello \\${e.user}'));
/// bus.publish(SignedIn('Alice'));
/// ```
/// {@end-tool}
class Publisher<T> with Dispose {
Publisher() {
_subscriptions.clear.disposeBy(this);
}
final List<ValueSetter<T>> _subscriptions = [];
/// Adds a raw subscription returning a removal callback.
VoidCallback subscribe(ValueSetter callback) {
_subscriptions.add(callback);
return () => _subscriptions.remove(callback);
}
/// Publishes an event to all current subscribers.
void publish(T msg) {
for (final subscription in _subscriptions.toList()) {
subscription(msg);
}
}
/// Filters events of subtype [E]. Optional immediate [callback] plus a
/// returned [Emitter] for builder/listener integration.
Emitter on<E extends T>([ValueSetter<E>? callback]) {
final notifier = PublicEmitter();
subscribe((e) {
if (e is E) {
callback?.call(e);
notifier.notifyListeners();
}
}).disposeBy(notifier);
return notifier;
}
}

24
lib/src/core/range.dart Normal file
View File

@@ -0,0 +1,24 @@
class Range {
Range(this.a, this.b) : assert(a <= b);
double a;
double b;
bool contains(double y) => a <= y && y <= b;
bool intersects(double x0, double x1) {
return contains(x0) || contains(x1) || (x0 < a && b < x1);
}
Range shift(double offset) {
a += offset;
b += offset;
return this;
}
Range inflate(double extent) {
a -= extent;
b += extent;
return this;
}
}

View File

@@ -0,0 +1,7 @@
import 'dart:async';
/// Optional mixin for services needing asynchronous post-construction setup.
mixin AsyncInit {
/// Performs initialisation; awaited automatically for lazy registrations.
FutureOr<void> init();
}

View File

@@ -0,0 +1,16 @@
import 'package:ux/src/core/store/factory/store_factory.dart';
/// Factory wrapping a pre-instantiated singleton.
class InstanceStoreFactory<T> extends StoreFactory<T> {
InstanceStoreFactory(T value) : _value = value;
final T _value;
Future<T> get future async => _value;
@override
T get instance => _value;
@override
String toString() => 'Instance: $T ${instance.runtimeType}';
}

View File

@@ -0,0 +1,37 @@
import 'dart:async';
import 'package:ux/src/core/core.dart';
/// Lazily creates & caches an instance (supports [AsyncInit]).
class LazyStoreFactory<T> extends StoreFactory<T> {
LazyStoreFactory({
required this.resolver,
required this.delegate,
});
final ResolverCreateDelegate<T> delegate;
final Resolver resolver;
Completer<T>? _completer;
T? _instance;
Future<T> get future async {
if (_completer == null) {
_completer = Completer<T>();
final instance = await delegate(resolver);
if (instance is AsyncInit) {
await instance.init();
}
_instance = instance;
_completer!.complete(instance);
}
return _completer!.future;
}
@override
T get instance => _instance == null
? throw Exception('Service not initialized: $T')
: _instance!;
@override
String toString() => 'Lazy $T ${_instance?.runtimeType}';
}

View File

@@ -0,0 +1,16 @@
/// Decorator for [StoreFactory]
/// It will emit a new instance each time it's requested
/// Note: This is not an enforcement, only a hint
/// Marker mixin indicating a factory yields transient (non-cached) instances.
mixin TransientFactory {}
abstract class StoreFactory<T> {
/// Future for async resolution / warm-up path.
Future<T> get future;
/// Synchronous instance access (may throw if not ready for lazy types).
T get instance;
/// Passes strongly typed instance to [fn] while retaining generic [T].
R pipeInstance<R>(R Function<T>(T instance) fn) => fn<T>(instance);
}

View File

@@ -0,0 +1,20 @@
import 'package:ux/src/core/core.dart';
/// Always produces a fresh instance on access.
class TransientStoreFactory<T> extends StoreFactory<T> with TransientFactory {
TransientStoreFactory({
required this.locator,
required this.delegate,
});
final LocatorCreateDelegate<T> delegate;
final Locator locator;
Future<T> get future async => instance;
@override
T get instance => delegate(locator);
@override
String toString() => 'Transient $T';
}

View File

@@ -0,0 +1,117 @@
import 'package:ux/src/core/store/factory/instance_store_factory.dart';
import 'package:ux/src/core/store/factory/lazy_store_factory.dart';
import 'package:ux/src/core/store/factory/store_factory.dart';
import 'package:ux/src/core/store/factory/transient_store_factory.dart';
export 'package:ux/src/core/store/factory/instance_store_factory.dart';
export 'package:ux/src/core/store/factory/lazy_store_factory.dart';
export 'package:ux/src/core/store/factory/store_factory.dart';
export 'package:ux/src/core/store/factory/transient_store_factory.dart';
typedef LocatorCreateDelegate<T> = T Function(Locator e);
typedef ResolverCreateDelegate<T> = Future<T> Function(Resolver e);
typedef FactoryDelegate<T> = StoreFactory<T> Function(Resolver e);
/// Sync locator
mixin Locator {
/// Returns a synchronously available instance (throws if not initialised
/// or registered; lazy entries must be warmed by [Register.init]).
T get<T>();
}
/// Async locator (mainly to enable lazy async)
mixin Resolver {
/// Resolves (and initialises if lazy) a registered service asynchronously.
Future<T> resolve<T>();
}
mixin Register on Resolver, Locator {
Map<Type, StoreFactory> registry = {};
/// Activates registered factories (except [TransientFactory])
/// This will enable a safe use of [Locator.get]
Future<void> init() async {
await Future.wait<void>(
registry.values.whereType<LazyStoreFactory>().map((e) => e.future),
eagerError: true,
);
}
/// Registers a prebuilt singleton [instance].
void add<T>(T instance) => addFactory((e) => InstanceStoreFactory(instance));
/// Registers a custom factory.
void addFactory<T>(FactoryDelegate<T> factory) => registry[T] = factory(this);
/// Registers a lazily created, cached async/sync singleton.
void addLazy<T>(ResolverCreateDelegate<T> delegate) => addFactory(
(e) => LazyStoreFactory<T>(resolver: this, delegate: delegate),
);
/// Registers a transient factory (new instance each request).
void addTransient<T>(LocatorCreateDelegate<T> delegate) => addFactory(
(e) => TransientStoreFactory<T>(locator: this, delegate: delegate),
);
}
/// Concrete store implementing locator + resolver behaviour.
///
/// {@tool snippet}
/// ```dart
/// final store = Store()
/// ..add(Logger())
/// ..addLazy<Config>((r) async => Config())
/// ..addTransient<DateTime>((l) => DateTime.now());
///
/// await store.init(); // warm lazy singletons
/// final logger = store.get<Logger>(); // sync
/// final config = await store.resolve<Config>(); // async
/// ```
/// {@end-tool}
class Store with Locator, Resolver, Register {
@override
Future<T> resolve<T>() async {
final entry = registry[T];
if (entry == null) {
throw Exception('$T is not registered (Store)');
}
final instance = await (entry.future as Future<T>);
return instance;
}
@override
T get<T>() {
final entry = registry[T];
if (entry == null) {
throw Exception('$T is not registered (Store)');
}
return entry.instance as T;
}
T? tryGet<T>() {
try {
return get<T>();
} catch (e) {
return null;
}
}
}
class ScopedLocator with Locator {
ScopedLocator(this.parent);
Locator parent;
final Map<Type, StoreFactory> _registry = {};
@override
T get<T>() => _registry[T]?.instance ?? parent.get<T>();
void add<T>(T instance) {
_registry[T] = InstanceStoreFactory(instance);
}
}
extension LocatorExtension on Locator {
ScopedLocator scoped() => ScopedLocator(this);
}

View File

@@ -0,0 +1,30 @@
import 'package:flutter/widgets.dart';
import 'package:ux/src/core/core.dart';
/// Provides a [Store] to the widget tree without creating an inherited
/// dependency (safe for `initState` reads).
///
/// Useful extension to be added.
/// extension StoreProviderExtension on BuildContext {
// /// Shortcut for `StoreProvider.of(context).store.get<T>()`.
// T get<T>() => StoreProvider.of(this).store.get<T>();
// }
class StoreProvider extends StatelessWidget {
const StoreProvider({
required this.store,
required this.builder,
Key? key,
}) : super(key: key);
final Store store;
final WidgetBuilder builder;
static StoreProvider of(BuildContext context) {
final result = context.findAncestorWidgetOfExactType<StoreProvider>();
assert(result != null, 'StoreProvider not found');
return result!;
}
@override
Widget build(BuildContext context) => Builder(builder: builder);
}

View File

@@ -0,0 +1,103 @@
import 'package:flutter/widgets.dart';
import 'emitter.dart';
import 'functional.dart';
/// A subscription to multiple listenables that notifies when any of them
/// change. Optionally, a selector can be provided to only notify when the
/// selected value changes.
class Subscription with Emitter {
/// Adds a listenable to the subscription.
/// Registers [listenable]. Optional [select] narrows change detection;
/// [when] gates notifications. Returns this for chaining.
Subscription add<T extends Listenable>(
T listenable, {
/// - [select]->[R] can be used to only notify when [R] changes
Object? Function(T)? select,
/// - [when] can be used to only notify if [true]
Predicate<T>? when,
}) {
var callback = notifyListeners;
if (select != null) {
callback = callback.pipe(
(e) {
var oldSelect = select(listenable);
return () {
final newSelect = select(listenable);
if (newSelect != oldSelect) {
oldSelect = newSelect;
e();
}
};
},
);
}
if (when != null) {
callback = callback.pipe(
(e) => () {
if (when(listenable)) {
e();
}
},
);
}
listenable.subscribe(callback).disposeBy(this);
//ignore: avoid_returning_this
return this;
}
}
/// Creates a [Subscription] to listen to multiple listenables and rebuilds the
/// widget tree when any of them change.
///
/// The [register] callback is used to specify the listenables to subscribe to.
class SubscriptionBuilder extends StatefulWidget {
const SubscriptionBuilder({
required this.register,
required this.builder,
this.child,
super.key,
});
/// Registers listenables given a [Subscription]
final ValueSetter<Subscription> register;
final TransitionBuilder builder;
final Widget? child;
@override
State<SubscriptionBuilder> createState() => _SubscriptionBuilderState();
}
class _SubscriptionBuilderState extends State<SubscriptionBuilder> {
late Subscription subscription;
void register() {
subscription = Subscription()
..pipe(widget.register)
..subscribe(() => setState(() {}));
}
@override
void initState() {
super.initState();
register();
}
@override
void didUpdateWidget(covariant SubscriptionBuilder oldWidget) {
super.didUpdateWidget(oldWidget);
subscription.dispose();
register();
}
@override
void dispose() {
subscription.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.builder(context, widget.child);
}

154
lib/src/core/tasks.dart Normal file
View File

@@ -0,0 +1,154 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'dispose.dart';
import 'emitter.dart';
/// Token allowing async work to cooperatively check for cancellation.
abstract class CancellationToken {
/// if(isCancelled) throw [TaskCancelledException]
void ensureRunning();
bool get isCancelled;
}
typedef TaskDelegate = Future<void> Function();
typedef CancellableTaskDelegate = Future<void> Function(
CancellationToken token);
typedef TypedTaskDelegate<T> = Future<T> Function();
/// Thrown when a queued task is cancelled (explicitly or during disposal).
class TaskCancelledException implements Exception {}
class _Task<T> implements CancellationToken {
_Task(
this.task, {
this.onDone,
}) : _isCancelled = false;
final CancellableTaskDelegate task;
final VoidCallback? onDone;
bool _isCancelled;
bool get isCancelled => _isCancelled;
final _completer = Completer<T?>();
void done() {
onDone?.call();
}
void cancel() {
_isCancelled = true;
}
void complete([FutureOr<T?> value]) {
_completer.complete(value);
done();
}
void completeError(Object error) {
_completer.completeError(error);
done();
}
Future<void> run() => task.call(this);
Future<T?> get future => _completer.future;
@override
void ensureRunning() {
if (isCancelled) {
throw TaskCancelledException();
}
}
}
/// [Tasks] mixin provides an easy way to sequence async work
/// Eg:
/// ...
/// enqueue(() async { await Future.delayed(Duration(seconds: 10)); print('10 secs'); });
/// enqueue(() async { await Future.delayed(Duration(seconds: 10)); print('1 sec'); });
/// Output:
/// 10 secs
/// 1 sec
/// Provides a FIFO async task queue (single concurrency) with cancellation.
mixin Tasks on Dispose {
final _queue = <_Task<void>>[];
/// Enqueues [task] for execution and return the completion future
/// Throws: [TaskCancelledException]
@nonVirtual
Future<void> enqueue(TaskDelegate task) => enqueueCancellable((_) => task());
/// Enqueues [task] for execution and return the completion future
/// Also provides the [CancellationToken] to the closure
/// Throws: [TaskCancelledException]
@nonVirtual
Future<void> enqueueCancellable(CancellableTaskDelegate task) {
if (!_isActive) {
_isActive = true;
disposeTasks.disposeBy(this);
}
final completer = _Task<void>(task);
_queue.add(completer);
_dequeue();
return completer.future;
}
/// Provides a future that will complete when the current queue completes
/// Throws: [TaskCancelledException]
Future<void> waitIdle() => enqueue(() async {});
/// Cancels all enqueued tasks
@nonVirtual
void cancelTasks() {
_queue.forEach((e) => e.cancel());
}
bool _isActive = false;
bool _isClosed = false;
int _runningTasks = 0;
final int _maxConcurrentTasks = 1;
/// Concurrently running tasks count
int get runningTasks => _runningTasks;
Future<void> _dequeue() async {
if (_isClosed || _runningTasks >= _maxConcurrentTasks || _queue.isEmpty) {
return;
}
_runningTasks++;
while (!_isClosed && _queue.isNotEmpty) {
final task = _queue.removeAt(0);
if (task.isCancelled) {
task.completeError(TaskCancelledException());
} else {
try {
await task.run();
if (_isClosed || task.isCancelled) {
task.completeError(TaskCancelledException());
} else {
task.complete();
}
} catch (e) {
task.completeError(e);
}
}
}
while (_isClosed && _queue.isNotEmpty) {
_queue.removeAt(0).completeError(TaskCancelledException());
}
_runningTasks--;
}
@protected
@nonVirtual
void disposeTasks() {
_isClosed = true;
}
}

200
lib/src/core/uri.dart Normal file
View File

@@ -0,0 +1,200 @@
import 'dart:developer' as dev;
/// We might have different variants for a slug, that can be localized - canonized
/// We expect the [UriCanonicalConverter] to return the local domain form of the slug
/// returns null when no match
typedef UriCanonicalConverter = String? Function(String slug);
/// Iterates configured [UriMap] patterns attempting to build an [Out] result.
///
/// {@tool snippet}
/// ```dart
/// final parser = UriParser<String, void>(
/// routes: [
/// UriMap('/users/{id:#}', (m) => 'User ' + m.pathParameters['id']!),
/// UriMap.many(['/posts/{slug:w}', '/blog/{slug:w}'],
/// (m) => 'Post ' + m.pathParameters['slug']!),
/// ],
/// );
/// final result = parser.parse(Uri.parse('/users/42'), null); // User 42
/// ```
/// {@end-tool}
class UriParser<Out, State> {
UriParser({
this.routes = const [],
this.canonical = const {},
});
final List<UriMap> routes;
final Map<String, UriCanonicalConverter> canonical;
/// Returns parsed domain object or `null` if no route matches.
Out? parse(Uri url, State state) {
for (final route in routes) {
for (final template in route.matchers) {
final match = template.match(url.path);
if (match != null) {
var isMatch = true;
if (match.keys.any(canonical.containsKey)) {
for (final entry in match.entries.toList()) {
final canonicalValue = canonical[entry.key]?.call(entry.value);
// not part of the canonical set
if (canonicalValue == null) {
isMatch = false;
break;
}
match[entry.key] = canonicalValue;
}
if (!isMatch) continue;
}
try {
final result = route.builder(UriMatch(url, match, state));
if (result != null) {
return result;
}
} catch (e, s) {
// exceptions are treated as non-matches
dev.log('''
UriParser.parse failed to build:
url: $url
pattern: ${template.pattern}
error: $e
stack: $s
''');
}
}
}
}
return null;
}
}
typedef UriMapBuilder<Out, State> = Out? Function(UriMatch<State> match);
/// Matched values & context provided to a [UriMapBuilder].
class UriMatch<State> {
UriMatch(
this.uri,
this.pathParameters,
this.state,
);
final Uri uri;
final Map<String, String> pathParameters;
final State state;
Map<String, String> get queryParameters => uri.queryParameters;
}
/// Associates one (or many) path patterns with a builder.
class UriMap<Out, State> {
UriMap(
String pattern,
this.builder, {
bool matchEnd = true,
}) : matchers = [PathMatcher(pattern, matchEnd: matchEnd)];
UriMap.many(
List<String> patterns,
this.builder, {
bool matchEnd = true,
}) : matchers =
patterns.map((e) => PathMatcher(e, matchEnd: matchEnd)).toList();
final List<PathMatcher> matchers;
final UriMapBuilder builder;
}
/// Starts with `/path/` and has a field name word
/// `/path/{name}`
/// Starts with `/path/` followed by a number
/// `/path/{number:#}`
/// Field regex
/// # - number
/// w - word
/// * - anything
/// Compiles a path pattern with named fields into a matching regex.
class PathMatcher {
PathMatcher(
this.pattern, {
// match end [$]
bool matchEnd = true,
}) {
var index = 0;
final regex = StringBuffer();
final fieldRegex = RegExp(r'{(?<field>([*]|(\w+)?(:[.*+#]+)?)+)}');
final fieldMatches = fieldRegex.allMatches(pattern);
for (final fieldMatch in fieldMatches) {
if (index < fieldMatch.start) {
regex.write(pattern.substring(index, fieldMatch.start));
}
final group = fieldMatch.namedGroup('field');
if (group == null) {
throw ArgumentError('Invalid pattern: $pattern', 'pattern');
}
final fieldRegexStart = group.indexOf(':');
var fieldName = group;
var fieldRegex = '';
if (fieldRegexStart != -1) {
fieldName = group.substring(0, fieldRegexStart);
if (fieldRegexStart < group.length) {
fieldRegex = group.substring(fieldRegexStart + 1);
}
}
String reg;
switch (fieldRegex) {
case '':
reg = '([-_]|\\w)+';
case '*':
reg = '.+';
case '#':
reg = '\\d+';
case 'w':
reg = '\\w+';
default:
reg = fieldRegex;
}
if (fieldName.isEmpty) {
regex.write(reg);
} else if (fieldName == '*') {
regex.write('.*');
} else {
fields.add(fieldName);
regex.write('(?<$fieldName>$reg)');
}
index = fieldMatch.end;
}
if (index < pattern.length) {
regex.write(pattern.substring(index));
}
if (matchEnd) regex.write('\$');
pathTemplate = RegExp(regex.toString());
}
/// Returns null if [pathTemplate] doesn't match the [path]
Map<String, String>? match(String path) {
final match = pathTemplate.firstMatch(path);
if (match == null || path != pathTemplate.stringMatch(path)) {
return null;
}
final map = fields.fold(
<String, String>{},
(p, e) => p..[e] = match.namedGroup(e)!,
);
return fields.every(map.containsKey) ? map : null;
}
late final RegExp pathTemplate;
final String pattern;
final Set<String> fields = {};
@override
String toString() {
return '$pathTemplate => (${fields.join(',')})';
}
}

56
lib/src/core/value.dart Normal file
View File

@@ -0,0 +1,56 @@
import 'package:flutter/animation.dart';
import 'package:flutter/foundation.dart';
import 'dispose.dart';
import 'emitter.dart';
mixin Value<T> {
set value(T value);
T get value;
}
class DelegatedValue<T> with Dispose, Value<T> {
DelegatedValue({required this.setter, required this.getter});
final ValueSetter<T> setter;
final ValueGetter<T> getter;
@override
set value(T value) => setter(value);
@override
T get value => getter();
}
extension ValueAnimationControllerExtension on AnimationController {
DelegatedValue<double> delegate() {
return DelegatedValue<double>(
setter: (e) => value = e,
getter: () => value,
);
}
// creates a two way link with a ValueEmitter
Disposable link(ValueEmitter<double> other) {
final it = this;
final diposer = Dispose();
void intoOther() {
if (other.value == it.value) return;
other.value = it.value;
}
void intoIt() {
if (other.value == it.value) return;
it.value = other.value;
}
it.subscribe(intoOther).disposeBy(diposer);
other.subscribe(intoIt).disposeBy(diposer);
intoIt();
return diposer;
}
}

29
lib/src/core/widget.dart Normal file
View File

@@ -0,0 +1,29 @@
import 'package:flutter/widgets.dart';
extension GlobalKeyExtension on GlobalKey {
Offset? get position {
final context = currentContext;
if (context == null) return null;
final box = context.findRenderObject() as RenderBox;
final pos = box.localToGlobal(Offset.zero);
return pos;
}
Size? get size {
final context = currentContext;
if (context == null) return null;
final box = context.findRenderObject() as RenderBox;
return box.size;
}
Rect? rect({Offset offset = Offset.zero}) {
final context = currentContext;
if (context == null) return null;
final box = context.findRenderObject() as RenderBox;
return box.localToGlobal(offset) & box.size;
}
}
extension SliverExtension on Widget {
Widget sliver({Key? key}) => SliverToBoxAdapter(key: key, child: this);
}

View File

@@ -10,8 +10,8 @@ import 'log.dart';
/// Pull persisted native crash records and re-emit them as [Log.f]. Call once /// Pull persisted native crash records and re-emit them as [Log.f]. Call once
/// during app boot, after `Log.configure(...)`, before [runApp]. /// during app boot, after `Log.configure(...)`, before [runApp].
class UxCrash { class XCrash {
UxCrash._(); XCrash._();
static const _channel = MethodChannel('ux/crash'); static const _channel = MethodChannel('ux/crash');

View File

@@ -8,8 +8,8 @@ import 'package:flutter/services.dart';
/// PNG ready to embed in a thumbnail proto / paint via `Image.memory`; /// PNG ready to embed in a thumbnail proto / paint via `Image.memory`;
/// [width] / [height] describe the encoded image, which may be smaller /// [width] / [height] describe the encoded image, which may be smaller
/// than the source video due to the `maxWidth` constraint at extraction. /// than the source video due to the `maxWidth` constraint at extraction.
class UxVideoThumbnail { class XVideoThumbnail {
const UxVideoThumbnail({ const XVideoThumbnail({
required this.pngBytes, required this.pngBytes,
required this.width, required this.width,
required this.height, required this.height,
@@ -23,8 +23,8 @@ class UxVideoThumbnail {
/// A handle to a file on local disk. Minimal — [path] is the only /// A handle to a file on local disk. Minimal — [path] is the only
/// guaranteed field. Returned from anything in `package:ux` that produces /// guaranteed field. Returned from anything in `package:ux` that produces
/// a file (camera capture today; future writers). /// a file (camera capture today; future writers).
class UxFile { class XFile {
const UxFile(this.path); const XFile(this.path);
/// Absolute path on local disk. Readable through `dart:io File`. Lifetime /// Absolute path on local disk. Readable through `dart:io File`. Lifetime
/// is producer-defined — camera capture writes to a temp dir, the /// is producer-defined — camera capture writes to a temp dir, the
@@ -37,13 +37,13 @@ class UxFile {
/// ///
/// On macOS / iOS the picker returns the user's original location (no /// On macOS / iOS the picker returns the user's original location (no
/// temp-dir copy); access across cold restarts is preserved by storing /// temp-dir copy); access across cold restarts is preserved by storing
/// [bookmark] and re-acquiring scope via [UxFiles.withScopedAccess] / /// [bookmark] and re-acquiring scope via [XFiles.withScopedAccess] /
/// [UxFiles.open] / [UxFiles.showInFolder]. On Android the native side /// [XFiles.open] / [XFiles.showInFolder]. On Android the native side
/// stream-copies a `content://` source into the app cache (since /// stream-copies a `content://` source into the app cache (since
/// `dart:io` can't open content URIs); [bookmark] holds the source URI /// `dart:io` can't open content URIs); [bookmark] holds the source URI
/// as UTF-8 bytes for symmetry but isn't required for reads. /// as UTF-8 bytes for symmetry but isn't required for reads.
class UxPickedFile { class XPickedFile {
const UxPickedFile({ const XPickedFile({
required this.path, required this.path,
this.name, this.name,
this.mimeType, this.mimeType,
@@ -62,8 +62,8 @@ class UxPickedFile {
final Uint8List? bookmark; final Uint8List? bookmark;
} }
class UxFiles { class XFiles {
UxFiles._(); XFiles._();
static const _channel = MethodChannel('ux/file'); static const _channel = MethodChannel('ux/file');
@@ -171,7 +171,7 @@ class UxFiles {
/// (`image/*`, `video/*`, `application/pdf`) work on all three. For /// (`image/*`, `video/*`, `application/pdf`) work on all three. For
/// Apple-specific types prefer concrete MIME like `image/jpeg` over /// Apple-specific types prefer concrete MIME like `image/jpeg` over
/// wildcards. /// wildcards.
static Future<UxPickedFile?> pick({ static Future<XPickedFile?> pick({
List<String>? mimeTypes, List<String>? mimeTypes,
}) async { }) async {
final result = await _channel.invokeMapMethod<String, Object?>('pick', { final result = await _channel.invokeMapMethod<String, Object?>('pick', {
@@ -180,7 +180,7 @@ class UxFiles {
if (result == null) return null; if (result == null) return null;
final path = result['path'] as String?; final path = result['path'] as String?;
if (path == null) return null; if (path == null) return null;
return UxPickedFile( return XPickedFile(
path: path, path: path,
name: result['name'] as String?, name: result['name'] as String?,
mimeType: result['mimeType'] as String?, mimeType: result['mimeType'] as String?,
@@ -220,7 +220,7 @@ class UxFiles {
/// [atMs] picks the frame timestamp in milliseconds (default 0 = first /// [atMs] picks the frame timestamp in milliseconds (default 0 = first
/// available keyframe). [maxWidth] caps the output's longer edge while /// available keyframe). [maxWidth] caps the output's longer edge while
/// preserving aspect ratio. /// preserving aspect ratio.
static Future<UxVideoThumbnail?> videoThumbnail({ static Future<XVideoThumbnail?> videoThumbnail({
required String path, required String path,
int atMs = 0, int atMs = 0,
int maxWidth = 320, int maxWidth = 320,
@@ -238,6 +238,6 @@ class UxFiles {
final width = (result['width'] as num?)?.toInt(); final width = (result['width'] as num?)?.toInt();
final height = (result['height'] as num?)?.toInt(); final height = (result['height'] as num?)?.toInt();
if (bytes == null || width == null || height == null) return null; if (bytes == null || width == null || height == null) return null;
return UxVideoThumbnail(pngBytes: bytes, width: width, height: height); return XVideoThumbnail(pngBytes: bytes, width: width, height: height);
} }
} }

View File

@@ -7,22 +7,22 @@ import 'package:flutter/services.dart';
/// Mirrors the union of `PHAuthorizationStatus` (iOS / macOS) and /// Mirrors the union of `PHAuthorizationStatus` (iOS / macOS) and
/// Android's manifest-permission outcomes: /// Android's manifest-permission outcomes:
/// - [notDetermined] — never asked. Prompt the user with /// - [notDetermined] — never asked. Prompt the user with
/// [UxGallery.requestPermission]. /// [XGallery.requestPermission].
/// - [denied] — user said no. [UxGallery.openSettings] is the only /// - [denied] — user said no. [XGallery.openSettings] is the only
/// way back. /// way back.
/// - [restricted] — parental controls / MDM. Same UI as [denied]. /// - [restricted] — parental controls / MDM. Same UI as [denied].
/// - [limited] — iOS 14+. User picked a subset; the grid still /// - [limited] — iOS 14+. User picked a subset; the grid still
/// populates from that subset. Call /// populates from that subset. Call
/// [UxGallery.presentLimitedLibraryPicker] to let them adjust. /// [XGallery.presentLimitedLibraryPicker] to let them adjust.
/// - [granted] — full access. /// - [granted] — full access.
enum UxGalleryPermission { notDetermined, denied, restricted, limited, granted } enum XGalleryPermission { notDetermined, denied, restricted, limited, granted }
enum UxAssetKind { image, video } enum XAssetKind { image, video }
/// A user-visible album / collection in the system photo library /// A user-visible album / collection in the system photo library
/// (`PHAssetCollection` on Apple, `MediaStore.Bucket` on Android). /// (`PHAssetCollection` on Apple, `MediaStore.Bucket` on Android).
class UxAlbum { class XAlbum {
const UxAlbum({ const XAlbum({
required this.id, required this.id,
required this.name, required this.name,
required this.count, required this.count,
@@ -32,13 +32,13 @@ class UxAlbum {
final String id; final String id;
final String name; final String name;
final int count; final int count;
final UxAssetKind? coverKind; final XAssetKind? coverKind;
} }
/// A single asset (photo or video) in the system library /// A single asset (photo or video) in the system library
/// (`PHAsset` / `ContentResolver` row). /// (`PHAsset` / `ContentResolver` row).
class UxAsset { class XAsset {
const UxAsset({ const XAsset({
required this.id, required this.id,
required this.kind, required this.kind,
this.duration, this.duration,
@@ -49,7 +49,7 @@ class UxAsset {
/// Stable identifier (e.g. iOS `localIdentifier`, Android `content://` URI). /// Stable identifier (e.g. iOS `localIdentifier`, Android `content://` URI).
final String id; final String id;
final UxAssetKind kind; final XAssetKind kind;
/// Duration for videos; null for photos. /// Duration for videos; null for photos.
final Duration? duration; final Duration? duration;
@@ -61,8 +61,8 @@ class UxAsset {
/// Cell-sized thumbnail bytes ready for `Image.memory`. Backed by /// Cell-sized thumbnail bytes ready for `Image.memory`. Backed by
/// `PHCachingImageManager` on Apple and `MediaStore.Thumbnails` on /// `PHCachingImageManager` on Apple and `MediaStore.Thumbnails` on
/// Android. /// Android.
class UxAssetThumbnail { class XAssetThumbnail {
const UxAssetThumbnail({ const XAssetThumbnail({
required this.bytes, required this.bytes,
required this.width, required this.width,
required this.height, required this.height,
@@ -73,23 +73,23 @@ class UxAssetThumbnail {
final int height; final int height;
} }
/// Backend contract that [UxGallery] dispatches into. The default /// Backend contract that [XGallery] dispatches into. The default
/// implementation calls into native code via the `ux/gallery` method /// implementation calls into native code via the `ux/gallery` method
/// channel; tests substitute their own (see /// channel; tests substitute their own (see
/// `ux/lib/testing.dart`'s `FakeUxGalleryBackend`). /// `ux/lib/testing.dart`'s `FakeXGalleryBackend`).
abstract class UxGalleryBackend { abstract class XGalleryBackend {
Future<UxGalleryPermission> permission(); Future<XGalleryPermission> permission();
Future<UxGalleryPermission> requestPermission(); Future<XGalleryPermission> requestPermission();
Future<void> openSettings(); Future<void> openSettings();
Future<void> presentLimitedLibraryPicker(); Future<void> presentLimitedLibraryPicker();
Future<List<UxAlbum>> albums({UxAssetKind? filter}); Future<List<XAlbum>> albums({XAssetKind? filter});
Future<List<UxAsset>> assets({ Future<List<XAsset>> assets({
String? albumId, String? albumId,
UxAssetKind? filter, XAssetKind? filter,
required int start, required int start,
required int end, required int end,
}); });
Future<UxAssetThumbnail> thumbnail(String assetId, {required int sizePx}); Future<XAssetThumbnail> thumbnail(String assetId, {required int sizePx});
Future<io.File> resolveFile(String assetId); Future<io.File> resolveFile(String assetId);
/// Emits whenever the underlying photo library reports a change — /// Emits whenever the underlying photo library reports a change —
@@ -103,17 +103,17 @@ abstract class UxGalleryBackend {
/// Static facade for the system photo library. All state lives in the /// Static facade for the system photo library. All state lives in the
/// platform; this class is a thin Dart-side wrapper that dispatches /// platform; this class is a thin Dart-side wrapper that dispatches
/// into [backend]. /// into [backend].
class UxGallery { class XGallery {
UxGallery._(); XGallery._();
/// Swap to inject a fake (e.g. /// Swap to inject a fake (e.g.
/// `FakeUxGalleryBackend` from `package:ux/testing.dart`) before /// `FakeXGalleryBackend` from `package:ux/testing.dart`) before
/// any UI code mounts. /// any UI code mounts.
static UxGalleryBackend backend = MethodChannelGalleryBackend(); static XGalleryBackend backend = MethodChannelGalleryBackend();
static Future<UxGalleryPermission> permission() => backend.permission(); static Future<XGalleryPermission> permission() => backend.permission();
static Future<UxGalleryPermission> requestPermission() => static Future<XGalleryPermission> requestPermission() =>
backend.requestPermission(); backend.requestPermission();
static Future<void> openSettings() => backend.openSettings(); static Future<void> openSettings() => backend.openSettings();
@@ -125,14 +125,14 @@ class UxGallery {
/// Albums in the user's library, ordered Recents → smart → user. /// Albums in the user's library, ordered Recents → smart → user.
/// Pass [filter] to restrict to image-only / video-only albums. /// Pass [filter] to restrict to image-only / video-only albums.
static Future<List<UxAlbum>> albums({UxAssetKind? filter}) => static Future<List<XAlbum>> albums({XAssetKind? filter}) =>
backend.albums(filter: filter); backend.albums(filter: filter);
/// Paginate assets within [albumId] (or all assets if null), sorted /// Paginate assets within [albumId] (or all assets if null), sorted
/// by `createdAt DESC`. [start] inclusive, [end] exclusive. /// by `createdAt DESC`. [start] inclusive, [end] exclusive.
static Future<List<UxAsset>> assets({ static Future<List<XAsset>> assets({
String? albumId, String? albumId,
UxAssetKind? filter, XAssetKind? filter,
required int start, required int start,
required int end, required int end,
}) => }) =>
@@ -146,7 +146,7 @@ class UxGallery {
/// Cell-sized thumbnail at `~max(width,height) <= sizePx`. Native /// Cell-sized thumbnail at `~max(width,height) <= sizePx`. Native
/// caches keep repeated calls cheap; the caller still maintains a /// caches keep repeated calls cheap; the caller still maintains a
/// small Dart-side LRU keyed by `(assetId, sizePx)`. /// small Dart-side LRU keyed by `(assetId, sizePx)`.
static Future<UxAssetThumbnail> thumbnail( static Future<XAssetThumbnail> thumbnail(
String assetId, { String assetId, {
required int sizePx, required int sizePx,
}) => }) =>
@@ -164,10 +164,10 @@ class UxGallery {
static Stream<void> get libraryChanges => backend.libraryChanges; static Stream<void> get libraryChanges => backend.libraryChanges;
} }
/// Default [UxGalleryBackend] — dispatches to native code via the /// Default [XGalleryBackend] — dispatches to native code via the
/// `ux/gallery` method channel. Public so test code can reinstall it /// `ux/gallery` method channel. Public so test code can reinstall it
/// after swapping to a fake. /// after swapping to a fake.
class MethodChannelGalleryBackend implements UxGalleryBackend { class MethodChannelGalleryBackend implements XGalleryBackend {
static const _channel = MethodChannel('ux/gallery'); static const _channel = MethodChannel('ux/gallery');
static const _changesChannel = EventChannel('ux/gallery/changes'); static const _changesChannel = EventChannel('ux/gallery/changes');
@@ -178,26 +178,26 @@ class MethodChannelGalleryBackend implements UxGalleryBackend {
late final Stream<void> libraryChanges = late final Stream<void> libraryChanges =
_changesChannel.receiveBroadcastStream().map((_) {}); _changesChannel.receiveBroadcastStream().map((_) {});
static String _kindArg(UxAssetKind? k) => switch (k) { static String _kindArg(XAssetKind? k) => switch (k) {
null => 'any', null => 'any',
UxAssetKind.image => 'image', XAssetKind.image => 'image',
UxAssetKind.video => 'video', XAssetKind.video => 'video',
}; };
static UxGalleryPermission _parsePermission(String? name) => static XGalleryPermission _parsePermission(String? name) =>
switch (name) { switch (name) {
'notDetermined' => UxGalleryPermission.notDetermined, 'notDetermined' => XGalleryPermission.notDetermined,
'denied' => UxGalleryPermission.denied, 'denied' => XGalleryPermission.denied,
'restricted' => UxGalleryPermission.restricted, 'restricted' => XGalleryPermission.restricted,
'limited' => UxGalleryPermission.limited, 'limited' => XGalleryPermission.limited,
'granted' => UxGalleryPermission.granted, 'granted' => XGalleryPermission.granted,
_ => UxGalleryPermission.denied, _ => XGalleryPermission.denied,
}; };
static UxAssetKind _parseKind(Object? v) => static XAssetKind _parseKind(Object? v) =>
v == 'video' ? UxAssetKind.video : UxAssetKind.image; v == 'video' ? XAssetKind.video : XAssetKind.image;
static UxAsset _parseAsset(Map<Object?, Object?> m) => UxAsset( static XAsset _parseAsset(Map<Object?, Object?> m) => XAsset(
id: m['id']! as String, id: m['id']! as String,
kind: _parseKind(m['kind']), kind: _parseKind(m['kind']),
duration: m['duration_ms'] != null duration: m['duration_ms'] != null
@@ -210,7 +210,7 @@ class MethodChannelGalleryBackend implements UxGalleryBackend {
), ),
); );
static UxAlbum _parseAlbum(Map<Object?, Object?> m) => UxAlbum( static XAlbum _parseAlbum(Map<Object?, Object?> m) => XAlbum(
id: m['id']! as String, id: m['id']! as String,
name: m['name']! as String, name: m['name']! as String,
count: (m['count']! as num).toInt(), count: (m['count']! as num).toInt(),
@@ -218,13 +218,13 @@ class MethodChannelGalleryBackend implements UxGalleryBackend {
); );
@override @override
Future<UxGalleryPermission> permission() async { Future<XGalleryPermission> permission() async {
final s = await _channel.invokeMethod<String>('permission'); final s = await _channel.invokeMethod<String>('permission');
return _parsePermission(s); return _parsePermission(s);
} }
@override @override
Future<UxGalleryPermission> requestPermission() async { Future<XGalleryPermission> requestPermission() async {
final s = await _channel.invokeMethod<String>('requestPermission'); final s = await _channel.invokeMethod<String>('requestPermission');
return _parsePermission(s); return _parsePermission(s);
} }
@@ -240,7 +240,7 @@ class MethodChannelGalleryBackend implements UxGalleryBackend {
} }
@override @override
Future<List<UxAlbum>> albums({UxAssetKind? filter}) async { Future<List<XAlbum>> albums({XAssetKind? filter}) async {
final list = await _channel.invokeMethod<List<Object?>>( final list = await _channel.invokeMethod<List<Object?>>(
'albums', 'albums',
{'filter': _kindArg(filter)}, {'filter': _kindArg(filter)},
@@ -252,9 +252,9 @@ class MethodChannelGalleryBackend implements UxGalleryBackend {
} }
@override @override
Future<List<UxAsset>> assets({ Future<List<XAsset>> assets({
String? albumId, String? albumId,
UxAssetKind? filter, XAssetKind? filter,
required int start, required int start,
required int end, required int end,
}) async { }) async {
@@ -274,7 +274,7 @@ class MethodChannelGalleryBackend implements UxGalleryBackend {
} }
@override @override
Future<UxAssetThumbnail> thumbnail( Future<XAssetThumbnail> thumbnail(
String assetId, { String assetId, {
required int sizePx, required int sizePx,
}) async { }) async {
@@ -282,7 +282,7 @@ class MethodChannelGalleryBackend implements UxGalleryBackend {
'thumbnail', 'thumbnail',
{'assetId': assetId, 'sizePx': sizePx}, {'assetId': assetId, 'sizePx': sizePx},
); );
return UxAssetThumbnail( return XAssetThumbnail(
bytes: m!['bytes']! as Uint8List, bytes: m!['bytes']! as Uint8List,
width: (m['width']! as num).toInt(), width: (m['width']! as num).toInt(),
height: (m['height']! as num).toInt(), height: (m['height']! as num).toInt(),

View File

@@ -95,11 +95,11 @@ double _inverseLerp(List<double> samples, double value) {
/// Use the singleton [instance] and listen for changes via [addListener]: /// Use the singleton [instance] and listen for changes via [addListener]:
/// ///
/// ```dart /// ```dart
/// final keyboard = UxKeyboard.instance; /// final keyboard = XKeyboard.instance;
/// keyboard.enableInteractiveDismiss(trackingInset: 56); /// keyboard.enableInteractiveDismiss(trackingInset: 56);
/// ``` /// ```
class UxKeyboard with ChangeNotifier { class XKeyboard with ChangeNotifier {
UxKeyboard._() { XKeyboard._() {
if (Platform.isAndroid) { if (Platform.isAndroid) {
_channel.setMethodCallHandler(_onMethodCall); _channel.setMethodCallHandler(_onMethodCall);
} }
@@ -108,7 +108,7 @@ class UxKeyboard with ChangeNotifier {
} }
/// The singleton instance. /// The singleton instance.
static final UxKeyboard instance = UxKeyboard._(); static final XKeyboard instance = XKeyboard._();
static const _channel = MethodChannel('ux/keyboard'); static const _channel = MethodChannel('ux/keyboard');

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;
}

View File

@@ -0,0 +1,86 @@
import 'package:flutter/widgets.dart';
import 'package:ux/src/reactive/reactive.dart';
/// Awaits an async creation of a [Reactive] and rebuilds on its changes.
///
/// {@tool snippet}
/// Lazy async loading of a bloc.
/// ```dart
/// FutureReactiveBuilder<CounterState>(
/// future: (ctx) async => CounterBloc()..increment(),
/// builder: (_, state, __) => Text('Loaded: \\${state.count}'),
/// );
/// ```
/// {@end-tool}
class FutureReactiveBuilder<T> extends StatefulWidget {
const FutureReactiveBuilder({
required this.future,
required this.builder,
this.child,
Key? key,
}) : super(key: key);
final Widget? child;
/// Async factory returning a [Reactive] instance.
final Future<Reactive<T>> Function(BuildContext context) future;
/// Builder invoked with the current state value once ready.
final Widget Function(BuildContext context, T state, Widget? child) builder;
@override
State<FutureReactiveBuilder<T>> createState() => _FutureReactiveBuilderState<T>();
}
class _FutureReactiveBuilderState<T> extends State<FutureReactiveBuilder<T>> {
@override
void initState() {
super.initState();
_initBloc();
}
Reactive<T>? _bloc;
Future<void> _initBloc() async {
final bloc = await widget.future(context);
bloc.addListener(_notify);
_bloc = bloc;
// notify bloc is ready
_notify();
}
void _notify() => setState(() {});
void _disposeBloc() async {
if (_bloc != null) {
_bloc!
..removeListener(_notify)
..dispose();
_bloc = null;
}
}
@override
void didUpdateWidget(FutureReactiveBuilder<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.future != widget.future ||
oldWidget.builder != widget.builder ||
oldWidget.child != widget.child) {
_disposeBloc();
_initBloc();
}
}
@override
Widget build(BuildContext context) {
return _bloc == null
? SizedBox.shrink()
: widget.builder(
context,
_bloc!.value,
widget.child,
);
}
}

View File

@@ -0,0 +1,57 @@
import 'package:flutter/foundation.dart';
import 'package:ux/src/core/core.dart';
import 'package:ux/src/reactive/reactive_builder.dart';
/// Base class for view models exposing a lazily built immutable state value.
/// Use with [ListenableBuilder] / [ReactiveBuilder]. Call [notifyListeners]
/// with an optional mutation callback to rebuild & emit only on value change.
///
/// {@tool snippet}
/// Simple counter bloc using [Reactive] and consumed via [ReactiveBuilder].
/// ```dart
/// class CounterState { const CounterState(this.count); final int count; }
/// class CounterBloc extends Reactive<CounterState> {
/// int _count = 0;
/// void increment() => notifyListeners(() => _count++);
/// @override CounterState buildState() => CounterState(_count);
/// }
///
/// Widget build(BuildContext context) => ReactiveBuilder<CounterBloc>(
/// create: (_) => CounterBloc(),
/// builder: (_, bloc, __) => Text('Count: \\${bloc.value.count}'),
/// );
/// ```
/// {@end-tool}
abstract class Reactive<T> with Emitter, Tasks implements ValueListenable<T> {
/// The current value of the [Reactive].
T? _value;
/// The current value of the [Reactive].
/// If [value] is null, [buildState] is called to build the value.
T get value => _value ??= buildState();
/// Override to build the derived state snapshot.
@protected
T buildState();
/// Runs optional [callback], rebuilds state; notifies only if new value
/// differs (using `==`).
@override
@protected
@mustCallSuper
void notifyListeners() async {
final oldValue = _value;
_value = buildState();
if (oldValue != _value) {
super.notifyListeners();
}
}
@mustCallSuper
@override
void dispose() {
disposeTasks();
super.dispose();
}
}

View File

@@ -0,0 +1,146 @@
import 'package:flutter/widgets.dart';
import 'package:ux/src/core/core.dart';
/// Builder signature receiving the created/listened reactive object.
typedef ReactiveBuilderDelegate<T extends Listenable> = Widget Function(
BuildContext context,
T bloc,
Widget? child,
);
typedef ReactiveBuilderUpdateDelegate<T extends Listenable> = void Function(
BuildContext context,
T bloc,
);
/// Creation callback invoked once when no external value is provided.
typedef ReactiveCreateDelegate<T extends Listenable> = T Function(
BuildContext context,
);
/// Creates (or uses provided) listenable and rebuilds on its notifications.
///
/// {@tool snippet}
/// Basic usage with externally provided bloc instance.
/// ```dart
/// final bloc = CounterBloc();
/// ReactiveBuilder.value(
/// bloc,
/// builder: (_, b, __) => Text('Count: \\${b.value.count}'),
/// );
/// ```
/// {@end-tool}
class ReactiveBuilder<T extends Listenable> extends StatefulWidget {
const ReactiveBuilder({
Key? key,
required ReactiveCreateDelegate<T> create,
required this.builder,
this.child,
this.onUpdate,
}) : _value = null,
_create = create,
super(key: key);
const ReactiveBuilder.value(
T value, {
Key? key,
required this.builder,
this.child,
this.onUpdate,
}) : _value = value,
_create = null,
super(key: key);
/// Creates the listenable once; dependencies captured at creation time.
final ReactiveCreateDelegate<T>? _create;
/// Externally-owned listenable; not disposed by this widget.
final T? _value;
/// Builds subtree; invoked on each listenable change.
final ReactiveBuilderDelegate<T> builder;
final Widget? child;
/// Delegate that gets called before every build.
/// Useful to react to dependency changes before rendering.
///
/// In [onUpdate] it is allowed to trigger changes that cause a rebuild.
/// If you watch/listen to provided dependencies inside the delegate,
/// changes to any of those will cause [onUpdate] to be called,
/// followed by rebuilding the subtree via calling [builder].
/// It will not trigger a rebuild of the parent by itself.
///
/// Will also be called if the parent rebuilds for any reason, so you can
/// also use it to react to externally controlled dependency changes.
/// Will be called before the actual widget build.
/// Can be used for explicitly watching provided values and marking the build
/// dirty before the actual build happens.
final ReactiveBuilderUpdateDelegate<T>? onUpdate;
@override
_ReactiveBuilderState<T> createState() => _ReactiveBuilderState();
}
class _ReactiveBuilderState<T extends Listenable>
extends State<ReactiveBuilder<T>> {
late T reactive;
@override
void initState() {
super.initState();
_initReactive();
}
@override
void dispose() {
_disposeReactive(widget);
super.dispose();
}
@override
void didUpdateWidget(covariant ReactiveBuilder<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget._value != widget._value) {
_disposeReactive(oldWidget);
_initReactive();
_triggerBuild();
}
_callOnUpdate();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_callOnUpdate();
}
void _initReactive() {
reactive = (widget._value ?? widget._create!(context))
..addListener(_triggerBuild);
}
void _disposeReactive(ReactiveBuilder<T> widget) {
reactive.removeListener(_triggerBuild);
if (reactive is Disposable && widget._value == null) {
(reactive as Disposable).dispose();
}
}
void _callOnUpdate() {
widget.onUpdate?.call(context, reactive);
}
void _triggerBuild() => setState(() {});
@override
Widget build(BuildContext context) {
return widget.builder(
context,
reactive,
widget.child,
);
}
}

View File

@@ -12,8 +12,8 @@ import 'package:flutter/widgets.dart';
enum BarcodeFormat { qr } enum BarcodeFormat { qr }
/// Static helpers exposed by the platform-side scanner plugin. /// Static helpers exposed by the platform-side scanner plugin.
class UxScannerPermission { class XScannerPermission {
UxScannerPermission._(); XScannerPermission._();
static const _channel = MethodChannel('ux/scanner'); static const _channel = MethodChannel('ux/scanner');
@@ -41,8 +41,8 @@ class UxScannerPermission {
/// Camera permission must be granted by the host app before mounting. /// Camera permission must be granted by the host app before mounting.
/// On platforms other than iOS / Android the widget renders an empty /// On platforms other than iOS / Android the widget renders an empty
/// box. /// box.
class UxScanner extends StatefulWidget { class XScanner extends StatefulWidget {
const UxScanner({ const XScanner({
super.key, super.key,
required this.onCode, required this.onCode,
this.formats = const [BarcodeFormat.qr], this.formats = const [BarcodeFormat.qr],
@@ -52,10 +52,10 @@ class UxScanner extends StatefulWidget {
final List<BarcodeFormat> formats; final List<BarcodeFormat> formats;
@override @override
State<UxScanner> createState() => _UxScannerState(); State<XScanner> createState() => _XScannerState();
} }
class _UxScannerState extends State<UxScanner> { class _XScannerState extends State<XScanner> {
static const _events = EventChannel('ux/scanner/events'); static const _events = EventChannel('ux/scanner/events');
StreamSubscription<dynamic>? _sub; StreamSubscription<dynamic>? _sub;

View File

@@ -7,8 +7,8 @@ import 'package:ux/src/_ffi.dart';
final _uxDeviceOrientation = uxLookupInt32('ux_device_orientation'); final _uxDeviceOrientation = uxLookupInt32('ux_device_orientation');
class UxSensor { class XSensor {
UxSensor._(); XSensor._();
/// Accelerometer-driven physical device rotation; updates regardless of /// Accelerometer-driven physical device rotation; updates regardless of
/// OS auto-rotate or app UI orientation lock. /// OS auto-rotate or app UI orientation lock.
@@ -32,7 +32,7 @@ class UxSensor {
class _OrientationNotifier extends ChangeNotifier class _OrientationNotifier extends ChangeNotifier
implements ValueListenable<DeviceOrientation> { implements ValueListenable<DeviceOrientation> {
_OrientationNotifier() : _value = UxSensor.orientation; _OrientationNotifier() : _value = XSensor.orientation;
Timer? _timer; Timer? _timer;
DeviceOrientation _value; DeviceOrientation _value;
@@ -56,7 +56,7 @@ class _OrientationNotifier extends ChangeNotifier
} }
void _tick(Timer _) { void _tick(Timer _) {
final next = UxSensor.orientation; final next = XSensor.orientation;
if (next != _value) { if (next != _value) {
_value = next; _value = next;
notifyListeners(); notifyListeners();

View File

@@ -6,31 +6,31 @@ import 'dart:typed_data';
import 'package:ux/src/gallery.dart'; import 'package:ux/src/gallery.dart';
/// 1×1 transparent PNG. Decodable by `Image.memory`, so tests that /// 1×1 transparent PNG. Decodable by `Image.memory`, so tests that
/// mount the picker UI against [FakeUxGalleryBackend] don't need to /// mount the picker UI against [FakeXGalleryBackend] don't need to
/// provide their own thumbnail bytes. /// provide their own thumbnail bytes.
final Uint8List _placeholderPng = base64Decode( final Uint8List _placeholderPng = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
); );
/// In-memory backend for [UxGallery] tests. Swap in via /// In-memory backend for [XGallery] tests. Swap in via
/// `UxGallery.backend = FakeUxGalleryBackend(...)` before any UI /// `XGallery.backend = FakeXGalleryBackend(...)` before any UI
/// mounts; restore with `UxGallery.backend = ...` (or by replacing /// mounts; restore with `XGallery.backend = ...` (or by replacing
/// with another fake) in `tearDown`. /// with another fake) in `tearDown`.
class FakeUxGalleryBackend implements UxGalleryBackend { class FakeXGalleryBackend implements XGalleryBackend {
FakeUxGalleryBackend({ FakeXGalleryBackend({
this.permissionState = UxGalleryPermission.granted, this.permissionState = XGalleryPermission.granted,
List<UxAlbum> albums = const [], List<XAlbum> albums = const [],
Map<String, List<UxAsset>> assetsByAlbum = const {}, Map<String, List<XAsset>> assetsByAlbum = const {},
List<UxAsset> recents = const [], List<XAsset> recents = const [],
this.onRequestPermission, this.onRequestPermission,
this.onOpenSettings, this.onOpenSettings,
this.onPresentLimitedLibraryPicker, this.onPresentLimitedLibraryPicker,
UxAssetThumbnail Function(String assetId, int sizePx)? thumbnailFor, XAssetThumbnail Function(String assetId, int sizePx)? thumbnailFor,
io.File Function(String assetId)? fileFor, io.File Function(String assetId)? fileFor,
}) : _albums = List.unmodifiable(albums), }) : _albums = List.unmodifiable(albums),
_assetsByAlbum = Map.unmodifiable( _assetsByAlbum = Map.unmodifiable(
assetsByAlbum.map( assetsByAlbum.map(
(k, v) => MapEntry(k, List<UxAsset>.unmodifiable(v)), (k, v) => MapEntry(k, List<XAsset>.unmodifiable(v)),
), ),
), ),
_recents = List.unmodifiable(recents), _recents = List.unmodifiable(recents),
@@ -38,12 +38,12 @@ class FakeUxGalleryBackend implements UxGalleryBackend {
_fileFor = fileFor ?? _defaultFile; _fileFor = fileFor ?? _defaultFile;
/// Mutable so tests can simulate user grant after `requestPermission`. /// Mutable so tests can simulate user grant after `requestPermission`.
UxGalleryPermission permissionState; XGalleryPermission permissionState;
final List<UxAlbum> _albums; final List<XAlbum> _albums;
final Map<String, List<UxAsset>> _assetsByAlbum; final Map<String, List<XAsset>> _assetsByAlbum;
final List<UxAsset> _recents; final List<XAsset> _recents;
final UxAssetThumbnail Function(String assetId, int sizePx) _thumbnailFor; final XAssetThumbnail Function(String assetId, int sizePx) _thumbnailFor;
final io.File Function(String assetId) _fileFor; final io.File Function(String assetId) _fileFor;
final StreamController<void> _libraryChanges = final StreamController<void> _libraryChanges =
@@ -58,13 +58,13 @@ class FakeUxGalleryBackend implements UxGalleryBackend {
Stream<void> get libraryChanges => _libraryChanges.stream; Stream<void> get libraryChanges => _libraryChanges.stream;
/// Optional hook fired on `requestPermission`. Default updates /// Optional hook fired on `requestPermission`. Default updates
/// `permissionState` to [UxGalleryPermission.granted]. /// `permissionState` to [XGalleryPermission.granted].
final UxGalleryPermission Function()? onRequestPermission; final XGalleryPermission Function()? onRequestPermission;
final void Function()? onOpenSettings; final void Function()? onOpenSettings;
final void Function()? onPresentLimitedLibraryPicker; final void Function()? onPresentLimitedLibraryPicker;
static UxAssetThumbnail _defaultThumbnail(String _, int sizePx) => static XAssetThumbnail _defaultThumbnail(String _, int sizePx) =>
UxAssetThumbnail( XAssetThumbnail(
bytes: _placeholderPng, bytes: _placeholderPng,
width: sizePx, width: sizePx,
height: sizePx, height: sizePx,
@@ -74,12 +74,12 @@ class FakeUxGalleryBackend implements UxGalleryBackend {
io.File('/dev/null/$assetId'); io.File('/dev/null/$assetId');
@override @override
Future<UxGalleryPermission> permission() async => permissionState; Future<XGalleryPermission> permission() async => permissionState;
@override @override
Future<UxGalleryPermission> requestPermission() async { Future<XGalleryPermission> requestPermission() async {
permissionState = permissionState =
onRequestPermission?.call() ?? UxGalleryPermission.granted; onRequestPermission?.call() ?? XGalleryPermission.granted;
return permissionState; return permissionState;
} }
@@ -94,7 +94,7 @@ class FakeUxGalleryBackend implements UxGalleryBackend {
} }
@override @override
Future<List<UxAlbum>> albums({UxAssetKind? filter}) async { Future<List<XAlbum>> albums({XAssetKind? filter}) async {
if (filter == null) return _albums; if (filter == null) return _albums;
return [ return [
for (final a in _albums) for (final a in _albums)
@@ -103,15 +103,15 @@ class FakeUxGalleryBackend implements UxGalleryBackend {
} }
@override @override
Future<List<UxAsset>> assets({ Future<List<XAsset>> assets({
String? albumId, String? albumId,
UxAssetKind? filter, XAssetKind? filter,
required int start, required int start,
required int end, required int end,
}) async { }) async {
final source = albumId == null final source = albumId == null
? _recents ? _recents
: _assetsByAlbum[albumId] ?? const <UxAsset>[]; : _assetsByAlbum[albumId] ?? const <XAsset>[];
final filtered = filter == null final filtered = filter == null
? source ? source
: [for (final a in source) if (a.kind == filter) a]; : [for (final a in source) if (a.kind == filter) a];
@@ -120,7 +120,7 @@ class FakeUxGalleryBackend implements UxGalleryBackend {
} }
@override @override
Future<UxAssetThumbnail> thumbnail( Future<XAssetThumbnail> thumbnail(
String assetId, { String assetId, {
required int sizePx, required int sizePx,
}) async => }) async =>

View File

@@ -9,8 +9,8 @@ import 'package:flutter/services.dart';
import 'package:ux/src/_ffi.dart'; import 'package:ux/src/_ffi.dart';
/// Native URL / phone / email detection plus an OS-handler launcher. /// Native URL / phone / email detection plus an OS-handler launcher.
class UxUrl { class XUrl {
UxUrl._(); XUrl._();
static const _channel = MethodChannel('ux/url'); static const _channel = MethodChannel('ux/url');

View File

@@ -1,7 +1,9 @@
/// Flutter toolkit for fluid, native-feeling UIs. /// Flutter toolkit for fluid, native-feeling UIs.
/// ///
/// Includes [UxKeyboard] for frame-accurate keyboard height tracking, /// Includes [XKeyboard] for frame-accurate keyboard height tracking,
/// [BendBox] for curved layout painting, and bezier curve utilities. /// [BendBox] for curved layout painting, bezier curve utilities,
/// reactive primitives ([Emitter], [ValueEmitter]), a custom navigation
/// system ([XApp], [XRouter], [Screen]), and modal sheets ([Sheet]).
library; library;
export 'src/app_info.dart'; export 'src/app_info.dart';
@@ -9,8 +11,8 @@ export 'src/bend_box.dart';
export 'src/json_extension.dart'; export 'src/json_extension.dart';
export 'src/bezier.dart'; export 'src/bezier.dart';
export 'src/camera/camera.dart'; export 'src/camera/camera.dart';
export 'src/camera/camera_backend.dart' show UxCameraBackend, UxCameraCreateResult, UxCameraEvent, UxCameraDeviceOrientationChanged, UxCameraSessionError, UxCameraSessionInterrupted, UxCameraSessionResumed, UxCameraDiagnostic, UxCameraPreviewSizeChanged; export 'src/camera/camera_backend.dart' show XCameraBackend, XCameraCreateResult, XCameraEvent, XCameraDeviceOrientationChanged, XCameraSessionError, XCameraSessionInterrupted, XCameraSessionResumed, XCameraDiagnostic, XCameraPreviewSizeChanged;
export 'src/camera/camera_channel.dart' show MethodChannelUxCameraBackend; export 'src/camera/camera_channel.dart' show MethodChannelXCameraBackend;
export 'src/camera/camera_preview.dart'; export 'src/camera/camera_preview.dart';
export 'src/clipboard.dart'; export 'src/clipboard.dart';
export 'src/file.dart'; export 'src/file.dart';
@@ -23,4 +25,29 @@ export 'src/sensor.dart';
export 'src/functional.dart'; export 'src/functional.dart';
export 'src/crash.dart'; export 'src/crash.dart';
export 'src/log.dart'; export 'src/log.dart';
export 'src/log_http.dart'; export 'src/log_http.dart';
// Reactive primitives, lifecycle, DI, async tasks (formerly `stated`).
export 'src/core/core.dart';
// Reactive state base + builders.
export 'src/reactive/reactive.dart';
export 'src/reactive/reactive_builder.dart';
export 'src/reactive/future_reactive_builder.dart';
// Navigation (Screen, XApp, XRouter, ScreenStack, ScreenHero, …).
export 'src/navi/screen.dart';
export 'src/navi/screen_host.dart';
export 'src/navi/screen_stack.dart';
export 'src/navi/hero.dart';
export 'src/navi/sheet_screen.dart';
export 'src/navi/transitions.dart';
export 'src/navi/router.dart';
// Animations, layout, sheet, pane.
export 'src/anim/animated_color.dart';
export 'src/anim/animated_double.dart';
export 'src/anim/dock.dart';
export 'src/anim/measured.dart';
export 'src/anim/pane.dart';
export 'src/anim/sheet.dart';

View File

@@ -15,7 +15,7 @@ import AVFoundation
/// videoOrientation = .landscapeRight"). Prefer the new API where /// videoOrientation = .landscapeRight"). Prefer the new API where
/// available, fall back to the deprecated one for older macOS. /// available, fall back to the deprecated one for older macOS.
extension AVCaptureConnection { extension AVCaptureConnection {
func applyUxCaptureOrientation(_ orientation: DeviceOrientationFlutter) { func applyXCaptureOrientation(_ orientation: DeviceOrientationFlutter) {
// Pin to 0° rotation (`.landscapeRight`) on macOS desktop // Pin to 0° rotation (`.landscapeRight`) on macOS desktop
// cameras are physically landscape and any non-zero rotation // cameras are physically landscape and any non-zero rotation
// physically rotates the buffer. Diagnostic build confirmed // physically rotates the buffer. Diagnostic build confirmed

View File

@@ -106,7 +106,7 @@ public class FilePlugin: NSObject, NativePlugin {
panel.allowedFileTypes = utis panel.allowedFileTypes = utis
} }
let host = UxWindow.flutterView?.window let host = XWindow.flutterView?.window
let completion: (NSApplication.ModalResponse) -> Void = { response in let completion: (NSApplication.ModalResponse) -> Void = { response in
guard response == .OK, let url = panel.url else { guard response == .OK, let url = panel.url else {
result(nil) result(nil)
@@ -173,7 +173,7 @@ public class FilePlugin: NSObject, NativePlugin {
let path = args["path"] as? String else { let path = args["path"] as? String else {
return result(FlutterError(code: "bad_args", message: "path is required", details: nil)) return result(FlutterError(code: "bad_args", message: "path is required", details: nil))
} }
guard let view = UxWindow.flutterView else { guard let view = XWindow.flutterView else {
return result(FlutterError(code: "no_view", message: "no Flutter view", details: nil)) return result(FlutterError(code: "no_view", message: "no Flutter view", details: nil))
} }
@@ -314,10 +314,10 @@ public class FilePlugin: NSObject, NativePlugin {
withScope(path: path, bookmark: bookmarkData) { url in withScope(path: path, bookmark: bookmarkData) { url in
// Prefer in-app Quick Look (keeps the host app in the foreground). // Prefer in-app Quick Look (keeps the host app in the foreground).
// Fall back to NSWorkspace.open if there's no window to host the panel. // Fall back to NSWorkspace.open if there's no window to host the panel.
if let flutterView = UxWindow.flutterView, if let flutterView = XWindow.flutterView,
let window = flutterView.window, let window = flutterView.window,
let panel = QLPreviewPanel.shared() { let panel = QLPreviewPanel.shared() {
let responder = UxQLPreviewResponder(url: url, window: window) let responder = XQLPreviewResponder(url: url, window: window)
flutterView.addSubview(responder) flutterView.addSubview(responder)
window.makeFirstResponder(responder) window.makeFirstResponder(responder)
panel.updateController() panel.updateController()
@@ -344,7 +344,7 @@ fileprivate func mimeFromExtension(_ ext: String) -> String? {
return mime as String return mime as String
} }
private final class UxQLPreviewResponder: NSView, QLPreviewPanelDataSource { private final class XQLPreviewResponder: NSView, QLPreviewPanelDataSource {
let url: URL let url: URL
private weak var previousFirstResponder: NSResponder? private weak var previousFirstResponder: NSResponder?
private weak var previousWindow: NSWindow? private weak var previousWindow: NSWindow?

View File

@@ -5,7 +5,7 @@ public protocol NativePlugin {
func register(with registrar: FlutterPluginRegistrar) func register(with registrar: FlutterPluginRegistrar)
} }
public enum UxWindow { public enum XWindow {
public static var keyWindow: NSWindow? { public static var keyWindow: NSWindow? {
NSApp.keyWindow ?? NSApp.mainWindow NSApp.keyWindow ?? NSApp.mainWindow
} }

View File

@@ -1,7 +1,7 @@
import FlutterMacOS import FlutterMacOS
import AppKit import AppKit
public class UxPlugin: NSObject, FlutterPlugin { public class XPlugin: NSObject, FlutterPlugin {
private static var plugins: [NativePlugin] = [] private static var plugins: [NativePlugin] = []
public static func register(with registrar: FlutterPluginRegistrar) { public static func register(with registrar: FlutterPluginRegistrar) {

View File

@@ -1,4 +1,4 @@
// Native data detection for UxUrl. Synchronous, callable via dart:ffi. // Native data detection for XUrl. Synchronous, callable via dart:ffi.
// //
// Exports two symbols: // Exports two symbols:
// uint8_t* ux_match_url(const uint16_t* utf16, int32_t len, int32_t* out_size); // uint8_t* ux_match_url(const uint16_t* utf16, int32_t len, int32_t* out_size);
@@ -24,13 +24,13 @@ static const uint32_t kKindWeb = 0;
static const uint32_t kKindEmail = 1; static const uint32_t kKindEmail = 1;
static const uint32_t kKindPhone = 2; static const uint32_t kKindPhone = 2;
@interface UxUrlRawMatch : NSObject @interface XUrlRawMatch : NSObject
@property (nonatomic) int32_t start; @property (nonatomic) int32_t start;
@property (nonatomic) int32_t end; @property (nonatomic) int32_t end;
@property (nonatomic) uint32_t kind; @property (nonatomic) uint32_t kind;
@property (nonatomic, copy) NSData *urlUtf8; @property (nonatomic, copy) NSData *urlUtf8;
@end @end
@implementation UxUrlRawMatch @implementation XUrlRawMatch
@end @end
static NSDataDetector *ux_url_data_detector(void) { static NSDataDetector *ux_url_data_detector(void) {
@@ -76,7 +76,7 @@ uint8_t *ux_match_url(const uint16_t *utf16, int32_t len, int32_t *out_size) {
if (text.length == 0) return NULL; if (text.length == 0) return NULL;
NSRange whole = NSMakeRange(0, text.length); NSRange whole = NSMakeRange(0, text.length);
NSMutableArray<UxUrlRawMatch *> *raws = [NSMutableArray array]; NSMutableArray<XUrlRawMatch *> *raws = [NSMutableArray array];
NSDataDetector *detector = ux_url_data_detector(); NSDataDetector *detector = ux_url_data_detector();
if (detector != nil) { if (detector != nil) {
@@ -130,7 +130,7 @@ uint8_t *ux_match_url(const uint16_t *utf16, int32_t len, int32_t *out_size) {
} }
if (url.length == 0) return; if (url.length == 0) return;
UxUrlRawMatch *m = [[UxUrlRawMatch alloc] init]; XUrlRawMatch *m = [[XUrlRawMatch alloc] init];
m.start = (int32_t)r.location; m.start = (int32_t)r.location;
m.end = (int32_t)(r.location + r.length); m.end = (int32_t)(r.location + r.length);
m.kind = kind; m.kind = kind;
@@ -152,7 +152,7 @@ uint8_t *ux_match_url(const uint16_t *utf16, int32_t len, int32_t *out_size) {
if (r.location == NSNotFound || r.length == 0) return; if (r.location == NSNotFound || r.length == 0) return;
NSString *substr = [text substringWithRange:r]; NSString *substr = [text substringWithRange:r];
NSString *withScheme = [@"http://" stringByAppendingString:substr]; NSString *withScheme = [@"http://" stringByAppendingString:substr];
UxUrlRawMatch *m = [[UxUrlRawMatch alloc] init]; XUrlRawMatch *m = [[XUrlRawMatch alloc] init];
m.start = (int32_t)r.location; m.start = (int32_t)r.location;
m.end = (int32_t)(r.location + r.length); m.end = (int32_t)(r.location + r.length);
m.kind = kKindWeb; m.kind = kKindWeb;
@@ -164,7 +164,7 @@ uint8_t *ux_match_url(const uint16_t *utf16, int32_t len, int32_t *out_size) {
if (raws.count == 0) return NULL; if (raws.count == 0) return NULL;
// Sort: start asc, then length desc, then kind desc (phone > email > web on tie). // Sort: start asc, then length desc, then kind desc (phone > email > web on tie).
[raws sortUsingComparator:^NSComparisonResult(UxUrlRawMatch *a, UxUrlRawMatch *b) { [raws sortUsingComparator:^NSComparisonResult(XUrlRawMatch *a, XUrlRawMatch *b) {
if (a.start != b.start) return a.start < b.start ? NSOrderedAscending : NSOrderedDescending; if (a.start != b.start) return a.start < b.start ? NSOrderedAscending : NSOrderedDescending;
int32_t la = a.end - a.start; int32_t la = a.end - a.start;
int32_t lb = b.end - b.start; int32_t lb = b.end - b.start;
@@ -174,10 +174,10 @@ uint8_t *ux_match_url(const uint16_t *utf16, int32_t len, int32_t *out_size) {
}]; }];
// Greedy de-overlap. // Greedy de-overlap.
NSMutableArray<UxUrlRawMatch *> *kept = [NSMutableArray arrayWithCapacity:raws.count]; NSMutableArray<XUrlRawMatch *> *kept = [NSMutableArray arrayWithCapacity:raws.count];
int32_t lastEnd = 0; int32_t lastEnd = 0;
BOOL haveAny = NO; BOOL haveAny = NO;
for (UxUrlRawMatch *m in raws) { for (XUrlRawMatch *m in raws) {
if (haveAny && m.start < lastEnd) continue; if (haveAny && m.start < lastEnd) continue;
[kept addObject:m]; [kept addObject:m];
lastEnd = m.end; lastEnd = m.end;
@@ -185,7 +185,7 @@ uint8_t *ux_match_url(const uint16_t *utf16, int32_t len, int32_t *out_size) {
} }
NSUInteger total = 4; NSUInteger total = 4;
for (UxUrlRawMatch *m in kept) { for (XUrlRawMatch *m in kept) {
total += 16 + (NSUInteger)m.urlUtf8.length; total += 16 + (NSUInteger)m.urlUtf8.length;
} }
@@ -194,7 +194,7 @@ uint8_t *ux_match_url(const uint16_t *utf16, int32_t len, int32_t *out_size) {
uint32_t cnt = (uint32_t)kept.count; uint32_t cnt = (uint32_t)kept.count;
memcpy(buf, &cnt, 4); memcpy(buf, &cnt, 4);
NSUInteger off = 4; NSUInteger off = 4;
for (UxUrlRawMatch *m in kept) { for (XUrlRawMatch *m in kept) {
int32_t start = m.start; int32_t start = m.start;
int32_t end = m.end; int32_t end = m.end;
uint32_t kind = m.kind; uint32_t kind = m.kind;

View File

@@ -3,7 +3,7 @@ description: >-
Flutter toolkit for fluid, native-feeling UIs. Includes frame-accurate Flutter toolkit for fluid, native-feeling UIs. Includes frame-accurate
keyboard height tracking via FFI with interactive dismiss, bezier utilities, keyboard height tracking via FFI with interactive dismiss, bezier utilities,
and layout primitives. and layout primitives.
version: 0.9.0 version: 0.10.0
homepage: https://swipelab.co/ux.html homepage: https://swipelab.co/ux.html
repository: https://github.com/swipelab/ux repository: https://github.com/swipelab/ux
issue_tracker: https://github.com/swipelab/ux/issues issue_tracker: https://github.com/swipelab/ux/issues
@@ -33,9 +33,9 @@ flutter:
plugin: plugin:
platforms: platforms:
ios: ios:
pluginClass: UxPlugin pluginClass: XPlugin
android: android:
package: io.swipelab.ux package: io.swipelab.ux
pluginClass: UxPlugin pluginClass: XPlugin
macos: macos:
pluginClass: UxPlugin pluginClass: XPlugin

View File

@@ -5,14 +5,14 @@ import 'package:ux/ux.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
group('MethodChannelUxCameraBackend — arg/return parsing', () { group('MethodChannelXCameraBackend — arg/return parsing', () {
const channel = MethodChannel('ux/camera'); const channel = MethodChannel('ux/camera');
late MethodChannelUxCameraBackend backend; late MethodChannelXCameraBackend backend;
late List<MethodCall> calls; late List<MethodCall> calls;
setUp(() { setUp(() {
backend = MethodChannelUxCameraBackend(); backend = MethodChannelXCameraBackend();
calls = []; calls = [];
}); });
@@ -39,10 +39,10 @@ void main() {
expect(calls.single.method, 'availableCameras'); expect(calls.single.method, 'availableCameras');
expect(result, [ expect(result, [
const UxCameraDescription( const XCameraDescription(
id: 'a', lens: UxCameraLens.front, sensorOrientation: 270), id: 'a', lens: XCameraLens.front, sensorOrientation: 270),
const UxCameraDescription( const XCameraDescription(
id: 'b', lens: UxCameraLens.back, sensorOrientation: 90), id: 'b', lens: XCameraLens.back, sensorOrientation: 90),
]); ]);
}); });
@@ -57,7 +57,7 @@ void main() {
final result = await backend.create( final result = await backend.create(
cameraId: 'cam-1', cameraId: 'cam-1',
enableAudio: true, enableAudio: true,
preset: UxResolutionPreset.high, preset: XResolutionPreset.high,
); );
expect(calls.single.method, 'create'); expect(calls.single.method, 'create');
@@ -98,8 +98,8 @@ void main() {
test('setFlashMode encodes the enum', () async { test('setFlashMode encodes the enum', () async {
handle((_) => null); handle((_) => null);
await backend.setFlashMode(3, UxFlashMode.always); await backend.setFlashMode(3, XFlashMode.always);
await backend.setFlashMode(3, UxFlashMode.off); await backend.setFlashMode(3, XFlashMode.off);
expect(calls.map((c) => (c.arguments as Map)['mode']).toList(), expect(calls.map((c) => (c.arguments as Map)['mode']).toList(),
['always', 'off']); ['always', 'off']);
@@ -180,11 +180,11 @@ void main() {
); );
addTearDown(() => messenger.setMockStreamHandler(eventsChannel, null)); addTearDown(() => messenger.setMockStreamHandler(eventsChannel, null));
final received = <UxCameraEvent>[]; final received = <XCameraEvent>[];
await backend.events(4).forEach(received.add); await backend.events(4).forEach(received.add);
expect(received, hasLength(1)); expect(received, hasLength(1));
final e = received.single as UxCameraDiagnostic; final e = received.single as XCameraDiagnostic;
expect(e.handle, 4); expect(e.handle, 4);
expect(e.message, 'video input added'); expect(e.message, 'video input added');
}); });
@@ -208,7 +208,7 @@ void main() {
); );
}); });
test('PlatformException maps to UxCameraException carrying code/message', test('PlatformException maps to XCameraException carrying code/message',
() async { () async {
handle((_) => throw PlatformException( handle((_) => throw PlatformException(
code: 'device_busy', code: 'device_busy',
@@ -217,7 +217,7 @@ void main() {
await expectLater( await expectLater(
backend.initialize(1), backend.initialize(1),
throwsA(isA<UxCameraException>() throwsA(isA<XCameraException>()
.having((e) => e.code, 'code', 'device_busy') .having((e) => e.code, 'code', 'device_busy')
.having((e) => e.description, 'description', 'front camera in use')), .having((e) => e.description, 'description', 'front camera in use')),
); );

View File

@@ -50,7 +50,7 @@ void main() {
return null; return null;
}); });
await UxCrash.drainAndReport(); await XCrash.drainAndReport();
final records = sink.snapshot(); final records = sink.snapshot();
expect(records.length, 2); expect(records.length, 2);
@@ -78,7 +78,7 @@ void main() {
throw MissingPluginException(); throw MissingPluginException();
}); });
await UxCrash.drainAndReport(); await XCrash.drainAndReport();
expect(sink.snapshot(), isEmpty); expect(sink.snapshot(), isEmpty);
}); });
@@ -91,7 +91,7 @@ void main() {
throw PlatformException(code: 'oops'); throw PlatformException(code: 'oops');
}); });
await UxCrash.drainAndReport(); await XCrash.drainAndReport();
expect(sink.snapshot().length, 1); expect(sink.snapshot().length, 1);
expect(sink.snapshot().single.level, LogLevel.warn); expect(sink.snapshot().single.level, LogLevel.warn);

View File

@@ -6,11 +6,11 @@ import 'package:ux/ux.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
group('UxGallery facade — method channel parsing', () { group('XGallery facade — method channel parsing', () {
const channel = MethodChannel('ux/gallery'); const channel = MethodChannel('ux/gallery');
setUp(() { setUp(() {
UxGallery.backend = MethodChannelGalleryBackend(); XGallery.backend = MethodChannelGalleryBackend();
}); });
tearDown(() { tearDown(() {
@@ -25,17 +25,17 @@ void main() {
return 'granted'; return 'granted';
}); });
expect(await UxGallery.permission(), UxGalleryPermission.granted); expect(await XGallery.permission(), XGalleryPermission.granted);
}); });
test('permission() falls back to denied on unknown values', () async { test('permission() falls back to denied on unknown values', () async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (_) async => 'mystery'); .setMockMethodCallHandler(channel, (_) async => 'mystery');
expect(await UxGallery.permission(), UxGalleryPermission.denied); expect(await XGallery.permission(), XGalleryPermission.denied);
}); });
test('albums() decodes a list of UxAlbum', () async { test('albums() decodes a list of XAlbum', () async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async { .setMockMethodCallHandler(channel, (call) async {
expect(call.method, 'albums'); expect(call.method, 'albums');
@@ -56,12 +56,12 @@ void main() {
]; ];
}); });
final albums = await UxGallery.albums(); final albums = await XGallery.albums();
expect(albums, hasLength(2)); expect(albums, hasLength(2));
expect(albums[0].id, 'recents'); expect(albums[0].id, 'recents');
expect(albums[0].count, 1234); expect(albums[0].count, 1234);
expect(albums[0].coverKind, UxAssetKind.image); expect(albums[0].coverKind, XAssetKind.image);
expect(albums[1].coverKind, UxAssetKind.video); expect(albums[1].coverKind, XAssetKind.video);
}); });
test('assets() decodes durations and timestamps', () async { test('assets() decodes durations and timestamps', () async {
@@ -85,14 +85,14 @@ void main() {
]; ];
}); });
final assets = await UxGallery.assets( final assets = await XGallery.assets(
albumId: 'recents', albumId: 'recents',
filter: UxAssetKind.video, filter: XAssetKind.video,
start: 0, start: 0,
end: 60, end: 60,
); );
expect(assets, hasLength(1)); expect(assets, hasLength(1));
expect(assets.single.kind, UxAssetKind.video); expect(assets.single.kind, XAssetKind.video);
expect(assets.single.duration, const Duration(milliseconds: 8500)); expect(assets.single.duration, const Duration(milliseconds: 8500));
expect(assets.single.createdAt.millisecondsSinceEpoch, 1700000000000); expect(assets.single.createdAt.millisecondsSinceEpoch, 1700000000000);
}); });
@@ -111,76 +111,76 @@ void main() {
}; };
}); });
final thumb = await UxGallery.thumbnail('a1', sizePx: 381); final thumb = await XGallery.thumbnail('a1', sizePx: 381);
expect(thumb.bytes, [1, 2, 3]); expect(thumb.bytes, [1, 2, 3]);
expect(thumb.width, 381); expect(thumb.width, 381);
expect(thumb.height, 254); expect(thumb.height, 254);
}); });
}); });
group('FakeUxGalleryBackend', () { group('FakeXGalleryBackend', () {
test('requestPermission flips state to granted by default', () async { test('requestPermission flips state to granted by default', () async {
final fake = FakeUxGalleryBackend( final fake = FakeXGalleryBackend(
permissionState: UxGalleryPermission.notDetermined, permissionState: XGalleryPermission.notDetermined,
); );
UxGallery.backend = fake; XGallery.backend = fake;
expect(await UxGallery.permission(), expect(await XGallery.permission(),
UxGalleryPermission.notDetermined); XGalleryPermission.notDetermined);
expect(await UxGallery.requestPermission(), expect(await XGallery.requestPermission(),
UxGalleryPermission.granted); XGalleryPermission.granted);
expect(await UxGallery.permission(), UxGalleryPermission.granted); expect(await XGallery.permission(), XGalleryPermission.granted);
}); });
test('assets honours the (start, end) page window per album', () async { test('assets honours the (start, end) page window per album', () async {
final pile = [ final pile = [
for (var i = 0; i < 50; i++) for (var i = 0; i < 50; i++)
UxAsset( XAsset(
id: 'a$i', id: 'a$i',
kind: UxAssetKind.image, kind: XAssetKind.image,
width: 100, width: 100,
height: 100, height: 100,
createdAt: DateTime.fromMillisecondsSinceEpoch(i * 1000), createdAt: DateTime.fromMillisecondsSinceEpoch(i * 1000),
), ),
]; ];
final fake = FakeUxGalleryBackend( final fake = FakeXGalleryBackend(
recents: pile, recents: pile,
assetsByAlbum: {'all': pile}, assetsByAlbum: {'all': pile},
); );
UxGallery.backend = fake; XGallery.backend = fake;
final firstPage = await UxGallery.assets(start: 0, end: 10); final firstPage = await XGallery.assets(start: 0, end: 10);
expect(firstPage.map((a) => a.id), [ expect(firstPage.map((a) => a.id), [
for (var i = 0; i < 10; i++) 'a$i', for (var i = 0; i < 10; i++) 'a$i',
]); ]);
final tail = await UxGallery.assets(albumId: 'all', start: 45, end: 999); final tail = await XGallery.assets(albumId: 'all', start: 45, end: 999);
expect(tail, hasLength(5)); expect(tail, hasLength(5));
final past = await UxGallery.assets(start: 200, end: 210); final past = await XGallery.assets(start: 200, end: 210);
expect(past, isEmpty); expect(past, isEmpty);
}); });
test('assets filters by kind when requested', () async { test('assets filters by kind when requested', () async {
final mix = [ final mix = [
UxAsset( XAsset(
id: 'p', id: 'p',
kind: UxAssetKind.image, kind: XAssetKind.image,
width: 1, width: 1,
height: 1, height: 1,
createdAt: DateTime(2024), createdAt: DateTime(2024),
), ),
UxAsset( XAsset(
id: 'v', id: 'v',
kind: UxAssetKind.video, kind: XAssetKind.video,
duration: const Duration(seconds: 3), duration: const Duration(seconds: 3),
width: 1, width: 1,
height: 1, height: 1,
createdAt: DateTime(2024), createdAt: DateTime(2024),
), ),
]; ];
UxGallery.backend = FakeUxGalleryBackend(recents: mix); XGallery.backend = FakeXGalleryBackend(recents: mix);
final justVideos = await UxGallery.assets( final justVideos = await XGallery.assets(
filter: UxAssetKind.video, filter: XAssetKind.video,
start: 0, start: 0,
end: 10, end: 10,
); );

View File

@@ -2,15 +2,15 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:ux/ux.dart'; import 'package:ux/ux.dart';
void main() { void main() {
test('UxKeyboard.instance is a singleton', () { test('XKeyboard.instance is a singleton', () {
expect(UxKeyboard.instance, same(UxKeyboard.instance)); expect(XKeyboard.instance, same(XKeyboard.instance));
}); });
test('UxKeyboard.height starts at 0', () { test('XKeyboard.height starts at 0', () {
expect(UxKeyboard.instance.height, 0); expect(XKeyboard.instance.height, 0);
}); });
test('UxKeyboard.isOpen is false when height is 0', () { test('XKeyboard.isOpen is false when height is 0', () {
expect(UxKeyboard.instance.isOpen, false); expect(XKeyboard.instance.isOpen, false);
}); });
} }