diff --git a/LICENSE b/LICENSE index ba75c69..297539d 100644 --- a/LICENSE +++ b/LICENSE @@ -1 +1,21 @@ -TODO: Add your license here. +MIT License + +Copyright (c) 2024 SWIPELAB LTD + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..868d700 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + public_member_api_docs: true diff --git a/example/.flutter-plugins-dependencies b/example/.flutter-plugins-dependencies index 5f7e80f..a5ea408 100644 --- a/example/.flutter-plugins-dependencies +++ b/example/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"ux","path":"/Users/agra/projects/ux/","native_build":true,"dependencies":[],"dev_dependency":true}],"android":[{"name":"ux","path":"/Users/agra/projects/ux/","native_build":true,"dependencies":[],"dev_dependency":true}],"macos":[],"linux":[],"windows":[],"web":[]},"dependencyGraph":[{"name":"ux","dependencies":[]}],"date_created":"2026-04-15 13:13:36.363037","version":"3.41.5","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"ux","path":"/Users/agra/projects/ux/","native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"ux","path":"/Users/agra/projects/ux/","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[],"linux":[],"windows":[],"web":[]},"dependencyGraph":[{"name":"ux","dependencies":[]}],"date_created":"2026-04-16 18:49:44.385595","version":"3.41.5","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/example/pubspec.lock b/example/pubspec.lock index 6729596..7f2ec48 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -62,6 +62,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" flutter_test: dependency: "direct dev" description: flutter @@ -91,6 +99,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" matcher: dependency: transitive description: @@ -177,12 +193,12 @@ packages: source: hosted version: "0.7.10" ux: - dependency: "direct dev" + dependency: "direct main" description: path: ".." relative: true source: path - version: "0.2.0" + version: "0.3.0" vector_math: dependency: transitive description: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index ce9da81..1050413 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -8,56 +8,14 @@ environment: dependencies: flutter: sdk: flutter - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: 1.0.8 - -dev_dependencies: - flutter_test: - sdk: flutter - ux: path: ../ -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec +dev_dependencies: + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter -# The following section is specific to Flutter. flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware. - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/assets-and-images/#from-packages - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index 3dadec9..cad8b8e 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -1,26 +1,11 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility that Flutter provides. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ux_example/main.dart'; void main() { - testWidgets('Verify Platform version', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(MyApp()); - - // Verify that platform version is retrieved. - expect( - find.byWidgetPredicate( - (Widget widget) => - widget is Text && (widget.data?.startsWith('Running on:') ?? false), - ), - findsOneWidget, - ); + testWidgets('ChatScreen renders', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp(home: ChatScreen())); + expect(find.text('UxKeyboard Chat'), findsOneWidget); + expect(find.text('Type a message...'), findsOneWidget); }); } diff --git a/lib/src/bend_box.dart b/lib/src/bend_box.dart index 1d4952d..9d6f25d 100644 --- a/lib/src/bend_box.dart +++ b/lib/src/bend_box.dart @@ -1,11 +1,19 @@ import 'package:flutter/material.dart'; +/// A widget that paints a filled shape with curved (bent) edges. +/// +/// Each edge bends inward by the amount specified in [inward]. class BendBox extends StatelessWidget { + /// How far each edge bends inward. Positive values bend toward the center. final EdgeInsets inward; + + /// The fill color of the shape. final Color color; - BendBox({this.inward = const EdgeInsets.all(0), this.color = Colors.red}); + /// Creates a [BendBox] with the given [inward] bend and [color]. + const BendBox({super.key, this.inward = const EdgeInsets.all(0), this.color = Colors.red}); + @override Widget build(BuildContext context) { return CustomPaint(painter: _BendBoxPainter(inward: inward, color: color)); } @@ -20,6 +28,7 @@ class _BendBoxPainter extends CustomPainter { required this.color, }); + @override void paint(Canvas canvas, Size size) { final paint = Paint() ..style = PaintingStyle.fill @@ -39,5 +48,6 @@ class _BendBoxPainter extends CustomPainter { canvas.drawPath(path, paint); } + @override bool shouldRepaint(CustomPainter oldDelegate) => true; } diff --git a/lib/src/bezier.dart b/lib/src/bezier.dart index cb88cd8..8141b7a 100644 --- a/lib/src/bezier.dart +++ b/lib/src/bezier.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'dart:ui'; +/// Computes the approximate arc length of a [Bezier] curve by sampling. double bezierLength(Bezier bezier, [double steps = 10]) { assert(steps != 0); final step = 1 / steps; @@ -14,36 +15,56 @@ double bezierLength(Bezier bezier, [double steps = 10]) { return length; } +/// Base class for parametric bezier curves. +/// +/// Evaluate a point on the curve with [point] at parameter `t` in [0, 1]. abstract class Bezier { + /// Returns the point on the curve at parameter [t] (0 = start, 1 = end). Offset point(double t); + /// The approximate arc length of the curve. double get length => bezierLength(this); } +/// A straight line segment from [p0] to [p1]. class LinearBezier extends Bezier { - Offset p0, p1; + /// The start point. + Offset p0; + /// The end point. + Offset p1; + + /// Creates a linear bezier from [p0] to [p1]. LinearBezier(this.p0, this.p1); + @override Offset point(double t) { return p0 + (p1 - p0) * t; } + @override double get length => (p1 - p0).distance; } +/// A quadratic bezier curve with one control point. class QuadraticBezier extends Bezier { double _quadraticBezier(double t, double p0, double p1, double p2) { - //final lt = 1 - t; - //return lt * (lt * p0 + t * p1) + t * (lt * p1 + t * p2); - //return pow(1 - t, 2) * p0 + 2 * (1 - t) * t * p1 + pow(t, 2) * p2; return p1 + pow(1 - t, 2) * (p0 - p1) + pow(t, 2) * (p2 - p1); } - Offset p0, p1, p2; + /// The start point. + Offset p0; + /// The control point. + Offset p1; + + /// The end point. + Offset p2; + + /// Creates a quadratic bezier from [p0] to [p2] with control point [p1]. QuadraticBezier(this.p0, this.p1, this.p2); + @override Offset point(double t) { return Offset( _quadraticBezier(t, p0.dx, p1.dx, p2.dx), @@ -52,6 +73,7 @@ class QuadraticBezier extends Bezier { } } +/// A cubic bezier curve with two control points. class CubicBezier extends Bezier { double _cubicBezier(double t, double p0, double p1, double p2, double p3) { return pow(1 - t, 3) * p0 + @@ -60,35 +82,53 @@ class CubicBezier extends Bezier { pow(t, 3) * p3; } - Offset p0, p1, p2, p3; + /// The start point. + Offset p0; + /// The first control point. + Offset p1; + + /// The second control point. + Offset p2; + + /// The end point. + Offset p3; + + /// Creates a cubic bezier from [p0] to [p3] with control points [p1] and [p2]. CubicBezier(this.p0, this.p1, this.p2, this.p3); - Offset point(double t) => - Offset( + @override + Offset point(double t) => Offset( _cubicBezier(t, p0.dx, p1.dx, p2.dx, p3.dx), _cubicBezier(t, p0.dy, p1.dy, p2.dy, p3.dy), ); } +/// A composite path of multiple bezier segments. +/// +/// Build a path incrementally with [lineTo], [quadTo], and [cubeTo]. +/// Evaluate any point along the total path with [point]. class PathBezier extends Bezier { double _length = 0; + @override double get length => _length; - List _curves = []; - List _lens = []; + final List _curves = []; + final List _lens = []; + /// The starting point of the path. final Offset p0; Offset _p0; + /// Creates a path starting at [p0]. PathBezier(this.p0) : _p0 = p0; + /// Creates a path tracing a rounded rectangle. static PathBezier roundedRect(RRect rrect) { return PathBezier(Offset(rrect.left + rrect.width / 2, rrect.top)) ..lineTo(Offset(rrect.right - rrect.trRadiusX, rrect.top)) - ..quadTo( - Offset(rrect.right, rrect.top), + ..quadTo(Offset(rrect.right, rrect.top), Offset(rrect.right, rrect.top + rrect.trRadiusY)) ..lineTo(Offset(rrect.right, rrect.bottom - rrect.brRadiusY)) ..quadTo(Offset(rrect.right, rrect.bottom), @@ -102,7 +142,7 @@ class PathBezier extends Bezier { ..lineTo(Offset(rrect.left + rrect.width / 2, rrect.top)); } - _add(Bezier bezier, Offset pn) { + void _add(Bezier bezier, Offset pn) { final bl = bezierLength(bezier); _curves.add(bezier); _lens.add(bl); @@ -110,32 +150,29 @@ class PathBezier extends Bezier { _p0 = pn; } - lineTo(Offset p1) => _add(LinearBezier(_p0, p1), p1); + /// Appends a straight line to [p1]. + void lineTo(Offset p1) => _add(LinearBezier(_p0, p1), p1); - quadTo(Offset p1, Offset p2) => _add(QuadraticBezier(_p0, p1, p2), p2); + /// Appends a quadratic curve with control point [p1] to endpoint [p2]. + void quadTo(Offset p1, Offset p2) => + _add(QuadraticBezier(_p0, p1, p2), p2); - cubeTo(Offset p1, Offset p2, Offset p3) => + /// Appends a cubic curve with control points [p1], [p2] to endpoint [p3]. + void cubeTo(Offset p1, Offset p2, Offset p3) => _add(CubicBezier(_p0, p1, p2, p3), p3); - relativeLineTo(Offset p1) => - lineTo( - p1 + _p0, - ); + /// Appends a straight line to a point relative to the current position. + void relativeLineTo(Offset p1) => lineTo(p1 + _p0); - relativeQuadTo(Offset p1, Offset p2) => - quadTo( - p1 + p0, - p1 + p2 + p0, - ); - - relativeCubeTo(Offset p1, Offset p2, Offset p3) => - cubeTo( - p0 + p1, - p0 + p1 + p2, - p0 + p1 + p2 + p3, - ); + /// Appends a quadratic curve with relative control and end points. + void relativeQuadTo(Offset p1, Offset p2) => + quadTo(p1 + p0, p1 + p2 + p0); + /// Appends a cubic curve with relative control and end points. + void relativeCubeTo(Offset p1, Offset p2, Offset p3) => + cubeTo(p0 + p1, p0 + p1 + p2, p0 + p1 + p2 + p3); + @override Offset point(double t) { if (t > 1) { t = t - t.floor(); diff --git a/lib/src/json_extension.dart b/lib/src/json_extension.dart index 848e602..d8c7228 100644 --- a/lib/src/json_extension.dart +++ b/lib/src/json_extension.dart @@ -1,15 +1,28 @@ -typedef T _JsonConvert(Map json); +/// Signature for a function that converts a JSON map to a typed object. +typedef JsonConvert = T Function(Map json); +/// Utilities for converting JSON structures to typed Dart objects. class Json { - static List list(List? json, _JsonConvert fromJson) => json == null + /// Converts a JSON list of maps to a typed `List`. + /// + /// Returns an empty list if [json] is null. + static List list(List? json, JsonConvert fromJson) => json == null ? [] : json.cast>().map(fromJson).toList(); - static Map map(Map? json, _JsonConvert fromJson) => json == null - ? {} - : Map.fromEntries( - json.entries.map((e) => MapEntry(e.key, fromJson(e.value)))); + /// Converts a JSON map of maps to a typed `Map`. + /// + /// Returns an empty map if [json] is null. + static Map map(Map? json, JsonConvert fromJson) => + json == null + ? {} + : Map.fromEntries( + json.entries.map((e) => MapEntry(e.key, fromJson(e.value)))); + /// Reads a value at a dot-separated [path] from a nested JSON structure. + /// + /// Supports both map keys and list indices (numeric segments). + /// Returns [defaultValue] if the path doesn't exist. static dynamic path(Map json, String path, {dynamic defaultValue}) { try { @@ -24,7 +37,6 @@ class Json { }); return current ?? defaultValue; } catch (error) { - print(error); return defaultValue; } } diff --git a/lib/src/keyboard.dart b/lib/src/keyboard.dart index a8f403d..76edd81 100644 --- a/lib/src/keyboard.dart +++ b/lib/src/keyboard.dart @@ -2,7 +2,6 @@ import 'dart:ffi'; import 'dart:io'; import 'dart:math' as math; -import 'package:flutter/animation.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/scheduler.dart'; @@ -126,19 +125,50 @@ double _inverseLerp(List samples, double value) { // --------------------------------------------------------------------------- +/// Frame-accurate keyboard height tracking for iOS and Android. +/// +/// Reads the keyboard position directly from the native layer via FFI each +/// frame, bypassing Flutter's `MediaQuery.viewInsets` which lags behind. +/// +/// Supports interactive dismiss (swipe-to-dismiss like iMessage) and provides +/// sampled native animation curves with adaptive learning. +/// +/// Use the singleton [instance] and listen for changes via [addListener]: +/// +/// ```dart +/// final keyboard = UxKeyboard.instance; +/// keyboard.enableInteractiveDismiss(trackingInset: 56); +/// ``` class UxKeyboard with ChangeNotifier { UxKeyboard._() { if (_lib == null) return; SchedulerBinding.instance.addPersistentFrameCallback(_onFrame); } + /// The singleton instance. static final UxKeyboard instance = UxKeyboard._(); double _height = 0; + /// The current keyboard height in logical pixels. + /// + /// Updated every frame while the keyboard is animating or open. + /// Returns 0 when the keyboard is fully closed. double get height => _height; + + /// The last system-reported keyboard height. + /// + /// Unlike [height], this is not interpolated — it reflects the target + /// height from the most recent keyboard notification. double get systemHeight => _uxSystemHeight?.call() ?? 0; + + /// Whether the keyboard is currently visible. bool get isOpen => _height > 0; + + /// Whether an interactive dismiss pan gesture is active. + /// + /// When true, the user is dragging the keyboard down. Use this to freeze + /// scroll views so they don't fight the pan gesture. bool get isTracking => (_uxIsTracking?.call() ?? 0) > 0; // Animation state — replays the keyboard's own animation inside Flutter. @@ -308,6 +338,13 @@ class UxKeyboard with ChangeNotifier { _obs.clear(); } + /// Enables swipe-to-dismiss on the keyboard. + /// + /// [trackingInset] is the height of your input bar in logical pixels. + /// The dismiss gesture activates when the finger enters the keyboard zone + /// below this inset. void enableInteractiveDismiss({double trackingInset = 0}) => _uxEnableInteractiveDismiss?.call(trackingInset); + + /// Disables the swipe-to-dismiss gesture. void disableInteractiveDismiss() => _uxDisableInteractiveDismiss?.call(); } diff --git a/lib/src/util.dart b/lib/src/util.dart index 406c697..4009159 100644 --- a/lib/src/util.dart +++ b/lib/src/util.dart @@ -1,3 +1,7 @@ int _idCounter = 0; +/// Returns a monotonically increasing integer ID. +/// +/// Useful for generating unique keys within a single isolate session. +/// Resets to 0 on app restart. int nextId() => _idCounter++; diff --git a/lib/ux.dart b/lib/ux.dart index 7c35632..60244fb 100644 --- a/lib/ux.dart +++ b/lib/ux.dart @@ -1,6 +1,10 @@ -library ux; +/// Flutter toolkit for fluid, native-feeling UIs. +/// +/// Includes [UxKeyboard] for frame-accurate keyboard height tracking, +/// [BendBox] for curved layout painting, and bezier curve utilities. +library; export 'src/bend_box.dart'; export 'src/json_extension.dart'; export 'src/bezier.dart'; -export 'src/keyboard.dart'; \ No newline at end of file +export 'src/keyboard.dart'; diff --git a/pubspec.lock b/pubspec.lock index 84c68fe..2892aa5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -54,6 +54,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" flutter_test: dependency: "direct dev" description: flutter @@ -83,6 +91,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" matcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index f8863a8..cb036a9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,16 @@ name: ux -description: UX Kit +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.3.0 homepage: https://swipelab.co/ux.html +repository: https://github.com/swipelab/ux +issue_tracker: https://github.com/swipelab/ux/issues +topics: + - keyboard + - ui + - animation environment: sdk: ">=3.0.0" @@ -11,6 +20,7 @@ dependencies: sdk: flutter dev_dependencies: + flutter_lints: ^6.0.0 flutter_test: sdk: flutter @@ -21,4 +31,4 @@ flutter: pluginClass: KeyboardPlugin android: package: io.swipelab.ux - pluginClass: KeyboardPlugin \ No newline at end of file + pluginClass: KeyboardPlugin diff --git a/test/ux_test.dart b/test/ux_test.dart index ba43ce2..93e871f 100644 --- a/test/ux_test.dart +++ b/test/ux_test.dart @@ -1,21 +1,16 @@ -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ux/ux.dart'; void main() { - const MethodChannel channel = MethodChannel('ux'); - - setUp(() { - channel.setMockMethodCallHandler((MethodCall methodCall) async { - return '42'; - }); + test('UxKeyboard.instance is a singleton', () { + expect(UxKeyboard.instance, same(UxKeyboard.instance)); }); - tearDown(() { - channel.setMockMethodCallHandler(null); + test('UxKeyboard.height starts at 0', () { + expect(UxKeyboard.instance.height, 0); }); - test('getPlatformVersion', () async { - expect(await UX.platformVersion, '42'); + test('UxKeyboard.isOpen is false when height is 0', () { + expect(UxKeyboard.instance.isOpen, false); }); }