From 00f43ffc25192353624cb7fbf0a7303253451fcb Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Tue, 24 Feb 2026 02:50:25 +0530 Subject: [PATCH] chore: add Option type (#26467) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/utils/option.dart | 58 +++++++++++++++ mobile/test/utils/option_test.dart | 116 +++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 mobile/lib/utils/option.dart create mode 100644 mobile/test/utils/option_test.dart diff --git a/mobile/lib/utils/option.dart b/mobile/lib/utils/option.dart new file mode 100644 index 0000000000..3470e8489e --- /dev/null +++ b/mobile/lib/utils/option.dart @@ -0,0 +1,58 @@ +sealed class Option { + const Option(); + + const factory Option.some(T value) = Some; + + const factory Option.none() = None; + + factory Option.fromNullable(T? value) => value != null ? Some(value) : None(); + + @pragma('vm:prefer-inline') + bool get isSome => this is Some; + + @pragma('vm:prefer-inline') + bool get isNone => this is None; + + @pragma('vm:prefer-inline') + T? get unwrapOrNull => switch (this) { + Some(:final value) => value, + None() => null, + }; + + U fold(U Function(T value) onSome, U Function() onNone) => switch (this) { + Some(:final value) => onSome(value), + None() => onNone(), + }; + + @override + String toString() => switch (this) { + Some(:final value) => 'Some($value)', + None() => 'None', + }; +} + +final class Some extends Option { + final T value; + + const Some(this.value); + + @override + bool operator ==(Object other) => other is Some && other.value == value; + + @override + int get hashCode => value.hashCode; +} + +final class None extends Option { + const None(); + + @override + bool operator ==(Object other) => other is None; + + @override + int get hashCode => 0; +} + +extension ObjectOptionExtension on T? { + Option toOption() => Option.fromNullable(this); +} diff --git a/mobile/test/utils/option_test.dart b/mobile/test/utils/option_test.dart new file mode 100644 index 0000000000..4fa44a3865 --- /dev/null +++ b/mobile/test/utils/option_test.dart @@ -0,0 +1,116 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/utils/option.dart'; + +void main() { + group('Option', () { + group('constructors', () { + test('Option.some creates a Some instance', () { + const option = Option.some(42); + expect(option, isA>()); + expect((option as Some).value, 42); + }); + + test('Option.none creates a None instance', () { + const option = Option.none(); + expect(option, isA>()); + }); + + test('Option.fromNullable returns Some for non-null value', () { + final option = Option.fromNullable('hello'); + expect(option, isA>()); + expect((option as Some).value, 'hello'); + }); + + test('Option.fromNullable returns None for null value', () { + final option = Option.fromNullable(null); + expect(option, isA()); + }); + }); + + group('isSome / isNone', () { + test('Some.isSome is true', () { + expect(const Option.some(1).isSome, isTrue); + }); + + test('Some.isNone is false', () { + expect(const Option.some(1).isNone, isFalse); + }); + + test('None.isSome is false', () { + expect(const Option.none().isSome, isFalse); + }); + + test('None.isNone is true', () { + expect(const Option.none().isNone, isTrue); + }); + }); + + group('unwrapOrNull', () { + test('returns value for Some', () { + expect(const Option.some('hi').unwrapOrNull, 'hi'); + }); + + test('returns null for None', () { + expect(const Option.none().unwrapOrNull, isNull); + }); + }); + + group('fold', () { + test('calls onSome with value for Some', () { + final result = const Option.some('world').fold((v) => 'some: $v', () => 'none'); + expect(result, 'some: world'); + }); + + test('calls onNone for None', () { + final result = const Option.none().fold((v) => 'some: $v', () => 'none'); + expect(result, 'none'); + }); + }); + + group('equality', () { + test('Some equals Some with same value', () { + expect(const Option.some(1) == const Option.some(1), isTrue); + }); + + test('Some does not equal Some with different value', () { + expect(const Option.some(1) == const Option.some(2), isFalse); + }); + + test('None equals None of same type', () { + expect(const Option.none() == const Option.none(), isTrue); + }); + + test('None does not equal None of different type', () { + expect(const Option.none() == (const Option.none() as Object), isFalse); + }); + + test('Some does not equal None', () { + expect(const Option.some(0) == const Option.none(), isFalse); + }); + }); + + group('hashCode', () { + test('Some hashCode equals value hashCode', () { + expect(const Option.some('abc').hashCode, 'abc'.hashCode); + }); + + test('None hashCode is 0', () { + expect(const Option.none().hashCode, 0); + }); + }); + }); + + group('ObjectOptionExtension', () { + test('non-null value.toOption() returns Some', () { + final option = 'hello'.toOption(); + expect(option, isA>()); + expect((option as Some).value, 'hello'); + }); + + test('null value.toOption() returns None', () { + const String? value = null; + final option = value.toOption(); + expect(option, isA>()); + }); + }); +}