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: