From d68a2978eba024896b9e9a634cb89edebde44923 Mon Sep 17 00:00:00 2001 From: agra Date: Thu, 21 May 2026 08:58:07 +0300 Subject: [PATCH] =?UTF-8?q?ux:=20bulk=20WIP=20=E2=80=94=20UxPlugin?= =?UTF-8?q?=E2=86=92XPlugin=20rename=20+=20new=20anim/core/navi/reactive?= =?UTF-8?q?=20packages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 28 +- README.md | 10 +- android/src/main/jni/url_detect.cpp | 4 +- .../kotlin/io/swipelab/ux/GalleryPlugin.kt | 2 +- .../main/kotlin/io/swipelab/ux/UrlPlugin.kt | 2 +- .../swipelab/ux/{UxPlugin.kt => XPlugin.kt} | 2 +- .../io/swipelab/ux/camera/CameraInstance.kt | 2 +- .../io/swipelab/ux/camera/VideoCapture.kt | 2 +- darwin/Camera/CameraInstance.swift | 6 +- darwin/Camera/CaptureDevice.swift | 2 +- darwin/Camera/DeviceOrientation.swift | 2 +- darwin/Camera/PhotoOutput.swift | 4 +- example/lib/main.dart | 8 +- .../Flutter/GeneratedPluginRegistrant.swift | 2 +- example/pubspec.lock | 2 +- example/test/widget_test.dart | 2 +- .../Camera/AVCaptureConnection+iOS.swift | 2 +- ios/Classes/FilePlugin.swift | 12 +- ios/Classes/GalleryPlugin.swift | 6 +- ios/Classes/KeyboardPlugin.swift | 2 +- ios/Classes/NativePlugin.swift | 2 +- ios/Classes/{UxPlugin.swift => XPlugin.swift} | 2 +- ios/Classes/url_detect.m | 22 +- lib/src/anim/animated_color.dart | 61 ++ lib/src/anim/animated_double.dart | 61 ++ lib/src/anim/dock.dart | 118 ++++ lib/src/anim/measured.dart | 42 ++ lib/src/anim/pane.dart | 384 ++++++++++ lib/src/anim/sheet.dart | 668 ++++++++++++++++++ lib/src/camera/camera_preview.dart | 14 +- lib/src/clipboard.dart | 4 +- lib/src/core/bloc_builder.dart | 74 ++ lib/src/core/core.dart | 18 + lib/src/core/debouncer.dart | 35 + lib/src/core/dispose.dart | 67 ++ lib/src/core/emitter.dart | 189 +++++ lib/src/core/functional.dart | 32 + lib/src/core/late.dart | 141 ++++ lib/src/core/list_emitter.dart | 291 ++++++++ lib/src/core/presenter.dart | 5 + lib/src/core/publisher.dart | 49 ++ lib/src/core/range.dart | 24 + lib/src/core/store/async_init.dart | 7 + .../store/factory/instance_store_factory.dart | 16 + .../store/factory/lazy_store_factory.dart | 37 + lib/src/core/store/factory/store_factory.dart | 16 + .../factory/transient_store_factory.dart | 20 + lib/src/core/store/store.dart | 117 +++ lib/src/core/store/store_provider.dart | 30 + lib/src/core/subscription.dart | 103 +++ lib/src/core/tasks.dart | 154 ++++ lib/src/core/uri.dart | 200 ++++++ lib/src/core/value.dart | 56 ++ lib/src/core/widget.dart | 29 + lib/src/crash.dart | 4 +- lib/src/file.dart | 28 +- lib/src/gallery.dart | 110 +-- lib/src/keyboard.dart | 8 +- lib/src/navi/hero.dart | 156 ++++ lib/src/navi/router.dart | 385 ++++++++++ lib/src/navi/screen.dart | 136 ++++ lib/src/navi/screen_host.dart | 15 + lib/src/navi/screen_stack.dart | 427 +++++++++++ lib/src/navi/sheet_screen.dart | 182 +++++ lib/src/navi/transitions.dart | 70 ++ lib/src/reactive/future_reactive_builder.dart | 86 +++ lib/src/reactive/reactive.dart | 57 ++ lib/src/reactive/reactive_builder.dart | 146 ++++ lib/src/scanner.dart | 12 +- lib/src/sensor.dart | 8 +- lib/src/testing/fake_gallery.dart | 58 +- lib/src/url.dart | 4 +- lib/ux.dart | 37 +- .../Camera/AVCaptureConnection+macOS.swift | 2 +- macos/Classes/FilePlugin.swift | 10 +- macos/Classes/NativePlugin.swift | 2 +- .../Classes/{UxPlugin.swift => XPlugin.swift} | 2 +- macos/Classes/url_detect.m | 22 +- pubspec.yaml | 8 +- test/camera/camera_channel_test.dart | 28 +- test/crash_test.dart | 6 +- test/gallery_test.dart | 70 +- test/ux_test.dart | 12 +- 83 files changed, 5006 insertions(+), 275 deletions(-) rename android/src/main/kotlin/io/swipelab/ux/{UxPlugin.kt => XPlugin.kt} (96%) rename ios/Classes/{UxPlugin.swift => XPlugin.swift} (93%) create mode 100644 lib/src/anim/animated_color.dart create mode 100644 lib/src/anim/animated_double.dart create mode 100644 lib/src/anim/dock.dart create mode 100644 lib/src/anim/measured.dart create mode 100644 lib/src/anim/pane.dart create mode 100644 lib/src/anim/sheet.dart create mode 100644 lib/src/core/bloc_builder.dart create mode 100644 lib/src/core/core.dart create mode 100644 lib/src/core/debouncer.dart create mode 100644 lib/src/core/dispose.dart create mode 100644 lib/src/core/emitter.dart create mode 100644 lib/src/core/functional.dart create mode 100644 lib/src/core/late.dart create mode 100644 lib/src/core/list_emitter.dart create mode 100644 lib/src/core/presenter.dart create mode 100644 lib/src/core/publisher.dart create mode 100644 lib/src/core/range.dart create mode 100644 lib/src/core/store/async_init.dart create mode 100644 lib/src/core/store/factory/instance_store_factory.dart create mode 100644 lib/src/core/store/factory/lazy_store_factory.dart create mode 100644 lib/src/core/store/factory/store_factory.dart create mode 100644 lib/src/core/store/factory/transient_store_factory.dart create mode 100644 lib/src/core/store/store.dart create mode 100644 lib/src/core/store/store_provider.dart create mode 100644 lib/src/core/subscription.dart create mode 100644 lib/src/core/tasks.dart create mode 100644 lib/src/core/uri.dart create mode 100644 lib/src/core/value.dart create mode 100644 lib/src/core/widget.dart create mode 100644 lib/src/navi/hero.dart create mode 100644 lib/src/navi/router.dart create mode 100644 lib/src/navi/screen.dart create mode 100644 lib/src/navi/screen_host.dart create mode 100644 lib/src/navi/screen_stack.dart create mode 100644 lib/src/navi/sheet_screen.dart create mode 100644 lib/src/navi/transitions.dart create mode 100644 lib/src/reactive/future_reactive_builder.dart create mode 100644 lib/src/reactive/reactive.dart create mode 100644 lib/src/reactive/reactive_builder.dart rename macos/Classes/{UxPlugin.swift => XPlugin.swift} (91%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6043208..aaddbdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,9 @@ ### 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); Android uses CameraX preview + ZXing decoder (`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')`. ### 0.8.0 @@ -17,7 +17,7 @@ log-then-rethrow is deduped via an `Expando` mark so crash handlers don't double-report. Override the `captureCrashes` hook to customise or pass `() {}` 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 when debug level is enabled. @@ -30,11 +30,11 @@ order; every other op is O(1). ### 0.6.0 -- `UxFile`: new module for handing files to the OS - - `UxFile.share(path, {title, mimeType, sourceRect})` — iOS `UIActivityViewController`, +- `XFile`: new module for handing files to the OS + - `XFile.share(path, {title, mimeType, sourceRect})` — iOS `UIActivityViewController`, macOS `NSSharingServicePicker`, Android `Intent.ACTION_SEND` via FileProvider. `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, no foreground loss), Android `Intent.ACTION_VIEW` with MIME inference from `MimeTypeMap` + a fallback set of text/code extensions so unknown types @@ -44,14 +44,14 @@ - Android: ships a FileProvider under `${applicationId}.ux.fileprovider` scoped to `ux_share/` in the app cache — host apps don't need manifest plumbing. -- iOS: `UxWindow` helper (`keyWindow` / `topViewController`) in +- iOS: `XWindow` helper (`keyWindow` / `topViewController`) in `NativePlugin.swift`, shared between keyboard and file plugins. ### 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 - `AppInfo`: version + buildNumber surface -- `UxKeyboard`: focus tracking integration +- `XKeyboard`: focus tracking integration ### 0.4.0 - `package:ux/testing.dart`: new entry point for test-only utilities @@ -63,17 +63,17 @@ `flutter test --update-goldens` regenerates text goldens too. ### 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 -- `UxKeyboard`: handle zero-duration keyboard notifications (instant snap) -- `UxKeyboard`: gate adaptive learning debug output behind `kDebugMode` +- `XKeyboard`: handle zero-duration keyboard notifications (instant snap) +- `XKeyboard`: gate adaptive learning debug output behind `kDebugMode` - Example: rewrite as chat UI demonstrating `ListenableBuilder`, scroll freeze, and interactive dismiss - Example: modernize Android project (v2 embedding, AGP 8.7, Gradle 8.11) ### 0.2.0 -- `UxKeyboard`: sampled native animation curves (iOS & Android) with adaptive learning -- `UxKeyboard`: interactive dismiss via pan gesture +- `XKeyboard`: sampled native animation curves (iOS & Android) with adaptive learning +- `XKeyboard`: interactive dismiss via pan gesture - Android: keyboard height tracking via JNI/FFI bridge ### 0.1.1 diff --git a/README.md b/README.md index e3a6f78..c212328 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ A Flutter toolkit for building fluid, native-feeling UIs. -## UxKeyboard +## XKeyboard 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 -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. ### Features @@ -21,7 +21,7 @@ from the native layer via FFI — zero channel latency, every frame. ### Quick start ```dart -final keyboard = UxKeyboard.instance; +final keyboard = XKeyboard.instance; // Enable swipe-to-dismiss. trackingInset is the height of your input bar. keyboard.enableInteractiveDismiss(trackingInset: 56); @@ -66,7 +66,7 @@ Scaffold( | Member | Description | |---|---| -| `UxKeyboard.instance` | Singleton instance | +| `XKeyboard.instance` | Singleton instance | | `.height` | Current keyboard height in logical pixels | | `.systemHeight` | Last system-reported keyboard height | | `.isOpen` | Whether the keyboard is visible | @@ -78,7 +78,7 @@ Scaffold( ### Key points - 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 is consumed by `Scaffold`) - Use `max(keyboardHeight, safeBottom)` for bottom padding — the keyboard height includes diff --git a/android/src/main/jni/url_detect.cpp b/android/src/main/jni/url_detect.cpp index 84d5d54..2b122ac 100644 --- a/android/src/main/jni/url_detect.cpp +++ b/android/src/main/jni/url_detect.cpp @@ -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: // uint8_t* ux_match_url(const uint16_t* utf16, int32_t len, int32_t* out_size); @@ -24,7 +24,7 @@ #include #include -#define UX_LOG_TAG "UxUrl" +#define UX_LOG_TAG "XUrl" #define UX_LOGE(...) __android_log_print(ANDROID_LOG_ERROR, UX_LOG_TAG, __VA_ARGS__) namespace { diff --git a/android/src/main/kotlin/io/swipelab/ux/GalleryPlugin.kt b/android/src/main/kotlin/io/swipelab/ux/GalleryPlugin.kt index 72e7b92..21dd79c 100644 --- a/android/src/main/kotlin/io/swipelab/ux/GalleryPlugin.kt +++ b/android/src/main/kotlin/io/swipelab/ux/GalleryPlugin.kt @@ -26,7 +26,7 @@ import java.io.ByteArrayOutputStream import java.io.File 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 /// `ContentResolver.loadThumbnail` (API 29+), and stream-copy file /// resolution into the app cache so `dart:io` can read what the diff --git a/android/src/main/kotlin/io/swipelab/ux/UrlPlugin.kt b/android/src/main/kotlin/io/swipelab/ux/UrlPlugin.kt index 861025a..29cbd13 100644 --- a/android/src/main/kotlin/io/swipelab/ux/UrlPlugin.kt +++ b/android/src/main/kotlin/io/swipelab/ux/UrlPlugin.kt @@ -14,7 +14,7 @@ class UrlPlugin : NativePlugin, MethodChannel.MethodCallHandler { init { // Trigger JNI_OnLoad in libux.so so the FFI shim's // android.util.Patterns bindings are cached before the first - // UxUrl.match call from Dart. + // XUrl.match call from Dart. System.loadLibrary("ux") } } diff --git a/android/src/main/kotlin/io/swipelab/ux/UxPlugin.kt b/android/src/main/kotlin/io/swipelab/ux/XPlugin.kt similarity index 96% rename from android/src/main/kotlin/io/swipelab/ux/UxPlugin.kt rename to android/src/main/kotlin/io/swipelab/ux/XPlugin.kt index 652966c..cba89b0 100644 --- a/android/src/main/kotlin/io/swipelab/ux/UxPlugin.kt +++ b/android/src/main/kotlin/io/swipelab/ux/XPlugin.kt @@ -5,7 +5,7 @@ import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.swipelab.ux.camera.CameraPlugin -class UxPlugin : FlutterPlugin, ActivityAware { +class XPlugin : FlutterPlugin, ActivityAware { private val plugins: List = listOf( KeyboardPlugin(), SensorPlugin(), diff --git a/android/src/main/kotlin/io/swipelab/ux/camera/CameraInstance.kt b/android/src/main/kotlin/io/swipelab/ux/camera/CameraInstance.kt index 399b5ce..3e9f179 100644 --- a/android/src/main/kotlin/io/swipelab/ux/camera/CameraInstance.kt +++ b/android/src/main/kotlin/io/swipelab/ux/camera/CameraInstance.kt @@ -13,7 +13,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.ProcessLifecycleOwner 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 + /// SurfaceProvider), the [PhotoCapture] use-case, and the per-instance /// [CustomLifecycleOwner] that drives them. diff --git a/android/src/main/kotlin/io/swipelab/ux/camera/VideoCapture.kt b/android/src/main/kotlin/io/swipelab/ux/camera/VideoCapture.kt index cae1e35..36346b2 100644 --- a/android/src/main/kotlin/io/swipelab/ux/camera/VideoCapture.kt +++ b/android/src/main/kotlin/io/swipelab/ux/camera/VideoCapture.kt @@ -32,7 +32,7 @@ class VideoCapture { val useCase: androidx.camera.video.VideoCapture = androidx.camera.video.VideoCapture.Builder(recorder) // 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) .build() diff --git a/darwin/Camera/CameraInstance.swift b/darwin/Camera/CameraInstance.swift index 9946b12..4094286 100644 --- a/darwin/Camera/CameraInstance.swift +++ b/darwin/Camera/CameraInstance.swift @@ -6,7 +6,7 @@ import FlutterMacOS #endif 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 /// output, audio + video data outputs, and the /// [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 // playback decision. if let videoConn = videoDataOutput?.connection(with: .video) { - videoConn.applyUxCaptureOrientation( + videoConn.applyXCaptureOrientation( lockedOrientation ?? orientation.current ) if videoConn.isVideoMirroringSupported { @@ -558,7 +558,7 @@ final class CameraInstance { private func applyVideoOrientationOnPreview(_ next: DeviceOrientationFlutter) { videoDataOutput?.connection(with: .video)? - .applyUxCaptureOrientation(next) + .applyXCaptureOrientation(next) } private func emit(_ extras: [String: Any]) { diff --git a/darwin/Camera/CaptureDevice.swift b/darwin/Camera/CaptureDevice.swift index 931686e..7ffee90 100644 --- a/darwin/Camera/CaptureDevice.swift +++ b/darwin/Camera/CaptureDevice.swift @@ -108,7 +108,7 @@ enum CaptureDevice { /// One row of the discovery result. `lens` and `sensorOrientation` /// match the wire shape expected by Dart's -/// `MethodChannelUxCameraBackend.availableCameras()`. +/// `MethodChannelXCameraBackend.availableCameras()`. struct DiscoveredCamera { let device: AVCaptureDevice let lens: String diff --git a/darwin/Camera/DeviceOrientation.swift b/darwin/Camera/DeviceOrientation.swift index d86d30d..acc0600 100644 --- a/darwin/Camera/DeviceOrientation.swift +++ b/darwin/Camera/DeviceOrientation.swift @@ -18,7 +18,7 @@ public enum DeviceOrientationFlutter: String { case landscapeRight /// 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 { return DeviceOrientationFlutter(rawValue: raw ?? "") ?? .portraitUp } diff --git a/darwin/Camera/PhotoOutput.swift b/darwin/Camera/PhotoOutput.swift index 9fb1a68..441cb62 100644 --- a/darwin/Camera/PhotoOutput.swift +++ b/darwin/Camera/PhotoOutput.swift @@ -49,7 +49,7 @@ final class PhotoOutput { // JPEG); macOS is a no-op (desktop cams are physically // landscape, any rotation skews the photo). See // `AVCaptureConnection+iOS.swift` / `…+macOS.swift`. - connection.applyUxCaptureOrientation(orientation) + connection.applyXCaptureOrientation(orientation) // The recorded photo carries no mirror; mirroring is a // preview-only concern. if connection.isVideoMirroringSupported { @@ -79,7 +79,7 @@ final class PhotoOutput { // snapshot defaults cleanly. No-op on macOS (the // extension method is empty there). self?.avOutput.connection(with: .video)? - .applyUxCaptureOrientation(.portraitUp) + .applyXCaptureOrientation(.portraitUp) self?.inFlight = nil DispatchQueue.main.async { completion(result) } } diff --git a/example/lib/main.dart b/example/lib/main.dart index 2f0dc61..6228316 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -8,7 +8,7 @@ void main() => runApp(MaterialApp( home: ChatScreen(), )); -/// Demonstrates UxKeyboard in a chat UI: +/// Demonstrates XKeyboard in a chat UI: /// - Frame-accurate keyboard height tracking (no Flutter viewInsets lag) /// - Interactive dismiss (swipe the keyboard down like iMessage) /// - Scroll freeze while the user is panning the keyboard @@ -20,7 +20,7 @@ class ChatScreen extends StatefulWidget { } class _ChatScreenState extends State { - final _keyboard = UxKeyboard.instance; + final _keyboard = XKeyboard.instance; final _textController = TextEditingController(); final _messages = List.generate( 30, @@ -54,8 +54,8 @@ class _ChatScreenState extends State { // Disable Flutter's built-in resize — we handle it ourselves. return Scaffold( resizeToAvoidBottomInset: false, - appBar: AppBar(title: Text('UxKeyboard Chat')), - // ListenableBuilder rebuilds only when UxKeyboard notifies (height changes). + appBar: AppBar(title: Text('XKeyboard Chat')), + // ListenableBuilder rebuilds only when XKeyboard notifies (height changes). body: ListenableBuilder( listenable: _keyboard, builder: (context, _) { diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index a154548..e3191ba 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,5 +8,5 @@ import Foundation import ux func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - UxPlugin.register(with: registry.registrar(forPlugin: "UxPlugin")) + XPlugin.register(with: registry.registrar(forPlugin: "XPlugin")) } diff --git a/example/pubspec.lock b/example/pubspec.lock index 9247aa9..af78c09 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -510,7 +510,7 @@ packages: path: ".." relative: true source: path - version: "0.9.0" + version: "0.10.0" vector_math: dependency: transitive description: diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index cad8b8e..ff937ca 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -5,7 +5,7 @@ import 'package:ux_example/main.dart'; void main() { testWidgets('ChatScreen renders', (WidgetTester tester) async { 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); }); } diff --git a/ios/Classes/Camera/AVCaptureConnection+iOS.swift b/ios/Classes/Camera/AVCaptureConnection+iOS.swift index 4371bad..6b3d676 100644 --- a/ios/Classes/Camera/AVCaptureConnection+iOS.swift +++ b/ios/Classes/Camera/AVCaptureConnection+iOS.swift @@ -8,7 +8,7 @@ import AVFoundation /// the result. The macOS counterpart in /// `macos/Classes/Camera/AVCaptureConnection+macOS.swift` is a no-op. extension AVCaptureConnection { - func applyUxCaptureOrientation(_ orientation: DeviceOrientationFlutter) { + func applyXCaptureOrientation(_ orientation: DeviceOrientationFlutter) { if isVideoOrientationSupported { videoOrientation = orientation.avVideoOrientation } diff --git a/ios/Classes/FilePlugin.swift b/ios/Classes/FilePlugin.swift index 5f45b9a..e6382fd 100644 --- a/ios/Classes/FilePlugin.swift +++ b/ios/Classes/FilePlugin.swift @@ -7,7 +7,7 @@ import UIKit public class FilePlugin: NSObject, NativePlugin { private var channel: FlutterMethodChannel? private var previewDataSource: FilePreviewDataSource? - private var pickerDelegate: UxDocumentPickerDelegate? + private var pickerDelegate: XDocumentPickerDelegate? private struct ScopedEntry { let url: URL @@ -91,7 +91,7 @@ public class FilePlugin: NSObject, NativePlugin { let path = args["path"] as? String else { 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)) } @@ -123,7 +123,7 @@ public class FilePlugin: NSObject, NativePlugin { } 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)) } let args = call.arguments as? [String: Any] @@ -136,7 +136,7 @@ public class FilePlugin: NSObject, NativePlugin { // cold starts. let picker = UIDocumentPickerViewController(documentTypes: utis, in: .open) picker.allowsMultipleSelection = false - let delegate = UxDocumentPickerDelegate(result: result) { [weak self] in + let delegate = XDocumentPickerDelegate(result: result) { [weak self] in self?.pickerDelegate = nil } // 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 { 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)) } @@ -322,7 +322,7 @@ fileprivate func mimeFromExtension(_ ext: String) -> String? { /// we briefly start access to read attributes + create a bookmark, then /// stop access. Persisted bookmark lets Dart re-acquire scope later via /// `beginScopedAccess`. -private final class UxDocumentPickerDelegate: NSObject, UIDocumentPickerDelegate { +private final class XDocumentPickerDelegate: NSObject, UIDocumentPickerDelegate { let result: FlutterResult let onDone: () -> Void private var settled = false diff --git a/ios/Classes/GalleryPlugin.swift b/ios/Classes/GalleryPlugin.swift index c0f7e2c..9921d9d 100644 --- a/ios/Classes/GalleryPlugin.swift +++ b/ios/Classes/GalleryPlugin.swift @@ -3,7 +3,7 @@ import Photos import PhotosUI 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 /// file resolution into the app cache. 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 // `ux/gallery/changes` via the library observer (fires after // 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 // Apple's docs: the completion handler runs on "an // arbitrary serial dispatch queue". Flutter method- @@ -110,7 +110,7 @@ public class GalleryPlugin: NSObject, NativePlugin, PHPhotoLibraryChangeObserver 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) result(nil) } else { diff --git a/ios/Classes/KeyboardPlugin.swift b/ios/Classes/KeyboardPlugin.swift index eff1d23..0a9faa5 100644 --- a/ios/Classes/KeyboardPlugin.swift +++ b/ios/Classes/KeyboardPlugin.swift @@ -268,7 +268,7 @@ public class KeyboardPlugin: NSObject, NativePlugin { } 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 pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) diff --git a/ios/Classes/NativePlugin.swift b/ios/Classes/NativePlugin.swift index 35f8f18..4e2bc3d 100644 --- a/ios/Classes/NativePlugin.swift +++ b/ios/Classes/NativePlugin.swift @@ -5,7 +5,7 @@ public protocol NativePlugin { func register(with registrar: FlutterPluginRegistrar) } -public enum UxWindow { +public enum XWindow { public static var keyWindow: UIWindow? { if let w = UIApplication.shared.delegate?.window ?? nil { return w } for scene in UIApplication.shared.connectedScenes { diff --git a/ios/Classes/UxPlugin.swift b/ios/Classes/XPlugin.swift similarity index 93% rename from ios/Classes/UxPlugin.swift rename to ios/Classes/XPlugin.swift index 4e9a343..b53418b 100644 --- a/ios/Classes/UxPlugin.swift +++ b/ios/Classes/XPlugin.swift @@ -1,7 +1,7 @@ import Flutter import UIKit -public class UxPlugin: NSObject, FlutterPlugin { +public class XPlugin: NSObject, FlutterPlugin { private static var plugins: [NativePlugin] = [] public static func register(with registrar: FlutterPluginRegistrar) { diff --git a/ios/Classes/url_detect.m b/ios/Classes/url_detect.m index c7c798a..dc87acd 100644 --- a/ios/Classes/url_detect.m +++ b/ios/Classes/url_detect.m @@ -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: // 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 kKindPhone = 2; -@interface UxUrlRawMatch : NSObject +@interface XUrlRawMatch : NSObject @property (nonatomic) int32_t start; @property (nonatomic) int32_t end; @property (nonatomic) uint32_t kind; @property (nonatomic, copy) NSData *urlUtf8; @end -@implementation UxUrlRawMatch +@implementation XUrlRawMatch @end 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; NSRange whole = NSMakeRange(0, text.length); - NSMutableArray *raws = [NSMutableArray array]; + NSMutableArray *raws = [NSMutableArray array]; NSDataDetector *detector = ux_url_data_detector(); 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; - UxUrlRawMatch *m = [[UxUrlRawMatch alloc] init]; + XUrlRawMatch *m = [[XUrlRawMatch alloc] init]; m.start = (int32_t)r.location; m.end = (int32_t)(r.location + r.length); 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; NSString *substr = [text substringWithRange:r]; NSString *withScheme = [@"http://" stringByAppendingString:substr]; - UxUrlRawMatch *m = [[UxUrlRawMatch alloc] init]; + XUrlRawMatch *m = [[XUrlRawMatch alloc] init]; m.start = (int32_t)r.location; m.end = (int32_t)(r.location + r.length); 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; // 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; int32_t la = a.end - a.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. - NSMutableArray *kept = [NSMutableArray arrayWithCapacity:raws.count]; + NSMutableArray *kept = [NSMutableArray arrayWithCapacity:raws.count]; int32_t lastEnd = 0; BOOL haveAny = NO; - for (UxUrlRawMatch *m in raws) { + for (XUrlRawMatch *m in raws) { if (haveAny && m.start < lastEnd) continue; [kept addObject:m]; 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; - for (UxUrlRawMatch *m in kept) { + for (XUrlRawMatch *m in kept) { 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; memcpy(buf, &cnt, 4); NSUInteger off = 4; - for (UxUrlRawMatch *m in kept) { + for (XUrlRawMatch *m in kept) { int32_t start = m.start; int32_t end = m.end; uint32_t kind = m.kind; diff --git a/lib/src/anim/animated_color.dart b/lib/src/anim/animated_color.dart new file mode 100644 index 0000000..7e71f3b --- /dev/null +++ b/lib/src/anim/animated_color.dart @@ -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 builder; + final Widget? child; + + @override + State createState() => _AnimatedColorState(); +} + +class _AnimatedColorState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _animationController; + late Animation _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), + ); + } +} diff --git a/lib/src/anim/animated_double.dart b/lib/src/anim/animated_double.dart new file mode 100644 index 0000000..7ea9795 --- /dev/null +++ b/lib/src/anim/animated_double.dart @@ -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 builder; + final Widget? child; + final double start; + final double end; + final Duration duration; + final Curve curve; + + @override + State createState() => _AnimatedDoubleState(); +} + +class _AnimatedDoubleState extends State + 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, + ); + } +} diff --git a/lib/src/anim/dock.dart b/lib/src/anim/dock.dart new file mode 100644 index 0000000..6e54bbc --- /dev/null +++ b/lib/src/anim/dock.dart @@ -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, + ); + } +} diff --git a/lib/src/anim/measured.dart b/lib/src/anim/measured.dart new file mode 100644 index 0000000..b1137e5 --- /dev/null +++ b/lib/src/anim/measured.dart @@ -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 createState() => _MeasuredState(); +} + +class _MeasuredState extends State { + @override + void initState() { + super.initState(); + report(); + } + + @override + Widget build(BuildContext context) { + return NotificationListener( + onNotification: handleSizeChanged, + child: SizeChangedLayoutNotifier( + child: widget.child, + ), + ); + } + + void report() { + widget.onSize?.call(); + } + + bool handleSizeChanged(SizeChangedLayoutNotification notification) { + report(); + return false; + } +} diff --git a/lib/src/anim/pane.dart b/lib/src/anim/pane.dart new file mode 100644 index 0000000..ba4f07f --- /dev/null +++ b/lib/src/anim/pane.dart @@ -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 widgets = {}; + final Map elements = {}; + final Map 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 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( + LayoutCallback 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 debugDescribeChildren() => + controller.debugDescribeChildren(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + controller.debugFillProperties(properties); + } +} diff --git a/lib/src/anim/sheet.dart b/lib/src/anim/sheet.dart new file mode 100644 index 0000000..1b59b98 --- /dev/null +++ b/lib/src/anim/sheet.dart @@ -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 Function()? _dismissCallback; + + /// Animate the sheet offscreen. Returns a future that completes + /// when the dismissal is done. + Future 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? 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 createState() => SheetState(); +} + +class SheetState extends State 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 _animateDismiss() { + if (_hasRouteAnimation) { + widget.onDismiss?.call(); + return Future.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 _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 _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 _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: >{ + _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), + ), + ), + ), + ); + } +} diff --git a/lib/src/camera/camera_preview.dart b/lib/src/camera/camera_preview.dart index f1e947b..bb76159 100644 --- a/lib/src/camera/camera_preview.dart +++ b/lib/src/camera/camera_preview.dart @@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart' show defaultTargetPlatform, TargetPlatform; 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 /// 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 /// 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. /// /// Front-camera preview is auto-mirrored here (the analog of /// telegram-iOS's `CameraPreviewView.mirroring` property), so the /// recorded MP4 + captured JPEG carry the raw sensor feed while the /// on-screen preview still reads as a natural mirror to the user. -class UxCameraPreview extends StatelessWidget { - const UxCameraPreview({super.key, required this.controller}); +class XCameraPreview extends StatelessWidget { + const XCameraPreview({super.key, required this.controller}); - final UxCameraController controller; + final XCameraController controller; @override Widget build(BuildContext context) { - return ValueListenableBuilder( + return ValueListenableBuilder( valueListenable: controller, builder: (context, value, _) { final id = controller.textureId; @@ -47,7 +47,7 @@ class UxCameraPreview extends StatelessWidget { // the phone tilt (because we're flipping the axis CameraX // rotated around). if (defaultTargetPlatform != TargetPlatform.android && - value.description.lens == UxCameraLens.front) { + value.description.lens == XCameraLens.front) { child = Transform.flip(flipX: true, child: child); } return child; diff --git a/lib/src/clipboard.dart b/lib/src/clipboard.dart index 5f29ab1..6faa0df 100644 --- a/lib/src/clipboard.dart +++ b/lib/src/clipboard.dart @@ -3,8 +3,8 @@ import 'package:flutter/services.dart'; /// OS clipboard access for shapes Flutter's [Clipboard] doesn't cover. /// Right now this is image bytes — the system text path is already /// handled by the SDK's `Clipboard.getData(Clipboard.kTextPlain)`. -class UxClipboard { - UxClipboard._(); +class XClipboard { + XClipboard._(); static const _channel = MethodChannel('ux/clipboard'); diff --git a/lib/src/core/bloc_builder.dart b/lib/src/core/bloc_builder.dart new file mode 100644 index 0000000..414f5c2 --- /dev/null +++ b/lib/src/core/bloc_builder.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'dispose.dart'; + +/// Signature for the widget building function used by [BlocBuilder]. +typedef BlocBuilderDelegate = Widget Function( + BuildContext context, + T bloc, + Widget? child, +); + +/// Signature for the create callback that instantiates the bloc once. +typedef BlocBuilderCreateDelegate = T Function( + BuildContext context, +); + +/// Lightweight life‑cycle 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 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 create; + + /// Builds the widget given the created bloc + final BlocBuilderDelegate builder; + final Widget? child; + + @override + State> createState() => _BlocBuilderState(); +} + +class _BlocBuilderState extends State> { + late T bloc; + + @override + void initState() { + super.initState(); + bloc = widget.create(context); + } + + /// Disposes underlying bloc if it supports disposal semantics. + void _dispose(BlocBuilder 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, + ); + } +} diff --git a/lib/src/core/core.dart b/lib/src/core/core.dart new file mode 100644 index 0000000..d951b33 --- /dev/null +++ b/lib/src/core/core.dart @@ -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'; diff --git a/lib/src/core/debouncer.dart b/lib/src/core/debouncer.dart new file mode 100644 index 0000000..d014325 --- /dev/null +++ b/lib/src/core/debouncer.dart @@ -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 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]. Re‑invocation +/// resets the timer. Useful for text search, resize, etc. +VoidCallback debounce( + FutureOr Function() f, [ + Duration duration = kDefaultDebounce, +]) { + final debouncer = Debouncer(duration: duration); + return () => debouncer.run(f); +} diff --git a/lib/src/core/dispose.dart b/lib/src/core/dispose.dart new file mode 100644 index 0000000..3b4e7e2 --- /dev/null +++ b/lib/src/core/dispose.dart @@ -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 _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); +} diff --git a/lib/src/core/emitter.dart b/lib/src/core/emitter.dart new file mode 100644 index 0000000..ffb8cdd --- /dev/null +++ b/lib/src/core/emitter.dart @@ -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 map(List sources, ValueGetter 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 sources, + VoidCallback callback, + ) { + final subscriptions = sources.fold( + [], + (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(0); +/// counter.addListener(() => print(counter.value)); +/// counter.value++; +/// ``` +/// {@end-tool} +class ValueEmitter with Emitter implements ValueNotifier { + 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 with Emitter implements ValueListenable { + LazyEmitter(this.fn); + + final ValueGetter 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 on Stream { + VoidCallback subscribe(ValueChanged 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); +} diff --git a/lib/src/core/functional.dart b/lib/src/core/functional.dart new file mode 100644 index 0000000..c3c92f8 --- /dev/null +++ b/lib/src/core/functional.dart @@ -0,0 +1,32 @@ +const kEpsilon = 0.001; + +/// Generic function taking no params returning [T]. +typedef Callback = T Function(); + +/// Truthy test used across selection APIs. +typedef Predicate = bool Function(T e); + +extension ObjectFunctional on T { + /// Functional pipe: `value.pipe(fn)` -> `fn(value)`. + R pipe(R Function(T e) e) => e(this); +} + +/// Identity helper (returns input unchanged). +T self(T e) => e; + +/// Executes a zero‑arg [Callback]. +void call(Callback e) => e(); + +bool True() => true; + +bool False() => false; + +extension IterableLastWhereOrNull on Iterable { + T? lastWhereOrNull(bool Function(T) test) { + T? result; + for (final element in this) { + if (test(element)) result = element; + } + return result; + } +} diff --git a/lib/src/core/late.dart b/lib/src/core/late.dart new file mode 100644 index 0000000..aacd6e9 --- /dev/null +++ b/lib/src/core/late.dart @@ -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 { + factory Late.value(T value) => LateValue(value); + + factory Late.loading() => const LateLoading(); + + factory Late.error([dynamic error]) => LateError(error); +} + +extension LateExtension on Late { + 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({ + required R Function(LateValue e) value, + required R Function(LateError e) error, + required R Function(LateLoading e) loading, + }) { + final e = this; + return switch (e) { + LateLoading() => loading(e), + LateError() => error(e), + LateValue() => value(e), + }; + } + + R maybeMap({ + required R Function() orElse, + R Function(LateValue e)? value, + R Function(LateError e)? error, + R Function(LateLoading 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 Function(LateValue e)? value, + R Function(LateError e)? error, + R Function(LateLoading e)? loading, + }) { + final e = this; + return switch (e) { + LateLoading() => loading?.call(e), + LateError() => error?.call(e), + LateValue() => value?.call(e), + }; + } + + R when({ + 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 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({ + 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 implements Late { + 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 implements Late { + const LateLoading(); + + @override + bool operator ==(Object other) => + other is LateLoading && other.runtimeType == runtimeType; + + @override + int get hashCode => 0; +} + +class LateValue implements Late { + 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]); +} diff --git a/lib/src/core/list_emitter.dart b/lib/src/core/list_emitter.dart new file mode 100644 index 0000000..63d2684 --- /dev/null +++ b/lib/src/core/list_emitter.dart @@ -0,0 +1,291 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; + +import 'emitter.dart'; + +/// A `List` wrapper that emits change notifications for all mutating +/// operations. Non‑mutating queries delegate to the underlying list. +/// A `List` wrapper that emits change notifications for all mutating +/// operations. Non‑mutating queries delegate to the underlying list. +/// +/// {@tool snippet} +/// ```dart +/// final list = ListEmitter(); +/// list.addListener(() => print('len: ${list.length}')); +/// list.add(1); // prints len: 1 +/// ``` +/// {@end-tool} +class ListEmitter with Emitter implements List, ValueListenable> { + List _list; + + ListEmitter([Iterable? items]) : _list = List.from(items ?? []); + + @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 operator +(List 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 iterable) { + _list.addAll(iterable); + notifyListeners(); + } + + @override + bool any(bool Function(T element) test) => _list.any(test); + + @override + Map asMap() => _list.asMap(); + + @override + List cast() => _list.cast(); + + @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 expand(Iterable f(T element)) => _list.expand(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 initialValue, R Function(R previousValue, T element) combine) => + _list.fold(initialValue, combine); + + @override + Iterable followedBy(Iterable other) => _list.followedBy(other); + + @override + void forEach(void Function(T element) f) => _list.forEach(f); + + @override + Iterable 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 iterable) { + _list.insertAll(index, iterable); + notifyListeners(); + } + + @override + bool get isEmpty => _list.isEmpty; + + @override + bool get isNotEmpty => _list.isNotEmpty; + + @override + Iterator 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 map(R Function(T e) f) => _list.map(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 replacement) { + _list.replaceRange(start, end, replacement); + notifyListeners(); + } + + /// Replaces entire contents with [replacement] (single notification). + void replaceWith(Iterable replacement) { + _list.clear(); + _list.addAll(replacement); + notifyListeners(); + } + + /// Swaps underlying list reference with [list] and notifies. + void swap(List list) { + _list = list; + notifyListeners(); + } + + @override + void retainWhere(bool Function(T element) test) { + _list.retainWhere(test); + notifyListeners(); + } + + @override + Iterable get reversed => _list.reversed; + + @override + void setAll(int index, Iterable iterable) { + _list.setAll(index, iterable); + notifyListeners(); + } + + @override + void setRange(int start, int end, Iterable 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 skip(int count) => _list.skip(count); + + @override + Iterable 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 sublist(int start, [int? end]) => _list.sublist(start, end); + + @override + Iterable take(int count) => _list.take(count); + + @override + Iterable takeWhile(bool Function(T value) test) => _list.takeWhile(test); + + @override + List toList({bool growable = true}) => _list.toList(growable: growable); + + @override + Set toSet() => _list.toSet(); + + @override + Iterable where(bool Function(T element) test) => _list.where(test); + + @override + Iterable whereType() => _list.whereType(); + + @override + List get value => _list; +} diff --git a/lib/src/core/presenter.dart b/lib/src/core/presenter.dart new file mode 100644 index 0000000..63a5e92 --- /dev/null +++ b/lib/src/core/presenter.dart @@ -0,0 +1,5 @@ +import 'package:flutter/widgets.dart'; + +mixin Presenter { + Widget buildPresenter(BuildContext context); +} diff --git a/lib/src/core/publisher.dart b/lib/src/core/publisher.dart new file mode 100644 index 0000000..5587036 --- /dev/null +++ b/lib/src/core/publisher.dart @@ -0,0 +1,49 @@ +import 'package:flutter/foundation.dart'; + +import 'dispose.dart'; +import 'emitter.dart'; + +/// Minimal synchronous event bus for simple message fan‑out. +/// +/// {@tool snippet} +/// ```dart +/// sealed class AppEvent {} +/// class SignedIn extends AppEvent { SignedIn(this.user); final String user; } +/// final bus = Publisher(); +/// bus.on((e) => print('Hello \\${e.user}')); +/// bus.publish(SignedIn('Alice')); +/// ``` +/// {@end-tool} +class Publisher with Dispose { + Publisher() { + _subscriptions.clear.disposeBy(this); + } + + final List> _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([ValueSetter? callback]) { + final notifier = PublicEmitter(); + subscribe((e) { + if (e is E) { + callback?.call(e); + notifier.notifyListeners(); + } + }).disposeBy(notifier); + return notifier; + } +} diff --git a/lib/src/core/range.dart b/lib/src/core/range.dart new file mode 100644 index 0000000..a0c195b --- /dev/null +++ b/lib/src/core/range.dart @@ -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; + } +} diff --git a/lib/src/core/store/async_init.dart b/lib/src/core/store/async_init.dart new file mode 100644 index 0000000..923ee79 --- /dev/null +++ b/lib/src/core/store/async_init.dart @@ -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 init(); +} diff --git a/lib/src/core/store/factory/instance_store_factory.dart b/lib/src/core/store/factory/instance_store_factory.dart new file mode 100644 index 0000000..33102ab --- /dev/null +++ b/lib/src/core/store/factory/instance_store_factory.dart @@ -0,0 +1,16 @@ +import 'package:ux/src/core/store/factory/store_factory.dart'; + +/// Factory wrapping a pre-instantiated singleton. +class InstanceStoreFactory extends StoreFactory { + InstanceStoreFactory(T value) : _value = value; + + final T _value; + + Future get future async => _value; + + @override + T get instance => _value; + + @override + String toString() => 'Instance: $T ${instance.runtimeType}'; +} diff --git a/lib/src/core/store/factory/lazy_store_factory.dart b/lib/src/core/store/factory/lazy_store_factory.dart new file mode 100644 index 0000000..ed34c4c --- /dev/null +++ b/lib/src/core/store/factory/lazy_store_factory.dart @@ -0,0 +1,37 @@ +import 'dart:async'; + +import 'package:ux/src/core/core.dart'; + +/// Lazily creates & caches an instance (supports [AsyncInit]). +class LazyStoreFactory extends StoreFactory { + LazyStoreFactory({ + required this.resolver, + required this.delegate, + }); + + final ResolverCreateDelegate delegate; + final Resolver resolver; + Completer? _completer; + T? _instance; + + Future get future async { + if (_completer == null) { + _completer = Completer(); + 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}'; +} diff --git a/lib/src/core/store/factory/store_factory.dart b/lib/src/core/store/factory/store_factory.dart new file mode 100644 index 0000000..7f8a3bf --- /dev/null +++ b/lib/src/core/store/factory/store_factory.dart @@ -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 { + /// Future for async resolution / warm-up path. + Future 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 Function(T instance) fn) => fn(instance); +} diff --git a/lib/src/core/store/factory/transient_store_factory.dart b/lib/src/core/store/factory/transient_store_factory.dart new file mode 100644 index 0000000..2a87cb9 --- /dev/null +++ b/lib/src/core/store/factory/transient_store_factory.dart @@ -0,0 +1,20 @@ +import 'package:ux/src/core/core.dart'; + +/// Always produces a fresh instance on access. +class TransientStoreFactory extends StoreFactory with TransientFactory { + TransientStoreFactory({ + required this.locator, + required this.delegate, + }); + + final LocatorCreateDelegate delegate; + final Locator locator; + + Future get future async => instance; + + @override + T get instance => delegate(locator); + + @override + String toString() => 'Transient $T'; +} diff --git a/lib/src/core/store/store.dart b/lib/src/core/store/store.dart new file mode 100644 index 0000000..1249ebd --- /dev/null +++ b/lib/src/core/store/store.dart @@ -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 Function(Locator e); +typedef ResolverCreateDelegate = Future Function(Resolver e); +typedef FactoryDelegate = StoreFactory 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(); +} + +/// Async locator (mainly to enable lazy async) +mixin Resolver { + /// Resolves (and initialises if lazy) a registered service asynchronously. + Future resolve(); +} + +mixin Register on Resolver, Locator { + Map registry = {}; + + /// Activates registered factories (except [TransientFactory]) + /// This will enable a safe use of [Locator.get] + Future init() async { + await Future.wait( + registry.values.whereType().map((e) => e.future), + eagerError: true, + ); + } + + /// Registers a pre‑built singleton [instance]. + void add(T instance) => addFactory((e) => InstanceStoreFactory(instance)); + + /// Registers a custom factory. + void addFactory(FactoryDelegate factory) => registry[T] = factory(this); + + /// Registers a lazily created, cached async/sync singleton. + void addLazy(ResolverCreateDelegate delegate) => addFactory( + (e) => LazyStoreFactory(resolver: this, delegate: delegate), + ); + + /// Registers a transient factory (new instance each request). + void addTransient(LocatorCreateDelegate delegate) => addFactory( + (e) => TransientStoreFactory(locator: this, delegate: delegate), + ); +} + +/// Concrete store implementing locator + resolver behaviour. +/// +/// {@tool snippet} +/// ```dart +/// final store = Store() +/// ..add(Logger()) +/// ..addLazy((r) async => Config()) +/// ..addTransient((l) => DateTime.now()); +/// +/// await store.init(); // warm lazy singletons +/// final logger = store.get(); // sync +/// final config = await store.resolve(); // async +/// ``` +/// {@end-tool} +class Store with Locator, Resolver, Register { + @override + Future resolve() async { + final entry = registry[T]; + if (entry == null) { + throw Exception('$T is not registered (Store)'); + } + final instance = await (entry.future as Future); + return instance; + } + + @override + T get() { + final entry = registry[T]; + if (entry == null) { + throw Exception('$T is not registered (Store)'); + } + return entry.instance as T; + } + + T? tryGet() { + try { + return get(); + } catch (e) { + return null; + } + } +} + +class ScopedLocator with Locator { + ScopedLocator(this.parent); + + Locator parent; + + final Map _registry = {}; + + @override + T get() => _registry[T]?.instance ?? parent.get(); + + void add(T instance) { + _registry[T] = InstanceStoreFactory(instance); + } +} + +extension LocatorExtension on Locator { + ScopedLocator scoped() => ScopedLocator(this); +} diff --git a/lib/src/core/store/store_provider.dart b/lib/src/core/store/store_provider.dart new file mode 100644 index 0000000..16355c0 --- /dev/null +++ b/lib/src/core/store/store_provider.dart @@ -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 get() => StoreProvider.of(this).store.get(); +// } +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(); + assert(result != null, 'StoreProvider not found'); + return result!; + } + + @override + Widget build(BuildContext context) => Builder(builder: builder); +} diff --git a/lib/src/core/subscription.dart b/lib/src/core/subscription.dart new file mode 100644 index 0000000..21e41ac --- /dev/null +++ b/lib/src/core/subscription.dart @@ -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 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? 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 register; + final TransitionBuilder builder; + final Widget? child; + + @override + State createState() => _SubscriptionBuilderState(); +} + +class _SubscriptionBuilderState extends State { + 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); +} diff --git a/lib/src/core/tasks.dart b/lib/src/core/tasks.dart new file mode 100644 index 0000000..fcee593 --- /dev/null +++ b/lib/src/core/tasks.dart @@ -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 Function(); +typedef CancellableTaskDelegate = Future Function( + CancellationToken token); +typedef TypedTaskDelegate = Future Function(); + +/// Thrown when a queued task is cancelled (explicitly or during disposal). +class TaskCancelledException implements Exception {} + +class _Task implements CancellationToken { + _Task( + this.task, { + this.onDone, + }) : _isCancelled = false; + + final CancellableTaskDelegate task; + final VoidCallback? onDone; + bool _isCancelled; + + bool get isCancelled => _isCancelled; + + final _completer = Completer(); + + void done() { + onDone?.call(); + } + + void cancel() { + _isCancelled = true; + } + + void complete([FutureOr value]) { + _completer.complete(value); + done(); + } + + void completeError(Object error) { + _completer.completeError(error); + done(); + } + + Future run() => task.call(this); + + Future 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>[]; + + /// Enqueues [task] for execution and return the completion future + /// Throws: [TaskCancelledException] + @nonVirtual + Future 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 enqueueCancellable(CancellableTaskDelegate task) { + if (!_isActive) { + _isActive = true; + disposeTasks.disposeBy(this); + } + + final completer = _Task(task); + _queue.add(completer); + _dequeue(); + return completer.future; + } + + /// Provides a future that will complete when the current queue completes + /// Throws: [TaskCancelledException] + Future 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 _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; + } +} diff --git a/lib/src/core/uri.dart b/lib/src/core/uri.dart new file mode 100644 index 0000000..5124306 --- /dev/null +++ b/lib/src/core/uri.dart @@ -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( +/// 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 { + UriParser({ + this.routes = const [], + this.canonical = const {}, + }); + + final List routes; + final Map 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? Function(UriMatch match); + +/// Matched values & context provided to a [UriMapBuilder]. +class UriMatch { + UriMatch( + this.uri, + this.pathParameters, + this.state, + ); + + final Uri uri; + final Map pathParameters; + final State state; + + Map get queryParameters => uri.queryParameters; +} + +/// Associates one (or many) path patterns with a builder. +class UriMap { + UriMap( + String pattern, + this.builder, { + bool matchEnd = true, + }) : matchers = [PathMatcher(pattern, matchEnd: matchEnd)]; + + UriMap.many( + List patterns, + this.builder, { + bool matchEnd = true, + }) : matchers = + patterns.map((e) => PathMatcher(e, matchEnd: matchEnd)).toList(); + + final List 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'{(?([*]|(\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? match(String path) { + final match = pathTemplate.firstMatch(path); + if (match == null || path != pathTemplate.stringMatch(path)) { + return null; + } + final map = fields.fold( + {}, + (p, e) => p..[e] = match.namedGroup(e)!, + ); + return fields.every(map.containsKey) ? map : null; + } + + late final RegExp pathTemplate; + final String pattern; + final Set fields = {}; + + @override + String toString() { + return '$pathTemplate => (${fields.join(',')})'; + } +} diff --git a/lib/src/core/value.dart b/lib/src/core/value.dart new file mode 100644 index 0000000..79b2898 --- /dev/null +++ b/lib/src/core/value.dart @@ -0,0 +1,56 @@ +import 'package:flutter/animation.dart'; +import 'package:flutter/foundation.dart'; + +import 'dispose.dart'; +import 'emitter.dart'; + +mixin Value { + set value(T value); + + T get value; +} + +class DelegatedValue with Dispose, Value { + DelegatedValue({required this.setter, required this.getter}); + + final ValueSetter setter; + final ValueGetter getter; + + @override + set value(T value) => setter(value); + + @override + T get value => getter(); +} + +extension ValueAnimationControllerExtension on AnimationController { + DelegatedValue delegate() { + return DelegatedValue( + setter: (e) => value = e, + getter: () => value, + ); + } + + // creates a two way link with a ValueEmitter + Disposable link(ValueEmitter 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; + } +} diff --git a/lib/src/core/widget.dart b/lib/src/core/widget.dart new file mode 100644 index 0000000..7cd94de --- /dev/null +++ b/lib/src/core/widget.dart @@ -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); +} diff --git a/lib/src/crash.dart b/lib/src/crash.dart index 445fd5d..6e44fb0 100644 --- a/lib/src/crash.dart +++ b/lib/src/crash.dart @@ -10,8 +10,8 @@ import 'log.dart'; /// Pull persisted native crash records and re-emit them as [Log.f]. Call once /// during app boot, after `Log.configure(...)`, before [runApp]. -class UxCrash { - UxCrash._(); +class XCrash { + XCrash._(); static const _channel = MethodChannel('ux/crash'); diff --git a/lib/src/file.dart b/lib/src/file.dart index f8d2c73..5f6d457 100644 --- a/lib/src/file.dart +++ b/lib/src/file.dart @@ -8,8 +8,8 @@ import 'package:flutter/services.dart'; /// PNG ready to embed in a thumbnail proto / paint via `Image.memory`; /// [width] / [height] describe the encoded image, which may be smaller /// than the source video due to the `maxWidth` constraint at extraction. -class UxVideoThumbnail { - const UxVideoThumbnail({ +class XVideoThumbnail { + const XVideoThumbnail({ required this.pngBytes, required this.width, required this.height, @@ -23,8 +23,8 @@ class UxVideoThumbnail { /// A handle to a file on local disk. Minimal — [path] is the only /// guaranteed field. Returned from anything in `package:ux` that produces /// a file (camera capture today; future writers). -class UxFile { - const UxFile(this.path); +class XFile { + const XFile(this.path); /// Absolute path on local disk. Readable through `dart:io File`. Lifetime /// 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 /// temp-dir copy); access across cold restarts is preserved by storing -/// [bookmark] and re-acquiring scope via [UxFiles.withScopedAccess] / -/// [UxFiles.open] / [UxFiles.showInFolder]. On Android the native side +/// [bookmark] and re-acquiring scope via [XFiles.withScopedAccess] / +/// [XFiles.open] / [XFiles.showInFolder]. On Android the native side /// stream-copies a `content://` source into the app cache (since /// `dart:io` can't open content URIs); [bookmark] holds the source URI /// as UTF-8 bytes for symmetry but isn't required for reads. -class UxPickedFile { - const UxPickedFile({ +class XPickedFile { + const XPickedFile({ required this.path, this.name, this.mimeType, @@ -62,8 +62,8 @@ class UxPickedFile { final Uint8List? bookmark; } -class UxFiles { - UxFiles._(); +class XFiles { + XFiles._(); static const _channel = MethodChannel('ux/file'); @@ -171,7 +171,7 @@ class UxFiles { /// (`image/*`, `video/*`, `application/pdf`) work on all three. For /// Apple-specific types prefer concrete MIME like `image/jpeg` over /// wildcards. - static Future pick({ + static Future pick({ List? mimeTypes, }) async { final result = await _channel.invokeMapMethod('pick', { @@ -180,7 +180,7 @@ class UxFiles { if (result == null) return null; final path = result['path'] as String?; if (path == null) return null; - return UxPickedFile( + return XPickedFile( path: path, name: result['name'] as String?, mimeType: result['mimeType'] as String?, @@ -220,7 +220,7 @@ class UxFiles { /// [atMs] picks the frame timestamp in milliseconds (default 0 = first /// available keyframe). [maxWidth] caps the output's longer edge while /// preserving aspect ratio. - static Future videoThumbnail({ + static Future videoThumbnail({ required String path, int atMs = 0, int maxWidth = 320, @@ -238,6 +238,6 @@ class UxFiles { final width = (result['width'] as num?)?.toInt(); final height = (result['height'] as num?)?.toInt(); 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); } } diff --git a/lib/src/gallery.dart b/lib/src/gallery.dart index e33e4c0..61853c6 100644 --- a/lib/src/gallery.dart +++ b/lib/src/gallery.dart @@ -7,22 +7,22 @@ import 'package:flutter/services.dart'; /// Mirrors the union of `PHAuthorizationStatus` (iOS / macOS) and /// Android's manifest-permission outcomes: /// - [notDetermined] — never asked. Prompt the user with -/// [UxGallery.requestPermission]. -/// - [denied] — user said no. [UxGallery.openSettings] is the only +/// [XGallery.requestPermission]. +/// - [denied] — user said no. [XGallery.openSettings] is the only /// way back. /// - [restricted] — parental controls / MDM. Same UI as [denied]. /// - [limited] — iOS 14+. User picked a subset; the grid still /// populates from that subset. Call -/// [UxGallery.presentLimitedLibraryPicker] to let them adjust. +/// [XGallery.presentLimitedLibraryPicker] to let them adjust. /// - [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 /// (`PHAssetCollection` on Apple, `MediaStore.Bucket` on Android). -class UxAlbum { - const UxAlbum({ +class XAlbum { + const XAlbum({ required this.id, required this.name, required this.count, @@ -32,13 +32,13 @@ class UxAlbum { final String id; final String name; final int count; - final UxAssetKind? coverKind; + final XAssetKind? coverKind; } /// A single asset (photo or video) in the system library /// (`PHAsset` / `ContentResolver` row). -class UxAsset { - const UxAsset({ +class XAsset { + const XAsset({ required this.id, required this.kind, this.duration, @@ -49,7 +49,7 @@ class UxAsset { /// Stable identifier (e.g. iOS `localIdentifier`, Android `content://` URI). final String id; - final UxAssetKind kind; + final XAssetKind kind; /// Duration for videos; null for photos. final Duration? duration; @@ -61,8 +61,8 @@ class UxAsset { /// Cell-sized thumbnail bytes ready for `Image.memory`. Backed by /// `PHCachingImageManager` on Apple and `MediaStore.Thumbnails` on /// Android. -class UxAssetThumbnail { - const UxAssetThumbnail({ +class XAssetThumbnail { + const XAssetThumbnail({ required this.bytes, required this.width, required this.height, @@ -73,23 +73,23 @@ class UxAssetThumbnail { 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 /// channel; tests substitute their own (see -/// `ux/lib/testing.dart`'s `FakeUxGalleryBackend`). -abstract class UxGalleryBackend { - Future permission(); - Future requestPermission(); +/// `ux/lib/testing.dart`'s `FakeXGalleryBackend`). +abstract class XGalleryBackend { + Future permission(); + Future requestPermission(); Future openSettings(); Future presentLimitedLibraryPicker(); - Future> albums({UxAssetKind? filter}); - Future> assets({ + Future> albums({XAssetKind? filter}); + Future> assets({ String? albumId, - UxAssetKind? filter, + XAssetKind? filter, required int start, required int end, }); - Future thumbnail(String assetId, {required int sizePx}); + Future thumbnail(String assetId, {required int sizePx}); Future resolveFile(String assetId); /// 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 /// platform; this class is a thin Dart-side wrapper that dispatches /// into [backend]. -class UxGallery { - UxGallery._(); +class XGallery { + XGallery._(); /// 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. - static UxGalleryBackend backend = MethodChannelGalleryBackend(); + static XGalleryBackend backend = MethodChannelGalleryBackend(); - static Future permission() => backend.permission(); + static Future permission() => backend.permission(); - static Future requestPermission() => + static Future requestPermission() => backend.requestPermission(); static Future openSettings() => backend.openSettings(); @@ -125,14 +125,14 @@ class UxGallery { /// Albums in the user's library, ordered Recents → smart → user. /// Pass [filter] to restrict to image-only / video-only albums. - static Future> albums({UxAssetKind? filter}) => + static Future> albums({XAssetKind? filter}) => backend.albums(filter: filter); /// Paginate assets within [albumId] (or all assets if null), sorted /// by `createdAt DESC`. [start] inclusive, [end] exclusive. - static Future> assets({ + static Future> assets({ String? albumId, - UxAssetKind? filter, + XAssetKind? filter, required int start, required int end, }) => @@ -146,7 +146,7 @@ class UxGallery { /// Cell-sized thumbnail at `~max(width,height) <= sizePx`. Native /// caches keep repeated calls cheap; the caller still maintains a /// small Dart-side LRU keyed by `(assetId, sizePx)`. - static Future thumbnail( + static Future thumbnail( String assetId, { required int sizePx, }) => @@ -164,10 +164,10 @@ class UxGallery { static Stream 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 /// after swapping to a fake. -class MethodChannelGalleryBackend implements UxGalleryBackend { +class MethodChannelGalleryBackend implements XGalleryBackend { static const _channel = MethodChannel('ux/gallery'); static const _changesChannel = EventChannel('ux/gallery/changes'); @@ -178,26 +178,26 @@ class MethodChannelGalleryBackend implements UxGalleryBackend { late final Stream libraryChanges = _changesChannel.receiveBroadcastStream().map((_) {}); - static String _kindArg(UxAssetKind? k) => switch (k) { + static String _kindArg(XAssetKind? k) => switch (k) { null => 'any', - UxAssetKind.image => 'image', - UxAssetKind.video => 'video', + XAssetKind.image => 'image', + XAssetKind.video => 'video', }; - static UxGalleryPermission _parsePermission(String? name) => + static XGalleryPermission _parsePermission(String? name) => switch (name) { - 'notDetermined' => UxGalleryPermission.notDetermined, - 'denied' => UxGalleryPermission.denied, - 'restricted' => UxGalleryPermission.restricted, - 'limited' => UxGalleryPermission.limited, - 'granted' => UxGalleryPermission.granted, - _ => UxGalleryPermission.denied, + 'notDetermined' => XGalleryPermission.notDetermined, + 'denied' => XGalleryPermission.denied, + 'restricted' => XGalleryPermission.restricted, + 'limited' => XGalleryPermission.limited, + 'granted' => XGalleryPermission.granted, + _ => XGalleryPermission.denied, }; - static UxAssetKind _parseKind(Object? v) => - v == 'video' ? UxAssetKind.video : UxAssetKind.image; + static XAssetKind _parseKind(Object? v) => + v == 'video' ? XAssetKind.video : XAssetKind.image; - static UxAsset _parseAsset(Map m) => UxAsset( + static XAsset _parseAsset(Map m) => XAsset( id: m['id']! as String, kind: _parseKind(m['kind']), duration: m['duration_ms'] != null @@ -210,7 +210,7 @@ class MethodChannelGalleryBackend implements UxGalleryBackend { ), ); - static UxAlbum _parseAlbum(Map m) => UxAlbum( + static XAlbum _parseAlbum(Map m) => XAlbum( id: m['id']! as String, name: m['name']! as String, count: (m['count']! as num).toInt(), @@ -218,13 +218,13 @@ class MethodChannelGalleryBackend implements UxGalleryBackend { ); @override - Future permission() async { + Future permission() async { final s = await _channel.invokeMethod('permission'); return _parsePermission(s); } @override - Future requestPermission() async { + Future requestPermission() async { final s = await _channel.invokeMethod('requestPermission'); return _parsePermission(s); } @@ -240,7 +240,7 @@ class MethodChannelGalleryBackend implements UxGalleryBackend { } @override - Future> albums({UxAssetKind? filter}) async { + Future> albums({XAssetKind? filter}) async { final list = await _channel.invokeMethod>( 'albums', {'filter': _kindArg(filter)}, @@ -252,9 +252,9 @@ class MethodChannelGalleryBackend implements UxGalleryBackend { } @override - Future> assets({ + Future> assets({ String? albumId, - UxAssetKind? filter, + XAssetKind? filter, required int start, required int end, }) async { @@ -274,7 +274,7 @@ class MethodChannelGalleryBackend implements UxGalleryBackend { } @override - Future thumbnail( + Future thumbnail( String assetId, { required int sizePx, }) async { @@ -282,7 +282,7 @@ class MethodChannelGalleryBackend implements UxGalleryBackend { 'thumbnail', {'assetId': assetId, 'sizePx': sizePx}, ); - return UxAssetThumbnail( + return XAssetThumbnail( bytes: m!['bytes']! as Uint8List, width: (m['width']! as num).toInt(), height: (m['height']! as num).toInt(), diff --git a/lib/src/keyboard.dart b/lib/src/keyboard.dart index 2f2fd3f..4d55c08 100644 --- a/lib/src/keyboard.dart +++ b/lib/src/keyboard.dart @@ -95,11 +95,11 @@ double _inverseLerp(List samples, double value) { /// Use the singleton [instance] and listen for changes via [addListener]: /// /// ```dart -/// final keyboard = UxKeyboard.instance; +/// final keyboard = XKeyboard.instance; /// keyboard.enableInteractiveDismiss(trackingInset: 56); /// ``` -class UxKeyboard with ChangeNotifier { - UxKeyboard._() { +class XKeyboard with ChangeNotifier { + XKeyboard._() { if (Platform.isAndroid) { _channel.setMethodCallHandler(_onMethodCall); } @@ -108,7 +108,7 @@ class UxKeyboard with ChangeNotifier { } /// The singleton instance. - static final UxKeyboard instance = UxKeyboard._(); + static final XKeyboard instance = XKeyboard._(); static const _channel = MethodChannel('ux/keyboard'); diff --git a/lib/src/navi/hero.dart b/lib/src/navi/hero.dart new file mode 100644 index 0000000..d54f67e --- /dev/null +++ b/lib/src/navi/hero.dart @@ -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 createState() => ScreenHeroState(); +} + +class ScreenHeroState extends State { + 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 _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 snapshot() { + final result = {}; + 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 before, + required Animation 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 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!); + } +} diff --git a/lib/src/navi/router.dart b/lib/src/navi/router.dart new file mode 100644 index 0000000..4b42fb8 --- /dev/null +++ b/lib/src/navi/router.dart @@ -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? routeParser; + late final stack = ListEmitter()..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 _expand(Screen page) sync* { + yield page; + if (page is ScreenShell) { + for (final screen in (page as ScreenShell).pages) { + if (!screen.popped) yield screen; + } + } + } + + List 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( + 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 push(Screen 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; + } + + 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 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 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 with Emitter { + XRouterDelegate({required this.router}); + + final XRouter router; + + @override + Widget build(BuildContext context) => router.build(context); + + @override + Future popRoute() => router.backDispatcher.didPopRoute(); + + @override + Future 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 get pages => const []; +} + +/// Mixin for screens that support deep-linking via a URL. +mixin Deeplink on Screen { + String get restoreUrl; +} + +/// A [RouteInformationParser] that converts URIs into [Screen]s +/// using a [UriParser], and restores URLs from [Deeplink] screens. +class XRouteParser extends RouteInformationParser { + XRouteParser({required this.parser, this.normalize}); + + final UriParser 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 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>? localizationsDelegates; + final Iterable supportedLocales; + final ScrollBehavior? scrollBehavior; + final GlobalKey? 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, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/navi/screen.dart b/lib/src/navi/screen.dart new file mode 100644 index 0000000..36e60a2 --- /dev/null +++ b/lib/src/navi/screen.dart @@ -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 { + // === 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 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 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? _completer; + + /// A future that completes with the result when the page is popped. + Future get future { + _completer ??= Completer(); + 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; + } + } +} diff --git a/lib/src/navi/screen_host.dart b/lib/src/navi/screen_host.dart new file mode 100644 index 0000000..eb853e5 --- /dev/null +++ b/lib/src/navi/screen_host.dart @@ -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 push(Screen page); +} diff --git a/lib/src/navi/screen_stack.dart b/lib/src/navi/screen_stack.dart new file mode 100644 index 0000000..c8aa156 --- /dev/null +++ b/lib/src/navi/screen_stack.dart @@ -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 stack; + final ValueChanged? onRemoved; + + @override + State createState() => ScreenStackState(); + + static ScreenStackState of(BuildContext context) { + return context.findAncestorStateOfType()!; + } + + static ScreenStackState? maybeOf(BuildContext context) { + return context.findAncestorStateOfType(); + } +} + +class ScreenStackState extends State with TickerProviderStateMixin { + late ScreenSlot _homeSlot; + final List _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 oldStack, List newStack) { + final oldSet = {...oldStack}; + final newSet = {...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 = []; + for (final page in newStack) { + ordered.add(_entries.firstWhere((e) => e.page == page && !e.removing)); + } + final result = []; + 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( + onInvoke: (_) { + final focus = FocusManager.instance.primaryFocus; + if (focus != null && focus.context != null && + focus.context!.findAncestorWidgetOfExactType() != 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 createState() => ScreenBackHandlerState(); +} + +class ScreenBackHandlerState extends State + 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, + ), + ), + ], + ); + } +} diff --git a/lib/src/navi/sheet_screen.dart b/lib/src/navi/sheet_screen.dart new file mode 100644 index 0000000..913f505 --- /dev/null +++ b/lib/src/navi/sheet_screen.dart @@ -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, SheetScreen, 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 on Screen { + /// Initial height as a fraction of the viewport (0.0–1.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 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 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(); + + @override + Future push(Screen 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 { + _SheetBody(this.sheet); + final SheetScreen sheet; + + @override + Widget buildPresenter(BuildContext context) => + sheet.buildSheetBody(context); +} diff --git a/lib/src/navi/transitions.dart b/lib/src/navi/transitions.dart new file mode 100644 index 0000000..543bd08 --- /dev/null +++ b/lib/src/navi/transitions.dart @@ -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 animation, + Widget child, + ) { + final position = Tween( + 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 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 animation, + Widget child, + ) { + final position = Tween( + 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 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( + 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 animation, + Widget child, + ) => child; +} diff --git a/lib/src/reactive/future_reactive_builder.dart b/lib/src/reactive/future_reactive_builder.dart new file mode 100644 index 0000000..e5c3ef9 --- /dev/null +++ b/lib/src/reactive/future_reactive_builder.dart @@ -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( +/// future: (ctx) async => CounterBloc()..increment(), +/// builder: (_, state, __) => Text('Loaded: \\${state.count}'), +/// ); +/// ``` +/// {@end-tool} +class FutureReactiveBuilder 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> 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> createState() => _FutureReactiveBuilderState(); +} + +class _FutureReactiveBuilderState extends State> { + @override + void initState() { + super.initState(); + _initBloc(); + } + + Reactive? _bloc; + + Future _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 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, + ); + } +} diff --git a/lib/src/reactive/reactive.dart b/lib/src/reactive/reactive.dart new file mode 100644 index 0000000..2b9f271 --- /dev/null +++ b/lib/src/reactive/reactive.dart @@ -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 { +/// int _count = 0; +/// void increment() => notifyListeners(() => _count++); +/// @override CounterState buildState() => CounterState(_count); +/// } +/// +/// Widget build(BuildContext context) => ReactiveBuilder( +/// create: (_) => CounterBloc(), +/// builder: (_, bloc, __) => Text('Count: \\${bloc.value.count}'), +/// ); +/// ``` +/// {@end-tool} +abstract class Reactive with Emitter, Tasks implements ValueListenable { + /// 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(); + } +} diff --git a/lib/src/reactive/reactive_builder.dart b/lib/src/reactive/reactive_builder.dart new file mode 100644 index 0000000..d913eb0 --- /dev/null +++ b/lib/src/reactive/reactive_builder.dart @@ -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 = Widget Function( + BuildContext context, + T bloc, + Widget? child, +); + +typedef ReactiveBuilderUpdateDelegate = void Function( + BuildContext context, + T bloc, +); + +/// Creation callback invoked once when no external value is provided. +typedef ReactiveCreateDelegate = 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 extends StatefulWidget { + const ReactiveBuilder({ + Key? key, + required ReactiveCreateDelegate 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? _create; + + /// Externally-owned listenable; not disposed by this widget. + final T? _value; + + /// Builds subtree; invoked on each listenable change. + final ReactiveBuilderDelegate 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? onUpdate; + + @override + _ReactiveBuilderState createState() => _ReactiveBuilderState(); +} + +class _ReactiveBuilderState + extends State> { + late T reactive; + + @override + void initState() { + super.initState(); + _initReactive(); + } + + @override + void dispose() { + _disposeReactive(widget); + super.dispose(); + } + + @override + void didUpdateWidget(covariant ReactiveBuilder 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 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, + ); + } +} diff --git a/lib/src/scanner.dart b/lib/src/scanner.dart index e239512..5ca4d9b 100644 --- a/lib/src/scanner.dart +++ b/lib/src/scanner.dart @@ -12,8 +12,8 @@ import 'package:flutter/widgets.dart'; enum BarcodeFormat { qr } /// Static helpers exposed by the platform-side scanner plugin. -class UxScannerPermission { - UxScannerPermission._(); +class XScannerPermission { + XScannerPermission._(); static const _channel = MethodChannel('ux/scanner'); @@ -41,8 +41,8 @@ class UxScannerPermission { /// Camera permission must be granted by the host app before mounting. /// On platforms other than iOS / Android the widget renders an empty /// box. -class UxScanner extends StatefulWidget { - const UxScanner({ +class XScanner extends StatefulWidget { + const XScanner({ super.key, required this.onCode, this.formats = const [BarcodeFormat.qr], @@ -52,10 +52,10 @@ class UxScanner extends StatefulWidget { final List formats; @override - State createState() => _UxScannerState(); + State createState() => _XScannerState(); } -class _UxScannerState extends State { +class _XScannerState extends State { static const _events = EventChannel('ux/scanner/events'); StreamSubscription? _sub; diff --git a/lib/src/sensor.dart b/lib/src/sensor.dart index bff7d59..110a676 100644 --- a/lib/src/sensor.dart +++ b/lib/src/sensor.dart @@ -7,8 +7,8 @@ import 'package:ux/src/_ffi.dart'; final _uxDeviceOrientation = uxLookupInt32('ux_device_orientation'); -class UxSensor { - UxSensor._(); +class XSensor { + XSensor._(); /// Accelerometer-driven physical device rotation; updates regardless of /// OS auto-rotate or app UI orientation lock. @@ -32,7 +32,7 @@ class UxSensor { class _OrientationNotifier extends ChangeNotifier implements ValueListenable { - _OrientationNotifier() : _value = UxSensor.orientation; + _OrientationNotifier() : _value = XSensor.orientation; Timer? _timer; DeviceOrientation _value; @@ -56,7 +56,7 @@ class _OrientationNotifier extends ChangeNotifier } void _tick(Timer _) { - final next = UxSensor.orientation; + final next = XSensor.orientation; if (next != _value) { _value = next; notifyListeners(); diff --git a/lib/src/testing/fake_gallery.dart b/lib/src/testing/fake_gallery.dart index 9eb0f41..f2db073 100644 --- a/lib/src/testing/fake_gallery.dart +++ b/lib/src/testing/fake_gallery.dart @@ -6,31 +6,31 @@ import 'dart:typed_data'; import 'package:ux/src/gallery.dart'; /// 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. final Uint8List _placeholderPng = base64Decode( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', ); -/// In-memory backend for [UxGallery] tests. Swap in via -/// `UxGallery.backend = FakeUxGalleryBackend(...)` before any UI -/// mounts; restore with `UxGallery.backend = ...` (or by replacing +/// In-memory backend for [XGallery] tests. Swap in via +/// `XGallery.backend = FakeXGalleryBackend(...)` before any UI +/// mounts; restore with `XGallery.backend = ...` (or by replacing /// with another fake) in `tearDown`. -class FakeUxGalleryBackend implements UxGalleryBackend { - FakeUxGalleryBackend({ - this.permissionState = UxGalleryPermission.granted, - List albums = const [], - Map> assetsByAlbum = const {}, - List recents = const [], +class FakeXGalleryBackend implements XGalleryBackend { + FakeXGalleryBackend({ + this.permissionState = XGalleryPermission.granted, + List albums = const [], + Map> assetsByAlbum = const {}, + List recents = const [], this.onRequestPermission, this.onOpenSettings, this.onPresentLimitedLibraryPicker, - UxAssetThumbnail Function(String assetId, int sizePx)? thumbnailFor, + XAssetThumbnail Function(String assetId, int sizePx)? thumbnailFor, io.File Function(String assetId)? fileFor, }) : _albums = List.unmodifiable(albums), _assetsByAlbum = Map.unmodifiable( assetsByAlbum.map( - (k, v) => MapEntry(k, List.unmodifiable(v)), + (k, v) => MapEntry(k, List.unmodifiable(v)), ), ), _recents = List.unmodifiable(recents), @@ -38,12 +38,12 @@ class FakeUxGalleryBackend implements UxGalleryBackend { _fileFor = fileFor ?? _defaultFile; /// Mutable so tests can simulate user grant after `requestPermission`. - UxGalleryPermission permissionState; + XGalleryPermission permissionState; - final List _albums; - final Map> _assetsByAlbum; - final List _recents; - final UxAssetThumbnail Function(String assetId, int sizePx) _thumbnailFor; + final List _albums; + final Map> _assetsByAlbum; + final List _recents; + final XAssetThumbnail Function(String assetId, int sizePx) _thumbnailFor; final io.File Function(String assetId) _fileFor; final StreamController _libraryChanges = @@ -58,13 +58,13 @@ class FakeUxGalleryBackend implements UxGalleryBackend { Stream get libraryChanges => _libraryChanges.stream; /// Optional hook fired on `requestPermission`. Default updates - /// `permissionState` to [UxGalleryPermission.granted]. - final UxGalleryPermission Function()? onRequestPermission; + /// `permissionState` to [XGalleryPermission.granted]. + final XGalleryPermission Function()? onRequestPermission; final void Function()? onOpenSettings; final void Function()? onPresentLimitedLibraryPicker; - static UxAssetThumbnail _defaultThumbnail(String _, int sizePx) => - UxAssetThumbnail( + static XAssetThumbnail _defaultThumbnail(String _, int sizePx) => + XAssetThumbnail( bytes: _placeholderPng, width: sizePx, height: sizePx, @@ -74,12 +74,12 @@ class FakeUxGalleryBackend implements UxGalleryBackend { io.File('/dev/null/$assetId'); @override - Future permission() async => permissionState; + Future permission() async => permissionState; @override - Future requestPermission() async { + Future requestPermission() async { permissionState = - onRequestPermission?.call() ?? UxGalleryPermission.granted; + onRequestPermission?.call() ?? XGalleryPermission.granted; return permissionState; } @@ -94,7 +94,7 @@ class FakeUxGalleryBackend implements UxGalleryBackend { } @override - Future> albums({UxAssetKind? filter}) async { + Future> albums({XAssetKind? filter}) async { if (filter == null) return _albums; return [ for (final a in _albums) @@ -103,15 +103,15 @@ class FakeUxGalleryBackend implements UxGalleryBackend { } @override - Future> assets({ + Future> assets({ String? albumId, - UxAssetKind? filter, + XAssetKind? filter, required int start, required int end, }) async { final source = albumId == null ? _recents - : _assetsByAlbum[albumId] ?? const []; + : _assetsByAlbum[albumId] ?? const []; final filtered = filter == null ? source : [for (final a in source) if (a.kind == filter) a]; @@ -120,7 +120,7 @@ class FakeUxGalleryBackend implements UxGalleryBackend { } @override - Future thumbnail( + Future thumbnail( String assetId, { required int sizePx, }) async => diff --git a/lib/src/url.dart b/lib/src/url.dart index 1d0ca30..b4518e0 100644 --- a/lib/src/url.dart +++ b/lib/src/url.dart @@ -9,8 +9,8 @@ import 'package:flutter/services.dart'; import 'package:ux/src/_ffi.dart'; /// Native URL / phone / email detection plus an OS-handler launcher. -class UxUrl { - UxUrl._(); +class XUrl { + XUrl._(); static const _channel = MethodChannel('ux/url'); diff --git a/lib/ux.dart b/lib/ux.dart index 712ba9e..8de1e4f 100644 --- a/lib/ux.dart +++ b/lib/ux.dart @@ -1,7 +1,9 @@ /// Flutter toolkit for fluid, native-feeling UIs. /// -/// Includes [UxKeyboard] for frame-accurate keyboard height tracking, -/// [BendBox] for curved layout painting, and bezier curve utilities. +/// Includes [XKeyboard] for frame-accurate keyboard height tracking, +/// [BendBox] for curved layout painting, bezier curve utilities, +/// reactive primitives ([Emitter], [ValueEmitter]), a custom navigation +/// system ([XApp], [XRouter], [Screen]), and modal sheets ([Sheet]). library; export 'src/app_info.dart'; @@ -9,8 +11,8 @@ export 'src/bend_box.dart'; export 'src/json_extension.dart'; export 'src/bezier.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_channel.dart' show MethodChannelUxCameraBackend; +export 'src/camera/camera_backend.dart' show XCameraBackend, XCameraCreateResult, XCameraEvent, XCameraDeviceOrientationChanged, XCameraSessionError, XCameraSessionInterrupted, XCameraSessionResumed, XCameraDiagnostic, XCameraPreviewSizeChanged; +export 'src/camera/camera_channel.dart' show MethodChannelXCameraBackend; export 'src/camera/camera_preview.dart'; export 'src/clipboard.dart'; export 'src/file.dart'; @@ -23,4 +25,29 @@ export 'src/sensor.dart'; export 'src/functional.dart'; export 'src/crash.dart'; export 'src/log.dart'; -export 'src/log_http.dart'; \ No newline at end of file +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'; diff --git a/macos/Classes/Camera/AVCaptureConnection+macOS.swift b/macos/Classes/Camera/AVCaptureConnection+macOS.swift index 28bbc2d..b6a5a87 100644 --- a/macos/Classes/Camera/AVCaptureConnection+macOS.swift +++ b/macos/Classes/Camera/AVCaptureConnection+macOS.swift @@ -15,7 +15,7 @@ import AVFoundation /// videoOrientation = .landscapeRight"). Prefer the new API where /// available, fall back to the deprecated one for older macOS. extension AVCaptureConnection { - func applyUxCaptureOrientation(_ orientation: DeviceOrientationFlutter) { + func applyXCaptureOrientation(_ orientation: DeviceOrientationFlutter) { // Pin to 0° rotation (`.landscapeRight`) on macOS — desktop // cameras are physically landscape and any non-zero rotation // physically rotates the buffer. Diagnostic build confirmed diff --git a/macos/Classes/FilePlugin.swift b/macos/Classes/FilePlugin.swift index 91b1dbe..f3fee40 100644 --- a/macos/Classes/FilePlugin.swift +++ b/macos/Classes/FilePlugin.swift @@ -106,7 +106,7 @@ public class FilePlugin: NSObject, NativePlugin { panel.allowedFileTypes = utis } - let host = UxWindow.flutterView?.window + let host = XWindow.flutterView?.window let completion: (NSApplication.ModalResponse) -> Void = { response in guard response == .OK, let url = panel.url else { result(nil) @@ -173,7 +173,7 @@ public class FilePlugin: NSObject, NativePlugin { let path = args["path"] as? String else { 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)) } @@ -314,10 +314,10 @@ public class FilePlugin: NSObject, NativePlugin { withScope(path: path, bookmark: bookmarkData) { url in // 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. - if let flutterView = UxWindow.flutterView, + if let flutterView = XWindow.flutterView, let window = flutterView.window, let panel = QLPreviewPanel.shared() { - let responder = UxQLPreviewResponder(url: url, window: window) + let responder = XQLPreviewResponder(url: url, window: window) flutterView.addSubview(responder) window.makeFirstResponder(responder) panel.updateController() @@ -344,7 +344,7 @@ fileprivate func mimeFromExtension(_ ext: String) -> String? { return mime as String } -private final class UxQLPreviewResponder: NSView, QLPreviewPanelDataSource { +private final class XQLPreviewResponder: NSView, QLPreviewPanelDataSource { let url: URL private weak var previousFirstResponder: NSResponder? private weak var previousWindow: NSWindow? diff --git a/macos/Classes/NativePlugin.swift b/macos/Classes/NativePlugin.swift index f638e02..f979127 100644 --- a/macos/Classes/NativePlugin.swift +++ b/macos/Classes/NativePlugin.swift @@ -5,7 +5,7 @@ public protocol NativePlugin { func register(with registrar: FlutterPluginRegistrar) } -public enum UxWindow { +public enum XWindow { public static var keyWindow: NSWindow? { NSApp.keyWindow ?? NSApp.mainWindow } diff --git a/macos/Classes/UxPlugin.swift b/macos/Classes/XPlugin.swift similarity index 91% rename from macos/Classes/UxPlugin.swift rename to macos/Classes/XPlugin.swift index b122972..085f187 100644 --- a/macos/Classes/UxPlugin.swift +++ b/macos/Classes/XPlugin.swift @@ -1,7 +1,7 @@ import FlutterMacOS import AppKit -public class UxPlugin: NSObject, FlutterPlugin { +public class XPlugin: NSObject, FlutterPlugin { private static var plugins: [NativePlugin] = [] public static func register(with registrar: FlutterPluginRegistrar) { diff --git a/macos/Classes/url_detect.m b/macos/Classes/url_detect.m index c7c798a..dc87acd 100644 --- a/macos/Classes/url_detect.m +++ b/macos/Classes/url_detect.m @@ -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: // 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 kKindPhone = 2; -@interface UxUrlRawMatch : NSObject +@interface XUrlRawMatch : NSObject @property (nonatomic) int32_t start; @property (nonatomic) int32_t end; @property (nonatomic) uint32_t kind; @property (nonatomic, copy) NSData *urlUtf8; @end -@implementation UxUrlRawMatch +@implementation XUrlRawMatch @end 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; NSRange whole = NSMakeRange(0, text.length); - NSMutableArray *raws = [NSMutableArray array]; + NSMutableArray *raws = [NSMutableArray array]; NSDataDetector *detector = ux_url_data_detector(); 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; - UxUrlRawMatch *m = [[UxUrlRawMatch alloc] init]; + XUrlRawMatch *m = [[XUrlRawMatch alloc] init]; m.start = (int32_t)r.location; m.end = (int32_t)(r.location + r.length); 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; NSString *substr = [text substringWithRange:r]; NSString *withScheme = [@"http://" stringByAppendingString:substr]; - UxUrlRawMatch *m = [[UxUrlRawMatch alloc] init]; + XUrlRawMatch *m = [[XUrlRawMatch alloc] init]; m.start = (int32_t)r.location; m.end = (int32_t)(r.location + r.length); 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; // 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; int32_t la = a.end - a.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. - NSMutableArray *kept = [NSMutableArray arrayWithCapacity:raws.count]; + NSMutableArray *kept = [NSMutableArray arrayWithCapacity:raws.count]; int32_t lastEnd = 0; BOOL haveAny = NO; - for (UxUrlRawMatch *m in raws) { + for (XUrlRawMatch *m in raws) { if (haveAny && m.start < lastEnd) continue; [kept addObject:m]; 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; - for (UxUrlRawMatch *m in kept) { + for (XUrlRawMatch *m in kept) { 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; memcpy(buf, &cnt, 4); NSUInteger off = 4; - for (UxUrlRawMatch *m in kept) { + for (XUrlRawMatch *m in kept) { int32_t start = m.start; int32_t end = m.end; uint32_t kind = m.kind; diff --git a/pubspec.yaml b/pubspec.yaml index c619cf7..43a69dc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: >- Flutter toolkit for fluid, native-feeling UIs. Includes frame-accurate keyboard height tracking via FFI with interactive dismiss, bezier utilities, and layout primitives. -version: 0.9.0 +version: 0.10.0 homepage: https://swipelab.co/ux.html repository: https://github.com/swipelab/ux issue_tracker: https://github.com/swipelab/ux/issues @@ -33,9 +33,9 @@ flutter: plugin: platforms: ios: - pluginClass: UxPlugin + pluginClass: XPlugin android: package: io.swipelab.ux - pluginClass: UxPlugin + pluginClass: XPlugin macos: - pluginClass: UxPlugin + pluginClass: XPlugin diff --git a/test/camera/camera_channel_test.dart b/test/camera/camera_channel_test.dart index 4f0b51b..195a62f 100644 --- a/test/camera/camera_channel_test.dart +++ b/test/camera/camera_channel_test.dart @@ -5,14 +5,14 @@ import 'package:ux/ux.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - group('MethodChannelUxCameraBackend — arg/return parsing', () { + group('MethodChannelXCameraBackend — arg/return parsing', () { const channel = MethodChannel('ux/camera'); - late MethodChannelUxCameraBackend backend; + late MethodChannelXCameraBackend backend; late List calls; setUp(() { - backend = MethodChannelUxCameraBackend(); + backend = MethodChannelXCameraBackend(); calls = []; }); @@ -39,10 +39,10 @@ void main() { expect(calls.single.method, 'availableCameras'); expect(result, [ - const UxCameraDescription( - id: 'a', lens: UxCameraLens.front, sensorOrientation: 270), - const UxCameraDescription( - id: 'b', lens: UxCameraLens.back, sensorOrientation: 90), + const XCameraDescription( + id: 'a', lens: XCameraLens.front, sensorOrientation: 270), + const XCameraDescription( + id: 'b', lens: XCameraLens.back, sensorOrientation: 90), ]); }); @@ -57,7 +57,7 @@ void main() { final result = await backend.create( cameraId: 'cam-1', enableAudio: true, - preset: UxResolutionPreset.high, + preset: XResolutionPreset.high, ); expect(calls.single.method, 'create'); @@ -98,8 +98,8 @@ void main() { test('setFlashMode encodes the enum', () async { handle((_) => null); - await backend.setFlashMode(3, UxFlashMode.always); - await backend.setFlashMode(3, UxFlashMode.off); + await backend.setFlashMode(3, XFlashMode.always); + await backend.setFlashMode(3, XFlashMode.off); expect(calls.map((c) => (c.arguments as Map)['mode']).toList(), ['always', 'off']); @@ -180,11 +180,11 @@ void main() { ); addTearDown(() => messenger.setMockStreamHandler(eventsChannel, null)); - final received = []; + final received = []; await backend.events(4).forEach(received.add); expect(received, hasLength(1)); - final e = received.single as UxCameraDiagnostic; + final e = received.single as XCameraDiagnostic; expect(e.handle, 4); 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 { handle((_) => throw PlatformException( code: 'device_busy', @@ -217,7 +217,7 @@ void main() { await expectLater( backend.initialize(1), - throwsA(isA() + throwsA(isA() .having((e) => e.code, 'code', 'device_busy') .having((e) => e.description, 'description', 'front camera in use')), ); diff --git a/test/crash_test.dart b/test/crash_test.dart index 22390b3..9910e40 100644 --- a/test/crash_test.dart +++ b/test/crash_test.dart @@ -50,7 +50,7 @@ void main() { return null; }); - await UxCrash.drainAndReport(); + await XCrash.drainAndReport(); final records = sink.snapshot(); expect(records.length, 2); @@ -78,7 +78,7 @@ void main() { throw MissingPluginException(); }); - await UxCrash.drainAndReport(); + await XCrash.drainAndReport(); expect(sink.snapshot(), isEmpty); }); @@ -91,7 +91,7 @@ void main() { throw PlatformException(code: 'oops'); }); - await UxCrash.drainAndReport(); + await XCrash.drainAndReport(); expect(sink.snapshot().length, 1); expect(sink.snapshot().single.level, LogLevel.warn); diff --git a/test/gallery_test.dart b/test/gallery_test.dart index 4f9ce74..c420a25 100644 --- a/test/gallery_test.dart +++ b/test/gallery_test.dart @@ -6,11 +6,11 @@ import 'package:ux/ux.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - group('UxGallery facade — method channel parsing', () { + group('XGallery facade — method channel parsing', () { const channel = MethodChannel('ux/gallery'); setUp(() { - UxGallery.backend = MethodChannelGalleryBackend(); + XGallery.backend = MethodChannelGalleryBackend(); }); tearDown(() { @@ -25,17 +25,17 @@ void main() { return 'granted'; }); - expect(await UxGallery.permission(), UxGalleryPermission.granted); + expect(await XGallery.permission(), XGalleryPermission.granted); }); test('permission() falls back to denied on unknown values', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .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 .setMockMethodCallHandler(channel, (call) async { 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[0].id, 'recents'); expect(albums[0].count, 1234); - expect(albums[0].coverKind, UxAssetKind.image); - expect(albums[1].coverKind, UxAssetKind.video); + expect(albums[0].coverKind, XAssetKind.image); + expect(albums[1].coverKind, XAssetKind.video); }); 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', - filter: UxAssetKind.video, + filter: XAssetKind.video, start: 0, end: 60, ); 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.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.width, 381); expect(thumb.height, 254); }); }); - group('FakeUxGalleryBackend', () { + group('FakeXGalleryBackend', () { test('requestPermission flips state to granted by default', () async { - final fake = FakeUxGalleryBackend( - permissionState: UxGalleryPermission.notDetermined, + final fake = FakeXGalleryBackend( + permissionState: XGalleryPermission.notDetermined, ); - UxGallery.backend = fake; + XGallery.backend = fake; - expect(await UxGallery.permission(), - UxGalleryPermission.notDetermined); - expect(await UxGallery.requestPermission(), - UxGalleryPermission.granted); - expect(await UxGallery.permission(), UxGalleryPermission.granted); + expect(await XGallery.permission(), + XGalleryPermission.notDetermined); + expect(await XGallery.requestPermission(), + XGalleryPermission.granted); + expect(await XGallery.permission(), XGalleryPermission.granted); }); test('assets honours the (start, end) page window per album', () async { final pile = [ for (var i = 0; i < 50; i++) - UxAsset( + XAsset( id: 'a$i', - kind: UxAssetKind.image, + kind: XAssetKind.image, width: 100, height: 100, createdAt: DateTime.fromMillisecondsSinceEpoch(i * 1000), ), ]; - final fake = FakeUxGalleryBackend( + final fake = FakeXGalleryBackend( recents: 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), [ 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)); - final past = await UxGallery.assets(start: 200, end: 210); + final past = await XGallery.assets(start: 200, end: 210); expect(past, isEmpty); }); test('assets filters by kind when requested', () async { final mix = [ - UxAsset( + XAsset( id: 'p', - kind: UxAssetKind.image, + kind: XAssetKind.image, width: 1, height: 1, createdAt: DateTime(2024), ), - UxAsset( + XAsset( id: 'v', - kind: UxAssetKind.video, + kind: XAssetKind.video, duration: const Duration(seconds: 3), width: 1, height: 1, createdAt: DateTime(2024), ), ]; - UxGallery.backend = FakeUxGalleryBackend(recents: mix); + XGallery.backend = FakeXGalleryBackend(recents: mix); - final justVideos = await UxGallery.assets( - filter: UxAssetKind.video, + final justVideos = await XGallery.assets( + filter: XAssetKind.video, start: 0, end: 10, ); diff --git a/test/ux_test.dart b/test/ux_test.dart index 93e871f..392bed6 100644 --- a/test/ux_test.dart +++ b/test/ux_test.dart @@ -2,15 +2,15 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:ux/ux.dart'; void main() { - test('UxKeyboard.instance is a singleton', () { - expect(UxKeyboard.instance, same(UxKeyboard.instance)); + test('XKeyboard.instance is a singleton', () { + expect(XKeyboard.instance, same(XKeyboard.instance)); }); - test('UxKeyboard.height starts at 0', () { - expect(UxKeyboard.instance.height, 0); + test('XKeyboard.height starts at 0', () { + expect(XKeyboard.instance.height, 0); }); - test('UxKeyboard.isOpen is false when height is 0', () { - expect(UxKeyboard.instance.isOpen, false); + test('XKeyboard.isOpen is false when height is 0', () { + expect(XKeyboard.instance.isOpen, false); }); }