diff --git a/.vscode/launch.json b/.vscode/launch.json index 6c5efa5..5cbb077 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,10 +2,10 @@ "version": "0.2.0", "configurations": [ { - "name": "Console (example)", + "name": "Example", "request": "launch", "type": "dart", - "program": "examples/console/bin/main.dart", + "program": "example", "env": { "ENVIRONMENT": "local" }, diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 96d945e..4f7d96b 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -11,6 +11,75 @@ }, "problemMatcher": [] }, + { + "label": "Get protoc plugin", + "type": "shell", + "command": ["dart pub global activate protoc_plugin"], + "dependsOn": ["Dependencies"], + "group": { + "kind": "none", + "isDefault": true + }, + "problemMatcher": [] + }, + { + "label": "Generate protobuf", + "type": "shell", + "command": [ + "protoc", + "--proto_path=lib/src/transport/protobuf", + "--dart_out=lib/src/transport/protobuf lib/src/transport/protobuf/client.proto" + ], + "dependsOn": ["Get protoc plugin"], + "group": { + "kind": "none", + "isDefault": true + }, + "problemMatcher": [] + }, + { + "label": "Codegenerate", + "type": "shell", + "command": ["dart run build_runner build --delete-conflicting-outputs"], + "dependsOn": ["Dependencies"], + "group": { + "kind": "none", + "isDefault": true + }, + "problemMatcher": [] + }, + { + "label": "Format", + "type": "shell", + "command": [ + "dart format --fix -l 80 lib/src/model/pubspec.yaml.g.dart lib/src/transport/protobuf/" + ], + "group": { + "kind": "none", + "isDefault": true + }, + "problemMatcher": [] + }, + { + "label": "Prepare example", + "type": "shell", + "options": { + "cwd": "${workspaceFolder}/example" + }, + "command": [ + "dart pub global activate intl_utils", + "dart pub global run intl_utils:generate", + "fvm flutter pub get", + /* "&& fvm flutter gen-l10n --arb-dir lib/src/common/localization --output-dir lib/src/common/localization/generated --template-arb-file intl_en.arb", */ + "&& fvm flutter pub run build_runner build --delete-conflicting-outputs", + "&& dart format --fix -l 80 ." + ], + "group": { + "kind": "none", + "isDefault": true + }, + "problemMatcher": [] + }, { "label": "Start Centrifugo Server", "type": "shell", diff --git a/Makefile b/Makefile index 8504dc8..21c6fca 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ get: test: get @dart test --debug --coverage=.coverage --platform chrome,vm -publish: +publish: generate @yes | dart pub publish deploy: publish diff --git a/example/lib/src/common/constant/config.dart b/example/lib/src/common/constant/config.dart new file mode 100644 index 0000000..31983b0 --- /dev/null +++ b/example/lib/src/common/constant/config.dart @@ -0,0 +1,70 @@ +/// Config for app. +abstract final class Config { + /// Environment flavor. + /// e.g. development, staging, production + static final EnvironmentFlavor environment = EnvironmentFlavor.from( + const String.fromEnvironment('ENVIRONMENT', defaultValue: 'development')); + + // --- Centrifuge --- // + + /// Centrifuge url. + /// e.g. https://domain.tld + static const String centrifugeBaseUrl = String.fromEnvironment( + 'CENTRIFUGE_BASE_URL', + defaultValue: 'http://127.0.0.1:8000'); + + /// Centrifuge timeout in milliseconds. + /// e.g. 15000 ms + static const Duration centrifugeTimeout = Duration( + milliseconds: + int.fromEnvironment('CENTRIFUGE_TIMEOUT', defaultValue: 15000)); + + /// Secret for HMAC token. + static const String passwordMinLength = + String.fromEnvironment('CENTRIFUGE_TOKEN_HMAC_SECRET'); + + // --- Layout --- // + + /// Maximum screen layout width for screen with list view. + static const int maxScreenLayoutWidth = + int.fromEnvironment('MAX_LAYOUT_WIDTH', defaultValue: 768); +} + +/// Environment flavor. +/// e.g. development, staging, production +enum EnvironmentFlavor { + /// Development + development('development'), + + /// Staging + staging('staging'), + + /// Production + production('production'); + + /// {@nodoc} + const EnvironmentFlavor(this.value); + + /// {@nodoc} + factory EnvironmentFlavor.from(String? value) => + switch (value?.trim().toLowerCase()) { + 'development' || 'debug' || 'develop' || 'dev' => development, + 'staging' || 'profile' || 'stage' || 'stg' => staging, + 'production' || 'release' || 'prod' || 'prd' => production, + _ => const bool.fromEnvironment('dart.vm.product') + ? production + : development, + }; + + /// development, staging, production + final String value; + + /// Whether the environment is development. + bool get isDevelopment => this == development; + + /// Whether the environment is staging. + bool get isStaging => this == staging; + + /// Whether the environment is production. + bool get isProduction => this == production; +} diff --git a/example/lib/src/common/constant/pubspec.yaml.g.dart b/example/lib/src/common/constant/pubspec.yaml.g.dart new file mode 100644 index 0000000..8b1243b --- /dev/null +++ b/example/lib/src/common/constant/pubspec.yaml.g.dart @@ -0,0 +1,527 @@ +// ignore_for_file: lines_longer_than_80_chars, unnecessary_raw_strings +// ignore_for_file: use_raw_strings, avoid_classes_with_only_static_members +// ignore_for_file: avoid_escaping_inner_quotes, prefer_single_quotes + +/// GENERATED CODE - DO NOT MODIFY BY HAND + +library pubspec; + +// ***************************************************************************** +// * pubspec_generator * +// ***************************************************************************** + +/* + + MIT License + + Copyright (c) 2023 Plague Fox + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + */ + +/// Given a version number MAJOR.MINOR.PATCH, increment the: +/// +/// 1. MAJOR version when you make incompatible API changes +/// 2. MINOR version when you add functionality in a backward compatible manner +/// 3. PATCH version when you make backward compatible bug fixes +/// +/// Additional labels for pre-release and build metadata are available +/// as extensions to the MAJOR.MINOR.PATCH format. +typedef PubspecVersion = ({ + String representation, + String canonical, + int major, + int minor, + int patch, + List preRelease, + List build +}); + +/// # The pubspec file +/// +/// Code generated pubspec.yaml.g.dart from pubspec.yaml +/// This class is generated from pubspec.yaml, do not edit directly. +/// +/// Every pub package needs some metadata so it can specify its dependencies. +/// Pub packages that are shared with others also need to provide some other +/// information so users can discover them. All of this metadata goes +/// in the package’s pubspec: +/// a file named pubspec.yaml that’s written in the YAML language. +/// +/// Read more: +/// - https://pub.dev/packages/pubspec_generator +/// - https://dart.dev/tools/pub/pubspec +sealed class Pubspec { + /// Version + /// + /// Current app [version] + /// + /// Every package has a version. + /// A version number is required to host your package on the pub.dev site, + /// but can be omitted for local-only packages. + /// If you omit it, your package is implicitly versioned 0.0.0. + /// + /// Versioning is necessary for reusing code while letting it evolve quickly. + /// A version number is three numbers separated by dots, like 0.2.43. + /// It can also optionally have a build ( +1, +2, +hotfix.oopsie) + /// or prerelease (-dev.4, -alpha.12, -beta.7, -rc.5) suffix. + /// + /// Each time you publish your package, you publish it at a specific version. + /// Once that’s been done, consider it hermetically sealed: + /// you can’t touch it anymore. To make more changes, + /// you’ll need a new version. + /// + /// When you select a version, + /// follow [semantic versioning](https://semver.org/). + static const PubspecVersion version = ( + /// Non-canonical string representation of the version as provided + /// in the pubspec.yaml file. + representation: r'1.0.0+1', + + /// Returns a 'canonicalized' representation + /// of the application version. + /// This represents the version string in accordance with + /// Semantic Versioning (SemVer) standards. + canonical: r'1.0.0+1', + + /// MAJOR version when you make incompatible API changes. + /// The major version number: 1 in "1.2.3". + major: 1, + + /// MINOR version when you add functionality + /// in a backward compatible manner. + /// The minor version number: 2 in "1.2.3". + minor: 0, + + /// PATCH version when you make backward compatible bug fixes. + /// The patch version number: 3 in "1.2.3". + patch: 0, + + /// The pre-release identifier: "foo" in "1.2.3-foo". + preRelease: [], + + /// The build identifier: "foo" in "1.2.3+foo". + build: [r'1'], + ); + + /// Build date and time (UTC) + static final DateTime timestamp = DateTime.utc( + 2023, + 8, + 4, + 9, + 6, + 50, + 841, + 552, + ); + + /// Name + /// + /// Current app [name] + /// + /// Every package needs a name. + /// It’s how other packages refer to yours, and how it appears to the world, + /// should you publish it. + /// + /// The name should be all lowercase, with underscores to separate words, + /// just_like_this. Use only basic Latin letters and Arabic digits: + /// [a-z0-9_]. Also, make sure the name is a valid Dart identifier—that + /// it doesn’t start with digits + /// and isn’t a [reserved word](https://dart.dev/language/keywords). + /// + /// Try to pick a name that is clear, terse, and not already in use. + /// A quick search of packages on the [pub.dev site](https://pub.dev/packages) + /// to make sure that nothing else is using your name is recommended. + static const String name = r'spinifyapp'; + + /// Description + /// + /// Current app [description] + /// + /// This is optional for your own personal packages, + /// but if you intend to publish your package you must provide a description, + /// which should be in English. + /// The description should be relatively short, from 60 to 180 characters + /// and tell a casual reader what they might want to know about your package. + /// + /// Think of the description as the sales pitch for your package. + /// Users see it when they [browse for packages](https://pub.dev/packages). + /// The description is plain text: no markdown or HTML. + static const String description = r'Spinify App Example'; + + /// Homepage + /// + /// Current app [homepage] + /// + /// This should be a URL pointing to the website for your package. + /// For [hosted packages](https://dart.dev/tools/pub/dependencies#hosted-packages), + /// this URL is linked from the package’s page. + /// While providing a homepage is optional, + /// please provide it or repository (or both). + /// It helps users understand where your package is coming from. + static const String homepage = r'https://centrifugal.dev'; + + /// Repository + /// + /// Current app [repository] + /// + /// Repository + /// The optional repository field should contain the URL for your package’s + /// source code repository—for example, + /// https://github.com//. + /// If you publish your package to the pub.dev site, + /// then your package’s page displays the repository URL. + /// While providing a repository is optional, + /// please provide it or homepage (or both). + /// It helps users understand where your package is coming from. + static const String repository = r'https://github.com/PlugFox/spinify'; + + /// Issue tracker + /// + /// Current app [issueTracker] + /// + /// The optional issue_tracker field should contain a URL for the package’s + /// issue tracker, where existing bugs can be viewed and new bugs can be filed. + /// The pub.dev site attempts to display a link + /// to each package’s issue tracker, using the value of this field. + /// If issue_tracker is missing but repository is present and points to GitHub, + /// then the pub.dev site uses the default issue tracker + /// (https://github.com///issues). + static const String issueTracker = + r'https://github.com/PlugFox/spinify/issues'; + + /// Documentation + /// + /// Current app [documentation] + /// + /// Some packages have a site that hosts documentation, + /// separate from the main homepage and from the Pub-generated API reference. + /// If your package has additional documentation, add a documentation: + /// field with that URL; pub shows a link to this documentation + /// on your package’s page. + static const String documentation = r''; + + /// Publish_to + /// + /// Current app [publishTo] + /// + /// The default uses the [pub.dev](https://pub.dev/) site. + /// Specify none to prevent a package from being published. + /// This setting can be used to specify a custom pub package server to publish. + /// + /// ```yaml + /// publish_to: none + /// ``` + static const String publishTo = r'none'; + + /// Funding + /// + /// Current app [funding] + /// + /// Package authors can use the funding property to specify + /// a list of URLs that provide information on how users + /// can help fund the development of the package. For example: + /// + /// ```yaml + /// funding: + /// - https://www.buymeacoffee.com/example_user + /// - https://www.patreon.com/some-account + /// ``` + /// + /// If published to [pub.dev](https://pub.dev/) the links are displayed on the package page. + /// This aims to help users fund the development of their dependencies. + static const List funding = [ + r'https://www.buymeacoffee.com/plugfox', + r'https://www.patreon.com/plugfox', + r'https://boosty.to/plugfox', + ]; + + /// False_secrets + /// + /// Current app [falseSecrets] + /// + /// When you try to publish a package, + /// pub conducts a search for potential leaks of secret credentials, + /// API keys, or cryptographic keys. + /// If pub detects a potential leak in a file that would be published, + /// then pub warns you and refuses to publish the package. + /// + /// Leak detection isn’t perfect. To avoid false positives, + /// you can tell pub not to search for leaks in certain files, + /// by creating an allowlist using gitignore + /// patterns under false_secrets in the pubspec. + /// + /// For example, the following entry causes pub not to look + /// for leaks in the file lib/src/hardcoded_api_key.dart + /// and in all .pem files in the test/localhost_certificates/ directory: + /// + /// ```yaml + /// false_secrets: + /// - /lib/src/hardcoded_api_key.dart + /// - /test/localhost_certificates/*.pem + /// ``` + /// + /// Starting a gitignore pattern with slash (/) ensures + /// that the pattern is considered relative to the package’s root directory. + static const List falseSecrets = []; + + /// Screenshots + /// + /// Current app [screenshots] + /// + /// Packages can showcase their widgets or other visual elements + /// using screenshots displayed on their pub.dev page. + /// To specify screenshots for the package to display, + /// use the screenshots field. + /// + /// A package can list up to 10 screenshots under the screenshots field. + /// Don’t include logos or other branding imagery in this section. + /// Each screenshot includes one description and one path. + /// The description explains what the screenshot depicts + /// in no more than 160 characters. For example: + /// + /// ```yaml + /// screenshots: + /// - description: 'This screenshot shows the transformation of a number of bytes + /// to a human-readable expression.' + /// path: path/to/image/in/package/500x500.webp + /// - description: 'This screenshot shows a stack trace returning a human-readable + /// representation.' + /// path: path/to/image/in/package.png + /// ``` + /// + /// Pub.dev limits screenshots to the following specifications: + /// + /// - File size: max 4 MB per image. + /// - File types: png, jpg, gif, or webp. + /// - Static and animated images are both allowed. + /// + /// Keep screenshot files small. Each download of the package + /// includes all screenshot files. + /// + /// Pub.dev generates the package’s thumbnail image from the first screenshot. + /// If this screenshot uses animation, pub.dev uses its first frame. + static const List screenshots = []; + + /// Topics + /// + /// Current app [topics] + /// + /// Package authors can use the topics field to categorize their package. Topics can be used to assist discoverability during search with filters on pub.dev. Pub.dev displays the topics on the package page as well as in the search results. + /// + /// The field consists of a list of names. For example: + /// + /// ```yaml + /// topics: + /// - network + /// - http + /// ``` + /// + /// Pub.dev requires topics to follow these specifications: + /// + /// - Tag each package with at most 5 topics. + /// - Write the topic name following these requirements: + /// 1) Use between 2 and 32 characters. + /// 2) Use only lowercase alphanumeric characters or hyphens (a-z, 0-9, -). + /// 3) Don’t use two consecutive hyphens (--). + /// 4) Start the name with lowercase alphabet characters (a-z). + /// 5) End with alphanumeric characters (a-z or 0-9). + /// + /// When choosing topics, consider if existing topics are relevant. + /// Tagging with existing topics helps users discover your package. + static const List topics = [ + r'spinify', + r'centrifugo', + r'centrifuge', + r'websocket', + r'cross-platform', + ]; + + /// Environment + static const Map environment = { + 'sdk': '>=3.1.0-63.1.beta <4.0.0', + 'flutter': '>=3.1.0-63.1.beta <4.0.0', + }; + + /// Platforms + /// + /// Current app [platforms] + /// + /// When you [publish a package](https://dart.dev/tools/pub/publishing), + /// pub.dev automatically detects the platforms that the package supports. + /// If this platform-support list is incorrect, + /// use platforms to explicitly declare which platforms your package supports. + /// + /// For example, the following platforms entry causes + /// pub.dev to list the package as supporting + /// Android, iOS, Linux, macOS, Web, and Windows: + /// + /// ```yaml + /// # This package supports all platforms listed below. + /// platforms: + /// android: + /// ios: + /// linux: + /// macos: + /// web: + /// windows: + /// ``` + /// + /// Here is an example of declaring that the package supports only Linux and macOS (and not, for example, Windows): + /// + /// ```yaml + /// # This package supports only Linux and macOS. + /// platforms: + /// linux: + /// macos: + /// ``` + static const Map platforms = { + 'android': r'', + 'ios': r'', + 'linux': r'', + 'macos': r'', + 'web': r'', + 'windows': r'', + }; + + /// Dependencies + /// + /// Current app [dependencies] + /// + /// [Dependencies](https://dart.dev/tools/pub/glossary#dependency) + /// are the pubspec’s `raison d’être`. + /// In this section you list each package that + /// your package needs in order to work. + /// + /// Dependencies fall into one of two types. + /// Regular dependencies are listed under dependencies: + /// these are packages that anyone using your package will also need. + /// Dependencies that are only needed in + /// the development phase of the package itself + /// are listed under dev_dependencies. + /// + /// During the development process, + /// you might need to temporarily override a dependency. + /// You can do so using dependency_overrides. + /// + /// For more information, + /// see [Package dependencies](https://dart.dev/tools/pub/dependencies). + static const Map dependencies = { + 'flutter': { + 'sdk': r'flutter', + }, + 'flutter_localizations': { + 'sdk': r'flutter', + }, + 'intl': r'any', + 'collection': r'any', + 'async': r'any', + 'meta': r'any', + 'path': r'any', + 'platform_info': r'^4.0.2', + 'win32': r'^5.0.6', + 'window_manager': r'^0.3.5', + 'l': r'^4.0.2', + 'spinify': { + 'path': r'../', + }, + 'cupertino_icons': r'^1.0.2', + }; + + /// Developer dependencies + static const Map devDependencies = { + 'flutter_test': { + 'sdk': r'flutter', + }, + 'integration_test': { + 'sdk': r'flutter', + }, + 'build_runner': r'^2.4.6', + 'pubspec_generator': r'>=4.0.0 <5.0.0', + 'flutter_lints': r'^2.0.1', + }; + + /// Dependency overrides + static const Map dependencyOverrides = {}; + + /// Executables + /// + /// Current app [executables] + /// + /// A package may expose one or more of its scripts as executables + /// that can be run directly from the command line. + /// To make a script publicly available, + /// list it under the executables field. + /// Entries are listed as key/value pairs: + /// + /// ```yaml + /// : + /// ``` + /// + /// For example, the following pubspec entry lists two scripts: + /// + /// ```yaml + /// executables: + /// slidy: main + /// fvm: + /// ``` + /// + /// Once the package is activated using pub global activate, + /// typing `slidy` executes `bin/main.dart`. + /// Typing `fvm` executes `bin/fvm.dart`. + /// If you don’t specify the value, it is inferred from the key. + /// + /// For more information, see pub global. + static const Map executables = {}; + + /// Source data from pubspec.yaml + static const Map source = { + 'name': name, + 'description': description, + 'repository': repository, + 'issue_tracker': issueTracker, + 'homepage': homepage, + 'documentation': documentation, + 'publish_to': publishTo, + 'version': version, + 'funding': funding, + 'false_secrets': falseSecrets, + 'screenshots': screenshots, + 'topics': topics, + 'platforms': platforms, + 'environment': environment, + 'dependencies': dependencies, + 'dev_dependencies': devDependencies, + 'dependency_overrides': dependencyOverrides, + 'flutter': { + 'generate': true, + 'uses-material-design': true, + }, + 'flutter_intl': { + 'enabled': true, + 'class_name': r'GeneratedLocalization', + 'main_locale': r'en', + 'arb_dir': r'lib/src/common/localization', + 'output_dir': r'lib/src/common/localization/generated', + 'use_deferred_loading': false, + }, + }; +} diff --git a/example/lib/src/common/controller/controller.dart b/example/lib/src/common/controller/controller.dart new file mode 100644 index 0000000..731fc37 --- /dev/null +++ b/example/lib/src/common/controller/controller.dart @@ -0,0 +1,74 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' show Listenable, ChangeNotifier; +import 'package:meta/meta.dart'; + +/// {@template controller} +/// The controller responsible for processing the logic, +/// the connection of widgets and the date of the layer. +/// {@endtemplate} +abstract interface class IController implements Listenable { + /// Whether the controller is currently handling a requests + bool get isProcessing; + + /// Discards any resources used by the object. + /// + /// This method should only be called by the object's owner. + void dispose(); +} + +/// Controller observer +abstract interface class IControllerObserver { + /// Called when the controller is created. + void onCreate(IController controller); + + /// Called when the controller is disposed. + void onDispose(IController controller); + + /// Called on any state change in the controller. + void onStateChanged(IController controller, Object prevState, Object nextState); + + /// Called on any error in the controller. + void onError(IController controller, Object error, StackTrace stackTrace); +} + +/// {@template controller} +abstract base class Controller with ChangeNotifier implements IController { + Controller() { + runZonedGuarded( + () => Controller.observer?.onCreate(this), + (error, stackTrace) {/* ignore */}, + ); + } + + /// Controller observer + static IControllerObserver? observer; + + bool get isDisposed => _$isDisposed; + bool _$isDisposed = false; + + @protected + void onError(Object error, StackTrace stackTrace) => runZonedGuarded( + () => Controller.observer?.onError(this, error, stackTrace), + (error, stackTrace) {/* ignore */}, + ); + + @protected + void handle(FutureOr Function() handler); + + @override + @mustCallSuper + void dispose() { + _$isDisposed = true; + runZonedGuarded( + () => Controller.observer?.onDispose(this), + (error, stackTrace) {/* ignore */}, + ); + super.dispose(); + } + + @protected + @nonVirtual + @override + void notifyListeners() => super.notifyListeners(); +} diff --git a/example/lib/src/common/controller/controller_observer.dart b/example/lib/src/common/controller/controller_observer.dart new file mode 100644 index 0000000..f288068 --- /dev/null +++ b/example/lib/src/common/controller/controller_observer.dart @@ -0,0 +1,25 @@ +import 'package:l/l.dart'; +import 'package:spinifyapp/src/common/controller/controller.dart'; + +class ControllerObserver implements IControllerObserver { + @override + void onCreate(IController controller) { + l.v6('Controller | ${controller.runtimeType} | Created'); + } + + @override + void onDispose(IController controller) { + l.v5('Controller | ${controller.runtimeType} | Disposed'); + } + + @override + void onStateChanged( + IController controller, Object prevState, Object nextState) { + l.d('Controller | ${controller.runtimeType} | $prevState -> $nextState'); + } + + @override + void onError(IController controller, Object error, StackTrace stackTrace) { + l.w('Controller | ${controller.runtimeType} | $error', stackTrace); + } +} diff --git a/example/lib/src/common/controller/droppable_controller_concurrency.dart b/example/lib/src/common/controller/droppable_controller_concurrency.dart new file mode 100644 index 0000000..a029f6b --- /dev/null +++ b/example/lib/src/common/controller/droppable_controller_concurrency.dart @@ -0,0 +1,40 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; +import 'package:spinifyapp/src/common/controller/controller.dart'; + +base mixin DroppableControllerConcurrency on Controller { + @override + @nonVirtual + bool get isProcessing => _$processingCalls > 0; + int _$processingCalls = 0; + + @override + @protected + @mustCallSuper + void handle( + FutureOr Function() handler, [ + FutureOr Function(Object error, StackTrace stackTrace)? errorHandler, + FutureOr Function()? doneHandler, + ]) => + runZonedGuarded( + () async { + if (isDisposed || isProcessing) return; + _$processingCalls++; + try { + await handler(); + } on Object catch (error, stackTrace) { + onError(error, stackTrace); + await Future(() async { + await errorHandler?.call(error, stackTrace); + }).catchError(onError); + } finally { + await Future(() async { + await doneHandler?.call(); + }).catchError(onError); + _$processingCalls--; + } + }, + onError, + ); +} diff --git a/example/lib/src/common/controller/sequential_controller_concurrency.dart b/example/lib/src/common/controller/sequential_controller_concurrency.dart new file mode 100644 index 0000000..e03b9a3 --- /dev/null +++ b/example/lib/src/common/controller/sequential_controller_concurrency.dart @@ -0,0 +1,137 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:meta/meta.dart'; +import 'package:spinifyapp/src/common/controller/controller.dart'; + +base mixin SequentialControllerConcurrency on Controller { + final _ControllerEventQueue _eventQueue = _ControllerEventQueue(); + + @override + @nonVirtual + bool get isProcessing => _eventQueue.length > 0; + + @override + @protected + @mustCallSuper + void handle( + FutureOr Function() handler, [ + FutureOr Function(Object error, StackTrace stackTrace)? errorHandler, + FutureOr Function()? doneHandler, + ]) => + _eventQueue.push( + () { + final completer = Completer(); + runZonedGuarded( + () async { + if (isDisposed) return; + try { + await handler(); + } on Object catch (error, stackTrace) { + onError(error, stackTrace); + await Future(() async { + await errorHandler?.call(error, stackTrace); + }).catchError(onError); + } finally { + await Future(() async { + await doneHandler?.call(); + }).catchError(onError); + completer.complete(); + } + }, + onError, + ); + return completer.future; + }, + ); +} + +/// {@nodoc} +final class _ControllerEventQueue { + /// {@nodoc} + _ControllerEventQueue(); + + final DoubleLinkedQueue<_SequentialTask> _queue = + DoubleLinkedQueue<_SequentialTask>(); + Future? _processing; + bool _isClosed = false; + + /// Event queue length. + /// {@nodoc} + int get length => _queue.length; + + /// Push it at the end of the queue. + /// {@nodoc} + Future push(FutureOr Function() fn) { + final task = _SequentialTask(fn); + _queue.add(task); + _exec(); + return task.future; + } + + /// Mark the queue as closed. + /// The queue will be processed until it's empty. + /// But all new and current events will be rejected with [WSClientClosed]. + /// {@nodoc} + FutureOr close() async { + _isClosed = true; + await _processing; + } + + /// Execute the queue. + /// {@nodoc} + void _exec() => _processing ??= Future.doWhile(() async { + final event = _queue.first; + try { + if (_isClosed) { + event.reject(StateError('Controller\'s event queue are disposed'), + StackTrace.current); + } else { + await event(); + } + } on Object catch (error, stackTrace) { + /* warning( + error, + stackTrace, + 'Error while processing event "${event.id}"', + ); */ + Future.sync(() => event.reject(error, stackTrace)).ignore(); + } + _queue.removeFirst(); + final isEmpty = _queue.isEmpty; + if (isEmpty) _processing = null; + return !isEmpty; + }); +} + +/// {@nodoc} +class _SequentialTask { + /// {@nodoc} + _SequentialTask(FutureOr Function() fn) + : _fn = fn, + _completer = Completer(); + + /// {@nodoc} + final Completer _completer; + + /// {@nodoc} + final FutureOr Function() _fn; + + /// {@nodoc} + Future get future => _completer.future; + + /// {@nodoc} + FutureOr call() async { + final result = await _fn(); + if (!_completer.isCompleted) { + _completer.complete(result); + } + return result; + } + + /// {@nodoc} + void reject(Object error, [StackTrace? stackTrace]) { + if (_completer.isCompleted) return; + _completer.completeError(error, stackTrace); + } +} diff --git a/example/lib/src/common/controller/state_consumer.dart b/example/lib/src/common/controller/state_consumer.dart new file mode 100644 index 0000000..ad366a2 --- /dev/null +++ b/example/lib/src/common/controller/state_consumer.dart @@ -0,0 +1,129 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:spinifyapp/src/common/controller/state_controller.dart'; + +/// Fire when the state changes. +typedef StateConsumerListener = void Function( + BuildContext context, S previous, S current); + +/// Build when the method returns true. +typedef StateConsumerCondition = bool Function(S previous, S current); + +/// Rebuild the widget when the state changes. +typedef StateConsumerBuilder = Widget Function( + BuildContext context, S state, Widget? child); + +/// {@template state_consumer} +/// StateBuilder widget. +/// {@endtemplate} +class StateConsumer extends StatefulWidget { + /// {@macro state_builder} + const StateConsumer({ + required this.controller, + this.listener, + this.buildWhen, + this.builder, + this.child, + super.key, + }); + + /// The controller responsible for processing the logic, + final IStateController controller; + + /// Takes the `BuildContext` along with the `state` + /// and is responsible for executing in response to `state` changes. + final StateConsumerListener? listener; + + /// Takes the previous `state` and the current `state` and is responsible for + /// returning a [bool] which determines whether or not to trigger + /// [builder] with the current `state`. + final StateConsumerCondition? buildWhen; + + /// The [builder] function which will be invoked on each widget build. + /// The [builder] takes the `BuildContext` and current `state` and + /// must return a widget. + /// This is analogous to the [builder] function in [StreamBuilder]. + final StateConsumerBuilder? builder; + + /// The child widget which will be passed to the [builder]. + final Widget? child; + + @override + State> createState() => _StateConsumerState(); +} + +class _StateConsumerState extends State> { + late IStateController _controller; + late S _previousState; + + @override + void initState() { + super.initState(); + _controller = widget.controller; + _previousState = _controller.state; + _subscribe(); + } + + @override + void didUpdateWidget(StateConsumer oldWidget) { + super.didUpdateWidget(oldWidget); + final oldController = oldWidget.controller, + newController = widget.controller; + if (identical(oldController, newController) || + oldController == newController) return; + _unsubscribe(); + _controller = newController; + _previousState = newController.state; + _subscribe(); + } + + @override + void dispose() { + _unsubscribe(); + super.dispose(); + } + + void _subscribe() => _controller.addListener(_valueChanged); + + void _unsubscribe() => _controller.removeListener(_valueChanged); + + void _valueChanged() { + final oldState = _previousState, newState = _controller.state; + if (!mounted || identical(oldState, newState)) return; + _previousState = newState; + widget.listener?.call(context, oldState, newState); + if (widget.buildWhen?.call(oldState, newState) ?? true) { + // Rebuild the widget when the state changes. + switch (SchedulerBinding.instance.schedulerPhase) { + case SchedulerPhase.idle: + case SchedulerPhase.transientCallbacks: + case SchedulerPhase.postFrameCallbacks: + setState(() {}); + case SchedulerPhase.persistentCallbacks: + case SchedulerPhase.midFrameMicrotasks: + SchedulerBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() {}); + }); + } + } + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) => + super.debugFillProperties(properties + ..add( + DiagnosticsProperty>('Controller', _controller)) + ..add(DiagnosticsProperty('State', _controller.state)) + ..add(FlagProperty('isProcessing', + value: _controller.isProcessing, + ifTrue: 'Processing', + ifFalse: 'Idle'))); + + @override + Widget build(BuildContext context) => + widget.builder?.call(context, _controller.state, widget.child) ?? + widget.child ?? + const SizedBox.shrink(); +} diff --git a/example/lib/src/common/controller/state_controller.dart b/example/lib/src/common/controller/state_controller.dart new file mode 100644 index 0000000..e6dcc64 --- /dev/null +++ b/example/lib/src/common/controller/state_controller.dart @@ -0,0 +1,35 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:spinifyapp/src/common/controller/controller.dart'; + +/// State controller +abstract interface class IStateController + implements IController { + /// The current state of the controller. + State get state; +} + +/// State controller +abstract base class StateController extends Controller + implements IStateController { + /// State controller + StateController({required State initialState}) : _$state = initialState; + + @override + @nonVirtual + State get state => _$state; + State _$state; + + @protected + @nonVirtual + void setState(State state) { + runZonedGuarded( + () => Controller.observer?.onStateChanged(this, _$state, state), + (error, stackTrace) {/* ignore */}, + ); + _$state = state; + if (isDisposed) return; + notifyListeners(); + } +} diff --git a/example/lib/src/common/localization/generated/intl/messages_all.dart b/example/lib/src/common/localization/generated/intl/messages_all.dart new file mode 100644 index 0000000..203415c --- /dev/null +++ b/example/lib/src/common/localization/generated/intl/messages_all.dart @@ -0,0 +1,63 @@ +// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart +// This is a library that looks up messages for specific locales by +// delegating to the appropriate library. + +// Ignore issues from commonly used lints in this file. +// ignore_for_file:implementation_imports, file_names, unnecessary_new +// ignore_for_file:unnecessary_brace_in_string_interps, directives_ordering +// ignore_for_file:argument_type_not_assignable, invalid_assignment +// ignore_for_file:prefer_single_quotes, prefer_generic_function_type_aliases +// ignore_for_file:comment_references + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:intl/intl.dart'; +import 'package:intl/message_lookup_by_library.dart'; +import 'package:intl/src/intl_helpers.dart'; + +import 'messages_en.dart' as messages_en; + +typedef Future LibraryLoader(); +Map _deferredLibraries = { + 'en': () => new SynchronousFuture(null), +}; + +MessageLookupByLibrary? _findExact(String localeName) { + switch (localeName) { + case 'en': + return messages_en.messages; + default: + return null; + } +} + +/// User programs should call this before using [localeName] for messages. +Future initializeMessages(String localeName) { + var availableLocale = Intl.verifiedLocale( + localeName, (locale) => _deferredLibraries[locale] != null, + onFailure: (_) => null); + if (availableLocale == null) { + return new SynchronousFuture(false); + } + var lib = _deferredLibraries[availableLocale]; + lib == null ? new SynchronousFuture(false) : lib(); + initializeInternalMessageLookup(() => new CompositeMessageLookup()); + messageLookup.addLocale(availableLocale, _findGeneratedMessagesFor); + return new SynchronousFuture(true); +} + +bool _messagesExistFor(String locale) { + try { + return _findExact(locale) != null; + } catch (e) { + return false; + } +} + +MessageLookupByLibrary? _findGeneratedMessagesFor(String locale) { + var actualLocale = + Intl.verifiedLocale(locale, _messagesExistFor, onFailure: (_) => null); + if (actualLocale == null) return null; + return _findExact(actualLocale); +} diff --git a/example/lib/src/common/localization/generated/intl/messages_en.dart b/example/lib/src/common/localization/generated/intl/messages_en.dart new file mode 100644 index 0000000..f998a81 --- /dev/null +++ b/example/lib/src/common/localization/generated/intl/messages_en.dart @@ -0,0 +1,154 @@ +// DO NOT EDIT. This is code generated via package:intl/generate_localized.dart +// This is a library that provides messages for a en locale. All the +// messages from the main program should be duplicated here with the same +// function name. + +// Ignore issues from commonly used lints in this file. +// ignore_for_file:unnecessary_brace_in_string_interps, unnecessary_new +// ignore_for_file:prefer_single_quotes,comment_references, directives_ordering +// ignore_for_file:annotate_overrides,prefer_generic_function_type_aliases +// ignore_for_file:unused_import, file_names, avoid_escaping_inner_quotes +// ignore_for_file:unnecessary_string_interpolations, unnecessary_string_escapes + +import 'package:intl/intl.dart'; +import 'package:intl/message_lookup_by_library.dart'; + +final messages = new MessageLookup(); + +typedef String MessageIfAbsent(String messageStr, List args); + +class MessageLookup extends MessageLookupByLibrary { + String get localeName => 'en'; + + final messages = _notInlinedMessages(_notInlinedMessages); + static Map _notInlinedMessages(_) => { + "addButton": MessageLookupByLibrary.simpleMessage("Add"), + "addToStarredButton": + MessageLookupByLibrary.simpleMessage("Add to starred"), + "apiDomain": MessageLookupByLibrary.simpleMessage("API domain"), + "appLabel": MessageLookupByLibrary.simpleMessage("App"), + "applicationInformationLabel": + MessageLookupByLibrary.simpleMessage("Application information"), + "applicationLabel": MessageLookupByLibrary.simpleMessage("Application"), + "applicationVersionLabel": + MessageLookupByLibrary.simpleMessage("Application version"), + "authenticateLabel": + MessageLookupByLibrary.simpleMessage("Authenticate"), + "authenticatedLabel": + MessageLookupByLibrary.simpleMessage("Authenticated"), + "authenticationLabel": + MessageLookupByLibrary.simpleMessage("Authentication"), + "backButton": MessageLookupByLibrary.simpleMessage("Back"), + "cancelButton": MessageLookupByLibrary.simpleMessage("Cancel"), + "conectedDevicesLabel": + MessageLookupByLibrary.simpleMessage("Connected devices"), + "confirmButton": MessageLookupByLibrary.simpleMessage("Confirm"), + "continueButton": MessageLookupByLibrary.simpleMessage("Continue"), + "copiedLabel": MessageLookupByLibrary.simpleMessage("Copied"), + "copyButton": MessageLookupByLibrary.simpleMessage("Copy"), + "createButton": MessageLookupByLibrary.simpleMessage("Create"), + "currentLabel": MessageLookupByLibrary.simpleMessage("Current"), + "currentUserLabel": + MessageLookupByLibrary.simpleMessage("Current user"), + "currentVersionLabel": + MessageLookupByLibrary.simpleMessage("Current version"), + "databaseLabel": MessageLookupByLibrary.simpleMessage("Database"), + "dateLabel": MessageLookupByLibrary.simpleMessage("Date"), + "deleteButton": MessageLookupByLibrary.simpleMessage("Delete"), + "downloadButton": MessageLookupByLibrary.simpleMessage("Download"), + "editButton": MessageLookupByLibrary.simpleMessage("Edit"), + "emailLabel": MessageLookupByLibrary.simpleMessage("Email"), + "errAnErrorHasOccurred": + MessageLookupByLibrary.simpleMessage("An error has occurred"), + "errAnExceptionHasOccurred": + MessageLookupByLibrary.simpleMessage("An exception has occurred"), + "errAnUnknownErrorWasReceivedFromTheServer": + MessageLookupByLibrary.simpleMessage( + "An unknown error was received from the server"), + "errAssertionError": + MessageLookupByLibrary.simpleMessage("Assertion error"), + "errBadGateway": MessageLookupByLibrary.simpleMessage("Bad gateway"), + "errBadRequest": MessageLookupByLibrary.simpleMessage("Bad request"), + "errBadStateError": + MessageLookupByLibrary.simpleMessage("Bad state error"), + "errError": MessageLookupByLibrary.simpleMessage("Error"), + "errException": MessageLookupByLibrary.simpleMessage("Exception"), + "errFileSystemException": + MessageLookupByLibrary.simpleMessage("File system error"), + "errForbidden": MessageLookupByLibrary.simpleMessage("Forbidden"), + "errGatewayTimeout": + MessageLookupByLibrary.simpleMessage("Gateway timeout"), + "errInternalServerError": + MessageLookupByLibrary.simpleMessage("Internal server error"), + "errInvalidCredentials": + MessageLookupByLibrary.simpleMessage("Invalid credentials"), + "errInvalidFormat": + MessageLookupByLibrary.simpleMessage("Invalid format"), + "errNotAcceptable": + MessageLookupByLibrary.simpleMessage("Not acceptable"), + "errNotFound": MessageLookupByLibrary.simpleMessage("Not found"), + "errNotImplementedYet": + MessageLookupByLibrary.simpleMessage("Not implemented yet"), + "errRequestTimeout": + MessageLookupByLibrary.simpleMessage("Request timeout"), + "errServiceUnavailable": + MessageLookupByLibrary.simpleMessage("Service unavailable"), + "errSomethingWentWrong": + MessageLookupByLibrary.simpleMessage("Something went wrong"), + "errTimeOutExceeded": + MessageLookupByLibrary.simpleMessage("Time out exceeded"), + "errTooManyRequests": + MessageLookupByLibrary.simpleMessage("Too many requests"), + "errTryAgainLater": + MessageLookupByLibrary.simpleMessage("Please try again later."), + "errUnauthorized": MessageLookupByLibrary.simpleMessage("Unauthorized"), + "errUnimplemented": + MessageLookupByLibrary.simpleMessage("Unimplemented"), + "errUnknownServerError": + MessageLookupByLibrary.simpleMessage("Unknown server error"), + "errUnsupportedOperation": + MessageLookupByLibrary.simpleMessage("Unsupported operation"), + "exitButton": MessageLookupByLibrary.simpleMessage("Exit"), + "helpLabel": MessageLookupByLibrary.simpleMessage("Help"), + "language": MessageLookupByLibrary.simpleMessage("English"), + "languageCode": MessageLookupByLibrary.simpleMessage("en"), + "languageSelectionLabel": + MessageLookupByLibrary.simpleMessage("Language selection"), + "latestVersionLabel": + MessageLookupByLibrary.simpleMessage("Latest version"), + "localeName": MessageLookupByLibrary.simpleMessage("en_US"), + "logInButton": MessageLookupByLibrary.simpleMessage("Log In"), + "logOutButton": MessageLookupByLibrary.simpleMessage("Log Out"), + "logOutDescription": MessageLookupByLibrary.simpleMessage( + "You will be logged out from your account"), + "moveButton": MessageLookupByLibrary.simpleMessage("Move"), + "moveToTrashButton": + MessageLookupByLibrary.simpleMessage("Move to trash"), + "nameLabel": MessageLookupByLibrary.simpleMessage("Name"), + "navigationLabel": MessageLookupByLibrary.simpleMessage("Navigation"), + "removeFromStarredButton": + MessageLookupByLibrary.simpleMessage("Remove from starred"), + "renameButton": MessageLookupByLibrary.simpleMessage("Rename"), + "renewalDateLabel": + MessageLookupByLibrary.simpleMessage("Renewal date"), + "restoreButton": MessageLookupByLibrary.simpleMessage("Restore"), + "saveButton": MessageLookupByLibrary.simpleMessage("Save"), + "selectedLabel": MessageLookupByLibrary.simpleMessage("Selected"), + "shareButton": MessageLookupByLibrary.simpleMessage("Share"), + "shareLinkButton": MessageLookupByLibrary.simpleMessage("Share link"), + "signInButton": MessageLookupByLibrary.simpleMessage("Sign In"), + "signUpButton": MessageLookupByLibrary.simpleMessage("Sign Up"), + "sizeLabel": MessageLookupByLibrary.simpleMessage("Size"), + "statusLabel": MessageLookupByLibrary.simpleMessage("Status"), + "storageLabel": MessageLookupByLibrary.simpleMessage("Storage"), + "surnameLabel": MessageLookupByLibrary.simpleMessage("Surname"), + "timeLabel": MessageLookupByLibrary.simpleMessage("Time"), + "title": MessageLookupByLibrary.simpleMessage("Spinify"), + "typeLabel": MessageLookupByLibrary.simpleMessage("Type"), + "upgradeLabel": MessageLookupByLibrary.simpleMessage("Upgrade"), + "uploadButton": MessageLookupByLibrary.simpleMessage("Upload"), + "usefulLinksLabel": + MessageLookupByLibrary.simpleMessage("Useful links"), + "versionLabel": MessageLookupByLibrary.simpleMessage("Version") + }; +} diff --git a/example/lib/src/common/localization/generated/l10n.dart b/example/lib/src/common/localization/generated/l10n.dart new file mode 100644 index 0000000..507d46d --- /dev/null +++ b/example/lib/src/common/localization/generated/l10n.dart @@ -0,0 +1,981 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'intl/messages_all.dart'; + +// ************************************************************************** +// Generator: Flutter Intl IDE plugin +// Made by Localizely +// ************************************************************************** + +// ignore_for_file: non_constant_identifier_names, lines_longer_than_80_chars +// ignore_for_file: join_return_with_assignment, prefer_final_in_for_each +// ignore_for_file: avoid_redundant_argument_values, avoid_escaping_inner_quotes + +class GeneratedLocalization { + GeneratedLocalization(); + + static GeneratedLocalization? _current; + + static GeneratedLocalization get current { + assert(_current != null, + 'No instance of GeneratedLocalization was loaded. Try to initialize the GeneratedLocalization delegate before accessing GeneratedLocalization.current.'); + return _current!; + } + + static const AppLocalizationDelegate delegate = AppLocalizationDelegate(); + + static Future load(Locale locale) { + final name = (locale.countryCode?.isEmpty ?? false) + ? locale.languageCode + : locale.toString(); + final localeName = Intl.canonicalizedLocale(name); + return initializeMessages(localeName).then((_) { + Intl.defaultLocale = localeName; + final instance = GeneratedLocalization(); + GeneratedLocalization._current = instance; + + return instance; + }); + } + + static GeneratedLocalization of(BuildContext context) { + final instance = GeneratedLocalization.maybeOf(context); + assert(instance != null, + 'No instance of GeneratedLocalization present in the widget tree. Did you add GeneratedLocalization.delegate in localizationsDelegates?'); + return instance!; + } + + static GeneratedLocalization? maybeOf(BuildContext context) { + return Localizations.of( + context, GeneratedLocalization); + } + + /// `en_US` + String get localeName { + return Intl.message( + 'en_US', + name: 'localeName', + desc: '', + args: [], + ); + } + + /// `en` + String get languageCode { + return Intl.message( + 'en', + name: 'languageCode', + desc: '', + args: [], + ); + } + + /// `English` + String get language { + return Intl.message( + 'English', + name: 'language', + desc: '', + args: [], + ); + } + + /// `Spinify` + String get title { + return Intl.message( + 'Spinify', + name: 'title', + desc: '', + args: [], + ); + } + + /// `Something went wrong` + String get errSomethingWentWrong { + return Intl.message( + 'Something went wrong', + name: 'errSomethingWentWrong', + desc: '', + args: [], + ); + } + + /// `Error` + String get errError { + return Intl.message( + 'Error', + name: 'errError', + desc: '', + args: [], + ); + } + + /// `Exception` + String get errException { + return Intl.message( + 'Exception', + name: 'errException', + desc: '', + args: [], + ); + } + + /// `An error has occurred` + String get errAnErrorHasOccurred { + return Intl.message( + 'An error has occurred', + name: 'errAnErrorHasOccurred', + desc: '', + args: [], + ); + } + + /// `An exception has occurred` + String get errAnExceptionHasOccurred { + return Intl.message( + 'An exception has occurred', + name: 'errAnExceptionHasOccurred', + desc: '', + args: [], + ); + } + + /// `Please try again later.` + String get errTryAgainLater { + return Intl.message( + 'Please try again later.', + name: 'errTryAgainLater', + desc: '', + args: [], + ); + } + + /// `Invalid format` + String get errInvalidFormat { + return Intl.message( + 'Invalid format', + name: 'errInvalidFormat', + desc: '', + args: [], + ); + } + + /// `Time out exceeded` + String get errTimeOutExceeded { + return Intl.message( + 'Time out exceeded', + name: 'errTimeOutExceeded', + desc: '', + args: [], + ); + } + + /// `Invalid credentials` + String get errInvalidCredentials { + return Intl.message( + 'Invalid credentials', + name: 'errInvalidCredentials', + desc: '', + args: [], + ); + } + + /// `Unimplemented` + String get errUnimplemented { + return Intl.message( + 'Unimplemented', + name: 'errUnimplemented', + desc: '', + args: [], + ); + } + + /// `Not implemented yet` + String get errNotImplementedYet { + return Intl.message( + 'Not implemented yet', + name: 'errNotImplementedYet', + desc: '', + args: [], + ); + } + + /// `Unsupported operation` + String get errUnsupportedOperation { + return Intl.message( + 'Unsupported operation', + name: 'errUnsupportedOperation', + desc: '', + args: [], + ); + } + + /// `File system error` + String get errFileSystemException { + return Intl.message( + 'File system error', + name: 'errFileSystemException', + desc: '', + args: [], + ); + } + + /// `Assertion error` + String get errAssertionError { + return Intl.message( + 'Assertion error', + name: 'errAssertionError', + desc: '', + args: [], + ); + } + + /// `Bad state error` + String get errBadStateError { + return Intl.message( + 'Bad state error', + name: 'errBadStateError', + desc: '', + args: [], + ); + } + + /// `Bad request` + String get errBadRequest { + return Intl.message( + 'Bad request', + name: 'errBadRequest', + desc: '', + args: [], + ); + } + + /// `Unauthorized` + String get errUnauthorized { + return Intl.message( + 'Unauthorized', + name: 'errUnauthorized', + desc: '', + args: [], + ); + } + + /// `Forbidden` + String get errForbidden { + return Intl.message( + 'Forbidden', + name: 'errForbidden', + desc: '', + args: [], + ); + } + + /// `Not found` + String get errNotFound { + return Intl.message( + 'Not found', + name: 'errNotFound', + desc: '', + args: [], + ); + } + + /// `Not acceptable` + String get errNotAcceptable { + return Intl.message( + 'Not acceptable', + name: 'errNotAcceptable', + desc: '', + args: [], + ); + } + + /// `Request timeout` + String get errRequestTimeout { + return Intl.message( + 'Request timeout', + name: 'errRequestTimeout', + desc: '', + args: [], + ); + } + + /// `Too many requests` + String get errTooManyRequests { + return Intl.message( + 'Too many requests', + name: 'errTooManyRequests', + desc: '', + args: [], + ); + } + + /// `Internal server error` + String get errInternalServerError { + return Intl.message( + 'Internal server error', + name: 'errInternalServerError', + desc: '', + args: [], + ); + } + + /// `Bad gateway` + String get errBadGateway { + return Intl.message( + 'Bad gateway', + name: 'errBadGateway', + desc: '', + args: [], + ); + } + + /// `Service unavailable` + String get errServiceUnavailable { + return Intl.message( + 'Service unavailable', + name: 'errServiceUnavailable', + desc: '', + args: [], + ); + } + + /// `Gateway timeout` + String get errGatewayTimeout { + return Intl.message( + 'Gateway timeout', + name: 'errGatewayTimeout', + desc: '', + args: [], + ); + } + + /// `Unknown server error` + String get errUnknownServerError { + return Intl.message( + 'Unknown server error', + name: 'errUnknownServerError', + desc: '', + args: [], + ); + } + + /// `An unknown error was received from the server` + String get errAnUnknownErrorWasReceivedFromTheServer { + return Intl.message( + 'An unknown error was received from the server', + name: 'errAnUnknownErrorWasReceivedFromTheServer', + desc: '', + args: [], + ); + } + + /// `Log Out` + String get logOutButton { + return Intl.message( + 'Log Out', + name: 'logOutButton', + desc: '', + args: [], + ); + } + + /// `Log In` + String get logInButton { + return Intl.message( + 'Log In', + name: 'logInButton', + desc: '', + args: [], + ); + } + + /// `Exit` + String get exitButton { + return Intl.message( + 'Exit', + name: 'exitButton', + desc: '', + args: [], + ); + } + + /// `Sign Up` + String get signUpButton { + return Intl.message( + 'Sign Up', + name: 'signUpButton', + desc: '', + args: [], + ); + } + + /// `Sign In` + String get signInButton { + return Intl.message( + 'Sign In', + name: 'signInButton', + desc: '', + args: [], + ); + } + + /// `Back` + String get backButton { + return Intl.message( + 'Back', + name: 'backButton', + desc: '', + args: [], + ); + } + + /// `Cancel` + String get cancelButton { + return Intl.message( + 'Cancel', + name: 'cancelButton', + desc: '', + args: [], + ); + } + + /// `Confirm` + String get confirmButton { + return Intl.message( + 'Confirm', + name: 'confirmButton', + desc: '', + args: [], + ); + } + + /// `Continue` + String get continueButton { + return Intl.message( + 'Continue', + name: 'continueButton', + desc: '', + args: [], + ); + } + + /// `Save` + String get saveButton { + return Intl.message( + 'Save', + name: 'saveButton', + desc: '', + args: [], + ); + } + + /// `Create` + String get createButton { + return Intl.message( + 'Create', + name: 'createButton', + desc: '', + args: [], + ); + } + + /// `Delete` + String get deleteButton { + return Intl.message( + 'Delete', + name: 'deleteButton', + desc: '', + args: [], + ); + } + + /// `Edit` + String get editButton { + return Intl.message( + 'Edit', + name: 'editButton', + desc: '', + args: [], + ); + } + + /// `Add` + String get addButton { + return Intl.message( + 'Add', + name: 'addButton', + desc: '', + args: [], + ); + } + + /// `Copy` + String get copyButton { + return Intl.message( + 'Copy', + name: 'copyButton', + desc: '', + args: [], + ); + } + + /// `Move` + String get moveButton { + return Intl.message( + 'Move', + name: 'moveButton', + desc: '', + args: [], + ); + } + + /// `Rename` + String get renameButton { + return Intl.message( + 'Rename', + name: 'renameButton', + desc: '', + args: [], + ); + } + + /// `Upload` + String get uploadButton { + return Intl.message( + 'Upload', + name: 'uploadButton', + desc: '', + args: [], + ); + } + + /// `Download` + String get downloadButton { + return Intl.message( + 'Download', + name: 'downloadButton', + desc: '', + args: [], + ); + } + + /// `Share` + String get shareButton { + return Intl.message( + 'Share', + name: 'shareButton', + desc: '', + args: [], + ); + } + + /// `Share link` + String get shareLinkButton { + return Intl.message( + 'Share link', + name: 'shareLinkButton', + desc: '', + args: [], + ); + } + + /// `Remove from starred` + String get removeFromStarredButton { + return Intl.message( + 'Remove from starred', + name: 'removeFromStarredButton', + desc: '', + args: [], + ); + } + + /// `Add to starred` + String get addToStarredButton { + return Intl.message( + 'Add to starred', + name: 'addToStarredButton', + desc: '', + args: [], + ); + } + + /// `Move to trash` + String get moveToTrashButton { + return Intl.message( + 'Move to trash', + name: 'moveToTrashButton', + desc: '', + args: [], + ); + } + + /// `Restore` + String get restoreButton { + return Intl.message( + 'Restore', + name: 'restoreButton', + desc: '', + args: [], + ); + } + + /// `Email` + String get emailLabel { + return Intl.message( + 'Email', + name: 'emailLabel', + desc: '', + args: [], + ); + } + + /// `Name` + String get nameLabel { + return Intl.message( + 'Name', + name: 'nameLabel', + desc: '', + args: [], + ); + } + + /// `Surname` + String get surnameLabel { + return Intl.message( + 'Surname', + name: 'surnameLabel', + desc: '', + args: [], + ); + } + + /// `Language selection` + String get languageSelectionLabel { + return Intl.message( + 'Language selection', + name: 'languageSelectionLabel', + desc: '', + args: [], + ); + } + + /// `Upgrade` + String get upgradeLabel { + return Intl.message( + 'Upgrade', + name: 'upgradeLabel', + desc: '', + args: [], + ); + } + + /// `App` + String get appLabel { + return Intl.message( + 'App', + name: 'appLabel', + desc: '', + args: [], + ); + } + + /// `Application` + String get applicationLabel { + return Intl.message( + 'Application', + name: 'applicationLabel', + desc: '', + args: [], + ); + } + + /// `Authenticate` + String get authenticateLabel { + return Intl.message( + 'Authenticate', + name: 'authenticateLabel', + desc: '', + args: [], + ); + } + + /// `Authenticated` + String get authenticatedLabel { + return Intl.message( + 'Authenticated', + name: 'authenticatedLabel', + desc: '', + args: [], + ); + } + + /// `Authentication` + String get authenticationLabel { + return Intl.message( + 'Authentication', + name: 'authenticationLabel', + desc: '', + args: [], + ); + } + + /// `Navigation` + String get navigationLabel { + return Intl.message( + 'Navigation', + name: 'navigationLabel', + desc: '', + args: [], + ); + } + + /// `Database` + String get databaseLabel { + return Intl.message( + 'Database', + name: 'databaseLabel', + desc: '', + args: [], + ); + } + + /// `Copied` + String get copiedLabel { + return Intl.message( + 'Copied', + name: 'copiedLabel', + desc: '', + args: [], + ); + } + + /// `Useful links` + String get usefulLinksLabel { + return Intl.message( + 'Useful links', + name: 'usefulLinksLabel', + desc: '', + args: [], + ); + } + + /// `Current version` + String get currentVersionLabel { + return Intl.message( + 'Current version', + name: 'currentVersionLabel', + desc: '', + args: [], + ); + } + + /// `Latest version` + String get latestVersionLabel { + return Intl.message( + 'Latest version', + name: 'latestVersionLabel', + desc: '', + args: [], + ); + } + + /// `Version` + String get versionLabel { + return Intl.message( + 'Version', + name: 'versionLabel', + desc: '', + args: [], + ); + } + + /// `Size` + String get sizeLabel { + return Intl.message( + 'Size', + name: 'sizeLabel', + desc: '', + args: [], + ); + } + + /// `Type` + String get typeLabel { + return Intl.message( + 'Type', + name: 'typeLabel', + desc: '', + args: [], + ); + } + + /// `Date` + String get dateLabel { + return Intl.message( + 'Date', + name: 'dateLabel', + desc: '', + args: [], + ); + } + + /// `Time` + String get timeLabel { + return Intl.message( + 'Time', + name: 'timeLabel', + desc: '', + args: [], + ); + } + + /// `Status` + String get statusLabel { + return Intl.message( + 'Status', + name: 'statusLabel', + desc: '', + args: [], + ); + } + + /// `Current` + String get currentLabel { + return Intl.message( + 'Current', + name: 'currentLabel', + desc: '', + args: [], + ); + } + + /// `Current user` + String get currentUserLabel { + return Intl.message( + 'Current user', + name: 'currentUserLabel', + desc: '', + args: [], + ); + } + + /// `Application version` + String get applicationVersionLabel { + return Intl.message( + 'Application version', + name: 'applicationVersionLabel', + desc: '', + args: [], + ); + } + + /// `Application information` + String get applicationInformationLabel { + return Intl.message( + 'Application information', + name: 'applicationInformationLabel', + desc: '', + args: [], + ); + } + + /// `Connected devices` + String get conectedDevicesLabel { + return Intl.message( + 'Connected devices', + name: 'conectedDevicesLabel', + desc: '', + args: [], + ); + } + + /// `Renewal date` + String get renewalDateLabel { + return Intl.message( + 'Renewal date', + name: 'renewalDateLabel', + desc: '', + args: [], + ); + } + + /// `API domain` + String get apiDomain { + return Intl.message( + 'API domain', + name: 'apiDomain', + desc: '', + args: [], + ); + } + + /// `Storage` + String get storageLabel { + return Intl.message( + 'Storage', + name: 'storageLabel', + desc: '', + args: [], + ); + } + + /// `Help` + String get helpLabel { + return Intl.message( + 'Help', + name: 'helpLabel', + desc: '', + args: [], + ); + } + + /// `Selected` + String get selectedLabel { + return Intl.message( + 'Selected', + name: 'selectedLabel', + desc: '', + args: [], + ); + } + + /// `You will be logged out from your account` + String get logOutDescription { + return Intl.message( + 'You will be logged out from your account', + name: 'logOutDescription', + desc: '', + args: [], + ); + } +} + +class AppLocalizationDelegate + extends LocalizationsDelegate { + const AppLocalizationDelegate(); + + List get supportedLocales { + return const [ + Locale.fromSubtags(languageCode: 'en'), + ]; + } + + @override + bool isSupported(Locale locale) => _isSupported(locale); + @override + Future load(Locale locale) => + GeneratedLocalization.load(locale); + @override + bool shouldReload(AppLocalizationDelegate old) => false; + + bool _isSupported(Locale locale) { + for (var supportedLocale in supportedLocales) { + if (supportedLocale.languageCode == locale.languageCode) { + return true; + } + } + return false; + } +} diff --git a/example/lib/src/common/localization/intl_en.arb b/example/lib/src/common/localization/intl_en.arb new file mode 100644 index 0000000..9ab5962 --- /dev/null +++ b/example/lib/src/common/localization/intl_en.arb @@ -0,0 +1,98 @@ +{ + "@@locale": "en", + "localeName": "en_US", + "languageCode": "en", + "language": "English", + "@ Title": {}, + "title": "Spinify", + "@ ############# Errors #############": {}, + "errSomethingWentWrong": "Something went wrong", + "errError": "Error", + "errException": "Exception", + "errAnErrorHasOccurred": "An error has occurred", + "errAnExceptionHasOccurred": "An exception has occurred", + "errTryAgainLater": "Please try again later.", + "errInvalidFormat": "Invalid format", + "errTimeOutExceeded": "Time out exceeded", + "errInvalidCredentials": "Invalid credentials", + "errUnimplemented": "Unimplemented", + "errNotImplementedYet": "Not implemented yet", + "errUnsupportedOperation": "Unsupported operation", + "errFileSystemException": "File system error", + "errAssertionError": "Assertion error", + "errBadStateError": "Bad state error", + "errBadRequest": "Bad request", + "errUnauthorized": "Unauthorized", + "errForbidden": "Forbidden", + "errNotFound": "Not found", + "errNotAcceptable": "Not acceptable", + "errRequestTimeout": "Request timeout", + "errTooManyRequests": "Too many requests", + "errInternalServerError": "Internal server error", + "errBadGateway": "Bad gateway", + "errServiceUnavailable": "Service unavailable", + "errGatewayTimeout": "Gateway timeout", + "errUnknownServerError": "Unknown server error", + "errAnUnknownErrorWasReceivedFromTheServer": "An unknown error was received from the server", + "@ ############# Buttons #############": {}, + "logOutButton": "Log Out", + "logInButton": "Log In", + "exitButton": "Exit", + "signUpButton": "Sign Up", + "signInButton": "Sign In", + "backButton": "Back", + "cancelButton": "Cancel", + "confirmButton": "Confirm", + "continueButton": "Continue", + "saveButton": "Save", + "createButton": "Create", + "deleteButton": "Delete", + "editButton": "Edit", + "addButton": "Add", + "copyButton": "Copy", + "moveButton": "Move", + "renameButton": "Rename", + "uploadButton": "Upload", + "downloadButton": "Download", + "shareButton": "Share", + "shareLinkButton": "Share link", + "removeFromStarredButton": "Remove from starred", + "addToStarredButton": "Add to starred", + "moveToTrashButton": "Move to trash", + "restoreButton": "Restore", + "@ ############# Labels #############": {}, + "emailLabel": "Email", + "nameLabel": "Name", + "surnameLabel": "Surname", + "languageSelectionLabel": "Language selection", + "upgradeLabel": "Upgrade", + "appLabel": "App", + "applicationLabel": "Application", + "authenticateLabel": "Authenticate", + "authenticatedLabel": "Authenticated", + "authenticationLabel": "Authentication", + "navigationLabel": "Navigation", + "databaseLabel": "Database", + "copiedLabel": "Copied", + "usefulLinksLabel": "Useful links", + "currentVersionLabel": "Current version", + "latestVersionLabel": "Latest version", + "versionLabel": "Version", + "sizeLabel": "Size", + "typeLabel": "Type", + "dateLabel": "Date", + "timeLabel": "Time", + "statusLabel": "Status", + "currentLabel": "Current", + "currentUserLabel": "Current user", + "applicationVersionLabel": "Application version", + "applicationInformationLabel": "Application information", + "conectedDevicesLabel": "Connected devices", + "renewalDateLabel": "Renewal date", + "apiDomain": "API domain", + "storageLabel": "Storage", + "helpLabel": "Help", + "selectedLabel": "Selected", + "@ ############# Descriptions #############": {}, + "logOutDescription": "You will be logged out from your account" +} \ No newline at end of file diff --git a/example/lib/src/common/localization/localization.dart b/example/lib/src/common/localization/localization.dart new file mode 100644 index 0000000..1eada77 --- /dev/null +++ b/example/lib/src/common/localization/localization.dart @@ -0,0 +1,237 @@ +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; +import 'package:spinifyapp/src/common/localization/generated/l10n.dart' + as generated show GeneratedLocalization, AppLocalizationDelegate; + +/// Localization. +final class Localization extends generated.GeneratedLocalization { + Localization._(this.locale); + + final Locale locale; + + /// Localization delegate. + static const LocalizationsDelegate delegate = + _LocalizationView(generated.AppLocalizationDelegate()); + + /// Current localization instance. + static Localization get current => _current; + static late Localization _current; + + /// Get localization instance for the widget structure. + static Localization of(BuildContext context) => + switch (Localizations.of(context, Localization)) { + Localization localization => localization, + _ => throw ArgumentError( + 'Out of scope, not found inherited widget ' + 'a Localization of the exact type', + 'out_of_scope', + ), + }; + + /// Get language by code. + static ({String name, String nativeName})? getLanguageByCode(String code) => + switch (_isoLangs[code]) { + (:String name, :String nativeName) => ( + name: name, + nativeName: nativeName + ), + _ => null, + }; + + /// Get supported locales. + static List get supportedLocales => + const generated.AppLocalizationDelegate().supportedLocales; +} + +@immutable +final class _LocalizationView extends LocalizationsDelegate { + @literal + const _LocalizationView( + LocalizationsDelegate delegate, + ) : _delegate = delegate; + + final LocalizationsDelegate _delegate; + + @override + bool isSupported(Locale locale) => _delegate.isSupported(locale); + + @override + Future load(Locale locale) => + generated.GeneratedLocalization.load(locale).then( + (localization) => Localization._current = Localization._(locale)); + + @override + bool shouldReload(covariant _LocalizationView old) => + _delegate.shouldReload(old._delegate); +} + +const Map _isoLangs = + { + "ab": ('Abkhaz', 'аҧсуа'), + "aa": ('Afar', 'Afaraf'), + "af": ('Afrikaans', 'Afrikaans'), + "ak": ('Akan', 'Akan'), + "sq": ('Albanian', 'Shqip'), + "am": ('Amharic', 'አማርኛ'), + "ar": ('Arabic', 'العربية'), + "an": ('Aragonese', 'Aragonés'), + "hy": ('Armenian', 'Հայերեն'), + "as": ('Assamese', 'অসমীয়া'), + "av": ('Avaric', 'авар мацӀ, магӀарул мацӀ'), + "ae": ('Avestan', 'avesta'), + "ay": ('Aymara', 'aymar aru'), + "az": ('Azerbaijani', 'azərbaycan dili'), + "bm": ('Bambara', 'bamanankan'), + "ba": ('Bashkir', 'башҡорт теле'), + "eu": ('Basque', 'euskara, euskera'), + "be": ('Belarusian', 'Беларуская'), + "bn": ('Bengali', 'বাংলা'), + "bh": ('Bihari', 'भोजपुरी'), + "bi": ('Bislama', 'Bislama'), + "bs": ('Bosnian', 'bosanski jezik'), + "br": ('Breton', 'brezhoneg'), + "bg": ('Bulgarian', 'български език'), + "my": ('Burmese', 'ဗမာစာ'), + "ca": ('Catalan, Valencian', 'Català'), + "ch": ('Chamorro', 'Chamoru'), + "ce": ('Chechen', 'нохчийн мотт'), + "ny": ('Chichewa, Chewa, Nyanja', 'chiCheŵa, chinyanja'), + "zh": ('Chinese', '中文 (Zhōngwén), 汉语, 漢語'), + "cv": ('Chuvash', 'чӑваш чӗлхи'), + "kw": ('Cornish', 'Kernewek'), + "co": ('Corsican', 'corsu, lingua corsa'), + "cr": ('Cree', 'ᓀᐦᐃᔭᐍᐏᐣ'), + "hr": ('Croatian', 'hrvatski'), + "cs": ('Czech', 'česky, čeština'), + "da": ('Danish', 'dansk'), + "dv": ('Divehi, Dhivehi, Maldivian;', 'ދިވެހި'), + "nl": ('Dutch', 'Nederlands, Vlaams'), + "en": ('English', 'English'), + "eo": ('Esperanto', 'Esperanto'), + "et": ('Estonian', 'eesti, eesti keel'), + "fo": ('Faroese', 'føroyskt'), + "fj": ('Fijian', 'vosa Vakaviti'), + "fi": ('Finnish', 'suomi, suomen kieli'), + "fr": ('French', 'Français'), + "ff": ('Fula, Fulah, Pulaar, Pular', 'Fulfulde, Pulaar, Pular'), + "gl": ('Galician', 'Galego'), + "ka": ('Georgian', 'ქართული'), + "de": ('German', 'Deutsch'), + "el": ('Greek, Modern', 'Ελληνικά'), + "gn": ('Guaraní', 'Avañeẽ'), + "gu": ('Gujarati', 'ગુજરાતી'), + "ht": ('Haitian, Haitian Creole', 'Kreyòl ayisyen'), + "ha": ('Hausa', 'Hausa, هَوُسَ'), + "he": ('Hebrew (modern)', 'עברית'), + "hz": ('Herero', 'Otjiherero'), + "hi": ('Hindi', 'हिन्दी, हिंदी'), + "ho": ('Hiri Motu', 'Hiri Motu'), + "hu": ('Hungarian', 'Magyar'), + "ia": ('Interlingua', 'Interlingua'), + "id": ('Indonesian', 'Bahasa Indonesia'), + "ie": ('Interlingue', 'Interlingue'), + "ga": ('Irish', 'Gaeilge'), + "ig": ('Igbo', 'Asụsụ Igbo'), + "ik": ('Inupiaq', 'Iñupiaq, Iñupiatun'), + "io": ('Ido', 'Ido'), + "is": ('Icelandic', 'Íslenska'), + "it": ('Italian', 'Italiano'), + "iu": ('Inuktitut', 'ᐃᓄᒃᑎᑐᑦ'), + "ja": ('Japanese', '日本語 (にほんご/にっぽんご)'), + "jv": ('Javanese', 'basa Jawa'), + "kl": ('Kalaallisut, Greenlandic', 'kalaallisut, kalaallit oqaasii'), + "kn": ('Kannada', 'ಕನ್ನಡ'), + "kr": ('Kanuri', 'Kanuri'), + "kk": ('Kazakh', 'Қазақ тілі'), + "km": ('Khmer', 'ភាសាខ្មែរ'), + "ki": ('Kikuyu, Gikuyu', 'Gĩkũyũ'), + "rw": ('Kinyarwanda', 'Ikinyarwanda'), + "ky": ('Kirghiz, Kyrgyz', 'кыргыз тили'), + "kv": ('Komi', 'коми кыв'), + "kg": ('Kongo', 'KiKongo'), + "ko": ('Korean', '한국어 (韓國語), 조선말 (朝鮮語)'), + "kj": ('Kwanyama, Kuanyama', 'Kuanyama'), + "la": ('Latin', 'latine, lingua latina'), + "lb": ('Luxembourgish', 'Lëtzebuergesch'), + "lg": ('Luganda', 'Luganda'), + "li": ('Limburgish, Limburgan, Limburger', 'Limburgs'), + "ln": ('Lingala', 'Lingála'), + "lo": ('Lao', 'ພາສາລາວ'), + "lt": ('Lithuanian', 'lietuvių kalba'), + "lu": ('Luba-Katanga', ''), + "lv": ('Latvian', 'latviešu valoda'), + "gv": ('Manx', 'Gaelg, Gailck'), + "mk": ('Macedonian', 'македонски јазик'), + "mg": ('Malagasy', 'Malagasy fiteny'), + "ml": ('Malayalam', 'മലയാളം'), + "mt": ('Maltese', 'Malti'), + "mi": ('Māori', 'te reo Māori'), + "mr": ('Marathi (Marāṭhī)', 'मराठी'), + "mh": ('Marshallese', 'Kajin M̧ajeļ'), + "mn": ('Mongolian', 'монгол'), + "na": ('Nauru', 'Ekakairũ Naoero'), + "nb": ('Norwegian Bokmål', 'Norsk bokmål'), + "nd": ('North Ndebele', 'isiNdebele'), + "ne": ('Nepali', 'नेपाली'), + "ng": ('Ndonga', 'Owambo'), + "nn": ('Norwegian Nynorsk', 'Norsk nynorsk'), + "no": ('Norwegian', 'Norsk'), + "ii": ('Nuosu', 'ꆈꌠ꒿ Nuosuhxop'), + "nr": ('South Ndebele', 'isiNdebele'), + "oc": ('Occitan', 'Occitan'), + "oj": ('Ojibwe, Ojibwa', 'ᐊᓂᔑᓈᐯᒧᐎᓐ'), + "om": ('Oromo', 'Afaan Oromoo'), + "or": ('Oriya', 'ଓଡ଼ିଆ'), + "pi": ('Pāli', 'पाऴि'), + "fa": ('Persian', 'فارسی'), + "pl": ('Polish', 'Polski'), + "ps": ('Pashto, Pushto', 'پښتو'), + "pt": ('Portuguese', 'Português'), + "qu": ('Quechua', 'Runa Simi, Kichwa'), + "rm": ('Romansh', 'rumantsch grischun'), + "rn": ('Kirundi', 'kiRundi'), + "ro": ('Romanian, Moldavian, Moldovan', 'română'), + "ru": ('Russian', 'Русский'), + "sa": ('Sanskrit (Saṁskṛta)', 'संस्कृतम्'), + "sc": ('Sardinian', 'sardu'), + "se": ('Northern Sami', 'Davvisámegiella'), + "sm": ('Samoan', 'gagana faa Samoa'), + "sg": ('Sango', 'yângâ tî sängö'), + "sr": ('Serbian', 'српски језик'), + "gd": ('Scottish Gaelic, Gaelic', 'Gàidhlig'), + "sn": ('Shona', 'chiShona'), + "si": ('Sinhala, Sinhalese', 'සිංහල'), + "sk": ('Slovak', 'slovenčina'), + "sl": ('Slovene', 'slovenščina'), + "so": ('Somali', 'Soomaaliga, af Soomaali'), + "st": ('Southern Sotho', 'Sesotho'), + "es": ('Spanish', 'Español'), + "su": ('Sundanese', 'Basa Sunda'), + "sw": ('Swahili', 'Kiswahili'), + "ss": ('Swati', 'SiSwati'), + "sv": ('Swedish', 'svenska'), + "ta": ('Tamil', 'தமிழ்'), + "te": ('Telugu', 'తెలుగు'), + "th": ('Thai', 'ไทย'), + "ti": ('Tigrinya', 'ትግርኛ'), + "bo": ('Tibetan', 'བོད་ཡིག'), + "tk": ('Turkmen', 'Türkmen, Түркмен'), + "tn": ('Tswana', 'Setswana'), + "to": ('Tonga (Tonga Islands)', 'faka Tonga'), + "tr": ('Turkish', 'Türkçe'), + "ts": ('Tsonga', 'Xitsonga'), + "tw": ('Twi', 'Twi'), + "ty": ('Tahitian', 'Reo Tahiti'), + "uk": ('Ukrainian', 'українська'), + "ur": ('Urdu', 'اردو'), + "ve": ('Venda', 'Tshivenḓa'), + "vi": ('Vietnamese', 'Tiếng Việt'), + "vo": ('Volapük', 'Volapük'), + "wa": ('Walloon', 'Walon'), + "cy": ('Welsh', 'Cymraeg'), + "wo": ('Wolof', 'Wollof'), + "fy": ('Western Frisian', 'Frysk'), + "xh": ('Xhosa', 'isiXhosa'), + "yi": ('Yiddish', 'ייִדיש'), + "yo": ('Yoruba', 'Yorùbá'), +}; diff --git a/example/lib/src/common/util/error_util.dart b/example/lib/src/common/util/error_util.dart new file mode 100644 index 0000000..0436878 --- /dev/null +++ b/example/lib/src/common/util/error_util.dart @@ -0,0 +1,106 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart' + show BuildContext, Colors, ScaffoldMessenger, SnackBar, Text; +import 'package:l/l.dart'; +import 'package:spinifyapp/src/common/localization/localization.dart'; +import 'package:spinifyapp/src/common/util/platform/error_util_vm.dart' + // ignore: uri_does_not_exist + if (dart.library.html) 'package:spinifyapp/src/common/util/platform/error_util_js.dart'; + +/// Error util. +sealed class ErrorUtil { + /// Log the error to the console and to Crashlytics. + static Future logError( + Object exception, + StackTrace stackTrace, { + String? hint, + bool fatal = false, + }) async { + try { + if (exception is String) { + return await logMessage( + exception, + stackTrace: stackTrace, + hint: hint, + warning: true, + ); + } + $captureException(exception, stackTrace, hint, fatal).ignore(); + l.e(exception, stackTrace); + } on Object catch (error, stackTrace) { + l.e( + 'Error while logging error "$error" inside ErrorUtil.logError', + stackTrace, + ); + } + } + + /// Logs a message to the console and to Crashlytics. + static Future logMessage( + String message, { + StackTrace? stackTrace, + String? hint, + bool warning = false, + }) async { + try { + l.e(message, stackTrace ?? StackTrace.current); + $captureMessage(message, stackTrace, hint, warning).ignore(); + } on Object catch (error, stackTrace) { + l.e( + 'Error while logging error "$error" inside ErrorUtil.logMessage', + stackTrace, + ); + } + } + + /// Rethrows the error with the stack trace. + static Never throwWithStackTrace(Object error, StackTrace stackTrace) => + Error.throwWithStackTrace(error, stackTrace); + + @pragma('vm:prefer-inline') + static String _localizedError( + String fallback, String Function(Localization l) localize) { + try { + return localize(Localization.current); + } on Object { + return fallback; + } + } + + // Also we can add current localization to this method + static String formatMessage( + Object error, [ + String fallback = 'An error has occurred', + ]) => + switch (error) { + String e => e, + FormatException _ => + _localizedError('Invalid format', (lcl) => lcl.errInvalidFormat), + TimeoutException _ => + _localizedError('Timeout exceeded', (lcl) => lcl.errTimeOutExceeded), + UnimplementedError _ => _localizedError( + 'Not implemented yet', (lcl) => lcl.errNotImplementedYet), + UnsupportedError _ => _localizedError( + 'Unsupported operation', (lcl) => lcl.errUnsupportedOperation), + FileSystemException _ => _localizedError( + 'File system error', (lcl) => lcl.errFileSystemException), + AssertionError _ => + _localizedError('Assertion error', (lcl) => lcl.errAssertionError), + Error _ => _localizedError( + 'An error has occurred', (lcl) => lcl.errAnErrorHasOccurred), + Exception _ => _localizedError('An exception has occurred', + (lcl) => lcl.errAnExceptionHasOccurred), + _ => fallback, + }; + + /// Shows a error snackbar with the given message. + static void showSnackBar(BuildContext context, Object message) => + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(formatMessage(message)), + backgroundColor: Colors.red, + ), + ); +} diff --git a/example/lib/src/common/util/log_buffer.dart b/example/lib/src/common/util/log_buffer.dart new file mode 100644 index 0000000..94c0730 --- /dev/null +++ b/example/lib/src/common/util/log_buffer.dart @@ -0,0 +1,47 @@ +import 'dart:collection' show Queue; + +import 'package:flutter/foundation.dart' show ChangeNotifier; +import 'package:l/l.dart'; + +/// LogBuffer Singleton class +class LogBuffer with ChangeNotifier { + static final LogBuffer _internalSingleton = LogBuffer._internal(); + static LogBuffer get instance => _internalSingleton; + LogBuffer._internal(); + + static const int bufferLimit = 10000; + final Queue _queue = Queue(); + + /// Get the logs + Iterable get logs => _queue; + + /// Clear the logs + void clear() { + _queue.clear(); + notifyListeners(); + } + + /// Add a log to the buffer + void add(LogMessage log) { + if (_queue.length >= bufferLimit) _queue.removeFirst(); + _queue.add(log); + notifyListeners(); + } + + /// Add a list of logs to the buffer + void addAll(List logs) { + logs = logs.take(bufferLimit).toList(); + if (_queue.length + logs.length >= bufferLimit) { + final toRemove = _queue.length + logs.length - bufferLimit; + for (var i = 0; i < toRemove; i++) _queue.removeFirst(); + } + _queue.addAll(logs); + notifyListeners(); + } + + @override + void dispose() { + _queue.clear(); + super.dispose(); + } +} diff --git a/example/lib/src/common/util/logger_util.dart b/example/lib/src/common/util/logger_util.dart new file mode 100644 index 0000000..aa7dbdd --- /dev/null +++ b/example/lib/src/common/util/logger_util.dart @@ -0,0 +1,12 @@ +import 'package:l/l.dart'; + +sealed class LoggerUtil { + /// Formats the log message. + static Object messageFormatting( + Object message, LogLevel logLevel, DateTime now) => + '${timeFormat(now)} | $message'; + + /// Formats the time. + static String timeFormat(DateTime time) => + '${time.hour}:${time.minute.toString().padLeft(2, '0')}'; +} diff --git a/example/lib/src/common/util/platform/error_util_js.dart b/example/lib/src/common/util/platform/error_util_js.dart new file mode 100644 index 0000000..6e966ea --- /dev/null +++ b/example/lib/src/common/util/platform/error_util_js.dart @@ -0,0 +1,17 @@ +// ignore_for_file: avoid_positional_boolean_parameters + +Future $captureException( + Object exception, + StackTrace stackTrace, + String? hint, + bool fatal, +) => + Future.value(null); + +Future $captureMessage( + String message, + StackTrace? stackTrace, + String? hint, + bool warning, +) => + Future.value(null); diff --git a/example/lib/src/common/util/platform/error_util_vm.dart b/example/lib/src/common/util/platform/error_util_vm.dart new file mode 100644 index 0000000..a3a83bf --- /dev/null +++ b/example/lib/src/common/util/platform/error_util_vm.dart @@ -0,0 +1,47 @@ +// ignore_for_file: avoid_positional_boolean_parameters +//import 'package:firebase_crashlytics/firebase_crashlytics.dart'; + +/* + * Sentry.captureException(exception, stackTrace: stackTrace, hint: hint); + * FirebaseCrashlytics.instance + * .recordError(exception, stackTrace ?? StackTrace.current, reason: hint, fatal: fatal); + * */ +Future $captureException( + Object exception, + StackTrace stackTrace, + String? hint, + bool fatal, +) => + Future.value(); +// FirebaseCrashlytics.instance.recordError(exception, stackTrace, reason: hint, fatal: fatal); + +/* + * Sentry.captureMessage( + * message, + * level: warning ? SentryLevel.warning : SentryLevel.info, + * hint: hint, + * params: [ + * ...?params, + * if (stackTrace != null) 'StackTrace: $stackTrace', + * ], + * ); + * (warning || stackTrace != null) + * ? FirebaseCrashlytics.instance.recordError(message, stackTrace ?? StackTrace.current); + * : FirebaseCrashlytics.instance.log('$message${hint != null ? '\r\n$hint' : ''}'); + * */ +Future $captureMessage( + String message, + StackTrace? stackTrace, + String? hint, + bool warning, +) => + Future.value(); +/* warning || stackTrace != null + ? FirebaseCrashlytics.instance.recordError( + message, + stackTrace ?? StackTrace.current, + reason: hint, + fatal: false, + ) + : FirebaseCrashlytics.instance.log('$message' + '${stackTrace != null ? '\nHint: $hint' : ''}'); */ diff --git a/example/lib/src/common/util/screen_util.dart b/example/lib/src/common/util/screen_util.dart new file mode 100644 index 0000000..0bc1659 --- /dev/null +++ b/example/lib/src/common/util/screen_util.dart @@ -0,0 +1,256 @@ +import 'dart:ui' as ui; + +import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; + +/// {@macro screen_util} +extension ScreenUtilExtension on BuildContext { + /// Get current screen logical size representation + /// + /// phone | <= 600 dp | 4 column + /// tablet | 600..1023 dp | 8 column + /// desktop | >= 1024 dp | 12 column + ScreenSize get screenSize => ScreenUtil.screenSizeOf(this); + + /// Portrait or Landscape + Orientation get orientation => ScreenUtil.orientationOf(this); + + /// Evaluate the result of the first matching callback. + /// + /// phone | <= 600 dp | 4 column + /// tablet | 600..1023 dp | 8 column + /// desktop | >= 1024 dp | 12 column + ScreenSizeWhenResult screenSizeWhen({ + required final ScreenSizeWhenResult Function() phone, + required final ScreenSizeWhenResult Function() tablet, + required final ScreenSizeWhenResult Function() desktop, + }) => + ScreenUtil.screenSizeOf(this).when( + phone: phone, + tablet: tablet, + desktop: desktop, + ); + + /// The [screenSizeMaybeWhen] method is equivalent to [screenSizeWhen], + /// but doesn't require all callbacks to be specified. + /// + /// On the other hand, it adds an extra [orElse] required parameter, + /// for fallback behavior. + ScreenSizeWhenResult + screenSizeMaybeWhen({ + required final ScreenSizeWhenResult Function() orElse, + final ScreenSizeWhenResult Function()? phone, + final ScreenSizeWhenResult Function()? tablet, + final ScreenSizeWhenResult Function()? desktop, + }) => + ScreenUtil.screenSizeOf(this).maybeWhen( + phone: phone, + tablet: tablet, + desktop: desktop, + orElse: orElse, + ); +} + +/// {@template screen_util} +/// Screen logical size representation +/// +/// phone | <= 600 dp | 4 column +/// tablet | 600..1023 dp | 8 column +/// desktop | >= 1024 dp | 12 column +/// {@endtemplate} +sealed class ScreenUtil { + /// {@macro screen_util} + static ScreenSize screenSize() { + final view = ui.PlatformDispatcher.instance.implicitView; + if (view == null) return ScreenSize.phone; + final size = view.physicalSize ~/ view.devicePixelRatio; + return _screenSizeFromSize(size); + } + + static ScreenSize from(Size size) => _screenSizeFromSize(size); + + /// {@macro screen_util} + static ScreenSize screenSizeOf(final BuildContext context) { + final size = MediaQuery.of(context).size; + return _screenSizeFromSize(size); + } + + static ScreenSize _screenSizeFromSize(final Size size) => + switch (size.width) { + >= 1024 => ScreenSize.desktop, + <= 600 => ScreenSize.phone, + _ => ScreenSize.tablet, + }; + + /// Portrait or Landscape + static Orientation orientation() { + final view = ui.PlatformDispatcher.instance.implicitView; + final size = view?.physicalSize; + return size == null || size.height > size.width + ? Orientation.portrait + : Orientation.landscape; + } + + /// Portrait or Landscape + static Orientation orientationOf(BuildContext context) => + MediaQuery.of(context).orientation; +} + +/// {@macro screen_util} +@immutable +sealed class ScreenSize { + /// {@macro screen_util} + @literal + const ScreenSize._(this.representation, this.min, this.max); + + /// Phone + static const ScreenSize phone = ScreenSize$Phone(); + + /// Tablet + static const ScreenSize tablet = ScreenSize$Tablet(); + + /// Large desktop + static const ScreenSize desktop = ScreenSize$Desktop(); + + /// Minimum width in logical pixels + final double min; + + /// Maximum width in logical pixels + final double max; + + /// String representation + final String representation; + + /// Is phone + abstract final bool isPhone; + + /// Is tablet + abstract final bool isTablet; + + /// Is desktop + abstract final bool isDesktop; + + /// Evaluate the result of the first matching callback. + /// + /// phone | <= 600 dp | 4 column + /// tablet | 600..1023 dp | 8 column + /// desktop | >= 1024 dp | 12 column + ScreenSizeWhenResult when({ + required final ScreenSizeWhenResult Function() phone, + required final ScreenSizeWhenResult Function() tablet, + required final ScreenSizeWhenResult Function() desktop, + }); + + /// The [maybeWhen] method is equivalent to [when], + /// but doesn't require all callbacks to be specified. + /// + /// On the other hand, it adds an extra [orElse] required parameter, + /// for fallback behavior. + ScreenSizeWhenResult maybeWhen({ + required final ScreenSizeWhenResult Function() orElse, + final ScreenSizeWhenResult Function()? phone, + final ScreenSizeWhenResult Function()? tablet, + final ScreenSizeWhenResult Function()? desktop, + }) => + when( + phone: phone ?? orElse, + tablet: tablet ?? orElse, + desktop: desktop ?? orElse, + ); + + @override + String toString() => representation; +} + +/// {@macro screen_util} +final class ScreenSize$Phone extends ScreenSize { + /// {@macro screen_util} + @literal + const ScreenSize$Phone() : super._('Phone', 0, 599); + + @override + ScreenSizeWhenResult when({ + required final ScreenSizeWhenResult Function() phone, + required final ScreenSizeWhenResult Function() tablet, + required final ScreenSizeWhenResult Function() desktop, + }) => + phone(); + + @override + final bool isPhone = true; + + @override + final bool isTablet = false; + + @override + final bool isDesktop = false; + + @override + int get hashCode => 0; + + @override + bool operator ==(final Object other) => + identical(other, this) || other is ScreenSize$Phone; +} + +/// {@macro screen_util} +final class ScreenSize$Tablet extends ScreenSize { + /// {@macro screen_util} + @literal + const ScreenSize$Tablet() : super._('Tablet', 600, 1023); + + @override + ScreenSizeWhenResult when({ + required final ScreenSizeWhenResult Function() phone, + required final ScreenSizeWhenResult Function() tablet, + required final ScreenSizeWhenResult Function() desktop, + }) => + tablet(); + + @override + final bool isPhone = false; + + @override + final bool isTablet = true; + + @override + final bool isDesktop = false; + + @override + int get hashCode => 1; + + @override + bool operator ==(final Object other) => + identical(other, this) || other is ScreenSize$Tablet; +} + +/// {@macro screen_util} +final class ScreenSize$Desktop extends ScreenSize { + /// {@macro screen_util} + @literal + const ScreenSize$Desktop() : super._('Desktop', 1024, double.infinity); + + @override + ScreenSizeWhenResult when({ + required final ScreenSizeWhenResult Function() phone, + required final ScreenSizeWhenResult Function() tablet, + required final ScreenSizeWhenResult Function() desktop, + }) => + desktop(); + + @override + final bool isPhone = false; + + @override + final bool isTablet = false; + + @override + final bool isDesktop = true; + + @override + int get hashCode => 2; + + @override + bool operator ==(final Object other) => + identical(other, this) || other is ScreenSize$Desktop; +} diff --git a/example/lib/src/common/util/timeouts.dart b/example/lib/src/common/util/timeouts.dart new file mode 100644 index 0000000..94de7f7 --- /dev/null +++ b/example/lib/src/common/util/timeouts.dart @@ -0,0 +1,19 @@ +import 'dart:async'; + +/// Extension methods for [Future]. +extension TimeoutsExtension on Future { + /// Returns a [Future] that completes with this future's result, or with the + /// result of calling the [onTimeout] function, if this future doesn't + /// complete before the timeout is exceeded. + /// + /// The [onTimeout] function must return a [Future] which will be used as the + /// result of the returned [Future], and must not throw. + Future logicTimeout({ + double coefficient = 1, + FutureOr Function()? onTimeout, + }) => + timeout( + const Duration(milliseconds: 20000) * coefficient, + onTimeout: onTimeout, + ); +} diff --git a/example/lib/src/common/widget/app.dart b/example/lib/src/common/widget/app.dart new file mode 100644 index 0000000..42dceb2 --- /dev/null +++ b/example/lib/src/common/widget/app.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:spinifyapp/src/common/localization/localization.dart'; +import 'package:spinifyapp/src/common/widget/window_scope.dart'; +import 'package:spinifyapp/src/feature/authentication/widget/authentication_scope.dart'; +import 'package:spinifyapp/src/feature/authentication/widget/sign_in_screen.dart'; + +/// {@template app} +/// App widget. +/// {@endtemplate} +class App extends StatelessWidget { + /// {@macro app} + const App({super.key}); + + @override + Widget build(BuildContext context) => MaterialApp( + title: 'Spinify', + debugShowCheckedModeBanner: false, + localizationsDelegates: const >[ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + Localization.delegate, + ], + theme: View.of(context).platformDispatcher.platformBrightness == + Brightness.dark + ? ThemeData.dark(useMaterial3: true) + : ThemeData.light( + useMaterial3: true), // TODO(plugfox): implement theme + home: const Placeholder(), + supportedLocales: Localization.supportedLocales, + locale: const Locale('en', 'US'), // TODO(plugfox): implement locale + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaleFactor: 1), + child: WindowScope( + title: Localization.of(context).title, + child: AuthenticationScope( + signInScreen: const SignInScreen(), + child: child ?? const SizedBox.shrink(), + ), + ), + ), + ); +} diff --git a/example/lib/src/common/widget/radial_progress_indicator.dart b/example/lib/src/common/widget/radial_progress_indicator.dart new file mode 100644 index 0000000..44d4fb1 --- /dev/null +++ b/example/lib/src/common/widget/radial_progress_indicator.dart @@ -0,0 +1,106 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +/// {@template radial_progress_indicator} +/// RadialProgressIndicator widget +/// {@endtemplate} +class RadialProgressIndicator extends StatefulWidget { + /// {@macro radial_progress_indicator} + const RadialProgressIndicator({ + this.size = 64, + this.child, + super.key, + }); + + /// The size of the progress indicator + final double size; + + /// The child widget + final Widget? child; + + @override + State createState() => + _RadialProgressIndicatorState(); +} + +class _RadialProgressIndicatorState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _sweepController; + late final Animation _curvedAnimation; + + @override + void initState() { + super.initState(); + _sweepController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1000), + )..repeat(); + _curvedAnimation = CurvedAnimation( + parent: _sweepController, + curve: Curves.ease, + ); + } + + @override + void dispose() { + _sweepController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => Center( + child: SizedBox.square( + dimension: widget.size, + child: RepaintBoundary( + child: CustomPaint( + painter: _RadialProgressIndicatorPainter( + animation: _curvedAnimation, + color: Theme.of(context).indicatorColor, + ), + child: Center( + child: widget.child, + ), + ), + ), + ), + ); +} + +class _RadialProgressIndicatorPainter extends CustomPainter { + _RadialProgressIndicatorPainter({ + required Animation animation, + Color color = Colors.blue, + }) : _animation = animation, + _arcPaint = Paint() + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.stroke + ..color = color, + super(repaint: animation); + + final Animation _animation; + final Paint _arcPaint; + + @override + void paint(Canvas canvas, Size size) { + _arcPaint.strokeWidth = size.shortestSide / 8; + final progress = _animation.value; + final rect = Rect.fromCircle( + center: size.center(Offset.zero), + radius: size.shortestSide / 2 - _arcPaint.strokeWidth / 2, + ); + final rotate = math.pow(progress, 2) * math.pi * 2; + final sweep = math.sin(progress * math.pi) * 3 + math.pi * .25; + + canvas.drawArc(rect, rotate, sweep, false, _arcPaint); + } + + @override + bool shouldRepaint(covariant _RadialProgressIndicatorPainter oldDelegate) => + _animation.value != oldDelegate._animation.value; + + @override + bool shouldRebuildSemantics( + covariant _RadialProgressIndicatorPainter oldDelegate) => + false; +} diff --git a/example/lib/src/common/widget/window_scope.dart b/example/lib/src/common/widget/window_scope.dart new file mode 100644 index 0000000..891ff50 --- /dev/null +++ b/example/lib/src/common/widget/window_scope.dart @@ -0,0 +1,242 @@ +import 'dart:io' as io; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:window_manager/window_manager.dart'; + +/// {@template window_scope} +/// WindowScope widget. +/// {@endtemplate} +class WindowScope extends StatefulWidget { + /// {@macro window_scope} + const WindowScope({ + required this.title, + required this.child, + super.key, + }); + + /// Title of the window. + final String title; + + /// The widget below this widget in the tree. + final Widget child; + + @override + State createState() => _WindowScopeState(); +} + +class _WindowScopeState extends State { + @override + Widget build(BuildContext context) => + kIsWeb || io.Platform.isAndroid || io.Platform.isIOS + ? widget.child + : Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _WindowTitle(), + Expanded( + child: widget.child, + ), + ], + ); +} + +class _WindowTitle extends StatefulWidget { + const _WindowTitle(); + + @override + State<_WindowTitle> createState() => _WindowTitleState(); +} + +class _WindowTitleState extends State<_WindowTitle> with WindowListener { + final ValueNotifier _isFullScreen = ValueNotifier(false); + final ValueNotifier _isAlwaysOnTop = ValueNotifier(false); + + @override + void initState() { + windowManager.addListener(this); + super.initState(); + } + + @override + void dispose() { + windowManager.removeListener(this); + super.dispose(); + } + + @override + void onWindowEnterFullScreen() { + super.onWindowEnterFullScreen(); + _isFullScreen.value = true; + } + + @override + void onWindowLeaveFullScreen() { + super.onWindowLeaveFullScreen(); + _isFullScreen.value = false; + } + + @override + void onWindowFocus() { + // Make sure to call once. + setState(() {}); + // do something + } + + void setAlwaysOnTop(bool value) { + Future(() async { + await windowManager.setAlwaysOnTop(value); + _isAlwaysOnTop.value = await windowManager.isAlwaysOnTop(); + }).ignore(); + } + + @override + Widget build(BuildContext context) => SizedBox( + height: 24, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onPanStart: (details) => windowManager.startDragging(), + onDoubleTap: null, + /* () async { + bool isMaximized = await windowManager.isMaximized(); + if (!isMaximized) { + windowManager.maximize(); + } else { + windowManager.unmaximize(); + } + }, */ + child: Material( + color: Theme.of(context).primaryColor, + child: Stack( + alignment: Alignment.center, + children: [ + Builder( + builder: (context) { + final size = MediaQuery.of(context).size; + return AnimatedPositioned( + duration: const Duration(milliseconds: 350), + left: size.width < 800 ? 8 : 78, + right: 78, + top: 0, + bottom: 0, + child: Center( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + transitionBuilder: (child, animation) => + FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: animation, + child: child, + ), + ), + child: Text( + context + .findAncestorWidgetOfExactType< + WindowScope>() + ?.title ?? + 'App', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith(height: 1), + ), + ), + ), + ); + }, + ), + _WindowButtons$Windows( + isFullScreen: _isFullScreen, + isAlwaysOnTop: _isAlwaysOnTop, + setAlwaysOnTop: setAlwaysOnTop, + ), + ], + ), + ), + ), + ); +} + +class _WindowButtons$Windows extends StatelessWidget { + const _WindowButtons$Windows({ + required ValueListenable isFullScreen, + required ValueListenable isAlwaysOnTop, + required this.setAlwaysOnTop, + }) : _isFullScreen = isFullScreen, + _isAlwaysOnTop = isAlwaysOnTop; + + // ignore: unused_field + final ValueListenable _isFullScreen; + final ValueListenable _isAlwaysOnTop; + + final ValueChanged setAlwaysOnTop; + + @override + Widget build(BuildContext context) => Align( + alignment: Alignment.centerRight, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Is always on top + ValueListenableBuilder( + valueListenable: _isAlwaysOnTop, + builder: (context, isAlwaysOnTop, _) => _WindowButton( + onPressed: () => setAlwaysOnTop(!isAlwaysOnTop), + icon: isAlwaysOnTop ? Icons.push_pin : Icons.push_pin_outlined, + ), + ), + + // Minimize + _WindowButton( + onPressed: () => windowManager.minimize(), + icon: Icons.minimize, + ), + + /* ValueListenableBuilder( + valueListenable: _isFullScreen, + builder: (context, isFullScreen, _) => _WindowButton( + onPressed: () => windowManager.setFullScreen(!isFullScreen), + icon: isFullScreen ? Icons.fullscreen_exit : Icons.fullscreen, + )), */ + + // Close + _WindowButton( + onPressed: () => windowManager.close(), + icon: Icons.close, + ), + const SizedBox(width: 4), + ], + ), + ); +} + +class _WindowButton extends StatelessWidget { + const _WindowButton({ + required this.onPressed, + required this.icon, + }); + + final VoidCallback onPressed; + final IconData icon; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: IconButton( + onPressed: onPressed, + icon: Icon(icon), + iconSize: 16, + alignment: Alignment.center, + padding: EdgeInsets.zero, + splashRadius: 12, + constraints: const BoxConstraints.tightFor(width: 24, height: 24), + ), + ); +} diff --git a/example/lib/src/feature/authentication/controller/authentication_controller.dart b/example/lib/src/feature/authentication/controller/authentication_controller.dart new file mode 100644 index 0000000..cc4f972 --- /dev/null +++ b/example/lib/src/feature/authentication/controller/authentication_controller.dart @@ -0,0 +1,45 @@ +import 'dart:async'; + +import 'package:spinifyapp/src/common/controller/droppable_controller_concurrency.dart'; +import 'package:spinifyapp/src/common/controller/state_controller.dart'; +import 'package:spinifyapp/src/common/util/error_util.dart'; +import 'package:spinifyapp/src/feature/authentication/controller/authentication_state.dart'; +import 'package:spinifyapp/src/feature/authentication/data/authentication_repository.dart'; +import 'package:spinifyapp/src/feature/authentication/model/user.dart'; + +final class AuthenticationController + extends StateController + with DroppableControllerConcurrency { + AuthenticationController( + {required IAuthenticationRepository repository, + super.initialState = + const AuthenticationState.idle(user: User.unauthenticated())}) + : _repository = repository { + _userSubscription = repository + .userChanges() + .map((u) => AuthenticationState.idle(user: u)) + .where((newState) => + state.isProcessing || !identical(newState.user, state.user)) + .listen(setState); + } + + final IAuthenticationRepository _repository; + StreamSubscription? _userSubscription; + + void signInAnonymously() => handle( + () async { + setState(AuthenticationState.processing( + user: state.user, message: 'Logging in...')); + await _repository.signInAnonymously(); + }, + (error, _) => setState(AuthenticationState.idle( + user: state.user, error: ErrorUtil.formatMessage(error))), + () => setState(AuthenticationState.idle(user: state.user)), + ); + + @override + void dispose() { + _userSubscription?.cancel(); + super.dispose(); + } +} diff --git a/example/lib/src/feature/authentication/controller/authentication_state.dart b/example/lib/src/feature/authentication/controller/authentication_state.dart new file mode 100644 index 0000000..225b314 --- /dev/null +++ b/example/lib/src/feature/authentication/controller/authentication_state.dart @@ -0,0 +1,135 @@ +import 'package:meta/meta.dart'; +import 'package:spinifyapp/src/feature/authentication/model/user.dart'; + +/// {@template authentication_state} +/// AuthenticationState. +/// {@endtemplate} +sealed class AuthenticationState extends _$AuthenticationStateBase { + /// Idling state + /// {@macro authentication_state} + const factory AuthenticationState.idle({ + required User user, + String message, + String? error, + }) = AuthenticationState$Idle; + + /// Processing + /// {@macro authentication_state} + const factory AuthenticationState.processing({ + required User user, + String message, + }) = AuthenticationState$Processing; + + /// {@macro authentication_state} + const AuthenticationState({required super.user, required super.message}); +} + +/// Idling state +/// {@nodoc} +final class AuthenticationState$Idle extends AuthenticationState + with _$AuthenticationState { + /// {@nodoc} + const AuthenticationState$Idle( + {required super.user, super.message = 'Idling', this.error}); + + @override + final String? error; +} + +/// Processing +/// {@nodoc} +final class AuthenticationState$Processing extends AuthenticationState + with _$AuthenticationState { + /// {@nodoc} + const AuthenticationState$Processing( + {required super.user, super.message = 'Processing'}); + + @override + String? get error => null; +} + +/// {@nodoc} +base mixin _$AuthenticationState on AuthenticationState {} + +/// Pattern matching for [AuthenticationState]. +typedef AuthenticationStateMatch = R Function( + S state); + +/// {@nodoc} +@immutable +abstract base class _$AuthenticationStateBase { + /// {@nodoc} + const _$AuthenticationStateBase({required this.user, required this.message}); + + /// Data entity payload. + @nonVirtual + final User user; + + /// Message or state description. + @nonVirtual + final String message; + + /// Error message. + abstract final String? error; + + /// If an error has occurred? + bool get hasError => error != null; + + /// Is in progress state? + bool get isProcessing => + maybeMap(orElse: () => false, processing: (_) => true); + + /// Is in idle state? + bool get isIdling => !isProcessing; + + /// Pattern matching for [AuthenticationState]. + R map({ + required AuthenticationStateMatch idle, + required AuthenticationStateMatch + processing, + }) => + switch (this) { + AuthenticationState$Idle s => idle(s), + AuthenticationState$Processing s => processing(s), + _ => throw AssertionError(), + }; + + /// Pattern matching for [AuthenticationState]. + R maybeMap({ + AuthenticationStateMatch? idle, + AuthenticationStateMatch? processing, + required R Function() orElse, + }) => + map( + idle: idle ?? (_) => orElse(), + processing: processing ?? (_) => orElse(), + ); + + /// Pattern matching for [AuthenticationState]. + R? mapOrNull({ + AuthenticationStateMatch? idle, + AuthenticationStateMatch? processing, + }) => + map( + idle: idle ?? (_) => null, + processing: processing ?? (_) => null, + ); + + @override + int get hashCode => user.hashCode; + + @override + bool operator ==(Object other) => identical(this, other); + + @override + String toString() { + final buffer = StringBuffer() + ..write('AuthenticationState(') + ..write('user: $user, '); + if (error != null) buffer.write('error: $error, '); + buffer + ..write('message: $message') + ..write(')'); + return buffer.toString(); + } +} diff --git a/example/lib/src/feature/authentication/data/authentication_repository.dart b/example/lib/src/feature/authentication/data/authentication_repository.dart new file mode 100644 index 0000000..aab590f --- /dev/null +++ b/example/lib/src/feature/authentication/data/authentication_repository.dart @@ -0,0 +1,41 @@ +import 'dart:async'; + +import 'package:spinifyapp/src/feature/authentication/model/user.dart'; + +abstract interface class IAuthenticationRepository { + Stream userChanges(); + FutureOr getUser(); + Future signInAnonymously(); + + /* Future sendSignInWithEmailLink(String email); + Future signInWithEmailLink(String email, String emailLink); + Future signInWithEmailAndPassword(String email, String password); + Future signInWithFacebook(); + Future signInWithApple(); + Future signInWithTwitter(); + Future signInWithGithub(); + Future signInWithPhoneNumber(String phoneNumber); + Future sendPasswordResetEmail(String email); + Future confirmPasswordReset(String code, String newPassword); + Future signUpWithEmailAndPassword(String email, String password); + Future deleteUser(); + Future isSignedIn(); + Future signInWithGoogle(); + Future signOut(); */ +} + +class AuthenticationRepositoryFake implements IAuthenticationRepository { + final StreamController _userController = + StreamController.broadcast(); + User _user = const User.unauthenticated(); + + @override + FutureOr getUser() => _user; + + @override + Stream userChanges() => _userController.stream; + + @override + Future signInAnonymously() => Future.sync(() => _userController + .add(_user = const User.authenticated(id: 'anonymous-user-id'))); +} diff --git a/example/lib/src/feature/authentication/model/user.dart b/example/lib/src/feature/authentication/model/user.dart new file mode 100644 index 0000000..a146046 --- /dev/null +++ b/example/lib/src/feature/authentication/model/user.dart @@ -0,0 +1,138 @@ +import 'package:meta/meta.dart'; + +/// User id type. +typedef UserId = String; + +/// {@template user} +/// The user entry model. +/// {@endtemplate} +sealed class User with _UserPatternMatching, _UserShortcuts { + /// {@macro user} + const User._(); + + /// {@macro user} + @literal + const factory User.unauthenticated() = UnauthenticatedUser; + + /// {@macro user} + const factory User.authenticated({ + required UserId id, + }) = AuthenticatedUser; + + /// The user's id. + abstract final UserId? id; +} + +/// {@macro user} +/// +/// Unauthenticated user. +class UnauthenticatedUser extends User { + /// {@macro user} + const UnauthenticatedUser() : super._(); + + @override + UserId? get id => null; + + @override + @nonVirtual + bool get isAuthenticated => false; + + @override + T map({ + required T Function(UnauthenticatedUser user) unauthenticated, + required T Function(AuthenticatedUser user) authenticated, + }) => + unauthenticated(this); + + @override + int get hashCode => -2; + + @override + bool operator ==(Object other) => identical(this, other) || other is UnauthenticatedUser && id == other.id; + + @override + String toString() => 'UnauthenticatedUser()'; +} + +final class AuthenticatedUser extends User { + const AuthenticatedUser({ + required this.id, + }) : super._(); + + factory AuthenticatedUser.fromJson(Map json) { + if (json.isEmpty) throw FormatException('Json is empty', json); + if (json + case { + 'id': UserId id, + }) + return AuthenticatedUser( + id: id, + ); + throw FormatException('Invalid json format', json); + } + + @override + @nonVirtual + final UserId id; + + @override + @nonVirtual + bool get isAuthenticated => true; + + @override + T map({ + required T Function(UnauthenticatedUser user) unauthenticated, + required T Function(AuthenticatedUser user) authenticated, + }) => + authenticated(this); + + Map toJson() => { + 'id': id, + }; + + @override + int get hashCode => id.hashCode; + + @override + bool operator ==(Object other) => identical(this, other) || other is AuthenticatedUser && id == other.id; + + @override + String toString() => 'AuthenticatedUser(id: $id)'; +} + +mixin _UserPatternMatching { + /// Pattern matching on [User] subclasses. + T map({ + required T Function(UnauthenticatedUser user) unauthenticated, + required T Function(AuthenticatedUser user) authenticated, + }); + + /// Pattern matching on [User] subclasses. + T maybeMap({ + required T Function() orElse, + T Function(UnauthenticatedUser user)? unauthenticated, + T Function(AuthenticatedUser user)? authenticated, + }) => + map( + unauthenticated: (user) => unauthenticated?.call(user) ?? orElse(), + authenticated: (user) => authenticated?.call(user) ?? orElse(), + ); + + /// Pattern matching on [User] subclasses. + T? mapOrNull({ + T Function(UnauthenticatedUser user)? unauthenticated, + T Function(AuthenticatedUser user)? authenticated, + }) => + map( + unauthenticated: (user) => unauthenticated?.call(user), + authenticated: (user) => authenticated?.call(user), + ); +} + +mixin _UserShortcuts on _UserPatternMatching { + /// User is authenticated. + bool get isAuthenticated; + + /// User is not authenticated. + bool get isNotAuthenticated => !isAuthenticated; +} diff --git a/example/lib/src/feature/authentication/widget/authentication_scope.dart b/example/lib/src/feature/authentication/widget/authentication_scope.dart new file mode 100644 index 0000000..e2e1b6b --- /dev/null +++ b/example/lib/src/feature/authentication/widget/authentication_scope.dart @@ -0,0 +1,110 @@ +import 'package:flutter/widgets.dart'; +import 'package:spinifyapp/src/feature/authentication/controller/authentication_controller.dart'; +import 'package:spinifyapp/src/feature/authentication/model/user.dart'; +import 'package:spinifyapp/src/feature/dependencies/widget/dependencies_scope.dart'; + +/// {@template authentication_scope} +/// AuthenticationScope widget. +/// {@endtemplate} +class AuthenticationScope extends StatefulWidget { + /// {@macro authentication_scope} + const AuthenticationScope({ + required this.signInScreen, + required this.child, + super.key, + }); + + /// Sign In screen for unauthenticated users. + final Widget signInScreen; + + /// The widget below this widget in the tree. + final Widget child; + + /// User of the authentication scope. + static User userOf(BuildContext context, {bool listen = true}) => + _InheritedAuthenticationScope.of(context, listen: listen).user; + + /// Authentication controller of the authentication scope. + static AuthenticationController controllerOf(BuildContext context) => + _InheritedAuthenticationScope.of(context, listen: false).controller; + + @override + State createState() => _AuthenticationScopeState(); +} + +/// State for widget AuthenticationScope. +class _AuthenticationScopeState extends State { + late final AuthenticationController _authenticationController; + User _user = const User.unauthenticated(); + + @override + void initState() { + super.initState(); + _authenticationController = AuthenticationController( + repository: DependenciesScope.of(context).authenticationRepository, + )..addListener(_onAuthenticationControllerChanged); + } + + @override + void dispose() { + _authenticationController + ..removeListener(_onAuthenticationControllerChanged) + ..dispose(); + super.dispose(); + } + + void _onAuthenticationControllerChanged() { + final user = _authenticationController.state.user; + if (!identical(_user, user)) setState(() => _user = user); + } + + @override + Widget build(BuildContext context) => _InheritedAuthenticationScope( + controller: _authenticationController, + user: _user, + child: switch (_user) { + UnauthenticatedUser _ => widget.signInScreen, + AuthenticatedUser _ => widget.child, + }, + ); +} + +/// Inherited widget for quick access in the element tree. +class _InheritedAuthenticationScope extends InheritedWidget { + const _InheritedAuthenticationScope({ + required this.controller, + required this.user, + required super.child, + }); + + final AuthenticationController controller; + final User user; + + /// The state from the closest instance of this class + /// that encloses the given context, if any. + /// For example: `AuthenticationScope.maybeOf(context)`. + static _InheritedAuthenticationScope? maybeOf(BuildContext context, + {bool listen = true}) => + listen + ? context.dependOnInheritedWidgetOfExactType< + _InheritedAuthenticationScope>() + : context + .getInheritedWidgetOfExactType<_InheritedAuthenticationScope>(); + + static Never _notFoundInheritedWidgetOfExactType() => throw ArgumentError( + 'Out of scope, not found inherited widget ' + 'a _InheritedAuthenticationScope of the exact type', + 'out_of_scope', + ); + + /// The state from the closest instance of this class + /// that encloses the given context. + /// For example: `AuthenticationScope.of(context)`. + static _InheritedAuthenticationScope of(BuildContext context, + {bool listen = true}) => + maybeOf(context, listen: listen) ?? _notFoundInheritedWidgetOfExactType(); + + @override + bool updateShouldNotify(covariant _InheritedAuthenticationScope oldWidget) => + !identical(user, oldWidget.user); +} diff --git a/example/lib/src/feature/authentication/widget/authentication_screen.dart b/example/lib/src/feature/authentication/widget/authentication_screen.dart new file mode 100644 index 0000000..08691b6 --- /dev/null +++ b/example/lib/src/feature/authentication/widget/authentication_screen.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +/// {@template authentication_screen} +/// AuthenticationScreen widget. +/// {@endtemplate} +class AuthenticationScreen extends StatelessWidget { + /// {@macro authentication_screen} + const AuthenticationScreen({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('Authentication'), + ), + body: const Center( + child: Text('Authentication'), + ), + ); +} diff --git a/example/lib/src/feature/authentication/widget/sign_in_screen.dart b/example/lib/src/feature/authentication/widget/sign_in_screen.dart new file mode 100644 index 0000000..59fc019 --- /dev/null +++ b/example/lib/src/feature/authentication/widget/sign_in_screen.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:spinifyapp/src/feature/authentication/widget/authentication_scope.dart'; + +/// {@template sign_in_screen} +/// SignInScreen widget. +/// {@endtemplate} +class SignInScreen extends StatelessWidget { + /// {@macro sign_in_screen} + const SignInScreen({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('Sign In'), + ), + body: Center( + child: ElevatedButton( + onPressed: () => + AuthenticationScope.controllerOf(context).signInAnonymously(), + child: const Text('Sign In Anonymously'), + ), + ), + ); +} diff --git a/example/lib/src/feature/dependencies/initialization/initialization.dart b/example/lib/src/feature/dependencies/initialization/initialization.dart new file mode 100644 index 0000000..092ef58 --- /dev/null +++ b/example/lib/src/feature/dependencies/initialization/initialization.dart @@ -0,0 +1,103 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' + show ChangeNotifier, FlutterError, PlatformDispatcher, ValueListenable; +import 'package:flutter/services.dart' show SystemChrome, DeviceOrientation; +import 'package:flutter/widgets.dart' + show WidgetsBinding, WidgetsFlutterBinding; +import 'package:spinifyapp/src/common/util/error_util.dart'; +import 'package:spinifyapp/src/feature/dependencies/initialization/initialize_dependencies.dart'; +import 'package:spinifyapp/src/feature/dependencies/model/dependencies.dart'; + +typedef InitializationProgressTuple = ({int progress, String message}); + +abstract interface class InitializationProgressListenable + implements ValueListenable {} + +class InitializationExecutor + with ChangeNotifier, InitializeDependencies + implements InitializationProgressListenable { + InitializationExecutor(); + + /// Ephemerally initializes the app and prepares it for use. + Future? _$currentInitialization; + + @override + InitializationProgressTuple get value => _value; + InitializationProgressTuple _value = (progress: 0, message: ''); + + /// Initializes the app and prepares it for use. + Future call({ + bool deferFirstFrame = false, + List? orientations, + void Function(int progress, String message)? onProgress, + void Function(Dependencies dependencies)? onSuccess, + void Function(Object error, StackTrace stackTrace)? onError, + }) => + _$currentInitialization ??= Future(() async { + late final WidgetsBinding binding; + final stopwatch = Stopwatch()..start(); + void notifyProgress(int progress, String message) { + _value = (progress: progress.clamp(0, 100), message: message); + onProgress?.call(_value.progress, _value.message); + notifyListeners(); + } + + notifyProgress(0, 'Initializing'); + try { + binding = WidgetsFlutterBinding.ensureInitialized(); + if (deferFirstFrame) binding.deferFirstFrame(); + await _catchExceptions(); + if (orientations != null) + await SystemChrome.setPreferredOrientations(orientations); + final dependencies = + await $initializeDependencies(onProgress: notifyProgress) + .timeout(const Duration(minutes: 5)); + notifyProgress(100, 'Done'); + onSuccess?.call(dependencies); + return dependencies; + } on Object catch (error, stackTrace) { + onError?.call(error, stackTrace); + ErrorUtil.logError( + error, + stackTrace, + hint: 'Failed to initialize app', + ).ignore(); + rethrow; + } finally { + stopwatch.stop(); + binding.addPostFrameCallback((_) { + // Closes splash screen, and show the app layout. + if (deferFirstFrame) binding.allowFirstFrame(); + //final context = binding.renderViewElement; + }); + _$currentInitialization = null; + } + }); + + Future _catchExceptions() async { + try { + PlatformDispatcher.instance.onError = (error, stackTrace) { + ErrorUtil.logError( + error, + stackTrace, + hint: 'ROOT | ${Error.safeToString(error)}', + ).ignore(); + return true; + }; + + final sourceFlutterError = FlutterError.onError; + FlutterError.onError = (final details) { + ErrorUtil.logError( + details.exception, + details.stack ?? StackTrace.current, + hint: 'FLUTTER ERROR\r\n$details', + ).ignore(); + // FlutterError.presentError(details); + sourceFlutterError?.call(details); + }; + } on Object catch (error, stackTrace) { + ErrorUtil.logError(error, stackTrace).ignore(); + } + } +} diff --git a/example/lib/src/feature/dependencies/initialization/initialize_dependencies.dart b/example/lib/src/feature/dependencies/initialization/initialize_dependencies.dart new file mode 100644 index 0000000..0eaee93 --- /dev/null +++ b/example/lib/src/feature/dependencies/initialization/initialize_dependencies.dart @@ -0,0 +1,114 @@ +import 'dart:async'; + +import 'package:l/l.dart'; +import 'package:meta/meta.dart'; +import 'package:platform_info/platform_info.dart'; +import 'package:spinifyapp/src/common/constant/config.dart'; +import 'package:spinifyapp/src/common/constant/pubspec.yaml.g.dart'; +import 'package:spinifyapp/src/common/controller/controller.dart'; +import 'package:spinifyapp/src/common/controller/controller_observer.dart'; +import 'package:spinifyapp/src/common/util/screen_util.dart'; +import 'package:spinifyapp/src/feature/authentication/data/authentication_repository.dart'; +import 'package:spinifyapp/src/feature/dependencies/initialization/platform/initialization_vm.dart' + // ignore: uri_does_not_exist + if (dart.library.html) 'package:spinifyapp/src/feature/dependencies/initialization/platform/initialization_js.dart'; +import 'package:spinifyapp/src/feature/dependencies/model/app_metadata.dart'; +import 'package:spinifyapp/src/feature/dependencies/model/dependencies.dart'; + +typedef _InitializationStep = FutureOr Function( + _MutableDependencies dependencies); + +class _MutableDependencies implements Dependencies { + @override + late AppMetadata appMetadata; + + @override + late IAuthenticationRepository authenticationRepository; +} + +@internal +mixin InitializeDependencies { + /// Initializes the app and returns a [Dependencies] object + @protected + Future $initializeDependencies({ + void Function(int progress, String message)? onProgress, + }) async { + final steps = _initializationSteps; + final dependencies = _MutableDependencies(); + final totalSteps = steps.length; + for (var currentStep = 0; currentStep < totalSteps; currentStep++) { + final step = steps[currentStep]; + final percent = (currentStep * 100 ~/ totalSteps).clamp(0, 100); + onProgress?.call(percent, step.$1); + l.v6( + 'Initialization | $currentStep/$totalSteps ($percent%) | "${step.$1}"'); + await step.$2(dependencies); + } + return dependencies; + } + + List<(String, _InitializationStep)> get _initializationSteps => + <(String, _InitializationStep)>[ + ( + 'Platform pre-initialization', + (_) => $platformInitialization(), + ), + ( + 'Creating app metadata', + (dependencies) => dependencies.appMetadata = AppMetadata( + environment: Config.environment.value, + isWeb: platform.isWeb, + isRelease: platform.buildMode.isRelease, + appName: Pubspec.name, + appVersion: Pubspec.version.canonical, + appVersionMajor: Pubspec.version.major, + appVersionMinor: Pubspec.version.minor, + appVersionPatch: Pubspec.version.patch, + appBuildTimestamp: Pubspec.version.build.isNotEmpty + ? (int.tryParse( + Pubspec.version.build.firstOrNull ?? '-1') ?? + -1) + : -1, + operatingSystem: platform.operatingSystem.name, + processorsCount: platform.numberOfProcessors, + appLaunchedTimestamp: DateTime.now(), + locale: platform.locale, + deviceVersion: platform.version, + deviceScreenSize: ScreenUtil.screenSize().representation, + ), + ), + ( + 'Observer state managment', + (_) => Controller.observer = ControllerObserver(), + ), + ( + 'Initializing analytics', + (_) {}, + ), + ( + 'Log app open', + (_) {}, + ), + ( + 'Get remote config', + (_) {}, + ), + ( + 'Authentication repository', + (dependencies) => dependencies.authenticationRepository = + AuthenticationRepositoryFake(), + ), + ( + 'Fake delay 1', + (_) => Future.delayed(const Duration(seconds: 1)), + ), + ( + 'Fake delay 2', + (_) => Future.delayed(const Duration(seconds: 1)), + ), + ( + 'Fake delay 3', + (_) => Future.delayed(const Duration(seconds: 1)), + ), + ]; +} diff --git a/example/lib/src/feature/dependencies/initialization/platform/initialization_js.dart b/example/lib/src/feature/dependencies/initialization/platform/initialization_js.dart new file mode 100644 index 0000000..611f64e --- /dev/null +++ b/example/lib/src/feature/dependencies/initialization/platform/initialization_js.dart @@ -0,0 +1,21 @@ +// ignore_for_file: avoid_web_libraries_in_flutter + +import 'dart:html' as html; + +//import 'package:flutter_web_plugins/flutter_web_plugins.dart'; + +Future $platformInitialization() async { + //setUrlStrategy(const HashUrlStrategy()); + Future.delayed( + const Duration(seconds: 1), + () { + html.document.getElementById('splash')?.remove(); + html.document.getElementById('splash-branding')?.remove(); + html.document.body?.style.background = 'transparent'; + html.document + .getElementsByClassName('splash-loading') + .toList(growable: false) + .forEach((element) => element.remove()); + }, + ); +} diff --git a/example/lib/src/feature/dependencies/initialization/platform/initialization_vm.dart b/example/lib/src/feature/dependencies/initialization/platform/initialization_vm.dart new file mode 100644 index 0000000..76ba8d0 --- /dev/null +++ b/example/lib/src/feature/dependencies/initialization/platform/initialization_vm.dart @@ -0,0 +1,44 @@ +import 'dart:io' as io; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:window_manager/window_manager.dart'; + +Future $platformInitialization() => + io.Platform.isAndroid || io.Platform.isIOS + ? _mobileInitialization() + : _desktopInitialization(); + +Future _mobileInitialization() async {} + +Future _desktopInitialization() async { + // Must add this line. + await windowManager.ensureInitialized(); + final windowOptions = WindowOptions( + minimumSize: const Size(360, 480), + size: const Size(960, 800), + maximumSize: const Size(1440, 1080), + center: true, + backgroundColor: + PlatformDispatcher.instance.platformBrightness == Brightness.dark + ? ThemeData.dark().colorScheme.background + : ThemeData.light().colorScheme.background, + skipTaskbar: false, + titleBarStyle: TitleBarStyle.hidden, + /* alwaysOnTop: true, */ + windowButtonVisibility: false, + fullScreen: false, + title: 'Chat App', + ); + await windowManager.waitUntilReadyToShow( + windowOptions, + () async { + if (io.Platform.isMacOS) { + await windowManager.setMovable(true); + } + await windowManager.setMaximizable(false); + await windowManager.show(); + await windowManager.focus(); + }, + ); +} diff --git a/example/lib/src/feature/dependencies/model/app_metadata.dart b/example/lib/src/feature/dependencies/model/app_metadata.dart new file mode 100644 index 0000000..eeb599f --- /dev/null +++ b/example/lib/src/feature/dependencies/model/app_metadata.dart @@ -0,0 +1,92 @@ +import 'package:meta/meta.dart'; + +/// {@template app_metadata} +/// App metadata +/// {@endtemplate} +@immutable +class AppMetadata { + /// {@macro app_metadata} + const AppMetadata({ + required this.environment, + required this.isWeb, + required this.isRelease, + required this.appVersion, + required this.appVersionMajor, + required this.appVersionMinor, + required this.appVersionPatch, + required this.appBuildTimestamp, + required this.appName, + required this.operatingSystem, + required this.processorsCount, + required this.locale, + required this.deviceVersion, + required this.deviceScreenSize, + required this.appLaunchedTimestamp, + }); + + /// Environment + /// Possible values: development, staging, production + final String environment; + + /// Is web platform + final bool isWeb; + + /// Is release build + final bool isRelease; + + /// App version + final String appVersion; + + /// App version major + final int appVersionMajor; + + /// App version minor + final int appVersionMinor; + + /// App version patch + final int appVersionPatch; + + /// App build timestamp + final int appBuildTimestamp; + + /// App name + final String appName; + + /// Operating system + final String operatingSystem; + + /// Processors count + final int processorsCount; + + /// Locale + final String locale; + + /// Device representation + final String deviceVersion; + + /// Device logical screen size + final String deviceScreenSize; + + /// App launched timestamp + final DateTime appLaunchedTimestamp; + + /// Convert to headers + Map toHeaders() => { + 'X-Meta-Environment': environment, + 'X-Meta-Is-Web': isWeb ? 'true' : 'false', + 'X-Meta-Is-Release': isRelease ? 'true' : 'false', + 'X-Meta-App-Version': appVersion, + 'X-Meta-App-Version-Major': appVersionMajor.toString(), + 'X-Meta-App-Version-Minor': appVersionMinor.toString(), + 'X-Meta-App-Version-Patch': appVersionPatch.toString(), + 'X-Meta-App-Build-Timestamp': appBuildTimestamp.toString(), + 'X-Meta-App-Name': appName, + 'X-Meta-Operating-System': operatingSystem, + 'X-Meta-Processors-Count': processorsCount.toString(), + 'X-Meta-Locale': locale, + 'X-Meta-Device-Version': deviceVersion, + 'X-Meta-Device-Screen-Size': deviceScreenSize, + 'X-Meta-App-Launched-Timestamp': + appLaunchedTimestamp.millisecondsSinceEpoch.toString(), + }; +} diff --git a/example/lib/src/feature/dependencies/model/dependencies.dart b/example/lib/src/feature/dependencies/model/dependencies.dart new file mode 100644 index 0000000..ff8c1da --- /dev/null +++ b/example/lib/src/feature/dependencies/model/dependencies.dart @@ -0,0 +1,10 @@ +import 'package:spinifyapp/src/feature/authentication/data/authentication_repository.dart'; +import 'package:spinifyapp/src/feature/dependencies/model/app_metadata.dart'; + +abstract interface class Dependencies { + /// App metadata + abstract final AppMetadata appMetadata; + + /// Authentication repository + abstract final IAuthenticationRepository authenticationRepository; +} diff --git a/example/lib/src/feature/dependencies/widget/dependencies_scope.dart b/example/lib/src/feature/dependencies/widget/dependencies_scope.dart new file mode 100644 index 0000000..d44d74e --- /dev/null +++ b/example/lib/src/feature/dependencies/widget/dependencies_scope.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:spinifyapp/src/feature/dependencies/model/dependencies.dart'; + +/// {@template dependencies_scope} +/// DependenciesScope widget. +/// {@endtemplate} +class DependenciesScope extends StatelessWidget { + /// {@macro dependencies_scope} + const DependenciesScope({ + required this.initialization, + required this.splashScreen, + required this.child, + this.errorBuilder, + super.key, + }); + + /// The state from the closest instance of this class + /// that encloses the given context, if any. + /// e.g. `DependenciesScope.maybeOf(context)`. + static Dependencies? maybeOf(BuildContext context) => switch (context + .getElementForInheritedWidgetOfExactType<_InheritedDependencies>() + ?.widget) { + _InheritedDependencies inheritedDependencies => + inheritedDependencies.dependencies, + _ => null, + }; + + static Never _notFoundInheritedWidgetOfExactType() => throw ArgumentError( + 'Out of scope, not found inherited widget ' + 'a DependenciesScope of the exact type', + 'out_of_scope', + ); + + /// The state from the closest instance of this class + /// that encloses the given context. + /// e.g. `DependenciesScope.of(context)` + static Dependencies of(BuildContext context) => + maybeOf(context) ?? _notFoundInheritedWidgetOfExactType(); + + /// Initialization of the dependencies. + final Future initialization; + + /// Splash screen widget. + final Widget splashScreen; + + /// Error widget. + final Widget Function(Object error, StackTrace? stackTrace)? errorBuilder; + + /// The widget below this widget in the tree. + final Widget child; + + @override + Widget build(BuildContext context) => FutureBuilder( + future: initialization, + builder: (context, snapshot) => + switch ((snapshot.data, snapshot.error, snapshot.stackTrace)) { + (Dependencies dependencies, null, null) => _InheritedDependencies( + dependencies: dependencies, + child: child, + ), + (_, Object error, StackTrace? stackTrace) => + errorBuilder?.call(error, stackTrace) ?? ErrorWidget(error), + _ => splashScreen, + }, + ); +} + +/// {@template inherited_dependencies} +/// InheritedDependencies widget. +/// {@endtemplate} +class _InheritedDependencies extends InheritedWidget { + /// {@macro inherited_dependencies} + const _InheritedDependencies({ + required this.dependencies, + required super.child, + }); + + final Dependencies dependencies; + + @override + bool updateShouldNotify(covariant _InheritedDependencies oldWidget) => false; +} diff --git a/example/lib/src/feature/dependencies/widget/initialization_splash_screen.dart b/example/lib/src/feature/dependencies/widget/initialization_splash_screen.dart new file mode 100644 index 0000000..1e5139c --- /dev/null +++ b/example/lib/src/feature/dependencies/widget/initialization_splash_screen.dart @@ -0,0 +1,62 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:spinifyapp/src/common/widget/radial_progress_indicator.dart'; + +class InitializationSplashScreen extends StatelessWidget { + const InitializationSplashScreen({required this.progress, super.key}); + + final ValueListenable<({int progress, String message})> progress; + + @override + Widget build(BuildContext context) { + final theme = View.of(context).platformDispatcher.platformBrightness == + Brightness.dark + ? ThemeData.dark() + : ThemeData.light(); + return Material( + color: theme.primaryColor, + child: Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: ListView( + shrinkWrap: true, + children: [ + RadialProgressIndicator( + size: 128, + child: ValueListenableBuilder<({String message, int progress})>( + valueListenable: progress, + builder: (context, value, _) => Text( + '${value.progress}%', + overflow: TextOverflow.ellipsis, + maxLines: 1, + textAlign: TextAlign.center, + style: theme.textTheme.titleLarge?.copyWith( + height: 1, + fontSize: 32, + ), + ), + ), + ), + const SizedBox(height: 16), + Opacity( + opacity: .25, + child: ValueListenableBuilder<({String message, int progress})>( + valueListenable: progress, + builder: (context, value, _) => Text( + value.message, + overflow: TextOverflow.ellipsis, + maxLines: 3, + textAlign: TextAlign.center, + style: theme.textTheme.labelSmall?.copyWith( + height: 1, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml index c47971a..6da43d5 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -93,4 +93,19 @@ dev_dependencies: flutter: - uses-material-design: true \ No newline at end of file + generate: true + uses-material-design: true + + +flutter_intl: + enabled: true + class_name: GeneratedLocalization + main_locale: en + arb_dir: lib/src/common/localization + output_dir: lib/src/common/localization/generated + use_deferred_loading: false + + +#flutter_gen: +# output: lib/src/common/constant/ +# line_length: 120 \ No newline at end of file diff --git a/lib/src/model/pubspec.yaml.g.dart b/lib/src/model/pubspec.yaml.g.dart index dde0a2a..5fdca7f 100644 --- a/lib/src/model/pubspec.yaml.g.dart +++ b/lib/src/model/pubspec.yaml.g.dart @@ -93,13 +93,13 @@ sealed class Pubspec { static const PubspecVersion version = ( /// Non-canonical string representation of the version as provided /// in the pubspec.yaml file. - representation: r'0.0.1-pre.4', + representation: r'0.0.1-pre.6', /// Returns a 'canonicalized' representation /// of the application version. /// This represents the version string in accordance with /// Semantic Versioning (SemVer) standards. - canonical: r'0.0.1-pre.4', + canonical: r'0.0.1-pre.6', /// MAJOR version when you make incompatible API changes. /// The major version number: 1 in "1.2.3". @@ -115,7 +115,7 @@ sealed class Pubspec { patch: 1, /// The pre-release identifier: "foo" in "1.2.3-foo". - preRelease: [r'pre', r'4'], + preRelease: [r'pre', r'6'], /// The build identifier: "foo" in "1.2.3+foo". build: [], @@ -126,11 +126,11 @@ sealed class Pubspec { 2023, 8, 4, - 4, - 45, - 29, - 391, - 533, + 8, + 56, + 57, + 323, + 753, ); /// Name @@ -430,7 +430,7 @@ sealed class Pubspec { 'protobuf': r'^3.0.0', 'crypto': r'^3.0.3', 'fixnum': r'^1.1.0', - 'stack_trace': r'^1.11.1', + 'stack_trace': r'^1.11.0', }; /// Developer dependencies