ux: bulk WIP — UxPlugin→XPlugin rename + new anim/core/navi/reactive packages
Catch-all commit for outstanding pre-existing local changes. Mixes several themes that would normally be split: - Rename: UxPlugin → XPlugin across iOS, macOS, Android registrants. - New top-level packages under lib/src/: anim/ (animated values, panes, sheets, dock, measured), core/ (Emitter, ReactiveBuilder scaffolding, presenter/widget/value/dispose primitives), navi/ (Screen/ScreenStack/Router/hero/transitions), reactive/. - Edits across existing plugins (clipboard, crash, file, gallery, keyboard, scanner, sensor, url) to align with the new core. - Test updates and CHANGELOG/README touches accompanying the above.
This commit is contained in:
28
CHANGELOG.md
28
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
|
||||
|
||||
10
README.md
10
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
|
||||
|
||||
@@ -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 <string>
|
||||
#include <vector>
|
||||
|
||||
#define UX_LOG_TAG "UxUrl"
|
||||
#define UX_LOG_TAG "XUrl"
|
||||
#define UX_LOGE(...) __android_log_print(ANDROID_LOG_ERROR, UX_LOG_TAG, __VA_ARGS__)
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<NativePlugin> = listOf(
|
||||
KeyboardPlugin(),
|
||||
SensorPlugin(),
|
||||
@@ -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.
|
||||
|
||||
@@ -32,7 +32,7 @@ class VideoCapture {
|
||||
val useCase: androidx.camera.video.VideoCapture<Recorder> =
|
||||
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()
|
||||
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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<ChatScreen> {
|
||||
final _keyboard = UxKeyboard.instance;
|
||||
final _keyboard = XKeyboard.instance;
|
||||
final _textController = TextEditingController();
|
||||
final _messages = List.generate(
|
||||
30,
|
||||
@@ -54,8 +54,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
// 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, _) {
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -510,7 +510,7 @@ packages:
|
||||
path: ".."
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.9.0"
|
||||
version: "0.10.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
@@ -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<UxUrlRawMatch *> *raws = [NSMutableArray array];
|
||||
NSMutableArray<XUrlRawMatch *> *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<UxUrlRawMatch *> *kept = [NSMutableArray arrayWithCapacity:raws.count];
|
||||
NSMutableArray<XUrlRawMatch *> *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;
|
||||
|
||||
61
lib/src/anim/animated_color.dart
Normal file
61
lib/src/anim/animated_color.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class AnimatedColor extends StatefulWidget {
|
||||
const AnimatedColor({
|
||||
required this.color,
|
||||
required this.builder,
|
||||
this.child,
|
||||
this.curve = Curves.linear,
|
||||
this.duration = const Duration(milliseconds: 200),
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Curve curve;
|
||||
final Duration duration;
|
||||
final Color? color;
|
||||
final ValueWidgetBuilder<Color?> builder;
|
||||
final Widget? child;
|
||||
|
||||
@override
|
||||
State<AnimatedColor> createState() => _AnimatedColorState();
|
||||
}
|
||||
|
||||
class _AnimatedColorState extends State<AnimatedColor>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _animationController;
|
||||
late Animation<Color?> _colorAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_animationController =
|
||||
AnimationController(duration: widget.duration, vsync: this);
|
||||
_colorAnimation =
|
||||
ColorTween(begin: widget.color, end: widget.color).animate(
|
||||
CurvedAnimation(parent: _animationController, curve: widget.curve),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AnimatedColor oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (oldWidget.color != widget.color) {
|
||||
_colorAnimation =
|
||||
ColorTween(begin: oldWidget.color, end: widget.color).animate(
|
||||
CurvedAnimation(parent: _animationController, curve: widget.curve),
|
||||
);
|
||||
_animationController.forward(from: 0);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _colorAnimation,
|
||||
builder: (context, child) =>
|
||||
widget.builder(context, _colorAnimation.value, child),
|
||||
);
|
||||
}
|
||||
}
|
||||
61
lib/src/anim/animated_double.dart
Normal file
61
lib/src/anim/animated_double.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class AnimatedDouble extends StatefulWidget {
|
||||
const AnimatedDouble({
|
||||
super.key,
|
||||
required this.builder,
|
||||
required this.start,
|
||||
required this.end,
|
||||
this.curve = Curves.linear,
|
||||
this.duration = const Duration(milliseconds: 200),
|
||||
this.child,
|
||||
});
|
||||
|
||||
final ValueWidgetBuilder<double> builder;
|
||||
final Widget? child;
|
||||
final double start;
|
||||
final double end;
|
||||
final Duration duration;
|
||||
final Curve curve;
|
||||
|
||||
@override
|
||||
State<AnimatedDouble> createState() => _AnimatedDoubleState();
|
||||
}
|
||||
|
||||
class _AnimatedDoubleState extends State<AnimatedDouble>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = AnimationController.unbounded(
|
||||
vsync: this, value: widget.start, duration: widget.duration);
|
||||
if (widget.end != widget.start) {
|
||||
controller.animateTo(widget.end);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AnimatedDouble oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.end != oldWidget.end) {
|
||||
controller.animateTo(widget.end);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: controller,
|
||||
builder: widget.builder,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
118
lib/src/anim/dock.dart
Normal file
118
lib/src/anim/dock.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
abstract class Dock {
|
||||
static Widget top({
|
||||
required Widget child,
|
||||
double? left = 0,
|
||||
double? right = 0,
|
||||
double? top = 0,
|
||||
double? bottom,
|
||||
double? height,
|
||||
double? width,
|
||||
}) {
|
||||
return Positioned(
|
||||
top: top,
|
||||
left: left,
|
||||
right: right,
|
||||
width: width,
|
||||
height: height,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
static Widget bottom({
|
||||
required Widget child,
|
||||
double? left = 0,
|
||||
double? right = 0,
|
||||
double? top,
|
||||
double? bottom = 0,
|
||||
double? width,
|
||||
double? height,
|
||||
}) {
|
||||
return Positioned(
|
||||
top: top,
|
||||
left: left,
|
||||
right: right,
|
||||
bottom: bottom,
|
||||
width: width,
|
||||
height: height,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
static Widget bottomRight({
|
||||
required Widget child,
|
||||
double? left,
|
||||
double? right = 0,
|
||||
double? top,
|
||||
double? bottom = 0,
|
||||
double? width,
|
||||
double? height,
|
||||
}) {
|
||||
return Positioned(
|
||||
top: top,
|
||||
left: left,
|
||||
right: right,
|
||||
bottom: bottom,
|
||||
width: width,
|
||||
height: height,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
static Widget topRight({
|
||||
required Widget child,
|
||||
double? left,
|
||||
double? right = 0,
|
||||
double? top = 0,
|
||||
double? bottom,
|
||||
double? width,
|
||||
double? height,
|
||||
}) {
|
||||
return Positioned(
|
||||
top: top,
|
||||
left: left,
|
||||
right: right,
|
||||
bottom: bottom,
|
||||
width: width,
|
||||
height: height,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
static Widget topLeft({
|
||||
required Widget child,
|
||||
double? left = 0,
|
||||
double? right,
|
||||
double? top = 0,
|
||||
double? bottom,
|
||||
double? width,
|
||||
double? height,
|
||||
}) {
|
||||
return Positioned(
|
||||
top: top,
|
||||
left: left,
|
||||
right: right,
|
||||
bottom: bottom,
|
||||
width: width,
|
||||
height: height,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
static Widget fill({
|
||||
required Widget child,
|
||||
double? left,
|
||||
double? right,
|
||||
double? top,
|
||||
double? bottom,
|
||||
}) {
|
||||
return Positioned.fill(
|
||||
left: left,
|
||||
right: right,
|
||||
bottom: bottom,
|
||||
top: top,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
42
lib/src/anim/measured.dart
Normal file
42
lib/src/anim/measured.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class Measured extends StatefulWidget {
|
||||
const Measured({
|
||||
required this.child,
|
||||
this.onSize,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final VoidCallback? onSize;
|
||||
|
||||
@override
|
||||
State<Measured> createState() => _MeasuredState();
|
||||
}
|
||||
|
||||
class _MeasuredState extends State<Measured> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
report();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return NotificationListener<SizeChangedLayoutNotification>(
|
||||
onNotification: handleSizeChanged,
|
||||
child: SizeChangedLayoutNotifier(
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void report() {
|
||||
widget.onSize?.call();
|
||||
}
|
||||
|
||||
bool handleSizeChanged(SizeChangedLayoutNotification notification) {
|
||||
report();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
384
lib/src/anim/pane.dart
Normal file
384
lib/src/anim/pane.dart
Normal file
@@ -0,0 +1,384 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:ux/src/core/core.dart';
|
||||
|
||||
/// WIP
|
||||
abstract class Pane with Emitter {
|
||||
Pane();
|
||||
|
||||
final Map<Object?, Widget> widgets = {};
|
||||
final Map<Object?, Element> elements = {};
|
||||
final Map<Object?, RenderBox> renderBoxes = {};
|
||||
|
||||
// ready to manage children
|
||||
PaneElement? element;
|
||||
PaneRender? renderer;
|
||||
|
||||
bool get needsCompositing => renderer?.needsCompositing ?? false;
|
||||
|
||||
Size get size => renderer!.size;
|
||||
|
||||
BoxConstraints get constraints => renderer!.constraints;
|
||||
|
||||
void markNeedsLayout() => renderer?.markNeedsLayout();
|
||||
|
||||
void markNeedsPaint() => renderer?.markNeedsPaint();
|
||||
|
||||
void attachPipeline(PipelineOwner owner) {
|
||||
for (final child in renderBoxes.values) {
|
||||
if (child.owner == null) {
|
||||
child.attach(owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void detachPipeline() {
|
||||
for (final child in renderBoxes.values) {
|
||||
child.detach();
|
||||
}
|
||||
}
|
||||
|
||||
void visitChildrenRender(RenderObjectVisitor visitor) =>
|
||||
renderBoxes.values.forEach(visitor);
|
||||
|
||||
void setupParentData(covariant RenderObject child, covariant Object? slot);
|
||||
|
||||
List<DiagnosticsNode> debugDescribeChildren() => const [];
|
||||
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {}
|
||||
|
||||
void applyPaintTransform(covariant RenderObject child, Matrix4 transform) {}
|
||||
|
||||
bool hitTestChildren(BoxHitTestResult result, Offset position) => false;
|
||||
|
||||
bool hitTestSelf(Offset position) => false;
|
||||
|
||||
/// [constraints] are available
|
||||
/// [size] must be set
|
||||
void performLayout();
|
||||
|
||||
void paint(PaintingContext context, Offset offset);
|
||||
|
||||
void insertRenderObjectChild(
|
||||
PaneElement paneElement, covariant RenderBox child, Object? slot) {
|
||||
setupParentData(child, slot);
|
||||
renderBoxes[slot] = child;
|
||||
renderer?.adoptChild(child);
|
||||
}
|
||||
|
||||
void moveRenderObjectChild(PaneElement paneElement, RenderObject child,
|
||||
Object? oldSlot, Object? newSlot) {}
|
||||
|
||||
void removeRenderObjectChild(
|
||||
PaneElement paneElement, RenderObject child, Object? slot) {
|
||||
renderBoxes.remove(slot);
|
||||
renderer?.dropChild(child);
|
||||
}
|
||||
|
||||
void visitChildrenElement(ElementVisitor visitor) =>
|
||||
elements.values.forEach(visitor);
|
||||
|
||||
Widget itemBuilder(BuildContext context, Object? slot);
|
||||
|
||||
RenderBox? invokeLayoutUpsert(Object? slot) {
|
||||
if (element == null) return null;
|
||||
RenderBox? child = renderBoxes[slot];
|
||||
if (child != null) return child;
|
||||
renderer?.invokeLayoutCallback((constraints) {
|
||||
child = buildChild(slot);
|
||||
});
|
||||
return child;
|
||||
}
|
||||
|
||||
void invokeLayoutRemove(Object? slot) {
|
||||
if (element == null) return;
|
||||
renderer?.invokeLayoutCallback((constraints) {
|
||||
removeSlotElement(slot);
|
||||
});
|
||||
}
|
||||
|
||||
void removeSlotElement(Object? slot) {
|
||||
final element = this.element;
|
||||
if (element == null) return;
|
||||
elements.remove(slot)?.pipe(element.deactivateChild);
|
||||
widgets.remove(slot);
|
||||
}
|
||||
|
||||
void mountElement(Element? parent, Object? newSlot, PaneElement element) {
|
||||
this.element = element;
|
||||
}
|
||||
|
||||
void unmountElement() {
|
||||
for (final child in renderBoxes.values) {
|
||||
renderer?.dropChild(child);
|
||||
}
|
||||
renderBoxes.clear();
|
||||
elements.clear();
|
||||
widgets.clear();
|
||||
|
||||
element = null;
|
||||
}
|
||||
|
||||
RenderBox? buildChild(Object? slot) {
|
||||
final element = this.element;
|
||||
if (element == null) return null;
|
||||
|
||||
element.owner!.buildScope(element, () {
|
||||
Widget newWidget;
|
||||
try {
|
||||
newWidget = itemBuilder(element, slot);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
final oldElement = elements[slot];
|
||||
final oldWidget = widgets[slot];
|
||||
if (oldElement == null) {
|
||||
final newElement = element.inflateWidget(newWidget, slot);
|
||||
elements[slot] = newElement;
|
||||
widgets[slot] = newWidget;
|
||||
} else if (oldWidget != null && Widget.canUpdate(oldWidget, newWidget)) {
|
||||
oldElement.update(newWidget);
|
||||
widgets[slot] = newWidget;
|
||||
} else {
|
||||
element.deactivateChild(oldElement);
|
||||
final newElement = element.inflateWidget(newWidget, slot);
|
||||
elements[slot] = newElement;
|
||||
widgets[slot] = newWidget;
|
||||
}
|
||||
});
|
||||
return elements[slot]?.renderObject as RenderBox?;
|
||||
}
|
||||
|
||||
void forgetChildElement(Element child) {
|
||||
elements.removeWhere((_, value) => value == child);
|
||||
}
|
||||
}
|
||||
|
||||
class PaneView extends StatelessWidget {
|
||||
const PaneView({
|
||||
required this.controller,
|
||||
required this.scrollController,
|
||||
this.scrollPhysics,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Pane controller;
|
||||
final ScrollController scrollController;
|
||||
final ScrollPhysics? scrollPhysics;
|
||||
|
||||
Widget buildViewport(BuildContext context, ViewportOffset offset) {
|
||||
return PaneViewport(controller: controller);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scrollable(
|
||||
controller: scrollController,
|
||||
physics: scrollPhysics,
|
||||
viewportBuilder: buildViewport,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PaneViewport extends RenderObjectWidget {
|
||||
const PaneViewport({
|
||||
required this.controller,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Pane controller;
|
||||
|
||||
@override
|
||||
RenderObjectElement createElement() {
|
||||
return PaneElement(this, controller);
|
||||
}
|
||||
|
||||
@override
|
||||
RenderObject createRenderObject(BuildContext context) {
|
||||
return PaneRender(controller: controller);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
BuildContext context, covariant PaneRender renderObject) {
|
||||
renderObject.controller = controller;
|
||||
}
|
||||
}
|
||||
|
||||
class PaneElement extends RenderObjectElement {
|
||||
PaneElement(super.widget, this._controller);
|
||||
|
||||
Pane _controller;
|
||||
|
||||
Pane get controller => _controller;
|
||||
|
||||
@override
|
||||
void insertRenderObjectChild(
|
||||
covariant RenderBox child, covariant Object? slot) {
|
||||
controller.insertRenderObjectChild(this, child, slot);
|
||||
}
|
||||
|
||||
@override
|
||||
void moveRenderObjectChild(covariant RenderObject child,
|
||||
covariant Object? oldSlot, covariant Object? newSlot) {
|
||||
controller.moveRenderObjectChild(this, child, oldSlot, newSlot);
|
||||
}
|
||||
|
||||
@override
|
||||
void removeRenderObjectChild(
|
||||
covariant RenderObject child, covariant Object? slot) {
|
||||
controller.removeRenderObjectChild(this, child, slot);
|
||||
}
|
||||
|
||||
@override
|
||||
void visitChildren(ElementVisitor visitor) =>
|
||||
controller.visitChildrenElement(visitor);
|
||||
|
||||
@override
|
||||
void deactivateChild(Element child) => super.deactivateChild(child);
|
||||
|
||||
@override
|
||||
void mount(Element? parent, Object? newSlot) {
|
||||
super.mount(parent, newSlot);
|
||||
controller.mountElement(parent, newSlot, this);
|
||||
}
|
||||
|
||||
@override
|
||||
void unmount() {
|
||||
controller.unmountElement();
|
||||
super.unmount();
|
||||
}
|
||||
|
||||
@override
|
||||
void update(covariant PaneViewport newWidget) {
|
||||
final oldController = _controller;
|
||||
final newController = newWidget.controller;
|
||||
if (oldController != newController) {
|
||||
oldController.unmountElement();
|
||||
_controller = newController;
|
||||
newController.mountElement(null, null, this);
|
||||
}
|
||||
super.update(newWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
void forgetChild(Element child) {
|
||||
controller.forgetChildElement(child);
|
||||
super.forgetChild(child);
|
||||
}
|
||||
|
||||
@override
|
||||
Element inflateWidget(Widget newWidget, Object? newSlot) =>
|
||||
super.inflateWidget(newWidget, newSlot);
|
||||
|
||||
@override
|
||||
PaneRender get renderObject => super.renderObject as PaneRender;
|
||||
|
||||
@override
|
||||
PaneViewport get widget => super.widget as PaneViewport;
|
||||
}
|
||||
|
||||
class PaneRender extends RenderBox {
|
||||
PaneRender({
|
||||
required Pane controller,
|
||||
}) : _controller = controller;
|
||||
|
||||
Pane _controller;
|
||||
|
||||
Pane get controller => _controller;
|
||||
|
||||
set controller(Pane value) {
|
||||
if (_controller == value) return;
|
||||
if (attached) {
|
||||
_controller.detachPipeline();
|
||||
_controller.renderer = null;
|
||||
}
|
||||
_controller = value;
|
||||
if (attached) {
|
||||
_controller.attachPipeline(owner!);
|
||||
_controller.renderer = this;
|
||||
markNeedsLayout();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void attach(PipelineOwner owner) {
|
||||
super.attach(owner);
|
||||
_controller.attachPipeline(owner);
|
||||
_controller.renderer = this;
|
||||
}
|
||||
|
||||
@override
|
||||
void detach() {
|
||||
super.detach();
|
||||
_controller.detachPipeline();
|
||||
_controller.renderer = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void adoptChild(RenderObject child) => super.adoptChild(child);
|
||||
|
||||
@override
|
||||
void dropChild(RenderObject child) => super.dropChild(child);
|
||||
|
||||
@override
|
||||
void setupParentData(covariant RenderObject child) {
|
||||
//no-op
|
||||
}
|
||||
|
||||
@override
|
||||
bool get sizedByParent => true;
|
||||
|
||||
@override
|
||||
void performResize() {
|
||||
size = constraints.biggest;
|
||||
}
|
||||
|
||||
@override
|
||||
void performLayout() {
|
||||
if (controller.renderer == null) return;
|
||||
controller.performLayout();
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(PaintingContext context, Offset offset) {
|
||||
if (controller.renderer == null) return;
|
||||
controller.paint(context, offset);
|
||||
}
|
||||
|
||||
@override
|
||||
bool hitTestSelf(Offset position) => controller.hitTestSelf(position);
|
||||
|
||||
@override
|
||||
void invokeLayoutCallback<T extends Constraints>(
|
||||
LayoutCallback<T> callback) =>
|
||||
super.invokeLayoutCallback(callback);
|
||||
|
||||
@override
|
||||
bool hitTestChildren(
|
||||
BoxHitTestResult result, {
|
||||
required Offset position,
|
||||
}) =>
|
||||
controller.hitTestChildren(result, position);
|
||||
|
||||
@override
|
||||
void visitChildren(RenderObjectVisitor visitor) =>
|
||||
controller.visitChildrenRender(visitor);
|
||||
|
||||
@override
|
||||
void applyPaintTransform(RenderObject child, Matrix4 transform) {
|
||||
assert(child.parent == this);
|
||||
controller.applyPaintTransform(child, transform);
|
||||
}
|
||||
|
||||
@override
|
||||
List<DiagnosticsNode> debugDescribeChildren() =>
|
||||
controller.debugDescribeChildren();
|
||||
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
||||
super.debugFillProperties(properties);
|
||||
controller.debugFillProperties(properties);
|
||||
}
|
||||
}
|
||||
668
lib/src/anim/sheet.dart
Normal file
668
lib/src/anim/sheet.dart
Normal file
@@ -0,0 +1,668 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/physics.dart';
|
||||
|
||||
const Duration _kSheetAnimation = Duration(milliseconds: 300);
|
||||
|
||||
/// Controller for coordinating scroll views with a [Sheet].
|
||||
///
|
||||
/// Child scroll views should register their `ScrollController` using
|
||||
/// [attach] when they become active and [detach] when they're no longer
|
||||
/// active. This lets the sheet coordinate gestures with the active
|
||||
/// scroll view (so e.g. dragging down on a list at scroll-top moves
|
||||
/// the sheet down rather than overscrolling the list).
|
||||
///
|
||||
/// Use [physics] on the active scroll view so user-input scroll is
|
||||
/// blocked while the sheet is mid-drag.
|
||||
class SheetController {
|
||||
ScrollController? _activeScrollController;
|
||||
|
||||
ScrollController? get activeScrollController => _activeScrollController;
|
||||
|
||||
/// Whether scrolling should be enabled for the active scroll view.
|
||||
/// Sheet sets this to false while it's mid-pan from collapsed.
|
||||
bool scrollEnabled = true;
|
||||
|
||||
ScrollPhysics get physics => SheetScrollPhysics(controller: this);
|
||||
|
||||
Future<void> Function()? _dismissCallback;
|
||||
|
||||
/// Animate the sheet offscreen. Returns a future that completes
|
||||
/// when the dismissal is done.
|
||||
Future<void> animateDismiss() async {
|
||||
await _dismissCallback?.call();
|
||||
}
|
||||
|
||||
/// Normalized position when dismiss was triggered (0 = offscreen,
|
||||
/// 1 = collapsed). Used by host route adapters to start a
|
||||
/// physics-based exit animation from the gesture's current position.
|
||||
double? dismissPosition;
|
||||
|
||||
/// Normalized velocity (units / second) when dismiss was triggered.
|
||||
double? dismissVelocity;
|
||||
|
||||
void setDismissState(double position, double velocity) {
|
||||
dismissPosition = position;
|
||||
dismissVelocity = velocity;
|
||||
}
|
||||
|
||||
void clearDismissState() {
|
||||
dismissPosition = null;
|
||||
dismissVelocity = null;
|
||||
}
|
||||
|
||||
/// Read by host route adapters to fetch the sheet's current
|
||||
/// normalized position when `pop()` is called directly (no gesture).
|
||||
double Function()? getCurrentPosition;
|
||||
|
||||
/// Set by the host's dismiss adapter — tells the Sheet to defer to
|
||||
/// the route animation for position rather than its own animator.
|
||||
bool isPopping = false;
|
||||
|
||||
void attach(ScrollController controller) {
|
||||
_activeScrollController = controller;
|
||||
}
|
||||
|
||||
void detach(ScrollController controller) {
|
||||
if (_activeScrollController == controller) {
|
||||
_activeScrollController = null;
|
||||
}
|
||||
}
|
||||
|
||||
double get scrollOffset {
|
||||
final sc = _activeScrollController;
|
||||
if (sc == null || !sc.hasClients) return 0.0;
|
||||
return sc.offset;
|
||||
}
|
||||
}
|
||||
|
||||
/// Scroll physics that coordinate with a [SheetController].
|
||||
/// When `controller.scrollEnabled` is false, scrolling is blocked
|
||||
/// (the sheet itself is consuming the gesture). Clamping behavior —
|
||||
/// no overscroll bounce.
|
||||
class SheetScrollPhysics extends ScrollPhysics {
|
||||
const SheetScrollPhysics({required this.controller, super.parent});
|
||||
|
||||
final SheetController controller;
|
||||
|
||||
@override
|
||||
SheetScrollPhysics applyTo(ScrollPhysics? ancestor) {
|
||||
return SheetScrollPhysics(
|
||||
controller: controller,
|
||||
parent: buildParent(ancestor),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
double applyPhysicsToUserOffset(ScrollMetrics position, double offset) {
|
||||
if (!controller.scrollEnabled) return 0.0;
|
||||
return super.applyPhysicsToUserOffset(position, offset);
|
||||
}
|
||||
|
||||
@override
|
||||
double applyBoundaryConditions(ScrollMetrics position, double value) {
|
||||
if (!controller.scrollEnabled) return value - position.pixels;
|
||||
if (value < position.minScrollExtent) {
|
||||
return value - position.minScrollExtent;
|
||||
}
|
||||
if (value > position.maxScrollExtent) {
|
||||
return value - position.maxScrollExtent;
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
@override
|
||||
Simulation? createBallisticSimulation(
|
||||
ScrollMetrics position,
|
||||
double velocity,
|
||||
) {
|
||||
if (!controller.scrollEnabled) return null;
|
||||
return super.createBallisticSimulation(position, velocity);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bottom sheet with telegram-style behavior:
|
||||
/// - Entry: slides up from the bottom edge to the collapsed extent.
|
||||
/// - Drag down past threshold → calls [onDismiss] (host pops; reverse
|
||||
/// animation plays).
|
||||
/// - Drag up from collapsed → expands to fullscreen.
|
||||
/// - Drag down from expanded with scroll at top → collapses.
|
||||
/// - Spring physics for snap (telegram damping ≈ 124 / 0.45s).
|
||||
/// - Scroll coordination via [SheetController]: inner list scrolls
|
||||
/// freely when expanded + scrolled, sheet drags when collapsed or at
|
||||
/// scroll-top.
|
||||
///
|
||||
/// When [routeAnimation] is provided, the Sheet defers to it for the
|
||||
/// entry / exit position so the host's `Route` (or a `Screen`)
|
||||
/// can hero-animate. With null [routeAnimation] the Sheet self-manages
|
||||
/// entry / exit via its own [AnimationController].
|
||||
class Sheet extends StatefulWidget {
|
||||
const Sheet({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.controller,
|
||||
this.routeAnimation,
|
||||
this.collapsedExtent = 0.6,
|
||||
this.isFullSize = false,
|
||||
this.isDismissible = true,
|
||||
this.onDismiss,
|
||||
this.barrierColor = Colors.black54,
|
||||
this.backgroundColor,
|
||||
this.borderRadius = 16.0,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final SheetController? controller;
|
||||
|
||||
/// When provided, the sheet position is driven by this animation.
|
||||
/// 0.0 = offscreen (bottom), 1.0 = collapsed.
|
||||
final Animation<double>? routeAnimation;
|
||||
|
||||
final double collapsedExtent;
|
||||
final bool isFullSize;
|
||||
final bool isDismissible;
|
||||
final VoidCallback? onDismiss;
|
||||
final Color barrierColor;
|
||||
final Color? backgroundColor;
|
||||
|
||||
/// Corner radius of the sheet (top corners always; bottom corners
|
||||
/// shrink toward 0 as the sheet expands).
|
||||
final double borderRadius;
|
||||
|
||||
@override
|
||||
State<Sheet> createState() => SheetState();
|
||||
}
|
||||
|
||||
class SheetState extends State<Sheet> with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animController;
|
||||
|
||||
bool get _hasRouteAnimation => widget.routeAnimation != null;
|
||||
|
||||
bool _isExpanded = false;
|
||||
|
||||
/// Visual offset: 0 at rest, negative when dragged down (sheet smaller).
|
||||
double _boundsOriginY = 0.0;
|
||||
|
||||
/// Animated top during internal expand/collapse transitions.
|
||||
/// When null, position is computed from state instead.
|
||||
double? _animatedTop;
|
||||
|
||||
// Pan gesture state (mirrors telegram's panGestureArguments).
|
||||
double? _panTopInset;
|
||||
double _panOffset = 0.0;
|
||||
|
||||
bool _expandedDuringPan = false;
|
||||
|
||||
/// Last gesture velocity normalized to "screen heights / second".
|
||||
double _lastNormalizedVelocity = 0.0;
|
||||
|
||||
bool _isDismissing = false;
|
||||
|
||||
double get _screenHeight => MediaQuery.sizeOf(context).height;
|
||||
|
||||
/// Distance from screen top to the sheet's collapsed edge.
|
||||
double get _edgeTopInset => _screenHeight * (1.0 - widget.collapsedExtent);
|
||||
|
||||
double get _restTopOffset => _isExpanded ? 0.0 : _edgeTopInset;
|
||||
|
||||
/// Position from route animation (offscreen → collapsed).
|
||||
/// Maps animation 0→1 to screenHeight→edgeTopInset.
|
||||
double? get _routeAnimatedTop {
|
||||
final anim = widget.routeAnimation;
|
||||
if (anim == null) return null;
|
||||
return _screenHeight + (_edgeTopInset - _screenHeight) * anim.value;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animController = AnimationController(vsync: this);
|
||||
|
||||
widget.controller?._dismissCallback = _animateDismiss;
|
||||
widget.controller?.getCurrentPosition = _getCurrentNormalizedPosition;
|
||||
|
||||
widget.routeAnimation?.addListener(_onRouteAnimationUpdate);
|
||||
|
||||
if (!_hasRouteAnimation) {
|
||||
_animatedTop = double.infinity; // start offscreen
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _animateToCollapsed();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onRouteAnimationUpdate() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
/// Current normalized position, used by the host's route adapter
|
||||
/// to drive a physics-based exit animation.
|
||||
double _getCurrentNormalizedPosition() {
|
||||
final currentTop = _animatedTop ?? (_restTopOffset - _boundsOriginY);
|
||||
return ((_screenHeight - currentTop) / (_screenHeight - _edgeTopInset))
|
||||
.clamp(0.0, double.infinity);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.routeAnimation?.removeListener(_onRouteAnimationUpdate);
|
||||
widget.controller?._dismissCallback = null;
|
||||
widget.controller?.getCurrentPosition = null;
|
||||
_animController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _animateDismiss() {
|
||||
if (_hasRouteAnimation) {
|
||||
widget.onDismiss?.call();
|
||||
return Future<void>.value();
|
||||
}
|
||||
return _playDismissAnimation();
|
||||
}
|
||||
|
||||
/// Slide the sheet offscreen and pin it there. Pure visual — no
|
||||
/// [widget.onDismiss] notification, no [_isDismissing] flag.
|
||||
/// Callers ([_dismiss] for gesture / backdrop, [_animateDismiss]
|
||||
/// for [SheetController.animateDismiss]) layer their own pre- /
|
||||
/// post-actions on top.
|
||||
///
|
||||
/// The trailing pin to `_screenHeight` defends against the tail
|
||||
/// frame between the animator's own `_animatedTop = null` cleanup
|
||||
/// and the caller's pop / unmount — without it that frame paints
|
||||
/// at `_restTopOffset` (the pre-dismiss collapsed / expanded
|
||||
/// position) and flashes.
|
||||
Future<void> _playDismissAnimation() {
|
||||
return _animateWithEaseInOut(
|
||||
targetTop: _screenHeight,
|
||||
duration: _kSheetAnimation,
|
||||
).then((_) {
|
||||
if (mounted) setState(() => _animatedTop = _screenHeight);
|
||||
});
|
||||
}
|
||||
|
||||
double get _scrollOffset => widget.controller?.scrollOffset ?? 0.0;
|
||||
|
||||
// ── Pan handlers ───────────────────────────────────────────────────
|
||||
|
||||
void _onPanStart(DragStartDetails details) {
|
||||
_animController.stop();
|
||||
_panTopInset = _isExpanded ? 0.0 : _edgeTopInset;
|
||||
_panOffset = 0.0;
|
||||
_expandedDuringPan = false;
|
||||
|
||||
if (!_isExpanded) {
|
||||
widget.controller?.scrollEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _onPanUpdate(DragUpdateDetails details) {
|
||||
final topInset = _panTopInset;
|
||||
if (topInset == null) return;
|
||||
|
||||
final delta = details.delta.dy;
|
||||
final contentOffset = _scrollOffset;
|
||||
final translation = _panOffset + delta;
|
||||
|
||||
const epsilon = 1.0;
|
||||
|
||||
if (_isExpanded && !_expandedDuringPan) {
|
||||
// EXPANDED — drag down only moves the sheet if we're at scroll-top.
|
||||
if (contentOffset <= epsilon && delta > 0) {
|
||||
_panOffset = translation;
|
||||
final boundsOriginY = min(0.0, -translation);
|
||||
widget.controller?.scrollEnabled = false;
|
||||
setState(() => _boundsOriginY = boundsOriginY);
|
||||
} else if (_boundsOriginY < 0 && delta < 0) {
|
||||
// Sheet dragged down, now dragging back up — return to expanded.
|
||||
_panOffset = translation;
|
||||
final boundsOriginY = min(0.0, -translation);
|
||||
widget.controller?.scrollEnabled = boundsOriginY >= 0;
|
||||
setState(() => _boundsOriginY = boundsOriginY);
|
||||
} else if (contentOffset > epsilon) {
|
||||
// Mid-scroll — let the inner list handle it.
|
||||
} else {
|
||||
widget.controller?.scrollEnabled = true;
|
||||
}
|
||||
} else {
|
||||
// COLLAPSED (or expanding mid-pan).
|
||||
_panOffset = translation;
|
||||
|
||||
if (translation < 0) {
|
||||
// Dragging UP.
|
||||
final expandAmount = -translation;
|
||||
final maxExpand = _edgeTopInset;
|
||||
if (expandAmount > maxExpand) {
|
||||
setState(() => _animatedTop = 0);
|
||||
_expandedDuringPan = true;
|
||||
widget.controller?.scrollEnabled = true;
|
||||
} else {
|
||||
widget.controller?.scrollEnabled = false;
|
||||
_expandedDuringPan = false;
|
||||
setState(() => _animatedTop = _edgeTopInset - expandAmount);
|
||||
}
|
||||
} else {
|
||||
// Dragging DOWN — shrink sheet.
|
||||
final boundsOriginY = min(0.0, -translation);
|
||||
setState(() {
|
||||
_animatedTop = null;
|
||||
_boundsOriginY = boundsOriginY;
|
||||
});
|
||||
_expandedDuringPan = false;
|
||||
widget.controller?.scrollEnabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onPanEnd(DragEndDetails details) {
|
||||
final topInset = _panTopInset;
|
||||
if (topInset == null) return;
|
||||
|
||||
_panTopInset = null;
|
||||
_panOffset = 0.0;
|
||||
widget.controller?.scrollEnabled = true;
|
||||
|
||||
final contentOffset = _scrollOffset;
|
||||
var velocity = details.velocity.pixelsPerSecond.dy;
|
||||
|
||||
if ((_isExpanded || _expandedDuringPan) && contentOffset > 0.1) {
|
||||
velocity = 0.0;
|
||||
}
|
||||
|
||||
_lastNormalizedVelocity = -velocity / _screenHeight;
|
||||
|
||||
final edgeTopInset = _edgeTopInset;
|
||||
final currentTop = _animatedTop ?? (_restTopOffset - _boundsOriginY);
|
||||
|
||||
final thresholdOffset = widget.isFullSize ? 180.0 : 60.0;
|
||||
if (widget.isDismissible) {
|
||||
final pastThreshold = currentTop > edgeTopInset + thresholdOffset;
|
||||
final pastCollapsedWithVelocity =
|
||||
currentTop > edgeTopInset && velocity > 300.0;
|
||||
final fastFlingFromExpanded = (_isExpanded || _expandedDuringPan) &&
|
||||
contentOffset <= 0.1 &&
|
||||
velocity > 1800.0;
|
||||
|
||||
if (pastThreshold || pastCollapsedWithVelocity || fastFlingFromExpanded) {
|
||||
_expandedDuringPan = false;
|
||||
_dismiss();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_isExpanded || _expandedDuringPan) {
|
||||
if ((velocity > 300.0 || currentTop > edgeTopInset / 2.0) &&
|
||||
!widget.isFullSize) {
|
||||
// COLLAPSE.
|
||||
final fromTop = currentTop;
|
||||
_animatedTop = fromTop;
|
||||
_boundsOriginY = 0.0;
|
||||
_isExpanded = false;
|
||||
_expandedDuringPan = false;
|
||||
|
||||
final initialVelocity =
|
||||
velocity.abs() / max(1.0, (edgeTopInset - fromTop).abs());
|
||||
_animateWithSpring(
|
||||
targetTop: edgeTopInset,
|
||||
initialVelocity: initialVelocity,
|
||||
fromTop: fromTop,
|
||||
);
|
||||
} else {
|
||||
// STAY EXPANDED.
|
||||
_animatedTop = currentTop;
|
||||
_boundsOriginY = 0.0;
|
||||
_isExpanded = true;
|
||||
_expandedDuringPan = false;
|
||||
_animateWithEaseInOut(targetTop: 0.0, fromTop: currentTop);
|
||||
}
|
||||
} else {
|
||||
if (velocity < -300.0 || currentTop < edgeTopInset / 2.0) {
|
||||
// EXPAND.
|
||||
final fromTop = currentTop;
|
||||
_animatedTop = fromTop;
|
||||
_boundsOriginY = 0.0;
|
||||
_isExpanded = true;
|
||||
|
||||
final initialVelocity = velocity.abs() / max(1.0, fromTop.abs());
|
||||
_animateWithSpring(
|
||||
targetTop: 0.0,
|
||||
initialVelocity: initialVelocity,
|
||||
fromTop: fromTop,
|
||||
);
|
||||
} else {
|
||||
// STAY COLLAPSED.
|
||||
_animatedTop = currentTop;
|
||||
_boundsOriginY = 0.0;
|
||||
_animateWithEaseInOut(targetTop: edgeTopInset, fromTop: currentTop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Animations ─────────────────────────────────────────────────────
|
||||
|
||||
void _animateToCollapsed() {
|
||||
_isExpanded = false;
|
||||
_animatedTop = _screenHeight;
|
||||
_boundsOriginY = 0.0;
|
||||
_animateWithEaseInOut(targetTop: _edgeTopInset, fromOffscreen: true);
|
||||
}
|
||||
|
||||
void _dismiss() {
|
||||
if (_hasRouteAnimation) {
|
||||
final currentTop = _animatedTop ?? (_restTopOffset - _boundsOriginY);
|
||||
final normalizedPosition =
|
||||
((_screenHeight - currentTop) / (_screenHeight - _edgeTopInset))
|
||||
.clamp(0.0, double.infinity);
|
||||
widget.controller
|
||||
?.setDismissState(normalizedPosition, _lastNormalizedVelocity);
|
||||
_isDismissing = true;
|
||||
_isExpanded = false;
|
||||
_boundsOriginY = 0.0;
|
||||
_animatedTop = null;
|
||||
widget.onDismiss?.call();
|
||||
} else {
|
||||
_isDismissing = true;
|
||||
_playDismissAnimation().then((_) {
|
||||
if (mounted) widget.onDismiss?.call();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Spring animation tuned to feel like telegram's damping ≈ 124 / 0.45s.
|
||||
Future<void> _animateWithSpring({
|
||||
required double targetTop,
|
||||
required double initialVelocity,
|
||||
double? fromTop,
|
||||
}) {
|
||||
_animController.stop();
|
||||
|
||||
final currentTop = fromTop ?? (_restTopOffset - _boundsOriginY);
|
||||
_animatedTop = currentTop;
|
||||
_boundsOriginY = 0.0;
|
||||
|
||||
const spring = SpringDescription(mass: 1.0, stiffness: 500.0, damping: 30.0);
|
||||
final springVelocity =
|
||||
initialVelocity * (targetTop - currentTop).sign;
|
||||
final normalizedSpring = SpringSimulation(
|
||||
spring,
|
||||
0.0,
|
||||
1.0,
|
||||
springVelocity / max(1e-3, (targetTop - currentTop).abs()),
|
||||
);
|
||||
|
||||
late void Function() listener;
|
||||
listener = () {
|
||||
final t = _animController.value;
|
||||
final newTop = currentTop + (targetTop - currentTop) * t;
|
||||
if (mounted) {
|
||||
setState(() => _animatedTop = newTop.clamp(0.0, _screenHeight));
|
||||
}
|
||||
};
|
||||
|
||||
_animController.addListener(listener);
|
||||
return _animController.animateWith(normalizedSpring).then((_) {
|
||||
_animController.removeListener(listener);
|
||||
if (mounted) setState(() => _animatedTop = null);
|
||||
});
|
||||
}
|
||||
|
||||
/// EaseInOut animation, telegram default 0.3s.
|
||||
Future<void> _animateWithEaseInOut({
|
||||
required double targetTop,
|
||||
Duration duration = _kSheetAnimation,
|
||||
bool fromOffscreen = false,
|
||||
double? fromTop,
|
||||
}) {
|
||||
_animController.stop();
|
||||
|
||||
final startTop = fromOffscreen
|
||||
? _screenHeight
|
||||
: (fromTop ?? (_restTopOffset - _boundsOriginY));
|
||||
_animatedTop = startTop;
|
||||
_boundsOriginY = 0.0;
|
||||
|
||||
late void Function() listener;
|
||||
listener = () {
|
||||
final t = Curves.easeInOut.transform(_animController.value);
|
||||
if (mounted) {
|
||||
setState(() => _animatedTop = startTop + (targetTop - startTop) * t);
|
||||
}
|
||||
};
|
||||
|
||||
_animController.duration = duration;
|
||||
_animController.addListener(listener);
|
||||
_animController.forward(from: 0.0);
|
||||
|
||||
return _animController.animateTo(1.0).then((_) {
|
||||
_animController.removeListener(listener);
|
||||
if (mounted) setState(() => _animatedTop = null);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.sizeOf(context);
|
||||
final screenHeight = screenSize.height;
|
||||
final screenWidth = screenSize.width;
|
||||
|
||||
final isPopping = widget.controller?.isPopping ?? false;
|
||||
final double topOffset;
|
||||
if ((_isDismissing || isPopping) && _hasRouteAnimation) {
|
||||
topOffset = (_routeAnimatedTop ?? _restTopOffset).clamp(0.0, screenHeight);
|
||||
} else if (_animatedTop != null) {
|
||||
topOffset = _animatedTop!.clamp(0.0, screenHeight);
|
||||
} else if (_hasRouteAnimation && !_isExpanded && _boundsOriginY == 0.0) {
|
||||
topOffset = (_routeAnimatedTop ?? _restTopOffset).clamp(0.0, screenHeight);
|
||||
} else {
|
||||
topOffset = (_restTopOffset - _boundsOriginY).clamp(0.0, screenHeight);
|
||||
}
|
||||
|
||||
final safeAreaTop = MediaQuery.paddingOf(context).top;
|
||||
final edgeTopInset = _edgeTopInset;
|
||||
final clampedTopOffset = topOffset.clamp(safeAreaTop, screenHeight);
|
||||
|
||||
final expansionRange = edgeTopInset - safeAreaTop;
|
||||
final expansionProgress = expansionRange > 0
|
||||
? ((edgeTopInset - clampedTopOffset) / expansionRange).clamp(0.0, 1.0)
|
||||
: 0.0;
|
||||
|
||||
const collapsedPadding = 6.0;
|
||||
final sheetPadding = collapsedPadding * (1.0 - expansionProgress);
|
||||
|
||||
final collapsedHeight = screenHeight - edgeTopInset;
|
||||
final cornerRadius =
|
||||
(widget.borderRadius - sheetPadding).clamp(0.0, widget.borderRadius);
|
||||
|
||||
final barrierProgress =
|
||||
(1.0 - clampedTopOffset / edgeTopInset).clamp(0.0, 1.0);
|
||||
|
||||
final sheetLeft = sheetPadding;
|
||||
final sheetTop = clampedTopOffset;
|
||||
final sheetWidth = screenWidth - sheetPadding * 2;
|
||||
final sheetHeight =
|
||||
max(collapsedHeight, screenHeight - clampedTopOffset) - sheetPadding;
|
||||
|
||||
final animValue = widget.routeAnimation?.value ?? 1.0;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: widget.isDismissible ? _dismiss : null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: ColoredBox(
|
||||
color: widget.barrierColor.withValues(
|
||||
alpha: widget.barrierColor.a * barrierProgress * animValue,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: sheetLeft,
|
||||
top: sheetTop,
|
||||
width: sheetWidth,
|
||||
height: sheetHeight,
|
||||
child: RawGestureDetector(
|
||||
gestures: <Type, GestureRecognizerFactory<GestureRecognizer>>{
|
||||
_SheetPanRecognizer:
|
||||
GestureRecognizerFactoryWithHandlers<_SheetPanRecognizer>(
|
||||
_SheetPanRecognizer.new,
|
||||
(r) => r
|
||||
..onStart = _onPanStart
|
||||
..onUpdate = _onPanUpdate
|
||||
..onEnd = _onPanEnd,
|
||||
),
|
||||
},
|
||||
child: Material(
|
||||
color: widget.backgroundColor ??
|
||||
Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(widget.borderRadius),
|
||||
bottom: Radius.circular(cornerRadius),
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
elevation: 8,
|
||||
child: Column(
|
||||
children: [
|
||||
const SheetThumb(),
|
||||
Expanded(child: widget.child),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Vertical-only pan recognizer that always wins the arena. Lets a
|
||||
/// drag-to-expand on the sheet beat sibling recognizers (e.g. a tap
|
||||
/// recognizer in the title bar).
|
||||
class _SheetPanRecognizer extends VerticalDragGestureRecognizer {
|
||||
@override
|
||||
void rejectGesture(int pointer) => acceptGesture(pointer);
|
||||
}
|
||||
|
||||
/// Centered pill drag-handle. Default for the top edge of a [Sheet].
|
||||
class SheetThumb extends StatelessWidget {
|
||||
const SheetThumb({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Center(
|
||||
child: Container(
|
||||
height: 5,
|
||||
width: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(2.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<UxCameraValue>(
|
||||
return ValueListenableBuilder<XCameraValue>(
|
||||
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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
74
lib/src/core/bloc_builder.dart
Normal file
74
lib/src/core/bloc_builder.dart
Normal file
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dispose.dart';
|
||||
|
||||
/// Signature for the widget building function used by [BlocBuilder].
|
||||
typedef BlocBuilderDelegate<T> = Widget Function(
|
||||
BuildContext context,
|
||||
T bloc,
|
||||
Widget? child,
|
||||
);
|
||||
|
||||
/// Signature for the create callback that instantiates the bloc once.
|
||||
typedef BlocBuilderCreateDelegate<T> = T Function(
|
||||
BuildContext context,
|
||||
);
|
||||
|
||||
/// Lightweight 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<T> extends StatefulWidget {
|
||||
const BlocBuilder({
|
||||
/// NOTE: if the create returns a Disposable/ChangeNotifier the `dispose` will be called
|
||||
/// when the [BlocBuilder] is disposed
|
||||
required this.create,
|
||||
required this.builder,
|
||||
this.child,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Creates the bloc
|
||||
final BlocBuilderCreateDelegate<T> create;
|
||||
|
||||
/// Builds the widget given the created bloc
|
||||
final BlocBuilderDelegate<T> builder;
|
||||
final Widget? child;
|
||||
|
||||
@override
|
||||
State<BlocBuilder<T>> createState() => _BlocBuilderState<T>();
|
||||
}
|
||||
|
||||
class _BlocBuilderState<T> extends State<BlocBuilder<T>> {
|
||||
late T bloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
bloc = widget.create(context);
|
||||
}
|
||||
|
||||
/// Disposes underlying bloc if it supports disposal semantics.
|
||||
void _dispose(BlocBuilder<T> widget) {
|
||||
final bloc = this.bloc;
|
||||
switch (bloc) {
|
||||
case Disposable():
|
||||
bloc.dispose();
|
||||
case ChangeNotifier():
|
||||
bloc.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_dispose(widget);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.builder(
|
||||
context,
|
||||
bloc,
|
||||
widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
18
lib/src/core/core.dart
Normal file
18
lib/src/core/core.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
export 'bloc_builder.dart';
|
||||
export 'debouncer.dart';
|
||||
export 'dispose.dart';
|
||||
export 'emitter.dart';
|
||||
export 'functional.dart';
|
||||
export 'late.dart';
|
||||
export 'list_emitter.dart';
|
||||
export 'presenter.dart';
|
||||
export 'publisher.dart';
|
||||
export 'range.dart';
|
||||
export 'store/async_init.dart';
|
||||
export 'store/store.dart';
|
||||
export 'store/store_provider.dart';
|
||||
export 'subscription.dart';
|
||||
export 'tasks.dart';
|
||||
export 'uri.dart';
|
||||
export 'value.dart';
|
||||
export 'widget.dart';
|
||||
35
lib/src/core/debouncer.dart
Normal file
35
lib/src/core/debouncer.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
const kDefaultDebounce = const Duration(milliseconds: 100);
|
||||
|
||||
/// Utility that delays execution of the last scheduled callback until
|
||||
/// [duration] has elapsed without a new schedule.
|
||||
class Debouncer {
|
||||
Debouncer({
|
||||
this.duration = kDefaultDebounce,
|
||||
});
|
||||
|
||||
final Duration duration;
|
||||
Timer? timer;
|
||||
|
||||
/// Schedules [f] to run after [duration]; resets the timer if called again.
|
||||
void run(FutureOr<void> Function() f, {Duration? duration}) {
|
||||
timer?.cancel();
|
||||
timer = Timer(duration ?? this.duration, f);
|
||||
}
|
||||
|
||||
/// Cancels a pending scheduled callback, if any.
|
||||
void cancel() => timer?.cancel();
|
||||
}
|
||||
|
||||
/// Returns a closure that debounces calls to [f] by [duration]. Re‑invocation
|
||||
/// resets the timer. Useful for text search, resize, etc.
|
||||
VoidCallback debounce(
|
||||
FutureOr<void> Function() f, [
|
||||
Duration duration = kDefaultDebounce,
|
||||
]) {
|
||||
final debouncer = Debouncer(duration: duration);
|
||||
return () => debouncer.run(f);
|
||||
}
|
||||
67
lib/src/core/dispose.dart
Normal file
67
lib/src/core/dispose.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'functional.dart';
|
||||
|
||||
/// Basic disposable contract.
|
||||
abstract class Disposable {
|
||||
bool get disposed;
|
||||
|
||||
void dispose();
|
||||
}
|
||||
|
||||
/// Mixin that aggregates disposal callbacks. Call [addDispose] to register
|
||||
/// cleanups; they execute in reverse insertion order when [dispose] is called.
|
||||
mixin class Dispose implements Disposable {
|
||||
/// Registered callbacks executed (reversed) on [dispose].
|
||||
final List<VoidCallback> _dispose = [];
|
||||
|
||||
void _assertNotDisposed() {
|
||||
assert(() {
|
||||
if (disposed) {
|
||||
throw FlutterError(
|
||||
'A ${runtimeType} was used after being disposed.\n'
|
||||
'Once you have called dispose() on a ${runtimeType}, it '
|
||||
'can no longer be used.',
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}());
|
||||
}
|
||||
|
||||
/// Registers a disposal [callback]. Ignored if already disposed.
|
||||
void addDispose(VoidCallback callback) {
|
||||
_assertNotDisposed();
|
||||
if (!disposed) {
|
||||
_dispose.add(callback);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@mustCallSuper
|
||||
void dispose() {
|
||||
_assertNotDisposed();
|
||||
|
||||
_disposed = true;
|
||||
_dispose
|
||||
..reversed.forEach(call)
|
||||
..clear();
|
||||
}
|
||||
|
||||
bool _disposed = false;
|
||||
|
||||
@override
|
||||
bool get disposed => _disposed;
|
||||
|
||||
static void object(Object? object) {
|
||||
if (object is Disposable) {
|
||||
object.dispose();
|
||||
} else if (object is ChangeNotifier) {
|
||||
object.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension DisposableDisposerExtension on Disposable {
|
||||
/// Adds this object's [dispose] to another [Dispose] collector.
|
||||
void disposeBy(Dispose disposer) => disposer.addDispose(dispose);
|
||||
}
|
||||
189
lib/src/core/emitter.dart
Normal file
189
lib/src/core/emitter.dart
Normal file
@@ -0,0 +1,189 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'dispose.dart';
|
||||
|
||||
/// Core reactive mixin combining a private [ChangeNotifier] with disposal.
|
||||
/// Extend / mix into classes that need manual `notifyListeners()` control.
|
||||
mixin class Emitter implements Dispose, Listenable {
|
||||
final Dispose _dispose = Dispose();
|
||||
final _ChangeNotifier _notifier = _ChangeNotifier();
|
||||
|
||||
@override
|
||||
@mustCallSuper
|
||||
void addDispose(VoidCallback callback) => _dispose.addDispose(callback);
|
||||
|
||||
@override
|
||||
@mustCallSuper
|
||||
void addListener(VoidCallback listener) {
|
||||
_notifier.addListener(listener);
|
||||
}
|
||||
|
||||
@override
|
||||
@mustCallSuper
|
||||
void removeListener(VoidCallback listener) {
|
||||
_notifier.removeListener(listener);
|
||||
}
|
||||
|
||||
/// Notifies all registered listeners.
|
||||
@protected
|
||||
void notifyListeners() => _notifier.notifyListeners();
|
||||
|
||||
@override
|
||||
@mustCallSuper
|
||||
void dispose() {
|
||||
_dispose.dispose();
|
||||
_notifier.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
bool get disposed => _dispose.disposed;
|
||||
|
||||
/// Whether there is at least one active listener.
|
||||
bool get hasListeners => _notifier.hasListeners;
|
||||
|
||||
/// Creates a derived [LazyEmitter] recomputed when any [sources] fire.
|
||||
/// Emits only if the newly computed value differs from the cached value.
|
||||
static LazyEmitter<T> map<T>(List<Listenable> sources, ValueGetter<T> fn) {
|
||||
final notifier = LazyEmitter(fn);
|
||||
subscribe(sources, notifier.notifyListeners).disposeBy(notifier);
|
||||
return notifier;
|
||||
}
|
||||
|
||||
/// handy way to addListener to a [ValueNotifier] and return the disposer callback
|
||||
static VoidCallback subscribe(
|
||||
Iterable<Listenable> sources,
|
||||
VoidCallback callback,
|
||||
) {
|
||||
final subscriptions = sources.fold(
|
||||
<VoidCallback>[],
|
||||
(a, e) => a..add(e.subscribe(callback)),
|
||||
);
|
||||
return () {
|
||||
for (final subscription in subscriptions) {
|
||||
subscription();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Wraps [callback] so it is coalesced & scheduled in a microtask.
|
||||
static VoidCallback scheduled(VoidCallback callback) {
|
||||
var targetVersion = 0;
|
||||
var currentVersion = 0;
|
||||
return () {
|
||||
if (targetVersion == currentVersion) {
|
||||
targetVersion++;
|
||||
scheduleMicrotask(() {
|
||||
targetVersion = ++currentVersion;
|
||||
callback();
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// [ScheduledEmitter] allows listeners to be notified post frame.
|
||||
/// Usage: enables notifications during a widget's `setState` without re-entrancy.
|
||||
/// Not generally recommended—prefer standard emit patterns unless necessary.
|
||||
class ScheduledEmitter with Emitter {
|
||||
ScheduledEmitter();
|
||||
|
||||
late final VoidCallback _scheduledNotifyListeners =
|
||||
Emitter.scheduled(super.notifyListeners);
|
||||
|
||||
@override
|
||||
void notifyListeners() => _scheduledNotifyListeners();
|
||||
}
|
||||
|
||||
/// Exposes [notifyListeners] publicly (no protection) for advanced use cases.
|
||||
class PublicEmitter extends Emitter {
|
||||
@override
|
||||
void notifyListeners() => super.notifyListeners();
|
||||
}
|
||||
|
||||
class _ChangeNotifier extends ChangeNotifier {
|
||||
@override
|
||||
void notifyListeners() => super.notifyListeners();
|
||||
|
||||
@override
|
||||
bool get hasListeners => super.hasListeners;
|
||||
}
|
||||
|
||||
/// Mutable value holder emitting when [value] changes (shallow equality).
|
||||
/// Mutable value holder emitting when [value] changes (shallow equality).
|
||||
///
|
||||
/// {@tool snippet}
|
||||
/// ```dart
|
||||
/// final counter = ValueEmitter<int>(0);
|
||||
/// counter.addListener(() => print(counter.value));
|
||||
/// counter.value++;
|
||||
/// ```
|
||||
/// {@end-tool}
|
||||
class ValueEmitter<T> with Emitter implements ValueNotifier<T> {
|
||||
ValueEmitter(this._value);
|
||||
|
||||
T _value;
|
||||
|
||||
@override
|
||||
T get value => _value;
|
||||
|
||||
@override
|
||||
set value(T newValue) {
|
||||
if (_value == newValue) {
|
||||
return;
|
||||
}
|
||||
_value = newValue;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Lazily computes its value via [fn]. Recomputes on [update] if listeners
|
||||
/// are attached and the value actually changes.
|
||||
///
|
||||
/// {@tool snippet}
|
||||
/// ```dart
|
||||
/// final a = ValueEmitter(1);
|
||||
/// final b = ValueEmitter(2);
|
||||
/// final sum = Emitter.map([a, b], () => a.value + b.value);
|
||||
/// sum.addListener(() => print('sum: ${sum.value}'));
|
||||
/// a.value = 10; b.value = 5; // triggers recompute
|
||||
/// ```
|
||||
/// {@end-tool}
|
||||
class LazyEmitter<T> with Emitter implements ValueListenable<T> {
|
||||
LazyEmitter(this.fn);
|
||||
|
||||
final ValueGetter<T> fn;
|
||||
T? _lastValue;
|
||||
|
||||
@override
|
||||
T get value => _lastValue ??= fn();
|
||||
|
||||
@override
|
||||
void notifyListeners() {
|
||||
final newValue = fn();
|
||||
if (_lastValue != newValue) {
|
||||
_lastValue = newValue;
|
||||
super.notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ListenableExtension on Listenable {
|
||||
/// Adds [callback] as listener and returns a function to remove it.
|
||||
VoidCallback subscribe(VoidCallback callback) {
|
||||
addListener(callback);
|
||||
return () => removeListener(callback);
|
||||
}
|
||||
}
|
||||
|
||||
extension StreamSubscriptionExtension<T> on Stream<T> {
|
||||
VoidCallback subscribe(ValueChanged<T> callback) {
|
||||
return listen(callback).cancel;
|
||||
}
|
||||
}
|
||||
|
||||
extension VoidCallbackExtension on VoidCallback {
|
||||
/// Adds this callback to a [Dispose] aggregator for later invocation.
|
||||
void disposeBy(Dispose disposer) => disposer.addDispose(this);
|
||||
}
|
||||
32
lib/src/core/functional.dart
Normal file
32
lib/src/core/functional.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
const kEpsilon = 0.001;
|
||||
|
||||
/// Generic function taking no params returning [T].
|
||||
typedef Callback<T> = T Function();
|
||||
|
||||
/// Truthy test used across selection APIs.
|
||||
typedef Predicate<T> = bool Function(T e);
|
||||
|
||||
extension ObjectFunctional<T> on T {
|
||||
/// Functional pipe: `value.pipe(fn)` -> `fn(value)`.
|
||||
R pipe<R>(R Function(T e) e) => e(this);
|
||||
}
|
||||
|
||||
/// Identity helper (returns input unchanged).
|
||||
T self<T>(T e) => e;
|
||||
|
||||
/// Executes a zero‑arg [Callback].
|
||||
void call<T>(Callback<T> e) => e();
|
||||
|
||||
bool True() => true;
|
||||
|
||||
bool False() => false;
|
||||
|
||||
extension IterableLastWhereOrNull<T> on Iterable<T> {
|
||||
T? lastWhereOrNull(bool Function(T) test) {
|
||||
T? result;
|
||||
for (final element in this) {
|
||||
if (test(element)) result = element;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
141
lib/src/core/late.dart
Normal file
141
lib/src/core/late.dart
Normal file
@@ -0,0 +1,141 @@
|
||||
/// [Late] as in value is resolved later, but that may fail after loading.
|
||||
/// We tend to have quite a few cases where a set of tree states (error,
|
||||
/// loading, value) for pages.
|
||||
sealed class Late<T> {
|
||||
factory Late.value(T value) => LateValue<T>(value);
|
||||
|
||||
factory Late.loading() => const LateLoading();
|
||||
|
||||
factory Late.error([dynamic error]) => LateError<T>(error);
|
||||
}
|
||||
|
||||
extension LateExtension<T> on Late<T> {
|
||||
bool get isLoading => this is LateLoading;
|
||||
|
||||
bool get isError => this is LateError;
|
||||
|
||||
bool get isValue => this is LateValue;
|
||||
|
||||
T? get value => this.isValue ? (this as LateValue).value : null;
|
||||
|
||||
R map<R>({
|
||||
required R Function(LateValue<T> e) value,
|
||||
required R Function(LateError<T> e) error,
|
||||
required R Function(LateLoading<T> e) loading,
|
||||
}) {
|
||||
final e = this;
|
||||
return switch (e) {
|
||||
LateLoading() => loading(e),
|
||||
LateError() => error(e),
|
||||
LateValue() => value(e),
|
||||
};
|
||||
}
|
||||
|
||||
R maybeMap<R>({
|
||||
required R Function() orElse,
|
||||
R Function(LateValue<T> e)? value,
|
||||
R Function(LateError<T> e)? error,
|
||||
R Function(LateLoading<T> e)? loading,
|
||||
}) {
|
||||
final e = this;
|
||||
return switch (e) {
|
||||
LateLoading() => loading == null ? orElse() : loading(e),
|
||||
LateError() => error == null ? orElse() : error(e),
|
||||
LateValue() => value == null ? orElse() : value(e),
|
||||
};
|
||||
}
|
||||
|
||||
R? mapOrNull<R>({
|
||||
R Function(LateValue<T> e)? value,
|
||||
R Function(LateError<T> e)? error,
|
||||
R Function(LateLoading<T> e)? loading,
|
||||
}) {
|
||||
final e = this;
|
||||
return switch (e) {
|
||||
LateLoading() => loading?.call(e),
|
||||
LateError() => error?.call(e),
|
||||
LateValue() => value?.call(e),
|
||||
};
|
||||
}
|
||||
|
||||
R when<R>({
|
||||
required R Function() loading,
|
||||
required R Function(dynamic error) error,
|
||||
required R Function(T value) value,
|
||||
}) {
|
||||
final e = this;
|
||||
return switch (e) {
|
||||
LateLoading() => loading(),
|
||||
LateError() => error(e.error),
|
||||
LateValue() => value(e.value),
|
||||
};
|
||||
}
|
||||
|
||||
R? whenOrNull<R>({
|
||||
R Function()? loading,
|
||||
R Function([dynamic error])? error,
|
||||
R Function(T value)? value,
|
||||
}) {
|
||||
final e = this;
|
||||
return switch (e) {
|
||||
LateLoading() => loading?.call(),
|
||||
LateError() => error?.call(e.error),
|
||||
LateValue() => value?.call(e.value),
|
||||
};
|
||||
}
|
||||
|
||||
R maybeWhen<R>({
|
||||
required R Function() orElse,
|
||||
R Function()? loading,
|
||||
R Function([dynamic error])? error,
|
||||
R Function(T value)? value,
|
||||
}) {
|
||||
final e = this;
|
||||
return switch (e) {
|
||||
LateLoading() => loading == null ? orElse() : loading(),
|
||||
LateError() => error == null ? orElse() : error(e.error),
|
||||
LateValue() => value == null ? orElse() : value(e.value),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class LateError<T> implements Late<T> {
|
||||
const LateError([this.error]);
|
||||
|
||||
final dynamic error;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is LateError &&
|
||||
other.runtimeType == runtimeType &&
|
||||
other.error == error;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hashAll([error]);
|
||||
}
|
||||
|
||||
class LateLoading<T> implements Late<T> {
|
||||
const LateLoading();
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is LateLoading && other.runtimeType == runtimeType;
|
||||
|
||||
@override
|
||||
int get hashCode => 0;
|
||||
}
|
||||
|
||||
class LateValue<T> implements Late<T> {
|
||||
const LateValue(this.value);
|
||||
|
||||
final T value;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is LateValue &&
|
||||
other.runtimeType == runtimeType &&
|
||||
other.value == value;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hashAll([value]);
|
||||
}
|
||||
291
lib/src/core/list_emitter.dart
Normal file
291
lib/src/core/list_emitter.dart
Normal file
@@ -0,0 +1,291 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'emitter.dart';
|
||||
|
||||
/// A `List<T>` wrapper that emits change notifications for all mutating
|
||||
/// operations. Non‑mutating queries delegate to the underlying list.
|
||||
/// A `List<T>` wrapper that emits change notifications for all mutating
|
||||
/// operations. Non‑mutating queries delegate to the underlying list.
|
||||
///
|
||||
/// {@tool snippet}
|
||||
/// ```dart
|
||||
/// final list = ListEmitter<int>();
|
||||
/// list.addListener(() => print('len: ${list.length}'));
|
||||
/// list.add(1); // prints len: 1
|
||||
/// ```
|
||||
/// {@end-tool}
|
||||
class ListEmitter<T> with Emitter implements List<T>, ValueListenable<List<T>> {
|
||||
List<T> _list;
|
||||
|
||||
ListEmitter([Iterable<T>? items]) : _list = List.from(items ?? <T>[]);
|
||||
|
||||
@override
|
||||
T get first => _list.first;
|
||||
|
||||
set first(T value) {
|
||||
_list.first = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
T get last => _list.last;
|
||||
|
||||
set last(T value) {
|
||||
_list.last = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
int get length => _list.length;
|
||||
|
||||
set length(int value) {
|
||||
_list.length = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
List<T> operator +(List<T> other) {
|
||||
return _list + other;
|
||||
}
|
||||
|
||||
@override
|
||||
T operator [](int index) => _list[index];
|
||||
|
||||
@override
|
||||
void operator []=(int index, T value) {
|
||||
_list[index] = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void add(T value) {
|
||||
_list.add(value);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void addAll(Iterable<T> iterable) {
|
||||
_list.addAll(iterable);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
bool any(bool Function(T element) test) => _list.any(test);
|
||||
|
||||
@override
|
||||
Map<int, T> asMap() => _list.asMap();
|
||||
|
||||
@override
|
||||
List<R> cast<R>() => _list.cast<R>();
|
||||
|
||||
@override
|
||||
void clear() {
|
||||
_list.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
bool contains(Object? element) => _list.contains(element);
|
||||
|
||||
@override
|
||||
T elementAt(int index) => _list.elementAt(index);
|
||||
|
||||
@override
|
||||
bool every(bool Function(T element) test) => _list.every(test);
|
||||
|
||||
@override
|
||||
Iterable<R> expand<R>(Iterable<R> f(T element)) => _list.expand<R>(f);
|
||||
|
||||
@override
|
||||
void fillRange(int start, int end, [T? fillValue]) =>
|
||||
_list.fillRange(start, end, fillValue);
|
||||
|
||||
@override
|
||||
T firstWhere(bool Function(T element) test, {T Function()? orElse}) =>
|
||||
_list.firstWhere(test, orElse: orElse);
|
||||
|
||||
@override
|
||||
R fold<R>(R initialValue, R Function(R previousValue, T element) combine) =>
|
||||
_list.fold(initialValue, combine);
|
||||
|
||||
@override
|
||||
Iterable<T> followedBy(Iterable<T> other) => _list.followedBy(other);
|
||||
|
||||
@override
|
||||
void forEach(void Function(T element) f) => _list.forEach(f);
|
||||
|
||||
@override
|
||||
Iterable<T> getRange(int start, int end) => _list.getRange(start, end);
|
||||
|
||||
@override
|
||||
int indexOf(T element, [int start = 0]) => _list.indexOf(element, start);
|
||||
|
||||
@override
|
||||
int indexWhere(bool Function(T element) test, [int start = 0]) =>
|
||||
_list.indexWhere(test, start);
|
||||
|
||||
@override
|
||||
void insert(int index, T element) {
|
||||
_list.insert(index, element);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void insertAll(int index, Iterable<T> iterable) {
|
||||
_list.insertAll(index, iterable);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
bool get isEmpty => _list.isEmpty;
|
||||
|
||||
@override
|
||||
bool get isNotEmpty => _list.isNotEmpty;
|
||||
|
||||
@override
|
||||
Iterator<T> get iterator => _list.iterator;
|
||||
|
||||
@override
|
||||
String join([String separator = ""]) => _list.join(separator);
|
||||
|
||||
@override
|
||||
int lastIndexOf(T element, [int? start]) => _list.lastIndexOf(element, start);
|
||||
|
||||
@override
|
||||
int lastIndexWhere(bool Function(T element) test, [int? start]) =>
|
||||
_list.lastIndexWhere(test, start);
|
||||
|
||||
@override
|
||||
T lastWhere(bool Function(T element) test, {T Function()? orElse}) =>
|
||||
_list.lastWhere(test, orElse: orElse);
|
||||
|
||||
@override
|
||||
Iterable<R> map<R>(R Function(T e) f) => _list.map<R>(f);
|
||||
|
||||
@override
|
||||
T reduce(T Function(T value, T element) combine) => _list.reduce(combine);
|
||||
|
||||
@override
|
||||
bool remove(Object? value) {
|
||||
final result = _list.remove(value);
|
||||
if (result) notifyListeners();
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
T removeAt(int index) {
|
||||
final result = _list.removeAt(index);
|
||||
notifyListeners();
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
T removeLast() {
|
||||
final result = _list.removeLast();
|
||||
notifyListeners();
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
void removeRange(int start, int end) {
|
||||
_list.removeRange(start, end);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void removeWhere(bool Function(T element) test) {
|
||||
_list.removeWhere(test);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void replaceRange(int start, int end, Iterable<T> replacement) {
|
||||
_list.replaceRange(start, end, replacement);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Replaces entire contents with [replacement] (single notification).
|
||||
void replaceWith(Iterable<T> replacement) {
|
||||
_list.clear();
|
||||
_list.addAll(replacement);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Swaps underlying list reference with [list] and notifies.
|
||||
void swap(List<T> list) {
|
||||
_list = list;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void retainWhere(bool Function(T element) test) {
|
||||
_list.retainWhere(test);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
Iterable<T> get reversed => _list.reversed;
|
||||
|
||||
@override
|
||||
void setAll(int index, Iterable<T> iterable) {
|
||||
_list.setAll(index, iterable);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void setRange(int start, int end, Iterable<T> iterable, [int skipCount = 0]) {
|
||||
_list.setRange(start, end, iterable, skipCount);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void shuffle([Random? random]) {
|
||||
_list.shuffle(random);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
T get single => _list.single;
|
||||
|
||||
@override
|
||||
T singleWhere(bool Function(T element) test, {T Function()? orElse}) =>
|
||||
_list.singleWhere(test, orElse: orElse);
|
||||
|
||||
@override
|
||||
Iterable<T> skip(int count) => _list.skip(count);
|
||||
|
||||
@override
|
||||
Iterable<T> skipWhile(bool Function(T value) test) => _list.skipWhile(test);
|
||||
|
||||
@override
|
||||
void sort([int Function(T a, T b)? compare]) {
|
||||
_list.sort(compare);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
List<T> sublist(int start, [int? end]) => _list.sublist(start, end);
|
||||
|
||||
@override
|
||||
Iterable<T> take(int count) => _list.take(count);
|
||||
|
||||
@override
|
||||
Iterable<T> takeWhile(bool Function(T value) test) => _list.takeWhile(test);
|
||||
|
||||
@override
|
||||
List<T> toList({bool growable = true}) => _list.toList(growable: growable);
|
||||
|
||||
@override
|
||||
Set<T> toSet() => _list.toSet();
|
||||
|
||||
@override
|
||||
Iterable<T> where(bool Function(T element) test) => _list.where(test);
|
||||
|
||||
@override
|
||||
Iterable<R> whereType<R>() => _list.whereType<R>();
|
||||
|
||||
@override
|
||||
List<T> get value => _list;
|
||||
}
|
||||
5
lib/src/core/presenter.dart
Normal file
5
lib/src/core/presenter.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
mixin Presenter {
|
||||
Widget buildPresenter(BuildContext context);
|
||||
}
|
||||
49
lib/src/core/publisher.dart
Normal file
49
lib/src/core/publisher.dart
Normal file
@@ -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<AppEvent>();
|
||||
/// bus.on<SignedIn>((e) => print('Hello \\${e.user}'));
|
||||
/// bus.publish(SignedIn('Alice'));
|
||||
/// ```
|
||||
/// {@end-tool}
|
||||
class Publisher<T> with Dispose {
|
||||
Publisher() {
|
||||
_subscriptions.clear.disposeBy(this);
|
||||
}
|
||||
|
||||
final List<ValueSetter<T>> _subscriptions = [];
|
||||
|
||||
/// Adds a raw subscription returning a removal callback.
|
||||
VoidCallback subscribe(ValueSetter callback) {
|
||||
_subscriptions.add(callback);
|
||||
return () => _subscriptions.remove(callback);
|
||||
}
|
||||
|
||||
/// Publishes an event to all current subscribers.
|
||||
void publish(T msg) {
|
||||
for (final subscription in _subscriptions.toList()) {
|
||||
subscription(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// Filters events of subtype [E]. Optional immediate [callback] plus a
|
||||
/// returned [Emitter] for builder/listener integration.
|
||||
Emitter on<E extends T>([ValueSetter<E>? callback]) {
|
||||
final notifier = PublicEmitter();
|
||||
subscribe((e) {
|
||||
if (e is E) {
|
||||
callback?.call(e);
|
||||
notifier.notifyListeners();
|
||||
}
|
||||
}).disposeBy(notifier);
|
||||
return notifier;
|
||||
}
|
||||
}
|
||||
24
lib/src/core/range.dart
Normal file
24
lib/src/core/range.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
class Range {
|
||||
Range(this.a, this.b) : assert(a <= b);
|
||||
|
||||
double a;
|
||||
double b;
|
||||
|
||||
bool contains(double y) => a <= y && y <= b;
|
||||
|
||||
bool intersects(double x0, double x1) {
|
||||
return contains(x0) || contains(x1) || (x0 < a && b < x1);
|
||||
}
|
||||
|
||||
Range shift(double offset) {
|
||||
a += offset;
|
||||
b += offset;
|
||||
return this;
|
||||
}
|
||||
|
||||
Range inflate(double extent) {
|
||||
a -= extent;
|
||||
b += extent;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
7
lib/src/core/store/async_init.dart
Normal file
7
lib/src/core/store/async_init.dart
Normal file
@@ -0,0 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// Optional mixin for services needing asynchronous post-construction setup.
|
||||
mixin AsyncInit {
|
||||
/// Performs initialisation; awaited automatically for lazy registrations.
|
||||
FutureOr<void> init();
|
||||
}
|
||||
16
lib/src/core/store/factory/instance_store_factory.dart
Normal file
16
lib/src/core/store/factory/instance_store_factory.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:ux/src/core/store/factory/store_factory.dart';
|
||||
|
||||
/// Factory wrapping a pre-instantiated singleton.
|
||||
class InstanceStoreFactory<T> extends StoreFactory<T> {
|
||||
InstanceStoreFactory(T value) : _value = value;
|
||||
|
||||
final T _value;
|
||||
|
||||
Future<T> get future async => _value;
|
||||
|
||||
@override
|
||||
T get instance => _value;
|
||||
|
||||
@override
|
||||
String toString() => 'Instance: $T ${instance.runtimeType}';
|
||||
}
|
||||
37
lib/src/core/store/factory/lazy_store_factory.dart
Normal file
37
lib/src/core/store/factory/lazy_store_factory.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:ux/src/core/core.dart';
|
||||
|
||||
/// Lazily creates & caches an instance (supports [AsyncInit]).
|
||||
class LazyStoreFactory<T> extends StoreFactory<T> {
|
||||
LazyStoreFactory({
|
||||
required this.resolver,
|
||||
required this.delegate,
|
||||
});
|
||||
|
||||
final ResolverCreateDelegate<T> delegate;
|
||||
final Resolver resolver;
|
||||
Completer<T>? _completer;
|
||||
T? _instance;
|
||||
|
||||
Future<T> get future async {
|
||||
if (_completer == null) {
|
||||
_completer = Completer<T>();
|
||||
final instance = await delegate(resolver);
|
||||
if (instance is AsyncInit) {
|
||||
await instance.init();
|
||||
}
|
||||
_instance = instance;
|
||||
_completer!.complete(instance);
|
||||
}
|
||||
return _completer!.future;
|
||||
}
|
||||
|
||||
@override
|
||||
T get instance => _instance == null
|
||||
? throw Exception('Service not initialized: $T')
|
||||
: _instance!;
|
||||
|
||||
@override
|
||||
String toString() => 'Lazy $T ${_instance?.runtimeType}';
|
||||
}
|
||||
16
lib/src/core/store/factory/store_factory.dart
Normal file
16
lib/src/core/store/factory/store_factory.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
/// Decorator for [StoreFactory]
|
||||
/// It will emit a new instance each time it's requested
|
||||
/// Note: This is not an enforcement, only a hint
|
||||
/// Marker mixin indicating a factory yields transient (non-cached) instances.
|
||||
mixin TransientFactory {}
|
||||
|
||||
abstract class StoreFactory<T> {
|
||||
/// Future for async resolution / warm-up path.
|
||||
Future<T> get future;
|
||||
|
||||
/// Synchronous instance access (may throw if not ready for lazy types).
|
||||
T get instance;
|
||||
|
||||
/// Passes strongly typed instance to [fn] while retaining generic [T].
|
||||
R pipeInstance<R>(R Function<T>(T instance) fn) => fn<T>(instance);
|
||||
}
|
||||
20
lib/src/core/store/factory/transient_store_factory.dart
Normal file
20
lib/src/core/store/factory/transient_store_factory.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:ux/src/core/core.dart';
|
||||
|
||||
/// Always produces a fresh instance on access.
|
||||
class TransientStoreFactory<T> extends StoreFactory<T> with TransientFactory {
|
||||
TransientStoreFactory({
|
||||
required this.locator,
|
||||
required this.delegate,
|
||||
});
|
||||
|
||||
final LocatorCreateDelegate<T> delegate;
|
||||
final Locator locator;
|
||||
|
||||
Future<T> get future async => instance;
|
||||
|
||||
@override
|
||||
T get instance => delegate(locator);
|
||||
|
||||
@override
|
||||
String toString() => 'Transient $T';
|
||||
}
|
||||
117
lib/src/core/store/store.dart
Normal file
117
lib/src/core/store/store.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'package:ux/src/core/store/factory/instance_store_factory.dart';
|
||||
import 'package:ux/src/core/store/factory/lazy_store_factory.dart';
|
||||
import 'package:ux/src/core/store/factory/store_factory.dart';
|
||||
import 'package:ux/src/core/store/factory/transient_store_factory.dart';
|
||||
|
||||
export 'package:ux/src/core/store/factory/instance_store_factory.dart';
|
||||
export 'package:ux/src/core/store/factory/lazy_store_factory.dart';
|
||||
export 'package:ux/src/core/store/factory/store_factory.dart';
|
||||
export 'package:ux/src/core/store/factory/transient_store_factory.dart';
|
||||
|
||||
typedef LocatorCreateDelegate<T> = T Function(Locator e);
|
||||
typedef ResolverCreateDelegate<T> = Future<T> Function(Resolver e);
|
||||
typedef FactoryDelegate<T> = StoreFactory<T> Function(Resolver e);
|
||||
|
||||
/// Sync locator
|
||||
mixin Locator {
|
||||
/// Returns a synchronously available instance (throws if not initialised
|
||||
/// or registered; lazy entries must be warmed by [Register.init]).
|
||||
T get<T>();
|
||||
}
|
||||
|
||||
/// Async locator (mainly to enable lazy async)
|
||||
mixin Resolver {
|
||||
/// Resolves (and initialises if lazy) a registered service asynchronously.
|
||||
Future<T> resolve<T>();
|
||||
}
|
||||
|
||||
mixin Register on Resolver, Locator {
|
||||
Map<Type, StoreFactory> registry = {};
|
||||
|
||||
/// Activates registered factories (except [TransientFactory])
|
||||
/// This will enable a safe use of [Locator.get]
|
||||
Future<void> init() async {
|
||||
await Future.wait<void>(
|
||||
registry.values.whereType<LazyStoreFactory>().map((e) => e.future),
|
||||
eagerError: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// Registers a pre‑built singleton [instance].
|
||||
void add<T>(T instance) => addFactory((e) => InstanceStoreFactory(instance));
|
||||
|
||||
/// Registers a custom factory.
|
||||
void addFactory<T>(FactoryDelegate<T> factory) => registry[T] = factory(this);
|
||||
|
||||
/// Registers a lazily created, cached async/sync singleton.
|
||||
void addLazy<T>(ResolverCreateDelegate<T> delegate) => addFactory(
|
||||
(e) => LazyStoreFactory<T>(resolver: this, delegate: delegate),
|
||||
);
|
||||
|
||||
/// Registers a transient factory (new instance each request).
|
||||
void addTransient<T>(LocatorCreateDelegate<T> delegate) => addFactory(
|
||||
(e) => TransientStoreFactory<T>(locator: this, delegate: delegate),
|
||||
);
|
||||
}
|
||||
|
||||
/// Concrete store implementing locator + resolver behaviour.
|
||||
///
|
||||
/// {@tool snippet}
|
||||
/// ```dart
|
||||
/// final store = Store()
|
||||
/// ..add(Logger())
|
||||
/// ..addLazy<Config>((r) async => Config())
|
||||
/// ..addTransient<DateTime>((l) => DateTime.now());
|
||||
///
|
||||
/// await store.init(); // warm lazy singletons
|
||||
/// final logger = store.get<Logger>(); // sync
|
||||
/// final config = await store.resolve<Config>(); // async
|
||||
/// ```
|
||||
/// {@end-tool}
|
||||
class Store with Locator, Resolver, Register {
|
||||
@override
|
||||
Future<T> resolve<T>() async {
|
||||
final entry = registry[T];
|
||||
if (entry == null) {
|
||||
throw Exception('$T is not registered (Store)');
|
||||
}
|
||||
final instance = await (entry.future as Future<T>);
|
||||
return instance;
|
||||
}
|
||||
|
||||
@override
|
||||
T get<T>() {
|
||||
final entry = registry[T];
|
||||
if (entry == null) {
|
||||
throw Exception('$T is not registered (Store)');
|
||||
}
|
||||
return entry.instance as T;
|
||||
}
|
||||
|
||||
T? tryGet<T>() {
|
||||
try {
|
||||
return get<T>();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ScopedLocator with Locator {
|
||||
ScopedLocator(this.parent);
|
||||
|
||||
Locator parent;
|
||||
|
||||
final Map<Type, StoreFactory> _registry = {};
|
||||
|
||||
@override
|
||||
T get<T>() => _registry[T]?.instance ?? parent.get<T>();
|
||||
|
||||
void add<T>(T instance) {
|
||||
_registry[T] = InstanceStoreFactory(instance);
|
||||
}
|
||||
}
|
||||
|
||||
extension LocatorExtension on Locator {
|
||||
ScopedLocator scoped() => ScopedLocator(this);
|
||||
}
|
||||
30
lib/src/core/store/store_provider.dart
Normal file
30
lib/src/core/store/store_provider.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:ux/src/core/core.dart';
|
||||
|
||||
/// Provides a [Store] to the widget tree without creating an inherited
|
||||
/// dependency (safe for `initState` reads).
|
||||
///
|
||||
/// Useful extension to be added.
|
||||
/// extension StoreProviderExtension on BuildContext {
|
||||
// /// Shortcut for `StoreProvider.of(context).store.get<T>()`.
|
||||
// T get<T>() => StoreProvider.of(this).store.get<T>();
|
||||
// }
|
||||
class StoreProvider extends StatelessWidget {
|
||||
const StoreProvider({
|
||||
required this.store,
|
||||
required this.builder,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
final Store store;
|
||||
final WidgetBuilder builder;
|
||||
|
||||
static StoreProvider of(BuildContext context) {
|
||||
final result = context.findAncestorWidgetOfExactType<StoreProvider>();
|
||||
assert(result != null, 'StoreProvider not found');
|
||||
return result!;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Builder(builder: builder);
|
||||
}
|
||||
103
lib/src/core/subscription.dart
Normal file
103
lib/src/core/subscription.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'emitter.dart';
|
||||
import 'functional.dart';
|
||||
|
||||
/// A subscription to multiple listenables that notifies when any of them
|
||||
/// change. Optionally, a selector can be provided to only notify when the
|
||||
/// selected value changes.
|
||||
class Subscription with Emitter {
|
||||
/// Adds a listenable to the subscription.
|
||||
/// Registers [listenable]. Optional [select] narrows change detection;
|
||||
/// [when] gates notifications. Returns this for chaining.
|
||||
Subscription add<T extends Listenable>(
|
||||
T listenable, {
|
||||
/// - [select]->[R] can be used to only notify when [R] changes
|
||||
Object? Function(T)? select,
|
||||
|
||||
/// - [when] can be used to only notify if [true]
|
||||
Predicate<T>? when,
|
||||
}) {
|
||||
var callback = notifyListeners;
|
||||
if (select != null) {
|
||||
callback = callback.pipe(
|
||||
(e) {
|
||||
var oldSelect = select(listenable);
|
||||
return () {
|
||||
final newSelect = select(listenable);
|
||||
if (newSelect != oldSelect) {
|
||||
oldSelect = newSelect;
|
||||
e();
|
||||
}
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
if (when != null) {
|
||||
callback = callback.pipe(
|
||||
(e) => () {
|
||||
if (when(listenable)) {
|
||||
e();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
listenable.subscribe(callback).disposeBy(this);
|
||||
|
||||
//ignore: avoid_returning_this
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a [Subscription] to listen to multiple listenables and rebuilds the
|
||||
/// widget tree when any of them change.
|
||||
///
|
||||
/// The [register] callback is used to specify the listenables to subscribe to.
|
||||
class SubscriptionBuilder extends StatefulWidget {
|
||||
const SubscriptionBuilder({
|
||||
required this.register,
|
||||
required this.builder,
|
||||
this.child,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// Registers listenables given a [Subscription]
|
||||
final ValueSetter<Subscription> register;
|
||||
final TransitionBuilder builder;
|
||||
final Widget? child;
|
||||
|
||||
@override
|
||||
State<SubscriptionBuilder> createState() => _SubscriptionBuilderState();
|
||||
}
|
||||
|
||||
class _SubscriptionBuilderState extends State<SubscriptionBuilder> {
|
||||
late Subscription subscription;
|
||||
|
||||
void register() {
|
||||
subscription = Subscription()
|
||||
..pipe(widget.register)
|
||||
..subscribe(() => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
register();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant SubscriptionBuilder oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
subscription.dispose();
|
||||
register();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
subscription.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.builder(context, widget.child);
|
||||
}
|
||||
154
lib/src/core/tasks.dart
Normal file
154
lib/src/core/tasks.dart
Normal file
@@ -0,0 +1,154 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'dispose.dart';
|
||||
import 'emitter.dart';
|
||||
|
||||
/// Token allowing async work to cooperatively check for cancellation.
|
||||
abstract class CancellationToken {
|
||||
/// if(isCancelled) throw [TaskCancelledException]
|
||||
void ensureRunning();
|
||||
|
||||
bool get isCancelled;
|
||||
}
|
||||
|
||||
typedef TaskDelegate = Future<void> Function();
|
||||
typedef CancellableTaskDelegate = Future<void> Function(
|
||||
CancellationToken token);
|
||||
typedef TypedTaskDelegate<T> = Future<T> Function();
|
||||
|
||||
/// Thrown when a queued task is cancelled (explicitly or during disposal).
|
||||
class TaskCancelledException implements Exception {}
|
||||
|
||||
class _Task<T> implements CancellationToken {
|
||||
_Task(
|
||||
this.task, {
|
||||
this.onDone,
|
||||
}) : _isCancelled = false;
|
||||
|
||||
final CancellableTaskDelegate task;
|
||||
final VoidCallback? onDone;
|
||||
bool _isCancelled;
|
||||
|
||||
bool get isCancelled => _isCancelled;
|
||||
|
||||
final _completer = Completer<T?>();
|
||||
|
||||
void done() {
|
||||
onDone?.call();
|
||||
}
|
||||
|
||||
void cancel() {
|
||||
_isCancelled = true;
|
||||
}
|
||||
|
||||
void complete([FutureOr<T?> value]) {
|
||||
_completer.complete(value);
|
||||
done();
|
||||
}
|
||||
|
||||
void completeError(Object error) {
|
||||
_completer.completeError(error);
|
||||
done();
|
||||
}
|
||||
|
||||
Future<void> run() => task.call(this);
|
||||
|
||||
Future<T?> get future => _completer.future;
|
||||
|
||||
@override
|
||||
void ensureRunning() {
|
||||
if (isCancelled) {
|
||||
throw TaskCancelledException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [Tasks] mixin provides an easy way to sequence async work
|
||||
/// Eg:
|
||||
/// ...
|
||||
/// enqueue(() async { await Future.delayed(Duration(seconds: 10)); print('10 secs'); });
|
||||
/// enqueue(() async { await Future.delayed(Duration(seconds: 10)); print('1 sec'); });
|
||||
/// Output:
|
||||
/// 10 secs
|
||||
/// 1 sec
|
||||
/// Provides a FIFO async task queue (single concurrency) with cancellation.
|
||||
mixin Tasks on Dispose {
|
||||
final _queue = <_Task<void>>[];
|
||||
|
||||
/// Enqueues [task] for execution and return the completion future
|
||||
/// Throws: [TaskCancelledException]
|
||||
@nonVirtual
|
||||
Future<void> enqueue(TaskDelegate task) => enqueueCancellable((_) => task());
|
||||
|
||||
/// Enqueues [task] for execution and return the completion future
|
||||
/// Also provides the [CancellationToken] to the closure
|
||||
/// Throws: [TaskCancelledException]
|
||||
@nonVirtual
|
||||
Future<void> enqueueCancellable(CancellableTaskDelegate task) {
|
||||
if (!_isActive) {
|
||||
_isActive = true;
|
||||
disposeTasks.disposeBy(this);
|
||||
}
|
||||
|
||||
final completer = _Task<void>(task);
|
||||
_queue.add(completer);
|
||||
_dequeue();
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
/// Provides a future that will complete when the current queue completes
|
||||
/// Throws: [TaskCancelledException]
|
||||
Future<void> waitIdle() => enqueue(() async {});
|
||||
|
||||
/// Cancels all enqueued tasks
|
||||
@nonVirtual
|
||||
void cancelTasks() {
|
||||
_queue.forEach((e) => e.cancel());
|
||||
}
|
||||
|
||||
bool _isActive = false;
|
||||
bool _isClosed = false;
|
||||
int _runningTasks = 0;
|
||||
final int _maxConcurrentTasks = 1;
|
||||
|
||||
/// Concurrently running tasks count
|
||||
int get runningTasks => _runningTasks;
|
||||
|
||||
Future<void> _dequeue() async {
|
||||
if (_isClosed || _runningTasks >= _maxConcurrentTasks || _queue.isEmpty) {
|
||||
return;
|
||||
}
|
||||
_runningTasks++;
|
||||
|
||||
while (!_isClosed && _queue.isNotEmpty) {
|
||||
final task = _queue.removeAt(0);
|
||||
if (task.isCancelled) {
|
||||
task.completeError(TaskCancelledException());
|
||||
} else {
|
||||
try {
|
||||
await task.run();
|
||||
if (_isClosed || task.isCancelled) {
|
||||
task.completeError(TaskCancelledException());
|
||||
} else {
|
||||
task.complete();
|
||||
}
|
||||
} catch (e) {
|
||||
task.completeError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (_isClosed && _queue.isNotEmpty) {
|
||||
_queue.removeAt(0).completeError(TaskCancelledException());
|
||||
}
|
||||
|
||||
_runningTasks--;
|
||||
}
|
||||
|
||||
@protected
|
||||
@nonVirtual
|
||||
void disposeTasks() {
|
||||
_isClosed = true;
|
||||
}
|
||||
}
|
||||
200
lib/src/core/uri.dart
Normal file
200
lib/src/core/uri.dart
Normal file
@@ -0,0 +1,200 @@
|
||||
import 'dart:developer' as dev;
|
||||
|
||||
/// We might have different variants for a slug, that can be localized - canonized
|
||||
/// We expect the [UriCanonicalConverter] to return the local domain form of the slug
|
||||
/// returns null when no match
|
||||
typedef UriCanonicalConverter = String? Function(String slug);
|
||||
|
||||
/// Iterates configured [UriMap] patterns attempting to build an [Out] result.
|
||||
///
|
||||
/// {@tool snippet}
|
||||
/// ```dart
|
||||
/// final parser = UriParser<String, void>(
|
||||
/// routes: [
|
||||
/// UriMap('/users/{id:#}', (m) => 'User ' + m.pathParameters['id']!),
|
||||
/// UriMap.many(['/posts/{slug:w}', '/blog/{slug:w}'],
|
||||
/// (m) => 'Post ' + m.pathParameters['slug']!),
|
||||
/// ],
|
||||
/// );
|
||||
/// final result = parser.parse(Uri.parse('/users/42'), null); // User 42
|
||||
/// ```
|
||||
/// {@end-tool}
|
||||
class UriParser<Out, State> {
|
||||
UriParser({
|
||||
this.routes = const [],
|
||||
this.canonical = const {},
|
||||
});
|
||||
|
||||
final List<UriMap> routes;
|
||||
final Map<String, UriCanonicalConverter> canonical;
|
||||
|
||||
/// Returns parsed domain object or `null` if no route matches.
|
||||
Out? parse(Uri url, State state) {
|
||||
for (final route in routes) {
|
||||
for (final template in route.matchers) {
|
||||
final match = template.match(url.path);
|
||||
if (match != null) {
|
||||
var isMatch = true;
|
||||
if (match.keys.any(canonical.containsKey)) {
|
||||
for (final entry in match.entries.toList()) {
|
||||
final canonicalValue = canonical[entry.key]?.call(entry.value);
|
||||
// not part of the canonical set
|
||||
if (canonicalValue == null) {
|
||||
isMatch = false;
|
||||
break;
|
||||
}
|
||||
match[entry.key] = canonicalValue;
|
||||
}
|
||||
if (!isMatch) continue;
|
||||
}
|
||||
try {
|
||||
final result = route.builder(UriMatch(url, match, state));
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
} catch (e, s) {
|
||||
// exceptions are treated as non-matches
|
||||
dev.log('''
|
||||
UriParser.parse failed to build:
|
||||
url: $url
|
||||
pattern: ${template.pattern}
|
||||
error: $e
|
||||
stack: $s
|
||||
''');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
typedef UriMapBuilder<Out, State> = Out? Function(UriMatch<State> match);
|
||||
|
||||
/// Matched values & context provided to a [UriMapBuilder].
|
||||
class UriMatch<State> {
|
||||
UriMatch(
|
||||
this.uri,
|
||||
this.pathParameters,
|
||||
this.state,
|
||||
);
|
||||
|
||||
final Uri uri;
|
||||
final Map<String, String> pathParameters;
|
||||
final State state;
|
||||
|
||||
Map<String, String> get queryParameters => uri.queryParameters;
|
||||
}
|
||||
|
||||
/// Associates one (or many) path patterns with a builder.
|
||||
class UriMap<Out, State> {
|
||||
UriMap(
|
||||
String pattern,
|
||||
this.builder, {
|
||||
bool matchEnd = true,
|
||||
}) : matchers = [PathMatcher(pattern, matchEnd: matchEnd)];
|
||||
|
||||
UriMap.many(
|
||||
List<String> patterns,
|
||||
this.builder, {
|
||||
bool matchEnd = true,
|
||||
}) : matchers =
|
||||
patterns.map((e) => PathMatcher(e, matchEnd: matchEnd)).toList();
|
||||
|
||||
final List<PathMatcher> matchers;
|
||||
final UriMapBuilder builder;
|
||||
}
|
||||
|
||||
/// Starts with `/path/` and has a field name word
|
||||
/// `/path/{name}`
|
||||
/// Starts with `/path/` followed by a number
|
||||
/// `/path/{number:#}`
|
||||
/// Field regex
|
||||
/// # - number
|
||||
/// w - word
|
||||
/// * - anything
|
||||
/// Compiles a path pattern with named fields into a matching regex.
|
||||
class PathMatcher {
|
||||
PathMatcher(
|
||||
this.pattern, {
|
||||
// match end [$]
|
||||
bool matchEnd = true,
|
||||
}) {
|
||||
var index = 0;
|
||||
final regex = StringBuffer();
|
||||
final fieldRegex = RegExp(r'{(?<field>([*]|(\w+)?(:[.*+#]+)?)+)}');
|
||||
final fieldMatches = fieldRegex.allMatches(pattern);
|
||||
|
||||
for (final fieldMatch in fieldMatches) {
|
||||
if (index < fieldMatch.start) {
|
||||
regex.write(pattern.substring(index, fieldMatch.start));
|
||||
}
|
||||
|
||||
final group = fieldMatch.namedGroup('field');
|
||||
if (group == null) {
|
||||
throw ArgumentError('Invalid pattern: $pattern', 'pattern');
|
||||
}
|
||||
final fieldRegexStart = group.indexOf(':');
|
||||
var fieldName = group;
|
||||
var fieldRegex = '';
|
||||
if (fieldRegexStart != -1) {
|
||||
fieldName = group.substring(0, fieldRegexStart);
|
||||
if (fieldRegexStart < group.length) {
|
||||
fieldRegex = group.substring(fieldRegexStart + 1);
|
||||
}
|
||||
}
|
||||
|
||||
String reg;
|
||||
switch (fieldRegex) {
|
||||
case '':
|
||||
reg = '([-_]|\\w)+';
|
||||
case '*':
|
||||
reg = '.+';
|
||||
case '#':
|
||||
reg = '\\d+';
|
||||
case 'w':
|
||||
reg = '\\w+';
|
||||
default:
|
||||
reg = fieldRegex;
|
||||
}
|
||||
|
||||
if (fieldName.isEmpty) {
|
||||
regex.write(reg);
|
||||
} else if (fieldName == '*') {
|
||||
regex.write('.*');
|
||||
} else {
|
||||
fields.add(fieldName);
|
||||
regex.write('(?<$fieldName>$reg)');
|
||||
}
|
||||
index = fieldMatch.end;
|
||||
}
|
||||
|
||||
if (index < pattern.length) {
|
||||
regex.write(pattern.substring(index));
|
||||
}
|
||||
if (matchEnd) regex.write('\$');
|
||||
pathTemplate = RegExp(regex.toString());
|
||||
}
|
||||
|
||||
/// Returns null if [pathTemplate] doesn't match the [path]
|
||||
Map<String, String>? match(String path) {
|
||||
final match = pathTemplate.firstMatch(path);
|
||||
if (match == null || path != pathTemplate.stringMatch(path)) {
|
||||
return null;
|
||||
}
|
||||
final map = fields.fold(
|
||||
<String, String>{},
|
||||
(p, e) => p..[e] = match.namedGroup(e)!,
|
||||
);
|
||||
return fields.every(map.containsKey) ? map : null;
|
||||
}
|
||||
|
||||
late final RegExp pathTemplate;
|
||||
final String pattern;
|
||||
final Set<String> fields = {};
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return '$pathTemplate => (${fields.join(',')})';
|
||||
}
|
||||
}
|
||||
56
lib/src/core/value.dart
Normal file
56
lib/src/core/value.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/animation.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'dispose.dart';
|
||||
import 'emitter.dart';
|
||||
|
||||
mixin Value<T> {
|
||||
set value(T value);
|
||||
|
||||
T get value;
|
||||
}
|
||||
|
||||
class DelegatedValue<T> with Dispose, Value<T> {
|
||||
DelegatedValue({required this.setter, required this.getter});
|
||||
|
||||
final ValueSetter<T> setter;
|
||||
final ValueGetter<T> getter;
|
||||
|
||||
@override
|
||||
set value(T value) => setter(value);
|
||||
|
||||
@override
|
||||
T get value => getter();
|
||||
}
|
||||
|
||||
extension ValueAnimationControllerExtension on AnimationController {
|
||||
DelegatedValue<double> delegate() {
|
||||
return DelegatedValue<double>(
|
||||
setter: (e) => value = e,
|
||||
getter: () => value,
|
||||
);
|
||||
}
|
||||
|
||||
// creates a two way link with a ValueEmitter
|
||||
Disposable link(ValueEmitter<double> other) {
|
||||
final it = this;
|
||||
final diposer = Dispose();
|
||||
|
||||
void intoOther() {
|
||||
if (other.value == it.value) return;
|
||||
other.value = it.value;
|
||||
}
|
||||
|
||||
void intoIt() {
|
||||
if (other.value == it.value) return;
|
||||
it.value = other.value;
|
||||
}
|
||||
|
||||
it.subscribe(intoOther).disposeBy(diposer);
|
||||
other.subscribe(intoIt).disposeBy(diposer);
|
||||
|
||||
intoIt();
|
||||
|
||||
return diposer;
|
||||
}
|
||||
}
|
||||
29
lib/src/core/widget.dart
Normal file
29
lib/src/core/widget.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
extension GlobalKeyExtension on GlobalKey {
|
||||
Offset? get position {
|
||||
final context = currentContext;
|
||||
if (context == null) return null;
|
||||
final box = context.findRenderObject() as RenderBox;
|
||||
final pos = box.localToGlobal(Offset.zero);
|
||||
return pos;
|
||||
}
|
||||
|
||||
Size? get size {
|
||||
final context = currentContext;
|
||||
if (context == null) return null;
|
||||
final box = context.findRenderObject() as RenderBox;
|
||||
return box.size;
|
||||
}
|
||||
|
||||
Rect? rect({Offset offset = Offset.zero}) {
|
||||
final context = currentContext;
|
||||
if (context == null) return null;
|
||||
final box = context.findRenderObject() as RenderBox;
|
||||
return box.localToGlobal(offset) & box.size;
|
||||
}
|
||||
}
|
||||
|
||||
extension SliverExtension on Widget {
|
||||
Widget sliver({Key? key}) => SliverToBoxAdapter(key: key, child: this);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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<UxPickedFile?> pick({
|
||||
static Future<XPickedFile?> pick({
|
||||
List<String>? mimeTypes,
|
||||
}) async {
|
||||
final result = await _channel.invokeMapMethod<String, Object?>('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<UxVideoThumbnail?> videoThumbnail({
|
||||
static Future<XVideoThumbnail?> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<UxGalleryPermission> permission();
|
||||
Future<UxGalleryPermission> requestPermission();
|
||||
/// `ux/lib/testing.dart`'s `FakeXGalleryBackend`).
|
||||
abstract class XGalleryBackend {
|
||||
Future<XGalleryPermission> permission();
|
||||
Future<XGalleryPermission> requestPermission();
|
||||
Future<void> openSettings();
|
||||
Future<void> presentLimitedLibraryPicker();
|
||||
Future<List<UxAlbum>> albums({UxAssetKind? filter});
|
||||
Future<List<UxAsset>> assets({
|
||||
Future<List<XAlbum>> albums({XAssetKind? filter});
|
||||
Future<List<XAsset>> assets({
|
||||
String? albumId,
|
||||
UxAssetKind? filter,
|
||||
XAssetKind? filter,
|
||||
required int start,
|
||||
required int end,
|
||||
});
|
||||
Future<UxAssetThumbnail> thumbnail(String assetId, {required int sizePx});
|
||||
Future<XAssetThumbnail> thumbnail(String assetId, {required int sizePx});
|
||||
Future<io.File> resolveFile(String assetId);
|
||||
|
||||
/// 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<UxGalleryPermission> permission() => backend.permission();
|
||||
static Future<XGalleryPermission> permission() => backend.permission();
|
||||
|
||||
static Future<UxGalleryPermission> requestPermission() =>
|
||||
static Future<XGalleryPermission> requestPermission() =>
|
||||
backend.requestPermission();
|
||||
|
||||
static Future<void> 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<List<UxAlbum>> albums({UxAssetKind? filter}) =>
|
||||
static Future<List<XAlbum>> 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<List<UxAsset>> assets({
|
||||
static Future<List<XAsset>> 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<UxAssetThumbnail> thumbnail(
|
||||
static Future<XAssetThumbnail> thumbnail(
|
||||
String assetId, {
|
||||
required int sizePx,
|
||||
}) =>
|
||||
@@ -164,10 +164,10 @@ class UxGallery {
|
||||
static Stream<void> get libraryChanges => backend.libraryChanges;
|
||||
}
|
||||
|
||||
/// Default [UxGalleryBackend] — dispatches to native code via the
|
||||
/// Default [XGalleryBackend] — dispatches to native code via the
|
||||
/// `ux/gallery` method channel. Public so test code can reinstall it
|
||||
/// 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<void> 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<Object?, Object?> m) => UxAsset(
|
||||
static XAsset _parseAsset(Map<Object?, Object?> 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<Object?, Object?> m) => UxAlbum(
|
||||
static XAlbum _parseAlbum(Map<Object?, Object?> 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<UxGalleryPermission> permission() async {
|
||||
Future<XGalleryPermission> permission() async {
|
||||
final s = await _channel.invokeMethod<String>('permission');
|
||||
return _parsePermission(s);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UxGalleryPermission> requestPermission() async {
|
||||
Future<XGalleryPermission> requestPermission() async {
|
||||
final s = await _channel.invokeMethod<String>('requestPermission');
|
||||
return _parsePermission(s);
|
||||
}
|
||||
@@ -240,7 +240,7 @@ class MethodChannelGalleryBackend implements UxGalleryBackend {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<UxAlbum>> albums({UxAssetKind? filter}) async {
|
||||
Future<List<XAlbum>> albums({XAssetKind? filter}) async {
|
||||
final list = await _channel.invokeMethod<List<Object?>>(
|
||||
'albums',
|
||||
{'filter': _kindArg(filter)},
|
||||
@@ -252,9 +252,9 @@ class MethodChannelGalleryBackend implements UxGalleryBackend {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<UxAsset>> assets({
|
||||
Future<List<XAsset>> assets({
|
||||
String? albumId,
|
||||
UxAssetKind? filter,
|
||||
XAssetKind? filter,
|
||||
required int start,
|
||||
required int end,
|
||||
}) async {
|
||||
@@ -274,7 +274,7 @@ class MethodChannelGalleryBackend implements UxGalleryBackend {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UxAssetThumbnail> thumbnail(
|
||||
Future<XAssetThumbnail> 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(),
|
||||
|
||||
@@ -95,11 +95,11 @@ double _inverseLerp(List<double> 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');
|
||||
|
||||
|
||||
156
lib/src/navi/hero.dart
Normal file
156
lib/src/navi/hero.dart
Normal file
@@ -0,0 +1,156 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A shared-element widget that animates between two positions
|
||||
/// when the [ScreenStack] pushes or pops a screen.
|
||||
///
|
||||
/// Place [ScreenHero] widgets with the same [tag] on different screens.
|
||||
/// When a transition occurs, the hero "flies" from the old position
|
||||
/// to the new one inside an [Overlay].
|
||||
class ScreenHero extends StatefulWidget {
|
||||
const ScreenHero({
|
||||
super.key,
|
||||
required this.tag,
|
||||
required this.child,
|
||||
this.createRectTween,
|
||||
});
|
||||
|
||||
/// Identifier used to match heroes across pages.
|
||||
final Object tag;
|
||||
|
||||
/// The widget to display (and animate during flight).
|
||||
final Widget child;
|
||||
|
||||
/// Optional custom tween for the Rect interpolation.
|
||||
final CreateRectTween? createRectTween;
|
||||
|
||||
@override
|
||||
State<ScreenHero> createState() => ScreenHeroState();
|
||||
}
|
||||
|
||||
class ScreenHeroState extends State<ScreenHero> {
|
||||
Size? _placeholderSize;
|
||||
|
||||
void startFlight() {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
if (box == null || !box.hasSize) return;
|
||||
setState(() => _placeholderSize = box.size);
|
||||
}
|
||||
|
||||
void endFlight() {
|
||||
if (!mounted) return;
|
||||
setState(() => _placeholderSize = null);
|
||||
}
|
||||
|
||||
Rect? get globalRect {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
if (box == null || !box.hasSize) return null;
|
||||
return MatrixUtils.transformRect(
|
||||
box.getTransformTo(null),
|
||||
Offset.zero & box.size,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_placeholderSize != null) {
|
||||
return SizedBox.fromSize(size: _placeholderSize);
|
||||
}
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
||||
/// Registry + flight controller, owned by [ScreenStackState].
|
||||
class ScreenHeroController {
|
||||
final Map<Object, ScreenHeroState> _heroes = {};
|
||||
|
||||
void register(Object tag, ScreenHeroState state) => _heroes[tag] = state;
|
||||
void unregister(Object tag, ScreenHeroState state) {
|
||||
if (_heroes[tag] == state) _heroes.remove(tag);
|
||||
}
|
||||
|
||||
/// Snapshot current hero Rects. Call before the page list changes.
|
||||
Map<Object, Rect> snapshot() {
|
||||
final result = <Object, Rect>{};
|
||||
for (final entry in _heroes.entries) {
|
||||
final rect = entry.value.globalRect;
|
||||
if (rect != null) result[entry.key] = rect;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Start flights for any heroes whose position changed between
|
||||
/// [before] snapshot and the current state.
|
||||
void maybeStartFlights({
|
||||
required Map<Object, Rect> before,
|
||||
required Animation<double> animation,
|
||||
required OverlayState overlay,
|
||||
}) {
|
||||
for (final tag in before.keys) {
|
||||
final hero = _heroes[tag];
|
||||
if (hero == null) continue;
|
||||
final fromRect = before[tag]!;
|
||||
final toRect = hero.globalRect;
|
||||
if (toRect == null) continue;
|
||||
if (fromRect == toRect) continue;
|
||||
|
||||
_startFlight(
|
||||
tag: tag,
|
||||
hero: hero,
|
||||
fromRect: fromRect,
|
||||
toRect: toRect,
|
||||
animation: animation,
|
||||
overlay: overlay,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _startFlight({
|
||||
required Object tag,
|
||||
required ScreenHeroState hero,
|
||||
required Rect fromRect,
|
||||
required Rect toRect,
|
||||
required Animation<double> animation,
|
||||
required OverlayState overlay,
|
||||
}) {
|
||||
hero.startFlight();
|
||||
|
||||
final createTween = hero.widget.createRectTween;
|
||||
final rectTween = createTween != null
|
||||
? createTween(fromRect, toRect)
|
||||
: RectTween(begin: fromRect, end: toRect);
|
||||
|
||||
final child = hero.widget.child;
|
||||
OverlayEntry? entry;
|
||||
|
||||
void onEnd(AnimationStatus status) {
|
||||
if (status == AnimationStatus.completed ||
|
||||
status == AnimationStatus.dismissed) {
|
||||
entry?.remove();
|
||||
entry = null;
|
||||
hero.endFlight();
|
||||
animation.removeStatusListener(onEnd);
|
||||
}
|
||||
}
|
||||
|
||||
animation.addStatusListener(onEnd);
|
||||
|
||||
entry = OverlayEntry(
|
||||
builder: (context) => AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, _) {
|
||||
final rect = rectTween.evaluate(animation);
|
||||
if (rect == null) return const SizedBox.shrink();
|
||||
return Positioned(
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
child: IgnorePointer(child: child),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
overlay.insert(entry!);
|
||||
}
|
||||
}
|
||||
385
lib/src/navi/router.dart
Normal file
385
lib/src/navi/router.dart
Normal file
@@ -0,0 +1,385 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:ux/src/core/dispose.dart';
|
||||
import 'package:ux/src/core/emitter.dart';
|
||||
import 'package:ux/src/core/functional.dart';
|
||||
import 'package:ux/src/core/list_emitter.dart';
|
||||
import 'package:ux/src/core/store/store.dart';
|
||||
import 'package:ux/src/core/uri.dart';
|
||||
import 'package:ux/src/navi/screen_host.dart';
|
||||
import 'package:ux/src/navi/screen_stack.dart';
|
||||
import 'package:ux/src/navi/screen.dart';
|
||||
|
||||
/// Generic stack-based screen router.
|
||||
///
|
||||
/// Manages a [home] screen and a [stack] of pushed screens, rendering them
|
||||
/// via [ScreenStack]. Subclass and override [pages] to add guards
|
||||
/// (e.g. auth checks) before the default screen list.
|
||||
class XRouter extends BackButtonDispatcher
|
||||
with Dispose, ScreenShell
|
||||
implements ScreenHost {
|
||||
XRouter({required this.home, this.routeParser}) {
|
||||
home.parentHost = this;
|
||||
_updateActive();
|
||||
}
|
||||
|
||||
final Screen home;
|
||||
final RouteInformationParser<Object>? routeParser;
|
||||
late final stack = ListEmitter<Screen>()..disposeBy(this);
|
||||
late final delegate = XRouterDelegate(router: this);
|
||||
late final backDispatcher = XRouterBack(router: this);
|
||||
|
||||
/// The screen most recently fired `onActive`. Used to de-duplicate and
|
||||
/// pair active/inactive transitions when the topmost visible screen
|
||||
/// changes.
|
||||
Screen? _active;
|
||||
|
||||
/// Recomputes the topmost visible screen and fires onInactive/onActive
|
||||
/// when it has changed. Topmost is the last non-popped entry in [pages],
|
||||
/// which already expands ScreenShell hosting. Also re-applies the
|
||||
/// topmost screen's [Screen.supportedOrientations] to the OS, mirroring
|
||||
/// UIKit's per-VC `supportedInterfaceOrientations` unwind on pop.
|
||||
void _updateActive() {
|
||||
Screen? top;
|
||||
for (final page in pages.reversed) {
|
||||
if (!page.popped) {
|
||||
top = page;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (identical(top, _active)) return;
|
||||
_active?.onInactive();
|
||||
_active = top;
|
||||
top?.onActive();
|
||||
SystemChrome.setPreferredOrientations(
|
||||
(top?.supportedOrientations ?? DeviceOrientation.values).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Screen? get currentConfiguration => stack.lastOrNull ?? home;
|
||||
|
||||
Iterable<Screen> _expand(Screen page) sync* {
|
||||
yield page;
|
||||
if (page is ScreenShell) {
|
||||
for (final screen in (page as ScreenShell).pages) {
|
||||
if (!screen.popped) yield screen;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<Screen> get pages => [
|
||||
..._expand(home),
|
||||
for (final page in stack) ..._expand(page),
|
||||
];
|
||||
|
||||
late final _overlayEntry = OverlayEntry(builder: _buildContent);
|
||||
|
||||
bool get canPop {
|
||||
if (stack.isNotEmpty) return true;
|
||||
if (home is ScreenShell) {
|
||||
for (final screen in (home as ScreenShell).pages) {
|
||||
if (!screen.popped) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _updateSystemBack() {
|
||||
SystemNavigator.setFrameworkHandlesBack(canPop);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context) {
|
||||
// [pages] (not the [home]/[stack] fields) so subclasses can
|
||||
// override for gating — e.g. swap home to an auth screen.
|
||||
final p = pages;
|
||||
return MediaQuery.removeViewInsets(
|
||||
context: context,
|
||||
removeBottom: true,
|
||||
child: Material(
|
||||
child: NotificationListener<NavigationNotification>(
|
||||
onNotification: (_) => true, // absorb — router manages system back directly
|
||||
child: ScreenStack(
|
||||
home: p.first,
|
||||
stack: p.skip(1).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget build(BuildContext context) {
|
||||
_overlayEntry.markNeedsBuild();
|
||||
return Overlay(initialEntries: [_overlayEntry]);
|
||||
}
|
||||
|
||||
void popAll() {
|
||||
while (stack.isNotEmpty) {
|
||||
stack.last.pop();
|
||||
}
|
||||
if (home is ScreenShell) {
|
||||
for (final screen in (home as ScreenShell).pages.toList()) {
|
||||
screen.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Push [page] onto the router.
|
||||
///
|
||||
/// If an `==`-equal page is already mounted (shell-hosted by [home] or
|
||||
/// anywhere on [stack]), pops every stack entry above it and returns
|
||||
/// that existing page's future — the caller awaits until the mounted
|
||||
/// page is popped. No duplicate is created, no fresh instance is
|
||||
/// mounted. For identity-equal screens (no `==` override), this dedup
|
||||
/// path is unreachable; behavior matches the pre-dedup semantics.
|
||||
///
|
||||
/// The type parameter `T` on the awaiter may differ from the mounted
|
||||
/// page's original `T`; the returned future is cast. Pushing an equal
|
||||
/// page of a different `T` is undefined.
|
||||
Future<T?> push<T>(Screen<T> page) async {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
if (home == page) {
|
||||
popAll();
|
||||
return SynchronousFuture(null);
|
||||
}
|
||||
|
||||
final existing = _findMounted(page);
|
||||
if (existing != null) {
|
||||
while (stack.isNotEmpty && !identical(stack.last, existing)) {
|
||||
stack.last.pop();
|
||||
}
|
||||
delegate.notifyListeners();
|
||||
_updateActive();
|
||||
return existing.future as Future<T?>;
|
||||
}
|
||||
|
||||
final top = stack.lastOrNull ?? home;
|
||||
if (top case final ScreenShell host when host.accept(page)) {
|
||||
page.parentHost = this;
|
||||
page.detach = () {
|
||||
delegate.notifyListeners();
|
||||
_updateActive();
|
||||
};
|
||||
page.removed = () {
|
||||
host.remove(page);
|
||||
Dispose.object(page);
|
||||
};
|
||||
page.onPush();
|
||||
delegate.notifyListeners();
|
||||
_updateActive();
|
||||
return page.future;
|
||||
}
|
||||
|
||||
page.parentHost = this;
|
||||
page.detach = () {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
stack.removeWhere((p) => identical(p, page));
|
||||
delegate.notifyListeners();
|
||||
_updateActive();
|
||||
};
|
||||
page.removed = () => Dispose.object(page);
|
||||
page.onPush();
|
||||
stack.add(page);
|
||||
|
||||
delegate.notifyListeners();
|
||||
_updateActive();
|
||||
return page.future;
|
||||
}
|
||||
|
||||
/// Find an already-mounted screen equal to [page], skipping popped
|
||||
/// entries. Scans shell-hosted children of [home] first, then the
|
||||
/// router stack bottom→top.
|
||||
Screen? _findMounted(Screen page) {
|
||||
if (home is ScreenShell) {
|
||||
for (final hosted in (home as ScreenShell).pages) {
|
||||
if (hosted.popped) continue;
|
||||
if (identical(hosted, page) || hosted == page) return hosted;
|
||||
}
|
||||
}
|
||||
for (final entry in stack) {
|
||||
if (entry.popped) continue;
|
||||
if (identical(entry, page) || entry == page) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> setNewRoutePath(Screen? configuration) {
|
||||
if (configuration == null || configuration == home) {
|
||||
popAll();
|
||||
return SynchronousFuture(null);
|
||||
}
|
||||
return push(configuration);
|
||||
}
|
||||
}
|
||||
|
||||
class XRouterBack extends RootBackButtonDispatcher {
|
||||
XRouterBack({required this.router});
|
||||
|
||||
final XRouter router;
|
||||
|
||||
@override
|
||||
Future<bool> didPopRoute() async {
|
||||
for (final page in router.stack.reversed) {
|
||||
if (page.handleBack()) return true;
|
||||
}
|
||||
if (router.home.handleBack()) return true;
|
||||
final page = router.stack.lastOrNull;
|
||||
if (page != null) {
|
||||
page.pop();
|
||||
return true;
|
||||
}
|
||||
return super.didPopRoute();
|
||||
}
|
||||
}
|
||||
|
||||
class XRouterDelegate extends RouterDelegate<Screen> with Emitter {
|
||||
XRouterDelegate({required this.router});
|
||||
|
||||
final XRouter router;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => router.build(context);
|
||||
|
||||
@override
|
||||
Future<bool> popRoute() => router.backDispatcher.didPopRoute();
|
||||
|
||||
@override
|
||||
Future<void> setNewRoutePath(Screen? configuration) async =>
|
||||
router.setNewRoutePath(configuration);
|
||||
|
||||
@override
|
||||
void notifyListeners() {
|
||||
super.notifyListeners();
|
||||
router._updateSystemBack();
|
||||
}
|
||||
|
||||
@override
|
||||
Screen? get currentConfiguration => router.currentConfiguration;
|
||||
}
|
||||
|
||||
/// Mixin for screens that can host other screens (e.g. a tab host showing
|
||||
/// a detail screen alongside the tab bar).
|
||||
///
|
||||
/// When a screen is pushed, the router asks the current top screen's shell
|
||||
/// whether to [accept] it. If accepted, the screen is managed by the shell
|
||||
/// instead of the router's main stack.
|
||||
mixin ScreenShell {
|
||||
/// Whether this shell wants to own [screen]. Return `true` to intercept
|
||||
/// the push — the screen will not go onto the router stack.
|
||||
bool accept(Screen screen) => false;
|
||||
|
||||
/// Called after a hosted screen's exit animation completes.
|
||||
void remove(Screen screen) {}
|
||||
|
||||
/// The screens currently hosted by this shell. The router flattens
|
||||
/// these into the [ScreenStack] alongside the main stack.
|
||||
Iterable<Screen> get pages => const [];
|
||||
}
|
||||
|
||||
/// Mixin for screens that support deep-linking via a URL.
|
||||
mixin Deeplink<T> on Screen<T> {
|
||||
String get restoreUrl;
|
||||
}
|
||||
|
||||
/// A [RouteInformationParser] that converts URIs into [Screen]s
|
||||
/// using a [UriParser], and restores URLs from [Deeplink] screens.
|
||||
class XRouteParser extends RouteInformationParser<Object> {
|
||||
XRouteParser({required this.parser, this.normalize});
|
||||
|
||||
final UriParser<Screen, dynamic> parser;
|
||||
final Uri Function(Uri)? normalize;
|
||||
|
||||
Screen? parse(Uri? uri) {
|
||||
if (uri == null) return null;
|
||||
if (normalize != null) uri = normalize!(uri);
|
||||
return parser.parse(uri, null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Object> parseRouteInformation(RouteInformation routeInformation) async {
|
||||
return parse(routeInformation.uri) as Object;
|
||||
}
|
||||
|
||||
@override
|
||||
RouteInformation? restoreRouteInformation(Object configuration) {
|
||||
return (configuration is Deeplink ? configuration.restoreUrl : null)
|
||||
?.pipe(Uri.tryParse)
|
||||
?.pipe((uri) => RouteInformation(uri: uri));
|
||||
}
|
||||
}
|
||||
|
||||
/// The root widget for a ux app.
|
||||
///
|
||||
/// Resolves a [XRouter] from the [Store] and sets up theming,
|
||||
/// localization, scroll behavior, and back-button dispatching —
|
||||
/// without pulling in Flutter's [Navigator] or [MaterialApp].
|
||||
class XApp extends StatelessWidget {
|
||||
const XApp({
|
||||
super.key,
|
||||
required this.router,
|
||||
this.title = '',
|
||||
this.theme,
|
||||
this.debugShowCheckedModeBanner = true,
|
||||
this.localizationsDelegates,
|
||||
this.supportedLocales = const [Locale('en')],
|
||||
this.scrollBehavior,
|
||||
this.overlayKey,
|
||||
});
|
||||
|
||||
final XRouter router;
|
||||
final String title;
|
||||
final ThemeData? theme;
|
||||
final bool debugShowCheckedModeBanner;
|
||||
final Iterable<LocalizationsDelegate<dynamic>>? localizationsDelegates;
|
||||
final Iterable<Locale> supportedLocales;
|
||||
final ScrollBehavior? scrollBehavior;
|
||||
final GlobalKey<OverlayState>? overlayKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final data = theme ?? ThemeData();
|
||||
|
||||
return Title(
|
||||
title: title,
|
||||
color: data.primaryColor,
|
||||
child: MediaQuery.fromView(
|
||||
view: View.of(context),
|
||||
child: Localizations(
|
||||
locale: supportedLocales.first,
|
||||
delegates: [
|
||||
...?localizationsDelegates,
|
||||
DefaultMaterialLocalizations.delegate,
|
||||
DefaultWidgetsLocalizations.delegate,
|
||||
],
|
||||
child: AnimatedTheme(
|
||||
data: data,
|
||||
child: ScrollConfiguration(
|
||||
behavior: scrollBehavior ?? const MaterialScrollBehavior(),
|
||||
child: Overlay(
|
||||
key: overlayKey,
|
||||
initialEntries: [
|
||||
OverlayEntry(
|
||||
builder: (_) => Shortcuts(
|
||||
shortcuts: WidgetsApp.defaultShortcuts,
|
||||
child: Actions(
|
||||
actions: WidgetsApp.defaultActions,
|
||||
child: DefaultTextEditingShortcuts(
|
||||
child: Router(
|
||||
routeInformationParser: router.routeParser,
|
||||
routerDelegate: router.delegate,
|
||||
backButtonDispatcher: router.backDispatcher,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
136
lib/src/navi/screen.dart
Normal file
136
lib/src/navi/screen.dart
Normal file
@@ -0,0 +1,136 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:ux/src/navi/screen_host.dart';
|
||||
import 'package:ux/src/navi/transitions.dart';
|
||||
|
||||
/// A full-screen destination that can be pushed onto a [ScreenStack]
|
||||
/// and managed by a [XRouter].
|
||||
///
|
||||
/// Provides its own content via [buildPresenter] and controls
|
||||
/// how it transitions in/out via [buildTransition].
|
||||
mixin Screen<T> {
|
||||
// === rendering ===
|
||||
|
||||
/// Identity tag used by [ScreenStack] to reconcile a `home` slot:
|
||||
/// same [runtimeType] + same [key] → same surface, slot State
|
||||
/// preserved. Has no effect on stack entries (diffed by `==`).
|
||||
Key? get key => null;
|
||||
|
||||
/// Build the page content.
|
||||
Widget buildPresenter(BuildContext context);
|
||||
|
||||
/// Wrap [child] (the output of [buildPresenter]) in a transition
|
||||
/// driven by [animation] (0→1 on push, 1→0 on pop).
|
||||
///
|
||||
/// Override to customise. The default is a platform slide.
|
||||
Widget buildTransition(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Widget child,
|
||||
) => ScreenTransitions.platformSlide(context, animation, child);
|
||||
|
||||
/// Duration of the enter/exit animation.
|
||||
Duration get transitionDuration => const Duration(milliseconds: 300);
|
||||
|
||||
/// Whether this screen is a modal (dialog, bottom sheet, etc.).
|
||||
bool get isModal => false;
|
||||
|
||||
/// Whether the iOS edge-swipe gesture should be enabled for this page.
|
||||
/// Set to `false` for bottom sheets and overlays that dismiss differently.
|
||||
bool get canSwipeBack => true;
|
||||
|
||||
/// Device orientations this screen supports while it is the topmost
|
||||
/// visible page. The router applies the topmost screen's value via
|
||||
/// [SystemChrome.setPreferredOrientations] on each active-screen
|
||||
/// change, mirroring UIKit's per-`UIViewController.supportedInterfaceOrientations`.
|
||||
/// Override to force portrait/landscape on a specific page (e.g. a camera).
|
||||
Iterable<DeviceOrientation> get supportedOrientations =>
|
||||
DeviceOrientation.values;
|
||||
|
||||
// === navigation ===
|
||||
|
||||
/// The host that pushed this screen — set by the host on push.
|
||||
/// Mirrors UIKit's `UIViewController.navigationController`.
|
||||
/// `null` until pushed.
|
||||
///
|
||||
/// Pages typically don't read this directly; use [host] instead,
|
||||
/// which is the right target for "push another page from here".
|
||||
ScreenHost? parentHost;
|
||||
|
||||
/// The host that pushes from inside this screen's body should
|
||||
/// target. Defaults to [parentHost] — regular screens push
|
||||
/// siblings onto their own host. `SheetScreen` overrides this to
|
||||
/// route into its own nested stack so pages pushed from inside a
|
||||
/// sheet body land within the sheet rather than escaping it.
|
||||
ScreenHost? get host => parentHost;
|
||||
|
||||
/// Whether the back button / swipe should pop this page.
|
||||
bool get canPop => true;
|
||||
|
||||
/// Custom back-button handling. Return `true` to consume the event.
|
||||
bool handleBack() => false;
|
||||
|
||||
/// Called after the page is pushed onto the router.
|
||||
void onPush() {}
|
||||
|
||||
/// Called when this screen becomes the topmost visible screen in the
|
||||
/// router. Fires once after push, and again whenever it becomes topmost
|
||||
/// after a screen above it pops. Always paired with a later [onInactive].
|
||||
void onActive() {}
|
||||
|
||||
/// Called when another screen is pushed on top, or right before this one
|
||||
/// is popped. Mirror of [onActive].
|
||||
void onInactive() {}
|
||||
|
||||
// === pop lifecycle ===
|
||||
|
||||
/// Whether this page has been popped (explicitly removed).
|
||||
bool popped = false;
|
||||
|
||||
/// Set by the router when pushing. Called during [pop] to remove the page
|
||||
/// from the router stack and notify the delegate.
|
||||
VoidCallback? detach;
|
||||
|
||||
/// Set by the router when pushing. Called from [onRemoved] after the exit
|
||||
/// animation completes for final cleanup (disposal).
|
||||
VoidCallback? removed;
|
||||
|
||||
Completer<T?>? _completer;
|
||||
|
||||
/// A future that completes with the result when the page is popped.
|
||||
Future<T?> get future {
|
||||
_completer ??= Completer<T?>();
|
||||
return _completer!.future;
|
||||
}
|
||||
|
||||
/// Remove this page. Marks as popped, detaches from the router,
|
||||
/// and completes the [future] with [result].
|
||||
void pop([T? result]) {
|
||||
popped = true;
|
||||
onDetach();
|
||||
if (_completer?.isCompleted != true) {
|
||||
_completer?.complete(result);
|
||||
}
|
||||
}
|
||||
|
||||
/// Called during [pop] before the future is completed.
|
||||
/// Override for cleanup that must happen before the exit animation.
|
||||
@mustCallSuper
|
||||
void onDetach() {
|
||||
detach?.call();
|
||||
detach = null;
|
||||
}
|
||||
|
||||
/// Called after the exit animation completes and the page is fully removed.
|
||||
/// Override for cleanup that should happen after the page is off screen.
|
||||
@mustCallSuper
|
||||
void onRemoved() {
|
||||
if (popped) {
|
||||
removed?.call();
|
||||
removed = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
15
lib/src/navi/screen_host.dart
Normal file
15
lib/src/navi/screen_host.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:ux/src/navi/screen.dart';
|
||||
|
||||
/// A target for [Screen]-level pushes. Implementations include
|
||||
/// [XRouter] (the global navigator) and `_SheetNestedHost` (the
|
||||
/// inner stack of a `SheetScreen`).
|
||||
///
|
||||
/// Mirrors UIKit's pattern where every `UIViewController` knows its
|
||||
/// `navigationController`: pages route via the host they were pushed
|
||||
/// onto rather than walking ancestor stacks or looking up global
|
||||
/// state.
|
||||
abstract class ScreenHost {
|
||||
/// Push [page] onto this host's stack. Future completes when
|
||||
/// [page] is popped (with the popped result, or `null` on dismiss).
|
||||
Future<R?> push<R>(Screen<R> page);
|
||||
}
|
||||
427
lib/src/navi/screen_stack.dart
Normal file
427
lib/src/navi/screen_stack.dart
Normal file
@@ -0,0 +1,427 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' show PredictiveBackEvent;
|
||||
|
||||
import 'package:ux/src/core/functional.dart';
|
||||
import 'package:ux/src/navi/hero.dart';
|
||||
import 'package:ux/src/navi/screen.dart';
|
||||
|
||||
/// A declarative screen stack with animated transitions.
|
||||
///
|
||||
/// Two slots with different reconciliation contracts:
|
||||
///
|
||||
/// - [home] — `Widget.canUpdate`-style match (`runtimeType` +
|
||||
/// [Screen.key]). A fresh instance of the same type/key updates the
|
||||
/// home slot in place; State below is preserved. Different type or
|
||||
/// key tears it down. Home never animates in/out, never pops.
|
||||
/// - [stack] — `==`-based diff. Equal Screens reorder, distinct
|
||||
/// Screens get new slots with enter transitions and pop animations.
|
||||
class ScreenStack extends StatefulWidget {
|
||||
const ScreenStack({
|
||||
super.key,
|
||||
required this.home,
|
||||
this.stack = const [],
|
||||
this.onRemoved,
|
||||
});
|
||||
|
||||
final Screen home;
|
||||
final List<Screen> stack;
|
||||
final ValueChanged<Screen>? onRemoved;
|
||||
|
||||
@override
|
||||
State<ScreenStack> createState() => ScreenStackState();
|
||||
|
||||
static ScreenStackState of(BuildContext context) {
|
||||
return context.findAncestorStateOfType<ScreenStackState>()!;
|
||||
}
|
||||
|
||||
static ScreenStackState? maybeOf(BuildContext context) {
|
||||
return context.findAncestorStateOfType<ScreenStackState>();
|
||||
}
|
||||
}
|
||||
|
||||
class ScreenStackState extends State<ScreenStack> with TickerProviderStateMixin {
|
||||
late ScreenSlot _homeSlot;
|
||||
final List<ScreenSlot> _entries = [];
|
||||
final ScreenHeroController heroController = ScreenHeroController();
|
||||
final FocusScopeNode _outerScope = FocusScopeNode(debugLabel: 'ScreenStack');
|
||||
|
||||
bool _canPop = false;
|
||||
ScreenSlot? _focusedSlot;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_homeSlot = _createHomeSlot(widget.home);
|
||||
for (final page in widget.stack) {
|
||||
_entries.add(_createEntry(page, animated: false));
|
||||
}
|
||||
_updateCanPop();
|
||||
_scheduleTopFocusHandoff();
|
||||
}
|
||||
|
||||
/// Hand focus to the current top slot's scope on the next frame.
|
||||
/// Mirrors what [Navigator] does via `setFirstFocus` on push: without
|
||||
/// it, a freshly-pushed page's `autofocus: true` TextField ends up
|
||||
/// focused but never becomes the active scope, so the keyboard token
|
||||
/// is never consumed and the system keyboard doesn't open.
|
||||
void _scheduleTopFocusHandoff() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final top = _topSlot;
|
||||
if (top == _focusedSlot) return;
|
||||
_focusedSlot = top;
|
||||
_outerScope.setFirstFocus(top.focusScope);
|
||||
});
|
||||
}
|
||||
|
||||
void _updateCanPop() {
|
||||
final canPop = _entries.any((e) => !e.removing);
|
||||
if (canPop == _canPop) return;
|
||||
_canPop = canPop;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) NavigationNotification(canHandlePop: canPop).dispatch(context);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ScreenStack oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_updateHome(oldWidget.home, widget.home);
|
||||
_diffStack(oldWidget.stack, widget.stack);
|
||||
}
|
||||
|
||||
void _updateHome(Screen oldHome, Screen newHome) {
|
||||
if (identical(oldHome, newHome)) return;
|
||||
if (oldHome.runtimeType == newHome.runtimeType &&
|
||||
oldHome.key == newHome.key) {
|
||||
_homeSlot.page = newHome;
|
||||
return;
|
||||
}
|
||||
final old = _homeSlot;
|
||||
old.controller.dispose();
|
||||
old.focusScope.dispose();
|
||||
if (identical(_focusedSlot, old)) _focusedSlot = null;
|
||||
_homeSlot = _createHomeSlot(newHome);
|
||||
_scheduleTopFocusHandoff();
|
||||
}
|
||||
|
||||
void _diffStack(List<Screen> oldStack, List<Screen> newStack) {
|
||||
final oldSet = <Screen>{...oldStack};
|
||||
final newSet = <Screen>{...newStack};
|
||||
|
||||
final hasChanges = oldStack.length != newStack.length ||
|
||||
oldSet.any((p) => !newSet.contains(p)) ||
|
||||
newSet.any((p) => !oldSet.contains(p));
|
||||
|
||||
// Snapshot only on actual mutation; otherwise every host-driven
|
||||
// rebuild would schedule a post-frame flight check.
|
||||
final heroSnapshot = hasChanges ? heroController.snapshot() : null;
|
||||
|
||||
for (final entry in _entries.toList()) {
|
||||
if (entry.removing) continue;
|
||||
if (!newSet.contains(entry.page)) {
|
||||
_removeEntry(entry);
|
||||
}
|
||||
}
|
||||
|
||||
for (final page in newStack) {
|
||||
if (!oldSet.contains(page)) {
|
||||
_entries.add(_createEntry(page, animated: true));
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder to match newStack, keeping removing entries in their
|
||||
// original relative position so an exiting child stays above its
|
||||
// parent, not behind it.
|
||||
final ordered = <ScreenSlot>[];
|
||||
for (final page in newStack) {
|
||||
ordered.add(_entries.firstWhere((e) => e.page == page && !e.removing));
|
||||
}
|
||||
final result = <ScreenSlot>[];
|
||||
int oi = 0;
|
||||
for (final entry in _entries) {
|
||||
if (entry.removing) {
|
||||
result.add(entry);
|
||||
} else if (oi < ordered.length) {
|
||||
result.add(ordered[oi++]);
|
||||
}
|
||||
}
|
||||
while (oi < ordered.length) {
|
||||
result.add(ordered[oi++]);
|
||||
}
|
||||
_entries
|
||||
..clear()
|
||||
..addAll(result);
|
||||
|
||||
if (heroSnapshot != null && heroSnapshot.isNotEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final topEntry = _entries.lastWhereOrNull((e) => !e.removing);
|
||||
if (topEntry == null) return;
|
||||
final overlay = Overlay.maybeOf(context);
|
||||
if (overlay == null) return;
|
||||
heroController.maybeStartFlights(
|
||||
before: heroSnapshot,
|
||||
animation: topEntry.controller,
|
||||
overlay: overlay,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
_updateCanPop();
|
||||
_scheduleTopFocusHandoff();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
ScreenSlot _createHomeSlot(Screen home) {
|
||||
final controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: Duration.zero,
|
||||
value: 1.0,
|
||||
);
|
||||
return ScreenSlot(page: home, controller: controller);
|
||||
}
|
||||
|
||||
ScreenSlot _createEntry(Screen page, {required bool animated}) {
|
||||
final controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: page.transitionDuration,
|
||||
value: animated ? 0.0 : 1.0,
|
||||
);
|
||||
final entry = ScreenSlot(page: page, controller: controller);
|
||||
if (animated) controller.forward();
|
||||
return entry;
|
||||
}
|
||||
|
||||
void _removeEntry(ScreenSlot entry) {
|
||||
entry.removing = true;
|
||||
entry.controller.reverse().then((_) {
|
||||
if (!mounted) return;
|
||||
_entries.remove(entry);
|
||||
entry.controller.dispose();
|
||||
entry.focusScope.dispose();
|
||||
if (_focusedSlot == entry) _focusedSlot = null;
|
||||
entry.page.onRemoved();
|
||||
widget.onRemoved?.call(entry.page);
|
||||
_scheduleTopFocusHandoff();
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_homeSlot.controller.dispose();
|
||||
_homeSlot.focusScope.dispose();
|
||||
for (final entry in _entries) {
|
||||
entry.controller.dispose();
|
||||
entry.focusScope.dispose();
|
||||
}
|
||||
_outerScope.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Topmost live slot — the last non-removing pushed entry if any,
|
||||
/// else home. Used for focus handoff.
|
||||
ScreenSlot get _topSlot =>
|
||||
_entries.lastWhereOrNull((e) => !e.removing) ?? _homeSlot;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final top = _entries.lastWhereOrNull((e) => !e.removing);
|
||||
return Actions(
|
||||
actions: {
|
||||
DismissIntent: CallbackAction<DismissIntent>(
|
||||
onInvoke: (_) {
|
||||
final focus = FocusManager.instance.primaryFocus;
|
||||
if (focus != null && focus.context != null &&
|
||||
focus.context!.findAncestorWidgetOfExactType<EditableText>() != null) {
|
||||
focus.unfocus();
|
||||
return null;
|
||||
}
|
||||
if (top != null && top.page.isModal) {
|
||||
top.page.pop();
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: FocusScope(
|
||||
node: _outerScope,
|
||||
child: Stack(
|
||||
fit: StackFit.passthrough,
|
||||
children: [
|
||||
// Anchored on the slot's GlobalKey so a slot replacement
|
||||
// unmounts the old subtree — without it, Flutter would
|
||||
// reconcile inner widgets by type and preserve their State.
|
||||
KeyedSubtree(key: _homeSlot.key, child: _homeSlot.build(context)),
|
||||
for (final entry in _entries)
|
||||
ScreenBackHandler(
|
||||
key: entry.key,
|
||||
entry: entry,
|
||||
enabled: entry == top && _canPop,
|
||||
onPop: () => entry.page.pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A slot in the [ScreenStack] that holds a [Screen] and manages its
|
||||
/// transition animation.
|
||||
class ScreenSlot {
|
||||
ScreenSlot({required this.page, required this.controller});
|
||||
|
||||
Screen page;
|
||||
final AnimationController controller;
|
||||
final key = GlobalKey();
|
||||
final contentKey = GlobalKey();
|
||||
// Each slot owns its own FocusScope so pushed pages start with a clean
|
||||
// focus state — the outer ScreenStack scope calls `setFirstFocus` on this
|
||||
// node when the slot becomes top, matching Navigator's per-route scope
|
||||
// handoff. Without per-slot scopes, a new page's `autofocus: true`
|
||||
// TextField silently no-ops (the outgoing page's focus still occupies the
|
||||
// shared scope's `_focusedChildren`), and without `setFirstFocus` the
|
||||
// keyboard token is never consumed so no keyboard appears.
|
||||
final focusScope = FocusScopeNode();
|
||||
bool removing = false;
|
||||
|
||||
Widget build(BuildContext context) {
|
||||
final child = page is Listenable
|
||||
? ListenableBuilder(
|
||||
listenable: page as Listenable,
|
||||
builder: (context, _) => page.buildPresenter(context),
|
||||
)
|
||||
: page.buildPresenter(context);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: controller,
|
||||
child: FocusScope(node: focusScope, child: child),
|
||||
builder: (context, child) =>
|
||||
page.buildTransition(context, controller, child!),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps the top screen entry and handles Android predictive back gestures.
|
||||
/// Drives the entry's [AnimationController] in response to gesture progress.
|
||||
class ScreenBackHandler extends StatefulWidget {
|
||||
const ScreenBackHandler({
|
||||
super.key,
|
||||
required this.entry,
|
||||
required this.enabled,
|
||||
required this.onPop,
|
||||
});
|
||||
|
||||
final ScreenSlot entry;
|
||||
final bool enabled;
|
||||
final VoidCallback onPop;
|
||||
|
||||
@override
|
||||
State<ScreenBackHandler> createState() => ScreenBackHandlerState();
|
||||
}
|
||||
|
||||
class ScreenBackHandlerState extends State<ScreenBackHandler>
|
||||
with WidgetsBindingObserver {
|
||||
bool _gestureInProgress = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
bool handleStartBackGesture(PredictiveBackEvent backEvent) {
|
||||
if (!widget.enabled || backEvent.isButtonEvent) return false;
|
||||
_gestureInProgress = true;
|
||||
widget.entry.controller.value = 1.0 - backEvent.progress;
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
void handleUpdateBackGestureProgress(PredictiveBackEvent backEvent) {
|
||||
if (!_gestureInProgress) return;
|
||||
widget.entry.controller.value = 1.0 - backEvent.progress;
|
||||
}
|
||||
|
||||
@override
|
||||
void handleCommitBackGesture() {
|
||||
if (!_gestureInProgress) return;
|
||||
_gestureInProgress = false;
|
||||
widget.onPop();
|
||||
}
|
||||
|
||||
@override
|
||||
void handleCancelBackGesture() {
|
||||
if (!_gestureInProgress) return;
|
||||
_gestureInProgress = false;
|
||||
widget.entry.controller.animateTo(1.0,
|
||||
duration: const Duration(milliseconds: 150), curve: Curves.easeOut);
|
||||
}
|
||||
|
||||
// ── iOS edge swipe ──
|
||||
|
||||
bool _swiping = false;
|
||||
double _swipeStart = 0;
|
||||
|
||||
static const _edgeWidth = 40.0;
|
||||
|
||||
void _onHorizontalDragUpdate(DragUpdateDetails d) {
|
||||
if (!widget.enabled) return;
|
||||
if (!_swiping) {
|
||||
_swiping = true;
|
||||
_swipeStart = 0;
|
||||
}
|
||||
final width = context.size?.width ?? 400;
|
||||
final progress = ((d.localPosition.dx - _swipeStart) / width).clamp(0.0, 1.0);
|
||||
widget.entry.controller.value = 1.0 - progress;
|
||||
}
|
||||
|
||||
void _onHorizontalDragEnd(DragEndDetails d) {
|
||||
if (!_swiping) return;
|
||||
_swiping = false;
|
||||
if (widget.entry.controller.value < 0.5 || d.velocity.pixelsPerSecond.dx > 300) {
|
||||
widget.onPop();
|
||||
} else {
|
||||
widget.entry.controller.animateTo(1.0,
|
||||
duration: const Duration(milliseconds: 150), curve: Curves.easeOut);
|
||||
}
|
||||
}
|
||||
|
||||
bool get _isIOSLike {
|
||||
final platform = Theme.of(context).platform;
|
||||
return platform == TargetPlatform.iOS || platform == TargetPlatform.macOS;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
fit: StackFit.passthrough,
|
||||
children: [
|
||||
widget.entry.build(context),
|
||||
// Left edge swipe strip — sits on top to catch horizontal drags
|
||||
// without competing with the page's scroll views.
|
||||
if (_isIOSLike && widget.enabled && widget.entry.page.canSwipeBack)
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: _edgeWidth,
|
||||
child: GestureDetector(
|
||||
onHorizontalDragUpdate: _onHorizontalDragUpdate,
|
||||
onHorizontalDragEnd: _onHorizontalDragEnd,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
182
lib/src/navi/sheet_screen.dart
Normal file
182
lib/src/navi/sheet_screen.dart
Normal file
@@ -0,0 +1,182 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:ux/src/core/dispose.dart';
|
||||
import 'package:ux/src/core/list_emitter.dart';
|
||||
import 'package:ux/src/navi/screen.dart';
|
||||
import 'package:ux/src/navi/screen_host.dart';
|
||||
import 'package:ux/src/navi/screen_stack.dart';
|
||||
import 'package:ux/src/navi/transitions.dart';
|
||||
import 'package:ux/src/anim/sheet.dart';
|
||||
|
||||
/// Screen mixin that presents its content as a half-sheet with full
|
||||
/// telegram-style physics (drag-to-expand, drag-to-dismiss with spring
|
||||
/// snap, scroll coordination via [SheetController]) and a nested
|
||||
/// navigation stack inside the modal — same pattern as telegram-iOS's
|
||||
/// `AttachmentController` hosting a `NavigationContainer`.
|
||||
///
|
||||
/// The screen-stack's transition animation is set to instant
|
||||
/// (`ScreenTransitions.none`) — the [Sheet] widget self-manages its
|
||||
/// own slide-up entry / exit using its internal animation controller.
|
||||
/// This keeps the route layer thin while preserving the full physics
|
||||
/// model from `~/projects/ciao/app/lib/widgets/sheet.dart`.
|
||||
///
|
||||
/// Inside the sheet, [host] resolves to this sheet's own
|
||||
/// [nestedHost], so `host?.push(SomePage())` from the body lands in
|
||||
/// a nested `ScreenStack` rendered inside the sheet's bounds (slides
|
||||
/// in from the right). The original parent host that pushed this
|
||||
/// sheet is reachable as [parentHost] for rare escape cases.
|
||||
///
|
||||
/// Typical use:
|
||||
///
|
||||
/// ```dart
|
||||
/// class MyPage with Screen<Result>, SheetScreen<Result>, Emitter {
|
||||
/// @override
|
||||
/// double get sheetCollapsedExtent => 0.55;
|
||||
///
|
||||
/// @override
|
||||
/// Widget buildSheetBody(BuildContext context) => /* page content */;
|
||||
/// }
|
||||
///
|
||||
/// // Pushing within the sheet:
|
||||
/// final picked = await host?.push(SubPage());
|
||||
/// ```
|
||||
mixin SheetScreen<T> on Screen<T> {
|
||||
/// Initial height as a fraction of the viewport (0.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<T> overrides ────────────────────────────────────────────
|
||||
|
||||
/// Pushes from inside this sheet body target the nested stack,
|
||||
/// not the parent host that pushed the sheet. Use [parentHost]
|
||||
/// (inherited from `Screen`) to escape the modal — rare.
|
||||
@override
|
||||
ScreenHost? get host => _nestedHost;
|
||||
|
||||
@override
|
||||
Widget buildTransition(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Widget child,
|
||||
) =>
|
||||
ScreenTransitions.none(context, animation, child);
|
||||
|
||||
/// Zero-duration route transition — the [Sheet] widget self-manages
|
||||
/// its slide-up entry via its own internal animation controller.
|
||||
@override
|
||||
Duration get transitionDuration => Duration.zero;
|
||||
|
||||
@override
|
||||
Widget buildPresenter(BuildContext context) {
|
||||
return Sheet(
|
||||
controller: sheetController,
|
||||
collapsedExtent: sheetCollapsedExtent,
|
||||
isFullSize: sheetIsFullSize,
|
||||
isDismissible: sheetIsDismissible,
|
||||
barrierColor: sheetBarrierColor,
|
||||
backgroundColor: sheetBackgroundColor,
|
||||
borderRadius: sheetBorderRadius,
|
||||
onDismiss: pop,
|
||||
// Fresh `_SheetBody` per rebuild is fine — `ScreenStack`
|
||||
// reconciles `home` by type+key.
|
||||
child: ListenableBuilder(
|
||||
listenable: _nestedHost.pages,
|
||||
builder: (context, _) => ScreenStack(
|
||||
home: _SheetBody(this),
|
||||
stack: [..._nestedHost.pages],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// System back / iOS edge swipe pops the innermost nested page
|
||||
/// first. With the nested stack empty, falls through to the
|
||||
/// default `handleBack` (which pops the sheet itself).
|
||||
@override
|
||||
bool handleBack() {
|
||||
if (_nestedHost.pages.isNotEmpty) {
|
||||
_nestedHost.pages.last.pop();
|
||||
return true;
|
||||
}
|
||||
return super.handleBack();
|
||||
}
|
||||
|
||||
@override
|
||||
void onRemoved() {
|
||||
super.onRemoved();
|
||||
// Tear down any still-mounted nested entries when the sheet is
|
||||
// dismissed (drag, backdrop tap, programmatic pop).
|
||||
for (final page in _nestedHost.pages.toList()) {
|
||||
page.pop();
|
||||
}
|
||||
_nestedHost.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _SheetNestedHost with Dispose implements ScreenHost {
|
||||
final pages = ListEmitter<Screen>();
|
||||
|
||||
@override
|
||||
Future<R?> push<R>(Screen<R> page) {
|
||||
page.parentHost = this;
|
||||
page.detach = () => pages.removeWhere((p) => identical(p, page));
|
||||
page.removed = () => Dispose.object(page);
|
||||
page.onPush();
|
||||
pages.add(page);
|
||||
return page.future;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
pages.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapter that exposes [SheetScreen.buildSheetBody] as the nested
|
||||
/// `ScreenStack`'s `home`.
|
||||
class _SheetBody with Screen<void> {
|
||||
_SheetBody(this.sheet);
|
||||
final SheetScreen sheet;
|
||||
|
||||
@override
|
||||
Widget buildPresenter(BuildContext context) =>
|
||||
sheet.buildSheetBody(context);
|
||||
}
|
||||
70
lib/src/navi/transitions.dart
Normal file
70
lib/src/navi/transitions.dart
Normal file
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Built-in transition builders for use in [Screen.buildTransition].
|
||||
abstract final class ScreenTransitions {
|
||||
/// Slide from right (push) / slide to right (pop).
|
||||
static Widget platformSlide(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Widget child,
|
||||
) {
|
||||
final position = Tween<Offset>(
|
||||
begin: const Offset(1, 0),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut));
|
||||
return SlideTransition(position: position, child: child);
|
||||
}
|
||||
|
||||
/// Simple opacity fade.
|
||||
static Widget fade(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Widget child,
|
||||
) {
|
||||
return FadeTransition(
|
||||
opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
/// Slide up from the bottom edge.
|
||||
static Widget slideUp(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Widget child,
|
||||
) {
|
||||
final position = Tween<Offset>(
|
||||
begin: const Offset(0, 1),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut));
|
||||
return SlideTransition(position: position, child: child);
|
||||
}
|
||||
|
||||
/// Fade on wide screens, slide on narrow. Stable widget tree — no
|
||||
/// unmount/remount when the screen width crosses the threshold.
|
||||
static Widget responsive(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Widget child, {
|
||||
double breakpoint = 600,
|
||||
}) {
|
||||
final wide = MediaQuery.sizeOf(context).width >= breakpoint;
|
||||
final curved = CurvedAnimation(parent: animation, curve: Curves.easeOut);
|
||||
final isExiting = animation.status == AnimationStatus.reverse;
|
||||
final position = Tween<Offset>(
|
||||
begin: wide ? Offset.zero : const Offset(1, 0),
|
||||
end: Offset.zero,
|
||||
).animate(curved);
|
||||
return FadeTransition(
|
||||
opacity: wide && !isExiting ? curved : const AlwaysStoppedAnimation(1.0),
|
||||
child: SlideTransition(position: position, child: child),
|
||||
);
|
||||
}
|
||||
|
||||
/// No animation — instant swap.
|
||||
static Widget none(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Widget child,
|
||||
) => child;
|
||||
}
|
||||
86
lib/src/reactive/future_reactive_builder.dart
Normal file
86
lib/src/reactive/future_reactive_builder.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:ux/src/reactive/reactive.dart';
|
||||
|
||||
/// Awaits an async creation of a [Reactive] and rebuilds on its changes.
|
||||
///
|
||||
/// {@tool snippet}
|
||||
/// Lazy async loading of a bloc.
|
||||
/// ```dart
|
||||
/// FutureReactiveBuilder<CounterState>(
|
||||
/// future: (ctx) async => CounterBloc()..increment(),
|
||||
/// builder: (_, state, __) => Text('Loaded: \\${state.count}'),
|
||||
/// );
|
||||
/// ```
|
||||
/// {@end-tool}
|
||||
class FutureReactiveBuilder<T> extends StatefulWidget {
|
||||
const FutureReactiveBuilder({
|
||||
required this.future,
|
||||
required this.builder,
|
||||
this.child,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
final Widget? child;
|
||||
|
||||
/// Async factory returning a [Reactive] instance.
|
||||
final Future<Reactive<T>> Function(BuildContext context) future;
|
||||
|
||||
/// Builder invoked with the current state value once ready.
|
||||
final Widget Function(BuildContext context, T state, Widget? child) builder;
|
||||
|
||||
@override
|
||||
State<FutureReactiveBuilder<T>> createState() => _FutureReactiveBuilderState<T>();
|
||||
}
|
||||
|
||||
class _FutureReactiveBuilderState<T> extends State<FutureReactiveBuilder<T>> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initBloc();
|
||||
}
|
||||
|
||||
Reactive<T>? _bloc;
|
||||
|
||||
Future<void> _initBloc() async {
|
||||
final bloc = await widget.future(context);
|
||||
bloc.addListener(_notify);
|
||||
_bloc = bloc;
|
||||
|
||||
// notify bloc is ready
|
||||
_notify();
|
||||
}
|
||||
|
||||
void _notify() => setState(() {});
|
||||
|
||||
void _disposeBloc() async {
|
||||
if (_bloc != null) {
|
||||
_bloc!
|
||||
..removeListener(_notify)
|
||||
..dispose();
|
||||
_bloc = null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(FutureReactiveBuilder<T> oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (oldWidget.future != widget.future ||
|
||||
oldWidget.builder != widget.builder ||
|
||||
oldWidget.child != widget.child) {
|
||||
_disposeBloc();
|
||||
_initBloc();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _bloc == null
|
||||
? SizedBox.shrink()
|
||||
: widget.builder(
|
||||
context,
|
||||
_bloc!.value,
|
||||
widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
57
lib/src/reactive/reactive.dart
Normal file
57
lib/src/reactive/reactive.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:ux/src/core/core.dart';
|
||||
import 'package:ux/src/reactive/reactive_builder.dart';
|
||||
|
||||
/// Base class for view models exposing a lazily built immutable state value.
|
||||
/// Use with [ListenableBuilder] / [ReactiveBuilder]. Call [notifyListeners]
|
||||
/// with an optional mutation callback to rebuild & emit only on value change.
|
||||
///
|
||||
/// {@tool snippet}
|
||||
/// Simple counter bloc using [Reactive] and consumed via [ReactiveBuilder].
|
||||
/// ```dart
|
||||
/// class CounterState { const CounterState(this.count); final int count; }
|
||||
/// class CounterBloc extends Reactive<CounterState> {
|
||||
/// int _count = 0;
|
||||
/// void increment() => notifyListeners(() => _count++);
|
||||
/// @override CounterState buildState() => CounterState(_count);
|
||||
/// }
|
||||
///
|
||||
/// Widget build(BuildContext context) => ReactiveBuilder<CounterBloc>(
|
||||
/// create: (_) => CounterBloc(),
|
||||
/// builder: (_, bloc, __) => Text('Count: \\${bloc.value.count}'),
|
||||
/// );
|
||||
/// ```
|
||||
/// {@end-tool}
|
||||
abstract class Reactive<T> with Emitter, Tasks implements ValueListenable<T> {
|
||||
/// The current value of the [Reactive].
|
||||
T? _value;
|
||||
|
||||
/// The current value of the [Reactive].
|
||||
/// If [value] is null, [buildState] is called to build the value.
|
||||
T get value => _value ??= buildState();
|
||||
|
||||
/// Override to build the derived state snapshot.
|
||||
@protected
|
||||
T buildState();
|
||||
|
||||
/// Runs optional [callback], rebuilds state; notifies only if new value
|
||||
/// differs (using `==`).
|
||||
@override
|
||||
@protected
|
||||
@mustCallSuper
|
||||
void notifyListeners() async {
|
||||
final oldValue = _value;
|
||||
_value = buildState();
|
||||
|
||||
if (oldValue != _value) {
|
||||
super.notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@mustCallSuper
|
||||
@override
|
||||
void dispose() {
|
||||
disposeTasks();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
146
lib/src/reactive/reactive_builder.dart
Normal file
146
lib/src/reactive/reactive_builder.dart
Normal file
@@ -0,0 +1,146 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:ux/src/core/core.dart';
|
||||
|
||||
/// Builder signature receiving the created/listened reactive object.
|
||||
typedef ReactiveBuilderDelegate<T extends Listenable> = Widget Function(
|
||||
BuildContext context,
|
||||
T bloc,
|
||||
Widget? child,
|
||||
);
|
||||
|
||||
typedef ReactiveBuilderUpdateDelegate<T extends Listenable> = void Function(
|
||||
BuildContext context,
|
||||
T bloc,
|
||||
);
|
||||
|
||||
/// Creation callback invoked once when no external value is provided.
|
||||
typedef ReactiveCreateDelegate<T extends Listenable> = T Function(
|
||||
BuildContext context,
|
||||
);
|
||||
|
||||
/// Creates (or uses provided) listenable and rebuilds on its notifications.
|
||||
///
|
||||
/// {@tool snippet}
|
||||
/// Basic usage with externally provided bloc instance.
|
||||
/// ```dart
|
||||
/// final bloc = CounterBloc();
|
||||
/// ReactiveBuilder.value(
|
||||
/// bloc,
|
||||
/// builder: (_, b, __) => Text('Count: \\${b.value.count}'),
|
||||
/// );
|
||||
/// ```
|
||||
/// {@end-tool}
|
||||
class ReactiveBuilder<T extends Listenable> extends StatefulWidget {
|
||||
const ReactiveBuilder({
|
||||
Key? key,
|
||||
required ReactiveCreateDelegate<T> create,
|
||||
required this.builder,
|
||||
this.child,
|
||||
this.onUpdate,
|
||||
}) : _value = null,
|
||||
_create = create,
|
||||
super(key: key);
|
||||
|
||||
const ReactiveBuilder.value(
|
||||
T value, {
|
||||
Key? key,
|
||||
required this.builder,
|
||||
this.child,
|
||||
this.onUpdate,
|
||||
}) : _value = value,
|
||||
_create = null,
|
||||
super(key: key);
|
||||
|
||||
/// Creates the listenable once; dependencies captured at creation time.
|
||||
final ReactiveCreateDelegate<T>? _create;
|
||||
|
||||
/// Externally-owned listenable; not disposed by this widget.
|
||||
final T? _value;
|
||||
|
||||
/// Builds subtree; invoked on each listenable change.
|
||||
final ReactiveBuilderDelegate<T> builder;
|
||||
|
||||
final Widget? child;
|
||||
|
||||
/// Delegate that gets called before every build.
|
||||
/// Useful to react to dependency changes before rendering.
|
||||
///
|
||||
/// In [onUpdate] it is allowed to trigger changes that cause a rebuild.
|
||||
/// If you watch/listen to provided dependencies inside the delegate,
|
||||
/// changes to any of those will cause [onUpdate] to be called,
|
||||
/// followed by rebuilding the subtree via calling [builder].
|
||||
/// It will not trigger a rebuild of the parent by itself.
|
||||
///
|
||||
/// Will also be called if the parent rebuilds for any reason, so you can
|
||||
/// also use it to react to externally controlled dependency changes.
|
||||
/// Will be called before the actual widget build.
|
||||
/// Can be used for explicitly watching provided values and marking the build
|
||||
/// dirty before the actual build happens.
|
||||
final ReactiveBuilderUpdateDelegate<T>? onUpdate;
|
||||
|
||||
@override
|
||||
_ReactiveBuilderState<T> createState() => _ReactiveBuilderState();
|
||||
}
|
||||
|
||||
class _ReactiveBuilderState<T extends Listenable>
|
||||
extends State<ReactiveBuilder<T>> {
|
||||
late T reactive;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initReactive();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeReactive(widget);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ReactiveBuilder<T> oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
if (oldWidget._value != widget._value) {
|
||||
_disposeReactive(oldWidget);
|
||||
_initReactive();
|
||||
_triggerBuild();
|
||||
}
|
||||
|
||||
_callOnUpdate();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_callOnUpdate();
|
||||
}
|
||||
|
||||
void _initReactive() {
|
||||
reactive = (widget._value ?? widget._create!(context))
|
||||
..addListener(_triggerBuild);
|
||||
}
|
||||
|
||||
void _disposeReactive(ReactiveBuilder<T> widget) {
|
||||
reactive.removeListener(_triggerBuild);
|
||||
if (reactive is Disposable && widget._value == null) {
|
||||
(reactive as Disposable).dispose();
|
||||
}
|
||||
}
|
||||
|
||||
void _callOnUpdate() {
|
||||
widget.onUpdate?.call(context, reactive);
|
||||
}
|
||||
|
||||
void _triggerBuild() => setState(() {});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.builder(
|
||||
context,
|
||||
reactive,
|
||||
widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<BarcodeFormat> formats;
|
||||
|
||||
@override
|
||||
State<UxScanner> createState() => _UxScannerState();
|
||||
State<XScanner> createState() => _XScannerState();
|
||||
}
|
||||
|
||||
class _UxScannerState extends State<UxScanner> {
|
||||
class _XScannerState extends State<XScanner> {
|
||||
static const _events = EventChannel('ux/scanner/events');
|
||||
StreamSubscription<dynamic>? _sub;
|
||||
|
||||
|
||||
@@ -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<DeviceOrientation> {
|
||||
_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();
|
||||
|
||||
@@ -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<UxAlbum> albums = const [],
|
||||
Map<String, List<UxAsset>> assetsByAlbum = const {},
|
||||
List<UxAsset> recents = const [],
|
||||
class FakeXGalleryBackend implements XGalleryBackend {
|
||||
FakeXGalleryBackend({
|
||||
this.permissionState = XGalleryPermission.granted,
|
||||
List<XAlbum> albums = const [],
|
||||
Map<String, List<XAsset>> assetsByAlbum = const {},
|
||||
List<XAsset> 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<UxAsset>.unmodifiable(v)),
|
||||
(k, v) => MapEntry(k, List<XAsset>.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<UxAlbum> _albums;
|
||||
final Map<String, List<UxAsset>> _assetsByAlbum;
|
||||
final List<UxAsset> _recents;
|
||||
final UxAssetThumbnail Function(String assetId, int sizePx) _thumbnailFor;
|
||||
final List<XAlbum> _albums;
|
||||
final Map<String, List<XAsset>> _assetsByAlbum;
|
||||
final List<XAsset> _recents;
|
||||
final XAssetThumbnail Function(String assetId, int sizePx) _thumbnailFor;
|
||||
final io.File Function(String assetId) _fileFor;
|
||||
|
||||
final StreamController<void> _libraryChanges =
|
||||
@@ -58,13 +58,13 @@ class FakeUxGalleryBackend implements UxGalleryBackend {
|
||||
Stream<void> 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<UxGalleryPermission> permission() async => permissionState;
|
||||
Future<XGalleryPermission> permission() async => permissionState;
|
||||
|
||||
@override
|
||||
Future<UxGalleryPermission> requestPermission() async {
|
||||
Future<XGalleryPermission> requestPermission() async {
|
||||
permissionState =
|
||||
onRequestPermission?.call() ?? UxGalleryPermission.granted;
|
||||
onRequestPermission?.call() ?? XGalleryPermission.granted;
|
||||
return permissionState;
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ class FakeUxGalleryBackend implements UxGalleryBackend {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<UxAlbum>> albums({UxAssetKind? filter}) async {
|
||||
Future<List<XAlbum>> 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<List<UxAsset>> assets({
|
||||
Future<List<XAsset>> assets({
|
||||
String? albumId,
|
||||
UxAssetKind? filter,
|
||||
XAssetKind? filter,
|
||||
required int start,
|
||||
required int end,
|
||||
}) async {
|
||||
final source = albumId == null
|
||||
? _recents
|
||||
: _assetsByAlbum[albumId] ?? const <UxAsset>[];
|
||||
: _assetsByAlbum[albumId] ?? const <XAsset>[];
|
||||
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<UxAssetThumbnail> thumbnail(
|
||||
Future<XAssetThumbnail> thumbnail(
|
||||
String assetId, {
|
||||
required int sizePx,
|
||||
}) async =>
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
35
lib/ux.dart
35
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';
|
||||
@@ -24,3 +26,28 @@ export 'src/functional.dart';
|
||||
export 'src/crash.dart';
|
||||
export 'src/log.dart';
|
||||
export 'src/log_http.dart';
|
||||
|
||||
// Reactive primitives, lifecycle, DI, async tasks (formerly `stated`).
|
||||
export 'src/core/core.dart';
|
||||
|
||||
// Reactive state base + builders.
|
||||
export 'src/reactive/reactive.dart';
|
||||
export 'src/reactive/reactive_builder.dart';
|
||||
export 'src/reactive/future_reactive_builder.dart';
|
||||
|
||||
// Navigation (Screen, XApp, XRouter, ScreenStack, ScreenHero, …).
|
||||
export 'src/navi/screen.dart';
|
||||
export 'src/navi/screen_host.dart';
|
||||
export 'src/navi/screen_stack.dart';
|
||||
export 'src/navi/hero.dart';
|
||||
export 'src/navi/sheet_screen.dart';
|
||||
export 'src/navi/transitions.dart';
|
||||
export 'src/navi/router.dart';
|
||||
|
||||
// Animations, layout, sheet, pane.
|
||||
export 'src/anim/animated_color.dart';
|
||||
export 'src/anim/animated_double.dart';
|
||||
export 'src/anim/dock.dart';
|
||||
export 'src/anim/measured.dart';
|
||||
export 'src/anim/pane.dart';
|
||||
export 'src/anim/sheet.dart';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -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<UxUrlRawMatch *> *raws = [NSMutableArray array];
|
||||
NSMutableArray<XUrlRawMatch *> *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<UxUrlRawMatch *> *kept = [NSMutableArray arrayWithCapacity:raws.count];
|
||||
NSMutableArray<XUrlRawMatch *> *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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<MethodCall> 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 = <UxCameraEvent>[];
|
||||
final received = <XCameraEvent>[];
|
||||
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<UxCameraException>()
|
||||
throwsA(isA<XCameraException>()
|
||||
.having((e) => e.code, 'code', 'device_busy')
|
||||
.having((e) => e.description, 'description', 'front camera in use')),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user