diff --git a/.github/renovate.json b/.github/renovate.json index 143d9cdd7..b369a295d 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -17,6 +17,19 @@ "automerge": true }, + { + "groupName": "Docs - Major Revisions", + "matchPaths": ["docs/**"], + "matchUpdateTypes": ["major"], + "automerge": false + }, + { + "groupName": "Docs - Minor Revisions", + "matchPaths": ["docs/**"], + "matchUpdateTypes": ["minor", "patch", "pin", "digest"], + "automerge": true + }, + { "groupName": "Forui - Major Revisions", "matchPaths": ["forui/**"], diff --git a/docs/package.json b/docs/package.json index 207d30875..b13213794 100644 --- a/docs/package.json +++ b/docs/package.json @@ -13,7 +13,7 @@ }, "homepage": "https://forui.dev", "dependencies": { - "lucide-react": "^0.383.0", + "lucide-react": "^0.414.0", "next": "^13.5.6", "nextra": "^2.13.4", "nextra-theme-docs": "^2.13.4", diff --git a/docs/pages/docs/resizable.mdx b/docs/pages/docs/resizable.mdx new file mode 100644 index 000000000..c0c8d6724 --- /dev/null +++ b/docs/pages/docs/resizable.mdx @@ -0,0 +1,186 @@ +import { Tabs } from 'nextra/components'; +import { Widget } from "../../components/widget"; + +# Resizable box +A box which children can be resized along either the horizontal or vertical axis. + + + + + + + ```dart + class TimeOfDay extends StatelessWidget { + @override + Widget build(BuildContext context) => FResizable( + axis: Axis.vertical, + crossAxisExtent: 400, + interaction: const FResizableRegionInteraction.selectAndResize(0), + children: [ + FResizableRegion.raw( + initialSize: 200, + minSize: 100, + builder: (context, data, _) { + final colorScheme = context.theme.colorScheme; + return Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: data.selected ? colorScheme.foreground : colorScheme.background, + borderRadius: const BorderRadius.vertical(top: Radius.circular(8)), + border: Border.all(color: colorScheme.border), + ), + child: Label(data: data, icon: FAssets.icons.sunrise, label: 'Morning'), + ); + }, + ), + FResizableRegion.raw( + initialSize: 200, + minSize: 100, + builder: (context, data, _) { + final colorScheme = context.theme.colorScheme; + return Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: data.selected ? colorScheme.foreground : colorScheme.background, + border: Border.all(color: colorScheme.border), + ), + child: Label(data: data, icon: FAssets.icons.sun, label: 'Afternoon'), + ); + }, + ), + FResizableRegion.raw( + initialSize: 200, + minSize: 100, + builder: (context, data, _) { + final colorScheme = context.theme.colorScheme; + return Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: data.selected ? colorScheme.foreground : colorScheme.background, + borderRadius: const BorderRadius.vertical(bottom: Radius.circular(8)), + border: Border.all(color: colorScheme.border), + ), + child: Label(data: data, icon: FAssets.icons.moon, label: 'Night'), + ); + }, + ), + ], + ); + } + + class Label extends StatelessWidget { + static final DateFormat format = DateFormat.jm(); // Requires package:intl + + final FResizableRegionData data; + final SvgAsset icon; + final String label; + + const Label({required this.data, required this.icon, required this.label, super.key}); + + @override + Widget build(BuildContext context) { + final FThemeData(:colorScheme, :typography) = context.theme; + final color = data.selected ? colorScheme.background : colorScheme.foreground; + final start = + DateTime.fromMillisecondsSinceEpoch((data.percentage.min * Duration.millisecondsPerDay).round(), isUtc: true); + final end = + DateTime.fromMillisecondsSinceEpoch((data.percentage.max * Duration.millisecondsPerDay).round(), isUtc: true); + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + icon(height: 15, colorFilter: ColorFilter.mode(color, BlendMode.srcIn)), + const SizedBox(width: 3), + Text(label, style: typography.sm.copyWith(color: color)), + ], + ), + const SizedBox(height: 5), + Text('${format.format(start)} - ${format.format(end)}', style: typography.sm.copyWith(color: color)), + ], + ); + } + } + ``` + + + +## Usage + +### `FResizable(...)` + +```dart +FResizable( + axis: Axis.vertical, + crossAxisExtent: 400, + interaction: const FResizableRegionInteraction.selectAndResize(0), + children: [ + FResizableRegion.raw( + initialSize: 200, + minSize: 100, + onPress: (index) {}, + onResizeUpdate: (selected, neighbour) {}, + onResizeEnd: (selected, neighbour) {}, + builder: (context, data, child) => child!, + child: const Placeholder(), + ), + ], +); +``` + +## Examples + +### Resize without selecting + + + + + + + ```dart + @override + Widget build(BuildContext context) => FResizable( + axis: Axis.horizontal, + crossAxisExtent: 300, + children: [ + FResizableRegion.raw( + initialSize: 100, + minSize: 100, + builder: (context, data, _) { + final colorScheme = context.theme.colorScheme; + return Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: data.selected ? colorScheme.foreground : colorScheme.background, + borderRadius: const BorderRadius.vertical(top: Radius.circular(8)), + border: Border.all(color: colorScheme.border), + ), + child: Text('Sidebar', style: context.theme.typography.sm) + ); + }, + ), + FResizableRegion.raw( + initialSize: 300, + minSize: 100, + builder: (context, data, _) { + final colorScheme = context.theme.colorScheme; + return Container( + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: const BorderRadius.vertical(bottom: Radius.circular(8)), + border: Border.all(color: colorScheme.border), + ), + child: Text('Content', style: context.theme.typography.sm), + ); + }, + ), + ], + ); + ``` + + + + diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index 24e798e4d..aeea5ad1e 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: lucide-react: - specifier: ^0.383.0 - version: 0.383.0(react@18.2.0) + specifier: ^0.414.0 + version: 0.414.0(react@18.2.0) next: specifier: ^13.5.6 version: 13.5.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -1038,10 +1038,10 @@ packages: lru-cache@4.1.5: resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==} - lucide-react@0.383.0: - resolution: {integrity: sha512-13xlG0CQCJtzjSQYwwJ3WRqMHtRj3EXmLlorrARt7y+IHnxUCp3XyFNL1DfaGySWxHObDvnu1u1dV+0VMKHUSg==} + lucide-react@0.414.0: + resolution: {integrity: sha512-Krr/MHg9AWoJc52qx8hyJ64X9++JNfS1wjaJviLM1EP/68VNB7Tv0VMldLCB1aUe6Ka9QxURPhQm/eB6cqOM3A==} peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 markdown-extensions@1.1.1: resolution: {integrity: sha512-WWC0ZuMzCyDHYCasEGs4IPvLyTGftYwh6wIEOULOF0HXcqZlhwRzrK0w2VUlxWA98xnvb/jszw4ZSkJ6ADpM6Q==} @@ -2853,7 +2853,7 @@ snapshots: pseudomap: 1.0.2 yallist: 2.1.2 - lucide-react@0.383.0(react@18.2.0): + lucide-react@0.414.0(react@18.2.0): dependencies: react: 18.2.0 diff --git a/forui/.gitignore b/forui/.gitignore index f5549bd71..19dfbc50e 100644 --- a/forui/.gitignore +++ b/forui/.gitignore @@ -30,3 +30,4 @@ migrate_working_dir/ .flutter-plugins-dependencies build/ test/golden/failures/ +test/**/*_test.mocks.dart diff --git a/forui/CHANGELOG.md b/forui/CHANGELOG.md index 8ce4339b1..19dd1a078 100644 --- a/forui/CHANGELOG.md +++ b/forui/CHANGELOG.md @@ -1,3 +1,9 @@ +## Next + +### Additions +* Add `FResizable` + + ## 0.3.0 ### Additions diff --git a/forui/example/lib/example.dart b/forui/example/lib/example.dart index ce4f08855..3b41b8274 100644 --- a/forui/example/lib/example.dart +++ b/forui/example/lib/example.dart @@ -1,7 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:forui/forui.dart'; - class Example extends StatefulWidget { const Example({super.key}); @@ -16,18 +14,5 @@ class _ExampleState extends State { } @override - Widget build(BuildContext context) => Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 100), - FProgress(value: 0.9), - const SizedBox(height: 10), - FAvatar( - image: const NetworkImage('https://picsum.photos/'), - placeholderBuilder: (_) => const Text('DC'), - ), - const SizedBox(height: 20), - ], - ); + Widget build(BuildContext context) => const Placeholder(); } diff --git a/forui/example/lib/main.dart b/forui/example/lib/main.dart index 047d3f805..5c0544cc6 100644 --- a/forui/example/lib/main.dart +++ b/forui/example/lib/main.dart @@ -21,7 +21,7 @@ class _ApplicationState extends State { @override Widget build(BuildContext context) => MaterialApp( builder: (context, child) => FTheme( - data: FThemes.zinc.light, + data: FThemes.zinc.dark, child: FScaffold( header: FHeader( title: const Text('Example'), @@ -32,7 +32,7 @@ class _ApplicationState extends State { ), ], ), - content: child ?? const SizedBox(), + content: const Example(), footer: FBottomNavigationBar( index: index, onChange: (index) => setState(() => this.index = index), diff --git a/forui/example/pubspec.lock b/forui/example/pubspec.lock index 9bee11ef4..53a12ba22 100644 --- a/forui/example/pubspec.lock +++ b/forui/example/pubspec.lock @@ -274,10 +274,10 @@ packages: dependency: transitive description: name: graphs - sha256: aedc5a15e78fc65a6e23bcd927f24c64dd995062bcd1ca6eda65a3cff92a4d19 + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" http: dependency: transitive description: @@ -303,7 +303,7 @@ packages: source: hosted version: "4.0.2" intl: - dependency: transitive + dependency: "direct main" description: name: intl sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf diff --git a/forui/example/pubspec.yaml b/forui/example/pubspec.yaml index 6fcb9e530..f7fedb9ac 100644 --- a/forui/example/pubspec.yaml +++ b/forui/example/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: sdk: flutter forui: path: ../ + intl: ^0.19.0 sugar: ^3.1.0 dev_dependencies: diff --git a/forui/lib/src/widgets/alert/alert_icon.dart b/forui/lib/src/widgets/alert/alert_icon.dart index 7e05287d7..150328ef4 100644 --- a/forui/lib/src/widgets/alert/alert_icon.dart +++ b/forui/lib/src/widgets/alert/alert_icon.dart @@ -35,7 +35,7 @@ final class FAlertIconStyle with Diagnosticable { /// Creates a [FButtonIconStyle]. /// - /// ## Contract: + /// ## Contract /// Throws [AssertionError] if: /// * `height` <= 0.0 /// * `height` is Nan diff --git a/forui/lib/src/widgets/badge/badge.dart b/forui/lib/src/widgets/badge/badge.dart index bf58d1a8d..e90691c9b 100644 --- a/forui/lib/src/widgets/badge/badge.dart +++ b/forui/lib/src/widgets/badge/badge.dart @@ -121,7 +121,7 @@ final class FBadgeCustomStyle with Diagnosticable implements FBadgeStyle { /// The border width (thickness). /// - /// ## Contract: + /// ## Contract /// Throws [AssertionError] if: /// * `borderWidth` <= 0.0 /// * `borderWidth` is Nan diff --git a/forui/lib/src/widgets/button/button_icon.dart b/forui/lib/src/widgets/button/button_icon.dart index b791bd10f..f8c6b519d 100644 --- a/forui/lib/src/widgets/button/button_icon.dart +++ b/forui/lib/src/widgets/button/button_icon.dart @@ -38,7 +38,7 @@ final class FButtonIconStyle with Diagnosticable { /// Creates a [FButtonIconStyle]. /// - /// ## Contract: + /// ## Contract /// Throws [AssertionError] if: /// * `height` <= 0.0 /// * `height` is Nan diff --git a/forui/lib/src/widgets/calendar/calendar.dart b/forui/lib/src/widgets/calendar/calendar.dart index af11481fb..80471c381 100644 --- a/forui/lib/src/widgets/calendar/calendar.dart +++ b/forui/lib/src/widgets/calendar/calendar.dart @@ -1,7 +1,7 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; import 'package:sugar/sugar.dart'; import 'package:forui/forui.dart'; @@ -35,14 +35,14 @@ class FCalendar extends StatelessWidget { /// The start date. It is truncated to the nearest date. /// - /// ## Contract: - /// Throws an [AssertionError] if [end] <= [start] + /// ## Contract + /// Throws [AssertionError] if [end] <= [start] final DateTime start; /// The end date. It is truncated to the nearest date. /// - /// ## Contract: - /// Throws an [AssertionError] if [end] <= [start] + /// ## Contract + /// Throws [AssertionError] if [end] <= [start] final DateTime end; /// The current date. It is truncated to the nearest date. Defaults to the [DateTime.now]. @@ -224,6 +224,7 @@ final class FCalendarStyle with Diagnosticable { /// print(style.headerStyle == copy.headerStyle); // true /// print(style.dayPickerStyle == copy.dayPickerStyle); // false /// ``` + @useResult FCalendarStyle copyWith({ FCalendarHeaderStyle? headerStyle, FCalendarDayPickerStyle? dayPickerStyle, diff --git a/forui/lib/src/widgets/calendar/calendar_controller.dart b/forui/lib/src/widgets/calendar/calendar_controller.dart index ea2d8b710..065fc6ae5 100644 --- a/forui/lib/src/widgets/calendar/calendar_controller.dart +++ b/forui/lib/src/widgets/calendar/calendar_controller.dart @@ -29,8 +29,8 @@ abstract class FCalendarController extends ValueNotifier { final class FCalendarSingleValueController extends FCalendarController { /// Creates a [FCalendarSingleValueController] with the given initial [value]. /// - /// ## Contract: - /// Throws an [AssertionError] if the given [value] is not in UTC timezone. + /// ## Contract + /// Throws [AssertionError] if the given [value] is not in UTC timezone. FCalendarSingleValueController([super.value]) : assert(value?.isUtc ?? true, 'value must be in UTC timezone'); @override @@ -46,8 +46,8 @@ final class FCalendarSingleValueController extends FCalendarController> { /// Creates a [FCalendarMultiValueController] with the given initial [value]. /// - /// ## Contract: - /// Throws an [AssertionError] if the given dates in [value] is not in UTC timezone. + /// ## Contract + /// Throws [AssertionError] if the given dates in [value] is not in UTC timezone. FCalendarMultiValueController([super.value = const {}]) : assert(value.every((d) => d.isUtc), 'dates must be in UTC timezone'); @@ -68,8 +68,8 @@ final class FCalendarMultiValueController extends FCalendarController { /// Creates a [FCalendarSingleRangeController] with the given initial [value]. /// - /// ## Contract: - /// Throws an [AssertionError] if: + /// ## Contract + /// Throws [AssertionError] if: /// * the given dates in [value] is not in UTC timezone. /// * the end date is less than start date. FCalendarSingleRangeController([super.value]) diff --git a/forui/lib/src/widgets/calendar/day/day_picker.dart b/forui/lib/src/widgets/calendar/day/day_picker.dart index aa72f84b2..6df7b9152 100644 --- a/forui/lib/src/widgets/calendar/day/day_picker.dart +++ b/forui/lib/src/widgets/calendar/day/day_picker.dart @@ -186,8 +186,8 @@ final class FCalendarDayPickerStyle with Diagnosticable { /// /// Specifying [startDayOfWeek] will override the current locale's preferred starting day of the week. /// - /// ## Contract: - /// Throws an [AssertionError] if: + /// ## Contract + /// Throws [AssertionError] if: /// * [startDayOfWeek] < [DateTime.monday] /// * [DateTime.sunday] < [startDayOfWeek] final int? startDayOfWeek; @@ -272,6 +272,7 @@ final class FCalendarDayPickerStyle with Diagnosticable { /// print(style.headerTextStyle == copy.headerTextStyle); // true /// print(style.enabled.current == copy.enabled.current); // false /// ``` + @useResult FCalendarDayPickerStyle copyWith({ TextStyle? headerTextStyle, FCalendarDayStyle? enabledCurrent, @@ -350,6 +351,7 @@ final class FCalendarDayStyle with Diagnosticable { /// print(style.selectedStyle == copy.selectedStyle); // true /// print(style.unselectedStyle == copy.unselectedStyle); // false /// ``` + @useResult FCalendarDayStyle copyWith({ FCalendarEntryStyle? selectedStyle, FCalendarEntryStyle? unselectedStyle, diff --git a/forui/lib/src/widgets/calendar/shared/entry.dart b/forui/lib/src/widgets/calendar/shared/entry.dart index fc931db41..92882cdea 100644 --- a/forui/lib/src/widgets/calendar/shared/entry.dart +++ b/forui/lib/src/widgets/calendar/shared/entry.dart @@ -250,6 +250,7 @@ final class FCalendarEntryStyle with Diagnosticable { /// print(style.backgroundColor == copy.backgroundColor); // true /// print(style.textStyle == copy.textStyle); // false /// ``` + @useResult FCalendarEntryStyle copyWith({ Color? backgroundColor, TextStyle? textStyle, diff --git a/forui/lib/src/widgets/calendar/shared/header.dart b/forui/lib/src/widgets/calendar/shared/header.dart index 0a1cc80ce..de1c8b424 100644 --- a/forui/lib/src/widgets/calendar/shared/header.dart +++ b/forui/lib/src/widgets/calendar/shared/header.dart @@ -229,6 +229,7 @@ final class FCalendarHeaderStyle with Diagnosticable { /// print(style.headerTextStyle == copy.headerTextStyle); // true /// print(style.iconColor == copy.iconColor); // false /// ``` + @useResult FCalendarHeaderStyle copyWith({ TextStyle? headerTextStyle, Color? iconColor, diff --git a/forui/lib/src/widgets/calendar/year_month_picker.dart b/forui/lib/src/widgets/calendar/year_month_picker.dart index 7b1da364e..b28fc9942 100644 --- a/forui/lib/src/widgets/calendar/year_month_picker.dart +++ b/forui/lib/src/widgets/calendar/year_month_picker.dart @@ -116,6 +116,7 @@ final class FCalendarYearMonthPickerStyle with Diagnosticable { /// print(style.enabledStyle == copy.enabledStyle); // true /// print(style.disabledStyle == copy.disabledStyle); // false /// ``` + @useResult FCalendarYearMonthPickerStyle copyWith({ FCalendarEntryStyle? enabledStyle, FCalendarEntryStyle? disabledStyle, diff --git a/forui/lib/src/widgets/card/card_content.dart b/forui/lib/src/widgets/card/card_content.dart index 5444a0008..7b252bdba 100644 --- a/forui/lib/src/widgets/card/card_content.dart +++ b/forui/lib/src/widgets/card/card_content.dart @@ -93,6 +93,7 @@ final class FCardContentStyle with Diagnosticable { /// print(style.titleTextStyle == copy.titleTextStyle); // true /// print(style.subtitleTextStyle == copy.subtitleTextStyle); // false /// ``` + @useResult FCardContentStyle copyWith({ TextStyle? titleTextStyle, TextStyle? subtitleTextStyle, diff --git a/forui/lib/src/widgets/checkbox.dart b/forui/lib/src/widgets/checkbox.dart index 140e3f5e0..864da465a 100644 --- a/forui/lib/src/widgets/checkbox.dart +++ b/forui/lib/src/widgets/checkbox.dart @@ -2,6 +2,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; + import 'package:forui/forui.dart'; /// A checkbox control that allows the user to toggle between checked and not checked. @@ -234,6 +236,7 @@ final class FCheckboxStyle with Diagnosticable { /// print(style.animationDuration); // const Duration(minutes: 1) /// print(copy.curve); // Curves.bounceIn /// ``` + @useResult FCheckboxStyle copyWith({ Duration? animationDuration, Curve? curve, @@ -309,6 +312,7 @@ final class FCheckboxStateStyle with Diagnosticable { /// print(style.iconColor == copy.iconColor); // true /// print(style.checkedBackgroundColor == copy.checkedBackgroundColor); // false /// ``` + @useResult FCheckboxStateStyle copyWith({ Color? borderColor, Color? iconColor, diff --git a/forui/lib/src/widgets/header/header.dart b/forui/lib/src/widgets/header/header.dart index c7439b761..155fa759b 100644 --- a/forui/lib/src/widgets/header/header.dart +++ b/forui/lib/src/widgets/header/header.dart @@ -81,6 +81,7 @@ final class FHeaderStyles with Diagnosticable { /// print(style.rootStyle == copy.rootStyle); // true /// print(style.nestedStyle == copy.nestedStyle); // false /// ``` + @useResult FHeaderStyles copyWith({ FRootHeaderStyle? rootStyle, FNestedHeaderStyle? nestedStyle, diff --git a/forui/lib/src/widgets/header/nested_header.dart b/forui/lib/src/widgets/header/nested_header.dart index 9687d0892..63ccf5b95 100644 --- a/forui/lib/src/widgets/header/nested_header.dart +++ b/forui/lib/src/widgets/header/nested_header.dart @@ -131,6 +131,7 @@ final class FNestedHeaderStyle with Diagnosticable { /// print(style.titleTextStyle == copy.titleTextStyle); // true /// print(style.leftAction == copy.leftAction); // false /// ``` + @useResult FNestedHeaderStyle copyWith({ TextStyle? titleTextStyle, FHeaderActionStyle? actionStyle, diff --git a/forui/lib/src/widgets/progress.dart b/forui/lib/src/widgets/progress.dart index 00b2c0635..61d459876 100644 --- a/forui/lib/src/widgets/progress.dart +++ b/forui/lib/src/widgets/progress.dart @@ -18,7 +18,7 @@ class FProgress extends StatelessWidget { /// A value of 0.0 means no progress and 1.0 means that progress is complete. /// The value will be clamped to be in the range 0.0-1.0. /// - /// ## Contract: + /// ## Contract /// Throws [AssertionError] if: /// * [value] is NaN final double value; diff --git a/forui/lib/src/widgets/resizable/resizable.dart b/forui/lib/src/widgets/resizable/resizable.dart new file mode 100644 index 000000000..9ee7b78ed --- /dev/null +++ b/forui/lib/src/widgets/resizable/resizable.dart @@ -0,0 +1,249 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'package:meta/meta.dart'; +import 'package:sugar/sugar.dart'; + +import 'package:forui/forui.dart'; +import 'package:forui/src/widgets/resizable/resizable_controller.dart'; + +export '/src/widgets/resizable/resizable_region.dart'; +export '/src/widgets/resizable/resizable_controller.dart' hide ResizableController, Resize, SelectAndResize; +export '/src/widgets/resizable/resizable_region_data.dart' hide UpdatableResizableRegionData; + +/// A resizable which children can be resized along either the horizontal or vertical main axis. +/// +/// Each child is a [FResizableRegion] has a initial size and minimum size. Setting an initial size less than the +/// minimum size will result in undefined behaviour. The children are arranged from top to bottom, or left to right, +/// depending on the main [axis]. +/// +/// Although not required, it is recommended that a [FResizable] contains at least 2 [FResizable] regions. +/// +/// See: +/// * https://forui.dev/docs/resizable for working examples. +class FResizable extends StatefulWidget { + /// The main axis along which the [children] can be resized. + final Axis axis; + + /// The allowed way for the user to interact with this resizable box. Defaults to [FResizableInteraction.resize]. + final FResizableInteraction interaction; + + /// The number of pixels in the non-resizable axis. + /// + /// ## Contract + /// Throws [AssertionError] if [crossAxisExtent] is not positive. + final double? crossAxisExtent; + + /// The minimum velocity, inclusive, of a drag gesture for haptic feedback to be performed + /// on collision between two regions, defaults to 6.5. + /// + /// Setting it to `null` disables haptic feedback while setting it to 0 will cause + /// haptic feedback to always be performed. + /// + /// ## Contract + /// [_hapticFeedbackVelocity] should be a positive, finite number. It will otherwise + /// result in undefined behaviour. + final double _hapticFeedbackVelocity = 6.5; // TODO: haptic feedback + + /// The children that may be resized. + final List children; + + /// A function that is called when a resizable region is selected. This will only be called if [interaction] is + /// [FResizableInteraction.selectAndResize]. + final void Function(int index)? onPress; + + /// A function that is called when a resizable region and its neighbour are being resized. + /// + /// This function is called *while* the regions are being resized. Most users should prefer [onResizeEnd], which is + /// called only when the regions have finished resizing. + final void Function( + FResizableRegionData resized, + FResizableRegionData neighbour, + )? onResizeUpdate; + + /// A function that is called after a resizable region and its neighbour have been resized. + final void Function( + FResizableRegionData resized, + FResizableRegionData neighbour, + )? onResizeEnd; + + /// Creates a [FResizable]. + /// + /// ## Contract + /// Throws [AssertionError] if: + /// * [interaction] is a [FResizableInteraction.selectAndResize] and index is index < 0 or `children.length` <= index. + FResizable({ + required this.axis, + required this.children, + this.interaction = const FResizableInteraction.resize(), + this.crossAxisExtent, + this.onPress, + this.onResizeUpdate, + this.onResizeEnd, + super.key, + }) : assert( + crossAxisExtent == null || 0 < crossAxisExtent, + 'The crossAxisExtent should be positive, but it is $crossAxisExtent.', + ) { + if (interaction case SelectAndResize(:final index)) { + assert( + 0 <= index && index < children.length, + 'The initial index should be in 0 <= initialIndex < ${children.length}, but it is $index.', + ); + } + } + + @override + State createState() => _FResizableState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(EnumProperty('axis', axis)) + ..add(DiagnosticsProperty('interaction', interaction)) + ..add(DoubleProperty('crossAxisExtent', crossAxisExtent)) + ..add(DoubleProperty('_hapticFeedbackVelocity', _hapticFeedbackVelocity)) + ..add(IterableProperty('children', children)) + ..add(ObjectFlagProperty('onPress', onPress)) + ..add(ObjectFlagProperty('onResizeUpdate', onResizeUpdate)) + ..add(ObjectFlagProperty('onResizeEnd', onResizeEnd)); + } +} + +class _FResizableState extends State { + late ResizableController controller; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _update(widget.interaction); + } + + @override + void didUpdateWidget(FResizable old) { + super.didUpdateWidget(old); + if (widget.axis != old.axis || + widget.interaction != old.interaction || + widget.crossAxisExtent != old.crossAxisExtent || + widget._hapticFeedbackVelocity != old._hapticFeedbackVelocity || + widget.onPress != widget.onPress || + widget.onResizeUpdate != widget.onResizeUpdate || + widget.onResizeEnd != widget.onResizeEnd || + !widget.children.equals(old.children)) { + _update(controller.interaction); + } + } + + void _update(FResizableInteraction interaction) { + final selected = switch (interaction) { + SelectAndResize(:final index) => index, + Resize _ => null, + }; + + var minoffset = 0.0; + final allRegionsMin = widget.children.sum((child) => child.minSize, initial: 0.0); + final allRegions = widget.children.sum((child) => child.initialSize, initial: 0.0); + final regions = [ + for (final (index, region) in widget.children.indexed) + FResizableRegionData( + index: index, + selected: selected == index, + size: (min: region.minSize, max: allRegions - allRegionsMin + region.minSize, allRegions: allRegions), + offset: (min: minoffset, max: minoffset += region.initialSize), + ), + ]; + + controller = ResizableController( + regions: regions, + axis: widget.axis, + hapticFeedbackVelocity: widget._hapticFeedbackVelocity, + onPress: widget.onPress, + onResizeUpdate: widget.onResizeUpdate, + onResizeEnd: widget.onResizeEnd, + interaction: interaction, + ); + } + + @override + Widget build(BuildContext context) { + assert( + controller.regions.length == widget.children.length, + 'The number of FResizableData should be equal to the number of children. Please file a bug report.', + ); + + if (widget.axis == Axis.horizontal) { + return SizedBox( + height: widget.crossAxisExtent, + child: ListenableBuilder( + listenable: controller, + builder: (context, _) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < widget.children.length; i++) + InheritedData( + controller: controller, + data: controller.regions[i], + child: widget.children[i], + ), + ], + ), + ), + ); + } else { + return SizedBox( + width: widget.crossAxisExtent, + child: ListenableBuilder( + listenable: controller, + builder: (context, _) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < widget.children.length; i++) + InheritedData( + controller: controller, + data: controller.regions[i], + child: widget.children[i], + ), + ], + ), + ), + ); + } + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('controller', controller)); + } +} + +@internal +class InheritedData extends InheritedWidget { + final ResizableController controller; + final FResizableRegionData data; + + const InheritedData({ + required this.controller, + required this.data, + required super.child, + super.key, + }); + + static InheritedData of(BuildContext context) { + final InheritedData? result = context.dependOnInheritedWidgetOfExactType(); + assert(result != null, 'No InheritedData found in context. Is there a parent FResizableBox?'); + return result!; + } + + @override + bool updateShouldNotify(InheritedData old) => controller != old.controller || data != old.data; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('controller', controller)) + ..add(DiagnosticsProperty('data', data)); + } +} diff --git a/forui/lib/src/widgets/resizable/resizable_controller.dart b/forui/lib/src/widgets/resizable/resizable_controller.dart new file mode 100644 index 000000000..51a07d844 --- /dev/null +++ b/forui/lib/src/widgets/resizable/resizable_controller.dart @@ -0,0 +1,143 @@ +import 'package:flutter/widgets.dart'; + +import 'package:meta/meta.dart'; + +import 'package:forui/forui.dart'; +import 'package:forui/src/widgets/resizable/resizable_region_data.dart'; + +/// Possible ways for a user to interact with a [FResizable]. +sealed class FResizableInteraction { + /// Allows the user to interact with a [FResizableRegion] by selecting before resizing it. + const factory FResizableInteraction.selectAndResize(int initialIndex) = SelectAndResize; + + /// Allows the user to interact with a [FResizableRegion] by resizing it without selecting it first. + const factory FResizableInteraction.resize() = Resize; +} + +@internal +final class SelectAndResize implements FResizableInteraction { + final int index; + + const SelectAndResize(this.index); + + @override + bool operator ==(Object other) => + identical(this, other) || other is SelectAndResize && runtimeType == other.runtimeType && index == other.index; + + @override + int get hashCode => index.hashCode; +} + +@internal +final class Resize implements FResizableInteraction { + const Resize(); + + @override + bool operator ==(Object other) => identical(this, other) || other is Resize && runtimeType == other.runtimeType; + + @override + int get hashCode => 0; +} + +@internal +class ResizableController extends ChangeNotifier { + final List regions; + final Axis axis; + final double? hapticFeedbackVelocity; + final void Function(int)? _onPress; + final void Function(FResizableRegionData, FResizableRegionData)? _onResizeUpdate; + final void Function(FResizableRegionData, FResizableRegionData)? _onResizeEnd; + FResizableInteraction _interaction; + bool _haptic; + + ResizableController({ + required this.regions, + required this.axis, + required this.hapticFeedbackVelocity, + required void Function(int)? onPress, + required void Function(FResizableRegionData, FResizableRegionData)? onResizeUpdate, + required void Function(FResizableRegionData, FResizableRegionData)? onResizeEnd, + required FResizableInteraction interaction, + }) : _onPress = onPress, + _onResizeUpdate = onResizeUpdate, + _onResizeEnd = onResizeEnd, + _interaction = interaction, + _haptic = false; + + /// Updates the resizable and its neighbours' sizes at the given index, and returns true if the resizable has been + /// minimized or maximized. + bool update(int index, AxisDirection direction, Offset delta) { + final (selected, neighbour) = _find(index, direction); + + // We always want to resize the shrunken region first. This allows us to remove any overlaps caused by shrinking + // a region beyond the minimum size. + final Offset(:dx, :dy) = delta; + final opposite = switch (direction) { + AxisDirection.left => AxisDirection.right, + AxisDirection.up => AxisDirection.down, + AxisDirection.right => AxisDirection.left, + AxisDirection.down => AxisDirection.up, + }; + + final (shrink, shrinkDirection, expand, expandDirection) = switch (direction) { + AxisDirection.left when 0 < dx => (selected, direction, neighbour, opposite), + AxisDirection.up when 0 < dy => (selected, direction, neighbour, opposite), + AxisDirection.right when dx < 0 => (selected, direction, neighbour, opposite), + AxisDirection.down when dy < 0 => (selected, direction, neighbour, opposite), + _ => (neighbour, opposite, selected, direction), + }; + + final (shrunk, adjusted) = shrink.update(shrinkDirection, delta); + if (shrink.offset != shrunk.offset) { + final (expanded, _) = expand.update(expandDirection, adjusted); + regions[shrunk.index] = shrunk; + regions[expanded.index] = expanded; + + _onResizeUpdate?.call(selected, neighbour); + _haptic = true; + notifyListeners(); + + return false; + } + + if (_haptic) { + _haptic = false; + return true; + } else { + return false; + } + } + + /// Notifies the region at the [index] and its neighbour that it has been resized. + void end(int index, AxisDirection direction) { + final (resized, neighbour) = _find(index, direction); + _onResizeEnd?.call(resized, neighbour); + notifyListeners(); + } + + (FResizableRegionData resized, FResizableRegionData neighbour) _find(int index, AxisDirection direction) { + final resized = regions[index]; + final neighbour = switch (direction) { + AxisDirection.left || AxisDirection.up => regions[index - 1], + AxisDirection.right || AxisDirection.down => regions[index + 1], + }; + + return (resized, neighbour); + } + + bool select(int value) { + if (_interaction case SelectAndResize(index: final old) when old != value) { + _onPress?.call(value); + _interaction = SelectAndResize(value); + regions[old] = regions[old].copyWith(selected: false); + regions[value] = regions[value].copyWith(selected: true); + + notifyListeners(); + return true; + } else { + return false; + } + } + + FResizableInteraction get interaction => _interaction; +} diff --git a/forui/lib/src/widgets/resizable/resizable_region.dart b/forui/lib/src/widgets/resizable/resizable_region.dart new file mode 100644 index 000000000..8020074cb --- /dev/null +++ b/forui/lib/src/widgets/resizable/resizable_region.dart @@ -0,0 +1,158 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'package:sugar/sugar.dart'; + +import 'package:forui/src/widgets/resizable/resizable.dart'; +import 'package:forui/src/widgets/resizable/resizable_controller.dart'; +import 'package:forui/src/widgets/resizable/slider.dart'; + +/// A resizable region that can be resized along the parent [FResizable]'s axis. It should always be in a [FResizable]. +/// +/// See: +/// * https://forui.dev/docs/resizable for working examples. +class FResizableRegion extends StatelessWidget { + static double _platform(double? slider) => + slider ?? + switch (const Runtime().type) { + PlatformType.android || PlatformType.ios => 50, + _ => 5, + }; + + /// The initial height/width, in logical pixels. + /// + /// ## Contract + /// Throws a [AssertionError] if: + /// * [initialSize] is not positive + /// * [initialSize] < [minSize] + final double initialSize; + + /// The minimum height/width along the resizable axis, in logical pixels. + /// + /// The minimum size is either the given minimum size or 2 * [sliderSize], whichever is larger. Defaults to + /// 2 * [sliderSize] if not given. + final double minSize; + + /// The sliders' height/width along the resizable axis. A larger [sliderSize] may increase [minSize] if it is not + /// given. + /// + /// Defaults to `50` on Android and iOS, and `5` on other platforms. + /// + /// ## Contract + /// Throws [AssertionError] if [sliderSize] is not positive. + final double sliderSize; + + /// The builder used to create a child to display in this region. + final ValueWidgetBuilder builder; + + /// A height/width-independent widget which is passed back to the [builder]. + /// + /// This argument is optional and can be null if the entire widget subtree the [builder] builds depends on the size of + /// the region. + final Widget? child; + + /// Creates a [FResizableRegion]. + FResizableRegion.raw({ + required this.initialSize, + required this.builder, + double? minSize, + double? sliderSize, + this.child, + super.key, + }) : assert( + 0 < initialSize, + 'The initial size should be positive, but it is $initialSize.', + ), + assert( + minSize == null || 0 < minSize, + 'The min size should be positive, but it is $minSize.', + ), + assert( + sliderSize == null || 0 < sliderSize, + 'The slider size should be positive, but it is $sliderSize.', + ), + minSize = max(minSize ?? 0, 2 * (sliderSize ?? _platform(sliderSize))), + sliderSize = sliderSize ?? _platform(sliderSize) { + assert( + this.minSize <= initialSize, + 'The initial size, $initialSize is less than the required minimum size, ${this.minSize}.', + ); + } + + @override + Widget build(BuildContext context) { + final InheritedData(:controller, :data) = InheritedData.of(context); + final enabled = controller.interaction is Resize || data.selected; + return Semantics( + container: true, + enabled: enabled, + selected: data.selected, + child: MouseRegion( + cursor: enabled ? MouseCursor.defer : SystemMouseCursors.click, + child: GestureDetector( + onTap: switch (controller.interaction) { + SelectAndResize _ => () { + if (controller.select(data.index) && controller.hapticFeedbackVelocity != null) { + // TODO: haptic feedback + } + }, + Resize _ => null, + }, + child: switch (controller.axis) { + Axis.horizontal => SizedBox( + width: data.size.current, + child: Stack( + children: [ + builder(context, data, child), + if (data.index > 0) + HorizontalSlider.left( + controller: controller, + index: data.index, + size: sliderSize, + ), + if (data.index < controller.regions.length - 1) + HorizontalSlider.right( + controller: controller, + index: data.index, + size: sliderSize, + ), + ], + ), + ), + Axis.vertical => SizedBox( + height: data.size.current, + child: Stack( + children: [ + builder(context, data, child), + if (data.index > 0) + VerticalSlider.up( + controller: controller, + index: data.index, + size: sliderSize, + ), + if (data.index < controller.regions.length - 1) + VerticalSlider.down( + controller: controller, + index: data.index, + size: sliderSize, + ), + ], + ), + ), + }, + ), + ), + ); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DoubleProperty('initialSize', initialSize)) + ..add(DoubleProperty('minSize', minSize)) + ..add(DoubleProperty('sliderSize', sliderSize)) + ..add(DiagnosticsProperty('builder', builder)) + ..add(DiagnosticsProperty('child', child)); + } +} diff --git a/forui/lib/src/widgets/resizable/resizable_region_data.dart b/forui/lib/src/widgets/resizable/resizable_region_data.dart new file mode 100644 index 000000000..e7af2386f --- /dev/null +++ b/forui/lib/src/widgets/resizable/resizable_region_data.dart @@ -0,0 +1,185 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'package:meta/meta.dart'; + +import 'package:forui/forui.dart'; + +/// A [FResizableRegion]'s data. +final class FResizableRegionData with Diagnosticable { + /// The resizable region's index. + /// + /// ## Contract + /// Throws [AssertionError] if [index] < 0. + final int index; + + /// True if this resized region is selected. Always false if [FResizable.interaction] is + /// [FResizableInteraction.resize]. + final bool selected; + + /// This region's minimum and maximum height/width, in logical pixels, along the main resizable axis. + /// + /// The minimum height/width is determined by [FResizableRegion.minSize]. + /// The maximum height/width is determined by the [FResizable]'s size - the minimum size of all regions. + /// + /// ## Contract + /// Throws [AssertionError] if: + /// * min <= 0 + /// * max <= 0 + /// * max <= min + final ({double min, double current, double max, double allRegions}) size; + + /// This region's current minimum and maximum offset, in logical pixels, along the main resizable axis. + /// + /// Both offsets are relative to the top/left side of the parent [FResizable], or, in other words, relative to 0. + /// + /// ## Contract + /// Throws [AssertionError] if: + /// * min < 0 + /// * max <= min + /// * allRegions <= max + final ({double min, double max}) offset; + + /// Creates a [FResizableRegionData]. + FResizableRegionData({ + required this.index, + required this.selected, + required ({double min, double max, double allRegions}) size, + required this.offset, + }) : assert(0 <= index, 'Index should be non-negative, but is $index.'), + assert(0 < size.min, 'Minimum size should be positive, but is ${size.min}'), + assert( + size.min < size.max, + 'Min size should be less than the min size, but min is ${size.min} and maximum is ${size.max}', + ), + assert( + size.max <= size.allRegions, + 'Maximum size should be less than or equal to all regions size, but maximum is ${size.max} and all regions is ${size.allRegions}', + ), + assert(0 <= offset.min, 'Min offset should be non-negative, but is ${offset.min}'), + assert(0 < offset.max, 'Max offset should be non-negative, but is ${offset.max}'), + assert( + offset.min < offset.max, + 'Min offset should be less than the max offset, but min is ${offset.min} and max is ${offset.max}', + ), + assert( + 0 <= offset.max - offset.min && offset.max - offset.min <= size.max, + 'Current size should be non-negative and less than or equal to the maximum size, but current size is ${offset.max - offset.min} and maximum size is ${size.max}.', + ), + size = (min: size.min, current: offset.max - offset.min, max: size.max, allRegions: size.allRegions); + + /// Returns a copy of this [FResizableRegionData] with the given fields replaced by the new values. + /// + /// ```dart + /// final data = FResizableData( + /// index: 1, + /// selected: false, + /// // Other arguments omitted for brevity + /// ); + /// + /// final copy = data.copyWith(selected: true); + /// print(copy.index); // 1 + /// print(copy.selected); // true + /// ``` + @useResult + FResizableRegionData copyWith({ + int? index, + bool? selected, + double? minSize, + double? maxSize, + double? allRegionsSize, + double? minOffset, + double? maxOffset, + }) => + FResizableRegionData( + index: index ?? this.index, + selected: selected ?? this.selected, + size: (min: minSize ?? size.min, max: maxSize ?? size.max, allRegions: allRegionsSize ?? size.allRegions), + offset: (min: minOffset ?? offset.min, max: maxOffset ?? offset.max), + ); + + /// The offsets as a percentage of the parent [FResizable]'s size. + /// + /// For example, if the offsets are `(200, 400)`, and the [FResizable]'s size is 500, [offsetPercentage] will be + /// `(0.4, 0.8)`. + ({double min, double max}) get offsetPercentage => + (min: offset.min / size.allRegions, max: offset.max / size.allRegions); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FResizableRegionData && + runtimeType == other.runtimeType && + index == other.index && + selected == other.selected && + size == other.size && + offset == other.offset; + + @override + int get hashCode => index.hashCode ^ selected.hashCode ^ size.hashCode ^ offset.hashCode; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(IntProperty('index', index)) + ..add(FlagProperty('selected', value: selected, ifTrue: 'selected', ifFalse: 'not selected')) + ..add(DoubleProperty('minSize', size.min)) + ..add(DoubleProperty('currentSize', size.current)) + ..add(DoubleProperty('maxSize', size.max)) + ..add(DoubleProperty('allRegionsSize', size.allRegions)) + ..add(DoubleProperty('minOffset', offset.min)) + ..add(DoubleProperty('maxOffset', offset.max)) + ..add(DoubleProperty('minOffsetPercentage', offsetPercentage.min)) + ..add(DoubleProperty('maxOffsetPercentage', offsetPercentage.max)); + } +} + +@internal +extension UpdatableResizableRegionData on FResizableRegionData { + /// Updates the current height/width, returning an offset with any shrinkage beyond the minimum height/width removed. + @useResult + (FResizableRegionData, Offset) update(AxisDirection direction, Offset delta) { + final (:min, :max) = offset; + final Offset(:dx, :dy) = delta; + + switch (direction) { + case AxisDirection.left: + final (data, x) = _resize(direction, min + dx, max); + return (data, delta.translate(x, 0)); + + case AxisDirection.right: + final (data, x) = _resize(direction, min, max + dx); + return (data, delta.translate(-x, 0)); + + case AxisDirection.up: + final (data, y) = _resize(direction, min + dy, max); + return (data, delta.translate(0, y)); + + case AxisDirection.down: + final (data, y) = _resize(direction, min, max + dy); + return (data, delta.translate(0, -y)); + } + } + + (FResizableRegionData, double) _resize(AxisDirection direction, double min, double max) { + final newSize = max - min; + assert(0 <= min, '$min should be non-negative.'); + assert(newSize <= size.max, '$newSize should be less than ${size.max}.'); + + if (size.min <= newSize) { + return (copyWith(minOffset: min, maxOffset: max), 0); + } + + switch (direction) { + case AxisDirection.left || AxisDirection.up when offset.min < (max - size.min): + return (copyWith(minOffset: max - size.min), newSize - size.min); + + case AxisDirection.right || AxisDirection.down when (min + size.min) < offset.max: + return (copyWith(maxOffset: min + size.min), newSize - size.min); + + case _: + return (this, newSize - size.min); + } + } +} diff --git a/forui/lib/src/widgets/resizable/slider.dart b/forui/lib/src/widgets/resizable/slider.dart new file mode 100644 index 000000000..33260abc1 --- /dev/null +++ b/forui/lib/src/widgets/resizable/slider.dart @@ -0,0 +1,156 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'package:meta/meta.dart'; + +import 'package:forui/src/widgets/resizable/resizable_controller.dart'; + +@internal +sealed class Slider extends StatelessWidget { + final ResizableController controller; + final Alignment alignment; + final AxisDirection direction; + final MouseCursor cursor; + final double size; + final int index; + + Slider({ + required this.controller, + required this.alignment, + required this.direction, + required this.cursor, + required this.size, + required this.index, + super.key, + }) : assert( + 0 <= index && index < controller.regions.length, + 'Index should be in the range: 0 <= index < ${controller.regions.length}, but it is $index.', + ); + + @override + Widget build(BuildContext context) { + final enabled = switch (controller.interaction) { + Resize _ => true, + SelectAndResize(:final index) when index == this.index => true, + _ => false, + }; + + return Align( + alignment: alignment, + child: MouseRegion( + cursor: enabled ? cursor : MouseCursor.defer, + child: Semantics( + enabled: enabled, + slider: true, + child: _child(enabled: enabled), + ), + ), + ); + } + + Widget _child({required bool enabled}); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('controller', controller)) + ..add(DiagnosticsProperty('alignment', alignment)) + ..add(DiagnosticsProperty('direction', direction)) + ..add(DiagnosticsProperty('cursor', cursor)) + ..add(DoubleProperty('size', size)) + ..add(IntProperty('index', index)); + } +} + +/// A slider to used resize the containing resizable along the horizontal axis. +@internal +final class HorizontalSlider extends Slider { + HorizontalSlider.left({ + required super.controller, + required super.index, + required super.size, + super.key, + }) : super( + alignment: Alignment.centerLeft, + direction: AxisDirection.left, + cursor: SystemMouseCursors.resizeLeftRight, + ); + + HorizontalSlider.right({ + required super.controller, + required super.index, + required super.size, + super.key, + }) : super( + alignment: Alignment.centerRight, + direction: AxisDirection.right, + cursor: SystemMouseCursors.resizeLeftRight, + ); + + @override + Widget _child({required bool enabled}) => SizedBox( + width: size, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragUpdate: (details) { + if (!enabled || details.delta.dx == 0.0) { + return; + } + + final hitBoundary = controller.update(index, direction, details.delta); + final aboveVelocity = (controller.hapticFeedbackVelocity ?? double.infinity) <= details.delta.distance; + if (hitBoundary && aboveVelocity) { + // TODO: haptic feedback + } + }, + onHorizontalDragEnd: (details) => controller.end(index, direction), + ), + ); +} + +/// A slider to used resize the containing resizable along the vertical axis. +@internal +final class VerticalSlider extends Slider { + VerticalSlider.up({ + required super.controller, + required super.index, + required super.size, + super.key, + }) : super( + alignment: Alignment.topCenter, + direction: AxisDirection.up, + cursor: SystemMouseCursors.resizeUpDown, + ); + + VerticalSlider.down({ + required super.controller, + required super.index, + required super.size, + super.key, + }) : super( + alignment: Alignment.bottomCenter, + direction: AxisDirection.down, + cursor: SystemMouseCursors.resizeUpDown, + ); + + @override + Widget _child({required bool enabled}) => SizedBox( + height: size, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onVerticalDragUpdate: (details) { + if (!enabled || details.delta.dy == 0.0) { + return; + } + + final hitBoundary = controller.update(index, direction, details.delta); + final aboveVelocity = (controller.hapticFeedbackVelocity ?? double.infinity) <= details.delta.distance; + if (hitBoundary && aboveVelocity) { + // TODO: haptic feedback + } + }, + onVerticalDragEnd: (details) => controller.end(index, direction), + ), + ); +} diff --git a/forui/lib/src/widgets/separator.dart b/forui/lib/src/widgets/separator.dart index 7e624ba5c..1a72097e2 100644 --- a/forui/lib/src/widgets/separator.dart +++ b/forui/lib/src/widgets/separator.dart @@ -80,6 +80,7 @@ final class FSeparatorStyles with Diagnosticable { /// print(style.horizontal == copy.horizontal); // true /// print(style.vertical == copy.vertical); // false /// ``` + @useResult FSeparatorStyles copyWith({FSeparatorStyle? horizontal, FSeparatorStyle? vertical}) => FSeparatorStyles( horizontal: horizontal ?? this.horizontal, vertical: vertical ?? this.vertical, @@ -121,7 +122,7 @@ final class FSeparatorStyle with Diagnosticable { /// The width of the separating line. Defaults to 1. /// - /// ## Contract: + /// ## Contract /// Throws [AssertionError] if: /// * `width` <= 0.0 /// * `width` is Nan diff --git a/forui/lib/src/widgets/tabs/tabs.dart b/forui/lib/src/widgets/tabs/tabs.dart index 93fe4861d..cac0d09ec 100644 --- a/forui/lib/src/widgets/tabs/tabs.dart +++ b/forui/lib/src/widgets/tabs/tabs.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:meta/meta.dart'; import 'package:forui/forui.dart'; diff --git a/forui/lib/src/widgets/tabs/tabs_style.dart b/forui/lib/src/widgets/tabs/tabs_style.dart index 561fe0848..7529d7a0f 100644 --- a/forui/lib/src/widgets/tabs/tabs_style.dart +++ b/forui/lib/src/widgets/tabs/tabs_style.dart @@ -83,6 +83,7 @@ final class FTabsStyle with Diagnosticable { spacing = 10; /// Creates a copy of this [FCardStyle] with the given properties replaced. + @useResult FTabsStyle copyWith({ EdgeInsets? padding, BoxDecoration? decoration, diff --git a/forui/lib/widgets.dart b/forui/lib/widgets.dart index 6da3bd41b..985949896 100644 --- a/forui/lib/widgets.dart +++ b/forui/lib/widgets.dart @@ -31,6 +31,7 @@ export 'src/widgets/header/header.dart'; export 'src/widgets/progress.dart'; export 'src/widgets/tabs/tabs.dart'; export 'src/widgets/text_field/text_field.dart'; +export 'src/widgets/resizable/resizable.dart' hide InheritedData; export 'src/widgets/scaffold.dart'; export 'src/widgets/separator.dart'; export 'src/widgets/switch.dart'; diff --git a/forui/pubspec.yaml b/forui/pubspec.yaml index e9f214893..3bcfc2973 100644 --- a/forui/pubspec.yaml +++ b/forui/pubspec.yaml @@ -34,6 +34,7 @@ dev_dependencies: flint: ^2.8.1 flutter_test: sdk: flutter + mockito: ^5.4.4 #dependency_overrides: # forui_assets: diff --git a/forui/test/src/widgets/resizable/resizable_controller_test.dart b/forui/test/src/widgets/resizable/resizable_controller_test.dart new file mode 100644 index 000000000..9182b2d63 --- /dev/null +++ b/forui/test/src/widgets/resizable/resizable_controller_test.dart @@ -0,0 +1,162 @@ +import 'package:flutter/widgets.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:forui/forui.dart'; +import 'package:forui/src/widgets/resizable/resizable_controller.dart'; + +void main() { + late ResizableController controller; + + late FResizableRegionData top; + late FResizableRegionData middle; + late FResizableRegionData bottom; + + late int selectedIndex; + late int count; + (FResizableRegionData, FResizableRegionData)? resizeUpdate; + (FResizableRegionData, FResizableRegionData)? resizeEnd; + + setUp(() { + selectedIndex = 0; + count = 0; + resizeUpdate = null; + resizeEnd = null; + + top = FResizableRegionData( + index: 0, + selected: true, + size: (min: 10, max: 40, allRegions: 60), + offset: (min: 0, max: 25), + ); + middle = FResizableRegionData( + index: 1, + selected: false, + size: (min: 10, max: 40, allRegions: 60), + offset: (min: 25, max: 40), + ); + bottom = FResizableRegionData( + index: 2, + selected: false, + size: (min: 10, max: 40, allRegions: 60), + offset: (min: 40, max: 60), + ); + + controller = ResizableController( + regions: [top, middle, bottom], + axis: Axis.vertical, + hapticFeedbackVelocity: 0.0, + onPress: (index) => selectedIndex = index, + onResizeUpdate: (selected, neighbour) => resizeUpdate = (selected, neighbour), + onResizeEnd: (selected, neighbour) => resizeEnd = (selected, neighbour), + interaction: const SelectAndResize(0), + )..addListener(() => count++); + }); + + for (final (i, (index, direction, offset, topOffsets, middleOffsets, maximized)) in [ + (1, AxisDirection.left, const Offset(-100, 0), (0, 10), (10, 40), false), + (1, AxisDirection.left, const Offset(100, 0), (0, 30), (30, 40), false), + // + (0, AxisDirection.right, const Offset(-100, 0), (0, 10), (10, 40), false), + (0, AxisDirection.right, const Offset(100, 0), (0, 30), (30, 40), false), + // + (1, AxisDirection.up, const Offset(0, -100), (0, 10), (10, 40), false), + (1, AxisDirection.up, const Offset(0, 100), (0, 30), (30, 40), false), + // + (0, AxisDirection.down, const Offset(0, -100), (0, 10), (10, 40), false), + (0, AxisDirection.down, const Offset(0, 100), (0, 30), (30, 40), false), + ].indexed) { + test('[$i] update(...) direction', () { + expect(controller.update(index, direction, offset), maximized); + + expect( + controller.regions[0].offset, + (min: topOffsets.$1, max: topOffsets.$2), + ); + expect( + controller.regions[1].offset, + (min: middleOffsets.$1, max: middleOffsets.$2), + ); + expect(controller.regions[2].offset, (min: 40, max: 60)); + + expect(count, 1); + expect(resizeUpdate?.$1.index, index); + expect(resizeUpdate?.$2.index, index == 1 ? 0 : 1); + }); + } + + for (final (i, (selected, neighbour, direction)) in [ + (1, 0, AxisDirection.left), + (1, 0, AxisDirection.left), + (0, 1, AxisDirection.right), + (0, 1, AxisDirection.right), + (1, 0, AxisDirection.up), + (1, 0, AxisDirection.up), + (0, 1, AxisDirection.down), + (0, 1, AxisDirection.down), + ].indexed) { + test('[$i] end calls callback', () { + controller.end(selected, direction); + + expect(resizeEnd?.$1.index, selected); + expect(resizeEnd?.$2.index, neighbour); + expect(count, 1); + }); + } + + test('selected top when interaction is SelectAndResize', () { + expect(controller.interaction, const SelectAndResize(0)); + expect(selectedIndex, 0); + + expect(controller.select(0), false); + + expect(controller.interaction, const SelectAndResize(0)); + expect(selectedIndex, 0); + expect(count, 0); + + expect(controller.regions[0].selected, true); + expect(controller.regions[1].selected, false); + expect(controller.regions[2].selected, false); + }); + + test('selected bottom when interaction is SelectAndResize', () { + expect(controller.interaction, const SelectAndResize(0)); + expect(selectedIndex, 0); + + expect(controller.select(2), true); + + expect(controller.interaction, const SelectAndResize(2)); + expect(selectedIndex, 2); + expect(count, 1); + + expect(controller.regions[0].selected, false); + expect(controller.regions[1].selected, false); + expect(controller.regions[2].selected, true); + }); + + test('selected bottom when interaction is Resize', () { + controller = ResizableController( + regions: [top.copyWith(selected: false), middle, bottom], + axis: Axis.vertical, + hapticFeedbackVelocity: 0.0, + onPress: (index) => selectedIndex = index, + onResizeUpdate: (selected, neighbour) => resizeUpdate = (selected, neighbour), + onResizeEnd: (selected, neighbour) => resizeEnd = (selected, neighbour), + interaction: const Resize(), + )..addListener(() => count++); + selectedIndex = -1; + + expect(controller.interaction, const Resize()); + expect(selectedIndex, -1); + + expect(controller.select(2), false); + + expect(controller.interaction, const Resize()); + expect(selectedIndex, -1); + expect(count, 0); + + expect(controller.regions[0].selected, false); + expect(controller.regions[1].selected, false); + expect(controller.regions[2].selected, false); + }); +} diff --git a/forui/test/src/widgets/resizable/resizable_data_test.dart b/forui/test/src/widgets/resizable/resizable_data_test.dart new file mode 100644 index 000000000..0334a0ce2 --- /dev/null +++ b/forui/test/src/widgets/resizable/resizable_data_test.dart @@ -0,0 +1,182 @@ +import 'package:flutter/widgets.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:forui/src/widgets/resizable/resizable_region_data.dart'; + +void main() { + group('FResizableRegionData', () { + for (final (index, function) in [ + () => FResizableRegionData( + index: 1, + selected: false, + size: (min: 1, max: -2, allRegions: 10), + offset: (min: 1, max: 2), + ), + () => FResizableRegionData( + index: 1, + selected: false, + size: (min: -1, max: 2, allRegions: 10), + offset: (min: 1, max: 2), + ), + () => FResizableRegionData( + index: 1, + selected: false, + size: (min: 1, max: 1, allRegions: 10), + offset: (min: 1, max: 2), + ), + () => FResizableRegionData( + index: 1, + selected: false, + size: (min: 2, max: 1, allRegions: 10), + offset: (min: 1, max: 2), + ), + () => FResizableRegionData( + index: 1, + selected: false, + size: (min: 1, max: 5, allRegions: 10), + offset: (min: 1, max: 1), + ), + () => FResizableRegionData( + index: 1, + selected: false, + size: (min: 1, max: 5, allRegions: 10), + offset: (min: 2, max: 1), + ), + () => FResizableRegionData( + index: 1, + selected: false, + size: (min: 1, max: 5, allRegions: 10), + offset: (min: 1, max: 1), + ), + () => FResizableRegionData( + index: 1, + selected: false, + size: (min: 1, max: 5, allRegions: 10), + offset: (min: 1, max: 10), + ), + () => FResizableRegionData( + index: -1, + selected: false, + size: (min: 1, max: 5, allRegions: 10), + offset: (min: 1, max: 3), + ), + ].indexed) { + test( + '[$index] constructor throws error', + () => expect(function, throwsAssertionError), + ); + } + + test( + 'percentage', + () => expect( + FResizableRegionData( + index: 1, + selected: false, + size: (min: 1, max: 10, allRegions: 100), + offset: (min: 0, max: 5), + ).offsetPercentage, + (min: 0, max: 0.05), + ), + ); + + test( + 'size', + () => expect( + FResizableRegionData( + index: 1, + selected: false, + size: (min: 1, max: 10, allRegions: 100), + offset: (min: 0, max: 5), + ).size.current, + 5, + ), + ); + }); + + group('UpdatableResizableData', () { + for (final (index, (direction, delta, translated, min, max)) in [ + (AxisDirection.left, const Offset(-10, 0), const Offset(-10, 0), 10.0, 50.0), + (AxisDirection.left, const Offset(10, 0), const Offset(10, 0), 30.0, 50.0), + (AxisDirection.left, const Offset(50, 0), const Offset(20, 0), 40.0, 50.0), + // + (AxisDirection.right, const Offset(10, 0), const Offset(10, 0), 20.0, 60.0), + (AxisDirection.right, const Offset(-10, 0), const Offset(-10, 0), 20.0, 40.0), + (AxisDirection.right, const Offset(-50, 0), const Offset(-20, 0), 20.0, 30.0), + // + (AxisDirection.up, const Offset(0, -10), const Offset(0, -10), 10.0, 50.0), + (AxisDirection.up, const Offset(0, 10), const Offset(0, 10), 30.0, 50.0), + (AxisDirection.up, const Offset(0, 50), const Offset(0, 20), 40.0, 50.0), + // + (AxisDirection.down, const Offset(0, 10), const Offset(0, 10), 20.0, 60.0), + (AxisDirection.down, const Offset(0, -10), const Offset(0, -10), 20.0, 40.0), + (AxisDirection.down, const Offset(0, -50), const Offset(0, -20), 20.0, 30.0), + ].indexed) { + test('[$index] update(...)', () { + final data = FResizableRegionData( + index: 0, + selected: true, + size: (min: 10, max: 100, allRegions: 100), + offset: (min: 20, max: 50), + ); + + final (updated, updatedDelta) = data.update(direction, delta); + + expect(updated.offset, (min: min, max: max)); + expect(updatedDelta, translated); + }); + } + + test('update(...) throws error', () { + final data = FResizableRegionData( + index: 0, + selected: true, + size: (min: 10, max: 100, allRegions: 100), + offset: (min: 0, max: 30), + ); + + expect( + () => data.update(AxisDirection.left, const Offset(-10, 0)), + throwsAssertionError, + ); + }); + + for (final (index, (direction, delta, min, max)) in [ + (AxisDirection.left, const Offset(10, 0), 10.0, 20.0), + (AxisDirection.left, const Offset(50, 0), 10.0, 20.0), + (AxisDirection.right, const Offset(-10, 0), 10.0, 20.0), + (AxisDirection.right, const Offset(-50, 0), 10.0, 20.0), + (AxisDirection.up, const Offset(0, 10), 10.0, 20.0), + (AxisDirection.up, const Offset(0, 50), 10.0, 20.0), + (AxisDirection.down, const Offset(0, -10), 10.0, 20.0), + (AxisDirection.down, const Offset(0, -50), 10.0, 20.0), + ].indexed) { + test('[$index] update(...) beyond min/max size', () { + final data = FResizableRegionData( + index: 0, + selected: true, + size: (min: 10, max: 100, allRegions: 100), + offset: (min: min, max: max), + ); + final (updated, updatedDelta) = data.update(direction, delta); + + expect(updated.offset, (min: min, max: max)); + expect(updatedDelta, Offset.zero); + }); + } + + test( + 'update(...) beyond total', + () => expect( + () => FResizableRegionData( + index: 0, + selected: true, + size: (min: 1, max: 5, allRegions: 100), + offset: (min: 0, max: 2), + ).update(AxisDirection.right, const Offset(4, 0)), + throwsAssertionError, + ), + ); + }); +} diff --git a/forui/test/src/widgets/resizable/resizable_region_test.dart b/forui/test/src/widgets/resizable/resizable_region_test.dart new file mode 100644 index 000000000..31198108a --- /dev/null +++ b/forui/test/src/widgets/resizable/resizable_region_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter/widgets.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:forui/forui.dart'; + +Widget stub(BuildContext context, FResizableRegionData data, Widget? child) => child!; + +void main() { + group('FResizable', () { + for (final (index, constructor) in [ + () => FResizableRegion.raw(initialSize: 0, sliderSize: 10, builder: stub), + () => FResizableRegion.raw(initialSize: 10, sliderSize: 0, builder: stub), + () => FResizableRegion.raw(initialSize: 10, sliderSize: 10, builder: stub), + () => FResizableRegion.raw( + initialSize: 10, + sliderSize: 10, + minSize: 0, + builder: stub, + ), + () => FResizableRegion.raw( + initialSize: 10, + sliderSize: 2, + minSize: 20, + builder: stub, + ), + ].indexed) { + test( + '[$index] constructor throws error', + () => expect(constructor, throwsAssertionError), + ); + } + }); +} diff --git a/forui/test/src/widgets/resizable/resizable_test.dart b/forui/test/src/widgets/resizable/resizable_test.dart new file mode 100644 index 000000000..171dacbd3 --- /dev/null +++ b/forui/test/src/widgets/resizable/resizable_test.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:forui/forui.dart'; + +void main() { + final top = FResizableRegion.raw( + initialSize: 30, + sliderSize: 10, + builder: (context, snapshot, child) => const SizedBox(), + ); + + final bottom = FResizableRegion.raw( + initialSize: 70, + sliderSize: 10, + builder: (context, snapshot, child) => const SizedBox(), + ); + + for (final (index, constructor) in [ + () => FResizable( + crossAxisExtent: 0, + axis: Axis.vertical, + children: [top, bottom], + ), + () => FResizable( + crossAxisExtent: 10, + axis: Axis.vertical, + interaction: const FResizableInteraction.selectAndResize(-1), + children: [top, bottom], + ), + () => FResizable( + crossAxisExtent: 10, + axis: Axis.vertical, + interaction: const FResizableInteraction.selectAndResize(2), + children: [top, bottom], + ), + ].indexed) { + test( + '[$index] constructor throws error', + () => expect(constructor, throwsAssertionError), + ); + } + + testWidgets('vertical drag downwards', (tester) async { + final vertical = FResizable( + crossAxisExtent: 50, + axis: Axis.vertical, + children: [top, bottom], + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Scaffold(body: Center(child: vertical)), + ), + ); + + await tester.timedDrag( + find.byType(GestureDetector).at(1), + const Offset(0, 100), + const Duration(seconds: 1), + ); + await tester.pumpAndSettle(); + + expect( + tester.getSize(find.byType(FResizableRegion).first), + const Size(50, 80), + ); + expect( + tester.getSize(find.byType(FResizableRegion).last), + const Size(50, 20), + ); + }); + + testWidgets('no vertical drag when disabled', (tester) async { + final vertical = FResizable( + crossAxisExtent: 50, + axis: Axis.vertical, + interaction: const FResizableInteraction.selectAndResize(1), + children: [top, bottom], + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Scaffold(body: Center(child: vertical)), + ), + ); + + await tester.tap(find.byType(GestureDetector).at(3)); + + await tester.timedDrag( + find.byType(GestureDetector).at(1), + const Offset(0, 100), + const Duration(seconds: 1), + ); + await tester.pumpAndSettle(); + + expect( + tester.getSize(find.byType(FResizableRegion).first), + const Size(50, 30), + ); + expect( + tester.getSize(find.byType(FResizableRegion).last), + const Size(50, 70), + ); + }); + + testWidgets('vertical drag upwards', (tester) async { + final vertical = FResizable( + crossAxisExtent: 50, + axis: Axis.vertical, + interaction: const FResizableInteraction.selectAndResize(0), + children: [top, bottom], + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Scaffold(body: Center(child: vertical)), + ), + ); + + await tester.timedDrag( + find.byType(GestureDetector).at(1), + const Offset(0, -100), + const Duration(seconds: 1), + ); + await tester.pumpAndSettle(); + + expect( + tester.getSize(find.byType(FResizableRegion).first), + const Size(50, 20), + ); + expect( + tester.getSize(find.byType(FResizableRegion).last), + const Size(50, 80), + ); + }); + + testWidgets('horizontal drag right', (tester) async { + final horizontal = FResizable( + crossAxisExtent: 50, + axis: Axis.horizontal, + interaction: const FResizableInteraction.selectAndResize(0), + children: [top, bottom], + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Scaffold(body: Center(child: horizontal)), + ), + ); + + await tester.timedDrag( + find.byType(GestureDetector).at(1), + const Offset(100, 0), + const Duration(seconds: 1), + ); + await tester.pumpAndSettle(); + + expect( + tester.getSize(find.byType(FResizableRegion).first), + const Size(80, 50), + ); + expect( + tester.getSize(find.byType(FResizableRegion).last), + const Size(20, 50), + ); + }); + + testWidgets('horizontal drag left', (tester) async { + final horizontal = FResizable( + crossAxisExtent: 50, + axis: Axis.horizontal, + interaction: const FResizableInteraction.selectAndResize(0), + children: [top, bottom], + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Scaffold(body: Center(child: horizontal)), + ), + ); + + await tester.timedDrag( + find.byType(GestureDetector).at(1), + const Offset(-100, 0), + const Duration(seconds: 1), + ); + await tester.pumpAndSettle(); + + expect( + tester.getSize(find.byType(FResizableRegion).first), + const Size(20, 50), + ); + expect( + tester.getSize(find.byType(FResizableRegion).last), + const Size(80, 50), + ); + }); + + testWidgets('horizontal drag when disabled', (tester) async { + final horizontal = FResizable( + crossAxisExtent: 50, + axis: Axis.horizontal, + interaction: const FResizableInteraction.selectAndResize(0), + children: [top, bottom], + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Scaffold(body: Center(child: horizontal)), + ), + ); + + await tester.timedDrag( + find.byType(GestureDetector).at(1), + const Offset(100, 0), + const Duration(seconds: 1), + ); + await tester.pumpAndSettle(); + + expect( + tester.getSize(find.byType(FResizableRegion).first), + const Size(80, 50), + ); + expect( + tester.getSize(find.byType(FResizableRegion).last), + const Size(20, 50), + ); + }); + + testWidgets('no horizontal drag when disabled', (tester) async { + final horizontal = FResizable( + crossAxisExtent: 50, + axis: Axis.horizontal, + interaction: const FResizableInteraction.selectAndResize(1), + children: [top, bottom], + ); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Scaffold(body: Center(child: horizontal)), + ), + ); + + await tester.tap(find.byType(GestureDetector).at(3)); + + await tester.timedDrag( + find.byType(GestureDetector).at(1), + const Offset(-100, 0), + const Duration(seconds: 1), + ); + await tester.pumpAndSettle(); + + expect( + tester.getSize(find.byType(FResizableRegion).first), + const Size(30, 50), + ); + expect( + tester.getSize(find.byType(FResizableRegion).last), + const Size(70, 50), + ); + }); +} diff --git a/forui/test/src/widgets/resizable/slider_test.dart b/forui/test/src/widgets/resizable/slider_test.dart new file mode 100644 index 000000000..3cb389114 --- /dev/null +++ b/forui/test/src/widgets/resizable/slider_test.dart @@ -0,0 +1,207 @@ +import 'package:flutter/widgets.dart'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'package:forui/forui.dart'; +import 'package:forui/src/widgets/resizable/resizable_controller.dart'; +import 'package:forui/src/widgets/resizable/slider.dart'; +import 'slider_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + provideDummy(const FResizableInteraction.resize()); + + late MockResizableController controller; + late FResizableRegionData data; + + setUp(() { + data = FResizableRegionData( + index: 0, + selected: true, + size: (min: 10, max: 100, allRegions: 100), + offset: (min: 10, max: 100), + ); + controller = MockResizableController(); + when(controller.regions).thenReturn([data]); + when(controller.hapticFeedbackVelocity).thenReturn(0.0); + }); + + for (final (index, constructor) in [ + () => HorizontalSlider.left(controller: controller, index: -1, size: 500), + () => HorizontalSlider.right(controller: controller, index: -1, size: 500), + () => VerticalSlider.up(controller: controller, index: -1, size: 500), + () => VerticalSlider.down(controller: controller, index: -1, size: 500), + // + () => HorizontalSlider.left(controller: controller, index: 1, size: 500), + () => HorizontalSlider.right(controller: controller, index: 1, size: 500), + () => VerticalSlider.up(controller: controller, index: 1, size: 500), + () => VerticalSlider.down(controller: controller, index: 1, size: 500), + ].indexed) { + test( + '[$index] constructor throws error', + () => expect(constructor, throwsAssertionError), + ); + } + + for (final (index, (function, direction)) in [ + (() => HorizontalSlider.left(controller: controller, index: 0, size: 50), AxisDirection.left), + (() => HorizontalSlider.right(controller: controller, index: 0, size: 50), AxisDirection.right), + ].indexed) { + group('[$index] horizontal slider', () { + late HorizontalSlider slider; + + setUp(() => slider = function()); + + testWidgets('size', (tester) async { + await tester.pumpWidget(slider); + + final Size(:height, :width) = tester.getSize(find.byType(SizedBox)); + expect(height, isNot(50)); + expect(width, 50); + }); + + for (final (index, offset) in [ + const Offset(100, 0), + const Offset(-100, 0), + ].indexed) { + testWidgets('[$index] enabled horizontal drag, causes haptic feedback', (tester) async { + when(controller.interaction).thenReturn(const SelectAndResize(0)); + when(controller.update(any, any, any)).thenReturn(true); + + await tester.pumpWidget(slider); + await tester.drag(find.byType(GestureDetector), offset); + + verify(controller.update(0, direction, any)); + }); + + testWidgets('[$index] enabled horizontal drag, causes haptic feedback', (tester) async { + when(controller.interaction).thenReturn(const SelectAndResize(0)); + when(controller.update(any, any, any)).thenReturn(true); + + await tester.pumpWidget(slider); + await tester.drag(find.byType(GestureDetector), offset); + + verify(controller.update(0, direction, any)); + }); + + testWidgets('[$index] enabled horizontal drag, haptic feedback disabled', (tester) async { + when(controller.interaction).thenReturn(const SelectAndResize(0)); + when(controller.hapticFeedbackVelocity).thenReturn(null); + when(controller.update(any, any, any)).thenReturn(true); + + await tester.pumpWidget(slider); + await tester.drag(find.byType(GestureDetector), offset); + + verify(controller.update(0, direction, any)); + }); + + testWidgets('[$index] disabled horizontal drag', (tester) async { + when(controller.update(any, any, any)).thenReturn(true); + when(controller.interaction).thenReturn(const SelectAndResize(1)); + + await tester.pumpWidget(slider); + await tester.drag(find.byType(GestureDetector), offset); + + verifyNever(controller.update(0, direction, any)); + }); + } + + for (final (index, offset) in [ + const Offset(0, 1000), + const Offset(0, -1000), + ].indexed) { + testWidgets('[$index] enabled vertical drag, causes haptic feedback', (tester) async { + when(controller.interaction).thenReturn(const SelectAndResize(0)); + when(controller.update(any, any, any)).thenReturn(true); + + await tester.pumpWidget(slider); + await tester.drag(find.byType(GestureDetector), offset); + + verifyNever(controller.update(0, direction, any)); + }); + } + }); + } + + for (final (index, (function, direction)) in [ + (() => VerticalSlider.up(controller: controller, index: 0, size: 50), AxisDirection.up), + (() => VerticalSlider.down(controller: controller, index: 0, size: 50), AxisDirection.down), + ].indexed) { + group('[$index] vertical slider', () { + late VerticalSlider slider; + + setUp(() => slider = function()); + + testWidgets('size', (tester) async { + await tester.pumpWidget(slider); + + final Size(:height, :width) = tester.getSize(find.byType(SizedBox)); + expect(height, 50); + expect(width, isNot(50)); + }); + + for (final (index, offset) in [ + const Offset(0, 1000), + const Offset(0, -1000), + ].indexed) { + testWidgets('[$index] enabled vertical drag', (tester) async { + when(controller.update(any, any, any)).thenReturn(true); + when(controller.interaction).thenReturn(const SelectAndResize(0)); + + await tester.pumpWidget(slider); + await tester.drag(find.byType(GestureDetector), offset); + + verify(controller.update(0, direction, any)); + }); + + testWidgets('[$index] enabled horizontal drag, causes haptic feedback', (tester) async { + when(controller.update(any, any, any)).thenReturn(true); + when(controller.interaction).thenReturn(const SelectAndResize(0)); + + await tester.pumpWidget(slider); + await tester.drag(find.byType(GestureDetector), offset); + + verify(controller.update(0, direction, any)); + }); + + testWidgets('[$index] enabled horizontal drag, haptic feedback disabled', (tester) async { + when(controller.interaction).thenReturn(const SelectAndResize(0)); + when(controller.hapticFeedbackVelocity).thenReturn(null); + when(controller.update(any, any, any)).thenReturn(true); + + await tester.pumpWidget(slider); + await tester.drag(find.byType(GestureDetector), offset); + + verify(controller.update(0, direction, any)); + }); + + testWidgets('[$index] disabled vertical drag', (tester) async { + when(controller.update(any, any, any)).thenReturn(true); + when(controller.interaction).thenReturn(const SelectAndResize(1)); + + await tester.pumpWidget(slider); + await tester.drag(find.byType(GestureDetector), offset); + + verifyNever(controller.update(0, direction, any)); + }); + } + + for (final (index, offset) in [ + const Offset(100, 0), + const Offset(-100, 0), + ].indexed) { + testWidgets('[$index] enabled horizontal drag', (tester) async { + when(controller.update(any, any, any)).thenReturn(true); + when(controller.interaction).thenReturn(const SelectAndResize(0)); + + await tester.pumpWidget(slider); + await tester.drag(find.byType(GestureDetector), offset); + + verifyNever(controller.update(0, direction, any)); + }); + } + }); + } +} diff --git a/samples/ios/Podfile b/samples/ios/Podfile new file mode 100644 index 000000000..c9339a034 --- /dev/null +++ b/samples/ios/Podfile @@ -0,0 +1,41 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '12.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/samples/lib/main.dart b/samples/lib/main.dart index 66e8c97d7..c4ebcaaa2 100644 --- a/samples/lib/main.dart +++ b/samples/lib/main.dart @@ -107,6 +107,14 @@ class _AppRouter extends $_AppRouter { path: '/progress/default', page: ProgressRoute.page, ), + AutoRoute( + path: '/resizable/default', + page: ResizableRoute.page, + ), + AutoRoute( + path: '/resizable/horizontal', + page: HorizontalResizableRoute.page, + ), AutoRoute( path: '/tabs/default', page: TabsRoute.page, diff --git a/samples/lib/widgets/resizable.dart b/samples/lib/widgets/resizable.dart new file mode 100644 index 000000000..64bab1c81 --- /dev/null +++ b/samples/lib/widgets/resizable.dart @@ -0,0 +1,157 @@ +import 'package:flutter/widgets.dart'; + +import 'package:auto_route/auto_route.dart'; +import 'package:forui/forui.dart'; +import 'package:intl/intl.dart'; + +import 'package:forui_samples/sample_scaffold.dart'; + +@RoutePage() +class ResizablePage extends SampleScaffold { + ResizablePage({ + @queryParam super.theme, + }); + + @override + Widget child(BuildContext context) => FResizable( + axis: Axis.vertical, + crossAxisExtent: 400, + interaction: const FResizableInteraction.selectAndResize(0), + children: [ + FResizableRegion.raw( + initialSize: 200, + minSize: 100, + builder: (context, data, _) { + final colorScheme = context.theme.colorScheme; + return Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: data.selected ? colorScheme.foreground : colorScheme.background, + borderRadius: const BorderRadius.vertical(top: Radius.circular(8)), + border: Border.all(color: colorScheme.border), + ), + child: Label(data: data, icon: FAssets.icons.sunrise, label: 'Morning'), + ); + }, + ), + FResizableRegion.raw( + initialSize: 200, + minSize: 100, + builder: (context, data, _) { + final colorScheme = context.theme.colorScheme; + return Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: data.selected ? colorScheme.foreground : colorScheme.background, + border: Border.all(color: colorScheme.border), + ), + child: Label(data: data, icon: FAssets.icons.sun, label: 'Afternoon'), + ); + }, + ), + FResizableRegion.raw( + initialSize: 200, + minSize: 100, + builder: (context, data, _) { + final colorScheme = context.theme.colorScheme; + return Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: data.selected ? colorScheme.foreground : colorScheme.background, + borderRadius: const BorderRadius.vertical(bottom: Radius.circular(8)), + border: Border.all(color: colorScheme.border), + ), + child: Label(data: data, icon: FAssets.icons.moon, label: 'Night'), + ); + }, + ), + ], + ); +} + +class Label extends StatelessWidget { + static final DateFormat format = DateFormat.jm(); // Requires package:intl + + final FResizableRegionData data; + final SvgAsset icon; + final String label; + + const Label({required this.data, required this.icon, required this.label, super.key}); + + @override + Widget build(BuildContext context) { + final FThemeData(:colorScheme, :typography) = context.theme; + final color = data.selected ? colorScheme.background : colorScheme.foreground; + final start = DateTime.fromMillisecondsSinceEpoch( + (data.offsetPercentage.min * Duration.millisecondsPerDay).round(), + isUtc: true, + ); + final end = DateTime.fromMillisecondsSinceEpoch( + (data.offsetPercentage.max * Duration.millisecondsPerDay).round(), + isUtc: true, + ); + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + icon(height: 15, colorFilter: ColorFilter.mode(color, BlendMode.srcIn)), + const SizedBox(width: 3), + Text(label, style: typography.sm.copyWith(color: color)), + ], + ), + const SizedBox(height: 5), + Text('${format.format(start)} - ${format.format(end)}', style: typography.sm.copyWith(color: color)), + ], + ); + } +} + +@RoutePage() +class HorizontalResizablePage extends SampleScaffold { + HorizontalResizablePage({ + @queryParam super.theme, + }); + + @override + Widget child(BuildContext context) => FResizable( + axis: Axis.horizontal, + crossAxisExtent: 300, + children: [ + FResizableRegion.raw( + initialSize: 100, + minSize: 100, + builder: (context, data, _) { + final colorScheme = context.theme.colorScheme; + return Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: data.selected ? colorScheme.foreground : colorScheme.background, + borderRadius: const BorderRadius.vertical(top: Radius.circular(8)), + border: Border.all(color: colorScheme.border), + ), + child: Text('Sidebar', style: context.theme.typography.sm), + ); + }, + ), + FResizableRegion.raw( + initialSize: 300, + minSize: 100, + builder: (context, data, _) { + final colorScheme = context.theme.colorScheme; + return Container( + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: const BorderRadius.vertical(bottom: Radius.circular(8)), + border: Border.all(color: colorScheme.border), + ), + child: Text('Content', style: context.theme.typography.sm), + ); + }, + ), + ], + ); +} diff --git a/samples/macos/Podfile b/samples/macos/Podfile new file mode 100644 index 000000000..c795730db --- /dev/null +++ b/samples/macos/Podfile @@ -0,0 +1,43 @@ +platform :osx, '10.14' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/samples/pubspec.lock b/samples/pubspec.lock index 9968df5e8..4f8bd5ab7 100644 --- a/samples/pubspec.lock +++ b/samples/pubspec.lock @@ -267,7 +267,7 @@ packages: path: "../forui" relative: true source: path - version: "0.2.0+3" + version: "0.3.0" forui_assets: dependency: "direct overridden" description: @@ -332,7 +332,7 @@ packages: source: hosted version: "4.0.2" intl: - dependency: transitive + dependency: "direct main" description: name: intl sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf diff --git a/samples/pubspec.yaml b/samples/pubspec.yaml index 8931177d0..fc338bb43 100644 --- a/samples/pubspec.yaml +++ b/samples/pubspec.yaml @@ -36,6 +36,7 @@ dependencies: sdk: flutter forui: path: ../forui + intl: ^0.19.0 sugar: ^3.1.0 dev_dependencies: