diff --git a/CHANGELOG.md b/CHANGELOG.md index 23341ea..44cd95d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,105 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.0] - Major Release with New Extensions + +### Added + +#### Iterable/List Extensions +- **Query & Search:** + - `firstOrNull()` - Returns first element or `null` if empty + - `lastOrNull()` - Returns last element or `null` if empty + - `singleWhereOrNull(predicate)` - Returns match or `null` + - `containsWhere(predicate)` - Boolean check + - `indexWhereOrNull(predicate)` - Returns index or `null` + +- **Safe Access:** + - `getOrNull(index)` - Returns element at index or `null` + - `getOrDefault(index, defaultValue)` - Returns element or default value + +- **Transformations:** + - `chunked(size)` - Splits into chunks + - `mapIndexed((index, item) => ...)` - Maps with index + - `whereNotNull()` - Filters out nulls + - `distinctBy(keySelector)` - Unique items by property + - `flatten()` - Flattens nested lists + - `sortedBy(keySelector)` / `sortedByDesc(keySelector)` - Sort by property + - `flip()` - Reverses the list + +- **Aggregations (for numbers):** + - `sumBy(num Function(T))` - Sum elements by selector + - `averageBy(num Function(T))` - Average by selector + - `countWhere(predicate)` - Count matching elements + +- **Mutation Helpers (returns new copy):** + - `insertIf(condition, value)` - Insert conditionally + - `replaceWhere(predicate, newValue)` - Replace matching elements + - `removeWhereNot(predicate)` - Keep only matching elements + - `updateWhere(predicate, updater)` - Update matching elements + - `addIf(value)` / `addAllIf(values)` - Add conditionally + - `append(value)` / `appendAll(values)` - Append elements + - `appendIf(value)` / `appendAllIf(values)` - Append conditionally + - `pop()` - Remove and return last element + - `fliter(predicate)` - Filter elements (alias for where) + - `unique()` - Get unique elements + +- **Utility:** + - `isNullOrEmpty()` - Check if empty + - `joinToString(separator, transform)` - Join with custom format + - `forEachIndexed()` - Iterate with index + +#### Map Extensions +- **Safe Access:** + - `getOrNull(key)` - Get value or null + - `getOrDefault(key, defaultValue)` - Get value or default + +- **Transformations:** + - `mapKeys((k, v) => newKey)` - Transform keys + - `mapValues((k, v) => newValue)` - Transform values + - `filterKeys(predicate)` - Filter by keys + - `filterValues(predicate)` - Filter by values + - `invert()` - Swap keys and values + +- **Merge & Combine:** + - `merge(otherMap)` - Merge with precedence + - `mergeIfAbsent(otherMap)` - Merge without overriding + - `combine(other, (k, v1, v2) => mergedValue)` - Custom merge + +- **Utility:** + - `keysWhere(predicate)` - Get keys matching predicate + - `valuesWhere(predicate)` - Get values matching predicate + - `toQueryString()` - Convert to URL query string + +#### Set Extensions +- `toggle(element)` - Add if missing, remove if present +- `intersects(otherSet)` - Check for intersection +- `isSubsetOf(otherSet)` - Check if subset +- `isSupersetOf(otherSet)` - Check if superset +- `unionAll(sets)` - Union of multiple sets +- `without(element)` - Remove element + +#### Humanize Extensions +- **Duration:** + - `humanize(locale)` - Convert to "2 hours, 3 minutes" format + +- **DateTime:** + - `humanize(locale)` - Convert to relative time ("just now", "3 hours ago", "yesterday", "last week", etc.) + +- **Numbers:** + - `humanizeNumber()` - Format as "15.3k", "1.5M", etc. + - `humanizeOrdinal()` - Format as "1st", "2nd", "3rd", etc. + - `humanizeCount('item')` - Format as "1 item" / "3 items" + - `humanizePercentage(max, min)` - Format as "74%" + - `humanizeFileSize()` - Format as "1.0 MB", "520.3 KB", etc. + +### Changed +- Updated package version to 1.0.0 marking stable release + +### Improved +- Added comprehensive test coverage for all new extensions +- Complete documentation with examples for all methods +- Enhanced type safety with proper generic constraints + ## [Unreleased] ### Improved @@ -26,7 +125,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `truncate(maxLength)` - Truncates with word boundary awareness - `wrap(prefix, suffix)` - Wraps string with prefix and suffix - `removePrefix(prefix)` and `removeSuffix(suffix)` - Remove specific prefix or suffix - + - **Number Extensions:** - `toDecimalPlaces(places)` - Rounds double to specified decimal places - `isBetween(min, max)` - Checks if number is within range diff --git a/README.md b/README.md index 20a84cc..467ac08 100644 --- a/README.md +++ b/README.md @@ -367,6 +367,236 @@ await 2.seconds.delay(); // Waits for 2 seconds - `isNumericOnly` +------------------------------------------------------------------------------- + +### 🧩 Iterable / List Extensions + +#### Query & Search + +- `firstOrNull()` β†’ Returns first element or `null` if empty +- `lastOrNull()` β†’ Returns last element or `null` if empty +- `singleWhereOrNull(predicate)` β†’ Returns match or `null` +- `containsWhere(predicate)` β†’ Boolean check +- `indexWhereOrNull(predicate)` β†’ Returns index or `null` + +```dart +// Examples +[1, 2, 3].firstOrNull(); // 1 +[].firstOrNull(); // null +[1, 2, 3].singleWhereOrNull((e) => e == 2); // 2 +[1, 2, 3].containsWhere((e) => e > 2); // true +``` + +#### Safe Access + +- `getOrNull(index)` β†’ Returns element at index or `null` +- `getOrDefault(index, defaultValue)` β†’ Returns element or default value + +```dart +// Examples +[1, 2, 3].getOrNull(1); // 2 +[1, 2, 3].getOrNull(5); // null +[1, 2, 3].getOrDefault(5, 0); // 0 +``` + +#### Transformations + +- `chunked(size)` β†’ Splits into chunks +- `mapIndexed((index, item) => ...)` β†’ Maps with index +- `whereNotNull()` β†’ Filters out nulls +- `distinctBy(keySelector)` β†’ Unique items by property +- `flatten()` β†’ Flattens nested lists +- `sortedBy(keySelector)` / `sortedByDesc(keySelector)` β†’ Sort by property +- `flip()` β†’ Reverses the list + +```dart +// Examples +[1, 2, 3, 4, 5].chunked(2); // [[1, 2], [3, 4], [5]] +['a', 'b', 'c'].mapIndexed((i, e) => '$i: $e'); // ['0: a', '1: b', '2: c'] +[1, null, 2, null, 3].whereNotNull(); // [1, 2, 3] +[[1, 2], [3, 4]].flatten(); // [1, 2, 3, 4] +[1, 2, 3].flip(); // [3, 2, 1] +``` + +#### Aggregations (Only available on list of numbers) + +- `sumBy(num Function(T))` β†’ Sum elements by selector +- `averageBy(num Function(T))` β†’ Average by selector +- `min()` β†’ Minimum value +- `max()` β†’ Maximum value +- `countWhere(predicate)` β†’ Count matching elements + +```dart +// Examples +[1, 2, 3, 4, 5].sumBy((e) => e); // 15 +[1, 2, 3, 4, 5].averageBy((e) => e); // 3.0 +[3, 1, 4, 1, 5].min(); // 1 +[3, 1, 4, 1, 5].max(); // 5 +[1, 2, 3, 4, 5].countWhere((e) => e > 3); // 2 +``` + +#### Mutation Helpers (returns new copy) + +- `insertIf(condition, value)` β†’ Insert conditionally +- `replaceWhere(predicate, newValue)` β†’ Replace matching elements +- `removeWhereNot(predicate)` β†’ Keep only matching elements +- `updateWhere(predicate, updater)` β†’ Update matching elements +- `addIf(value)` / `addAllIf(values)` β†’ Add conditionally +- `append(value)` / `appendAll(values)` β†’ Append elements +- `appendIf(value)` / `appendAllIf(values)` β†’ Append conditionally +- `pop()` β†’ Remove and return last element +- `fliter(predicate)` β†’ Filter elements +- `unique()` β†’ Get unique elements + +```dart +// Examples +[1, 2, 3].insertIf(true, 4); // [1, 2, 3, 4] +[1, 2, 3, 2].replaceWhere((e) => e == 2, 5); // [1, 5, 3, 5] +[1, 2, 3, 4, 5].removeWhereNot((e) => e > 2); // [3, 4, 5] +[1, 2, 2, 3, 3, 4].unique(); // [1, 2, 3, 4] +``` + +#### Utility + +- `isNullOrEmpty()` β†’ Check if empty +- `joinToString(separator, transform)` β†’ Join with custom format +- `forEachIndexed()` β†’ Iterate with index + +```dart +// Examples +[].isNullOrEmpty(); // true +[1, 2, 3].joinToString(separator: ', '); // '1, 2, 3' +['a', 'b'].forEachIndexed((i, e) => print('$i: $e')); +``` + +------------------------------------------------------------------------------- + +### πŸ—ΊοΈ Map Extensions + +#### Safe Access + +- `getOrNull(key)` β†’ Get value or null +- `getOrDefault(key, defaultValue)` β†’ Get value or default + +```dart +// Examples +{'a': 1, 'b': 2}.getOrNull('a'); // 1 +{'a': 1, 'b': 2}.getOrNull('c'); // null +{'a': 1, 'b': 2}.getOrDefault('c', 0); // 0 +``` + +#### Transformations + +- `mapKeys((k, v) => newKey)` β†’ Transform keys +- `mapValues((k, v) => newValue)` β†’ Transform values +- `filterKeys(predicate)` β†’ Filter by keys +- `filterValues(predicate)` β†’ Filter by values +- `invert()` β†’ Swap keys and values + +```dart +// Examples +{'a': 1, 'b': 2}.mapKeys((k, v) => k.toUpperCase()); // {'A': 1, 'B': 2} +{'a': 1, 'b': 2}.mapValues((k, v) => v * 2); // {'a': 2, 'b': 4} +{'a': 1, 'b': 2}.invert(); // {1: 'a', 2: 'b'} +``` + +#### Merge & Combine + +- `merge(otherMap)` β†’ Merge with precedence +- `mergeIfAbsent(otherMap)` β†’ Merge without overriding +- `combine(other, (k, v1, v2) => mergedValue)` β†’ Custom merge + +```dart +// Examples +{'a': 1, 'b': 2}.merge({'b': 3, 'c': 4}); // {'a': 1, 'b': 3, 'c': 4} +{'a': 1, 'b': 2}.mergeIfAbsent({'b': 3, 'c': 4}); // {'a': 1, 'b': 2, 'c': 4} +``` + +#### Utility + +- `keysWhere(predicate)` β†’ Get keys matching predicate +- `valuesWhere(predicate)` β†’ Get values matching predicate +- `toQueryString()` β†’ Convert to URL query string + +```dart +// Examples +{'a': 1, 'b': 2, 'c': 3}.keysWhere((v) => v > 1); // ['b', 'c'] +{'name': 'John', 'age': '30'}.toQueryString(); // 'name=John&age=30' +``` + +------------------------------------------------------------------------------- + +### πŸ”’ Set Extensions + +- `toggle(element)` β†’ Adds if missing, removes if present +- `intersects(otherSet)` β†’ Check for intersection +- `isSubsetOf(otherSet)` β†’ Check if subset +- `isSupersetOf(otherSet)` β†’ Check if superset +- `unionAll(sets)` β†’ Union of multiple sets +- `without(element)` β†’ Remove element + +```dart +// Examples +{1, 2, 3}.toggle(2); // {1, 3} +{1, 2, 3}.toggle(4); // {1, 2, 3, 4} +{1, 2, 3}.intersects({2, 3, 4}); // true +{1, 2}.isSubsetOf({1, 2, 3}); // true +{1, 2}.unionAll([{2, 3}, {3, 4}]); // {1, 2, 3, 4} +``` + +------------------------------------------------------------------------------- + +### 🎯 Humanize Extensions + +The goal of `humanize` is simple: + +> Convert technical or numeric values into **readable, natural, human-friendly strings**. + +Where computers speak in seconds, bytes, and counts, `humanize` translates them into something that sounds like it came from a person. + +#### Durations + +- `.humanize(locale)` β†’ "2 hours, 3 minutes" + +```dart +// Examples +Duration(hours: 2, minutes: 3).humanize(); // '2 hours, 3 minutes' +Duration(days: 1).humanize(); // '1 day' +Duration(seconds: 45).humanize(); // '45 seconds' +``` + +#### Time (DateTime) + +- `.humanize(locale)` β†’ "just now", "3 hours ago", "yesterday", "last week", "3 days from now", "2 weeks ago" + +```dart +// Examples +DateTime.now().humanize(); // 'just now' +DateTime.now().subtract(Duration(hours: 3)).humanize(); // '3 hours ago' +DateTime.now().subtract(Duration(days: 1)).humanize(); // 'yesterday' +DateTime.now().add(Duration(days: 2)).humanize(); // 'in 2 days' +``` + +#### Numbers + +- `humanizeNumber()` β†’ "15.3k", "1.5M" +- `humanizeOrdinal()` β†’ "1st", "2nd", "3rd" +- `humanizeCount('item')` β†’ "1 item" / "3 items" +- `humanizePercentage(max, min)` β†’ "74%" +- `humanizeFileSize()` β†’ "1.0 MB", "520.3 KB" + +```dart +// Examples +1234.humanizeNumber(); // '1.2k' +1500000.humanizeNumber(); // '1.5M' +1.humanizeOrdinal(); // '1st' +21.humanizeOrdinal(); // '21st' +3.humanizeCount('item'); // '3 items' +0.75.humanizePercentage(); // '75%' +1024.humanizeFileSize(); // '1.0 KB' +520300.humanizeFileSize(); // '508.1 KB' +``` + ------------------------------------------------------------------------------- ### 🧩 Widget Extensions diff --git a/lib/src/extensions.dart b/lib/src/extensions.dart index 811ca07..ea9860a 100644 --- a/lib/src/extensions.dart +++ b/lib/src/extensions.dart @@ -17,9 +17,13 @@ part './extensions/build_context.dart'; part './extensions/date_time.dart'; part './extensions/duration.dart'; part './extensions/dynamic.dart'; +part './extensions/humanize.dart'; part './extensions/image_widget.dart'; +part './extensions/list.dart'; +part './extensions/map.dart'; part './extensions/number.dart'; part './extensions/object.dart'; +part './extensions/set.dart'; part './extensions/stateless_widget.dart'; part './extensions/string.dart'; part './extensions/text_style.dart'; diff --git a/lib/src/extensions/humanize.dart b/lib/src/extensions/humanize.dart new file mode 100644 index 0000000..41e3644 --- /dev/null +++ b/lib/src/extensions/humanize.dart @@ -0,0 +1,226 @@ +part of './../extensions.dart'; + +extension MayrDurationHumanizeExtensions on Duration { + /// Converts the duration to a human-readable string. + /// + /// Example: + /// ```dart + /// Duration(hours: 2, minutes: 3).humanize(); // '2 hours, 3 minutes' + /// Duration(days: 1).humanize(); // '1 day' + /// Duration(seconds: 45).humanize(); // '45 seconds' + /// ``` + String humanize({String locale = 'en'}) { + final days = inDays; + final hours = inHours.remainder(24); + final minutes = inMinutes.remainder(60); + final seconds = inSeconds.remainder(60); + + final parts = []; + + if (days > 0) { + parts.add('$days ${days == 1 ? 'day' : 'days'}'); + } + if (hours > 0) { + parts.add('$hours ${hours == 1 ? 'hour' : 'hours'}'); + } + if (minutes > 0) { + parts.add('$minutes ${minutes == 1 ? 'minute' : 'minutes'}'); + } + if (seconds > 0 && days == 0 && hours == 0) { + parts.add('$seconds ${seconds == 1 ? 'second' : 'seconds'}'); + } + + if (parts.isEmpty) return '0 seconds'; + if (parts.length == 1) return parts[0]; + if (parts.length == 2) return '${parts[0]}, ${parts[1]}'; + + return '${parts.sublist(0, parts.length - 1).join(', ')}, and ${parts.last}'; + } +} + +extension MayrDateTimeHumanizeExtensions on DateTime { + /// Converts the date time to a human-readable relative time string. + /// + /// Example: + /// ```dart + /// DateTime.now().humanize(); // 'just now' + /// DateTime.now().subtract(Duration(hours: 3)).humanize(); // '3 hours ago' + /// DateTime.now().add(Duration(days: 2)).humanize(); // '2 days from now' + /// ``` + String humanize({String locale = 'en'}) { + final now = DateTime.now(); + final difference = now.difference(this); + final isFuture = difference.isNegative; + final absDifference = difference.abs(); + + if (absDifference.inSeconds < 30) { + return 'just now'; + } + + if (absDifference.inMinutes < 1) { + return isFuture ? 'in a few seconds' : 'a few seconds ago'; + } + + if (absDifference.inMinutes < 60) { + final minutes = absDifference.inMinutes; + if (minutes == 1) { + return isFuture ? 'in 1 minute' : '1 minute ago'; + } + return isFuture ? 'in $minutes minutes' : '$minutes minutes ago'; + } + + if (absDifference.inHours < 24) { + final hours = absDifference.inHours; + if (hours == 1) { + return isFuture ? 'in 1 hour' : '1 hour ago'; + } + return isFuture ? 'in $hours hours' : '$hours hours ago'; + } + + if (absDifference.inDays == 1) { + return isFuture ? 'tomorrow' : 'yesterday'; + } + + if (absDifference.inDays < 7) { + final days = absDifference.inDays; + return isFuture ? 'in $days days' : '$days days ago'; + } + + if (absDifference.inDays < 14) { + return isFuture ? 'next week' : 'last week'; + } + + if (absDifference.inDays < 30) { + final weeks = (absDifference.inDays / 7).floor(); + return isFuture ? 'in $weeks weeks' : '$weeks weeks ago'; + } + + if (absDifference.inDays < 60) { + return isFuture ? 'next month' : 'last month'; + } + + if (absDifference.inDays < 365) { + final months = (absDifference.inDays / 30).floor(); + return isFuture ? 'in $months months' : '$months months ago'; + } + + final years = (absDifference.inDays / 365).floor(); + if (years == 1) { + return isFuture ? 'next year' : 'last year'; + } + return isFuture ? 'in $years years' : '$years years ago'; + } +} + +extension MayrNumHumanizeExtensions on num { + /// Converts the number to a human-readable compact format. + /// + /// Example: + /// ```dart + /// 1234.humanizeNumber(); // '1.2k' + /// 1500000.humanizeNumber(); // '1.5M' + /// 999.humanizeNumber(); // '999' + /// ``` + String humanizeNumber({int decimals = 1}) { + if (abs() < 1000) return toString(); + + final suffixes = ['', 'k', 'M', 'B', 'T']; + var index = 0; + var value = toDouble(); + + while (value.abs() >= 1000 && index < suffixes.length - 1) { + value /= 1000; + index++; + } + + if (value == value.roundToDouble()) { + return '${value.toInt()}${suffixes[index]}'; + } + + return '${value.toStringAsFixed(decimals)}${suffixes[index]}'; + } + + /// Converts the number to an ordinal string (1st, 2nd, 3rd, etc.). + /// + /// Example: + /// ```dart + /// 1.humanizeOrdinal(); // '1st' + /// 2.humanizeOrdinal(); // '2nd' + /// 3.humanizeOrdinal(); // '3rd' + /// 21.humanizeOrdinal(); // '21st' + /// ``` + String humanizeOrdinal() { + final n = toInt(); + if (n % 100 >= 11 && n % 100 <= 13) { + return '${n}th'; + } + + switch (n % 10) { + case 1: + return '${n}st'; + case 2: + return '${n}nd'; + case 3: + return '${n}rd'; + default: + return '${n}th'; + } + } + + /// Converts the number to a count with singular/plural item name. + /// + /// Example: + /// ```dart + /// 1.humanizeCount('item'); // '1 item' + /// 3.humanizeCount('item'); // '3 items' + /// 0.humanizeCount('item'); // '0 items' + /// ``` + String humanizeCount(String itemName, {String? pluralName}) { + final count = toInt(); + final name = count == 1 ? itemName : (pluralName ?? '${itemName}s'); + return '$count $name'; + } + + /// Converts the number to a percentage string. + /// + /// Example: + /// ```dart + /// 0.75.humanizePercentage(); // '75%' + /// 50.humanizePercentage(max: 100); // '50%' + /// ``` + String humanizePercentage({num max = 1, int decimals = 0}) { + final percentage = (this / max * 100); + if (decimals == 0) { + return '${percentage.round()}%'; + } + return '${percentage.toStringAsFixed(decimals)}%'; + } + + /// Converts bytes to a human-readable file size. + /// + /// Example: + /// ```dart + /// 1024.humanizeFileSize(); // '1.0 KB' + /// 1048576.humanizeFileSize(); // '1.0 MB' + /// 520300.humanizeFileSize(); // '508.1 KB' + /// ``` + String humanizeFileSize({int decimals = 1}) { + if (this < 0) return 'Invalid size'; + if (this == 0) return '0 B'; + + const suffixes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + var index = 0; + var size = toDouble(); + + while (size >= 1024 && index < suffixes.length - 1) { + size /= 1024; + index++; + } + + if (index == 0 || size == size.roundToDouble()) { + return '${size.toInt()} ${suffixes[index]}'; + } + + return '${size.toStringAsFixed(decimals)} ${suffixes[index]}'; + } +} diff --git a/lib/src/extensions/list.dart b/lib/src/extensions/list.dart new file mode 100644 index 0000000..bc87c3c --- /dev/null +++ b/lib/src/extensions/list.dart @@ -0,0 +1,423 @@ +part of './../extensions.dart'; + +extension MayrIterableExtensions on Iterable { + /// Returns the first element or `null` if the iterable is empty. + /// + /// Example: + /// ```dart + /// [1, 2, 3].firstOrNull(); // 1 + /// [].firstOrNull(); // null + /// ``` + T? firstOrNull() => isEmpty ? null : first; + + /// Returns the last element or `null` if the iterable is empty. + /// + /// Example: + /// ```dart + /// [1, 2, 3].lastOrNull(); // 3 + /// [].lastOrNull(); // null + /// ``` + T? lastOrNull() => isEmpty ? null : last; + + /// Returns the single element that satisfies the predicate, or `null` if no such element exists. + /// + /// Example: + /// ```dart + /// [1, 2, 3].singleWhereOrNull((e) => e == 2); // 2 + /// [1, 2, 3].singleWhereOrNull((e) => e > 5); // null + /// ``` + T? singleWhereOrNull(bool Function(T) predicate) { + T? result; + var found = false; + for (var element in this) { + if (predicate(element)) { + if (found) return null; // More than one match + result = element; + found = true; + } + } + return result; + } + + /// Returns `true` if at least one element satisfies the predicate. + /// + /// Example: + /// ```dart + /// [1, 2, 3].containsWhere((e) => e > 2); // true + /// [1, 2, 3].containsWhere((e) => e > 5); // false + /// ``` + bool containsWhere(bool Function(T) predicate) => any(predicate); + + /// Returns the index of the first element that satisfies the predicate, or `null` if not found. + /// + /// Example: + /// ```dart + /// [1, 2, 3].indexWhereOrNull((e) => e == 2); // 1 + /// [1, 2, 3].indexWhereOrNull((e) => e > 5); // null + /// ``` + int? indexWhereOrNull(bool Function(T) predicate) { + var index = 0; + for (var element in this) { + if (predicate(element)) return index; + index++; + } + return null; + } + + /// Maps each element with its index. + /// + /// Example: + /// ```dart + /// ['a', 'b', 'c'].mapIndexed((i, e) => '$i: $e'); // ['0: a', '1: b', '2: c'] + /// ``` + Iterable mapIndexed(R Function(int index, T item) transform) sync* { + var index = 0; + for (var element in this) { + yield transform(index++, element); + } + } + + /// Filters out null values from the iterable. + /// + /// Example: + /// ```dart + /// [1, null, 2, null, 3].whereNotNull(); // [1, 2, 3] + /// ``` + Iterable whereNotNull() => where((e) => e != null); + + /// Returns unique elements based on a key selector. + /// + /// Example: + /// ```dart + /// [Person('Alice', 30), Person('Bob', 25), Person('Alice', 35)] + /// .distinctBy((p) => p.name); // [Person('Alice', 30), Person('Bob', 25)] + /// ``` + Iterable distinctBy(K Function(T) keySelector) { + final seen = {}; + return where((element) => seen.add(keySelector(element))); + } + + /// Returns elements sorted by a key selector. + /// + /// Example: + /// ```dart + /// [Person('Bob', 25), Person('Alice', 30)].sortedBy((p) => p.name); + /// // [Person('Alice', 30), Person('Bob', 25)] + /// ``` + List sortedBy>(K Function(T) keySelector) { + final list = toList(); + list.sort((a, b) => keySelector(a).compareTo(keySelector(b))); + return list; + } + + /// Returns elements sorted by a key selector in descending order. + /// + /// Example: + /// ```dart + /// [Person('Bob', 25), Person('Alice', 30)].sortedByDesc((p) => p.age); + /// // [Person('Alice', 30), Person('Bob', 25)] + /// ``` + List sortedByDesc>(K Function(T) keySelector) { + final list = toList(); + list.sort((a, b) => keySelector(b).compareTo(keySelector(a))); + return list; + } + + /// Counts elements that satisfy the predicate. + /// + /// Example: + /// ```dart + /// [1, 2, 3, 4, 5].countWhere((e) => e > 3); // 2 + /// ``` + int countWhere(bool Function(T) predicate) => where(predicate).length; + + /// Checks if the iterable is `null` or empty. + /// + /// Example: + /// ```dart + /// [].isNullOrEmpty(); // true + /// [1, 2, 3].isNullOrEmpty(); // false + /// ``` + bool isNullOrEmpty() => isEmpty; + + /// Joins elements to a string with optional separator and transform. + /// + /// Example: + /// ```dart + /// [1, 2, 3].joinToString(separator: ', '); // '1, 2, 3' + /// [1, 2, 3].joinToString(separator: ', ', transform: (e) => 'Item $e'); + /// // 'Item 1, Item 2, Item 3' + /// ``` + String joinToString({String separator = '', String Function(T)? transform}) { + if (transform != null) { + return map(transform).join(separator); + } + return join(separator); + } + + /// Executes an action for each element with its index. + /// + /// Example: + /// ```dart + /// [1, 2, 3].forEachIndexed((i, e) => print('$i: $e')); + /// // Prints: '0: 1', '1: 2', '2: 3' + /// ``` + void forEachIndexed(void Function(int index, T item) action) { + var index = 0; + for (var element in this) { + action(index++, element); + } + } +} + +extension MayrListExtensions on List { + /// Returns the element at [index] or `null` if out of bounds. + /// + /// Example: + /// ```dart + /// [1, 2, 3].getOrNull(1); // 2 + /// [1, 2, 3].getOrNull(5); // null + /// ``` + T? getOrNull(int index) { + if (index < 0 || index >= length) return null; + return this[index]; + } + + /// Returns the element at [index] or [defaultValue] if out of bounds. + /// + /// Example: + /// ```dart + /// [1, 2, 3].getOrDefault(1, 0); // 2 + /// [1, 2, 3].getOrDefault(5, 0); // 0 + /// ``` + T getOrDefault(int index, T defaultValue) { + if (index < 0 || index >= length) return defaultValue; + return this[index]; + } + + /// Splits the list into chunks of the specified size. + /// + /// Example: + /// ```dart + /// [1, 2, 3, 4, 5].chunked(2); // [[1, 2], [3, 4], [5]] + /// ``` + List> chunked(int size) { + if (size <= 0) throw ArgumentError('Size must be positive'); + final chunks = >[]; + for (var i = 0; i < length; i += size) { + chunks.add(sublist(i, i + size > length ? length : i + size)); + } + return chunks; + } + + /// Flattens a list of lists into a single list. + /// + /// Example: + /// ```dart + /// [[1, 2], [3, 4], [5]].flatten(); // [1, 2, 3, 4, 5] + /// ``` + List flatten() { + final result = []; + for (var element in this) { + if (element is List) { + result.addAll(element); + } + } + return result; + } + + /// Flips the list (reverses it). + /// + /// Example: + /// ```dart + /// [1, 2, 3].flip(); // [3, 2, 1] + /// ``` + List flip() => reversed.toList(); + + /// Inserts [value] if [condition] is true. + /// + /// Example: + /// ```dart + /// [1, 2, 3].insertIf(true, 4); // [1, 2, 3, 4] + /// [1, 2, 3].insertIf(false, 4); // [1, 2, 3] + /// ``` + List insertIf(bool condition, T value) { + final result = List.from(this); + if (condition) result.add(value); + return result; + } + + /// Replaces elements that satisfy the predicate with [newValue]. + /// + /// Example: + /// ```dart + /// [1, 2, 3, 2].replaceWhere((e) => e == 2, 5); // [1, 5, 3, 5] + /// ``` + List replaceWhere(bool Function(T) predicate, T newValue) { + return map((e) => predicate(e) ? newValue : e).toList(); + } + + /// Removes elements that do not satisfy the predicate. + /// + /// Example: + /// ```dart + /// [1, 2, 3, 4, 5].removeWhereNot((e) => e > 2); // [3, 4, 5] + /// ``` + List removeWhereNot(bool Function(T) predicate) { + return where(predicate).toList(); + } + + /// Updates elements that satisfy the predicate using the updater function. + /// + /// Example: + /// ```dart + /// [1, 2, 3, 4].updateWhere((e) => e > 2, (e) => e * 2); // [1, 2, 6, 8] + /// ``` + List updateWhere(bool Function(T) predicate, T Function(T) updater) { + return map((e) => predicate(e) ? updater(e) : e).toList(); + } + + /// Adds [value] if it's not null. + /// + /// Example: + /// ```dart + /// [1, 2, 3].addIf(4); // [1, 2, 3, 4] + /// [1, 2, 3].addIf(null); // [1, 2, 3] + /// ``` + List addIf(T? value) { + final result = List.from(this); + if (value != null) result.add(value); + return result; + } + + /// Adds all elements from [values] if not null. + /// + /// Example: + /// ```dart + /// [1, 2].addAllIf([3, 4]); // [1, 2, 3, 4] + /// [1, 2].addAllIf(null); // [1, 2] + /// ``` + List addAllIf(Iterable? values) { + final result = List.from(this); + if (values != null) result.addAll(values); + return result; + } + + /// Appends [value] to the list (alias for add, returns new list). + /// + /// Example: + /// ```dart + /// [1, 2, 3].append(4); // [1, 2, 3, 4] + /// ``` + List append(T value) { + final result = List.from(this); + result.add(value); + return result; + } + + /// Appends all [values] to the list (returns new list). + /// + /// Example: + /// ```dart + /// [1, 2].appendAll([3, 4]); // [1, 2, 3, 4] + /// ``` + List appendAll(Iterable values) { + final result = List.from(this); + result.addAll(values); + return result; + } + + /// Appends [value] to the list if condition is true. + /// + /// Example: + /// ```dart + /// [1, 2, 3].appendIf(4, true); // [1, 2, 3, 4] + /// [1, 2, 3].appendIf(4, false); // [1, 2, 3] + /// ``` + List appendIf(T value, bool condition) { + final result = List.from(this); + if (condition) result.add(value); + return result; + } + + /// Appends all [values] to the list if condition is true. + /// + /// Example: + /// ```dart + /// [1, 2].appendAllIf([3, 4], true); // [1, 2, 3, 4] + /// [1, 2].appendAllIf([3, 4], false); // [1, 2] + /// ``` + List appendAllIf(Iterable values, bool condition) { + final result = List.from(this); + if (condition) result.addAll(values); + return result; + } + + /// Removes and returns the last element of the list. + /// + /// Example: + /// ```dart + /// var list = [1, 2, 3]; + /// list.pop(); // Returns 3, list is now [1, 2] + /// ``` + T? pop() { + if (isEmpty) return null; + return removeLast(); + } + + /// Filters the list using the predicate (alias for where). + /// + /// Example: + /// ```dart + /// [1, 2, 3, 4, 5].fliter((e) => e > 2); // [3, 4, 5] + /// ``` + List fliter(bool Function(T) predicate) => where(predicate).toList(); + + /// Returns a list with unique elements. + /// + /// Example: + /// ```dart + /// [1, 2, 2, 3, 3, 4].unique(); // [1, 2, 3, 4] + /// ``` + List unique() => toSet().toList(); +} + +extension MayrNumIterableExtensions on Iterable { + /// Sums elements by applying a selector function. + /// + /// Example: + /// ```dart + /// [Person('Alice', 30), Person('Bob', 25)].sumBy((p) => p.age); // 55 + /// ``` + num sumBy(num Function(num) selector) => + map(selector).fold(0, (a, b) => a + b); + + /// Calculates the average by applying a selector function. + /// + /// Example: + /// ```dart + /// [Person('Alice', 30), Person('Bob', 20)].averageBy((p) => p.age); // 25.0 + /// ``` + double averageBy(num Function(num) selector) { + if (isEmpty) return 0; + return sumBy(selector) / length; + } + + /// Returns the minimum value in the iterable. + /// + /// Example: + /// ```dart + /// [3, 1, 4, 1, 5].min(); // 1 + /// [].min(); // null + /// ``` + T? min() => isEmpty ? null : reduce((a, b) => a < b ? a : b); + + /// Returns the maximum value in the iterable. + /// + /// Example: + /// ```dart + /// [3, 1, 4, 1, 5].max(); // 5 + /// [].max(); // null + /// ``` + T? max() => isEmpty ? null : reduce((a, b) => a > b ? a : b); +} diff --git a/lib/src/extensions/map.dart b/lib/src/extensions/map.dart new file mode 100644 index 0000000..ffa928c --- /dev/null +++ b/lib/src/extensions/map.dart @@ -0,0 +1,166 @@ +part of './../extensions.dart'; + +extension MayrMapExtensions on Map { + /// Returns the value for [key] or `null` if the key doesn't exist. + /// + /// Example: + /// ```dart + /// {'a': 1, 'b': 2}.getOrNull('a'); // 1 + /// {'a': 1, 'b': 2}.getOrNull('c'); // null + /// ``` + V? getOrNull(K key) => this[key]; + + /// Returns the value for [key] or [defaultValue] if the key doesn't exist. + /// + /// Example: + /// ```dart + /// {'a': 1, 'b': 2}.getOrDefault('a', 0); // 1 + /// {'a': 1, 'b': 2}.getOrDefault('c', 0); // 0 + /// ``` + V getOrDefault(K key, V defaultValue) => this[key] ?? defaultValue; + + /// Creates a new map with keys transformed by [transform]. + /// + /// Example: + /// ```dart + /// {'a': 1, 'b': 2}.mapKeys((k, v) => k.toUpperCase()); + /// // {'A': 1, 'B': 2} + /// ``` + Map mapKeys(K2 Function(K key, V value) transform) { + return Map.fromEntries( + entries.map((e) => MapEntry(transform(e.key, e.value), e.value)), + ); + } + + /// Creates a new map with values transformed by [transform]. + /// + /// Example: + /// ```dart + /// {'a': 1, 'b': 2}.mapValues((k, v) => v * 2); + /// // {'a': 2, 'b': 4} + /// ``` + Map mapValues(V2 Function(K key, V value) transform) { + return Map.fromEntries( + entries.map((e) => MapEntry(e.key, transform(e.key, e.value))), + ); + } + + /// Creates a new map with only the entries whose keys satisfy [predicate]. + /// + /// Example: + /// ```dart + /// {'a': 1, 'b': 2, 'c': 3}.filterKeys((k) => k != 'b'); + /// // {'a': 1, 'c': 3} + /// ``` + Map filterKeys(bool Function(K key) predicate) { + return Map.fromEntries(entries.where((e) => predicate(e.key))); + } + + /// Creates a new map with only the entries whose values satisfy [predicate]. + /// + /// Example: + /// ```dart + /// {'a': 1, 'b': 2, 'c': 3}.filterValues((v) => v > 1); + /// // {'b': 2, 'c': 3} + /// ``` + Map filterValues(bool Function(V value) predicate) { + return Map.fromEntries(entries.where((e) => predicate(e.value))); + } + + /// Creates a new map with keys and values swapped. + /// + /// Example: + /// ```dart + /// {'a': 1, 'b': 2}.invert(); + /// // {1: 'a', 2: 'b'} + /// ``` + Map invert() { + return Map.fromEntries(entries.map((e) => MapEntry(e.value, e.key))); + } + + /// Merges this map with [otherMap], with entries from [otherMap] taking precedence. + /// + /// Example: + /// ```dart + /// {'a': 1, 'b': 2}.merge({'b': 3, 'c': 4}); + /// // {'a': 1, 'b': 3, 'c': 4} + /// ``` + Map merge(Map otherMap) { + return {...this, ...otherMap}; + } + + /// Merges this map with [otherMap], but only adds entries that don't exist in this map. + /// + /// Example: + /// ```dart + /// {'a': 1, 'b': 2}.mergeIfAbsent({'b': 3, 'c': 4}); + /// // {'a': 1, 'b': 2, 'c': 4} + /// ``` + Map mergeIfAbsent(Map otherMap) { + final result = Map.from(this); + otherMap.forEach((key, value) { + result.putIfAbsent(key, () => value); + }); + return result; + } + + /// Combines this map with [other] using a [combiner] function for conflicting keys. + /// + /// Example: + /// ```dart + /// {'a': 1, 'b': 2}.combine({'b': 3, 'c': 4}, (k, v1, v2) => v1 + v2); + /// // {'a': 1, 'b': 5, 'c': 4} + /// ``` + Map combine( + Map other, + V Function(K key, V value1, V value2) combiner, + ) { + final result = Map.from(this); + other.forEach((key, value) { + if (result.containsKey(key)) { + result[key] = combiner(key, result[key] as V, value); + } else { + result[key] = value; + } + }); + return result; + } + + /// Returns all keys whose values satisfy [predicate]. + /// + /// Example: + /// ```dart + /// {'a': 1, 'b': 2, 'c': 3}.keysWhere((v) => v > 1); + /// // ['b', 'c'] + /// ``` + Iterable keysWhere(bool Function(V value) predicate) { + return entries.where((e) => predicate(e.value)).map((e) => e.key); + } + + /// Returns all values whose keys satisfy [predicate]. + /// + /// Example: + /// ```dart + /// {'a': 1, 'b': 2, 'c': 3}.valuesWhere((k) => k != 'b'); + /// // [1, 3] + /// ``` + Iterable valuesWhere(bool Function(K key) predicate) { + return entries.where((e) => predicate(e.key)).map((e) => e.value); + } + + /// Converts the map to a URL query string. + /// + /// Example: + /// ```dart + /// {'name': 'John', 'age': '30'}.toQueryString(); + /// // 'name=John&age=30' + /// ``` + String toQueryString() { + return entries + .map( + (e) => + '${Uri.encodeComponent(e.key.toString())}=${Uri.encodeComponent(e.value.toString())}', + ) + .join('&'); + } +} diff --git a/lib/src/extensions/set.dart b/lib/src/extensions/set.dart new file mode 100644 index 0000000..85a952b --- /dev/null +++ b/lib/src/extensions/set.dart @@ -0,0 +1,72 @@ +part of './../extensions.dart'; + +extension MayrSetExtensions on Set { + /// Toggles [element] in the set - adds if missing, removes if present. + /// + /// Example: + /// ```dart + /// var set = {1, 2, 3}; + /// set.toggle(2); // {1, 3} + /// set.toggle(4); // {1, 3, 4} + /// ``` + Set toggle(T element) { + final result = Set.from(this); + if (result.contains(element)) { + result.remove(element); + } else { + result.add(element); + } + return result; + } + + /// Returns `true` if this set intersects with [otherSet]. + /// + /// Example: + /// ```dart + /// {1, 2, 3}.intersects({2, 3, 4}); // true + /// {1, 2}.intersects({3, 4}); // false + /// ``` + bool intersects(Set otherSet) => intersection(otherSet).isNotEmpty; + + /// Returns `true` if this set is a subset of [otherSet]. + /// + /// Example: + /// ```dart + /// {1, 2}.isSubsetOf({1, 2, 3}); // true + /// {1, 4}.isSubsetOf({1, 2, 3}); // false + /// ``` + bool isSubsetOf(Set otherSet) => difference(otherSet).isEmpty; + + /// Returns `true` if this set is a superset of [otherSet]. + /// + /// Example: + /// ```dart + /// {1, 2, 3}.isSupersetOf({1, 2}); // true + /// {1, 2}.isSupersetOf({1, 2, 3}); // false + /// ``` + bool isSupersetOf(Set otherSet) => otherSet.difference(this).isEmpty; + + /// Returns the union of this set with all [sets]. + /// + /// Example: + /// ```dart + /// {1, 2}.unionAll([{2, 3}, {3, 4}]); // {1, 2, 3, 4} + /// ``` + Set unionAll(Iterable> sets) { + var result = Set.from(this); + for (var set in sets) { + result = result.union(set); + } + return result; + } + + /// Returns a new set without [element]. + /// + /// Example: + /// ```dart + /// {1, 2, 3}.without(2); // {1, 3} + /// ``` + Set without(T element) { + return Set.from(this)..remove(element); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index cf12ba4..5d2bd31 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: mayr_extensions description: A comprehensive set of handy Dart and Flutter extensions to make your code cleaner, shorter, and more expressive. -version: 0.4.0 +version: 1.0.0 homepage: https://github.com/YoungMayor/flutter_utils_extensions repository: https://github.com/YoungMayor/flutter_utils_extensions issue_tracker: https://github.com/YoungMayor/flutter_utils_extensions/issues diff --git a/test/extensions/humanize_test.dart b/test/extensions/humanize_test.dart new file mode 100644 index 0000000..fe3b8fa --- /dev/null +++ b/test/extensions/humanize_test.dart @@ -0,0 +1,171 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mayr_extensions/mayr_extensions.dart'; + +void main() { + group('Duration Humanize Extensions', () { + test('humanize formats duration correctly', () { + expect(Duration(hours: 2, minutes: 3).humanize(), '2 hours, 3 minutes'); + expect(Duration(days: 1).humanize(), '1 day'); + expect(Duration(seconds: 45).humanize(), '45 seconds'); + expect(Duration(hours: 1, minutes: 30).humanize(), '1 hour, 30 minutes'); + expect( + Duration(days: 2, hours: 3, minutes: 15).humanize(), + '2 days, 3 hours, and 15 minutes', + ); + }); + + test('humanize handles zero duration', () { + expect(Duration.zero.humanize(), '0 seconds'); + }); + + test('humanize handles single units', () { + expect(Duration(days: 1).humanize(), '1 day'); + expect(Duration(hours: 1).humanize(), '1 hour'); + expect(Duration(minutes: 1).humanize(), '1 minute'); + expect(Duration(seconds: 1).humanize(), '1 second'); + }); + }); + + group('DateTime Humanize Extensions', () { + test('humanize returns "just now" for recent time', () { + final now = DateTime.now(); + expect(now.humanize(), 'just now'); + }); + + test('humanize formats past times correctly', () { + final now = DateTime.now(); + + // Use a time far enough in the past to avoid timing issues + final fiveMinutesAgo = now.subtract(Duration(minutes: 5, seconds: 30)); + final result = fiveMinutesAgo.humanize(); + expect(result == '5 minutes ago' || result == '6 minutes ago', true); + + // Add extra seconds to avoid boundary timing issues + final threeHoursAgo = now.subtract(Duration(hours: 3, seconds: 30)); + final hoursResult = threeHoursAgo.humanize(); + expect( + hoursResult == '3 hours ago' || hoursResult == '4 hours ago', + true, + ); + + expect(now.subtract(Duration(days: 1)).humanize(), 'yesterday'); + expect(now.subtract(Duration(days: 3)).humanize(), '3 days ago'); + expect(now.subtract(Duration(days: 10)).humanize(), 'last week'); + }); + + test('humanize formats future times correctly', () { + final now = DateTime.now(); + + // Use a time far enough in the future to avoid timing issues + final fiveMinutesLater = now.add(Duration(minutes: 5, seconds: 30)); + final result = fiveMinutesLater.humanize(); + expect(result == 'in 5 minutes' || result == 'in 6 minutes', true); + + // Add extra seconds to avoid boundary timing issues + final threeHoursLater = now.add(Duration(hours: 3, seconds: 30)); + final hoursResult = threeHoursLater.humanize(); + expect(hoursResult == 'in 3 hours' || hoursResult == 'in 4 hours', true); + + expect(now.add(Duration(days: 1, seconds: 1)).humanize(), 'tomorrow'); + expect(now.add(Duration(days: 3, seconds: 1)).humanize(), 'in 3 days'); + expect(now.add(Duration(days: 10, seconds: 1)).humanize(), 'next week'); + }); + + test('humanize handles singular values', () { + final now = DateTime.now(); + + // Add buffer seconds to avoid timing boundary issues + final oneMinuteAgo = now.subtract(Duration(minutes: 1, seconds: 30)); + final minuteAgoResult = oneMinuteAgo.humanize(); + expect( + minuteAgoResult == '1 minute ago' || minuteAgoResult == '2 minutes ago', + true, + ); + + final oneHourAgo = now.subtract(Duration(hours: 1, seconds: 30)); + final hourAgoResult = oneHourAgo.humanize(); + expect( + hourAgoResult == '1 hour ago' || hourAgoResult == '2 hours ago', + true, + ); + + final oneMinuteLater = now.add(Duration(minutes: 1, seconds: 30)); + final minuteLaterResult = oneMinuteLater.humanize(); + expect( + minuteLaterResult == 'in 1 minute' || + minuteLaterResult == 'in 2 minutes', + true, + ); + + final oneHourLater = now.add(Duration(hours: 1, seconds: 30)); + final hourLaterResult = oneHourLater.humanize(); + expect( + hourLaterResult == 'in 1 hour' || hourLaterResult == 'in 2 hours', + true, + ); + }); + }); + + group('Num Humanize Extensions', () { + test('humanizeNumber formats numbers correctly', () { + expect(999.humanizeNumber(), '999'); + expect(1234.humanizeNumber(), '1.2k'); + expect(1500000.humanizeNumber(), '1.5M'); + expect(1000000000.humanizeNumber(), '1B'); + expect(1100000000.humanizeNumber(), '1.1B'); + }); + + test('humanizeNumber handles decimals', () { + expect(1500.humanizeNumber(decimals: 2), '1.50k'); + expect(1234567.humanizeNumber(decimals: 0), '1M'); + }); + + test('humanizeOrdinal formats ordinals correctly', () { + expect(1.humanizeOrdinal(), '1st'); + expect(2.humanizeOrdinal(), '2nd'); + expect(3.humanizeOrdinal(), '3rd'); + expect(4.humanizeOrdinal(), '4th'); + expect(11.humanizeOrdinal(), '11th'); + expect(12.humanizeOrdinal(), '12th'); + expect(13.humanizeOrdinal(), '13th'); + expect(21.humanizeOrdinal(), '21st'); + expect(22.humanizeOrdinal(), '22nd'); + expect(23.humanizeOrdinal(), '23rd'); + expect(101.humanizeOrdinal(), '101st'); + }); + + test('humanizeCount formats counts correctly', () { + expect(0.humanizeCount('item'), '0 items'); + expect(1.humanizeCount('item'), '1 item'); + expect(3.humanizeCount('item'), '3 items'); + expect(2.humanizeCount('person', pluralName: 'people'), '2 people'); + }); + + test('humanizePercentage formats percentages correctly', () { + expect(0.75.humanizePercentage(), '75%'); + expect(0.5.humanizePercentage(), '50%'); + expect(50.humanizePercentage(max: 100), '50%'); + expect(0.333.humanizePercentage(decimals: 1), '33.3%'); + }); + + test('humanizeFileSize formats file sizes correctly', () { + expect(0.humanizeFileSize(), '0 B'); + expect(500.humanizeFileSize(), '500 B'); + expect(1024.humanizeFileSize(), '1 KB'); + expect(1025.humanizeFileSize(), '1.0 KB'); + expect(1126.humanizeFileSize(), '1.1 KB'); + expect(1048576.humanizeFileSize(), '1 MB'); + expect(520300.humanizeFileSize(), '508.1 KB'); + expect(1073741824.humanizeFileSize(), '1 GB'); + }); + + test('humanizeFileSize handles decimals', () { + expect(1536.humanizeFileSize(decimals: 2), '1.50 KB'); + expect(1024.humanizeFileSize(decimals: 0), '1 KB'); + }); + + test('humanizeFileSize handles invalid sizes', () { + expect((-100).humanizeFileSize(), 'Invalid size'); + }); + }); +} diff --git a/test/extensions/list_test.dart b/test/extensions/list_test.dart new file mode 100644 index 0000000..0668a4a --- /dev/null +++ b/test/extensions/list_test.dart @@ -0,0 +1,248 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mayr_extensions/mayr_extensions.dart'; + +void main() { + group('Iterable Extensions', () { + test('firstOrNull returns first element or null', () { + expect([1, 2, 3].firstOrNull(), 1); + expect([].firstOrNull(), null); + }); + + test('lastOrNull returns last element or null', () { + expect([1, 2, 3].lastOrNull(), 3); + expect([].lastOrNull(), null); + }); + + test('singleWhereOrNull returns matching element or null', () { + expect([1, 2, 3].singleWhereOrNull((e) => e == 2), 2); + expect([1, 2, 3].singleWhereOrNull((e) => e > 5), null); + expect( + [1, 2, 2, 3].singleWhereOrNull((e) => e == 2), + null, + ); // Multiple matches + }); + + test('containsWhere checks if predicate is satisfied', () { + expect([1, 2, 3].containsWhere((e) => e > 2), true); + expect([1, 2, 3].containsWhere((e) => e > 5), false); + }); + + test('indexWhereOrNull returns index or null', () { + expect([1, 2, 3].indexWhereOrNull((e) => e == 2), 1); + expect([1, 2, 3].indexWhereOrNull((e) => e > 5), null); + }); + + test('mapIndexed maps with index', () { + final result = ['a', 'b', 'c'].mapIndexed((i, e) => '$i: $e').toList(); + expect(result, ['0: a', '1: b', '2: c']); + }); + + test('whereNotNull filters out nulls', () { + expect([1, null, 2, null, 3].whereNotNull().toList(), [1, 2, 3]); + }); + + test('distinctBy returns unique elements by key', () { + final list = [ + {'name': 'Alice', 'age': 30}, + {'name': 'Bob', 'age': 25}, + {'name': 'Alice', 'age': 35}, + ]; + final result = list.distinctBy((e) => e['name']).toList(); + expect(result.length, 2); + expect(result[0]['name'], 'Alice'); + expect(result[1]['name'], 'Bob'); + }); + + test('sortedBy sorts by key selector', () { + final list = [ + {'name': 'Charlie', 'age': 30}, + {'name': 'Alice', 'age': 25}, + {'name': 'Bob', 'age': 35}, + ]; + final result = list.sortedBy((e) => e['name'] as String); + expect(result[0]['name'], 'Alice'); + expect(result[1]['name'], 'Bob'); + expect(result[2]['name'], 'Charlie'); + }); + + test('sortedByDesc sorts by key selector descending', () { + final list = [ + {'name': 'Charlie', 'age': 30}, + {'name': 'Alice', 'age': 25}, + {'name': 'Bob', 'age': 35}, + ]; + final result = list.sortedByDesc((e) => e['age'] as int); + expect(result[0]['age'], 35); + expect(result[1]['age'], 30); + expect(result[2]['age'], 25); + }); + + test('countWhere counts matching elements', () { + expect([1, 2, 3, 4, 5].countWhere((e) => e > 3), 2); + expect([1, 2, 3].countWhere((e) => e > 5), 0); + }); + + test('isNullOrEmpty checks if empty', () { + expect([].isNullOrEmpty(), true); + expect([1, 2, 3].isNullOrEmpty(), false); + }); + + test('joinToString joins elements', () { + expect([1, 2, 3].joinToString(separator: ', '), '1, 2, 3'); + expect( + [1, 2, 3].joinToString(separator: ', ', transform: (e) => 'Item $e'), + 'Item 1, Item 2, Item 3', + ); + }); + + test('forEachIndexed executes with index', () { + final indices = []; + final items = []; + ['a', 'b', 'c'].forEachIndexed((i, e) { + indices.add(i); + items.add(e); + }); + expect(indices, [0, 1, 2]); + expect(items, ['a', 'b', 'c']); + }); + }); + + group('List Extensions', () { + test('getOrNull returns element or null', () { + expect([1, 2, 3].getOrNull(1), 2); + expect([1, 2, 3].getOrNull(5), null); + expect([1, 2, 3].getOrNull(-1), null); + }); + + test('getOrDefault returns element or default', () { + expect([1, 2, 3].getOrDefault(1, 0), 2); + expect([1, 2, 3].getOrDefault(5, 0), 0); + }); + + test('chunked splits into chunks', () { + expect([1, 2, 3, 4, 5].chunked(2), [ + [1, 2], + [3, 4], + [5], + ]); + expect([1, 2, 3].chunked(1), [ + [1], + [2], + [3], + ]); + expect([1, 2, 3].chunked(5), [ + [1, 2, 3], + ]); + }); + + test('flatten flattens nested lists', () { + expect( + [ + [1, 2], + [3, 4], + [5], + ].flatten(), + [1, 2, 3, 4, 5], + ); + expect( + [ + [], + [1], + [2, 3], + ].flatten(), + [1, 2, 3], + ); + }); + + test('flip reverses the list', () { + expect([1, 2, 3].flip(), [3, 2, 1]); + expect([1].flip(), [1]); + expect([].flip(), []); + }); + + test('insertIf inserts conditionally', () { + expect([1, 2, 3].insertIf(true, 4), [1, 2, 3, 4]); + expect([1, 2, 3].insertIf(false, 4), [1, 2, 3]); + }); + + test('replaceWhere replaces matching elements', () { + expect([1, 2, 3, 2].replaceWhere((e) => e == 2, 5), [1, 5, 3, 5]); + }); + + test('removeWhereNot keeps matching elements', () { + expect([1, 2, 3, 4, 5].removeWhereNot((e) => e > 2), [3, 4, 5]); + }); + + test('updateWhere updates matching elements', () { + expect([1, 2, 3, 4].updateWhere((e) => e > 2, (e) => e * 2), [ + 1, + 2, + 6, + 8, + ]); + }); + + test('addIf adds value conditionally', () { + expect([1, 2, 3].addIf(4), [1, 2, 3, 4]); + expect([1, 2, 3].addIf(null), [1, 2, 3]); + }); + + test('addAllIf adds values conditionally', () { + expect([1, 2].addAllIf([3, 4]), [1, 2, 3, 4]); + expect([1, 2].addAllIf(null), [1, 2]); + }); + + test('append adds value', () { + expect([1, 2, 3].append(4), [1, 2, 3, 4]); + }); + + test('appendAll adds all values', () { + expect([1, 2].appendAll([3, 4]), [1, 2, 3, 4]); + }); + + test('appendIf adds value conditionally', () { + expect([1, 2, 3].appendIf(4, true), [1, 2, 3, 4]); + expect([1, 2, 3].appendIf(4, false), [1, 2, 3]); + }); + + test('appendAllIf adds values conditionally', () { + expect([1, 2].appendAllIf([3, 4], true), [1, 2, 3, 4]); + expect([1, 2].appendAllIf([3, 4], false), [1, 2]); + }); + + test('pop removes and returns last element', () { + var list = [1, 2, 3]; + expect(list.pop(), 3); + expect(list, [1, 2]); + expect([].pop(), null); + }); + + test('fliter filters elements', () { + expect([1, 2, 3, 4, 5].fliter((e) => e > 2), [3, 4, 5]); + }); + + test('unique returns unique elements', () { + expect([1, 2, 2, 3, 3, 4].unique(), [1, 2, 3, 4]); + }); + }); + + group('Num Iterable Extensions', () { + test('sumBy sums elements', () { + expect([1, 2, 3, 4, 5].sumBy((e) => e), 15); + expect([2, 4, 6].sumBy((e) => e * 2), 24); + }); + + test('averageBy calculates average', () { + expect([1, 2, 3, 4, 5].averageBy((e) => e), 3.0); + expect([].averageBy((e) => e), 0); + }); + + test('min returns minimum', () { + expect([3, 1, 4, 1, 5].min(), 1); + }); + + test('max returns maximum', () { + expect([3, 1, 4, 1, 5].max(), 5); + }); + }); +} diff --git a/test/extensions/map_test.dart b/test/extensions/map_test.dart new file mode 100644 index 0000000..acf4df9 --- /dev/null +++ b/test/extensions/map_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mayr_extensions/mayr_extensions.dart'; + +void main() { + group('Map Extensions', () { + test('getOrNull returns value or null', () { + expect({'a': 1, 'b': 2}.getOrNull('a'), 1); + expect({'a': 1, 'b': 2}.getOrNull('c'), null); + }); + + test('getOrDefault returns value or default', () { + expect({'a': 1, 'b': 2}.getOrDefault('a', 0), 1); + expect({'a': 1, 'b': 2}.getOrDefault('c', 0), 0); + }); + + test('mapKeys transforms keys', () { + final result = {'a': 1, 'b': 2}.mapKeys((k, v) => k.toUpperCase()); + expect(result, {'A': 1, 'B': 2}); + }); + + test('mapValues transforms values', () { + final result = {'a': 1, 'b': 2}.mapValues((k, v) => v * 2); + expect(result, {'a': 2, 'b': 4}); + }); + + test('filterKeys filters by key predicate', () { + final result = {'a': 1, 'b': 2, 'c': 3}.filterKeys((k) => k != 'b'); + expect(result, {'a': 1, 'c': 3}); + }); + + test('filterValues filters by value predicate', () { + final result = {'a': 1, 'b': 2, 'c': 3}.filterValues((v) => v > 1); + expect(result, {'b': 2, 'c': 3}); + }); + + test('invert swaps keys and values', () { + final result = {'a': 1, 'b': 2}.invert(); + expect(result, {1: 'a', 2: 'b'}); + }); + + test('merge combines maps with precedence', () { + final result = {'a': 1, 'b': 2}.merge({'b': 3, 'c': 4}); + expect(result, {'a': 1, 'b': 3, 'c': 4}); + }); + + test('mergeIfAbsent combines maps without overriding', () { + final result = {'a': 1, 'b': 2}.mergeIfAbsent({'b': 3, 'c': 4}); + expect(result, {'a': 1, 'b': 2, 'c': 4}); + }); + + test('combine merges with custom combiner', () { + final result = { + 'a': 1, + 'b': 2, + }.combine({'b': 3, 'c': 4}, (k, v1, v2) => v1 + v2); + expect(result, {'a': 1, 'b': 5, 'c': 4}); + }); + + test('keysWhere returns keys matching predicate', () { + final result = {'a': 1, 'b': 2, 'c': 3}.keysWhere((v) => v > 1).toList(); + expect(result, ['b', 'c']); + }); + + test('valuesWhere returns values matching predicate', () { + final result = + {'a': 1, 'b': 2, 'c': 3}.valuesWhere((k) => k != 'b').toList(); + expect(result, [1, 3]); + }); + + test('toQueryString converts to URL query string', () { + final result = {'name': 'John', 'age': '30'}.toQueryString(); + expect(result, 'name=John&age=30'); + }); + + test('toQueryString handles special characters', () { + final result = + {'key': 'value with spaces', 'special': 'a&b=c'}.toQueryString(); + expect( + result.contains('value+with+spaces') || + result.contains('value%20with%20spaces'), + true, + ); + }); + }); +} diff --git a/test/extensions/set_test.dart b/test/extensions/set_test.dart new file mode 100644 index 0000000..da426c0 --- /dev/null +++ b/test/extensions/set_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mayr_extensions/mayr_extensions.dart'; + +void main() { + group('Set Extensions', () { + test('toggle adds element if missing', () { + final result = {1, 2, 3}.toggle(4); + expect(result, {1, 2, 3, 4}); + }); + + test('toggle removes element if present', () { + final result = {1, 2, 3}.toggle(2); + expect(result, {1, 3}); + }); + + test('intersects returns true if sets have common elements', () { + expect({1, 2, 3}.intersects({2, 3, 4}), true); + expect({1, 2}.intersects({3, 4}), false); + }); + + test('isSubsetOf returns true if this set is contained in other', () { + expect({1, 2}.isSubsetOf({1, 2, 3}), true); + expect({1, 4}.isSubsetOf({1, 2, 3}), false); + expect({1, 2, 3}.isSubsetOf({1, 2, 3}), true); + }); + + test('isSupersetOf returns true if this set contains other', () { + expect({1, 2, 3}.isSupersetOf({1, 2}), true); + expect({1, 2}.isSupersetOf({1, 2, 3}), false); + expect({1, 2, 3}.isSupersetOf({1, 2, 3}), true); + }); + + test('unionAll combines multiple sets', () { + final result = {1, 2}.unionAll([ + {2, 3}, + {3, 4}, + ]); + expect(result, {1, 2, 3, 4}); + }); + + test('without removes element', () { + final result = {1, 2, 3}.without(2); + expect(result, {1, 3}); + }); + + test('without does nothing if element not present', () { + final result = {1, 2, 3}.without(4); + expect(result, {1, 2, 3}); + }); + }); +}