From 6b96736a19ced329b3e9e02f1c0d1cc5415c75aa Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 4 Aug 2023 00:06:28 +0400 Subject: [PATCH 01/46] Update documentation --- CHANGELOG.md | 2 +- lib/spinify.dart | 4 +- lib/src/client/observer.dart | 4 +- lib/src/client/spinify.dart | 18 +++++++- lib/src/client/state.dart | 9 ++-- lib/src/model/channel_presence.dart | 17 +++++-- lib/src/model/channel_push.dart | 6 +++ lib/src/model/connect.dart | 5 ++ lib/src/model/disconnect.dart | 5 ++ lib/src/model/event.dart | 9 +++- lib/src/model/history.dart | 3 ++ lib/src/model/jwt.dart | 1 + lib/src/model/message.dart | 5 ++ lib/src/model/presence.dart | 3 ++ lib/src/model/presence_stats.dart | 3 ++ lib/src/model/publication.dart | 6 ++- lib/src/model/pushes_stream.dart | 14 ++++-- lib/src/model/refresh.dart | 46 ++----------------- lib/src/model/refresh_result.dart | 43 +++++++++++++++++ lib/src/model/stream_position.dart | 1 + lib/src/model/subscribe.dart | 5 ++ lib/src/model/unsubscribe.dart | 5 ++ .../client_subscription_impl.dart | 3 ++ .../server_subscription_impl.dart | 3 ++ lib/src/subscription/subcibed_on_channel.dart | 6 +++ lib/src/subscription/subscription.dart | 39 +++++++++++----- lib/src/subscription/subscription_config.dart | 10 ++++ lib/src/subscription/subscription_state.dart | 19 ++++++-- .../subscription_states_stream.dart | 5 ++ lib/src/transport/transport_interface.dart | 2 +- lib/src/transport/ws_protobuf_transport.dart | 1 + lib/src/util/logger.dart | 2 +- pubspec.yaml | 2 +- 33 files changed, 226 insertions(+), 80 deletions(-) create mode 100644 lib/src/model/refresh_result.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index ed0c828..2f47a22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,3 @@ -## 0.0.1-pre.2 +## 0.0.1-pre.3 - **ADDED**: Initial release diff --git a/lib/spinify.dart b/lib/spinify.dart index dd6cc1c..b3bab3c 100644 --- a/lib/spinify.dart +++ b/lib/spinify.dart @@ -19,13 +19,11 @@ export 'package:spinify/src/model/presence.dart'; export 'package:spinify/src/model/presence_stats.dart'; export 'package:spinify/src/model/publication.dart'; export 'package:spinify/src/model/pushes_stream.dart'; -export 'package:spinify/src/model/refresh.dart' show SpinifyRefresh; +export 'package:spinify/src/model/refresh.dart'; export 'package:spinify/src/model/stream_position.dart'; export 'package:spinify/src/model/subscribe.dart'; export 'package:spinify/src/model/unsubscribe.dart'; export 'package:spinify/src/subscription/subcibed_on_channel.dart'; -export 'package:spinify/src/subscription/subscription.dart' - show SpinifyClientSubscription, SpinifyServerSubscription; export 'package:spinify/src/subscription/subscription.dart'; export 'package:spinify/src/subscription/subscription_config.dart'; export 'package:spinify/src/subscription/subscription_state.dart'; diff --git a/lib/src/client/observer.dart b/lib/src/client/observer.dart index a44fa28..51bfc16 100644 --- a/lib/src/client/observer.dart +++ b/lib/src/client/observer.dart @@ -23,10 +23,10 @@ abstract class SpinifyObserver { /// from [prev] to [next]. void onStateChanged(ISpinify client, SpinifyState prev, SpinifyState next) {} - /// Called whenever a [ISpinifySubscription] changes its state + /// Called whenever a [SpinifySubscription] changes its state /// from [prev] to [next]. /// Works both for client-side and server-side subscriptions. - void onSubscriptionChanged(ISpinifySubscription subscription, + void onSubscriptionChanged(SpinifySubscription subscription, SpinifySubscriptionState prev, SpinifySubscriptionState next) {} /// Called whenever a [ISpinify] client changes its state diff --git a/lib/src/client/spinify.dart b/lib/src/client/spinify.dart index 44f5663..91307d5 100644 --- a/lib/src/client/spinify.dart +++ b/lib/src/client/spinify.dart @@ -33,7 +33,23 @@ import 'package:spinify/src/util/event_queue.dart'; import 'package:spinify/src/util/logger.dart' as logger; /// {@template spinify} -/// Spinify client. +/// Spinify client for Centrifuge. +/// +/// Centrifugo SDKs use WebSocket as the main data transport and send/receive +/// messages encoded according to our bidirectional protocol. +/// That protocol is built on top of the Protobuf schema +/// (both JSON and binary Protobuf formats are supported). +/// It provides asynchronous communication, sending RPC, +/// multiplexing subscriptions to channels, etc. +/// +/// Client SDK wraps the protocol and exposes a set of APIs to developers. +/// +/// Client connection has 4 states: +/// - [SpinifyState$Disconnected] +/// - [SpinifyState$Connecting] +/// - [SpinifyState$Connected] +/// - [SpinifyState$Closed] +/// /// {@endtemplate} /// {@category Client} final class Spinify extends SpinifyBase diff --git a/lib/src/client/state.dart b/lib/src/client/state.dart index 11f9ed7..ffc0722 100644 --- a/lib/src/client/state.dart +++ b/lib/src/client/state.dart @@ -198,7 +198,7 @@ final class SpinifyState$Disconnected extends SpinifyState with _$SpinifyState { other.timestamp.isAtSameMomentAs(timestamp)); @override - String toString() => 'SpinifyState.disconnected{$timestamp}'; + String toString() => r'SpinifyState$Disconnected{}'; } /// Connecting @@ -250,7 +250,7 @@ final class SpinifyState$Connecting extends SpinifyState with _$SpinifyState { other.timestamp.isAtSameMomentAs(timestamp)); @override - String toString() => 'SpinifyState.connecting{$timestamp}'; + String toString() => r'SpinifyState$Connecting{}'; } /// Connected @@ -357,7 +357,7 @@ final class SpinifyState$Connected extends SpinifyState with _$SpinifyState { other.timestamp.isAtSameMomentAs(timestamp)); @override - String toString() => 'SpinifyState.connected{$timestamp}'; + String toString() => r'SpinifyState$Connected{}'; } /// Permanently closed @@ -409,13 +409,14 @@ final class SpinifyState$Closed extends SpinifyState with _$SpinifyState { other.timestamp.isAtSameMomentAs(timestamp)); @override - String toString() => 'SpinifyState.closed{$timestamp}'; + String toString() => r'SpinifyState$Closed{}'; } /// {@nodoc} base mixin _$SpinifyState on SpinifyState {} /// Pattern matching for [SpinifyState]. +/// {@category Entity} typedef SpinifyStateMatch = R Function(S state); /// {@nodoc} diff --git a/lib/src/model/channel_presence.dart b/lib/src/model/channel_presence.dart index 361d8bc..bbafc08 100644 --- a/lib/src/model/channel_presence.dart +++ b/lib/src/model/channel_presence.dart @@ -6,9 +6,8 @@ import 'package:spinify/src/model/client_info.dart'; /// Channel presence. /// Join / Leave events. /// {@endtemplate} -/// {@category Entity} -/// {@subCategory Channel} -/// {@subCategory Presence} +/// {@category Event} +/// {@subCategory Push} @immutable sealed class SpinifyChannelPresence extends SpinifyChannelPush { /// {@macro channel_presence} @@ -29,6 +28,9 @@ sealed class SpinifyChannelPresence extends SpinifyChannelPush { } /// {@macro channel_presence} +/// {@category Event} +/// {@subCategory Push} +/// {@subCategory Presence} final class SpinifyJoin extends SpinifyChannelPresence { /// {@macro channel_presence} const SpinifyJoin({ @@ -45,9 +47,15 @@ final class SpinifyJoin extends SpinifyChannelPresence { @override bool get isLeave => false; + + @override + String toString() => 'SpinifyJoin{channel: $channel}'; } /// {@macro channel_presence} +/// {@category Event} +/// {@subCategory Push} +/// {@subCategory Presence} final class SpinifyLeave extends SpinifyChannelPresence { /// {@macro channel_presence} const SpinifyLeave({ @@ -64,4 +72,7 @@ final class SpinifyLeave extends SpinifyChannelPresence { @override bool get isLeave => true; + + @override + String toString() => 'SpinifyLeave{channel: $channel}'; } diff --git a/lib/src/model/channel_push.dart b/lib/src/model/channel_push.dart index a6ec1ce..29e8208 100644 --- a/lib/src/model/channel_push.dart +++ b/lib/src/model/channel_push.dart @@ -4,6 +4,9 @@ import 'package:spinify/src/model/event.dart'; /// {@template spinify_channel_push} /// Base class for all channel push events. /// {@endtemplate} +/// {@category Event} +/// {@subCategory Push} +@immutable abstract base class SpinifyChannelPush extends SpinifyEvent { /// {@template spinify_channel_push} const SpinifyChannelPush({ @@ -17,4 +20,7 @@ abstract base class SpinifyChannelPush extends SpinifyEvent { @override @nonVirtual bool get isPush => true; + + @override + String toString() => 'SpinifyChannelPush{channel: $channel}'; } diff --git a/lib/src/model/connect.dart b/lib/src/model/connect.dart index ca3a96d..aede9e4 100644 --- a/lib/src/model/connect.dart +++ b/lib/src/model/connect.dart @@ -3,6 +3,8 @@ import 'package:spinify/src/model/channel_push.dart'; /// {@template connect} /// Connect push from Centrifugo server. /// {@endtemplate} +/// {@category Event} +/// {@subCategory Push} final class SpinifyConnect extends SpinifyChannelPush { /// {@macro connect} const SpinifyConnect({ @@ -51,4 +53,7 @@ final class SpinifyConnect extends SpinifyChannelPush { /// Payload of connected push. final List data; + + @override + String toString() => 'SpinifyConnect{channel: $channel}'; } diff --git a/lib/src/model/disconnect.dart b/lib/src/model/disconnect.dart index f9133cb..2eeecf5 100644 --- a/lib/src/model/disconnect.dart +++ b/lib/src/model/disconnect.dart @@ -3,6 +3,8 @@ import 'package:spinify/src/model/channel_push.dart'; /// {@template disconnect} /// Disconnect push from Centrifugo server. /// {@endtemplate} +/// {@category Event} +/// {@subCategory Push} final class SpinifyDisconnect extends SpinifyChannelPush { /// {@macro disconnect} const SpinifyDisconnect({ @@ -24,4 +26,7 @@ final class SpinifyDisconnect extends SpinifyChannelPush { /// Reconnect flag. final bool reconnect; + + @override + String toString() => 'SpinifyDisconnect{channel: $channel}'; } diff --git a/lib/src/model/event.dart b/lib/src/model/event.dart index 65897ae..08fca41 100644 --- a/lib/src/model/event.dart +++ b/lib/src/model/event.dart @@ -3,8 +3,9 @@ import 'package:meta/meta.dart'; /// {@template spinify_event} /// Base class for all channel events. /// {@endtemplate} +/// {@category Event} @immutable -abstract base class SpinifyEvent { +abstract base class SpinifyEvent implements Comparable { /// {@template spinify_event} const SpinifyEvent({ required this.timestamp, @@ -18,4 +19,10 @@ abstract base class SpinifyEvent { /// Whether this event is a push event. bool get isPush; + + @override + int compareTo(SpinifyEvent other) => timestamp.compareTo(other.timestamp); + + @override + String toString() => 'SpinifyEvent{type: $type}'; } diff --git a/lib/src/model/history.dart b/lib/src/model/history.dart index b091c1e..5945ce9 100644 --- a/lib/src/model/history.dart +++ b/lib/src/model/history.dart @@ -19,4 +19,7 @@ final class SpinifyHistory { /// Offset and epoch of last publication in publications list final SpinifyStreamPosition since; + + @override + String toString() => 'SpinifyHistory{}'; } diff --git a/lib/src/model/jwt.dart b/lib/src/model/jwt.dart index 7980684..f772efd 100644 --- a/lib/src/model/jwt.dart +++ b/lib/src/model/jwt.dart @@ -19,6 +19,7 @@ import 'package:meta/meta.dart'; /// https://centrifugal.dev/docs/server/authentication#connection-jwt-claims /// {@endtemplate} /// {@category Entity} +/// {@subCategory JWT} @immutable sealed class SpinifyJWT { /// {@macro jwt} diff --git a/lib/src/model/message.dart b/lib/src/model/message.dart index 535eab5..779f4c4 100644 --- a/lib/src/model/message.dart +++ b/lib/src/model/message.dart @@ -3,6 +3,8 @@ import 'package:spinify/src/model/channel_push.dart'; /// {@template message} /// Message push from Centrifugo server. /// {@endtemplate} +/// {@category Event} +/// {@subCategory Push} final class SpinifyMessage extends SpinifyChannelPush { /// {@macro message} const SpinifyMessage({ @@ -16,4 +18,7 @@ final class SpinifyMessage extends SpinifyChannelPush { /// Payload of message. final List data; + + @override + String toString() => 'SpinifyMessage{channel: $channel}'; } diff --git a/lib/src/model/presence.dart b/lib/src/model/presence.dart index cecfc53..38dacd9 100644 --- a/lib/src/model/presence.dart +++ b/lib/src/model/presence.dart @@ -18,4 +18,7 @@ final class SpinifyPresence { /// Publications final Map clients; + + @override + String toString() => 'SpinifyPresence{channel: $channel}'; } diff --git a/lib/src/model/presence_stats.dart b/lib/src/model/presence_stats.dart index fa1ed42..eb3bc04 100644 --- a/lib/src/model/presence_stats.dart +++ b/lib/src/model/presence_stats.dart @@ -21,4 +21,7 @@ final class SpinifyPresenceStats { /// Users count final int users; + + @override + String toString() => 'SpinifyPresenceStats{channel: $channel}'; } diff --git a/lib/src/model/publication.dart b/lib/src/model/publication.dart index 1e609b9..a30a5c4 100644 --- a/lib/src/model/publication.dart +++ b/lib/src/model/publication.dart @@ -6,7 +6,8 @@ import 'package:spinify/src/model/client_info.dart'; /// {@template publication} /// Publication context /// {@endtemplate} -/// {@category Entity} +/// {@category Event} +/// {@subCategory Push} @immutable final class SpinifyPublication extends SpinifyChannelPush { /// {@macro publication} @@ -34,4 +35,7 @@ final class SpinifyPublication extends SpinifyChannelPush { /// Optional tags, this is a map with string keys and string values final Map? tags; + + @override + String toString() => 'SpinifyPublication{channel: $channel}'; } diff --git a/lib/src/model/pushes_stream.dart b/lib/src/model/pushes_stream.dart index cf4bf08..6a3b505 100644 --- a/lib/src/model/pushes_stream.dart +++ b/lib/src/model/pushes_stream.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:meta/meta.dart'; import 'package:spinify/src/model/channel_presence.dart'; import 'package:spinify/src/model/channel_push.dart'; import 'package:spinify/src/model/event.dart'; @@ -7,13 +8,15 @@ import 'package:spinify/src/model/message.dart'; import 'package:spinify/src/model/publication.dart'; /// Stream of received pushes from Centrifugo server for a channel. -/// {@category Entity} -/// {@subCategory Pushes} -/// {@subCategory Events} +/// {@category Event} +/// {@category Client} +/// {@category Subscription} +/// {@subCategory Push} /// {@subCategory Channel} +@immutable final class SpinifyPushesStream extends StreamView { /// Stream of received events. - SpinifyPushesStream({ + const SpinifyPushesStream({ required Stream pushes, required this.publications, required this.messages, @@ -45,4 +48,7 @@ final class SpinifyPushesStream extends StreamView { _ => null, }, )).asBroadcastStream(); + + @override + String toString() => 'SpinifyPushesStream{}'; } diff --git a/lib/src/model/refresh.dart b/lib/src/model/refresh.dart index 2310b40..32eecfa 100644 --- a/lib/src/model/refresh.dart +++ b/lib/src/model/refresh.dart @@ -1,9 +1,10 @@ -import 'package:meta/meta.dart'; import 'package:spinify/src/model/channel_push.dart'; /// {@template refresh} /// Refresh push from Centrifugo server. /// {@endtemplate} +/// {@category Event} +/// {@subCategory Push} final class SpinifyRefresh extends SpinifyChannelPush { /// {@macro refresh} const SpinifyRefresh({ @@ -21,46 +22,7 @@ final class SpinifyRefresh extends SpinifyChannelPush { /// Time when connection will be expired final DateTime? ttl; -} - -/// {@nodoc} -@internal -@immutable -final class SpinifyRefreshResult { - /// {@nodoc} - const SpinifyRefreshResult({ - required this.expires, - this.client, - this.version, - this.ttl, - }); - - /// Unique client connection ID server issued to this connection - final String? client; - - /// Server version - final String? version; - - /// Whether a server will expire connection at some point - final bool expires; - - /// Time when connection will be expired - final DateTime? ttl; -} -/// {@nodoc} -@internal -@immutable -final class SpinifySubRefreshResult { - /// {@nodoc} - const SpinifySubRefreshResult({ - required this.expires, - this.ttl, - }); - - /// Whether a server will expire subscription at some point - final bool expires; - - /// Time when subscription will be expired - final DateTime? ttl; + @override + String toString() => 'SpinifyRefresh{channel: $channel}'; } diff --git a/lib/src/model/refresh_result.dart b/lib/src/model/refresh_result.dart new file mode 100644 index 0000000..bff42dd --- /dev/null +++ b/lib/src/model/refresh_result.dart @@ -0,0 +1,43 @@ +import 'package:meta/meta.dart'; + +/// {@nodoc} +@internal +@immutable +final class SpinifyRefreshResult { + /// {@nodoc} + const SpinifyRefreshResult({ + required this.expires, + this.client, + this.version, + this.ttl, + }); + + /// Unique client connection ID server issued to this connection + final String? client; + + /// Server version + final String? version; + + /// Whether a server will expire connection at some point + final bool expires; + + /// Time when connection will be expired + final DateTime? ttl; +} + +/// {@nodoc} +@internal +@immutable +final class SpinifySubRefreshResult { + /// {@nodoc} + const SpinifySubRefreshResult({ + required this.expires, + this.ttl, + }); + + /// Whether a server will expire subscription at some point + final bool expires; + + /// Time when subscription will be expired + final DateTime? ttl; +} diff --git a/lib/src/model/stream_position.dart b/lib/src/model/stream_position.dart index f118247..c9047ab 100644 --- a/lib/src/model/stream_position.dart +++ b/lib/src/model/stream_position.dart @@ -1,4 +1,5 @@ import 'package:fixnum/fixnum.dart' as fixnum; /// Stream position. +/// {@category Entity} typedef SpinifyStreamPosition = ({fixnum.Int64 offset, String epoch}); diff --git a/lib/src/model/subscribe.dart b/lib/src/model/subscribe.dart index ddc5362..be8d8eb 100644 --- a/lib/src/model/subscribe.dart +++ b/lib/src/model/subscribe.dart @@ -4,6 +4,8 @@ import 'package:spinify/src/model/stream_position.dart'; /// {@template subscribe} /// Subscribe push from Centrifugo server. /// {@endtemplate} +/// {@category Event} +/// {@subCategory Push} final class SpinifySubscribe extends SpinifyChannelPush { /// {@macro subscribe} const SpinifySubscribe({ @@ -29,4 +31,7 @@ final class SpinifySubscribe extends SpinifyChannelPush { /// Stream position. final SpinifyStreamPosition? streamPosition; + + @override + String toString() => 'SpinifySubscribe{channel: $channel}'; } diff --git a/lib/src/model/unsubscribe.dart b/lib/src/model/unsubscribe.dart index 620634d..786e8cc 100644 --- a/lib/src/model/unsubscribe.dart +++ b/lib/src/model/unsubscribe.dart @@ -3,6 +3,8 @@ import 'package:spinify/src/model/channel_push.dart'; /// {@template unsubscribe} /// Unsubscribe push from Centrifugo server. /// {@endtemplate} +/// {@category Event} +/// {@subCategory Push} final class SpinifyUnsubscribe extends SpinifyChannelPush { /// {@macro unsubscribe} const SpinifyUnsubscribe({ @@ -20,4 +22,7 @@ final class SpinifyUnsubscribe extends SpinifyChannelPush { /// Reason of unsubscribe. final String reason; + + @override + String toString() => 'SpinifyUnsubscribe{channel: $channel}'; } diff --git a/lib/src/subscription/client_subscription_impl.dart b/lib/src/subscription/client_subscription_impl.dart index 61c40f8..07735b0 100644 --- a/lib/src/subscription/client_subscription_impl.dart +++ b/lib/src/subscription/client_subscription_impl.dart @@ -148,6 +148,9 @@ abstract base class SpinifyClientSubscriptionBase )); _stateController.close().ignore(); } + + @override + String toString() => 'SpinifyClientSubscription{channel: $channel}'; } /// Mixin responsible for event receiving and distribution by controllers diff --git a/lib/src/subscription/server_subscription_impl.dart b/lib/src/subscription/server_subscription_impl.dart index 1dd27db..38f11cf 100644 --- a/lib/src/subscription/server_subscription_impl.dart +++ b/lib/src/subscription/server_subscription_impl.dart @@ -139,6 +139,9 @@ abstract base class SpinifyServerSubscriptionBase )); _stateController.close().ignore(); } + + @override + String toString() => 'SpinifyServerSubscription{channel: $channel}'; } /// Mixin responsible for event receiving and distribution by controllers diff --git a/lib/src/subscription/subcibed_on_channel.dart b/lib/src/subscription/subcibed_on_channel.dart index 6ea0464..1ea3e3f 100644 --- a/lib/src/subscription/subcibed_on_channel.dart +++ b/lib/src/subscription/subcibed_on_channel.dart @@ -3,6 +3,9 @@ import 'package:spinify/src/model/publication.dart'; import 'package:spinify/src/model/stream_position.dart'; /// Subscribed on channel message. +/// {@category Subscription} +/// {@category Entity} +/// {@subCategory Channel} @immutable final class SubcibedOnChannel { /// Subscribed on channel message. @@ -48,4 +51,7 @@ final class SubcibedOnChannel { /// Raw data. final List? data; + + @override + String toString() => 'SubcibedOnChannel{channel: $channel}'; } diff --git a/lib/src/subscription/subscription.dart b/lib/src/subscription/subscription.dart index 0693e0d..6a3ca96 100644 --- a/lib/src/subscription/subscription.dart +++ b/lib/src/subscription/subscription.dart @@ -10,10 +10,33 @@ import 'package:spinify/src/subscription/subscription_states_stream.dart'; /// {@template subscription} /// Spinify subscription interface. +/// +/// Client allows subscribing on channels. +/// This can be done by creating Subscription object. +/// +/// ```dart +/// final subscription = client.newSubscription('chat'); +/// await subscription.subscribe(); +/// ``` +/// When anewSubscription method is called Client allocates a new +/// Subscription instance and saves it in the internal subscription registry. +/// Having a registry of allocated subscriptions allows SDK to manage +/// resubscribes upon reconnecting to a server. +/// Centrifugo connectors do not allow creating two subscriptions to the +/// same channel – in this case, newSubscription can throw an exception. +/// +/// Subscription has 3 states: +/// - [SpinifySubscriptionState$Unsubscribed] +/// - [SpinifySubscriptionState$Subscribing] +/// - [SpinifySubscriptionState$Subscribed] +/// +/// When a new Subscription is created it has an unsubscribed state. +/// +/// - For client-side subscriptions see [SpinifyClientSubscription]. +/// - For server-side subscriptions see [SpinifyServerSubscription]. /// {@endtemplate} /// {@category Subscription} -/// {@category Entity} -abstract interface class ISpinifySubscription { +abstract interface class SpinifySubscription { /// Channel name. abstract final String channel; @@ -105,10 +128,9 @@ abstract interface class ISpinifySubscription { /// /// {@endtemplate} /// {@category Subscription} -/// {@category Entity} /// {@subCategory Client-side} abstract interface class SpinifyClientSubscription - implements ISpinifySubscription { + implements SpinifySubscription { /// Start subscribing to a channel Future subscribe(); @@ -117,9 +139,6 @@ abstract interface class SpinifyClientSubscription int code = 0, String reason = 'unsubscribe called', ]); - - @override - String toString() => 'SpinifyClientSubscription{channel: $channel}'; } /// {@template server_subscription} @@ -135,10 +154,6 @@ abstract interface class SpinifyClientSubscription /// but without possibility to control them. /// {@endtemplate} /// {@category Subscription} -/// {@category Entity} /// {@subCategory Server-side} abstract interface class SpinifyServerSubscription - implements ISpinifySubscription { - @override - String toString() => 'SpinifyServerSubscription{channel: $channel}'; -} + implements SpinifySubscription {} diff --git a/lib/src/subscription/subscription_config.dart b/lib/src/subscription/subscription_config.dart index 334a68a..306a503 100644 --- a/lib/src/subscription/subscription_config.dart +++ b/lib/src/subscription/subscription_config.dart @@ -4,16 +4,23 @@ import 'package:fixnum/fixnum.dart' as fixnum; import 'package:meta/meta.dart'; /// Token used for subscription. +/// {@category Subscription} +/// {@category Entity} typedef SpinifySubscriptionToken = String; /// Callback to get token for subscription. /// If method returns null then subscription will be established without token. +/// {@category Subscription} +/// {@category Entity} typedef SpinifySubscriptionTokenCallback = FutureOr Function(); /// Callback to set subscription payload data. /// /// If method returns null then no payload will be sent at subscribe time. + +/// {@category Subscription} +/// {@category Entity} typedef SpinifySubscribePayloadCallback = FutureOr?> Function(); /// {@template subscription_config} @@ -89,4 +96,7 @@ class SpinifySubscriptionConfig { /// Maximum time to wait for the subscription to be established. /// If not specified, the timeout will be 15 seconds. final Duration timeout; + + @override + String toString() => 'SpinifySubscriptionConfig{}'; } diff --git a/lib/src/subscription/subscription_state.dart b/lib/src/subscription/subscription_state.dart index 9aa951a..265ac34 100644 --- a/lib/src/subscription/subscription_state.dart +++ b/lib/src/subscription/subscription_state.dart @@ -67,6 +67,9 @@ final class SpinifySubscriptionState$Unsubscribed since: since, recoverable: recoverable); + @override + String get type => 'unsubscribed'; + /// Unsubscribe code. final int code; @@ -103,7 +106,7 @@ final class SpinifySubscriptionState$Unsubscribed bool operator ==(Object other) => identical(this, other); @override - String toString() => 'unsubscribed'; + String toString() => r'SpinifySubscriptionState$Unsubscribed{}'; } /// Subscribing state @@ -122,6 +125,9 @@ final class SpinifySubscriptionState$Subscribing since: since, recoverable: recoverable); + @override + String get type => 'subscribing'; + @override bool get isUnsubscribed => false; @@ -152,7 +158,7 @@ final class SpinifySubscriptionState$Subscribing bool operator ==(Object other) => identical(this, other); @override - String toString() => 'subscribing'; + String toString() => r'SpinifySubscriptionState$Subscribing{}'; } /// Subscribed state @@ -172,6 +178,9 @@ final class SpinifySubscriptionState$Subscribed extends SpinifySubscriptionState since: since, recoverable: recoverable); + @override + String get type => 'subscribed'; + /// Time to live in seconds. final DateTime? ttl; @@ -205,13 +214,14 @@ final class SpinifySubscriptionState$Subscribed extends SpinifySubscriptionState bool operator ==(Object other) => identical(this, other); @override - String toString() => 'subscribed'; + String toString() => r'SpinifySubscriptionState$Subscribed{}'; } /// {@nodoc} base mixin _$SpinifySubscriptionState on SpinifySubscriptionState {} /// Pattern matching for [SpinifySubscriptionState]. +/// {@category Entity} typedef SpinifySubscriptionStateMatch = R Function(S state); @@ -225,6 +235,9 @@ abstract base class _$SpinifySubscriptionStateBase { required this.recoverable, }); + /// Represents the current state type. + abstract final String type; + /// Timestamp of state change. final DateTime timestamp; diff --git a/lib/src/subscription/subscription_states_stream.dart b/lib/src/subscription/subscription_states_stream.dart index 5997d4e..310e373 100644 --- a/lib/src/subscription/subscription_states_stream.dart +++ b/lib/src/subscription/subscription_states_stream.dart @@ -1,10 +1,12 @@ import 'dart:async'; +import 'package:meta/meta.dart'; import 'package:spinify/src/subscription/subscription_state.dart'; /// Stream of Spinify's [SpinifySubscriptionState] changes. /// {@category Subscription} /// {@category Entity} +@immutable final class SpinifySubscriptionStateStream extends StreamView { /// Stream of Spinify's [SpinifySubscriptionState] changes. @@ -30,4 +32,7 @@ final class SpinifySubscriptionStateStream _ => null, }, )).asBroadcastStream(); + + @override + String toString() => 'SpinifySubscriptionStateStream{}'; } diff --git a/lib/src/transport/transport_interface.dart b/lib/src/transport/transport_interface.dart index 6989379..ba544b7 100644 --- a/lib/src/transport/transport_interface.dart +++ b/lib/src/transport/transport_interface.dart @@ -6,7 +6,7 @@ import 'package:spinify/src/model/event.dart'; import 'package:spinify/src/model/history.dart'; import 'package:spinify/src/model/presence.dart'; import 'package:spinify/src/model/presence_stats.dart'; -import 'package:spinify/src/model/refresh.dart'; +import 'package:spinify/src/model/refresh_result.dart'; import 'package:spinify/src/model/stream_position.dart'; import 'package:spinify/src/subscription/server_subscription_manager.dart'; import 'package:spinify/src/subscription/subcibed_on_channel.dart'; diff --git a/lib/src/transport/ws_protobuf_transport.dart b/lib/src/transport/ws_protobuf_transport.dart index 78315a0..1775768 100644 --- a/lib/src/transport/ws_protobuf_transport.dart +++ b/lib/src/transport/ws_protobuf_transport.dart @@ -20,6 +20,7 @@ import 'package:spinify/src/model/presence_stats.dart'; import 'package:spinify/src/model/protobuf/client.pb.dart' as pb; import 'package:spinify/src/model/publication.dart'; import 'package:spinify/src/model/refresh.dart'; +import 'package:spinify/src/model/refresh_result.dart'; import 'package:spinify/src/model/stream_position.dart'; import 'package:spinify/src/model/subscribe.dart'; import 'package:spinify/src/model/unsubscribe.dart'; diff --git a/lib/src/util/logger.dart b/lib/src/util/logger.dart index 15695cf..1d7e971 100644 --- a/lib/src/util/logger.dart +++ b/lib/src/util/logger.dart @@ -4,7 +4,7 @@ import 'dart:developer' as developer; import 'package:meta/meta.dart'; /// Constants used to debug the Spinify client. -/// --dart-define=dev.plugfox.ws.debug=true +/// --dart-define=dev.plugfox.spinify.debug=true /// {@nodoc} @internal bool get $enableLogging => diff --git a/pubspec.yaml b/pubspec.yaml index ec0cdd5..b84cb4c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ description: > Dart client to communicate with Centrifuge and Centrifugo from Flutter and VM over WebSockets -version: 0.0.1-pre.2 +version: 0.0.1-pre.3 homepage: https://centrifugal.dev From d82894f045124230cb56d880a4433fad2f122873 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 4 Aug 2023 00:27:21 +0400 Subject: [PATCH 02/46] Add metrics --- lib/spinify.dart | 1 + lib/src/client/spinify.dart | 40 +++++++++++- lib/src/client/spinify_interface.dart | 10 ++- lib/src/model/metrics.dart | 67 ++++++++++++++++++++ lib/src/transport/transport_interface.dart | 5 ++ lib/src/transport/ws_protobuf_transport.dart | 3 + 6 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 lib/src/model/metrics.dart diff --git a/lib/spinify.dart b/lib/spinify.dart index b3bab3c..266485f 100644 --- a/lib/spinify.dart +++ b/lib/spinify.dart @@ -15,6 +15,7 @@ export 'package:spinify/src/model/exception.dart'; export 'package:spinify/src/model/history.dart'; export 'package:spinify/src/model/jwt.dart'; export 'package:spinify/src/model/message.dart'; +export 'package:spinify/src/model/metrics.dart'; export 'package:spinify/src/model/presence.dart'; export 'package:spinify/src/model/presence_stats.dart'; export 'package:spinify/src/model/publication.dart'; diff --git a/lib/src/client/spinify.dart b/lib/src/client/spinify.dart index 91307d5..4600143 100644 --- a/lib/src/client/spinify.dart +++ b/lib/src/client/spinify.dart @@ -15,6 +15,7 @@ import 'package:spinify/src/model/event.dart'; import 'package:spinify/src/model/exception.dart'; import 'package:spinify/src/model/history.dart'; import 'package:spinify/src/model/message.dart'; +import 'package:spinify/src/model/metrics.dart'; import 'package:spinify/src/model/presence.dart'; import 'package:spinify/src/model/presence_stats.dart'; import 'package:spinify/src/model/publication.dart'; @@ -65,7 +66,8 @@ final class Spinify extends SpinifyBase SpinifyPresenceMixin, SpinifyHistoryMixin, SpinifyRPCMixin, - SpinifyQueueMixin { + SpinifyQueueMixin, + SpinifyMetricsMixin { /// {@macro spinify} Spinify([SpinifyConfig? config]) : super(config ?? SpinifyConfig.byDefault()); @@ -713,6 +715,42 @@ base mixin SpinifyRPCMixin on SpinifyBase, SpinifyErrorsMixin { } } +/// Responsible for metrics. +/// {@nodoc} +@internal +base mixin SpinifyMetricsMixin on SpinifyBase { + int _connectsTotal = 0, _connectsSuccessful = 0; + + @override + Future connect(String url) async { + _connectsTotal++; + return super.connect(url); + } + + @override + void _onConnected(SpinifyState$Connected state) { + super._onConnected(state); + _connectsSuccessful++; + } + + /// Get metrics of Spinify client. + @override + SpinifyMetrics get metrics { + final timestamp = DateTime.now().toUtc(); + final wsMetrics = _transport.metrics; + return SpinifyMetrics( + timestamp: timestamp, + lastUrl: wsMetrics.lastUrl, + reconnects: (successful: _connectsSuccessful, total: _connectsTotal), + state: state, + receivedCount: wsMetrics.receivedCount, + receivedSize: wsMetrics.receivedSize, + transferredCount: wsMetrics.transferredCount, + transferredSize: wsMetrics.transferredSize, + ); + } +} + /// Mixin responsible for queue. /// SHOULD BE LAST MIXIN. /// {@nodoc} diff --git a/lib/src/client/spinify_interface.dart b/lib/src/client/spinify_interface.dart index 56e40ba..8a6d70a 100644 --- a/lib/src/client/spinify_interface.dart +++ b/lib/src/client/spinify_interface.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'package:spinify/src/client/state.dart'; import 'package:spinify/src/client/states_stream.dart'; import 'package:spinify/src/model/history.dart'; +import 'package:spinify/src/model/metrics.dart'; import 'package:spinify/src/model/presence.dart'; import 'package:spinify/src/model/presence_stats.dart'; import 'package:spinify/src/model/pushes_stream.dart'; @@ -22,7 +23,8 @@ abstract interface class ISpinify ISpinifyClientSubscriptionsManager, ISpinifyPresenceOwner, ISpinifyHistoryOwner, - ISpinifyRemoteProcedureCall { + ISpinifyRemoteProcedureCall, + ISpinifyMetricsOwner { /// Connect to the server. /// [url] is a URL of endpoint. Future connect(String url); @@ -128,3 +130,9 @@ abstract interface class ISpinifyRemoteProcedureCall { /// Send arbitrary RPC and wait for response. Future> rpc(String method, List data); } + +/// Spinify metrics interface. +abstract interface class ISpinifyMetricsOwner { + /// Get metrics of Spinify client. + SpinifyMetrics get metrics; +} diff --git a/lib/src/model/metrics.dart b/lib/src/model/metrics.dart new file mode 100644 index 0000000..9195f6f --- /dev/null +++ b/lib/src/model/metrics.dart @@ -0,0 +1,67 @@ +import 'package:meta/meta.dart'; +import 'package:spinify/src/client/state.dart'; + +/// {@template metrics} +/// Metrics of Spinify client. +/// {@endtemplate} +/// {@category Client} +/// {@category Entity} +@immutable +final class SpinifyMetrics implements Comparable { + /// {@macro metrics} + const SpinifyMetrics({ + required this.timestamp, + required this.state, + required this.transferredSize, + required this.receivedSize, + required this.reconnects, + required this.transferredCount, + required this.receivedCount, + required this.lastUrl, + }); + + /// Timestamp of the metrics. + final DateTime timestamp; + + /// The current state of the client. + final SpinifyState state; + + /// The total number of bytes sent. + final BigInt transferredSize; + + /// The total number of bytes received. + final BigInt receivedSize; + + /// The total number of times the connection has been re-established. + final ({int successful, int total}) reconnects; + + /// The total number of messages sent. + final BigInt transferredCount; + + /// The total number of messages received. + final BigInt receivedCount; + + /// The last URL used to connect. + final String? lastUrl; + + @override + int compareTo(SpinifyMetrics other) => timestamp.compareTo(other.timestamp); + + /// Convert metrics to JSON. + Map toJson() => { + 'timestamp': timestamp, + 'state': state.toJson(), + 'reconnects': { + 'successful': reconnects.successful, + 'total': reconnects.total, + }, + 'transferredSize': transferredSize, + 'receivedSize': receivedSize, + 'transferredCount': transferredCount, + 'receivedCount': receivedCount, + 'lastUrl': lastUrl, + }; + + @override + String toString() => 'SpinifyMetrics{}'; +} diff --git a/lib/src/transport/transport_interface.dart b/lib/src/transport/transport_interface.dart index ba544b7..aed4316 100644 --- a/lib/src/transport/transport_interface.dart +++ b/lib/src/transport/transport_interface.dart @@ -12,6 +12,7 @@ import 'package:spinify/src/subscription/server_subscription_manager.dart'; import 'package:spinify/src/subscription/subcibed_on_channel.dart'; import 'package:spinify/src/subscription/subscription_config.dart'; import 'package:spinify/src/util/notifier.dart'; +import 'package:ws/ws.dart'; /// Class responsible for sending and receiving data from the server. /// {@nodoc} @@ -29,6 +30,10 @@ abstract interface class ISpinifyTransport { /// {@nodoc} abstract final SpinifyListenable events; + /// Get web socket metrics. + /// {@nodoc} + WebSocketMetrics get metrics; + /// Connect to the server. /// [url] is a URL of endpoint. /// [subs] is a list of server-side subscriptions to subscribe on connect. diff --git a/lib/src/transport/ws_protobuf_transport.dart b/lib/src/transport/ws_protobuf_transport.dart index 1775768..0d2860a 100644 --- a/lib/src/transport/ws_protobuf_transport.dart +++ b/lib/src/transport/ws_protobuf_transport.dart @@ -75,6 +75,9 @@ abstract base class SpinifyWSPBTransportBase implements ISpinifyTransport { final SpinifyChangeNotifier events = SpinifyChangeNotifier(); + @override + WebSocketMetrics get metrics => _webSocket.metrics; + /// Init transport, override this method to add custom logic. /// {@nodoc} @protected From fc479ae00a41a27ec7d2f5d2276522a4b6022288 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 4 Aug 2023 00:54:48 +0400 Subject: [PATCH 03/46] Add reconnects & subscriptions metrics --- lib/src/client/spinify.dart | 17 +++++++--- lib/src/client/spinify_interface.dart | 22 +++++++++---- lib/src/model/metrics.dart | 33 +++++++++++++++++++ .../client_subscription_impl.dart | 2 +- .../client_subscription_manager.dart | 23 +++++++++++++ .../server_subscription_impl.dart | 2 +- .../server_subscription_manager.dart | 23 +++++++++++++ lib/src/subscription/subscription.dart | 8 ++--- 8 files changed, 112 insertions(+), 18 deletions(-) diff --git a/lib/src/client/spinify.dart b/lib/src/client/spinify.dart index 4600143..ef8cb41 100644 --- a/lib/src/client/spinify.dart +++ b/lib/src/client/spinify.dart @@ -113,6 +113,15 @@ abstract base class SpinifyBase implements ISpinify { late final ServerSubscriptionManager _serverSubscriptionManager = ServerSubscriptionManager(_transport); + @override + ({ + Map client, + Map server, + }) get subscriptions => ( + client: _clientSubscriptionManager.subscriptions, + server: _serverSubscriptionManager.subscriptions + ); + /// Init spinify client, override this method to add custom logic. /// This method is called in constructor. /// {@nodoc} @@ -527,10 +536,6 @@ base mixin SpinifyClientSubscriptionMixin on SpinifyBase, SpinifyErrorsMixin { return _clientSubscriptionManager.newSubscription(channel, config); } - @override - Map get subscriptions => - _clientSubscriptionManager.subscriptions; - @override SpinifyClientSubscription? getSubscription(String channel) => _clientSubscriptionManager[channel]; @@ -742,6 +747,10 @@ base mixin SpinifyMetricsMixin on SpinifyBase { timestamp: timestamp, lastUrl: wsMetrics.lastUrl, reconnects: (successful: _connectsSuccessful, total: _connectsTotal), + subscriptions: ( + client: _clientSubscriptionManager.count, + server: _serverSubscriptionManager.count, + ), state: state, receivedCount: wsMetrics.receivedCount, receivedSize: wsMetrics.receivedSize, diff --git a/lib/src/client/spinify_interface.dart b/lib/src/client/spinify_interface.dart index 8a6d70a..cec9c6e 100644 --- a/lib/src/client/spinify_interface.dart +++ b/lib/src/client/spinify_interface.dart @@ -20,7 +20,7 @@ abstract interface class ISpinify ISpinifyAsyncMessageSender, ISpinifyPublicationSender, ISpinifyEventReceiver, - ISpinifyClientSubscriptionsManager, + ISpinifySubscriptionsManager, ISpinifyPresenceOwner, ISpinifyHistoryOwner, ISpinifyRemoteProcedureCall, @@ -75,7 +75,7 @@ abstract interface class ISpinifyEventReceiver { } /// Spinify client subscriptions manager interface. -abstract interface class ISpinifyClientSubscriptionsManager { +abstract interface class ISpinifySubscriptionsManager { /// Create new client-side subscription. /// `newSubscription(channel, config)` allocates a new Subscription /// in the registry or throws an exception if the Subscription @@ -93,15 +93,23 @@ abstract interface class ISpinifyClientSubscriptionsManager { /// in the channel. SpinifyClientSubscription? getSubscription(String channel); - /// Remove the [Subscription] from internal registry + /// Remove the [SpinifySubscription] from internal registry /// and unsubscribe from [SpinifyClientSubscription.channel]. Future removeSubscription(SpinifyClientSubscription subscription); - /// Get map wirth all registered client-side subscriptions. + /// Get map wirth all registered client-side & server-side subscriptions. /// Returns all registered subscriptions, - /// so you can iterate over all and do some action if required - /// (for example, you want to unsubscribe/remove all subscriptions). - Map get subscriptions; + /// so you can iterate over all and do some action if required. + /// + /// For example: + /// ```dart + /// final subscription = spinify.subscriptions.client['chat']!; + /// await subscription.unsubscribe(); + /// ``` + ({ + Map client, + Map server, + }) get subscriptions; } /// Spinify presence owner interface. diff --git a/lib/src/model/metrics.dart b/lib/src/model/metrics.dart index 9195f6f..1e6faff 100644 --- a/lib/src/model/metrics.dart +++ b/lib/src/model/metrics.dart @@ -1,6 +1,18 @@ import 'package:meta/meta.dart'; import 'package:spinify/src/client/state.dart'; +/// Subscription count +/// - total +/// - unsubscribed +/// - subscribing +/// - subscribed +typedef SpinifySubscriptionCount = ({ + int total, + int unsubscribed, + int subscribing, + int subscribed +}); + /// {@template metrics} /// Metrics of Spinify client. /// {@endtemplate} @@ -15,6 +27,7 @@ final class SpinifyMetrics implements Comparable { required this.transferredSize, required this.receivedSize, required this.reconnects, + required this.subscriptions, required this.transferredCount, required this.receivedCount, required this.lastUrl, @@ -35,6 +48,12 @@ final class SpinifyMetrics implements Comparable { /// The total number of times the connection has been re-established. final ({int successful, int total}) reconnects; + /// The number of subscriptions. + final ({ + SpinifySubscriptionCount client, + SpinifySubscriptionCount server + }) subscriptions; + /// The total number of messages sent. final BigInt transferredCount; @@ -55,6 +74,20 @@ final class SpinifyMetrics implements Comparable { 'successful': reconnects.successful, 'total': reconnects.total, }, + 'subscriptions': >{ + 'client': { + 'total': subscriptions.client.total, + 'unsubscribed': subscriptions.client.unsubscribed, + 'subscribing': subscriptions.client.subscribing, + 'subscribed': subscriptions.client.subscribed, + }, + 'server': { + 'total': subscriptions.server.total, + 'unsubscribed': subscriptions.server.unsubscribed, + 'subscribing': subscriptions.server.subscribing, + 'subscribed': subscriptions.server.subscribed, + }, + }, 'transferredSize': transferredSize, 'receivedSize': receivedSize, 'transferredCount': transferredCount, diff --git a/lib/src/subscription/client_subscription_impl.dart b/lib/src/subscription/client_subscription_impl.dart index 07735b0..731d1b6 100644 --- a/lib/src/subscription/client_subscription_impl.dart +++ b/lib/src/subscription/client_subscription_impl.dart @@ -51,7 +51,7 @@ final class SpinifyClientSubscriptionImpl extends SpinifyClientSubscriptionBase /// {@nodoc} @internal abstract base class SpinifyClientSubscriptionBase - implements SpinifyClientSubscription { + extends SpinifyClientSubscription { /// {@nodoc} SpinifyClientSubscriptionBase({ required this.channel, diff --git a/lib/src/subscription/client_subscription_manager.dart b/lib/src/subscription/client_subscription_manager.dart index 0968dc5..e809316 100644 --- a/lib/src/subscription/client_subscription_manager.dart +++ b/lib/src/subscription/client_subscription_manager.dart @@ -6,6 +6,7 @@ import 'package:spinify/src/model/exception.dart'; import 'package:spinify/src/subscription/client_subscription_impl.dart'; import 'package:spinify/src/subscription/subscription.dart'; import 'package:spinify/src/subscription/subscription_config.dart'; +import 'package:spinify/src/subscription/subscription_state.dart'; import 'package:spinify/src/transport/transport_interface.dart'; /// Responsible for managing client-side subscriptions. @@ -20,6 +21,28 @@ final class ClientSubscriptionManager { /// {@nodoc} final WeakReference _transportWeakRef; + /// Subscriptions count. + ({int total, int unsubscribed, int subscribing, int subscribed}) get count { + var total = 0, unsubscribed = 0, subscribing = 0, subscribed = 0; + for (final entry in _channelSubscriptions.values) { + total++; + switch (entry.state) { + case SpinifySubscriptionState$Unsubscribed _: + unsubscribed++; + case SpinifySubscriptionState$Subscribing _: + subscribing++; + case SpinifySubscriptionState$Subscribed _: + subscribed++; + } + } + return ( + total: total, + unsubscribed: unsubscribed, + subscribing: subscribing, + subscribed: subscribed, + ); + } + /// Subscriptions registry (channel -> subscription). /// Channel : SpinifyClientSubscription /// {@nodoc} diff --git a/lib/src/subscription/server_subscription_impl.dart b/lib/src/subscription/server_subscription_impl.dart index 38f11cf..555098e 100644 --- a/lib/src/subscription/server_subscription_impl.dart +++ b/lib/src/subscription/server_subscription_impl.dart @@ -48,7 +48,7 @@ final class SpinifyServerSubscriptionImpl extends SpinifyServerSubscriptionBase /// {@nodoc} @internal abstract base class SpinifyServerSubscriptionBase - implements SpinifyServerSubscription { + extends SpinifyServerSubscription { /// {@nodoc} SpinifyServerSubscriptionBase({ required this.channel, diff --git a/lib/src/subscription/server_subscription_manager.dart b/lib/src/subscription/server_subscription_manager.dart index 414bc25..a679981 100644 --- a/lib/src/subscription/server_subscription_manager.dart +++ b/lib/src/subscription/server_subscription_manager.dart @@ -6,6 +6,7 @@ import 'package:spinify/src/model/subscribe.dart'; import 'package:spinify/src/model/unsubscribe.dart'; import 'package:spinify/src/subscription/server_subscription_impl.dart'; import 'package:spinify/src/subscription/subscription.dart'; +import 'package:spinify/src/subscription/subscription_state.dart'; import 'package:spinify/src/transport/transport_interface.dart'; /// Responsible for managing client-side subscriptions. @@ -20,6 +21,28 @@ final class ServerSubscriptionManager { /// {@nodoc} final WeakReference _transportWeakRef; + /// Subscriptions count. + ({int total, int unsubscribed, int subscribing, int subscribed}) get count { + var total = 0, unsubscribed = 0, subscribing = 0, subscribed = 0; + for (final entry in _channelSubscriptions.values) { + total++; + switch (entry.state) { + case SpinifySubscriptionState$Unsubscribed _: + unsubscribed++; + case SpinifySubscriptionState$Subscribing _: + subscribing++; + case SpinifySubscriptionState$Subscribed _: + subscribed++; + } + } + return ( + total: total, + unsubscribed: unsubscribed, + subscribing: subscribing, + subscribed: subscribed, + ); + } + /// Subscriptions registry (channel -> subscription). /// Channel : SpinifyClientSubscription /// {@nodoc} diff --git a/lib/src/subscription/subscription.dart b/lib/src/subscription/subscription.dart index 6a3ca96..deee860 100644 --- a/lib/src/subscription/subscription.dart +++ b/lib/src/subscription/subscription.dart @@ -36,7 +36,7 @@ import 'package:spinify/src/subscription/subscription_states_stream.dart'; /// - For server-side subscriptions see [SpinifyServerSubscription]. /// {@endtemplate} /// {@category Subscription} -abstract interface class SpinifySubscription { +sealed class SpinifySubscription { /// Channel name. abstract final String channel; @@ -129,8 +129,7 @@ abstract interface class SpinifySubscription { /// {@endtemplate} /// {@category Subscription} /// {@subCategory Client-side} -abstract interface class SpinifyClientSubscription - implements SpinifySubscription { +abstract class SpinifyClientSubscription extends SpinifySubscription { /// Start subscribing to a channel Future subscribe(); @@ -155,5 +154,4 @@ abstract interface class SpinifyClientSubscription /// {@endtemplate} /// {@category Subscription} /// {@subCategory Server-side} -abstract interface class SpinifyServerSubscription - implements SpinifySubscription {} +abstract class SpinifyServerSubscription extends SpinifySubscription {} From cfdb468c2500cdac87ff10e27a75da8438d98cea Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 4 Aug 2023 01:34:12 +0400 Subject: [PATCH 04/46] Add metrics --- lib/src/client/spinify.dart | 21 ++++++++- lib/src/model/metrics.dart | 45 +++++++++++++++++++- lib/src/transport/transport_interface.dart | 4 ++ lib/src/transport/ws_protobuf_transport.dart | 16 ++++++- lib/src/util/speed_meter.dart | 38 +++++++++++++++++ 5 files changed, 119 insertions(+), 5 deletions(-) create mode 100644 lib/src/util/speed_meter.dart diff --git a/lib/src/client/spinify.dart b/lib/src/client/spinify.dart index ef8cb41..16c4fb8 100644 --- a/lib/src/client/spinify.dart +++ b/lib/src/client/spinify.dart @@ -723,8 +723,10 @@ base mixin SpinifyRPCMixin on SpinifyBase, SpinifyErrorsMixin { /// Responsible for metrics. /// {@nodoc} @internal -base mixin SpinifyMetricsMixin on SpinifyBase { - int _connectsTotal = 0, _connectsSuccessful = 0; +base mixin SpinifyMetricsMixin on SpinifyBase, SpinifyStateMixin { + int _connectsTotal = 0, _connectsSuccessful = 0, _disconnects = 0; + DateTime? _lastDisconnectTime, _lastConnectTime; + ({int? code, String? reason})? _lastDisconnect; @override Future connect(String url) async { @@ -734,10 +736,19 @@ base mixin SpinifyMetricsMixin on SpinifyBase { @override void _onConnected(SpinifyState$Connected state) { + _lastConnectTime = DateTime.now().toUtc(); super._onConnected(state); _connectsSuccessful++; } + @override + void _onDisconnected(SpinifyState$Disconnected state) { + _lastDisconnectTime = DateTime.now().toUtc(); + super._onDisconnected(state); + _lastDisconnect = (code: state.closeCode, reason: state.closeReason); + _disconnects = 0; + } + /// Get metrics of Spinify client. @override SpinifyMetrics get metrics { @@ -751,11 +762,17 @@ base mixin SpinifyMetricsMixin on SpinifyBase { client: _clientSubscriptionManager.count, server: _serverSubscriptionManager.count, ), + speed: _transport.speed, state: state, receivedCount: wsMetrics.receivedCount, receivedSize: wsMetrics.receivedSize, transferredCount: wsMetrics.transferredCount, transferredSize: wsMetrics.transferredSize, + lastConnectTime: _lastConnectTime, + lastDisconnectTime: _lastDisconnectTime, + disconnects: _disconnects, + lastDisconnect: _lastDisconnect, + isRefreshActive: _refreshTimer?.isActive ?? false, ); } } diff --git a/lib/src/model/metrics.dart b/lib/src/model/metrics.dart index 1e6faff..ffd25db 100644 --- a/lib/src/model/metrics.dart +++ b/lib/src/model/metrics.dart @@ -28,9 +28,15 @@ final class SpinifyMetrics implements Comparable { required this.receivedSize, required this.reconnects, required this.subscriptions, + required this.speed, required this.transferredCount, required this.receivedCount, required this.lastUrl, + required this.lastConnectTime, + required this.lastDisconnectTime, + required this.disconnects, + required this.lastDisconnect, + required this.isRefreshActive, }); /// Timestamp of the metrics. @@ -54,6 +60,12 @@ final class SpinifyMetrics implements Comparable { SpinifySubscriptionCount server }) subscriptions; + /// The speed of the request/response in milliseconds. + /// - min - minimum speed + /// - avg - average speed + /// - max - maximum speed + final ({int min, int avg, int max}) speed; + /// The total number of messages sent. final BigInt transferredCount; @@ -63,12 +75,27 @@ final class SpinifyMetrics implements Comparable { /// The last URL used to connect. final String? lastUrl; + /// The time of the last connect. + final DateTime? lastConnectTime; + + /// The time of the last disconnect. + final DateTime? lastDisconnectTime; + + /// The total number of times the connection has been disconnected. + final int disconnects; + + /// The last disconnect reason. + final ({int? code, String? reason})? lastDisconnect; + + /// Is refresh active. + final bool isRefreshActive; + @override int compareTo(SpinifyMetrics other) => timestamp.compareTo(other.timestamp); /// Convert metrics to JSON. Map toJson() => { - 'timestamp': timestamp, + 'timestamp': timestamp.toIso8601String(), 'state': state.toJson(), 'reconnects': { 'successful': reconnects.successful, @@ -88,11 +115,27 @@ final class SpinifyMetrics implements Comparable { 'subscribed': subscriptions.server.subscribed, }, }, + 'speed': { + 'min': speed.min, + 'avg': speed.avg, + 'max': speed.max, + }, 'transferredSize': transferredSize, 'receivedSize': receivedSize, 'transferredCount': transferredCount, 'receivedCount': receivedCount, 'lastUrl': lastUrl, + 'lastConnectTime': lastConnectTime?.toIso8601String(), + 'lastDisconnectTime': lastDisconnectTime?.toIso8601String(), + 'disconnects': disconnects, + 'lastDisconnect': switch (lastDisconnect) { + (:int? code, :String? reason) => { + 'code': code, + 'reason': reason, + }, + _ => null, + }, + 'isRefreshActive': isRefreshActive, }; @override diff --git a/lib/src/transport/transport_interface.dart b/lib/src/transport/transport_interface.dart index aed4316..7fcb1ce 100644 --- a/lib/src/transport/transport_interface.dart +++ b/lib/src/transport/transport_interface.dart @@ -34,6 +34,10 @@ abstract interface class ISpinifyTransport { /// {@nodoc} WebSocketMetrics get metrics; + /// Message response timeout in milliseconds. + /// {@nodoc} + ({int min, int avg, int max}) get speed; + /// Connect to the server. /// [url] is a URL of endpoint. /// [subs] is a list of server-side subscriptions to subscribe on connect. diff --git a/lib/src/transport/ws_protobuf_transport.dart b/lib/src/transport/ws_protobuf_transport.dart index 0d2860a..47b5474 100644 --- a/lib/src/transport/ws_protobuf_transport.dart +++ b/lib/src/transport/ws_protobuf_transport.dart @@ -33,6 +33,7 @@ import 'package:spinify/src/transport/transport_interface.dart'; import 'package:spinify/src/transport/transport_protobuf_codec.dart'; import 'package:spinify/src/util/logger.dart' as logger; import 'package:spinify/src/util/notifier.dart'; +import 'package:spinify/src/util/speed_meter.dart'; import 'package:ws/ws.dart'; /// {@nodoc} @@ -209,6 +210,10 @@ base mixin SpinifyWSPBSenderMixin static const Converter> _commandEncoder = TransportProtobufEncoder(); + /// Speed meter of the connection. + final SpinifySpeedMeter _speedMeter = SpinifySpeedMeter(15); + ({int min, int avg, int max}) get speed => _speedMeter.speed; + /// Counter for messages. /// {@nodoc} int _messageId = 1; @@ -224,8 +229,15 @@ base mixin SpinifyWSPBSenderMixin final command = _createCommand(request, false); // Send command and wait for response. final future = _awaitReply(command.id); - await _sendCommand(command); - final reply = await future; + final stopwatch = Stopwatch()..start(); + pb.Reply reply; + try { + await _sendCommand(command); + reply = await future; + _speedMeter.add(stopwatch.elapsedMilliseconds); + } finally { + stopwatch.stop(); + } if (reply.hasError()) { throw SpinifyReplyException( replyCode: reply.error.code, diff --git a/lib/src/util/speed_meter.dart b/lib/src/util/speed_meter.dart new file mode 100644 index 0000000..928a3ea --- /dev/null +++ b/lib/src/util/speed_meter.dart @@ -0,0 +1,38 @@ +import 'dart:math' as math; + +import 'package:meta/meta.dart'; + +/// {@nodoc} +@internal +class SpinifySpeedMeter { + /// {@nodoc} + SpinifySpeedMeter(this.size) : _speeds = List.filled(size, 0); + + /// Size of the speed meter + /// {@nodoc} + final int size; + final List _speeds; + int _pointer = 0; + int _count = 0; + + /// Add new speed in ms + /// {@nodoc} + void add(num speed) { + _speeds[_pointer] = speed.toInt(); + _pointer = (_pointer + 1) % size; + if (_count < size) _count++; + } + + /// Get speed in ms + /// {@nodoc} + ({int min, int avg, int max}) get speed { + if (_count == 0) return (min: 0, avg: 0, max: 0); + var sum = _speeds.first, min = sum, max = sum; + for (final value in _speeds) { + min = math.min(min, value); + max = math.max(max, value); + sum += value; + } + return (min: min, avg: sum ~/ _count, max: max); + } +} From 89621631784c7328d5a8289abfbf12179b3a3873 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 4 Aug 2023 01:34:36 +0400 Subject: [PATCH 05/46] Update version --- CHANGELOG.md | 2 +- pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f47a22..4d96ded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,3 @@ -## 0.0.1-pre.3 +## 0.0.1-pre.4 - **ADDED**: Initial release diff --git a/pubspec.yaml b/pubspec.yaml index b84cb4c..ad298ab 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ description: > Dart client to communicate with Centrifuge and Centrifugo from Flutter and VM over WebSockets -version: 0.0.1-pre.3 +version: 0.0.1-pre.4 homepage: https://centrifugal.dev From a24616854d5b0b9989705c96475663afb2a27e6f Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 4 Aug 2023 01:34:55 +0400 Subject: [PATCH 06/46] Add override --- lib/src/transport/ws_protobuf_transport.dart | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/src/transport/ws_protobuf_transport.dart b/lib/src/transport/ws_protobuf_transport.dart index 47b5474..ebb24fc 100644 --- a/lib/src/transport/ws_protobuf_transport.dart +++ b/lib/src/transport/ws_protobuf_transport.dart @@ -212,6 +212,8 @@ base mixin SpinifyWSPBSenderMixin /// Speed meter of the connection. final SpinifySpeedMeter _speedMeter = SpinifySpeedMeter(15); + + @override ({int min, int avg, int max}) get speed => _speedMeter.speed; /// Counter for messages. From 921db9baa27b3ab0442384eb748d154fb266b416 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 4 Aug 2023 01:42:02 +0400 Subject: [PATCH 07/46] Add metrics to readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9e4912f..6d2cb9f 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Connection related features - ✅ Publish data into a channel - ✅ Enqueue methods - ✅ Set observer for hooking events & errors -- ❌ Metrics +- ✅ Metrics - ❌ Optimistic subscriptions - ❌ Run in separate isolate - ❌ JSON transport From 453c432ac53d63775b669d681f13bba58a76a6a1 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 4 Aug 2023 08:34:00 +0400 Subject: [PATCH 08/46] Update metrics --- lib/src/client/spinify.dart | 23 ++++++---- lib/src/model/metrics.dart | 45 ++++++++++---------- lib/src/transport/transport_interface.dart | 9 ++-- lib/src/transport/ws_protobuf_transport.dart | 23 +++++++--- 4 files changed, 62 insertions(+), 38 deletions(-) diff --git a/lib/src/client/spinify.dart b/lib/src/client/spinify.dart index 16c4fb8..c8a5404 100644 --- a/lib/src/client/spinify.dart +++ b/lib/src/client/spinify.dart @@ -727,9 +727,18 @@ base mixin SpinifyMetricsMixin on SpinifyBase, SpinifyStateMixin { int _connectsTotal = 0, _connectsSuccessful = 0, _disconnects = 0; DateTime? _lastDisconnectTime, _lastConnectTime; ({int? code, String? reason})? _lastDisconnect; + String? _lastUrl; + late DateTime _initializedAt; + + @override + void _initSpinify() { + _initializedAt = DateTime.now().toUtc(); + super._initSpinify(); + } @override Future connect(String url) async { + _lastUrl = url; _connectsTotal++; return super.connect(url); } @@ -737,26 +746,26 @@ base mixin SpinifyMetricsMixin on SpinifyBase, SpinifyStateMixin { @override void _onConnected(SpinifyState$Connected state) { _lastConnectTime = DateTime.now().toUtc(); - super._onConnected(state); _connectsSuccessful++; + super._onConnected(state); } @override void _onDisconnected(SpinifyState$Disconnected state) { _lastDisconnectTime = DateTime.now().toUtc(); - super._onDisconnected(state); _lastDisconnect = (code: state.closeCode, reason: state.closeReason); _disconnects = 0; + super._onDisconnected(state); } /// Get metrics of Spinify client. @override SpinifyMetrics get metrics { final timestamp = DateTime.now().toUtc(); - final wsMetrics = _transport.metrics; return SpinifyMetrics( timestamp: timestamp, - lastUrl: wsMetrics.lastUrl, + initializedAt: _initializedAt, + lastUrl: _lastUrl, reconnects: (successful: _connectsSuccessful, total: _connectsTotal), subscriptions: ( client: _clientSubscriptionManager.count, @@ -764,10 +773,8 @@ base mixin SpinifyMetricsMixin on SpinifyBase, SpinifyStateMixin { ), speed: _transport.speed, state: state, - receivedCount: wsMetrics.receivedCount, - receivedSize: wsMetrics.receivedSize, - transferredCount: wsMetrics.transferredCount, - transferredSize: wsMetrics.transferredSize, + received: _transport.received, + transferred: _transport.transferred, lastConnectTime: _lastConnectTime, lastDisconnectTime: _lastDisconnectTime, disconnects: _disconnects, diff --git a/lib/src/model/metrics.dart b/lib/src/model/metrics.dart index ffd25db..c887ffb 100644 --- a/lib/src/model/metrics.dart +++ b/lib/src/model/metrics.dart @@ -23,14 +23,13 @@ final class SpinifyMetrics implements Comparable { /// {@macro metrics} const SpinifyMetrics({ required this.timestamp, + required this.initializedAt, required this.state, - required this.transferredSize, - required this.receivedSize, + required this.transferred, + required this.received, required this.reconnects, required this.subscriptions, required this.speed, - required this.transferredCount, - required this.receivedCount, required this.lastUrl, required this.lastConnectTime, required this.lastDisconnectTime, @@ -42,14 +41,17 @@ final class SpinifyMetrics implements Comparable { /// Timestamp of the metrics. final DateTime timestamp; + /// The time when the client was initialized. + final DateTime initializedAt; + /// The current state of the client. final SpinifyState state; - /// The total number of bytes sent. - final BigInt transferredSize; + /// The total number of messages & size of bytes sent. + final ({BigInt count, BigInt size}) transferred; - /// The total number of bytes received. - final BigInt receivedSize; + /// The total number of messages & size of bytes received. + final ({BigInt count, BigInt size}) received; /// The total number of times the connection has been re-established. final ({int successful, int total}) reconnects; @@ -66,12 +68,6 @@ final class SpinifyMetrics implements Comparable { /// - max - maximum speed final ({int min, int avg, int max}) speed; - /// The total number of messages sent. - final BigInt transferredCount; - - /// The total number of messages received. - final BigInt receivedCount; - /// The last URL used to connect. final String? lastUrl; @@ -96,7 +92,11 @@ final class SpinifyMetrics implements Comparable { /// Convert metrics to JSON. Map toJson() => { 'timestamp': timestamp.toIso8601String(), + 'initializedAt': initializedAt.toIso8601String(), + 'lastConnectTime': lastConnectTime?.toIso8601String(), + 'lastDisconnectTime': lastDisconnectTime?.toIso8601String(), 'state': state.toJson(), + 'lastUrl': lastUrl, 'reconnects': { 'successful': reconnects.successful, 'total': reconnects.total, @@ -120,13 +120,15 @@ final class SpinifyMetrics implements Comparable { 'avg': speed.avg, 'max': speed.max, }, - 'transferredSize': transferredSize, - 'receivedSize': receivedSize, - 'transferredCount': transferredCount, - 'receivedCount': receivedCount, - 'lastUrl': lastUrl, - 'lastConnectTime': lastConnectTime?.toIso8601String(), - 'lastDisconnectTime': lastDisconnectTime?.toIso8601String(), + 'transferred': { + 'count': transferred.count, + 'size': transferred.size, + }, + 'received': { + 'count': received.count, + 'size': received.size, + }, + 'isRefreshActive': isRefreshActive, 'disconnects': disconnects, 'lastDisconnect': switch (lastDisconnect) { (:int? code, :String? reason) => { @@ -135,7 +137,6 @@ final class SpinifyMetrics implements Comparable { }, _ => null, }, - 'isRefreshActive': isRefreshActive, }; @override diff --git a/lib/src/transport/transport_interface.dart b/lib/src/transport/transport_interface.dart index 7fcb1ce..6cd4191 100644 --- a/lib/src/transport/transport_interface.dart +++ b/lib/src/transport/transport_interface.dart @@ -12,7 +12,6 @@ import 'package:spinify/src/subscription/server_subscription_manager.dart'; import 'package:spinify/src/subscription/subcibed_on_channel.dart'; import 'package:spinify/src/subscription/subscription_config.dart'; import 'package:spinify/src/util/notifier.dart'; -import 'package:ws/ws.dart'; /// Class responsible for sending and receiving data from the server. /// {@nodoc} @@ -30,9 +29,13 @@ abstract interface class ISpinifyTransport { /// {@nodoc} abstract final SpinifyListenable events; - /// Get web socket metrics. + /// Received bytes count & size. /// {@nodoc} - WebSocketMetrics get metrics; + ({BigInt count, BigInt size}) get received; + + /// Transferred bytes count & size. + /// {@nodoc} + ({BigInt count, BigInt size}) get transferred; /// Message response timeout in milliseconds. /// {@nodoc} diff --git a/lib/src/transport/ws_protobuf_transport.dart b/lib/src/transport/ws_protobuf_transport.dart index ebb24fc..264f67a 100644 --- a/lib/src/transport/ws_protobuf_transport.dart +++ b/lib/src/transport/ws_protobuf_transport.dart @@ -76,9 +76,6 @@ abstract base class SpinifyWSPBTransportBase implements ISpinifyTransport { final SpinifyChangeNotifier events = SpinifyChangeNotifier(); - @override - WebSocketMetrics get metrics => _webSocket.metrics; - /// Init transport, override this method to add custom logic. /// {@nodoc} @protected @@ -329,10 +326,18 @@ base mixin SpinifyWSPBSenderMixin return cmd; } - Future _sendCommand(pb.Command command) { + BigInt _transferredCount = BigInt.zero; + BigInt _transferredSize = BigInt.zero; + @override + ({BigInt count, BigInt size}) get transferred => + (count: _transferredCount, size: _transferredSize); + + Future _sendCommand(pb.Command command) async { if (!_webSocket.state.readyState.isOpen) throw StateError('Not connected'); final data = _commandEncoder.convert(command); - return _webSocket.add(data); + await _webSocket.add(data); + _transferredCount += BigInt.one; + _transferredSize += BigInt.from(data.length); } } @@ -577,6 +582,12 @@ base mixin SpinifyWSPBHandlerMixin /// {@nodoc} StreamSubscription>? _webSocketMessageSubscription; + BigInt _receivedCount = BigInt.zero; + BigInt _receivedSize = BigInt.zero; + @override + ({BigInt count, BigInt size}) get received => + (count: _receivedCount, size: _receivedSize); + @override Future connect( String url, @@ -596,6 +607,8 @@ base mixin SpinifyWSPBHandlerMixin @pragma('vm:prefer-inline') @pragma('dart2js:tryInline') void _handleWebSocketMessage(List response) { + _receivedCount += BigInt.one; + _receivedSize += BigInt.from(response.length); final replies = _replyDecoder.convert(response); for (final reply in replies) { if (reply.hasId() && reply.id > 0) { From 7f7fd71c2b2f74e16b511e5e71a1e427e4799929 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 4 Aug 2023 08:34:28 +0400 Subject: [PATCH 09/46] Add generics --- lib/src/model/metrics.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/model/metrics.dart b/lib/src/model/metrics.dart index c887ffb..2b3e846 100644 --- a/lib/src/model/metrics.dart +++ b/lib/src/model/metrics.dart @@ -102,13 +102,13 @@ final class SpinifyMetrics implements Comparable { 'total': reconnects.total, }, 'subscriptions': >{ - 'client': { + 'client': { 'total': subscriptions.client.total, 'unsubscribed': subscriptions.client.unsubscribed, 'subscribing': subscriptions.client.subscribing, 'subscribed': subscriptions.client.subscribed, }, - 'server': { + 'server': { 'total': subscriptions.server.total, 'unsubscribed': subscriptions.server.unsubscribed, 'subscribing': subscriptions.server.subscribing, From ad8099e0591dce330aa4154bc38733cf7173d66f Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 4 Aug 2023 08:41:29 +0400 Subject: [PATCH 10/46] Update subscription state --- lib/src/client/state.dart | 2 +- lib/src/subscription/subscription_state.dart | 35 ++++++++++++++++++-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/lib/src/client/state.dart b/lib/src/client/state.dart index ffc0722..8488ae8 100644 --- a/lib/src/client/state.dart +++ b/lib/src/client/state.dart @@ -485,7 +485,7 @@ abstract base class _$SpinifyStateBase { Map toJson() => { 'type': type, - 'timestamp': timestamp.microsecondsSinceEpoch, + 'timestamp': timestamp.toUtc().toIso8601String(), if (url != null) 'url': url, }; } diff --git a/lib/src/subscription/subscription_state.dart b/lib/src/subscription/subscription_state.dart index 265ac34..e04971b 100644 --- a/lib/src/subscription/subscription_state.dart +++ b/lib/src/subscription/subscription_state.dart @@ -50,7 +50,7 @@ sealed class SpinifySubscriptionState extends _$SpinifySubscriptionStateBase { /// Unsubscribed state /// -/// {@nodoc} +/// {@macro subscription_state} /// {@category Subscription} /// {@category Entity} final class SpinifySubscriptionState$Unsubscribed @@ -99,6 +99,13 @@ final class SpinifySubscriptionState$Unsubscribed }) => unsubscribed(this); + @override + Map toJson() => { + ...super.toJson(), + 'code': code, + 'reason': reason, + }; + @override int get hashCode => Object.hash(0, timestamp, since); @@ -110,7 +117,8 @@ final class SpinifySubscriptionState$Unsubscribed } /// Subscribing state -/// {@nodoc} +/// +/// {@macro subscription_state} /// {@category Subscription} /// {@category Entity} final class SpinifySubscriptionState$Subscribing @@ -162,7 +170,8 @@ final class SpinifySubscriptionState$Subscribing } /// Subscribed state -/// {@nodoc} +/// +/// {@macro subscription_state} /// {@category Subscription} /// {@category Entity} final class SpinifySubscriptionState$Subscribed extends SpinifySubscriptionState @@ -207,6 +216,12 @@ final class SpinifySubscriptionState$Subscribed extends SpinifySubscriptionState }) => subscribed(this); + @override + Map toJson() => { + ...super.toJson(), + if (ttl != null) 'ttl': ttl?.toUtc().toIso8601String(), + }; + @override int get hashCode => Object.hash(2, timestamp, since, recoverable, ttl); @@ -299,4 +314,18 @@ abstract base class _$SpinifySubscriptionStateBase { subscribing: subscribing ?? (_) => null, subscribed: subscribed ?? (_) => null, ); + + Map toJson() => { + 'type': type, + 'timestamp': timestamp.toUtc().toIso8601String(), + if (since != null) + 'since': switch (since) { + (:fixnum.Int64 offset, :String epoch) => { + 'offset': offset, + 'epoch': epoch, + }, + _ => null, + }, + 'recoverable': recoverable, + }; } From e65beca39f41ff7c8171e9a6779c5ef046ea35f6 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 4 Aug 2023 08:43:21 +0400 Subject: [PATCH 11/46] Update metrics documentation --- lib/src/client/config.dart | 9 +++++++++ lib/src/model/metrics.dart | 6 +++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/lib/src/client/config.dart b/lib/src/client/config.dart index 0c568a2..b6fa095 100644 --- a/lib/src/client/config.dart +++ b/lib/src/client/config.dart @@ -4,6 +4,9 @@ import 'package:meta/meta.dart'; import 'package:spinify/src/model/pubspec.yaml.g.dart'; /// Token used for authentication +/// +/// {@category Client} +/// {@category Entity} typedef SpinifyToken = String; /// Callback to get/refresh tokens @@ -11,11 +14,17 @@ typedef SpinifyToken = String; /// and for refreshing expired tokens. /// /// If method returns null then connection will be established without token. +/// +/// {@category Client} +/// {@category Entity} typedef SpinifyTokenCallback = FutureOr Function(); /// Callback to get initial connection payload data. /// /// If method returns null then no payload will be sent at connect time. +/// +/// {@category Client} +/// {@category Entity} typedef SpinifyConnectionPayloadCallback = FutureOr?> Function(); /// {@template spinify_config} diff --git a/lib/src/model/metrics.dart b/lib/src/model/metrics.dart index 2b3e846..67f17ee 100644 --- a/lib/src/model/metrics.dart +++ b/lib/src/model/metrics.dart @@ -6,6 +6,9 @@ import 'package:spinify/src/client/state.dart'; /// - unsubscribed /// - subscribing /// - subscribed +/// +/// {@category Metrics} +/// {@category Entity} typedef SpinifySubscriptionCount = ({ int total, int unsubscribed, @@ -16,7 +19,8 @@ typedef SpinifySubscriptionCount = ({ /// {@template metrics} /// Metrics of Spinify client. /// {@endtemplate} -/// {@category Client} +/// +/// {@category Metrics} /// {@category Entity} @immutable final class SpinifyMetrics implements Comparable { From 74c850305e9df62c9d3cc809bf0afcfdbcffeb8f Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 4 Aug 2023 08:46:18 +0400 Subject: [PATCH 12/46] Update codegeneration --- Makefile | 4 +-- lib/src/model/pubspec.yaml.g.dart | 32 +++++++++---------- .../protobuf/client.pb.dart | 0 .../protobuf/client.pbenum.dart | 0 .../protobuf/client.pbjson.dart | 0 .../protobuf/client.pbserver.dart | 0 .../protobuf/client.proto | 0 .../transport/transport_protobuf_codec.dart | 2 +- lib/src/transport/ws_protobuf_transport.dart | 2 +- 9 files changed, 20 insertions(+), 20 deletions(-) rename lib/src/{model => transport}/protobuf/client.pb.dart (100%) rename lib/src/{model => transport}/protobuf/client.pbenum.dart (100%) rename lib/src/{model => transport}/protobuf/client.pbjson.dart (100%) rename lib/src/{model => transport}/protobuf/client.pbserver.dart (100%) rename lib/src/{model => transport}/protobuf/client.proto (100%) diff --git a/Makefile b/Makefile index 8070650..8504dc8 100644 --- a/Makefile +++ b/Makefile @@ -44,6 +44,6 @@ pana: check generate: get @dart pub global activate protoc_plugin - @protoc --proto_path=lib/src/model/protobuf --dart_out=lib/src/model/protobuf lib/src/model/protobuf/client.proto + @protoc --proto_path=lib/src/transport/protobuf --dart_out=lib/src/transport/protobuf lib/src/transport/protobuf/client.proto @dart run build_runner build --delete-conflicting-outputs - @dart format -l 80 lib/src/model/pubspec.yaml.g.dart lib/src/model/protobuf/ \ No newline at end of file + @dart format -l 80 lib/src/model/pubspec.yaml.g.dart lib/src/transport/protobuf/ \ 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 d5ddd88..dde0a2a 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.1', + representation: r'0.0.1-pre.4', /// 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.1', + canonical: r'0.0.1-pre.4', /// 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'1'], + preRelease: [r'pre', r'4'], /// The build identifier: "foo" in "1.2.3+foo". build: [], @@ -124,13 +124,13 @@ sealed class Pubspec { /// Build date and time (UTC) static final DateTime timestamp = DateTime.utc( 2023, - 7, - 16, - 16, - 24, - 19, - 363, - 242, + 8, + 4, + 4, + 45, + 29, + 391, + 533, ); /// Name @@ -150,7 +150,7 @@ sealed class Pubspec { /// 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'centrifuge_dart'; + static const String name = r'spinify'; /// Description /// @@ -193,8 +193,7 @@ sealed class Pubspec { /// 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/centrifuge-client'; + static const String repository = r'https://github.com/PlugFox/spinify'; /// Issue tracker /// @@ -208,7 +207,7 @@ sealed class Pubspec { /// then the pub.dev site uses the default issue tracker /// (https://github.com///issues). static const String issueTracker = - r'https://github.com/PlugFox/centrifuge-client/issues'; + r'https://github.com/PlugFox/spinify/issues'; /// Documentation /// @@ -350,11 +349,11 @@ sealed class Pubspec { /// 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', - r'client', ]; /// Environment @@ -427,10 +426,11 @@ sealed class Pubspec { /// see [Package dependencies](https://dart.dev/tools/pub/dependencies). static const Map dependencies = { 'meta': r'^1.9.1', - 'ws': r'^0.1.2', + 'ws': r'^1.0.0-pre.6', 'protobuf': r'^3.0.0', 'crypto': r'^3.0.3', 'fixnum': r'^1.1.0', + 'stack_trace': r'^1.11.1', }; /// Developer dependencies diff --git a/lib/src/model/protobuf/client.pb.dart b/lib/src/transport/protobuf/client.pb.dart similarity index 100% rename from lib/src/model/protobuf/client.pb.dart rename to lib/src/transport/protobuf/client.pb.dart diff --git a/lib/src/model/protobuf/client.pbenum.dart b/lib/src/transport/protobuf/client.pbenum.dart similarity index 100% rename from lib/src/model/protobuf/client.pbenum.dart rename to lib/src/transport/protobuf/client.pbenum.dart diff --git a/lib/src/model/protobuf/client.pbjson.dart b/lib/src/transport/protobuf/client.pbjson.dart similarity index 100% rename from lib/src/model/protobuf/client.pbjson.dart rename to lib/src/transport/protobuf/client.pbjson.dart diff --git a/lib/src/model/protobuf/client.pbserver.dart b/lib/src/transport/protobuf/client.pbserver.dart similarity index 100% rename from lib/src/model/protobuf/client.pbserver.dart rename to lib/src/transport/protobuf/client.pbserver.dart diff --git a/lib/src/model/protobuf/client.proto b/lib/src/transport/protobuf/client.proto similarity index 100% rename from lib/src/model/protobuf/client.proto rename to lib/src/transport/protobuf/client.proto diff --git a/lib/src/transport/transport_protobuf_codec.dart b/lib/src/transport/transport_protobuf_codec.dart index a1dfd5d..ec51f7c 100644 --- a/lib/src/transport/transport_protobuf_codec.dart +++ b/lib/src/transport/transport_protobuf_codec.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'package:meta/meta.dart'; import 'package:protobuf/protobuf.dart' as pb; -import 'package:spinify/src/model/protobuf/client.pb.dart' as pb; +import 'package:spinify/src/transport/protobuf/client.pb.dart' as pb; import 'package:spinify/src/util/logger.dart' as logger; /// {@nodoc} diff --git a/lib/src/transport/ws_protobuf_transport.dart b/lib/src/transport/ws_protobuf_transport.dart index 264f67a..b329d8c 100644 --- a/lib/src/transport/ws_protobuf_transport.dart +++ b/lib/src/transport/ws_protobuf_transport.dart @@ -17,7 +17,6 @@ import 'package:spinify/src/model/history.dart'; import 'package:spinify/src/model/message.dart'; import 'package:spinify/src/model/presence.dart'; import 'package:spinify/src/model/presence_stats.dart'; -import 'package:spinify/src/model/protobuf/client.pb.dart' as pb; import 'package:spinify/src/model/publication.dart'; import 'package:spinify/src/model/refresh.dart'; import 'package:spinify/src/model/refresh_result.dart'; @@ -29,6 +28,7 @@ import 'package:spinify/src/subscription/subcibed_on_channel.dart'; import 'package:spinify/src/subscription/subscription.dart'; import 'package:spinify/src/subscription/subscription_config.dart'; import 'package:spinify/src/subscription/subscription_state.dart'; +import 'package:spinify/src/transport/protobuf/client.pb.dart' as pb; import 'package:spinify/src/transport/transport_interface.dart'; import 'package:spinify/src/transport/transport_protobuf_codec.dart'; import 'package:spinify/src/util/logger.dart' as logger; From d586ecc0203f20342e3bfbbd1bc93845fd41e400 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 4 Aug 2023 08:46:54 +0400 Subject: [PATCH 13/46] Update contribution guide --- CONTRIBUTING.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 27c36da..1a26720 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,9 +8,9 @@ Windows: $ choco install protoc $ dart pub global activate protoc_plugin $ dart pub get -$ protoc --proto_path=lib/src/model/protobuf --dart_out=lib/src/model/protobuf lib/src/model/protobuf/client.proto +$ protoc --proto_path=lib/src/transport/protobuf --dart_out=lib/src/transport/protobuf lib/src/transport/protobuf/client.proto $ dart run build_runner build --delete-conflicting-outputs -$ dart format -l 80 lib/src/model/pubspec.yaml.g.dart lib/src/model/protobuf/ +$ dart format -l 80 lib/src/model/pubspec.yaml.g.dart lib/src/transport/protobuf/ ``` Linux: @@ -21,9 +21,9 @@ $ sudo apt install -y protobuf-compiler dart $ export PATH="$PATH":"$HOME/.pub-cache/bin" $ dart pub global activate protoc_plugin $ dart pub get -$ protoc --proto_path=lib/src/model/protobuf --dart_out=lib/src/model/protobuf lib/src/model/protobuf/client.proto +$ protoc --proto_path=lib/src/transport/protobuf --dart_out=lib/src/transport/protobuf lib/src/transport/protobuf/client.proto $ dart run build_runner build --delete-conflicting-outputs -$ dart format -l 80 lib/src/model/pubspec.yaml.g.dart lib/src/model/protobuf/ +$ dart format -l 80 lib/src/model/pubspec.yaml.g.dart lib/src/transport/protobuf/ ``` macOS: @@ -34,7 +34,7 @@ $ brew install protobuf dart $ export PATH="$PATH":"$HOME/.pub-cache/bin" $ dart pub global activate protoc_plugin $ dart pub get -$ protoc --proto_path=lib/src/model/protobuf --dart_out=lib/src/model/protobuf lib/src/model/protobuf/client.proto +$ protoc --proto_path=lib/src/transport/protobuf --dart_out=lib/src/transport/protobuf lib/src/transport/protobuf/client.proto $ dart run build_runner build --delete-conflicting-outputs -$ dart format -l 80 lib/src/model/pubspec.yaml.g.dart lib/src/model/protobuf/ +$ dart format -l 80 lib/src/model/pubspec.yaml.g.dart lib/src/transport/protobuf/ ``` From 9fa0dd171d15c98a998ade4926fb5fd1507ac02c Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 4 Aug 2023 08:48:32 +0400 Subject: [PATCH 14/46] Fix linter issues --- lib/src/subscription/client_subscription_impl.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/src/subscription/client_subscription_impl.dart b/lib/src/subscription/client_subscription_impl.dart index 731d1b6..ee750d9 100644 --- a/lib/src/subscription/client_subscription_impl.dart +++ b/lib/src/subscription/client_subscription_impl.dart @@ -139,13 +139,14 @@ abstract base class SpinifyClientSubscriptionBase @internal @mustCallSuper Future close([int code = 0, String reason = 'closed']) async { - if (!_state.isUnsubscribed) + if (!_state.isUnsubscribed) { _setState(SpinifySubscriptionState.unsubscribed( code: code, reason: reason, recoverable: false, since: since, )); + } _stateController.close().ignore(); } @@ -308,8 +309,9 @@ base mixin SpinifyClientSubscriptionSubscribeMixin recoverable: subscribed.recoverable, ttl: subscribed.ttl, )); - if (subscribed.publications.isNotEmpty) + if (subscribed.publications.isNotEmpty) { subscribed.publications.forEach(_handlePublication); + } if (subscribed.expires) _setRefreshTimer(subscribed.ttl); } on SpinifyException catch (error, stackTrace) { unsubscribe(0, 'error while subscribing').ignore(); From 848826c0d4b29cd75328bfdbc36b113a4a48abd7 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 4 Aug 2023 08:49:00 +0400 Subject: [PATCH 15/46] Bump release --- CHANGELOG.md | 2 +- pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d96ded..6ef7ea6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,3 @@ -## 0.0.1-pre.4 +## 0.0.1-pre.5 - **ADDED**: Initial release diff --git a/pubspec.yaml b/pubspec.yaml index ad298ab..97582d2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ description: > Dart client to communicate with Centrifuge and Centrifugo from Flutter and VM over WebSockets -version: 0.0.1-pre.4 +version: 0.0.1-pre.5 homepage: https://centrifugal.dev From e478a3b9bb5ad87079efbc9fbfd868d7542f8558 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 4 Aug 2023 09:14:00 +0400 Subject: [PATCH 16/46] Add example --- CHANGELOG.md | 2 +- analysis_options.yaml | 4 +- example/.fvm/fvm_config.json | 4 + example/.gitignore | 77 ++ example/.metadata | 45 ++ example/README.md | 24 + example/analysis_options.yaml | 100 +++ example/android/.gitignore | 13 + example/android/app/build.gradle | 69 ++ .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 33 + .../spinify/spinifyapp/MainActivity.kt | 6 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/profile/AndroidManifest.xml | 7 + example/android/build.gradle | 31 + example/android/gradle.properties | 3 + .../gradle/wrapper/gradle-wrapper.properties | 5 + example/android/settings.gradle | 20 + example/build.yaml | 11 + example/console/README.md | 131 ---- example/console/bin/main.dart | 88 --- example/console/pubspec.yaml | 48 -- example/ios/.gitignore | 34 + example/ios/Flutter/AppFrameworkInfo.plist | 26 + example/ios/Flutter/Debug.xcconfig | 1 + example/ios/Flutter/Release.xcconfig | 1 + example/ios/Runner.xcodeproj/project.pbxproj | 614 ++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 98 +++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + example/ios/Runner/AppDelegate.swift | 13 + .../AppIcon.appiconset/Contents.json | 122 +++ .../Icon-App-1024x1024@1x.png | Bin 0 -> 10932 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 295 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 450 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 282 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 462 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 704 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 586 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 1674 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 762 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 1226 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 1418 bytes .../LaunchImage.imageset/Contents.json | 23 + .../LaunchImage.imageset/LaunchImage.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/README.md | 5 + .../Runner/Base.lproj/LaunchScreen.storyboard | 37 + example/ios/Runner/Base.lproj/Main.storyboard | 26 + example/ios/Runner/Info.plist | 51 ++ example/ios/Runner/Runner-Bridging-Header.h | 1 + example/ios/RunnerTests/RunnerTests.swift | 12 + example/lib/main.dart | 125 ++++ example/linux/.gitignore | 1 + example/linux/CMakeLists.txt | 139 ++++ example/linux/flutter/CMakeLists.txt | 88 +++ .../flutter/generated_plugin_registrant.cc | 19 + .../flutter/generated_plugin_registrant.h | 15 + example/linux/flutter/generated_plugins.cmake | 25 + example/linux/main.cc | 6 + example/linux/my_application.cc | 104 +++ example/linux/my_application.h | 18 + example/macos/.gitignore | 7 + example/macos/Flutter/Flutter-Debug.xcconfig | 1 + .../macos/Flutter/Flutter-Release.xcconfig | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 14 + .../macos/Runner.xcodeproj/project.pbxproj | 695 ++++++++++++++++++ .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 98 +++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + example/macos/Runner/AppDelegate.swift | 9 + .../AppIcon.appiconset/Contents.json | 68 ++ .../AppIcon.appiconset/app_icon_1024.png | Bin 0 -> 102994 bytes .../AppIcon.appiconset/app_icon_128.png | Bin 0 -> 5680 bytes .../AppIcon.appiconset/app_icon_16.png | Bin 0 -> 520 bytes .../AppIcon.appiconset/app_icon_256.png | Bin 0 -> 14142 bytes .../AppIcon.appiconset/app_icon_32.png | Bin 0 -> 1066 bytes .../AppIcon.appiconset/app_icon_512.png | Bin 0 -> 36406 bytes .../AppIcon.appiconset/app_icon_64.png | Bin 0 -> 2218 bytes example/macos/Runner/Base.lproj/MainMenu.xib | 343 +++++++++ example/macos/Runner/Configs/AppInfo.xcconfig | 14 + example/macos/Runner/Configs/Debug.xcconfig | 2 + example/macos/Runner/Configs/Release.xcconfig | 2 + .../macos/Runner/Configs/Warnings.xcconfig | 13 + .../macos/Runner/DebugProfile.entitlements | 12 + example/macos/Runner/Info.plist | 32 + example/macos/Runner/MainFlutterWindow.swift | 15 + example/macos/Runner/Release.entitlements | 8 + example/macos/RunnerTests/RunnerTests.swift | 12 + example/pubspec.yaml | 96 +++ example/test/widget_test.dart | 30 + example/web/favicon.png | Bin 0 -> 917 bytes example/web/icons/Icon-192.png | Bin 0 -> 5292 bytes example/web/icons/Icon-512.png | Bin 0 -> 8252 bytes example/web/icons/Icon-maskable-192.png | Bin 0 -> 5594 bytes example/web/icons/Icon-maskable-512.png | Bin 0 -> 20998 bytes example/web/index.html | 59 ++ example/web/manifest.json | 35 + example/windows/.gitignore | 17 + example/windows/CMakeLists.txt | 102 +++ example/windows/flutter/CMakeLists.txt | 104 +++ .../flutter/generated_plugin_registrant.cc | 17 + .../flutter/generated_plugin_registrant.h | 15 + .../windows/flutter/generated_plugins.cmake | 25 + example/windows/runner/CMakeLists.txt | 40 + example/windows/runner/Runner.rc | 121 +++ example/windows/runner/flutter_window.cpp | 66 ++ example/windows/runner/flutter_window.h | 33 + example/windows/runner/main.cpp | 43 ++ example/windows/runner/resource.h | 16 + example/windows/runner/resources/app_icon.ico | Bin 0 -> 33772 bytes example/windows/runner/runner.exe.manifest | 20 + example/windows/runner/utils.cpp | 65 ++ example/windows/runner/utils.h | 19 + example/windows/runner/win32_window.cpp | 288 ++++++++ example/windows/runner/win32_window.h | 102 +++ pubspec.yaml | 7 +- 135 files changed, 4855 insertions(+), 272 deletions(-) create mode 100644 example/.fvm/fvm_config.json create mode 100644 example/.gitignore create mode 100644 example/.metadata create mode 100644 example/README.md create mode 100644 example/analysis_options.yaml create mode 100644 example/android/.gitignore create mode 100644 example/android/app/build.gradle create mode 100644 example/android/app/src/debug/AndroidManifest.xml create mode 100644 example/android/app/src/main/AndroidManifest.xml create mode 100644 example/android/app/src/main/kotlin/dev/plugfox/spinify/spinifyapp/MainActivity.kt create mode 100644 example/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 example/android/app/src/main/res/drawable/launch_background.xml create mode 100644 example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 example/android/app/src/main/res/values-night/styles.xml create mode 100644 example/android/app/src/main/res/values/styles.xml create mode 100644 example/android/app/src/profile/AndroidManifest.xml create mode 100644 example/android/build.gradle create mode 100644 example/android/gradle.properties create mode 100644 example/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 example/android/settings.gradle create mode 100644 example/build.yaml delete mode 100644 example/console/README.md delete mode 100644 example/console/bin/main.dart delete mode 100644 example/console/pubspec.yaml create mode 100644 example/ios/.gitignore create mode 100644 example/ios/Flutter/AppFrameworkInfo.plist create mode 100644 example/ios/Flutter/Debug.xcconfig create mode 100644 example/ios/Flutter/Release.xcconfig create mode 100644 example/ios/Runner.xcodeproj/project.pbxproj create mode 100644 example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 example/ios/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 example/ios/Runner/AppDelegate.swift create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png create mode 100644 example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png create mode 100644 example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png create mode 100644 example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png create mode 100644 example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md create mode 100644 example/ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 example/ios/Runner/Base.lproj/Main.storyboard create mode 100644 example/ios/Runner/Info.plist create mode 100644 example/ios/Runner/Runner-Bridging-Header.h create mode 100644 example/ios/RunnerTests/RunnerTests.swift create mode 100644 example/lib/main.dart create mode 100644 example/linux/.gitignore create mode 100644 example/linux/CMakeLists.txt create mode 100644 example/linux/flutter/CMakeLists.txt create mode 100644 example/linux/flutter/generated_plugin_registrant.cc create mode 100644 example/linux/flutter/generated_plugin_registrant.h create mode 100644 example/linux/flutter/generated_plugins.cmake create mode 100644 example/linux/main.cc create mode 100644 example/linux/my_application.cc create mode 100644 example/linux/my_application.h create mode 100644 example/macos/.gitignore create mode 100644 example/macos/Flutter/Flutter-Debug.xcconfig create mode 100644 example/macos/Flutter/Flutter-Release.xcconfig create mode 100644 example/macos/Flutter/GeneratedPluginRegistrant.swift create mode 100644 example/macos/Runner.xcodeproj/project.pbxproj create mode 100644 example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 example/macos/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 example/macos/Runner/AppDelegate.swift create mode 100644 example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png create mode 100644 example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png create mode 100644 example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png create mode 100644 example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png create mode 100644 example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png create mode 100644 example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png create mode 100644 example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png create mode 100644 example/macos/Runner/Base.lproj/MainMenu.xib create mode 100644 example/macos/Runner/Configs/AppInfo.xcconfig create mode 100644 example/macos/Runner/Configs/Debug.xcconfig create mode 100644 example/macos/Runner/Configs/Release.xcconfig create mode 100644 example/macos/Runner/Configs/Warnings.xcconfig create mode 100644 example/macos/Runner/DebugProfile.entitlements create mode 100644 example/macos/Runner/Info.plist create mode 100644 example/macos/Runner/MainFlutterWindow.swift create mode 100644 example/macos/Runner/Release.entitlements create mode 100644 example/macos/RunnerTests/RunnerTests.swift create mode 100644 example/pubspec.yaml create mode 100644 example/test/widget_test.dart create mode 100644 example/web/favicon.png create mode 100644 example/web/icons/Icon-192.png create mode 100644 example/web/icons/Icon-512.png create mode 100644 example/web/icons/Icon-maskable-192.png create mode 100644 example/web/icons/Icon-maskable-512.png create mode 100644 example/web/index.html create mode 100644 example/web/manifest.json create mode 100644 example/windows/.gitignore create mode 100644 example/windows/CMakeLists.txt create mode 100644 example/windows/flutter/CMakeLists.txt create mode 100644 example/windows/flutter/generated_plugin_registrant.cc create mode 100644 example/windows/flutter/generated_plugin_registrant.h create mode 100644 example/windows/flutter/generated_plugins.cmake create mode 100644 example/windows/runner/CMakeLists.txt create mode 100644 example/windows/runner/Runner.rc create mode 100644 example/windows/runner/flutter_window.cpp create mode 100644 example/windows/runner/flutter_window.h create mode 100644 example/windows/runner/main.cpp create mode 100644 example/windows/runner/resource.h create mode 100644 example/windows/runner/resources/app_icon.ico create mode 100644 example/windows/runner/runner.exe.manifest create mode 100644 example/windows/runner/utils.cpp create mode 100644 example/windows/runner/utils.h create mode 100644 example/windows/runner/win32_window.cpp create mode 100644 example/windows/runner/win32_window.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ef7ea6..752444d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,3 @@ -## 0.0.1-pre.5 +## 0.0.1-pre.6 - **ADDED**: Initial release diff --git a/analysis_options.yaml b/analysis_options.yaml index be57b00..b43fc2e 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -6,8 +6,8 @@ analyzer: - "build/**" # Codegen - "lib/**.g.dart" - - "lib/src/model/protobuf/*" - - "lib/src/model/pubspec.yaml.g.dart" + - "lib/src/transport/protobuf/*" + - "lib/src/transport/pubspec.yaml.g.dart" # Tests - "test/**.mocks.dart" - ".test_coverage.dart" diff --git a/example/.fvm/fvm_config.json b/example/.fvm/fvm_config.json new file mode 100644 index 0000000..2038915 --- /dev/null +++ b/example/.fvm/fvm_config.json @@ -0,0 +1,4 @@ +{ + "flutterSdkVersion": "beta", + "flavors": {} +} \ No newline at end of file diff --git a/example/.gitignore b/example/.gitignore new file mode 100644 index 0000000..dce0393 --- /dev/null +++ b/example/.gitignore @@ -0,0 +1,77 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Databases +.sqlite +.db +.lock + +# Logs +log.txt + +# Emulator +emulator/ + +# Temporary files +temp/ +.temp/ + +# Env +.env* + +# Test +coverage/ + +# Flutter Version Manager +.fvm/flutter_sdk + +# Generated files + +# Screenshots +integration_test/screenshots/ + +# Firebase +.firebase/ + diff --git a/example/.metadata b/example/.metadata new file mode 100644 index 0000000..bd61a63 --- /dev/null +++ b/example/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled. + +version: + revision: 74e4b092e5212ebf8292dde2a48d3da960c0920b + channel: beta + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 74e4b092e5212ebf8292dde2a48d3da960c0920b + base_revision: 74e4b092e5212ebf8292dde2a48d3da960c0920b + - platform: android + create_revision: 74e4b092e5212ebf8292dde2a48d3da960c0920b + base_revision: 74e4b092e5212ebf8292dde2a48d3da960c0920b + - platform: ios + create_revision: 74e4b092e5212ebf8292dde2a48d3da960c0920b + base_revision: 74e4b092e5212ebf8292dde2a48d3da960c0920b + - platform: linux + create_revision: 74e4b092e5212ebf8292dde2a48d3da960c0920b + base_revision: 74e4b092e5212ebf8292dde2a48d3da960c0920b + - platform: macos + create_revision: 74e4b092e5212ebf8292dde2a48d3da960c0920b + base_revision: 74e4b092e5212ebf8292dde2a48d3da960c0920b + - platform: web + create_revision: 74e4b092e5212ebf8292dde2a48d3da960c0920b + base_revision: 74e4b092e5212ebf8292dde2a48d3da960c0920b + - platform: windows + create_revision: 74e4b092e5212ebf8292dde2a48d3da960c0920b + base_revision: 74e4b092e5212ebf8292dde2a48d3da960c0920b + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..436f3fd --- /dev/null +++ b/example/README.md @@ -0,0 +1,24 @@ +# spinifyapp + +Spinify App Example + +## Code generation + +```bash +$ make codegen +``` + +## Localization + +```bash +$ code lib/src/common/localization/intl_en.arb +$ make intl +``` + +## Recreating the project + +**! Warning: This will overwrite all files in the current directory.** + +```bash +fvm spawn beta create --overwrite -t app --project-name "spinifyapp" --org "dev.plugfox.spinify" --description "Spinify App Example" --platforms ios,android,windows,linux,macos,web . +``` diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml new file mode 100644 index 0000000..9c84a6e --- /dev/null +++ b/example/analysis_options.yaml @@ -0,0 +1,100 @@ +include: package:flutter_lints/flutter.yaml + +# Analyzer options +analyzer: + # Exclude files from analysis. Must be relative to the root of the package. + exclude: + # Build + - "build/**" + # Tests + - "test/**.mocks.dart" + - ".test_coverage.dart" + - "coverage/**" + # Assets + - "assets/**" + # Generated + - "lib/src/common/localization/generated/**" + - "lib/src/common/constants/pubspec.yaml.g.dart" + - "lib/src/common/model/generated/**" + - "**.g.dart" + - "**.gql.dart" + - "**.freezed.dart" + - "**.config.dart" + - "**.mocks.dart" + - "**.gen.dart" + - "**.pb.dart" + - "**.pbenum.dart" + - "**.pbjson.dart" + # Flutter Version Manager + - ".fvm/**" + # Tools + #- "tool/**" + - "scripts/**" + - ".dart_tool/**" + # Platform + - "ios/**" + - "android/**" + - "web/**" + - "macos/**" + - "windows/**" + - "linux/**" + + + # Enable the following options to enable strong mode. + language: + strict-casts: true + strict-raw-types: true + strict-inference: true + + # Enable the following options to enable new language features. + #enable-experiment: + # - patterns + # - sealed-class + # - records + # - class-modifiers + # - macros + # - const-functions + # - extension-types + # - inference-update-2 + # - inline-class + # - value-class + # - variance + + # Set the following options to true to enable additional analysis. + errors: + # Info + directives_ordering: info + always_declare_return_types: info + + # Warning + unsafe_html: warning + no_logic_in_create_state: warning + empty_catches: warning + close_sinks: warning + + # Error + always_use_package_imports: error + avoid_relative_lib_imports: error + avoid_slow_async_io: error + avoid_types_as_parameter_names: error + cancel_subscriptions: error + valid_regexps: error + always_require_non_null_named_parameters: error + + # Disable rules + +# Lint rules +linter: + rules: + # Public packages + public_member_api_docs: false + lines_longer_than_80_chars: false + + # Enabling rules + always_use_package_imports: true + avoid_relative_lib_imports: true + + # Disable rules + sort_pub_dependencies: false + prefer_relative_imports: false + curly_braces_in_flow_control_structures: false \ No newline at end of file diff --git a/example/android/.gitignore b/example/android/.gitignore new file mode 100644 index 0000000..6f56801 --- /dev/null +++ b/example/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties +**/*.keystore +**/*.jks diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle new file mode 100644 index 0000000..609c4f8 --- /dev/null +++ b/example/android/app/build.gradle @@ -0,0 +1,69 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +android { + namespace "dev.plugfox.spinify.spinifyapp" + compileSdkVersion flutter.compileSdkVersion + ndkVersion flutter.ndkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "dev.plugfox.spinify.spinifyapp" + // You can update the following values to match your application needs. + // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. + minSdkVersion flutter.minSdkVersion + targetSdkVersion flutter.targetSdkVersion + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } +} + +flutter { + source '../..' +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} diff --git a/example/android/app/src/debug/AndroidManifest.xml b/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..712541d --- /dev/null +++ b/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + diff --git a/example/android/app/src/main/kotlin/dev/plugfox/spinify/spinifyapp/MainActivity.kt b/example/android/app/src/main/kotlin/dev/plugfox/spinify/spinifyapp/MainActivity.kt new file mode 100644 index 0000000..3bae9b8 --- /dev/null +++ b/example/android/app/src/main/kotlin/dev/plugfox/spinify/spinifyapp/MainActivity.kt @@ -0,0 +1,6 @@ +package dev.plugfox.spinify.spinifyapp + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() { +} diff --git a/example/android/app/src/main/res/drawable-v21/launch_background.xml b/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/android/app/src/main/res/drawable/launch_background.xml b/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/example/android/app/src/main/res/values-night/styles.xml b/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/android/app/src/main/res/values/styles.xml b/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/android/app/src/profile/AndroidManifest.xml b/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/android/build.gradle b/example/android/build.gradle new file mode 100644 index 0000000..f7eb7f6 --- /dev/null +++ b/example/android/build.gradle @@ -0,0 +1,31 @@ +buildscript { + ext.kotlin_version = '1.7.10' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.3.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/example/android/gradle.properties b/example/android/gradle.properties new file mode 100644 index 0000000..94adc3a --- /dev/null +++ b/example/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..3c472b9 --- /dev/null +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip diff --git a/example/android/settings.gradle b/example/android/settings.gradle new file mode 100644 index 0000000..55c4ca8 --- /dev/null +++ b/example/android/settings.gradle @@ -0,0 +1,20 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + } + settings.ext.flutterSdkPath = flutterSdkPath() + + includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle") + + plugins { + id "dev.flutter.flutter-gradle-plugin" version "1.0.0" apply false + } +} + +include ":app" + +apply from: "${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/example/build.yaml b/example/build.yaml new file mode 100644 index 0000000..785f9a0 --- /dev/null +++ b/example/build.yaml @@ -0,0 +1,11 @@ +targets: + $default: + sources: + - $package$ + - lib/** + - pubspec.yaml + - test/** + builders: + pubspec_generator: + options: + output: lib/src/common/constant/pubspec.yaml.g.dart diff --git a/example/console/README.md b/example/console/README.md deleted file mode 100644 index f90a9df..0000000 --- a/example/console/README.md +++ /dev/null @@ -1,131 +0,0 @@ -# Example console chat app - -## Getting started with Centrifugo - -Before running this example make sure you created `chat` namespace in Centrifugo configuration and allowed publishing into channel. - -1. Call `centrifugo genconfig` to create a basic `config.json` at the first time. - -2. Update the `config.json` file with tokens and namespaces, e.g.: - -```json -{ - "token_hmac_secret_key": "", - "admin_password": "", - "admin_secret": "", - "api_key": "", - "allowed_origins": ["http://localhost:8000"], - "namespaces": [ - { - "name": "chat", - "join_leave": true, - "presence": true, - "allow_publish_for_subscriber": true, - "allow_subscribe_for_client": true - } - ] -} -``` - -3. When you use your own configuration, please re-generate the tokens registered in `example.dart`: - -- Use `centrifugo gentoken --user dart` to generate the user's JWT token. -- Use `centrifugo gensubtoken --user dart --channel chat:index` to generate the user's subscription JWT token. - -4. Run Centrifugo with the admin option, to later send messages to all subscribers: - -```bash -centrifugo --admin -``` - -For testing purposes only, you can also run Centrifugo in insecure client mode, so that the validity of JWT tokens -are not checked: - -```bash -centrifugo --client_insecure --admin -``` - -5. Now check the IP address if your system with `ipconfig` on Windows and `ip adds` on Unix-like systems and change the `serverAddr` variable in `example.dart` accordingly. - -6. When the configuration is correct, you can launch the console app with `dart example.dart`. - -7. When you have started centrifugo with the `--admin` option, you can also open `http://localhost:8000/#/actions` to send a message to your console app with the - following settings: - -- Method: Publish -- Channel: `chat:index` -- Data: `{"message": "hello world", "username": "admin"}` - -Congratulations, you have a running centrifugo system and a Flutter console app that connects to it! - -## Centrifugo with Docker - -### Configurate Centrifugo - -First, you need to create a config file for Centrifugo. You can do this with Docker: - -1. Generate config file: - -Bash: - -```bash -docker run -it --rm --volume ${PWD}:/centrifugo \ - --name centrifugo centrifugo/centrifugo:latest centrifugo genconfig -``` - -PowerShell: - -```powershell -docker run -it --rm --volume ${PWD}:/centrifugo ` - --name centrifugo centrifugo/centrifugo:latest centrifugo genconfig -``` - -2. Generate user token - with `centrifugo gentoken --user dart` to generate the user's JWT token. - `centrifugo gensubtoken --user dart --channel chat:index` to generate the user's subscription JWT token. - -Bash: - -```bash -docker run -it --rm --volume ${PWD}/config.json:/centrifugo/config.json:ro \ - --name centrifugo-cli centrifugo/centrifugo:latest \ - centrifugo gentoken --user dart -``` - -PowerShell: - -```powershell -docker run -it --rm --volume ${PWD}/config.json:/centrifugo/config.json:ro ` - --name centrifugo-cli centrifugo/centrifugo:latest ` - centrifugo gentoken --user dart -``` - -### Run Centrifugo - -You can also run the example with Docker. First, build the image: - -Bash: - -```bash -docker run -d -it --rm --ulimit nofile=65536:65536 -p 8000:8000/tcp \ - --volume ${PWD}/config.json:/centrifugo/config.json:ro \ - --name centrifugo centrifugo/centrifugo:latest centrifugo \ - --client_insecure --admin --admin_insecure --log_level=debug -``` - -PowerShell: - -```powershell -docker run -d -it --rm --ulimit nofile=65536:65536 -p 8000:8000/tcp ` - --volume ${PWD}/config.json:/centrifugo/config.json:ro ` - --name centrifugo centrifugo/centrifugo:latest centrifugo ` - --client_insecure --admin --admin_insecure --log_level=debug -``` - -### Stop Centrifugo - -Bash & PowerShell: - -```bash -docker stop centrifugo -``` diff --git a/example/console/bin/main.dart b/example/console/bin/main.dart deleted file mode 100644 index e0d2e23..0000000 --- a/example/console/bin/main.dart +++ /dev/null @@ -1,88 +0,0 @@ -// ignore_for_file: avoid_print - -import 'dart:async'; -import 'dart:io' as io show exit, Platform; - -import 'package:args/args.dart' show ArgParser; -import 'package:spinify/spinify.dart'; - -const url = 'ws://localhost:8000/connection/websocket?format=protobuf'; - -void main([List? args]) { - final options = _extractOptions(args ?? const []); - runZonedGuarded( - () async { - // Create Spinify client. - final client = Spinify( - SpinifyConfig( - client: ( - name: 'Spinify Console Example', - version: '0.0.1', - ), - getToken: () => - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkYXJ0IiwiZXhwIj' - 'oyMjk0OTE1MTMyLCJpYXQiOjE2OTAxMTUxMzJ9.hIGDXKn-eMdsdj57wn6-4y5p' - 'k0tZcKoJCu0qxuuWSoQ', - ), - ); - - // Connect to centrifuge server using provided URL. - await client.connect(url); - - // Output current client state. - print('Current state after connect: ${client.state}'); - - // State changes. - // Or you can observe specific state changes. - // e.g. `client.states.connected` - client.states.listen((state) => print('State changed to: $state')); - - // TODO(plugfox): Read from stdin and send to channel. - - /* // Close client - Timer( - const Duration(seconds: 240), - () async { - await client.close(); - await Future.delayed(const Duration(seconds: 1)); - io.exit(0); - }, - ); */ - }, - (error, stackTrace) { - print('Critical error: $error'); - io.exit(1); - }, - zoneValues: { - #dev.plugfox.spinify.log: options.verbose, - }, - ); -} - -({String token, bool verbose}) _extractOptions(List args) { - final result = (ArgParser() - ..addOption( - 'token', - abbr: 't', - help: 'Token to use.', - ) - ..addFlag( - 'verbose', - abbr: 'v', - help: 'Verbose mode.', - defaultsTo: false, - )) - .parse(args); - final token = result['token']?.toString() ?? - io.Platform.environment['SPINIFY_JWT_TOKEN']; - if (token == null || token.isEmpty || token.split('.').length != 3) { - print('Please provide a valid JWT token as argument with --token option ' - 'or ' - 'SPINIFY_JWT_TOKEN environment variable.'); - io.exit(1); - } - return ( - token: token, - verbose: result['verbose'] == true, - ); -} diff --git a/example/console/pubspec.yaml b/example/console/pubspec.yaml deleted file mode 100644 index 154314f..0000000 --- a/example/console/pubspec.yaml +++ /dev/null @@ -1,48 +0,0 @@ -name: spinify_example_console - -description: > - Dart client to communicate with Centrifuge and Centrifugo from Flutter and VM - over WebSockets - -version: 0.0.1-pre.1 - -publish_to: 'none' - -homepage: https://centrifugal.dev - -repository: https://github.com/PlugFox/spinify - -issue_tracker: https://github.com/PlugFox/spinify/issues - -funding: - - https://www.buymeacoffee.com/plugfox - - https://www.patreon.com/plugfox - - https://boosty.to/plugfox - -topics: - - spinify - - centrifugo - - centrifuge - - websocket - - cross-platform - -platforms: - android: - ios: - linux: - macos: - web: - windows: - -#screenshots: -# - description: 'Example of using the ws library to connect to a Centrifugo server.' -# path: example.png - -environment: - sdk: '>=3.0.0 <4.0.0' - -dependencies: - spinify: - path: ../../ - - args: ^2.4.2 \ No newline at end of file diff --git a/example/ios/.gitignore b/example/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/example/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..9625e10 --- /dev/null +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 11.0 + + diff --git a/example/ios/Flutter/Debug.xcconfig b/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/example/ios/Flutter/Release.xcconfig b/example/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/example/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..be18bd5 --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,614 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807E294A63A400263BE5 /* Frameworks */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.plugfox.spinify.spinifyapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AE0B7B92F70575B8D7E0D07E /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.plugfox.spinify.spinifyapp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 89B67EB44CE7B6631473024E /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.plugfox.spinify.spinifyapp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 640959BDD8F10B91D80A66BE /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.plugfox.spinify.spinifyapp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 11.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.plugfox.spinify.spinifyapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = dev.plugfox.spinify.spinifyapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..87131a0 --- /dev/null +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..70693e4 --- /dev/null +++ b/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import UIKit +import Flutter + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..dc9ada4725e9b0ddb1deab583e5b5102493aa332 GIT binary patch literal 10932 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 literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..797d452e458972bab9d994556c8305db4c827017 GIT binary patch literal 406 zcmV;H0crk;P))>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed2d933e1120817fe9182483a228007b18ab6ae GIT binary patch literal 450 zcmV;z0X_bSP)iGWQ_5NJQ_~rNh*z)}eT%KUb z`7gNk0#AwF^#0T0?hIa^`~Ck;!}#m+_uT050aTR(J!bU#|IzRL%^UsMS#KsYnTF*!YeDOytlP4VhV?b} z%rz_<=#CPc)tU1MZTq~*2=8~iZ!lSa<{9b@2Jl;?IEV8)=fG217*|@)CCYgFze-x? zIFODUIA>nWKpE+bn~n7;-89sa>#DR>TSlqWk*!2hSN6D~Qb#VqbP~4Fk&m`@1$JGr zXPIdeRE&b2Thd#{MtDK$px*d3-Wx``>!oimf%|A-&-q*6KAH)e$3|6JV%HX{Hig)k suLT-RhftRq8b9;(V=235Wa|I=027H2wCDra;{X5v07*qoM6N<$f;9x^2LJ#7 literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..4cd7b0099ca80c806f8fe495613e8d6c69460d76 GIT binary patch literal 282 zcmV+#0p(^bcu7P-R4C8Q z&e;xxFbF_Vrezo%_kH*OKhshZ6BFpG-Y1e10`QXJKbND7AMQ&cMj60B5TNObaZxYybcN07*qoM6N<$g3m;S%K!iX literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fe730945a01f64a61e2235dbe3f45b08f7729182 GIT binary patch literal 462 zcmV;<0WtoGP)-}iV`2<;=$?g5M=KQbZ{F&YRNy7Nn@%_*5{gvDM0aKI4?ESmw z{NnZg)A0R`+4?NF_RZexyVB&^^ZvN!{I28tr{Vje;QNTz`dG&Jz0~Ek&f2;*Z7>B|cg}xYpxEFY+0YrKLF;^Q+-HreN0P{&i zK~zY`?b7ECf-n?@;d<&orQ*Q7KoR%4|C>{W^h6@&01>0SKS`dn{Q}GT%Qj_{PLZ_& zs`MFI#j-(>?bvdZ!8^xTwlY{qA)T4QLbY@j(!YJ7aXJervHy6HaG_2SB`6CC{He}f zHVw(fJWApwPq!6VY7r1w-Fs)@ox~N+q|w~e;JI~C4Vf^@d>Wvj=fl`^u9x9wd9 zR%3*Q+)t%S!MU_`id^@&Y{y7-r98lZX0?YrHlfmwb?#}^1b{8g&KzmkE(L>Z&)179 zp<)v6Y}pRl100G2FL_t(o!|l{-Q-VMg#&MKg7c{O0 z2wJImOS3Gy*Z2Qifdv~JYOp;v+U)a|nLoc7hNH;I$;lzDt$}rkaFw1mYK5_0Q(Sut zvbEloxON7$+HSOgC9Z8ltuC&0OSF!-mXv5caV>#bc3@hBPX@I$58-z}(ZZE!t-aOG zpjNkbau@>yEzH(5Yj4kZiMH32XI!4~gVXNnjAvRx;Sdg^`>2DpUEwoMhTs_st8pKG z(%SHyHdU&v%f36~uERh!bd`!T2dw;z6PrOTQ7Vt*#9F2uHlUVnb#ev_o^fh}Dzmq} zWtlk35}k=?xj28uO|5>>$yXadTUE@@IPpgH`gJ~Ro4>jd1IF|(+IX>8M4Ps{PNvmI zNj4D+XgN83gPt_Gm}`Ybv{;+&yu-C(Grdiahmo~BjG-l&mWM+{e5M1sm&=xduwgM9 z`8OEh`=F3r`^E{n_;%9weN{cf2%7=VzC@cYj+lg>+3|D|_1C@{hcU(DyQG_BvBWe? zvTv``=%b1zrol#=R`JB)>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..502f463a9bc882b461c96aadf492d1729e49e725 GIT binary patch literal 586 zcmV-Q0=4~#P)+}#`wDE{8-2Mebf5<{{PqV{TgVcv*r8?UZ3{-|G?_}T*&y;@cqf{ z{Q*~+qr%%p!1pS*_Uicl#q9lc(D`!D`LN62sNwq{oYw(Wmhk)k<@f$!$@ng~_5)Ru z0Z)trIA5^j{DIW^c+vT2%lW+2<(RtE2wR;4O@)Tm`Xr*?A(qYoM}7i5Yxw>D(&6ou zxz!_Xr~yNF+waPe00049Nkl*;a!v6h%{rlvIH#gW3s8p;bFr=l}mRqpW2h zw=OA%hdyL~z+UHOzl0eKhEr$YYOL-c-%Y<)=j?(bzDweB7{b+%_ypvm_cG{SvM=DK zhv{K@m>#Bw>2W$eUI#iU)Wdgs8Y3U+A$Gd&{+j)d)BmGKx+43U_!tik_YlN)>$7G! zhkE!s;%oku3;IwG3U^2kw?z+HM)jB{@zFhK8P#KMSytSthr+4!c(5c%+^UBn`0X*2 zy3(k600_CSZj?O$Qu%&$;|TGUJrptR(HzyIx>5E(2r{eA(<6t3e3I0B)7d6s7?Z5J zZ!rtKvA{MiEBm&KFtoifx>5P^Z=vl)95XJn()aS5%ad(s?4-=Tkis9IGu{`Fy8r+H07*qoM6N<$f20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0ec303439225b78712f49115768196d8d76f6790 GIT binary patch literal 862 zcmV-k1EKthP)20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..e9f5fea27c705180eb716271f41b582e76dcbd90 GIT binary patch literal 1674 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 literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0467bf12aa4d28f374bb26596605a46dcbb3e7c8 GIT binary patch literal 1418 zcmV;51$Fv~P)q zKfU)WzW*n(@|xWGCA9ScMt*e9`2kdxPQ&&>|-UCa7_51w+ zLUsW@ZzZSW0y$)Hp~e9%PvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq z^={4hPQv)y=I|4n+?>7Fim=dxt1 z2H+Dm+1+fh+IF>G0SjJMkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJT zkdTm&kdTm&kdTm&kdP`esgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>Tmf` zm1{eLgw!b{bXkjWbF%dTkTZEJWyWOb##Lfw4EK2}<0d6%>AGS{po>WCOy&f$Tay_> z?NBlkpo@s-O;0V%Y_Xa-G#_O08q5LR*~F%&)}{}r&L%Sbs8AS4t7Y0NEx*{soY=0MZExqA5XHQkqi#4gW3 zqODM^iyZl;dvf)-bOXtOru(s)Uc7~BFx{w-FK;2{`VA?(g&@3z&bfLFyctOH!cVsF z7IL=fo-qBndRUm;kAdXR4e6>k-z|21AaN%ubeVrHl*<|s&Ax@W-t?LR(P-24A5=>a z*R9#QvjzF8n%@1Nw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE z>Gkj4Sq&2e+-G%|+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62( zdqY93Zy}v&c4n($Vv!UybR8ocs7#zbfX1IY-*w~)p}XyZ-SFC~4w>BvMVr`dFbelV{lLL0bx7@*ZZdebr3`sP;? zVImji)kG)(6Juv0lz@q`F!k1FE;CQ(D0iG$wchPbKZQELlsZ#~rt8#90Y_Xh&3U-< z{s<&cCV_1`^TD^ia9!*mQDq& zn2{r`j};V|uV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvw zvtqy3iz0WSpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqv zo}Piadq!j76}004RBio<`)70k^`K1NK)q>w?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF z34$0Z;QO!JOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUC YUoZo%k(ykuW&i*H07*qoM6N<$f+CH{y8r+H literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner/Base.lproj/Main.storyboard b/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist new file mode 100644 index 0000000..c1c8f38 --- /dev/null +++ b/example/ios/Runner/Info.plist @@ -0,0 +1,51 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Spinify App + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + spinifyapp + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/example/ios/Runner/Runner-Bridging-Header.h b/example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/example/ios/RunnerTests/RunnerTests.swift b/example/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/example/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/example/lib/main.dart b/example/lib/main.dart new file mode 100644 index 0000000..dda5554 --- /dev/null +++ b/example/lib/main.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + // This widget is the root of your application. + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + // This is the theme of your application. + // + // TRY THIS: Try running your application with "flutter run". You'll see + // the application has a blue toolbar. Then, without quitting the app, + // try changing the seedColor in the colorScheme below to Colors.green + // and then invoke "hot reload" (save your changes or press the "hot + // reload" button in a Flutter-supported IDE, or press "r" if you used + // the command line to start the app). + // + // Notice that the counter didn't reset back to zero; the application + // state is not lost during the reload. To reset the state, use hot + // restart instead. + // + // This works for code too, not just values: Most code changes can be + // tested with just a hot reload. + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + useMaterial3: true, + ), + home: const MyHomePage(title: 'Flutter Demo Home Page'), + ); + } +} + +class MyHomePage extends StatefulWidget { + const MyHomePage({super.key, required this.title}); + + // This widget is the home page of your application. It is stateful, meaning + // that it has a State object (defined below) that contains fields that affect + // how it looks. + + // This class is the configuration for the state. It holds the values (in this + // case the title) provided by the parent (in this case the App widget) and + // used by the build method of the State. Fields in a Widget subclass are + // always marked "final". + + final String title; + + @override + State createState() => _MyHomePageState(); +} + +class _MyHomePageState extends State { + int _counter = 0; + + void _incrementCounter() { + setState(() { + // This call to setState tells the Flutter framework that something has + // changed in this State, which causes it to rerun the build method below + // so that the display can reflect the updated values. If we changed + // _counter without calling setState(), then the build method would not be + // called again, and so nothing would appear to happen. + _counter++; + }); + } + + @override + Widget build(BuildContext context) { + // This method is rerun every time setState is called, for instance as done + // by the _incrementCounter method above. + // + // The Flutter framework has been optimized to make rerunning build methods + // fast, so that you can just rebuild anything that needs updating rather + // than having to individually change instances of widgets. + return Scaffold( + appBar: AppBar( + // TRY THIS: Try changing the color here to a specific color (to + // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar + // change color while the other colors stay the same. + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + // Here we take the value from the MyHomePage object that was created by + // the App.build method, and use it to set our appbar title. + title: Text(widget.title), + ), + body: Center( + // Center is a layout widget. It takes a single child and positions it + // in the middle of the parent. + child: Column( + // Column is also a layout widget. It takes a list of children and + // arranges them vertically. By default, it sizes itself to fit its + // children horizontally, and tries to be as tall as its parent. + // + // Column has various properties to control how it sizes itself and + // how it positions its children. Here we use mainAxisAlignment to + // center the children vertically; the main axis here is the vertical + // axis because Columns are vertical (the cross axis would be + // horizontal). + // + // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" + // action in the IDE, or press "p" in the console), to see the + // wireframe for each widget. + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'You have pushed the button this many times:', + ), + Text( + '$_counter', + style: Theme.of(context).textTheme.headlineMedium, + ), + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: _incrementCounter, + tooltip: 'Increment', + child: const Icon(Icons.add), + ), // This trailing comma makes auto-formatting nicer for build methods. + ); + } +} diff --git a/example/linux/.gitignore b/example/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/example/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/example/linux/CMakeLists.txt b/example/linux/CMakeLists.txt new file mode 100644 index 0000000..ac24608 --- /dev/null +++ b/example/linux/CMakeLists.txt @@ -0,0 +1,139 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.10) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "spinifyapp") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "dev.plugfox.spinify.spinifyapp") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Define the application target. To change its name, change BINARY_NAME above, +# not the value here, or `flutter run` will no longer work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/example/linux/flutter/CMakeLists.txt b/example/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/example/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/example/linux/flutter/generated_plugin_registrant.cc b/example/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..b7a6d08 --- /dev/null +++ b/example/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,19 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) screen_retriever_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverPlugin"); + screen_retriever_plugin_register_with_registrar(screen_retriever_registrar); + g_autoptr(FlPluginRegistrar) window_manager_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); + window_manager_plugin_register_with_registrar(window_manager_registrar); +} diff --git a/example/linux/flutter/generated_plugin_registrant.h b/example/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/example/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/example/linux/flutter/generated_plugins.cmake b/example/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..913ac71 --- /dev/null +++ b/example/linux/flutter/generated_plugins.cmake @@ -0,0 +1,25 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + screen_retriever + window_manager +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/example/linux/main.cc b/example/linux/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/example/linux/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/example/linux/my_application.cc b/example/linux/my_application.cc new file mode 100644 index 0000000..1283c30 --- /dev/null +++ b/example/linux/my_application.cc @@ -0,0 +1,104 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "spinifyapp"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "spinifyapp"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/example/linux/my_application.h b/example/linux/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/example/linux/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/example/macos/.gitignore b/example/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/example/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/example/macos/Flutter/Flutter-Debug.xcconfig b/example/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/example/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/example/macos/Flutter/Flutter-Release.xcconfig b/example/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..c2efd0b --- /dev/null +++ b/example/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..b622947 --- /dev/null +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import screen_retriever +import window_manager + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + ScreenRetrieverPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverPlugin")) + WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) +} diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..b26d7a9 --- /dev/null +++ b/example/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,695 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* spinifyapp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "spinifyapp.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* spinifyapp.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* spinifyapp.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1430; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.plugfox.spinify.spinifyapp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/spinifyapp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/spinifyapp"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.plugfox.spinify.spinifyapp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/spinifyapp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/spinifyapp"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.plugfox.spinify.spinifyapp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/spinifyapp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/spinifyapp"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c30f04d --- /dev/null +++ b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/example/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/example/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/example/macos/Runner/AppDelegate.swift b/example/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..d53ef64 --- /dev/null +++ b/example/macos/Runner/AppDelegate.swift @@ -0,0 +1,9 @@ +import Cocoa +import FlutterMacOS + +@NSApplicationMain +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } +} diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000000000000000000000000000000000000..82b6f9d9a33e198f5747104729e1fcef999772a5 GIT binary patch literal 102994 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ⅅ8MglG&ER{i5lN315cO?EdHNLrg? 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 z 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 Pe7HEdAEg|=c=LY&YVNkY literal 0 HcmV?d00001 diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000000000000000000000000000000000000..13b35eba55c6dabc3aac36f33d859266c18fa0d0 GIT binary patch literal 5680 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 literal 0 HcmV?d00001 diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000000000000000000000000000000000000..0a3f5fa40fb3d1e0710331a48de5d256da3f275d GIT binary patch literal 520 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 literal 0 HcmV?d00001 diff --git a/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000000000000000000000000000000000000..2f1632cfddf3d9dade342351e627a0a75609fb46 GIT binary patch literal 2218 zcmV;b2vzrqP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuE6iGxuRCodHTWf3-RTMruyW6Fu zQYeUM04eX6D5c0FCjKKPrco1(K`<0SL=crI{PC3-^hZU0kQie$gh-5!7z6SH6Q0J% zqot*`H1q{R5fHFYS}dje@;kG=v$L0(yY0?wY2%*c?A&{2?!D*x?m71{of2gv!$5|C z3>qG_BW}7K_yUcT3A5C6QD<+{aq?x;MAUyAiJn#Jv8_zZtQ{P zTRzbL3U9!qVuZzS$xKU10KiW~Bgdcv1-!uAhQxf3a7q+dU6lj?yoO4Lq4TUN4}h{N z*fIM=SS8|C2$(T>w$`t@3Tka!(r!7W`x z-isCVgQD^mG-MJ;XtJuK3V{Vy72GQ83KRWsHU?e*wrhKk=ApIYeDqLi;JI1e zuvv}5^Dc=k7F7?nm3nIw$NVmU-+R>> zyqOR$-2SDpJ}Pt;^RkJytDVXNTsu|mI1`~G7yw`EJR?VkGfNdqK9^^8P`JdtTV&tX4CNcV4 z&N06nZa??Fw1AgQOUSE2AmPE@WO(Fvo`%m`cDgiv(fAeRA%3AGXUbsGw{7Q`cY;1BI#ac3iN$$Hw z0LT0;xc%=q)me?Y*$xI@GRAw?+}>=9D+KTk??-HJ4=A>`V&vKFS75@MKdSF1JTq{S zc1!^8?YA|t+uKigaq!sT;Z!&0F2=k7F0PIU;F$leJLaw2UI6FL^w}OG&!;+b%ya1c z1n+6-inU<0VM-Y_s5iTElq)ThyF?StVcebpGI znw#+zLx2@ah{$_2jn+@}(zJZ{+}_N9BM;z)0yr|gF-4=Iyu@hI*Lk=-A8f#bAzc9f z`Kd6K--x@t04swJVC3JK1cHY-Hq+=|PN-VO;?^_C#;coU6TDP7Bt`;{JTG;!+jj(` zw5cLQ-(Cz-Tlb`A^w7|R56Ce;Wmr0)$KWOUZ6ai0PhzPeHwdl0H(etP zUV`va_i0s-4#DkNM8lUlqI7>YQLf)(lz9Q3Uw`)nc(z3{m5ZE77Ul$V%m)E}3&8L0 z-XaU|eB~Is08eORPk;=<>!1w)Kf}FOVS2l&9~A+@R#koFJ$Czd%Y(ENTV&A~U(IPI z;UY+gf+&6ioZ=roly<0Yst8ck>(M=S?B-ys3mLdM&)ex!hbt+ol|T6CTS+Sc0jv(& z7ijdvFwBq;0a{%3GGwkDKTeG`b+lyj0jjS1OMkYnepCdoosNY`*zmBIo*981BU%%U z@~$z0V`OVtIbEx5pa|Tct|Lg#ZQf5OYMUMRD>Wdxm5SAqV2}3!ceE-M2 z@O~lQ0OiKQp}o9I;?uxCgYVV?FH|?Riri*U$Zi_`V2eiA>l zdSm6;SEm6#T+SpcE8Ro_f2AwxzI z44hfe^WE3!h@W3RDyA_H440cpmYkv*)6m1XazTqw%=E5Xv7^@^^T7Q2wxr+Z2kVYr + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/example/macos/Runner/Configs/AppInfo.xcconfig b/example/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..67ee153 --- /dev/null +++ b/example/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = spinifyapp + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = dev.plugfox.spinify.spinifyapp + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2023 dev.plugfox.spinify. All rights reserved. diff --git a/example/macos/Runner/Configs/Debug.xcconfig b/example/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/example/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/example/macos/Runner/Configs/Release.xcconfig b/example/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/example/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/example/macos/Runner/Configs/Warnings.xcconfig b/example/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/example/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/example/macos/Runner/DebugProfile.entitlements b/example/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/example/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/example/macos/Runner/Info.plist b/example/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/example/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/example/macos/Runner/MainFlutterWindow.swift b/example/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/example/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/example/macos/Runner/Release.entitlements b/example/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/example/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/example/macos/RunnerTests/RunnerTests.swift b/example/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..5418c9f --- /dev/null +++ b/example/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import FlutterMacOS +import Cocoa +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml new file mode 100644 index 0000000..c47971a --- /dev/null +++ b/example/pubspec.yaml @@ -0,0 +1,96 @@ +name: spinifyapp + +description: Spinify App Example + +publish_to: 'none' + +version: 1.0.0+1 + +homepage: https://centrifugal.dev + +repository: https://github.com/PlugFox/spinify + +issue_tracker: https://github.com/PlugFox/spinify/issues + +funding: + - https://www.buymeacoffee.com/plugfox + - https://www.patreon.com/plugfox + - https://boosty.to/plugfox + +topics: + - spinify + - centrifugo + - centrifuge + - websocket + - cross-platform + +platforms: + android: + ios: + linux: + macos: + web: + windows: + + +environment: + sdk: '>=3.1.0-63.1.beta <4.0.0' + flutter: '>=3.10.1' + + +dependencies: + # Flutter SDK + flutter: + sdk: flutter + + # Localization + flutter_localizations: + sdk: flutter + intl: any + + # Utils + collection: any + async: any + meta: any + path: any + platform_info: ^4.0.2 + win32: ^5.0.6 + + # Desktop + window_manager: ^0.3.5 + + # Logger + l: ^4.0.2 + + # Transport + spinify: + path: ../ + + # UI + cupertino_icons: ^1.0.2 + + +dev_dependencies: + # Unit & Widget tests for Flutter + flutter_test: + sdk: flutter + # Integration tests for Flutter + integration_test: + sdk: flutter + + #mockito: ^5.4.2 + + # Codegen + build_runner: ^2.4.6 + #flutter_launcher_icons: ^0.13.1 + #flutter_native_splash: ^2.3.1 + # build_verify: ^3.1.0 + pubspec_generator: '>=4.0.0 <5.0.0' + #flutter_gen_runner: ^5.3.1 + + # Linting + flutter_lints: ^2.0.1 + + +flutter: + uses-material-design: true \ No newline at end of file diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart new file mode 100644 index 0000000..4959bf2 --- /dev/null +++ b/example/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:spinifyapp/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/example/web/favicon.png b/example/web/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8aaa46ac1ae21512746f852a42ba87e4165dfdd1 GIT binary patch literal 917 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`jKx9jP7LeL$-D$|I14-?iy0X7 zltGxWVyS%@P(fs7NJL45ua8x7ey(0(N`6wRUPW#JP&EUCO@$SZnVVXYs8ErclUHn2 zVXFjIVFhG^g!Ppaz)DK8ZIvQ?0~DO|i&7O#^-S~(l1AfjnEK zjFOT9D}DX)@^Za$W4-*MbbUihOG|wNBYh(yU7!lx;>x^|#0uTKVr7USFmqf|i<65o z3raHc^AtelCMM;Vme?vOfh>Xph&xL%(-1c06+^uR^q@XSM&D4+Kp$>4P^%3{)XKjo zGZknv$b36P8?Z_gF{nK@`XI}Z90TzwSQO}0J1!f2c(B=V`5aP@1P1a|PZ!4!3&Gl8 zTYqUsf!gYFyJnXpu0!n&N*SYAX-%d(5gVjrHJWqXQshj@!Zm{!01WsQrH~9=kTxW#6SvuapgMqt>$=j#%eyGrQzr zP{L-3gsMA^$I1&gsBAEL+vxi1*Igl=8#8`5?A-T5=z-sk46WA1IUT)AIZHx1rdUrf zVJrJn<74DDw`j)Ki#gt}mIT-Q`XRa2-jQXQoI%w`nb|XblvzK${ZzlV)m-XcwC(od z71_OEC5Bt9GEXosOXaPTYOia#R4ID2TiU~`zVMl08TV_C%DnU4^+HE>9(CE4D6?Fz oujB08i7adh9xk7*FX66dWH6F5TM;?E2b5PlUHx3vIVCg!0Dx9vYXATM literal 0 HcmV?d00001 diff --git a/example/web/icons/Icon-192.png b/example/web/icons/Icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..b749bfef07473333cf1dd31e9eed89862a5d52aa GIT binary patch literal 5292 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 literal 0 HcmV?d00001 diff --git a/example/web/icons/Icon-512.png b/example/web/icons/Icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..88cfd48dff1169879ba46840804b412fe02fefd6 GIT binary patch literal 8252 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 literal 0 HcmV?d00001 diff --git a/example/web/icons/Icon-maskable-192.png b/example/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000000000000000000000000000000000000..eb9b4d76e525556d5d89141648c724331630325d GIT binary patch literal 5594 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 X>c!)3PgKfL&lX^$Z>Cpu&6)6jvi^Z! literal 0 HcmV?d00001 diff --git a/example/web/icons/Icon-maskable-512.png b/example/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d69c56691fbdb0b7efa65097c7cc1edac12a6d3e GIT binary patch literal 20998 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 MPgg&ebxsLQ025IeI{*Lx literal 0 HcmV?d00001 diff --git a/example/web/index.html b/example/web/index.html new file mode 100644 index 0000000..4fb3004 --- /dev/null +++ b/example/web/index.html @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + Spinify + + + + + + + + + + diff --git a/example/web/manifest.json b/example/web/manifest.json new file mode 100644 index 0000000..2716d59 --- /dev/null +++ b/example/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "Spinify", + "short_name": "Spinify", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "Spinify App Example", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/example/windows/.gitignore b/example/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/example/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/example/windows/CMakeLists.txt b/example/windows/CMakeLists.txt new file mode 100644 index 0000000..0970965 --- /dev/null +++ b/example/windows/CMakeLists.txt @@ -0,0 +1,102 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(spinifyapp LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "spinifyapp") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/example/windows/flutter/CMakeLists.txt b/example/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..930d207 --- /dev/null +++ b/example/windows/flutter/CMakeLists.txt @@ -0,0 +1,104 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + windows-x64 $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/example/windows/flutter/generated_plugin_registrant.cc b/example/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..d6b86fa --- /dev/null +++ b/example/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,17 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + ScreenRetrieverPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ScreenRetrieverPlugin")); + WindowManagerPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowManagerPlugin")); +} diff --git a/example/windows/flutter/generated_plugin_registrant.h b/example/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/example/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/example/windows/flutter/generated_plugins.cmake b/example/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..bfa52f4 --- /dev/null +++ b/example/windows/flutter/generated_plugins.cmake @@ -0,0 +1,25 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + screen_retriever + window_manager +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/example/windows/runner/CMakeLists.txt b/example/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/example/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/example/windows/runner/Runner.rc b/example/windows/runner/Runner.rc new file mode 100644 index 0000000..b13385c --- /dev/null +++ b/example/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "dev.plugfox.spinify" "\0" + VALUE "FileDescription", "spinifyapp" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "spinifyapp" "\0" + VALUE "LegalCopyright", "Copyright (C) 2023 dev.plugfox.spinify. All rights reserved." "\0" + VALUE "OriginalFilename", "spinifyapp.exe" "\0" + VALUE "ProductName", "spinifyapp" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/example/windows/runner/flutter_window.cpp b/example/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..b25e363 --- /dev/null +++ b/example/windows/runner/flutter_window.cpp @@ -0,0 +1,66 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/example/windows/runner/flutter_window.h b/example/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/example/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/example/windows/runner/main.cpp b/example/windows/runner/main.cpp new file mode 100644 index 0000000..b9101f9 --- /dev/null +++ b/example/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"spinifyapp", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/example/windows/runner/resource.h b/example/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/example/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/example/windows/runner/resources/app_icon.ico b/example/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..c04e20caf6370ebb9253ad831cc31de4a9c965f6 GIT binary patch literal 33772 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 literal 0 HcmV?d00001 diff --git a/example/windows/runner/runner.exe.manifest b/example/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..a42ea76 --- /dev/null +++ b/example/windows/runner/runner.exe.manifest @@ -0,0 +1,20 @@ + + + + + PerMonitorV2 + + + + + + + + + + + + + + + diff --git a/example/windows/runner/utils.cpp b/example/windows/runner/utils.cpp new file mode 100644 index 0000000..b2b0873 --- /dev/null +++ b/example/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length <= 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/example/windows/runner/utils.h b/example/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/example/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/example/windows/runner/win32_window.cpp b/example/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/example/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/example/windows/runner/win32_window.h b/example/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/example/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/pubspec.yaml b/pubspec.yaml index 97582d2..2079d1b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ description: > Dart client to communicate with Centrifuge and Centrifugo from Flutter and VM over WebSockets -version: 0.0.1-pre.5 +version: 0.0.1-pre.6 homepage: https://centrifugal.dev @@ -36,9 +36,11 @@ platforms: # - description: 'Example of using the ws library to connect to a Centrifugo server.' # path: example.png + environment: sdk: '>=3.0.0 <4.0.0' + dependencies: # Annotations meta: ^1.9.1 @@ -52,7 +54,8 @@ dependencies: # Utilities crypto: ^3.0.3 fixnum: ^1.1.0 - stack_trace: ^1.11.1 + stack_trace: ^1.11.0 + dev_dependencies: build_runner: ^2.4.6 From c10900d488ebe6a012882d34cd0d360d228bb221 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 4 Aug 2023 09:16:07 +0400 Subject: [PATCH 17/46] Fix subscribe --- lib/src/subscription/client_subscription_impl.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/subscription/client_subscription_impl.dart b/lib/src/subscription/client_subscription_impl.dart index ee750d9..9829b3d 100644 --- a/lib/src/subscription/client_subscription_impl.dart +++ b/lib/src/subscription/client_subscription_impl.dart @@ -296,8 +296,8 @@ base mixin SpinifyClientSubscriptionSubscribeMixin _config, switch (state.since) { null => null, - ({String epoch, fixnum.Int64 offset}) s => ( - epoch: s.epoch, + (epoch: String epoch, offset: fixnum.Int64 _) => ( + epoch: epoch, offset: _offset, ), }, From 9de40ad85f28a8cae277e00cc7788dc35415c024 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 4 Aug 2023 09:31:52 +0400 Subject: [PATCH 18/46] Update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6d2cb9f..ac69208 100644 --- a/README.md +++ b/README.md @@ -99,4 +99,4 @@ We appreciate any form of support, whether it's a financial donation or just a s ## License -[MIT](https://opensource.org/licenses/MIT) +[The MIT License](https://opensource.org/licenses/MIT) From 7e9b9c6c07f3890c6ffc3585668346fc35803379 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 4 Aug 2023 13:18:47 +0400 Subject: [PATCH 19/46] Add example placeholder --- .vscode/launch.json | 4 +- .vscode/tasks.json | 69 ++ Makefile | 2 +- example/lib/src/common/constant/config.dart | 70 ++ .../src/common/constant/pubspec.yaml.g.dart | 527 ++++++++++ .../lib/src/common/controller/controller.dart | 74 ++ .../controller/controller_observer.dart | 25 + .../droppable_controller_concurrency.dart | 40 + .../sequential_controller_concurrency.dart | 137 +++ .../src/common/controller/state_consumer.dart | 129 +++ .../common/controller/state_controller.dart | 35 + .../generated/intl/messages_all.dart | 63 ++ .../generated/intl/messages_en.dart | 154 +++ .../common/localization/generated/l10n.dart | 981 ++++++++++++++++++ .../lib/src/common/localization/intl_en.arb | 98 ++ .../src/common/localization/localization.dart | 237 +++++ example/lib/src/common/util/error_util.dart | 106 ++ example/lib/src/common/util/log_buffer.dart | 47 + example/lib/src/common/util/logger_util.dart | 12 + .../common/util/platform/error_util_js.dart | 17 + .../common/util/platform/error_util_vm.dart | 47 + example/lib/src/common/util/screen_util.dart | 256 +++++ example/lib/src/common/util/timeouts.dart | 19 + example/lib/src/common/widget/app.dart | 44 + .../widget/radial_progress_indicator.dart | 106 ++ .../lib/src/common/widget/window_scope.dart | 242 +++++ .../controller/authentication_controller.dart | 45 + .../controller/authentication_state.dart | 135 +++ .../data/authentication_repository.dart | 41 + .../feature/authentication/model/user.dart | 138 +++ .../widget/authentication_scope.dart | 110 ++ .../widget/authentication_screen.dart | 19 + .../authentication/widget/sign_in_screen.dart | 24 + .../initialization/initialization.dart | 103 ++ .../initialize_dependencies.dart | 114 ++ .../platform/initialization_js.dart | 21 + .../platform/initialization_vm.dart | 44 + .../dependencies/model/app_metadata.dart | 92 ++ .../dependencies/model/dependencies.dart | 10 + .../widget/dependencies_scope.dart | 83 ++ .../widget/initialization_splash_screen.dart | 62 ++ example/pubspec.yaml | 17 +- lib/src/model/pubspec.yaml.g.dart | 18 +- 43 files changed, 4604 insertions(+), 13 deletions(-) create mode 100644 example/lib/src/common/constant/config.dart create mode 100644 example/lib/src/common/constant/pubspec.yaml.g.dart create mode 100644 example/lib/src/common/controller/controller.dart create mode 100644 example/lib/src/common/controller/controller_observer.dart create mode 100644 example/lib/src/common/controller/droppable_controller_concurrency.dart create mode 100644 example/lib/src/common/controller/sequential_controller_concurrency.dart create mode 100644 example/lib/src/common/controller/state_consumer.dart create mode 100644 example/lib/src/common/controller/state_controller.dart create mode 100644 example/lib/src/common/localization/generated/intl/messages_all.dart create mode 100644 example/lib/src/common/localization/generated/intl/messages_en.dart create mode 100644 example/lib/src/common/localization/generated/l10n.dart create mode 100644 example/lib/src/common/localization/intl_en.arb create mode 100644 example/lib/src/common/localization/localization.dart create mode 100644 example/lib/src/common/util/error_util.dart create mode 100644 example/lib/src/common/util/log_buffer.dart create mode 100644 example/lib/src/common/util/logger_util.dart create mode 100644 example/lib/src/common/util/platform/error_util_js.dart create mode 100644 example/lib/src/common/util/platform/error_util_vm.dart create mode 100644 example/lib/src/common/util/screen_util.dart create mode 100644 example/lib/src/common/util/timeouts.dart create mode 100644 example/lib/src/common/widget/app.dart create mode 100644 example/lib/src/common/widget/radial_progress_indicator.dart create mode 100644 example/lib/src/common/widget/window_scope.dart create mode 100644 example/lib/src/feature/authentication/controller/authentication_controller.dart create mode 100644 example/lib/src/feature/authentication/controller/authentication_state.dart create mode 100644 example/lib/src/feature/authentication/data/authentication_repository.dart create mode 100644 example/lib/src/feature/authentication/model/user.dart create mode 100644 example/lib/src/feature/authentication/widget/authentication_scope.dart create mode 100644 example/lib/src/feature/authentication/widget/authentication_screen.dart create mode 100644 example/lib/src/feature/authentication/widget/sign_in_screen.dart create mode 100644 example/lib/src/feature/dependencies/initialization/initialization.dart create mode 100644 example/lib/src/feature/dependencies/initialization/initialize_dependencies.dart create mode 100644 example/lib/src/feature/dependencies/initialization/platform/initialization_js.dart create mode 100644 example/lib/src/feature/dependencies/initialization/platform/initialization_vm.dart create mode 100644 example/lib/src/feature/dependencies/model/app_metadata.dart create mode 100644 example/lib/src/feature/dependencies/model/dependencies.dart create mode 100644 example/lib/src/feature/dependencies/widget/dependencies_scope.dart create mode 100644 example/lib/src/feature/dependencies/widget/initialization_splash_screen.dart 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 From 2ae2b4875303698ea87668ce5d75ffd9666a047e Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 4 Aug 2023 13:28:48 +0400 Subject: [PATCH 20/46] Patch native platform --- .vscode/launch.json | 13 +- example/config/development.json | 5 + example/lib/main.dart | 150 ++++--------------- example/linux/my_application.cc | 52 ++++--- example/macos/Runner/AppDelegate.swift | 3 +- example/macos/Runner/MainFlutterWindow.swift | 6 + example/windows/runner/flutter_window.cpp | 10 +- example/windows/runner/win32_window.cpp | 136 ++++++++--------- 8 files changed, 145 insertions(+), 230 deletions(-) create mode 100644 example/config/development.json diff --git a/.vscode/launch.json b/.vscode/launch.json index 5cbb077..532c5dc 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,20 +2,19 @@ "version": "0.2.0", "configurations": [ { - "name": "Example", + "name": "Example (dev)", "request": "launch", "type": "dart", - "program": "example", + "flutterMode": "debug", + "cwd": "${workspaceFolder}/example", + "program": "lib/main.dart", "env": { - "ENVIRONMENT": "local" + "ENVIRONMENT": "development" }, "console": "debugConsole", "runTestsOnDevice": false, "toolArgs": [], - "args": [ - // "--token=X.Y.Z", - "--verbose" - ] + "args": ["--dart-define-from-file=config/development.json"] } ] } diff --git a/example/config/development.json b/example/config/development.json new file mode 100644 index 0000000..f1cdc5e --- /dev/null +++ b/example/config/development.json @@ -0,0 +1,5 @@ +{ + "ENVIRONMENT": "development", + "CENTRIFUGE_BASE_URL": "http://localhost:8000", + "CENTRIFUGE_TIMEOUT": 8000 +} diff --git a/example/lib/main.dart b/example/lib/main.dart index dda5554..f532f7c 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,125 +1,33 @@ -import 'package:flutter/material.dart'; - -void main() { - runApp(const MyApp()); -} - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - // This widget is the root of your application. - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Demo', - theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a blue toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), - useMaterial3: true, - ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), - ); - } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". +import 'dart:async'; - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } - - @override - Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - 'You have pushed the button this many times:', - ), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, +import 'package:flutter/material.dart'; +import 'package:l/l.dart'; +import 'package:spinifyapp/src/common/util/logger_util.dart'; +import 'package:spinifyapp/src/common/widget/app.dart'; +import 'package:spinifyapp/src/feature/dependencies/initialization/initialization.dart'; +import 'package:spinifyapp/src/feature/dependencies/widget/dependencies_scope.dart'; +import 'package:spinifyapp/src/feature/dependencies/widget/initialization_splash_screen.dart'; + +void main() => l.capture( + () => runZonedGuarded( + () { + final initialization = InitializationExecutor(); + runApp( + DependenciesScope( + initialization: initialization(), + splashScreen: InitializationSplashScreen( + progress: initialization, + ), + child: const App(), ), - ], - ), + ); + }, + l.e, + ), + const LogOptions( + handlePrint: true, + messageFormatting: LoggerUtil.messageFormatting, + outputInRelease: false, + printColors: true, ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), // This trailing comma makes auto-formatting nicer for build methods. ); - } -} diff --git a/example/linux/my_application.cc b/example/linux/my_application.cc index 1283c30..c44e7ba 100644 --- a/example/linux/my_application.cc +++ b/example/linux/my_application.cc @@ -9,15 +9,15 @@ struct _MyApplication { GtkApplication parent_instance; - char** dart_entrypoint_arguments; + char **dart_entrypoint_arguments; }; G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) // Implements GApplication::activate. -static void my_application_activate(GApplication* application) { - MyApplication* self = MY_APPLICATION(application); - GtkWindow* window = +static void my_application_activate(GApplication *application) { + MyApplication *self = MY_APPLICATION(application); + GtkWindow *window = GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); // Use a header bar when running in GNOME as this is the common style used @@ -29,16 +29,16 @@ static void my_application_activate(GApplication* application) { // if future cases occur). gboolean use_header_bar = TRUE; #ifdef GDK_WINDOWING_X11 - GdkScreen* screen = gtk_window_get_screen(window); + GdkScreen *screen = gtk_window_get_screen(window); if (GDK_IS_X11_SCREEN(screen)) { - const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + const gchar *wm_name = gdk_x11_screen_get_window_manager_name(screen); if (g_strcmp0(wm_name, "GNOME Shell") != 0) { use_header_bar = FALSE; } } #endif if (use_header_bar) { - GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + GtkHeaderBar *header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); gtk_widget_show(GTK_WIDGET(header_bar)); gtk_header_bar_set_title(header_bar, "spinifyapp"); gtk_header_bar_set_show_close_button(header_bar, TRUE); @@ -48,12 +48,14 @@ static void my_application_activate(GApplication* application) { } gtk_window_set_default_size(window, 1280, 720); - gtk_widget_show(GTK_WIDGET(window)); + // gtk_widget_show(GTK_WIDGET(window)); + gtk_widget_realize(GTK_WIDGET(window)); g_autoptr(FlDartProject) project = fl_dart_project_new(); - fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); - FlView* view = fl_view_new(project); + FlView *view = fl_view_new(project); gtk_widget_show(GTK_WIDGET(view)); gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); @@ -63,16 +65,18 @@ static void my_application_activate(GApplication* application) { } // Implements GApplication::local_command_line. -static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { - MyApplication* self = MY_APPLICATION(application); +static gboolean my_application_local_command_line(GApplication *application, + gchar ***arguments, + int *exit_status) { + MyApplication *self = MY_APPLICATION(application); // Strip out the first argument as it is the binary name. self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); g_autoptr(GError) error = nullptr; if (!g_application_register(application, nullptr, &error)) { - g_warning("Failed to register: %s", error->message); - *exit_status = 1; - return TRUE; + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; } g_application_activate(application); @@ -82,23 +86,23 @@ static gboolean my_application_local_command_line(GApplication* application, gch } // Implements GObject::dispose. -static void my_application_dispose(GObject* object) { - MyApplication* self = MY_APPLICATION(object); +static void my_application_dispose(GObject *object) { + MyApplication *self = MY_APPLICATION(object); g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); G_OBJECT_CLASS(my_application_parent_class)->dispose(object); } -static void my_application_class_init(MyApplicationClass* klass) { +static void my_application_class_init(MyApplicationClass *klass) { G_APPLICATION_CLASS(klass)->activate = my_application_activate; - G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; G_OBJECT_CLASS(klass)->dispose = my_application_dispose; } -static void my_application_init(MyApplication* self) {} +static void my_application_init(MyApplication *self) {} -MyApplication* my_application_new() { +MyApplication *my_application_new() { return MY_APPLICATION(g_object_new(my_application_get_type(), - "application-id", APPLICATION_ID, - "flags", G_APPLICATION_NON_UNIQUE, - nullptr)); + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); } diff --git a/example/macos/Runner/AppDelegate.swift b/example/macos/Runner/AppDelegate.swift index d53ef64..a8565c3 100644 --- a/example/macos/Runner/AppDelegate.swift +++ b/example/macos/Runner/AppDelegate.swift @@ -4,6 +4,7 @@ import FlutterMacOS @NSApplicationMain class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return true + //return true + return false } } diff --git a/example/macos/Runner/MainFlutterWindow.swift b/example/macos/Runner/MainFlutterWindow.swift index 3cc05eb..1ca7bf0 100644 --- a/example/macos/Runner/MainFlutterWindow.swift +++ b/example/macos/Runner/MainFlutterWindow.swift @@ -1,5 +1,6 @@ import Cocoa import FlutterMacOS +import window_manager class MainFlutterWindow: NSWindow { override func awakeFromNib() { @@ -12,4 +13,9 @@ class MainFlutterWindow: NSWindow { super.awakeFromNib() } + + override public func order(_ place: NSWindow.OrderingMode, relativeTo otherWin: Int) { + super.order(place, relativeTo: otherWin) + hiddenWindowAtLaunch() + } } diff --git a/example/windows/runner/flutter_window.cpp b/example/windows/runner/flutter_window.cpp index b25e363..0e4c5f5 100644 --- a/example/windows/runner/flutter_window.cpp +++ b/example/windows/runner/flutter_window.cpp @@ -4,7 +4,7 @@ #include "flutter/generated_plugin_registrant.h" -FlutterWindow::FlutterWindow(const flutter::DartProject& project) +FlutterWindow::FlutterWindow(const flutter::DartProject &project) : project_(project) {} FlutterWindow::~FlutterWindow() {} @@ -28,7 +28,7 @@ bool FlutterWindow::OnCreate() { SetChildContent(flutter_controller_->view()->GetNativeWindow()); flutter_controller_->engine()->SetNextFrameCallback([&]() { - this->Show(); + // this->Show() }); return true; @@ -57,9 +57,9 @@ FlutterWindow::MessageHandler(HWND hwnd, UINT const message, } switch (message) { - case WM_FONTCHANGE: - flutter_controller_->engine()->ReloadSystemFonts(); - break; + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; } return Win32Window::MessageHandler(hwnd, message, wparam, lparam); diff --git a/example/windows/runner/win32_window.cpp b/example/windows/runner/win32_window.cpp index 60608d0..145247b 100644 --- a/example/windows/runner/win32_window.cpp +++ b/example/windows/runner/win32_window.cpp @@ -11,7 +11,8 @@ namespace { /// /// Redefined in case the developer's machine has a Windows SDK older than /// version 10.0.22000.0. -/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +/// See: +/// https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute #ifndef DWMWA_USE_IMMERSIVE_DARK_MODE #define DWMWA_USE_IMMERSIVE_DARK_MODE 20 #endif @@ -23,8 +24,9 @@ constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; /// A value of 0 indicates apps should use dark mode. A non-zero or missing /// value indicates apps should use light mode. constexpr const wchar_t kGetPreferredBrightnessRegKey[] = - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; -constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = + L"AppsUseLightTheme"; // The number of Win32Window objects that currently exist. static int g_active_window_count = 0; @@ -45,7 +47,7 @@ void EnableFullDpiSupportIfAvailable(HWND hwnd) { return; } auto enable_non_client_dpi_scaling = - reinterpret_cast( + reinterpret_cast( GetProcAddress(user32_module, "EnableNonClientDpiScaling")); if (enable_non_client_dpi_scaling != nullptr) { enable_non_client_dpi_scaling(hwnd); @@ -53,15 +55,15 @@ void EnableFullDpiSupportIfAvailable(HWND hwnd) { FreeLibrary(user32_module); } -} // namespace +} // namespace // Manages the Win32Window's window class registration. class WindowClassRegistrar { - public: +public: ~WindowClassRegistrar() = default; // Returns the singleton registrar instance. - static WindowClassRegistrar* GetInstance() { + static WindowClassRegistrar *GetInstance() { if (!instance_) { instance_ = new WindowClassRegistrar(); } @@ -70,23 +72,23 @@ class WindowClassRegistrar { // Returns the name of the window class, registering the class if it hasn't // previously been registered. - const wchar_t* GetWindowClass(); + const wchar_t *GetWindowClass(); // Unregisters the window class. Should only be called if there are no // instances of the window. void UnregisterWindowClass(); - private: +private: WindowClassRegistrar() = default; - static WindowClassRegistrar* instance_; + static WindowClassRegistrar *instance_; bool class_registered_ = false; }; -WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; +WindowClassRegistrar *WindowClassRegistrar::instance_ = nullptr; -const wchar_t* WindowClassRegistrar::GetWindowClass() { +const wchar_t *WindowClassRegistrar::GetWindowClass() { if (!class_registered_) { WNDCLASS window_class{}; window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); @@ -111,21 +113,18 @@ void WindowClassRegistrar::UnregisterWindowClass() { class_registered_ = false; } -Win32Window::Win32Window() { - ++g_active_window_count; -} +Win32Window::Win32Window() { ++g_active_window_count; } Win32Window::~Win32Window() { --g_active_window_count; Destroy(); } -bool Win32Window::Create(const std::wstring& title, - const Point& origin, - const Size& size) { +bool Win32Window::Create(const std::wstring &title, const Point &origin, + const Size &size) { Destroy(); - const wchar_t* window_class = + const wchar_t *window_class = WindowClassRegistrar::GetInstance()->GetWindowClass(); const POINT target_point = {static_cast(origin.x), @@ -149,24 +148,21 @@ bool Win32Window::Create(const std::wstring& title, return OnCreate(); } -bool Win32Window::Show() { - return ShowWindow(window_handle_, SW_SHOWNORMAL); -} +bool Win32Window::Show() { return ShowWindow(window_handle_, SW_SHOWNORMAL); } // static -LRESULT CALLBACK Win32Window::WndProc(HWND const window, - UINT const message, +LRESULT CALLBACK Win32Window::WndProc(HWND const window, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { if (message == WM_NCCREATE) { - auto window_struct = reinterpret_cast(lparam); + auto window_struct = reinterpret_cast(lparam); SetWindowLongPtr(window, GWLP_USERDATA, reinterpret_cast(window_struct->lpCreateParams)); - auto that = static_cast(window_struct->lpCreateParams); + auto that = static_cast(window_struct->lpCreateParams); EnableFullDpiSupportIfAvailable(window); that->window_handle_ = window; - } else if (Win32Window* that = GetThisFromHandle(window)) { + } else if (Win32Window *that = GetThisFromHandle(window)) { return that->MessageHandler(window, message, wparam, lparam); } @@ -174,48 +170,46 @@ LRESULT CALLBACK Win32Window::WndProc(HWND const window, } LRESULT -Win32Window::MessageHandler(HWND hwnd, - UINT const message, - WPARAM const wparam, +Win32Window::MessageHandler(HWND hwnd, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { switch (message) { - case WM_DESTROY: - window_handle_ = nullptr; - Destroy(); - if (quit_on_close_) { - PostQuitMessage(0); - } - return 0; - - case WM_DPICHANGED: { - auto newRectSize = reinterpret_cast(lparam); - LONG newWidth = newRectSize->right - newRectSize->left; - LONG newHeight = newRectSize->bottom - newRectSize->top; - - SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, - newHeight, SWP_NOZORDER | SWP_NOACTIVATE); - - return 0; + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); } - case WM_SIZE: { - RECT rect = GetClientArea(); - if (child_content_ != nullptr) { - // Size and position the child window. - MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, - rect.bottom - rect.top, TRUE); - } - return 0; + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); } + return 0; + } - case WM_ACTIVATE: - if (child_content_ != nullptr) { - SetFocus(child_content_); - } - return 0; + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; - case WM_DWMCOLORIZATIONCOLORCHANGED: - UpdateTheme(hwnd); - return 0; + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; } return DefWindowProc(window_handle_, message, wparam, lparam); @@ -233,8 +227,8 @@ void Win32Window::Destroy() { } } -Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { - return reinterpret_cast( +Win32Window *Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( GetWindowLongPtr(window, GWLP_USERDATA)); } @@ -255,9 +249,7 @@ RECT Win32Window::GetClientArea() { return frame; } -HWND Win32Window::GetHandle() { - return window_handle_; -} +HWND Win32Window::GetHandle() { return window_handle_; } void Win32Window::SetQuitOnClose(bool quit_on_close) { quit_on_close_ = quit_on_close; @@ -275,10 +267,10 @@ void Win32Window::OnDestroy() { void Win32Window::UpdateTheme(HWND const window) { DWORD light_mode; DWORD light_mode_size = sizeof(light_mode); - LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, - kGetPreferredBrightnessRegValue, - RRF_RT_REG_DWORD, nullptr, &light_mode, - &light_mode_size); + LSTATUS result = + RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, RRF_RT_REG_DWORD, nullptr, + &light_mode, &light_mode_size); if (result == ERROR_SUCCESS) { BOOL enable_dark_mode = light_mode == 0; From d442fa862f18ac730cad93d1cb01cd3a2e4a6a87 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Fri, 4 Aug 2023 13:29:25 +0400 Subject: [PATCH 21/46] Remove tests --- example/test/widget_test.dart | 31 +------------------------------ 1 file changed, 1 insertion(+), 30 deletions(-) diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index 4959bf2..ab73b3a 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -1,30 +1 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:spinifyapp/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -} +void main() {} From 1be87a1628269067478b5c78c3b60a6faffe01f0 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 5 Aug 2023 00:47:44 +0400 Subject: [PATCH 22/46] Add sign in form --- example/config/development.json | 1 + example/lib/src/common/constant/config.dart | 6 +- example/lib/src/common/widget/app.dart | 13 +- .../controller/authentication_controller.dart | 17 +- .../data/authentication_repository.dart | 35 +- .../authentication/model/sign_in_data.dart | 72 +++ .../widget/authentication_scope.dart | 56 ++- .../authentication/widget/sign_in_form.dart | 418 ++++++++++++++++++ .../authentication/widget/sign_in_screen.dart | 24 - .../src/feature/chat/widget/chat_screen.dart | 36 ++ .../initialize_dependencies.dart | 2 +- 11 files changed, 619 insertions(+), 61 deletions(-) create mode 100644 example/lib/src/feature/authentication/model/sign_in_data.dart create mode 100644 example/lib/src/feature/authentication/widget/sign_in_form.dart delete mode 100644 example/lib/src/feature/authentication/widget/sign_in_screen.dart create mode 100644 example/lib/src/feature/chat/widget/chat_screen.dart diff --git a/example/config/development.json b/example/config/development.json index f1cdc5e..9cbc255 100644 --- a/example/config/development.json +++ b/example/config/development.json @@ -1,5 +1,6 @@ { "ENVIRONMENT": "development", "CENTRIFUGE_BASE_URL": "http://localhost:8000", + "CENTRIFUGE_CHANNEL": "chat", "CENTRIFUGE_TIMEOUT": 8000 } diff --git a/example/lib/src/common/constant/config.dart b/example/lib/src/common/constant/config.dart index 31983b0..bf2cc78 100644 --- a/example/lib/src/common/constant/config.dart +++ b/example/lib/src/common/constant/config.dart @@ -20,9 +20,13 @@ abstract final class Config { int.fromEnvironment('CENTRIFUGE_TIMEOUT', defaultValue: 15000)); /// Secret for HMAC token. - static const String passwordMinLength = + static const String centrifugeToken = String.fromEnvironment('CENTRIFUGE_TOKEN_HMAC_SECRET'); + /// Channel by default. + static const String centrifugeChannel = + String.fromEnvironment('CENTRIFUGE_CHANNEL'); + // --- Layout --- // /// Maximum screen layout width for screen with list view. diff --git a/example/lib/src/common/widget/app.dart b/example/lib/src/common/widget/app.dart index 42dceb2..ef9c235 100644 --- a/example/lib/src/common/widget/app.dart +++ b/example/lib/src/common/widget/app.dart @@ -3,7 +3,8 @@ 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'; +import 'package:spinifyapp/src/feature/authentication/widget/sign_in_form.dart'; +import 'package:spinifyapp/src/feature/chat/widget/chat_screen.dart'; /// {@template app} /// App widget. @@ -27,17 +28,17 @@ class App extends StatelessWidget { ? ThemeData.dark(useMaterial3: true) : ThemeData.light( useMaterial3: true), // TODO(plugfox): implement theme - home: const Placeholder(), + home: const AuthenticationScope( + signInForm: SignInForm(), + child: ChatScreen(), + ), 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(), - ), + child: child ?? const SizedBox.shrink(), ), ), ); diff --git a/example/lib/src/feature/authentication/controller/authentication_controller.dart b/example/lib/src/feature/authentication/controller/authentication_controller.dart index cc4f972..2a46ee1 100644 --- a/example/lib/src/feature/authentication/controller/authentication_controller.dart +++ b/example/lib/src/feature/authentication/controller/authentication_controller.dart @@ -5,6 +5,7 @@ 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/sign_in_data.dart'; import 'package:spinifyapp/src/feature/authentication/model/user.dart'; final class AuthenticationController @@ -26,17 +27,29 @@ final class AuthenticationController final IAuthenticationRepository _repository; StreamSubscription? _userSubscription; - void signInAnonymously() => handle( + void signIn(SignInData data) => handle( () async { setState(AuthenticationState.processing( user: state.user, message: 'Logging in...')); - await _repository.signInAnonymously(); + await _repository.signIn(data); }, (error, _) => setState(AuthenticationState.idle( user: state.user, error: ErrorUtil.formatMessage(error))), () => setState(AuthenticationState.idle(user: state.user)), ); + void signOut() => handle( + () async { + setState(AuthenticationState.processing( + user: state.user, message: 'Logging out...')); + await _repository.signOut(); + }, + (error, _) => setState(AuthenticationState.idle( + user: state.user, error: ErrorUtil.formatMessage(error))), + () => setState( + const AuthenticationState.idle(user: User.unauthenticated())), + ); + @override void dispose() { _userSubscription?.cancel(); diff --git a/example/lib/src/feature/authentication/data/authentication_repository.dart b/example/lib/src/feature/authentication/data/authentication_repository.dart index aab590f..94da072 100644 --- a/example/lib/src/feature/authentication/data/authentication_repository.dart +++ b/example/lib/src/feature/authentication/data/authentication_repository.dart @@ -1,30 +1,18 @@ import 'dart:async'; +import 'package:spinifyapp/src/feature/authentication/model/sign_in_data.dart'; 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(); */ + Future signIn(SignInData data); + Future signOut(); } -class AuthenticationRepositoryFake implements IAuthenticationRepository { +class AuthenticationRepositoryImpl implements IAuthenticationRepository { + AuthenticationRepositoryImpl(); + final StreamController _userController = StreamController.broadcast(); User _user = const User.unauthenticated(); @@ -36,6 +24,13 @@ class AuthenticationRepositoryFake implements IAuthenticationRepository { Stream userChanges() => _userController.stream; @override - Future signInAnonymously() => Future.sync(() => _userController - .add(_user = const User.authenticated(id: 'anonymous-user-id'))); + Future signIn(SignInData data) { + // TODO(plugfox): implement signIn + return Future.sync(() => _userController + .add(_user = const User.authenticated(id: 'anonymous-user-id'))); + } + + @override + Future signOut() => Future.sync( + () => _userController.add(_user = const User.unauthenticated())); } diff --git a/example/lib/src/feature/authentication/model/sign_in_data.dart b/example/lib/src/feature/authentication/model/sign_in_data.dart new file mode 100644 index 0000000..cae7ed1 --- /dev/null +++ b/example/lib/src/feature/authentication/model/sign_in_data.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; + +@immutable +final class SignInData { + SignInData({ + required this.endpoint, + required this.token, + required this.username, + required this.channel, + String? secret, + }) : secret = secret == null || secret.isEmpty ? null : secret; + + final String endpoint; + final String token; + final String username; + final String channel; + final String? secret; + + static final RegExp _urlValidator = RegExp( + r'^(https?:\/\/)?(localhost|((([a-z\d]([a-z\d-]*[a-z\d])*)\.)+[a-z]{2,}|((\d{1,3}\.){3}\d{1,3})))?(:\d+)?(\/[-a-z\d%_.~+]*)*(\?[;&a-z\d%_.~+=-]*)?(\#[-a-z\d_]*)?$', + caseSensitive: false, + multiLine: false, + ); + String? isValidEndpoint() { + if (endpoint.isEmpty) return 'Endpoint is required'; + if (endpoint.length < 6) return 'Endpoint is too short'; + if (endpoint.length > 1024) return 'Endpoint is too long'; + if (!_urlValidator.hasMatch(endpoint)) return 'Endpoint is invalid'; + return null; + } + + String? isValidToken() { + if (token.isEmpty) return 'Token is required'; + if (token.length < 6) return 'Token is too short'; + if (token.length > 64) return 'Token is too long'; + return null; + } + + static final RegExp _usernameValidator = RegExp( + r'\@|[A-Z]|[a-z]|[0-9]|\.|\-|\_|\+', + caseSensitive: false, + multiLine: false, + ); + String? isValidUsername() { + if (username.isEmpty) return 'Username is required'; + if (username.length < 4) return 'Username is too short'; + if (username.length > 64) return 'Username is too long'; + if (!_usernameValidator.hasMatch(username)) return 'Username is invalid'; + return null; + } + + static final RegExp _channelValidator = RegExp( + r'^[a-zA-Z0-9_-]+$', + caseSensitive: false, + multiLine: false, + ); + String? isValidChannel() { + if (channel.isEmpty) return 'Channel is required'; + if (channel.length < 4) return 'Channel is too short'; + if (channel.length > 64) return 'Channel is too long'; + if (!_channelValidator.hasMatch(channel)) return 'Channel is invalid'; + return null; + } + + String? isValidSecret() { + final secret = this.secret; + if (secret == null || secret.isEmpty) return null; + if (secret.length < 4) return 'Secret is too short'; + if (secret.length > 64) return 'Secret is too long'; + return null; + } +} diff --git a/example/lib/src/feature/authentication/widget/authentication_scope.dart b/example/lib/src/feature/authentication/widget/authentication_scope.dart index e2e1b6b..0344648 100644 --- a/example/lib/src/feature/authentication/widget/authentication_scope.dart +++ b/example/lib/src/feature/authentication/widget/authentication_scope.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/widgets.dart'; import 'package:spinifyapp/src/feature/authentication/controller/authentication_controller.dart'; import 'package:spinifyapp/src/feature/authentication/model/user.dart'; @@ -9,13 +11,13 @@ import 'package:spinifyapp/src/feature/dependencies/widget/dependencies_scope.da class AuthenticationScope extends StatefulWidget { /// {@macro authentication_scope} const AuthenticationScope({ - required this.signInScreen, + required this.signInForm, required this.child, super.key, }); - /// Sign In screen for unauthenticated users. - final Widget signInScreen; + /// Sign In form for unauthenticated users. + final Widget signInForm; /// The widget below this widget in the tree. final Widget child; @@ -36,6 +38,7 @@ class AuthenticationScope extends StatefulWidget { class _AuthenticationScopeState extends State { late final AuthenticationController _authenticationController; User _user = const User.unauthenticated(); + bool _showForm = true; @override void initState() { @@ -55,17 +58,56 @@ class _AuthenticationScopeState extends State { void _onAuthenticationControllerChanged() { final user = _authenticationController.state.user; - if (!identical(_user, user)) setState(() => _user = user); + if (!identical(_user, user)) { + if (user.isNotAuthenticated) _showForm = true; + setState(() => _user = user); + } } @override Widget build(BuildContext context) => _InheritedAuthenticationScope( controller: _authenticationController, user: _user, - child: switch (_user) { - UnauthenticatedUser _ => widget.signInScreen, + /* child: switch (_user) { + UnauthenticatedUser _ => widget.signInForm, AuthenticatedUser _ => widget.child, - }, + }, */ + child: ClipRect( + child: StatefulBuilder( + builder: (context, setState) => Stack( + children: [ + Positioned.fill( + child: IgnorePointer( + ignoring: _user.isNotAuthenticated, + child: widget.child, + ), + ), + if (_showForm) + Positioned.fill( + child: IgnorePointer( + ignoring: _user.isAuthenticated, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 350), + onEnd: () => setState(() => _showForm = false), + curve: Curves.easeInOut, + opacity: _user.isNotAuthenticated ? 1 : 0, + child: RepaintBoundary( + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 2.5, + sigmaY: 2.5, + ), + child: Center( + child: widget.signInForm, + ), + ), + ), + ), + ), + ), + ], + )), + ), ); } diff --git a/example/lib/src/feature/authentication/widget/sign_in_form.dart b/example/lib/src/feature/authentication/widget/sign_in_form.dart new file mode 100644 index 0000000..cad2f0a --- /dev/null +++ b/example/lib/src/feature/authentication/widget/sign_in_form.dart @@ -0,0 +1,418 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:spinifyapp/src/common/constant/config.dart'; +import 'package:spinifyapp/src/common/controller/state_consumer.dart'; +import 'package:spinifyapp/src/common/localization/localization.dart'; +import 'package:spinifyapp/src/feature/authentication/controller/authentication_controller.dart'; +import 'package:spinifyapp/src/feature/authentication/model/sign_in_data.dart'; +import 'package:spinifyapp/src/feature/authentication/widget/authentication_scope.dart'; + +/// {@template sign_in_form} +/// SignInScreen widget. +/// {@endtemplate} +class SignInForm extends StatelessWidget implements PreferredSizeWidget { + /// {@macro sign_in_form} + const SignInForm({super.key}); + + /// Width of the sign in form. + static const double width = 480; + + /// Height of the sign in form. + static const double height = 720; + + @override + Size get preferredSize => const Size(width, height); + + @override + Widget build(BuildContext context) => + LayoutBuilder(builder: (context, constraints) { + final space = math.min(constraints.maxHeight - preferredSize.height, + constraints.maxWidth - preferredSize.width); + final padding = switch (space) { + > 32 => 24.0, + > 24 => 16.0, + > 16 => 8.0, + _ => 0.0, + }; + Widget wrap({required Widget child}) => padding > 0 + ? SizedBox( + width: width, + height: height, + child: child, + ) + : SizedBox.expand( + child: child, + ); + return wrap( + child: Card( + elevation: padding > 0 ? 8 : 0, + margin: EdgeInsets.all(padding), + shape: padding > 0 + ? RoundedRectangleBorder( + borderRadius: BorderRadius.circular(padding), + ) + : const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + child: const _SignInForm(), + ), + ); + }); +} + +class _SignInForm extends StatefulWidget { + const _SignInForm(); + + @override + State<_SignInForm> createState() => _SignInFormState(); +} + +/// State for widget _SignInForm. +class _SignInFormState extends State<_SignInForm> { + late final AuthenticationController authenticationController; + final TextEditingController _endpointController = + TextEditingController(text: Config.centrifugeBaseUrl), + _tokenController = TextEditingController(text: Config.centrifugeToken), + _channelController = + TextEditingController(text: Config.centrifugeChannel), + _usernameController = TextEditingController(), + _secretController = TextEditingController(); + + final FocusNode _endpointFocusNode = FocusNode(), + _tokenFocusNode = FocusNode(), + _channelFocusNode = FocusNode(), + _usernameFocusNode = FocusNode(), + _secretFocusNode = FocusNode(); + + final ValueNotifier _endpointError = ValueNotifier(null), + _tokenError = ValueNotifier(null), + _channelError = ValueNotifier(null), + _usernameError = ValueNotifier(null), + _secretError = ValueNotifier(null); + + final ValueNotifier _validNotifier = ValueNotifier(false); + + late final Listenable _observer; + late final List _controllers = [ + _endpointController, + _tokenController, + _channelController, + _usernameController, + _secretController, + ]; + + @override + void initState() { + super.initState(); + authenticationController = AuthenticationScope.controllerOf(context); + _observer = Listenable.merge(_controllers)..addListener(_onChanged); + _onChanged(); + } + + @override + void dispose() { + _observer.removeListener(_onChanged); + for (final controller in _controllers) { + controller.dispose(); + } + _validNotifier.dispose(); + super.dispose(); + } + + late SignInData _data; + + void _onChanged() { + if (!mounted) return; + _data = SignInData( + endpoint: _endpointController.text, + token: _tokenController.text, + channel: _channelController.text, + username: _usernameController.text, + secret: _secretController.text, + ); + _validNotifier.value = _validate(_data); + } + + late final List _validators = + [ + (data) => _endpointError.value = data.isValidEndpoint(), + (data) => _tokenError.value = data.isValidToken(), + (data) => _usernameError.value = data.isValidUsername(), + (data) => _channelError.value = data.isValidChannel(), + (data) => _secretError.value = data.isValidSecret(), + ]; + bool _validate(SignInData data) { + for (final validator in _validators) { + if (validator(data) != null) return false; + } + return true; + } + + void _submit() { + final data = _data; + if (!_validate(data)) return; + authenticationController.signIn(data); + } + + @override + Widget build(BuildContext context) => FocusScope( + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.all(16), + shrinkWrap: true, + children: [ + SizedBox( + width: double.infinity, + height: 48, + child: Text( + Localization.of(context).signInButton, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .headlineMedium + ?.copyWith(height: 1), + ), + ), + const SizedBox(height: 12), + SignInTextField( + focusNode: _endpointFocusNode, + controller: _endpointController, + error: _endpointError, + autofillHints: const [ + AutofillHints.url, + ], + maxLength: 1024, + keyboardType: TextInputType.url, + labelText: 'Endpoint', + hintText: 'Enter your endpoint', + ), + SignInTextField( + focusNode: _tokenFocusNode, + controller: _tokenController, + error: _tokenError, + maxLength: 64, + autofillHints: const [ + AutofillHints.password, + ], + keyboardType: TextInputType.visiblePassword, + labelText: 'Token', + hintText: 'Enter HMAC secret token', + obscureText: true, + ), + SignInTextField( + focusNode: _channelFocusNode, + controller: _channelController, + error: _channelError, + maxLength: 64, + labelText: 'Channel', + hintText: 'Enter your channel', + autofillHints: const [ + AutofillHints.username, + ], + keyboardType: TextInputType.name, + formatters: [ + FilteringTextInputFormatter.allow( + RegExp(r'^[a-zA-Z0-9_-]+$'), + ), + ], + ), + SignInTextField( + focusNode: _usernameFocusNode, + controller: _usernameController, + error: _usernameError, + maxLength: 64, + labelText: 'Username', + hintText: 'Select your username', + autofillHints: const [ + AutofillHints.username, + ], + keyboardType: TextInputType.name, + formatters: [ + FilteringTextInputFormatter.allow( + /// Allow only letters, numbers, + /// and the following characters: @.-_+ + RegExp(r'\@|[A-Z]|[a-z]|[0-9]|\.|\-|\_|\+'), + ), + ], + ), + SignInTextField( + focusNode: _secretFocusNode, + controller: _secretController, + error: _secretError, + maxLength: 64, + autofillHints: const [ + AutofillHints.password, + ], + keyboardType: TextInputType.visiblePassword, + labelText: 'Secret (optional)', + hintText: 'For private channels only', + obscureText: true, + ), + ], + ), + ), + const Divider(height: 1, thickness: 1, indent: 16, endIndent: 16), + Padding( + padding: const EdgeInsets.all(16), + child: Center( + child: SizedBox( + width: 320, + height: 64, + child: ValueListenableBuilder( + valueListenable: _validNotifier, + builder: (context, valid, _) => AnimatedOpacity( + opacity: valid ? 1 : .5, + duration: const Duration(milliseconds: 350), + child: ElevatedButton( + onPressed: valid ? _submit : null, + style: ElevatedButton.styleFrom( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(24), + bottomLeft: Radius.circular(8), + bottomRight: Radius.circular(24), + topRight: Radius.circular(8), + ), + ), + textStyle: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + child: const Text('Sign In Anonymously'), + ), + ), + ), + ), + ), + ), + ], + ), + ); +} + +class SignInTextField extends StatefulWidget { + const SignInTextField({ + required this.controller, + this.formatters, + this.focusNode, + this.error, + this.autofillHints, + this.labelText, + this.hintText, + this.obscureText = false, + this.keyboardType, + this.maxLength, + super.key, + }); + + final TextEditingController controller; + final List? formatters; + final FocusNode? focusNode; + final ValueListenable? error; + final List? autofillHints; + final String? labelText; + final String? hintText; + final bool obscureText; + final TextInputType? keyboardType; + final int? maxLength; + + @override + State createState() => _SignInTextFieldState(); +} + +class _SignInTextFieldState extends State { + bool _obscurePassword = false; + FocusNode? focusNode; + + @override + void initState() { + super.initState(); + _obscurePassword = widget.obscureText; + focusNode = widget.focusNode?..addListener(_onFocusChanged); + } + + @override + void dispose() { + focusNode?.removeListener(_onFocusChanged); + super.dispose(); + } + + void _onFocusChanged() { + if (focusNode?.hasFocus == false && + mounted && + widget.obscureText && + !_obscurePassword) { + setState(() => _obscurePassword = true); + } + } + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: StateConsumer( + controller: AuthenticationScope.controllerOf(context), + builder: (context, state, _) => AnimatedOpacity( + opacity: state.isIdling ? 1 : .5, + duration: const Duration(milliseconds: 250), + child: ValueListenableBuilder( + valueListenable: widget.error ?? ValueNotifier(null), + builder: (context, error, child) => StatefulBuilder( + builder: (context, setState) => TextField( + focusNode: widget.focusNode, + enabled: state.isIdling, + maxLines: 1, + minLines: 1, + maxLength: widget.maxLength, + controller: widget.controller, + autocorrect: false, + autofillHints: widget.autofillHints, + keyboardType: widget.keyboardType, + inputFormatters: widget.formatters, + obscureText: _obscurePassword, + decoration: InputDecoration( + constraints: const BoxConstraints(maxHeight: 84), + labelText: widget.labelText, + hintText: widget.hintText, + helperText: '', + helperMaxLines: 1, + errorText: error ?? state.error, + errorMaxLines: 1, + suffixIcon: widget.obscureText + ? IconButton( + icon: Icon(_obscurePassword + ? Icons.visibility + : Icons.visibility_off), + onPressed: () => setState( + () => _obscurePassword = !_obscurePassword, + ), + ) + : null, + border: const OutlineInputBorder(), + ), + ), + ), + ), + ), + ), + ); +} + +/* class _UsernameTextFormatter extends TextInputFormatter { + const _UsernameTextFormatter(); + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, TextEditingValue newValue) => + TextEditingValue( + text: newValue.text.toLowerCase(), + selection: newValue.selection, + ); +} */ diff --git a/example/lib/src/feature/authentication/widget/sign_in_screen.dart b/example/lib/src/feature/authentication/widget/sign_in_screen.dart deleted file mode 100644 index 59fc019..0000000 --- a/example/lib/src/feature/authentication/widget/sign_in_screen.dart +++ /dev/null @@ -1,24 +0,0 @@ -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/chat/widget/chat_screen.dart b/example/lib/src/feature/chat/widget/chat_screen.dart new file mode 100644 index 0000000..e63fcdb --- /dev/null +++ b/example/lib/src/feature/chat/widget/chat_screen.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:spinifyapp/src/common/controller/state_consumer.dart'; +import 'package:spinifyapp/src/feature/authentication/widget/authentication_scope.dart'; + +/// {@template chat_screen} +/// ChatScreen widget. +/// {@endtemplate} +class ChatScreen extends StatelessWidget { + /// {@macro chat_screen} + const ChatScreen({super.key}); + + @override + Widget build(BuildContext context) { + final authController = AuthenticationScope.controllerOf(context); + return StateConsumer( + controller: authController, + builder: (context, state, _) => Scaffold( + appBar: AppBar( + title: const Text('Chat'), + actions: [ + IconButton( + onPressed: state.user.isNotAuthenticated + ? null + : () => authController.signOut(), + icon: const Icon(Icons.logout), + ), + const SizedBox(width: 16), + ], + ), + body: const Center( + child: Text('Chat'), + ), + ), + ); + } +} diff --git a/example/lib/src/feature/dependencies/initialization/initialize_dependencies.dart b/example/lib/src/feature/dependencies/initialization/initialize_dependencies.dart index 0eaee93..29755c8 100644 --- a/example/lib/src/feature/dependencies/initialization/initialize_dependencies.dart +++ b/example/lib/src/feature/dependencies/initialization/initialize_dependencies.dart @@ -96,7 +96,7 @@ mixin InitializeDependencies { ( 'Authentication repository', (dependencies) => dependencies.authenticationRepository = - AuthenticationRepositoryFake(), + AuthenticationRepositoryImpl(), ), ( 'Fake delay 1', From 79101f3b5987cb5112754d76def5bd9eced2ce9d Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 5 Aug 2023 01:10:39 +0400 Subject: [PATCH 23/46] Update sign in form --- .../controller/authentication_controller.dart | 41 +++++++++--- .../data/authentication_repository.dart | 21 +++++-- .../authentication/model/sign_in_data.dart | 10 +++ .../feature/authentication/model/user.dart | 63 ++++++++++++++----- .../authentication/widget/sign_in_form.dart | 37 ++++------- 5 files changed, 116 insertions(+), 56 deletions(-) diff --git a/example/lib/src/feature/authentication/controller/authentication_controller.dart b/example/lib/src/feature/authentication/controller/authentication_controller.dart index 2a46ee1..c0c8d29 100644 --- a/example/lib/src/feature/authentication/controller/authentication_controller.dart +++ b/example/lib/src/feature/authentication/controller/authentication_controller.dart @@ -29,25 +29,46 @@ final class AuthenticationController void signIn(SignInData data) => handle( () async { - setState(AuthenticationState.processing( - user: state.user, message: 'Logging in...')); + setState( + AuthenticationState.processing( + user: state.user, + message: 'Logging in...', + ), + ); await _repository.signIn(data); }, - (error, _) => setState(AuthenticationState.idle( - user: state.user, error: ErrorUtil.formatMessage(error))), - () => setState(AuthenticationState.idle(user: state.user)), + (error, _) => setState( + AuthenticationState.idle( + user: state.user, + error: ErrorUtil.formatMessage(error), + ), + ), + () => setState( + AuthenticationState.idle(user: state.user), + ), ); void signOut() => handle( () async { - setState(AuthenticationState.processing( - user: state.user, message: 'Logging out...')); + setState( + AuthenticationState.processing( + user: state.user, + message: 'Logging out...', + ), + ); await _repository.signOut(); }, - (error, _) => setState(AuthenticationState.idle( - user: state.user, error: ErrorUtil.formatMessage(error))), + (error, _) => setState( + AuthenticationState.idle( + user: state.user, + error: ErrorUtil.formatMessage(error), + ), + ), () => setState( - const AuthenticationState.idle(user: User.unauthenticated())), + const AuthenticationState.idle( + user: User.unauthenticated(), + ), + ), ); @override diff --git a/example/lib/src/feature/authentication/data/authentication_repository.dart b/example/lib/src/feature/authentication/data/authentication_repository.dart index 94da072..059767b 100644 --- a/example/lib/src/feature/authentication/data/authentication_repository.dart +++ b/example/lib/src/feature/authentication/data/authentication_repository.dart @@ -24,13 +24,22 @@ class AuthenticationRepositoryImpl implements IAuthenticationRepository { Stream userChanges() => _userController.stream; @override - Future signIn(SignInData data) { - // TODO(plugfox): implement signIn - return Future.sync(() => _userController - .add(_user = const User.authenticated(id: 'anonymous-user-id'))); - } + Future signIn(SignInData data) => Future.sync( + () => _userController.add( + _user = User.authenticated( + username: data.username, + endpoint: data.endpoint, + token: data.token, + channel: data.channel, + secret: data.secret, + ), + ), + ); @override Future signOut() => Future.sync( - () => _userController.add(_user = const User.unauthenticated())); + () => _userController.add( + _user = const User.unauthenticated(), + ), + ); } diff --git a/example/lib/src/feature/authentication/model/sign_in_data.dart b/example/lib/src/feature/authentication/model/sign_in_data.dart index cae7ed1..b6757e5 100644 --- a/example/lib/src/feature/authentication/model/sign_in_data.dart +++ b/example/lib/src/feature/authentication/model/sign_in_data.dart @@ -10,10 +10,20 @@ final class SignInData { String? secret, }) : secret = secret == null || secret.isEmpty ? null : secret; + /// Centrifuge endpoint final String endpoint; + + /// Centrifuge HMAC token for JWT authentication. + /// **BEWARE**: You should not store the token in the real app! final String token; + + /// Centrifuge username. final String username; + + /// Centrifuge channel. final String channel; + + /// Centrifuge secret (optional) final String? secret; static final RegExp _urlValidator = RegExp( diff --git a/example/lib/src/feature/authentication/model/user.dart b/example/lib/src/feature/authentication/model/user.dart index a146046..09d2c29 100644 --- a/example/lib/src/feature/authentication/model/user.dart +++ b/example/lib/src/feature/authentication/model/user.dart @@ -1,7 +1,7 @@ import 'package:meta/meta.dart'; -/// User id type. -typedef UserId = String; +/// User username type. +typedef Username = String; /// {@template user} /// The user entry model. @@ -16,11 +16,15 @@ sealed class User with _UserPatternMatching, _UserShortcuts { /// {@macro user} const factory User.authenticated({ - required UserId id, + required Username username, + required String endpoint, + required String token, + required String channel, + String? secret, }) = AuthenticatedUser; - /// The user's id. - abstract final UserId? id; + /// The user's username. + abstract final Username? username; } /// {@macro user} @@ -31,7 +35,7 @@ class UnauthenticatedUser extends User { const UnauthenticatedUser() : super._(); @override - UserId? get id => null; + Username? get username => null; @override @nonVirtual @@ -48,7 +52,9 @@ class UnauthenticatedUser extends User { int get hashCode => -2; @override - bool operator ==(Object other) => identical(this, other) || other is UnauthenticatedUser && id == other.id; + bool operator ==(Object other) => + identical(this, other) || + other is UnauthenticatedUser && username == other.username; @override String toString() => 'UnauthenticatedUser()'; @@ -56,24 +62,49 @@ class UnauthenticatedUser extends User { final class AuthenticatedUser extends User { const AuthenticatedUser({ - required this.id, + required this.username, + required this.endpoint, + required this.token, + required this.channel, + this.secret, }) : super._(); factory AuthenticatedUser.fromJson(Map json) { if (json.isEmpty) throw FormatException('Json is empty', json); if (json case { - 'id': UserId id, + 'username': Username username, + 'endpoint': String endpoint, + 'token': String token, + 'channel': String channel, + 'secret': String? secret, }) return AuthenticatedUser( - id: id, + username: username, + endpoint: endpoint, + token: token, + channel: channel, + secret: secret, ); throw FormatException('Invalid json format', json); } @override @nonVirtual - final UserId id; + final Username username; + + /// Centrifuge endpoint + final String endpoint; + + /// Centrifuge HMAC token for JWT authentication. + /// **BEWARE**: You should not store the token in the real app! + final String token; + + /// Centrifuge channel. + final String channel; + + /// Centrifuge secret (optional) + final String? secret; @override @nonVirtual @@ -87,17 +118,19 @@ final class AuthenticatedUser extends User { authenticated(this); Map toJson() => { - 'id': id, + 'username': username, }; @override - int get hashCode => id.hashCode; + int get hashCode => username.hashCode; @override - bool operator ==(Object other) => identical(this, other) || other is AuthenticatedUser && id == other.id; + bool operator ==(Object other) => + identical(this, other) || + other is AuthenticatedUser && username == other.username; @override - String toString() => 'AuthenticatedUser(id: $id)'; + String toString() => 'AuthenticatedUser(username: $username)'; } mixin _UserPatternMatching { diff --git a/example/lib/src/feature/authentication/widget/sign_in_form.dart b/example/lib/src/feature/authentication/widget/sign_in_form.dart index cad2f0a..e24953b 100644 --- a/example/lib/src/feature/authentication/widget/sign_in_form.dart +++ b/example/lib/src/feature/authentication/widget/sign_in_form.dart @@ -70,8 +70,8 @@ class _SignInForm extends StatefulWidget { /// State for widget _SignInForm. class _SignInFormState extends State<_SignInForm> { - late final AuthenticationController authenticationController; - final TextEditingController _endpointController = + // Make it static so that it doesn't get disposed when the widget is rebuilt. + static final TextEditingController _endpointController = TextEditingController(text: Config.centrifugeBaseUrl), _tokenController = TextEditingController(text: Config.centrifugeToken), _channelController = @@ -93,29 +93,27 @@ class _SignInFormState extends State<_SignInForm> { final ValueNotifier _validNotifier = ValueNotifier(false); + late final AuthenticationController authenticationController; late final Listenable _observer; - late final List _controllers = [ - _endpointController, - _tokenController, - _channelController, - _usernameController, - _secretController, - ]; @override void initState() { super.initState(); authenticationController = AuthenticationScope.controllerOf(context); - _observer = Listenable.merge(_controllers)..addListener(_onChanged); + _observer = Listenable.merge([ + _endpointController, + _tokenController, + _channelController, + _usernameController, + _secretController, + ]) + ..addListener(_onChanged); _onChanged(); } @override void dispose() { _observer.removeListener(_onChanged); - for (final controller in _controllers) { - controller.dispose(); - } _validNotifier.dispose(); super.dispose(); } @@ -287,7 +285,7 @@ class _SignInFormState extends State<_SignInForm> { fontWeight: FontWeight.bold, ), ), - child: const Text('Sign In Anonymously'), + child: const Text('Sign In'), ), ), ), @@ -405,14 +403,3 @@ class _SignInTextFieldState extends State { ), ); } - -/* class _UsernameTextFormatter extends TextInputFormatter { - const _UsernameTextFormatter(); - @override - TextEditingValue formatEditUpdate( - TextEditingValue oldValue, TextEditingValue newValue) => - TextEditingValue( - text: newValue.text.toLowerCase(), - selection: newValue.selection, - ); -} */ From f6ce9afff1287cc5bb29553a2e970ca966983559 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 5 Aug 2023 01:54:13 +0400 Subject: [PATCH 24/46] Update app --- example/lib/src/common/widget/app.dart | 12 ++-- .../lib/src/common/widget/window_scope.dart | 54 ++++++++--------- .../widget/authentication_scope.dart | 6 +- .../authentication/widget/sign_in_form.dart | 1 + .../src/feature/chat/widget/chat_room.dart | 59 +++++++++++++++++++ .../src/feature/chat/widget/chat_screen.dart | 37 +++++++++--- 6 files changed, 127 insertions(+), 42 deletions(-) create mode 100644 example/lib/src/feature/chat/widget/chat_room.dart diff --git a/example/lib/src/common/widget/app.dart b/example/lib/src/common/widget/app.dart index ef9c235..4489e8e 100644 --- a/example/lib/src/common/widget/app.dart +++ b/example/lib/src/common/widget/app.dart @@ -1,5 +1,7 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:platform_info/platform_info.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'; @@ -26,18 +28,20 @@ class App extends StatelessWidget { theme: View.of(context).platformDispatcher.platformBrightness == Brightness.dark ? ThemeData.dark(useMaterial3: true) - : ThemeData.light( - useMaterial3: true), // TODO(plugfox): implement theme + : ThemeData.light(useMaterial3: true), + /* themeMode: ThemeMode.system, */ home: const AuthenticationScope( signInForm: SignInForm(), child: ChatScreen(), ), supportedLocales: Localization.supportedLocales, - locale: const Locale('en', 'US'), // TODO(plugfox): implement locale + locale: Localization.supportedLocales + .firstWhereOrNull((e) => e.languageCode == platform.locale) ?? + const Locale('en', 'US'), builder: (context, child) => MediaQuery( data: MediaQuery.of(context).copyWith(textScaleFactor: 1), child: WindowScope( - title: Localization.of(context).title, + /* title: Localization.of(context).title, */ child: child ?? const SizedBox.shrink(), ), ), diff --git a/example/lib/src/common/widget/window_scope.dart b/example/lib/src/common/widget/window_scope.dart index 891ff50..9461b8e 100644 --- a/example/lib/src/common/widget/window_scope.dart +++ b/example/lib/src/common/widget/window_scope.dart @@ -10,13 +10,13 @@ import 'package:window_manager/window_manager.dart'; class WindowScope extends StatefulWidget { /// {@macro window_scope} const WindowScope({ - required this.title, required this.child, + this.title, super.key, }); /// Title of the window. - final String title; + final String? title; /// The widget below this widget in the tree. final Widget child; @@ -93,13 +93,15 @@ class _WindowTitleState extends State<_WindowTitle> with WindowListener { } @override - Widget build(BuildContext context) => SizedBox( - height: 24, - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onPanStart: (details) => windowManager.startDragging(), - onDoubleTap: null, - /* () async { + Widget build(BuildContext context) { + final title = context.findAncestorWidgetOfExactType()?.title; + return SizedBox( + height: 24, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onPanStart: (details) => windowManager.startDragging(), + onDoubleTap: null, + /* () async { bool isMaximized = await windowManager.isMaximized(); if (!isMaximized) { windowManager.maximize(); @@ -107,11 +109,12 @@ class _WindowTitleState extends State<_WindowTitle> with WindowListener { windowManager.unmaximize(); } }, */ - child: Material( - color: Theme.of(context).primaryColor, - child: Stack( - alignment: Alignment.center, - children: [ + child: Material( + color: Theme.of(context).primaryColor, + child: Stack( + alignment: Alignment.center, + children: [ + if (title != null) Builder( builder: (context) { final size = MediaQuery.of(context).size; @@ -133,11 +136,7 @@ class _WindowTitleState extends State<_WindowTitle> with WindowListener { ), ), child: Text( - context - .findAncestorWidgetOfExactType< - WindowScope>() - ?.title ?? - 'App', + title, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context) @@ -150,16 +149,17 @@ class _WindowTitleState extends State<_WindowTitle> with WindowListener { ); }, ), - _WindowButtons$Windows( - isFullScreen: _isFullScreen, - isAlwaysOnTop: _isAlwaysOnTop, - setAlwaysOnTop: setAlwaysOnTop, - ), - ], - ), + _WindowButtons$Windows( + isFullScreen: _isFullScreen, + isAlwaysOnTop: _isAlwaysOnTop, + setAlwaysOnTop: setAlwaysOnTop, + ), + ], ), ), - ); + ), + ); + } } class _WindowButtons$Windows extends StatelessWidget { diff --git a/example/lib/src/feature/authentication/widget/authentication_scope.dart b/example/lib/src/feature/authentication/widget/authentication_scope.dart index 0344648..87a70dd 100644 --- a/example/lib/src/feature/authentication/widget/authentication_scope.dart +++ b/example/lib/src/feature/authentication/widget/authentication_scope.dart @@ -68,15 +68,12 @@ class _AuthenticationScopeState extends State { Widget build(BuildContext context) => _InheritedAuthenticationScope( controller: _authenticationController, user: _user, - /* child: switch (_user) { - UnauthenticatedUser _ => widget.signInForm, - AuthenticatedUser _ => widget.child, - }, */ child: ClipRect( child: StatefulBuilder( builder: (context, setState) => Stack( children: [ Positioned.fill( + key: const ValueKey('child'), child: IgnorePointer( ignoring: _user.isNotAuthenticated, child: widget.child, @@ -84,6 +81,7 @@ class _AuthenticationScopeState extends State { ), if (_showForm) Positioned.fill( + key: const ValueKey('authentication-form'), child: IgnorePointer( ignoring: _user.isAuthenticated, child: AnimatedOpacity( diff --git a/example/lib/src/feature/authentication/widget/sign_in_form.dart b/example/lib/src/feature/authentication/widget/sign_in_form.dart index e24953b..6cdaad5 100644 --- a/example/lib/src/feature/authentication/widget/sign_in_form.dart +++ b/example/lib/src/feature/authentication/widget/sign_in_form.dart @@ -241,6 +241,7 @@ class _SignInFormState extends State<_SignInForm> { ), ], ), + // TODO(plugfox): generate & copy SignInTextField( focusNode: _secretFocusNode, controller: _secretController, diff --git a/example/lib/src/feature/chat/widget/chat_room.dart b/example/lib/src/feature/chat/widget/chat_room.dart new file mode 100644 index 0000000..ed0363b --- /dev/null +++ b/example/lib/src/feature/chat/widget/chat_room.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:meta/meta.dart'; + +/// {@template chat_screen} +/// ChatRoom widget. +/// {@endtemplate} +class ChatRoom extends StatefulWidget { + /// {@macro chat_screen} + const ChatRoom({super.key}); + + /// The state from the closest instance of this class + /// that encloses the given context, if any. + @internal + static _ChatRoomState? maybeOf(BuildContext context) => + context.findAncestorStateOfType<_ChatRoomState>(); + + @override + State createState() => _ChatRoomState(); +} + +/// State for widget ChatRoom. +class _ChatRoomState extends State { + /* #region Lifecycle */ + @override + void initState() { + super.initState(); + // Initial state initialization + } + + @override + void didUpdateWidget(ChatRoom oldWidget) { + super.didUpdateWidget(oldWidget); + // Widget configuration changed + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // The configuration of InheritedWidgets has changed + // Also called after initState but before build + } + + @override + void dispose() { + // Permanent removal of a tree stent + super.dispose(); + } + /* #endregion */ + + @override + Widget build(BuildContext context) => ListView.builder( + scrollDirection: Axis.vertical, + reverse: true, + itemCount: 1000, + itemBuilder: (context, index) => ListTile( + title: Text('Item $index'), + ), + ); +} diff --git a/example/lib/src/feature/chat/widget/chat_screen.dart b/example/lib/src/feature/chat/widget/chat_screen.dart index e63fcdb..397192a 100644 --- a/example/lib/src/feature/chat/widget/chat_screen.dart +++ b/example/lib/src/feature/chat/widget/chat_screen.dart @@ -1,14 +1,30 @@ import 'package:flutter/material.dart'; import 'package:spinifyapp/src/common/controller/state_consumer.dart'; +import 'package:spinifyapp/src/common/localization/localization.dart'; +import 'package:spinifyapp/src/feature/authentication/controller/authentication_controller.dart'; import 'package:spinifyapp/src/feature/authentication/widget/authentication_scope.dart'; +import 'package:spinifyapp/src/feature/chat/widget/chat_room.dart'; /// {@template chat_screen} /// ChatScreen widget. /// {@endtemplate} -class ChatScreen extends StatelessWidget { +class ChatScreen extends StatefulWidget { /// {@macro chat_screen} const ChatScreen({super.key}); + @override + State createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + late final AuthenticationController authController; + + @override + void initState() { + super.initState(); + authController = AuthenticationScope.controllerOf(context); + } + @override Widget build(BuildContext context) { final authController = AuthenticationScope.controllerOf(context); @@ -16,19 +32,26 @@ class ChatScreen extends StatelessWidget { controller: authController, builder: (context, state, _) => Scaffold( appBar: AppBar( - title: const Text('Chat'), + title: Text(Localization.of(context).title), + centerTitle: true, + automaticallyImplyLeading: false, + /* elevation: 4, */ + /* pinned: MediaQuery.of(context).size.height > 600, */ actions: [ IconButton( - onPressed: state.user.isNotAuthenticated - ? null - : () => authController.signOut(), + onPressed: () => + AuthenticationScope.controllerOf(context).signOut(), icon: const Icon(Icons.logout), ), const SizedBox(width: 16), ], ), - body: const Center( - child: Text('Chat'), + body: AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: state.user.map( + authenticated: (user) => const ChatRoom(), + unauthenticated: (_) => const SizedBox.expand(), + ), ), ), ); From 31bdbe6b7f91bfebd081a0da1afb2f1821c4a3f6 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 12 Aug 2023 13:32:37 +0400 Subject: [PATCH 25/46] Update example --- .../feature/authentication/model/user.dart | 10 ++- .../chat_connection_controller.dart | 20 ++++++ .../controller/chat_connection_state.dart | 5 ++ .../feature/chat/data/chat_repository.dart | 66 +++++++++++++++++++ .../src/feature/chat/widget/chat_room.dart | 56 ++++++++++------ .../src/feature/chat/widget/chat_screen.dart | 18 +---- 6 files changed, 135 insertions(+), 40 deletions(-) create mode 100644 example/lib/src/feature/chat/controller/chat_connection_controller.dart create mode 100644 example/lib/src/feature/chat/controller/chat_connection_state.dart create mode 100644 example/lib/src/feature/chat/data/chat_repository.dart diff --git a/example/lib/src/feature/authentication/model/user.dart b/example/lib/src/feature/authentication/model/user.dart index 09d2c29..7c2d0f4 100644 --- a/example/lib/src/feature/authentication/model/user.dart +++ b/example/lib/src/feature/authentication/model/user.dart @@ -53,8 +53,7 @@ class UnauthenticatedUser extends User { @override bool operator ==(Object other) => - identical(this, other) || - other is UnauthenticatedUser && username == other.username; + identical(this, other) || other is UnauthenticatedUser; @override String toString() => 'UnauthenticatedUser()'; @@ -127,7 +126,12 @@ final class AuthenticatedUser extends User { @override bool operator ==(Object other) => identical(this, other) || - other is AuthenticatedUser && username == other.username; + other is AuthenticatedUser && + username == other.username && + endpoint == other.endpoint && + token == other.token && + channel == other.channel && + secret == other.secret; @override String toString() => 'AuthenticatedUser(username: $username)'; diff --git a/example/lib/src/feature/chat/controller/chat_connection_controller.dart b/example/lib/src/feature/chat/controller/chat_connection_controller.dart new file mode 100644 index 0000000..38b222b --- /dev/null +++ b/example/lib/src/feature/chat/controller/chat_connection_controller.dart @@ -0,0 +1,20 @@ +import 'package:spinifyapp/src/common/controller/droppable_controller_concurrency.dart'; +import 'package:spinifyapp/src/common/controller/state_controller.dart'; +import 'package:spinifyapp/src/feature/chat/controller/chat_connection_state.dart'; +import 'package:spinifyapp/src/feature/chat/data/chat_repository.dart'; + +final class ChatConnectionController + extends StateController + with DroppableControllerConcurrency { + ChatConnectionController({required IChatRepository repository}) + : _repository = repository, + super(initialState: repository.connectionState) { + _repository.connectionStates.distinct().listen(setState); + } + + final IChatRepository _repository; + + void connect(String url) => handle(() => _repository.connect(url)); + + void disconnect() => handle(_repository.disconnect); +} diff --git a/example/lib/src/feature/chat/controller/chat_connection_state.dart b/example/lib/src/feature/chat/controller/chat_connection_state.dart new file mode 100644 index 0000000..75f56d6 --- /dev/null +++ b/example/lib/src/feature/chat/controller/chat_connection_state.dart @@ -0,0 +1,5 @@ +enum ChatConnectionState { + disconnected, + connecting, + connected, +} diff --git a/example/lib/src/feature/chat/data/chat_repository.dart b/example/lib/src/feature/chat/data/chat_repository.dart new file mode 100644 index 0000000..2430fb9 --- /dev/null +++ b/example/lib/src/feature/chat/data/chat_repository.dart @@ -0,0 +1,66 @@ +import 'dart:async'; + +import 'package:spinify/spinify.dart'; +import 'package:spinifyapp/src/feature/chat/controller/chat_connection_state.dart'; + +/// Chat repository +abstract interface class IChatRepository { + Stream get messages; + + /// Connection state + ChatConnectionState get connectionState; + + /// Connection states stream + Stream get connectionStates; + + /// Connect to chat server + Future connect(String url); + + /// Disconnect from chat server + Future disconnect(); + + /// Dispose + Future dispose(); +} + +final class ChatRepositorySpinifyImpl implements IChatRepository { + ChatRepositorySpinifyImpl({required FutureOr Function()? getToken}) + : _spinify = Spinify( + SpinifyConfig( + getToken: getToken, + ), + ); + + /// Centrifugo client + final Spinify _spinify; + + @override + ChatConnectionState get connectionState => + _spinifyStateToConnectionState(_spinify.state); + + @override + late final Stream connectionStates = + _spinify.states.map(_spinifyStateToConnectionState); + + ChatConnectionState _spinifyStateToConnectionState(SpinifyState state) => + switch (state) { + SpinifyState$Connected _ => ChatConnectionState.connected, + SpinifyState$Connecting _ => ChatConnectionState.connecting, + SpinifyState$Disconnected _ => ChatConnectionState.disconnected, + SpinifyState$Closed _ => ChatConnectionState.disconnected, + }; + + @override + Stream get messages => throw UnimplementedError(); + + @override + Future connect(String url) => _spinify.connect(url); + + @override + Future disconnect() => _spinify.disconnect(); + + @override + Future dispose() async { + await _spinify.close(); + } +} diff --git a/example/lib/src/feature/chat/widget/chat_room.dart b/example/lib/src/feature/chat/widget/chat_room.dart index ed0363b..a07c891 100644 --- a/example/lib/src/feature/chat/widget/chat_room.dart +++ b/example/lib/src/feature/chat/widget/chat_room.dart @@ -1,18 +1,19 @@ import 'package:flutter/material.dart'; -import 'package:meta/meta.dart'; +import 'package:spinifyapp/src/common/controller/state_consumer.dart'; +import 'package:spinifyapp/src/feature/authentication/model/user.dart'; +import 'package:spinifyapp/src/feature/chat/controller/chat_connection_controller.dart'; +import 'package:spinifyapp/src/feature/chat/controller/chat_connection_state.dart'; +import 'package:spinifyapp/src/feature/chat/data/chat_repository.dart'; /// {@template chat_screen} /// ChatRoom widget. /// {@endtemplate} class ChatRoom extends StatefulWidget { /// {@macro chat_screen} - const ChatRoom({super.key}); + const ChatRoom({required this.user, super.key}); - /// The state from the closest instance of this class - /// that encloses the given context, if any. - @internal - static _ChatRoomState? maybeOf(BuildContext context) => - context.findAncestorStateOfType<_ChatRoomState>(); + /// The user that is currently logged in + final AuthenticatedUser user; @override State createState() => _ChatRoomState(); @@ -20,34 +21,47 @@ class ChatRoom extends StatefulWidget { /// State for widget ChatRoom. class _ChatRoomState extends State { - /* #region Lifecycle */ + late final IChatRepository _repository; + late final ChatConnectionController _chatConnectionController; + @override void initState() { super.initState(); - // Initial state initialization + _repository = ChatRepositorySpinifyImpl(getToken: () => widget.user.token); + _chatConnectionController = + ChatConnectionController(repository: _repository); + _chatConnectionController.connect(widget.user.endpoint); } @override - void didUpdateWidget(ChatRoom oldWidget) { + void didUpdateWidget(covariant ChatRoom oldWidget) { super.didUpdateWidget(oldWidget); - // Widget configuration changed - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - // The configuration of InheritedWidgets has changed - // Also called after initState but before build + if (oldWidget.user != widget.user) { + _chatConnectionController.disconnect(); + _chatConnectionController.connect(widget.user.endpoint); + } } @override void dispose() { - // Permanent removal of a tree stent + _chatConnectionController.dispose(); + _repository.dispose(); super.dispose(); } - /* #endregion */ @override + Widget build(BuildContext context) => Center( + child: StateConsumer( + controller: _chatConnectionController, + builder: (context, state, child) => switch (state) { + ChatConnectionState.connecting => const CircularProgressIndicator(), + ChatConnectionState.connected => const Text('Connected'), + ChatConnectionState.disconnected => const Text('Disconnected'), + }, + ), + ); + + /* @override Widget build(BuildContext context) => ListView.builder( scrollDirection: Axis.vertical, reverse: true, @@ -55,5 +69,5 @@ class _ChatRoomState extends State { itemBuilder: (context, index) => ListTile( title: Text('Item $index'), ), - ); + ); */ } diff --git a/example/lib/src/feature/chat/widget/chat_screen.dart b/example/lib/src/feature/chat/widget/chat_screen.dart index 397192a..e8860ff 100644 --- a/example/lib/src/feature/chat/widget/chat_screen.dart +++ b/example/lib/src/feature/chat/widget/chat_screen.dart @@ -1,30 +1,16 @@ import 'package:flutter/material.dart'; import 'package:spinifyapp/src/common/controller/state_consumer.dart'; import 'package:spinifyapp/src/common/localization/localization.dart'; -import 'package:spinifyapp/src/feature/authentication/controller/authentication_controller.dart'; import 'package:spinifyapp/src/feature/authentication/widget/authentication_scope.dart'; import 'package:spinifyapp/src/feature/chat/widget/chat_room.dart'; /// {@template chat_screen} /// ChatScreen widget. /// {@endtemplate} -class ChatScreen extends StatefulWidget { +class ChatScreen extends StatelessWidget { /// {@macro chat_screen} const ChatScreen({super.key}); - @override - State createState() => _ChatScreenState(); -} - -class _ChatScreenState extends State { - late final AuthenticationController authController; - - @override - void initState() { - super.initState(); - authController = AuthenticationScope.controllerOf(context); - } - @override Widget build(BuildContext context) { final authController = AuthenticationScope.controllerOf(context); @@ -49,7 +35,7 @@ class _ChatScreenState extends State { body: AnimatedSwitcher( duration: const Duration(milliseconds: 250), child: state.user.map( - authenticated: (user) => const ChatRoom(), + authenticated: (user) => ChatRoom(user: user), unauthenticated: (_) => const SizedBox.expand(), ), ), From af4b63b9975df18aad159d235c171b54e3d8470c Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 12 Aug 2023 14:01:28 +0400 Subject: [PATCH 26/46] Update location of chat connection repository --- .../data/authentication_repository.dart | 11 +++++++++ .../chat_connection_controller.dart | 6 +++++ .../feature/chat/data/chat_repository.dart | 15 +----------- .../src/feature/chat/widget/chat_room.dart | 9 +++---- .../initialize_dependencies.dart | 24 +++++++++++-------- .../dependencies/model/dependencies.dart | 4 ++++ 6 files changed, 39 insertions(+), 30 deletions(-) diff --git a/example/lib/src/feature/authentication/data/authentication_repository.dart b/example/lib/src/feature/authentication/data/authentication_repository.dart index 059767b..3366be0 100644 --- a/example/lib/src/feature/authentication/data/authentication_repository.dart +++ b/example/lib/src/feature/authentication/data/authentication_repository.dart @@ -6,6 +6,7 @@ import 'package:spinifyapp/src/feature/authentication/model/user.dart'; abstract interface class IAuthenticationRepository { Stream userChanges(); FutureOr getUser(); + FutureOr getToken(); Future signIn(SignInData data); Future signOut(); } @@ -20,6 +21,16 @@ class AuthenticationRepositoryImpl implements IAuthenticationRepository { @override FutureOr getUser() => _user; + @override + Future getToken() async { + switch (_user) { + case AuthenticatedUser user: + return user.token; + case UnauthenticatedUser _: + throw Exception('User is not authenticated'); + } + } + @override Stream userChanges() => _userController.stream; diff --git a/example/lib/src/feature/chat/controller/chat_connection_controller.dart b/example/lib/src/feature/chat/controller/chat_connection_controller.dart index 38b222b..9495966 100644 --- a/example/lib/src/feature/chat/controller/chat_connection_controller.dart +++ b/example/lib/src/feature/chat/controller/chat_connection_controller.dart @@ -17,4 +17,10 @@ final class ChatConnectionController void connect(String url) => handle(() => _repository.connect(url)); void disconnect() => handle(_repository.disconnect); + + @override + void dispose() { + _repository.disconnect(); + super.dispose(); + } } diff --git a/example/lib/src/feature/chat/data/chat_repository.dart b/example/lib/src/feature/chat/data/chat_repository.dart index 2430fb9..fe24ed9 100644 --- a/example/lib/src/feature/chat/data/chat_repository.dart +++ b/example/lib/src/feature/chat/data/chat_repository.dart @@ -18,18 +18,10 @@ abstract interface class IChatRepository { /// Disconnect from chat server Future disconnect(); - - /// Dispose - Future dispose(); } final class ChatRepositorySpinifyImpl implements IChatRepository { - ChatRepositorySpinifyImpl({required FutureOr Function()? getToken}) - : _spinify = Spinify( - SpinifyConfig( - getToken: getToken, - ), - ); + ChatRepositorySpinifyImpl({required Spinify spinify}) : _spinify = spinify; /// Centrifugo client final Spinify _spinify; @@ -58,9 +50,4 @@ final class ChatRepositorySpinifyImpl implements IChatRepository { @override Future disconnect() => _spinify.disconnect(); - - @override - Future dispose() async { - await _spinify.close(); - } } diff --git a/example/lib/src/feature/chat/widget/chat_room.dart b/example/lib/src/feature/chat/widget/chat_room.dart index a07c891..162b2e5 100644 --- a/example/lib/src/feature/chat/widget/chat_room.dart +++ b/example/lib/src/feature/chat/widget/chat_room.dart @@ -3,7 +3,7 @@ import 'package:spinifyapp/src/common/controller/state_consumer.dart'; import 'package:spinifyapp/src/feature/authentication/model/user.dart'; import 'package:spinifyapp/src/feature/chat/controller/chat_connection_controller.dart'; import 'package:spinifyapp/src/feature/chat/controller/chat_connection_state.dart'; -import 'package:spinifyapp/src/feature/chat/data/chat_repository.dart'; +import 'package:spinifyapp/src/feature/dependencies/widget/dependencies_scope.dart'; /// {@template chat_screen} /// ChatRoom widget. @@ -21,15 +21,13 @@ class ChatRoom extends StatefulWidget { /// State for widget ChatRoom. class _ChatRoomState extends State { - late final IChatRepository _repository; late final ChatConnectionController _chatConnectionController; @override void initState() { super.initState(); - _repository = ChatRepositorySpinifyImpl(getToken: () => widget.user.token); - _chatConnectionController = - ChatConnectionController(repository: _repository); + _chatConnectionController = ChatConnectionController( + repository: DependenciesScope.of(context).chatRepository); _chatConnectionController.connect(widget.user.endpoint); } @@ -45,7 +43,6 @@ class _ChatRoomState extends State { @override void dispose() { _chatConnectionController.dispose(); - _repository.dispose(); super.dispose(); } diff --git a/example/lib/src/feature/dependencies/initialization/initialize_dependencies.dart b/example/lib/src/feature/dependencies/initialization/initialize_dependencies.dart index 29755c8..8706630 100644 --- a/example/lib/src/feature/dependencies/initialization/initialize_dependencies.dart +++ b/example/lib/src/feature/dependencies/initialization/initialize_dependencies.dart @@ -3,12 +3,14 @@ import 'dart:async'; import 'package:l/l.dart'; import 'package:meta/meta.dart'; import 'package:platform_info/platform_info.dart'; +import 'package:spinify/spinify.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/chat/data/chat_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'; @@ -24,6 +26,9 @@ class _MutableDependencies implements Dependencies { @override late IAuthenticationRepository authenticationRepository; + + @override + late IChatRepository chatRepository; } @internal @@ -99,16 +104,15 @@ mixin InitializeDependencies { AuthenticationRepositoryImpl(), ), ( - '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)), + 'Chat repository', + (dependencies) => + dependencies.chatRepository = ChatRepositorySpinifyImpl( + spinify: Spinify( + SpinifyConfig( + getToken: dependencies.authenticationRepository.getToken, + ), + ), + ), ), ]; } diff --git a/example/lib/src/feature/dependencies/model/dependencies.dart b/example/lib/src/feature/dependencies/model/dependencies.dart index ff8c1da..3087822 100644 --- a/example/lib/src/feature/dependencies/model/dependencies.dart +++ b/example/lib/src/feature/dependencies/model/dependencies.dart @@ -1,4 +1,5 @@ import 'package:spinifyapp/src/feature/authentication/data/authentication_repository.dart'; +import 'package:spinifyapp/src/feature/chat/data/chat_repository.dart'; import 'package:spinifyapp/src/feature/dependencies/model/app_metadata.dart'; abstract interface class Dependencies { @@ -7,4 +8,7 @@ abstract interface class Dependencies { /// Authentication repository abstract final IAuthenticationRepository authenticationRepository; + + /// Chat repository + abstract final IChatRepository chatRepository; } From f9c31a7cff74c45b4a9ea6037dc64575b1e56b4f Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 12 Aug 2023 14:18:59 +0400 Subject: [PATCH 27/46] Add username by default --- example/lib/src/common/constant/config.dart | 3 +++ .../lib/src/feature/authentication/widget/sign_in_form.dart | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/example/lib/src/common/constant/config.dart b/example/lib/src/common/constant/config.dart index bf2cc78..8227600 100644 --- a/example/lib/src/common/constant/config.dart +++ b/example/lib/src/common/constant/config.dart @@ -27,6 +27,9 @@ abstract final class Config { static const String centrifugeChannel = String.fromEnvironment('CENTRIFUGE_CHANNEL'); + /// Username by default. + static const String centrifugeUsername = + String.fromEnvironment('CENTRIFUGE_USERNAME'); // --- Layout --- // /// Maximum screen layout width for screen with list view. diff --git a/example/lib/src/feature/authentication/widget/sign_in_form.dart b/example/lib/src/feature/authentication/widget/sign_in_form.dart index 6cdaad5..5410ccd 100644 --- a/example/lib/src/feature/authentication/widget/sign_in_form.dart +++ b/example/lib/src/feature/authentication/widget/sign_in_form.dart @@ -76,7 +76,8 @@ class _SignInFormState extends State<_SignInForm> { _tokenController = TextEditingController(text: Config.centrifugeToken), _channelController = TextEditingController(text: Config.centrifugeChannel), - _usernameController = TextEditingController(), + _usernameController = + TextEditingController(text: Config.centrifugeUsername), _secretController = TextEditingController(); final FocusNode _endpointFocusNode = FocusNode(), From f2d847180a1da0d34d80a35c859bbc0ff7611802 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 12 Aug 2023 14:37:14 +0400 Subject: [PATCH 28/46] Encode channels --- example/.gitignore | 2 ++ .../data/authentication_repository.dart | 30 ++++++++++++++++++- example/pubspec.yaml | 2 ++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/example/.gitignore b/example/.gitignore index dce0393..9e45942 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -75,3 +75,5 @@ integration_test/screenshots/ # Firebase .firebase/ +# Config +config/local.json \ No newline at end of file diff --git a/example/lib/src/feature/authentication/data/authentication_repository.dart b/example/lib/src/feature/authentication/data/authentication_repository.dart index 3366be0..c5ae56f 100644 --- a/example/lib/src/feature/authentication/data/authentication_repository.dart +++ b/example/lib/src/feature/authentication/data/authentication_repository.dart @@ -1,5 +1,9 @@ import 'dart:async'; +import 'dart:convert'; +import 'package:convert/convert.dart'; +import 'package:crypto/crypto.dart'; +import 'package:spinify/spinify.dart'; import 'package:spinifyapp/src/feature/authentication/model/sign_in_data.dart'; import 'package:spinifyapp/src/feature/authentication/model/user.dart'; @@ -25,7 +29,31 @@ class AuthenticationRepositoryImpl implements IAuthenticationRepository { Future getToken() async { switch (_user) { case AuthenticatedUser user: - return user.token; + final AuthenticatedUser( + :String username, + :String token, + :String channel, + :String? secret + ) = user; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + String encodeChannel(String secret) => '$channel' + '#' + '${hex.encode(utf8.encoder.fuse(sha256).convert(secret).bytes)}'; + SpinifyJWT jwt = SpinifyJWT( + sub: username, + exp: now + (24 * 60 * 60), + iat: now, + info: { + 'username': username, + }, + channels: [ + switch (secret) { + String secret => encodeChannel(secret), + null => channel, + } + ], + ); + return jwt.encode(token); case UnauthenticatedUser _: throw Exception('User is not authenticated'); } diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 6da43d5..802f9dc 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -55,6 +55,8 @@ dependencies: path: any platform_info: ^4.0.2 win32: ^5.0.6 + crypto: ^3.0.3 + convert: ^3.1.1 # Desktop window_manager: ^0.3.5 From 7d27e8cccb10bd69b586a235dedca9c77217510b Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 12 Aug 2023 14:51:44 +0400 Subject: [PATCH 29/46] Update launch --- .vscode/launch.json | 15 +++++++++++++++ example/lib/src/common/constant/config.dart | 10 +++++++--- .../src/feature/chat/data/chat_repository.dart | 3 +-- lib/src/client/state.dart | 11 ++++------- lib/src/subscription/subscription_state.dart | 11 ++++------- 5 files changed, 31 insertions(+), 19 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 532c5dc..0a75671 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,6 +1,21 @@ { "version": "0.2.0", "configurations": [ + { + "name": "Example (lcl)", + "request": "launch", + "type": "dart", + "flutterMode": "debug", + "cwd": "${workspaceFolder}/example", + "program": "lib/main.dart", + "env": { + "ENVIRONMENT": "local" + }, + "console": "debugConsole", + "runTestsOnDevice": false, + "toolArgs": [], + "args": ["--dart-define-from-file=config/local.json"] + }, { "name": "Example (dev)", "request": "launch", diff --git a/example/lib/src/common/constant/config.dart b/example/lib/src/common/constant/config.dart index 8227600..772fef8 100644 --- a/example/lib/src/common/constant/config.dart +++ b/example/lib/src/common/constant/config.dart @@ -40,6 +40,9 @@ abstract final class Config { /// Environment flavor. /// e.g. development, staging, production enum EnvironmentFlavor { + /// Local + local('local'), + /// Development development('development'), @@ -55,9 +58,10 @@ enum EnvironmentFlavor { /// {@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, + 'local' || 'loc' || 'lcl' || 'l' => development, + 'development' || 'debug' || 'develop' || 'dev' || 'd' => development, + 'staging' || 'profile' || 'stage' || 'stg' || 's' => staging, + 'production' || 'release' || 'prod' || 'prd' || 'p' => production, _ => const bool.fromEnvironment('dart.vm.product') ? production : development, diff --git a/example/lib/src/feature/chat/data/chat_repository.dart b/example/lib/src/feature/chat/data/chat_repository.dart index fe24ed9..d79939e 100644 --- a/example/lib/src/feature/chat/data/chat_repository.dart +++ b/example/lib/src/feature/chat/data/chat_repository.dart @@ -38,8 +38,7 @@ final class ChatRepositorySpinifyImpl implements IChatRepository { switch (state) { SpinifyState$Connected _ => ChatConnectionState.connected, SpinifyState$Connecting _ => ChatConnectionState.connecting, - SpinifyState$Disconnected _ => ChatConnectionState.disconnected, - SpinifyState$Closed _ => ChatConnectionState.disconnected, + _ => ChatConnectionState.disconnected, }; @override diff --git a/lib/src/client/state.dart b/lib/src/client/state.dart index 8488ae8..f209fbf 100644 --- a/lib/src/client/state.dart +++ b/lib/src/client/state.dart @@ -136,7 +136,7 @@ sealed class SpinifyState extends _$SpinifyStateBase { /// {@macro state} /// {@category Client} /// {@category Entity} -final class SpinifyState$Disconnected extends SpinifyState with _$SpinifyState { +final class SpinifyState$Disconnected extends SpinifyState { /// Disconnected /// /// {@macro state} @@ -206,7 +206,7 @@ final class SpinifyState$Disconnected extends SpinifyState with _$SpinifyState { /// {@macro state} /// {@category Client} /// {@category Entity} -final class SpinifyState$Connecting extends SpinifyState with _$SpinifyState { +final class SpinifyState$Connecting extends SpinifyState { /// Connecting /// /// {@macro state} @@ -258,7 +258,7 @@ final class SpinifyState$Connecting extends SpinifyState with _$SpinifyState { /// {@macro state} /// {@category Client} /// {@category Entity} -final class SpinifyState$Connected extends SpinifyState with _$SpinifyState { +final class SpinifyState$Connected extends SpinifyState { /// Connected /// /// {@macro state} @@ -365,7 +365,7 @@ final class SpinifyState$Connected extends SpinifyState with _$SpinifyState { /// {@macro state} /// {@category Client} /// {@category Entity} -final class SpinifyState$Closed extends SpinifyState with _$SpinifyState { +final class SpinifyState$Closed extends SpinifyState { /// Permanently closed /// /// {@macro state} @@ -412,9 +412,6 @@ final class SpinifyState$Closed extends SpinifyState with _$SpinifyState { String toString() => r'SpinifyState$Closed{}'; } -/// {@nodoc} -base mixin _$SpinifyState on SpinifyState {} - /// Pattern matching for [SpinifyState]. /// {@category Entity} typedef SpinifyStateMatch = R Function(S state); diff --git a/lib/src/subscription/subscription_state.dart b/lib/src/subscription/subscription_state.dart index e04971b..b3b7f1e 100644 --- a/lib/src/subscription/subscription_state.dart +++ b/lib/src/subscription/subscription_state.dart @@ -54,7 +54,7 @@ sealed class SpinifySubscriptionState extends _$SpinifySubscriptionStateBase { /// {@category Subscription} /// {@category Entity} final class SpinifySubscriptionState$Unsubscribed - extends SpinifySubscriptionState with _$SpinifySubscriptionState { + extends SpinifySubscriptionState { /// {@nodoc} SpinifySubscriptionState$Unsubscribed({ required this.code, @@ -122,7 +122,7 @@ final class SpinifySubscriptionState$Unsubscribed /// {@category Subscription} /// {@category Entity} final class SpinifySubscriptionState$Subscribing - extends SpinifySubscriptionState with _$SpinifySubscriptionState { + extends SpinifySubscriptionState { /// {@nodoc} SpinifySubscriptionState$Subscribing({ DateTime? timestamp, @@ -174,8 +174,8 @@ final class SpinifySubscriptionState$Subscribing /// {@macro subscription_state} /// {@category Subscription} /// {@category Entity} -final class SpinifySubscriptionState$Subscribed extends SpinifySubscriptionState - with _$SpinifySubscriptionState { +final class SpinifySubscriptionState$Subscribed + extends SpinifySubscriptionState { /// {@nodoc} SpinifySubscriptionState$Subscribed({ DateTime? timestamp, @@ -232,9 +232,6 @@ final class SpinifySubscriptionState$Subscribed extends SpinifySubscriptionState String toString() => r'SpinifySubscriptionState$Subscribed{}'; } -/// {@nodoc} -base mixin _$SpinifySubscriptionState on SpinifySubscriptionState {} - /// Pattern matching for [SpinifySubscriptionState]. /// {@category Entity} typedef SpinifySubscriptionStateMatch = R From 09d33ab12a314556e70cffcea9c3b83edc37b8ab Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 12 Aug 2023 17:45:25 +0400 Subject: [PATCH 30/46] Update message sender --- .../data/authentication_repository.dart | 28 ++- .../authentication/model/sign_in_data.dart | 2 +- .../controller/chat_connection_state.dart | 122 ++++++++++- .../controller/chat_messages_controller.dart | 41 ++++ .../chat/controller/chat_messages_state.dart | 161 ++++++++++++++ .../feature/chat/data/chat_repository.dart | 44 +++- .../lib/src/feature/chat/model/message.dart | 200 ++++++++++++++++++ .../src/feature/chat/widget/chat_room.dart | 138 +++++++++--- lib/src/client/spinify.dart | 2 +- .../client_subscription_impl.dart | 3 +- .../server_subscription_impl.dart | 3 +- lib/src/util/event_queue.dart | 18 +- 12 files changed, 708 insertions(+), 54 deletions(-) create mode 100644 example/lib/src/feature/chat/controller/chat_messages_controller.dart create mode 100644 example/lib/src/feature/chat/controller/chat_messages_state.dart create mode 100644 example/lib/src/feature/chat/model/message.dart diff --git a/example/lib/src/feature/authentication/data/authentication_repository.dart b/example/lib/src/feature/authentication/data/authentication_repository.dart index c5ae56f..d44c00e 100644 --- a/example/lib/src/feature/authentication/data/authentication_repository.dart +++ b/example/lib/src/feature/authentication/data/authentication_repository.dart @@ -48,8 +48,8 @@ class AuthenticationRepositoryImpl implements IAuthenticationRepository { }, channels: [ switch (secret) { + null || '' => channel, String secret => encodeChannel(secret), - null => channel, } ], ); @@ -63,17 +63,27 @@ class AuthenticationRepositoryImpl implements IAuthenticationRepository { Stream userChanges() => _userController.stream; @override - Future signIn(SignInData data) => Future.sync( - () => _userController.add( - _user = User.authenticated( + Future signIn(SignInData data) { + String encryptedChannel(String channel, String secret) => '${data.channel}' + '#' + '${hex.encode(utf8.encoder.fuse(sha256).convert(secret).bytes)}'; + return Future.sync( + () => _userController.add( + _user = User.authenticated( username: data.username, endpoint: data.endpoint, token: data.token, - channel: data.channel, - secret: data.secret, - ), - ), - ); + channel: switch (data.secret) { + null || '' => data.channel, + String secret => encryptedChannel(data.channel, secret), + }, + secret: switch (data.secret) { + null || '' => null, + String secret => secret, + }), + ), + ); + } @override Future signOut() => Future.sync( diff --git a/example/lib/src/feature/authentication/model/sign_in_data.dart b/example/lib/src/feature/authentication/model/sign_in_data.dart index b6757e5..77a8a9a 100644 --- a/example/lib/src/feature/authentication/model/sign_in_data.dart +++ b/example/lib/src/feature/authentication/model/sign_in_data.dart @@ -27,7 +27,7 @@ final class SignInData { final String? secret; static final RegExp _urlValidator = RegExp( - r'^(https?:\/\/)?(localhost|((([a-z\d]([a-z\d-]*[a-z\d])*)\.)+[a-z]{2,}|((\d{1,3}\.){3}\d{1,3})))?(:\d+)?(\/[-a-z\d%_.~+]*)*(\?[;&a-z\d%_.~+=-]*)?(\#[-a-z\d_]*)?$', + r'^(https?|ws|wss):\/\/(localhost|((([a-z\d]([a-z\d-]*[a-z\d])*)\.)+[a-z]{2,}|((\d{1,3}\.){3}\d{1,3})))?(:\d+)?(\/[-a-z\d%_.~+]*)*(\?[;&a-z\d%_.~+=-]*)?(\#[-a-z\d_]*)?$', caseSensitive: false, multiLine: false, ); diff --git a/example/lib/src/feature/chat/controller/chat_connection_state.dart b/example/lib/src/feature/chat/controller/chat_connection_state.dart index 75f56d6..cac8687 100644 --- a/example/lib/src/feature/chat/controller/chat_connection_state.dart +++ b/example/lib/src/feature/chat/controller/chat_connection_state.dart @@ -1,5 +1,119 @@ -enum ChatConnectionState { - disconnected, - connecting, - connected, +import 'package:meta/meta.dart'; + +/// {@template chat_connection_state} +/// ChatConnectionState. +/// {@endtemplate} +sealed class ChatConnectionState extends _$ChatConnectionStateBase { + /// Disconnected + /// {@macro chat_connection_state} + const factory ChatConnectionState.disconnected({ + String message, + }) = ChatConnectionState$Disconnected; + + /// Connecting + /// {@macro chat_connection_state} + const factory ChatConnectionState.connecting({ + String message, + }) = ChatConnectionState$Connecting; + + /// Connected + /// {@macro chat_connection_state} + const factory ChatConnectionState.connected({ + String message, + }) = ChatConnectionState$Connected; + + /// {@macro chat_connection_state} + const ChatConnectionState({required super.message}); +} + +/// Disconnected +/// {@nodoc} +final class ChatConnectionState$Disconnected extends ChatConnectionState { + /// {@nodoc} + const ChatConnectionState$Disconnected({super.message = 'Disconnected'}); +} + +/// Connecting +/// {@nodoc} +final class ChatConnectionState$Connecting extends ChatConnectionState { + /// {@nodoc} + const ChatConnectionState$Connecting({super.message = 'Connecting'}); +} + +/// Connected +/// {@nodoc} +final class ChatConnectionState$Connected extends ChatConnectionState { + /// {@nodoc} + const ChatConnectionState$Connected({super.message = 'Connected'}); +} + +/// Pattern matching for [ChatConnectionState]. +typedef ChatConnectionStateMatch = R Function( + S state); + +/// {@nodoc} +@immutable +abstract base class _$ChatConnectionStateBase { + /// {@nodoc} + const _$ChatConnectionStateBase({required this.message}); + + /// Message or state description. + @nonVirtual + final String message; + + /// Is connecting? + bool get isConnecting => + maybeMap(orElse: () => false, connecting: (_) => true); + + /// Is connected? + bool get isConnected => + maybeMap(orElse: () => false, connected: (_) => true); + + /// Is disconnected? + bool get isDisconnected => + maybeMap(orElse: () => false, disconnected: (_) => true); + + /// Pattern matching for [ChatConnectionState]. + R map({ + required ChatConnectionStateMatch + disconnected, + required ChatConnectionStateMatch + connecting, + required ChatConnectionStateMatch + connected, + }) => + switch (this) { + ChatConnectionState$Disconnected s => disconnected(s), + ChatConnectionState$Connecting s => connecting(s), + ChatConnectionState$Connected s => connected(s), + _ => throw AssertionError(), + }; + + /// Pattern matching for [ChatConnectionState]. + R maybeMap({ + ChatConnectionStateMatch? disconnected, + ChatConnectionStateMatch? connecting, + ChatConnectionStateMatch? connected, + required R Function() orElse, + }) => + map( + disconnected: disconnected ?? (_) => orElse(), + connecting: connecting ?? (_) => orElse(), + connected: connected ?? (_) => orElse(), + ); + + /// Pattern matching for [ChatConnectionState]. + R? mapOrNull({ + ChatConnectionStateMatch? disconnected, + ChatConnectionStateMatch? connecting, + ChatConnectionStateMatch? connected, + }) => + map( + disconnected: disconnected ?? (_) => null, + connecting: connecting ?? (_) => null, + connected: connected ?? (_) => null, + ); + + @override + String toString() => message; } diff --git a/example/lib/src/feature/chat/controller/chat_messages_controller.dart b/example/lib/src/feature/chat/controller/chat_messages_controller.dart new file mode 100644 index 0000000..7c81053 --- /dev/null +++ b/example/lib/src/feature/chat/controller/chat_messages_controller.dart @@ -0,0 +1,41 @@ +import 'package:l/l.dart'; +import 'package:spinifyapp/src/common/controller/droppable_controller_concurrency.dart'; +import 'package:spinifyapp/src/common/controller/state_controller.dart'; +import 'package:spinifyapp/src/feature/authentication/model/user.dart'; +import 'package:spinifyapp/src/feature/chat/controller/chat_messages_state.dart'; +import 'package:spinifyapp/src/feature/chat/data/chat_repository.dart'; + +final class ChatMessagesController extends StateController + with DroppableControllerConcurrency { + ChatMessagesController({required IChatRepository repository}) + : _repository = repository, + super(initialState: ChatMessagesState.initial); + + final IChatRepository _repository; + + void sendMessage(AuthenticatedUser user, String message) => handle( + () async { + l.v6('Sending message'); + await _repository.sendMessage(user, message); + setState(ChatMessagesState.successful( + data: state.data, message: 'Message sent')); + l.v6('Message sent'); + }, + (error, stackTrace) { + l.w('Error sending message: $error', stackTrace); + setState( + ChatMessagesState.error( + data: state.data, message: 'Error sending message'), + ); + }, + () => setState(ChatMessagesState.idle(data: state.data)), + ); + + void disconnect() => handle(_repository.disconnect); + + @override + void dispose() { + _repository.disconnect(); + super.dispose(); + } +} diff --git a/example/lib/src/feature/chat/controller/chat_messages_state.dart b/example/lib/src/feature/chat/controller/chat_messages_state.dart new file mode 100644 index 0000000..1acf9d1 --- /dev/null +++ b/example/lib/src/feature/chat/controller/chat_messages_state.dart @@ -0,0 +1,161 @@ +import 'package:meta/meta.dart'; + +/// {@template chat_messages_state_placeholder} +/// Entity placeholder for ChatMessagesState +/// {@endtemplate} +typedef ChatMessagesEntity = Object; + +/// {@template chat_messages_state} +/// ChatMessagesState. +/// {@endtemplate} +sealed class ChatMessagesState extends _$ChatMessagesStateBase { + /// Idling state + /// {@macro chat_messages_state} + const factory ChatMessagesState.idle({ + required ChatMessagesEntity? data, + String message, + }) = ChatMessagesState$Idle; + + /// Processing + /// {@macro chat_messages_state} + const factory ChatMessagesState.processing({ + required ChatMessagesEntity? data, + String message, + }) = ChatMessagesState$Processing; + + /// Successful + /// {@macro chat_messages_state} + const factory ChatMessagesState.successful({ + required ChatMessagesEntity? data, + String message, + }) = ChatMessagesState$Successful; + + /// An error has occurred + /// {@macro chat_messages_state} + const factory ChatMessagesState.error({ + required ChatMessagesEntity? data, + String message, + }) = ChatMessagesState$Error; + + /// {@macro chat_messages_state} + const ChatMessagesState({required super.data, required super.message}); + + static ChatMessagesState get initial => + const ChatMessagesState.idle(data: null); +} + +/// Idling state +/// {@nodoc} +final class ChatMessagesState$Idle extends ChatMessagesState { + /// {@nodoc} + const ChatMessagesState$Idle({required super.data, super.message = 'Idling'}); +} + +/// Processing +/// {@nodoc} +final class ChatMessagesState$Processing extends ChatMessagesState { + /// {@nodoc} + const ChatMessagesState$Processing( + {required super.data, super.message = 'Processing'}); +} + +/// Successful +/// {@nodoc} +final class ChatMessagesState$Successful extends ChatMessagesState { + /// {@nodoc} + const ChatMessagesState$Successful( + {required super.data, super.message = 'Successful'}); +} + +/// Error +/// {@nodoc} +final class ChatMessagesState$Error extends ChatMessagesState { + /// {@nodoc} + const ChatMessagesState$Error( + {required super.data, super.message = 'An error has occurred.'}); +} + +/// Pattern matching for [ChatMessagesState]. +typedef ChatMessagesStateMatch = R Function( + S state); + +/// {@nodoc} +@immutable +abstract base class _$ChatMessagesStateBase { + /// {@nodoc} + const _$ChatMessagesStateBase({required this.data, required this.message}); + + /// Data entity payload. + @nonVirtual + final ChatMessagesEntity? data; + + /// Message or state description. + @nonVirtual + final String message; + + /// Has data? + bool get hasData => data != null; + + /// If an error has occurred? + bool get hasError => maybeMap(orElse: () => false, error: (_) => true); + + /// Is in progress state? + bool get isProcessing => + maybeMap(orElse: () => false, processing: (_) => true); + + /// Is in idle state? + bool get isIdling => !isProcessing; + + /// Pattern matching for [ChatMessagesState]. + R map({ + required ChatMessagesStateMatch idle, + required ChatMessagesStateMatch processing, + required ChatMessagesStateMatch successful, + required ChatMessagesStateMatch error, + }) => + switch (this) { + ChatMessagesState$Idle s => idle(s), + ChatMessagesState$Processing s => processing(s), + ChatMessagesState$Successful s => successful(s), + ChatMessagesState$Error s => error(s), + _ => throw AssertionError(), + }; + + /// Pattern matching for [ChatMessagesState]. + R maybeMap({ + ChatMessagesStateMatch? idle, + ChatMessagesStateMatch? processing, + ChatMessagesStateMatch? successful, + ChatMessagesStateMatch? error, + required R Function() orElse, + }) => + map( + idle: idle ?? (_) => orElse(), + processing: processing ?? (_) => orElse(), + successful: successful ?? (_) => orElse(), + error: error ?? (_) => orElse(), + ); + + /// Pattern matching for [ChatMessagesState]. + R? mapOrNull({ + ChatMessagesStateMatch? idle, + ChatMessagesStateMatch? processing, + ChatMessagesStateMatch? successful, + ChatMessagesStateMatch? error, + }) => + map( + idle: idle ?? (_) => null, + processing: processing ?? (_) => null, + successful: successful ?? (_) => null, + error: error ?? (_) => null, + ); + + @override + int get hashCode => data.hashCode; + + @override + bool operator ==(Object other) => identical(this, other); + + @override + String toString() => 'ChatMessagesState(data: $data, message: $message)'; +} diff --git a/example/lib/src/feature/chat/data/chat_repository.dart b/example/lib/src/feature/chat/data/chat_repository.dart index d79939e..f4846ec 100644 --- a/example/lib/src/feature/chat/data/chat_repository.dart +++ b/example/lib/src/feature/chat/data/chat_repository.dart @@ -1,7 +1,9 @@ import 'dart:async'; import 'package:spinify/spinify.dart'; +import 'package:spinifyapp/src/feature/authentication/model/user.dart'; import 'package:spinifyapp/src/feature/chat/controller/chat_connection_state.dart'; +import 'package:spinifyapp/src/feature/chat/model/message.dart'; /// Chat repository abstract interface class IChatRepository { @@ -18,6 +20,9 @@ abstract interface class IChatRepository { /// Disconnect from chat server Future disconnect(); + + /// Send message to chat server + Future sendMessage(AuthenticatedUser user, String message); } final class ChatRepositorySpinifyImpl implements IChatRepository { @@ -36,9 +41,9 @@ final class ChatRepositorySpinifyImpl implements IChatRepository { ChatConnectionState _spinifyStateToConnectionState(SpinifyState state) => switch (state) { - SpinifyState$Connected _ => ChatConnectionState.connected, - SpinifyState$Connecting _ => ChatConnectionState.connecting, - _ => ChatConnectionState.disconnected, + SpinifyState$Connected _ => const ChatConnectionState.connected(), + SpinifyState$Connecting _ => const ChatConnectionState.connecting(), + _ => const ChatConnectionState.disconnected(), }; @override @@ -49,4 +54,37 @@ final class ChatRepositorySpinifyImpl implements IChatRepository { @override Future disconnect() => _spinify.disconnect(); + + @override + Future sendMessage(AuthenticatedUser user, String message) async { + if (!_spinify.state.isConnected) { + throw Exception('Spinify is not connected'); + } + final serverChannels = _spinify.subscriptions.server.values.toList(); + final AuthenticatedUser(:channel, :username, :secret) = user; + if (!serverChannels.any((c) => c.channel == channel)) + throw Exception('Spinify server channel is not set'); + List data; + switch (secret) { + case null || '': + data = const PlainMessageEncoder().convert( + PlainMessage( + author: username, + text: message, + createdAt: DateTime.now(), + version: 1, + ), + ); + case String secret: + data = EncryptedMessageEncoder(secretKey: secret).convert( + EncryptedMessage( + author: username, + text: message, + createdAt: DateTime.now(), + version: 1, + ), + ); + } + await _spinify.publish(channel, data); + } } diff --git a/example/lib/src/feature/chat/model/message.dart b/example/lib/src/feature/chat/model/message.dart new file mode 100644 index 0000000..27655df --- /dev/null +++ b/example/lib/src/feature/chat/model/message.dart @@ -0,0 +1,200 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:meta/meta.dart'; + +@immutable +sealed class Message { + const Message({ + required this.author, + required this.text, + required this.version, + required this.createdAt, + }); + + /// The type of the message. + abstract final String type; + + /// The author of the message. + final String author; + + /// The text of the message. + final String text; + + /// The version of the message. + final int version; + + /// The time the message was created. + final DateTime createdAt; + + Map toJson(); +} + +final class PlainMessage extends Message { + const PlainMessage({ + required super.author, + required super.text, + required super.version, + required super.createdAt, + }); + + factory PlainMessage.fromJson(Map json) { + if (json + case { + 'type': 'plain', + 'author': String author, + 'text': String text, + 'version': int version, + 'createdAt': int createdAt, + }) + return PlainMessage( + author: author, + text: text, + version: version, + createdAt: DateTime.fromMillisecondsSinceEpoch(createdAt * 1000), + ); + throw const FormatException('Invalid message type'); + } + + @override + String get type => 'plain'; + + @override + Map toJson() => { + 'type': type, + 'author': author, + 'text': text, + 'version': version, + 'createdAt': createdAt.millisecondsSinceEpoch ~/ 1000, + }; +} + +final class EncryptedMessage extends Message { + const EncryptedMessage({ + required super.author, + required super.text, + required super.version, + required super.createdAt, + }); + + factory EncryptedMessage.fromJson(Map json) { + if (json + case { + 'type': 'plain', + 'author': String author, + 'text': String text, + 'version': int version, + 'createdAt': int createdAt, + }) + return EncryptedMessage( + author: author, + text: text, + version: version, + createdAt: DateTime.fromMillisecondsSinceEpoch(createdAt * 1000), + ); + throw const FormatException('Invalid message type'); + } + + @override + String get type => 'encrypted'; + + @override + Map toJson() => { + 'type': type, + 'author': author, + 'text': text, + 'version': version, + 'createdAt': createdAt.millisecondsSinceEpoch ~/ 1000, + }; +} + +@immutable +final class PlainMessageCodec extends Codec> { + const PlainMessageCodec(); + + @override + Converter, PlainMessage> get decoder => const PlainMessageDecoder(); + + @override + Converter> get encoder => const PlainMessageEncoder(); +} + +final class PlainMessageDecoder extends Converter, PlainMessage> { + const PlainMessageDecoder(); + + @override + PlainMessage convert(List input) => + PlainMessage.fromJson(_$bytesDecoder.convert(input)); +} + +final class PlainMessageEncoder extends Converter> { + const PlainMessageEncoder(); + + @override + List convert(PlainMessage input) => + _$bytesEncoder.convert(input.toJson()); +} + +@immutable +final class EncryptedMessageCodec extends Codec> { + const EncryptedMessageCodec({required this.secretKey}); + + final String secretKey; + + @override + Converter, EncryptedMessage> get decoder => + EncryptedMessageDecoder(secretKey: secretKey); + + @override + Converter> get encoder => + EncryptedMessageEncoder(secretKey: secretKey); +} + +final class EncryptedMessageDecoder + extends Converter, EncryptedMessage> { + EncryptedMessageDecoder({required String secretKey}) + : _secret = utf8.encode(secretKey); + + final List _secret; + late final int secretLength = _secret.length; + late final Digest _digest = sha256.convert(_secret); + + @override + EncryptedMessage convert(List input) { + if (input.length < 32) throw const FormatException('Message too short'); + final signature = input.sublist(input.length - 32); + if (_digest != Digest(signature)) + throw const FormatException('Invalid signature'); + final bytes = input.sublist(0, input.length - 32); + for (var i = 0; i < bytes.length; i++) + bytes[i] ^= _secret[i % secretLength]; + return EncryptedMessage.fromJson(_$bytesDecoder.convert(bytes)); + } +} + +final class EncryptedMessageEncoder + extends Converter> { + EncryptedMessageEncoder({required String secretKey}) + : _secret = utf8.encode(secretKey); + + final List _secret; + late final int secretLength = _secret.length; + late final Digest _digest = sha256.convert(_secret); + + @override + List convert(EncryptedMessage input) { + final bytes = _$bytesEncoder.convert(input.toJson()); + for (var i = 0; i < bytes.length; i++) + bytes[i] ^= _secret[i % secretLength]; + return bytes + _digest.bytes; + } +} + +final Converter, Map> _$bytesDecoder = + const Utf8Decoder() + .fuse(const JsonDecoder().cast>()); + +final Converter, List> _$bytesEncoder = + const JsonEncoder() + .cast, String>() + .fuse(const Utf8Encoder()); diff --git a/example/lib/src/feature/chat/widget/chat_room.dart b/example/lib/src/feature/chat/widget/chat_room.dart index 162b2e5..b60f9d3 100644 --- a/example/lib/src/feature/chat/widget/chat_room.dart +++ b/example/lib/src/feature/chat/widget/chat_room.dart @@ -3,6 +3,8 @@ import 'package:spinifyapp/src/common/controller/state_consumer.dart'; import 'package:spinifyapp/src/feature/authentication/model/user.dart'; import 'package:spinifyapp/src/feature/chat/controller/chat_connection_controller.dart'; import 'package:spinifyapp/src/feature/chat/controller/chat_connection_state.dart'; +import 'package:spinifyapp/src/feature/chat/controller/chat_messages_controller.dart'; +import 'package:spinifyapp/src/feature/chat/controller/chat_messages_state.dart'; import 'package:spinifyapp/src/feature/dependencies/widget/dependencies_scope.dart'; /// {@template chat_screen} @@ -21,50 +23,134 @@ class ChatRoom extends StatefulWidget { /// State for widget ChatRoom. class _ChatRoomState extends State { - late final ChatConnectionController _chatConnectionController; + late final ChatConnectionController _connectionController; + late final ChatMessagesController _messagesController; + final TextEditingController _textEditingController = TextEditingController(); @override void initState() { super.initState(); - _chatConnectionController = ChatConnectionController( - repository: DependenciesScope.of(context).chatRepository); - _chatConnectionController.connect(widget.user.endpoint); + final repository = DependenciesScope.of(context).chatRepository; + _connectionController = ChatConnectionController(repository: repository); + _messagesController = ChatMessagesController(repository: repository); + _connectionController.connect(widget.user.endpoint); } @override void didUpdateWidget(covariant ChatRoom oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.user != widget.user) { - _chatConnectionController.disconnect(); - _chatConnectionController.connect(widget.user.endpoint); + _connectionController.disconnect(); + _connectionController.connect(widget.user.endpoint); } } @override void dispose() { - _chatConnectionController.dispose(); + _messagesController.dispose(); + _connectionController.dispose(); + _textEditingController.dispose(); super.dispose(); } @override - Widget build(BuildContext context) => Center( - child: StateConsumer( - controller: _chatConnectionController, - builder: (context, state, child) => switch (state) { - ChatConnectionState.connecting => const CircularProgressIndicator(), - ChatConnectionState.connected => const Text('Connected'), - ChatConnectionState.disconnected => const Text('Disconnected'), - }, - ), + Widget build(BuildContext context) => Stack( + children: [ + Positioned.fill( + child: ListView.builder( + scrollDirection: Axis.vertical, + padding: const EdgeInsets.only( + top: 16, + bottom: 84, + ), + reverse: true, + itemCount: 1000, + itemBuilder: (context, index) => ListTile( + title: Text('Item $index'), + ), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.only( + left: 8, + right: 8, + bottom: 16, + ), + child: SizedBox( + width: 480, + height: 48, + child: StateConsumer( + controller: _connectionController, + builder: (context, connectionState, _) => + StateConsumer( + controller: _messagesController, + listener: (context, previous, current) { + switch (current) { + case ChatMessagesState$Successful state: + _textEditingController.clear(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), + ), + ); + case ChatMessagesState$Error state: + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), + backgroundColor: Colors.red, + ), + ); + default: + break; + } + }, + builder: (context, messagesState, child) => Row( + children: [ + Expanded( + child: TextField( + controller: _textEditingController, + enabled: connectionState.isConnected, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'Enter a search term', + ), + ), + ), + ValueListenableBuilder( + valueListenable: _textEditingController, + builder: (context, value, _) { + final enabled = connectionState.isConnected && + messagesState.isIdling && + value.text.isNotEmpty; + return IconButton( + icon: AnimatedSwitcher( + duration: const Duration(milliseconds: 350), + child: switch (connectionState) { + ChatConnectionState$Connecting _ => + const CircularProgressIndicator(), + ChatConnectionState$Connected _ => + const Icon(Icons.send), + ChatConnectionState$Disconnected _ => + const Icon(Icons.send_outlined), + }, + ), + onPressed: enabled + ? () => _messagesController.sendMessage( + widget.user, + 'Hello World', + ) + : null, + ); + }, + ), + ], + ), + ), + ), + )), + ), + ], ); - - /* @override - Widget build(BuildContext context) => ListView.builder( - scrollDirection: Axis.vertical, - reverse: true, - itemCount: 1000, - itemBuilder: (context, index) => ListTile( - title: Text('Item $index'), - ), - ); */ } diff --git a/lib/src/client/spinify.dart b/lib/src/client/spinify.dart index c8a5404..6455ad8 100644 --- a/lib/src/client/spinify.dart +++ b/lib/src/client/spinify.dart @@ -66,7 +66,7 @@ final class Spinify extends SpinifyBase SpinifyPresenceMixin, SpinifyHistoryMixin, SpinifyRPCMixin, - SpinifyQueueMixin, + /* SpinifyQueueMixin, */ SpinifyMetricsMixin { /// {@macro spinify} Spinify([SpinifyConfig? config]) : super(config ?? SpinifyConfig.byDefault()); diff --git a/lib/src/subscription/client_subscription_impl.dart b/lib/src/subscription/client_subscription_impl.dart index 9829b3d..be20e55 100644 --- a/lib/src/subscription/client_subscription_impl.dart +++ b/lib/src/subscription/client_subscription_impl.dart @@ -38,8 +38,7 @@ final class SpinifyClientSubscriptionImpl extends SpinifyClientSubscriptionBase SpinifyClientSubscriptionSubscribeMixin, SpinifyClientSubscriptionPublishingMixin, SpinifyClientSubscriptionHistoryMixin, - SpinifyClientSubscriptionPresenceMixin, - SpinifyClientSubscriptionQueueMixin { + SpinifyClientSubscriptionPresenceMixin /* SpinifyClientSubscriptionQueueMixin */ { /// {@nodoc} SpinifyClientSubscriptionImpl({ required super.channel, diff --git a/lib/src/subscription/server_subscription_impl.dart b/lib/src/subscription/server_subscription_impl.dart index 555098e..acdef11 100644 --- a/lib/src/subscription/server_subscription_impl.dart +++ b/lib/src/subscription/server_subscription_impl.dart @@ -36,8 +36,7 @@ final class SpinifyServerSubscriptionImpl extends SpinifyServerSubscriptionBase SpinifyServerSubscriptionReadyMixin, SpinifyServerSubscriptionPublishingMixin, SpinifyServerSubscriptionHistoryMixin, - SpinifyServerSubscriptionPresenceMixin, - SpinifyServerSubscriptionQueueMixin { + SpinifyServerSubscriptionPresenceMixin /* SpinifyServerSubscriptionQueueMixin */ { /// {@nodoc} SpinifyServerSubscriptionImpl({ required super.channel, diff --git a/lib/src/util/event_queue.dart b/lib/src/util/event_queue.dart index 1bb6bfb..266b177 100644 --- a/lib/src/util/event_queue.dart +++ b/lib/src/util/event_queue.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:collection'; import 'package:meta/meta.dart'; +import 'package:spinify/src/util/logger.dart'; /// {@nodoc} @internal @@ -46,17 +47,22 @@ final class SpinifyEventQueue { await event(); } } on Object catch (error, stackTrace) { - /* warning( + 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; + if (_queue.isEmpty) { + _processing = null; + return false; + } else { + _queue.removeFirst(); + final isEmpty = _queue.isEmpty; + if (isEmpty) _processing = null; + return !isEmpty; + } }); } From 77366f37126ee3961b60c0d2c62d014a1f7d5ff6 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 12 Aug 2023 17:59:06 +0400 Subject: [PATCH 31/46] Fix queue --- .../src/feature/chat/widget/chat_room.dart | 156 +++++++++--------- lib/src/client/spinify.dart | 6 +- .../client_subscription_impl.dart | 9 +- .../server_subscription_impl.dart | 3 +- 4 files changed, 83 insertions(+), 91 deletions(-) diff --git a/example/lib/src/feature/chat/widget/chat_room.dart b/example/lib/src/feature/chat/widget/chat_room.dart index b60f9d3..4192a4d 100644 --- a/example/lib/src/feature/chat/widget/chat_room.dart +++ b/example/lib/src/feature/chat/widget/chat_room.dart @@ -54,15 +54,12 @@ class _ChatRoomState extends State { } @override - Widget build(BuildContext context) => Stack( + Widget build(BuildContext context) => Column( children: [ - Positioned.fill( + Expanded( child: ListView.builder( scrollDirection: Axis.vertical, - padding: const EdgeInsets.only( - top: 16, - bottom: 84, - ), + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8), reverse: true, itemCount: 1000, itemBuilder: (context, index) => ListTile( @@ -70,86 +67,85 @@ class _ChatRoomState extends State { ), ), ), - Align( - alignment: Alignment.bottomCenter, - child: Padding( - padding: const EdgeInsets.only( - left: 8, - right: 8, - bottom: 16, - ), - child: SizedBox( - width: 480, - height: 48, - child: StateConsumer( - controller: _connectionController, - builder: (context, connectionState, _) => - StateConsumer( - controller: _messagesController, - listener: (context, previous, current) { - switch (current) { - case ChatMessagesState$Successful state: - _textEditingController.clear(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(state.message), - ), - ); - case ChatMessagesState$Error state: - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(state.message), - backgroundColor: Colors.red, + const Divider(height: 1, thickness: .5), + SizedBox( + height: 64, + child: ColoredBox( + color: Colors.grey.withOpacity(0.2), + child: Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: StateConsumer( + controller: _connectionController, + builder: (context, connectionState, _) => + StateConsumer( + controller: _messagesController, + listener: (context, previous, current) { + switch (current) { + case ChatMessagesState$Successful _: + _textEditingController.clear(); + case ChatMessagesState$Error state: + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), + backgroundColor: Colors.red, + ), + ); + default: + break; + } + }, + builder: (context, messagesState, child) => Row( + children: [ + Expanded( + child: TextField( + controller: _textEditingController, + enabled: connectionState.isConnected, + decoration: const InputDecoration( + border: InputBorder.none, + hintText: 'Write a message...', + ), + ), ), - ); - default: - break; - } - }, - builder: (context, messagesState, child) => Row( - children: [ - Expanded( - child: TextField( - controller: _textEditingController, - enabled: connectionState.isConnected, - decoration: const InputDecoration( - border: OutlineInputBorder(), - hintText: 'Enter a search term', + ValueListenableBuilder( + valueListenable: _textEditingController, + builder: (context, value, _) { + final enabled = connectionState.isConnected && + messagesState.isIdling && + value.text.isNotEmpty; + return IconButton( + icon: AnimatedSwitcher( + duration: + const Duration(milliseconds: 350), + child: switch (connectionState) { + ChatConnectionState$Connecting _ => + const CircularProgressIndicator(), + ChatConnectionState$Connected _ => + const Icon(Icons.send), + ChatConnectionState$Disconnected _ => + const Icon(Icons.send_outlined), + }, + ), + onPressed: enabled + ? () => _messagesController.sendMessage( + widget.user, + 'Hello World', + ) + : null, + ); + }, ), - ), + ], ), - ValueListenableBuilder( - valueListenable: _textEditingController, - builder: (context, value, _) { - final enabled = connectionState.isConnected && - messagesState.isIdling && - value.text.isNotEmpty; - return IconButton( - icon: AnimatedSwitcher( - duration: const Duration(milliseconds: 350), - child: switch (connectionState) { - ChatConnectionState$Connecting _ => - const CircularProgressIndicator(), - ChatConnectionState$Connected _ => - const Icon(Icons.send), - ChatConnectionState$Disconnected _ => - const Icon(Icons.send_outlined), - }, - ), - onPressed: enabled - ? () => _messagesController.sendMessage( - widget.user, - 'Hello World', - ) - : null, - ); - }, - ), - ], + ), ), ), ), - )), + ], + ), + ), ), ], ); diff --git a/lib/src/client/spinify.dart b/lib/src/client/spinify.dart index 6455ad8..8db7a26 100644 --- a/lib/src/client/spinify.dart +++ b/lib/src/client/spinify.dart @@ -66,7 +66,7 @@ final class Spinify extends SpinifyBase SpinifyPresenceMixin, SpinifyHistoryMixin, SpinifyRPCMixin, - /* SpinifyQueueMixin, */ + SpinifyQueueMixin, SpinifyMetricsMixin { /// {@macro spinify} Spinify([SpinifyConfig? config]) : super(config ?? SpinifyConfig.byDefault()); @@ -800,8 +800,8 @@ base mixin SpinifyQueueMixin on SpinifyBase { Future publish(String channel, List data) => _eventQueue.push('publish', () => super.publish(channel, data)); - @override - FutureOr ready() => _eventQueue.push('ready', super.ready); + /* @override + FutureOr ready() => _eventQueue.push('ready', super.ready); */ @override Future presence(String channel) => diff --git a/lib/src/subscription/client_subscription_impl.dart b/lib/src/subscription/client_subscription_impl.dart index be20e55..640c720 100644 --- a/lib/src/subscription/client_subscription_impl.dart +++ b/lib/src/subscription/client_subscription_impl.dart @@ -38,7 +38,8 @@ final class SpinifyClientSubscriptionImpl extends SpinifyClientSubscriptionBase SpinifyClientSubscriptionSubscribeMixin, SpinifyClientSubscriptionPublishingMixin, SpinifyClientSubscriptionHistoryMixin, - SpinifyClientSubscriptionPresenceMixin /* SpinifyClientSubscriptionQueueMixin */ { + SpinifyClientSubscriptionPresenceMixin, + SpinifyClientSubscriptionQueueMixin { /// {@nodoc} SpinifyClientSubscriptionImpl({ required super.channel, @@ -563,12 +564,6 @@ base mixin SpinifyClientSubscriptionQueueMixin /// {@nodoc} final SpinifyEventQueue _eventQueue = SpinifyEventQueue(); - @override - FutureOr ready() => _eventQueue.push( - 'ready', - super.ready, - ); - @override Future subscribe() => _eventQueue.push( 'subscribe', diff --git a/lib/src/subscription/server_subscription_impl.dart b/lib/src/subscription/server_subscription_impl.dart index acdef11..555098e 100644 --- a/lib/src/subscription/server_subscription_impl.dart +++ b/lib/src/subscription/server_subscription_impl.dart @@ -36,7 +36,8 @@ final class SpinifyServerSubscriptionImpl extends SpinifyServerSubscriptionBase SpinifyServerSubscriptionReadyMixin, SpinifyServerSubscriptionPublishingMixin, SpinifyServerSubscriptionHistoryMixin, - SpinifyServerSubscriptionPresenceMixin /* SpinifyServerSubscriptionQueueMixin */ { + SpinifyServerSubscriptionPresenceMixin, + SpinifyServerSubscriptionQueueMixin { /// {@nodoc} SpinifyServerSubscriptionImpl({ required super.channel, From 5a4715889bc4f8663cd2262d76a09c544643f5d5 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 12 Aug 2023 17:59:34 +0400 Subject: [PATCH 32/46] Remove extra column --- .../src/feature/chat/widget/chat_room.dart | 127 +++++++++--------- 1 file changed, 60 insertions(+), 67 deletions(-) diff --git a/example/lib/src/feature/chat/widget/chat_room.dart b/example/lib/src/feature/chat/widget/chat_room.dart index 4192a4d..f7a3b4c 100644 --- a/example/lib/src/feature/chat/widget/chat_room.dart +++ b/example/lib/src/feature/chat/widget/chat_room.dart @@ -72,78 +72,71 @@ class _ChatRoomState extends State { height: 64, child: ColoredBox( color: Colors.grey.withOpacity(0.2), - child: Column( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: StateConsumer( - controller: _connectionController, - builder: (context, connectionState, _) => - StateConsumer( - controller: _messagesController, - listener: (context, previous, current) { - switch (current) { - case ChatMessagesState$Successful _: - _textEditingController.clear(); - case ChatMessagesState$Error state: - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(state.message), - backgroundColor: Colors.red, - ), - ); - default: - break; - } - }, - builder: (context, messagesState, child) => Row( - children: [ - Expanded( - child: TextField( - controller: _textEditingController, - enabled: connectionState.isConnected, - decoration: const InputDecoration( - border: InputBorder.none, - hintText: 'Write a message...', - ), - ), - ), - ValueListenableBuilder( - valueListenable: _textEditingController, - builder: (context, value, _) { - final enabled = connectionState.isConnected && - messagesState.isIdling && - value.text.isNotEmpty; - return IconButton( - icon: AnimatedSwitcher( - duration: - const Duration(milliseconds: 350), - child: switch (connectionState) { - ChatConnectionState$Connecting _ => - const CircularProgressIndicator(), - ChatConnectionState$Connected _ => - const Icon(Icons.send), - ChatConnectionState$Disconnected _ => - const Icon(Icons.send_outlined), - }, - ), - onPressed: enabled - ? () => _messagesController.sendMessage( - widget.user, - 'Hello World', - ) - : null, - ); + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: StateConsumer( + controller: _connectionController, + builder: (context, connectionState, _) => + StateConsumer( + controller: _messagesController, + listener: (context, previous, current) { + switch (current) { + case ChatMessagesState$Successful _: + _textEditingController.clear(); + case ChatMessagesState$Error state: + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), + backgroundColor: Colors.red, + ), + ); + default: + break; + } + }, + builder: (context, messagesState, child) => Row( + children: [ + Expanded( + child: TextField( + controller: _textEditingController, + enabled: connectionState.isConnected, + decoration: const InputDecoration( + border: InputBorder.none, + hintText: 'Write a message...', + ), + ), + ), + ValueListenableBuilder( + valueListenable: _textEditingController, + builder: (context, value, _) { + final enabled = connectionState.isConnected && + messagesState.isIdling && + value.text.isNotEmpty; + return IconButton( + icon: AnimatedSwitcher( + duration: const Duration(milliseconds: 350), + child: switch (connectionState) { + ChatConnectionState$Connecting _ => + const CircularProgressIndicator(), + ChatConnectionState$Connected _ => + const Icon(Icons.send), + ChatConnectionState$Disconnected _ => + const Icon(Icons.send_outlined), }, ), - ], - ), + onPressed: enabled + ? () => _messagesController.sendMessage( + widget.user, + 'Hello World', + ) + : null, + ); + }, ), - ), + ], ), ), - ], + ), ), ), ), From ab7270cd2da339efc59f2f49762de0af4aeaf98c Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 12 Aug 2023 18:06:48 +0400 Subject: [PATCH 33/46] Update chat room --- .../src/feature/chat/widget/chat_room.dart | 151 ++++++++++-------- 1 file changed, 80 insertions(+), 71 deletions(-) diff --git a/example/lib/src/feature/chat/widget/chat_room.dart b/example/lib/src/feature/chat/widget/chat_room.dart index f7a3b4c..2086bb0 100644 --- a/example/lib/src/feature/chat/widget/chat_room.dart +++ b/example/lib/src/feature/chat/widget/chat_room.dart @@ -57,83 +57,92 @@ class _ChatRoomState extends State { Widget build(BuildContext context) => Column( children: [ Expanded( - child: ListView.builder( - scrollDirection: Axis.vertical, - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8), - reverse: true, - itemCount: 1000, - itemBuilder: (context, index) => ListTile( - title: Text('Item $index'), + child: RepaintBoundary( + child: ListView.builder( + scrollDirection: Axis.vertical, + padding: + const EdgeInsets.symmetric(vertical: 16, horizontal: 8), + reverse: true, + itemCount: 1000, + itemBuilder: (context, index) => ListTile( + title: Text('Item $index'), + ), ), ), ), const Divider(height: 1, thickness: .5), - SizedBox( - height: 64, - child: ColoredBox( - color: Colors.grey.withOpacity(0.2), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: StateConsumer( - controller: _connectionController, - builder: (context, connectionState, _) => - StateConsumer( - controller: _messagesController, - listener: (context, previous, current) { - switch (current) { - case ChatMessagesState$Successful _: - _textEditingController.clear(); - case ChatMessagesState$Error state: - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(state.message), - backgroundColor: Colors.red, - ), - ); - default: - break; - } - }, - builder: (context, messagesState, child) => Row( - children: [ - Expanded( - child: TextField( - controller: _textEditingController, - enabled: connectionState.isConnected, - decoration: const InputDecoration( - border: InputBorder.none, - hintText: 'Write a message...', - ), - ), - ), - ValueListenableBuilder( - valueListenable: _textEditingController, - builder: (context, value, _) { - final enabled = connectionState.isConnected && - messagesState.isIdling && - value.text.isNotEmpty; - return IconButton( - icon: AnimatedSwitcher( - duration: const Duration(milliseconds: 350), - child: switch (connectionState) { - ChatConnectionState$Connecting _ => - const CircularProgressIndicator(), - ChatConnectionState$Connected _ => - const Icon(Icons.send), - ChatConnectionState$Disconnected _ => - const Icon(Icons.send_outlined), - }, + RepaintBoundary( + child: SizedBox( + height: 64, + width: double.infinity, + child: ColoredBox( + color: Colors.grey.withOpacity(0.2), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: StateConsumer( + controller: _connectionController, + builder: (context, connectionState, _) => + StateConsumer( + controller: _messagesController, + listener: (context, previous, current) { + switch (current) { + case ChatMessagesState$Successful _: + _textEditingController.clear(); + case ChatMessagesState$Error state: + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), + backgroundColor: Colors.red, ), - onPressed: enabled - ? () => _messagesController.sendMessage( - widget.user, - 'Hello World', - ) - : null, ); - }, - ), - ], + default: + break; + } + }, + builder: (context, messagesState, child) => Row( + children: [ + Expanded( + child: TextField( + controller: _textEditingController, + enabled: connectionState.isConnected, + maxLength: 128, + maxLines: 1, + decoration: const InputDecoration( + border: InputBorder.none, + counterText: '', + hintText: 'Write a message...', + ), + ), + ), + ValueListenableBuilder( + valueListenable: _textEditingController, + builder: (context, value, _) { + final enabled = connectionState.isConnected && + messagesState.isIdling && + value.text.isNotEmpty; + return IconButton( + icon: AnimatedSwitcher( + duration: const Duration(milliseconds: 350), + child: switch (connectionState) { + ChatConnectionState$Connecting _ => + const CircularProgressIndicator(), + ChatConnectionState$Connected _ => + const Icon(Icons.send), + ChatConnectionState$Disconnected _ => + const Icon(Icons.send_outlined), + }, + ), + onPressed: enabled + ? () => _messagesController.sendMessage( + widget.user, + 'Hello World', + ) + : null, + ); + }, + ), + ], + ), ), ), ), From 9a1356280a1f658d91b71c88dde3e2884cb62880 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Sat, 12 Aug 2023 18:31:41 +0400 Subject: [PATCH 34/46] Add placeholder subsriber --- example/lib/src/feature/chat/data/chat_repository.dart | 8 +++++++- example/lib/src/feature/chat/widget/chat_room.dart | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/example/lib/src/feature/chat/data/chat_repository.dart b/example/lib/src/feature/chat/data/chat_repository.dart index f4846ec..9a38d7a 100644 --- a/example/lib/src/feature/chat/data/chat_repository.dart +++ b/example/lib/src/feature/chat/data/chat_repository.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:l/l.dart'; import 'package:spinify/spinify.dart'; import 'package:spinifyapp/src/feature/authentication/model/user.dart'; import 'package:spinifyapp/src/feature/chat/controller/chat_connection_state.dart'; @@ -26,7 +27,12 @@ abstract interface class IChatRepository { } final class ChatRepositorySpinifyImpl implements IChatRepository { - ChatRepositorySpinifyImpl({required Spinify spinify}) : _spinify = spinify; + ChatRepositorySpinifyImpl({required Spinify spinify}) : _spinify = spinify { + // TODO(plugfox): remove + spinify.stream.publications.listen((event) { + l.s('publications: ${const PlainMessageDecoder().convert(event.data).text} '); + }, cancelOnError: false); + } /// Centrifugo client final Spinify _spinify; diff --git a/example/lib/src/feature/chat/widget/chat_room.dart b/example/lib/src/feature/chat/widget/chat_room.dart index 2086bb0..14dcb2d 100644 --- a/example/lib/src/feature/chat/widget/chat_room.dart +++ b/example/lib/src/feature/chat/widget/chat_room.dart @@ -135,7 +135,7 @@ class _ChatRoomState extends State { onPressed: enabled ? () => _messagesController.sendMessage( widget.user, - 'Hello World', + value.text, ) : null, ); From 28cbdf540acaddf8d6874bb8d9fe447d1dcb5dca Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 6 Sep 2023 14:34:10 +0400 Subject: [PATCH 35/46] Update analysis options --- analysis_options.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index b43fc2e..907ca29 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -89,10 +89,9 @@ linter: close_sinks: true control_flow_in_finally: true empty_statements: true - iterable_contains_unrelated_type: true + collection_methods_unrelated_type: true join_return_with_assignment: true leading_newlines_in_multiline_strings: true - list_remove_unrelated_type: true literal_only_boolean_expressions: true missing_whitespace_between_adjacent_strings: true no_adjacent_strings_in_list: true From d4cd7a264210c091e0fe18c5798ab560ff922b08 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 6 Sep 2023 14:35:24 +0400 Subject: [PATCH 36/46] Update depricated --- example/lib/src/common/widget/app.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/example/lib/src/common/widget/app.dart b/example/lib/src/common/widget/app.dart index 4489e8e..458a2bf 100644 --- a/example/lib/src/common/widget/app.dart +++ b/example/lib/src/common/widget/app.dart @@ -39,7 +39,9 @@ class App extends StatelessWidget { .firstWhereOrNull((e) => e.languageCode == platform.locale) ?? const Locale('en', 'US'), builder: (context, child) => MediaQuery( - data: MediaQuery.of(context).copyWith(textScaleFactor: 1), + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.noScaling, + ), child: WindowScope( /* title: Localization.of(context).title, */ child: child ?? const SizedBox.shrink(), From 8369a5ee5f5f414012797f1b9ffe5e3086e8ef5e Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 6 Sep 2023 16:18:04 +0400 Subject: [PATCH 37/46] Update chat subscription --- .../data/authentication_repository.dart | 28 +++----- .../controller/chat_messages_controller.dart | 32 +++++++-- .../chat/controller/chat_messages_state.dart | 68 +++++++++++++++---- .../feature/chat/data/chat_repository.dart | 30 +++++--- .../lib/src/feature/chat/model/message.dart | 10 ++- .../src/feature/chat/widget/chat_room.dart | 39 +++++++---- 6 files changed, 146 insertions(+), 61 deletions(-) diff --git a/example/lib/src/feature/authentication/data/authentication_repository.dart b/example/lib/src/feature/authentication/data/authentication_repository.dart index d44c00e..cf99cca 100644 --- a/example/lib/src/feature/authentication/data/authentication_repository.dart +++ b/example/lib/src/feature/authentication/data/authentication_repository.dart @@ -32,13 +32,9 @@ class AuthenticationRepositoryImpl implements IAuthenticationRepository { final AuthenticatedUser( :String username, :String token, - :String channel, - :String? secret + :String channel ) = user; final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - String encodeChannel(String secret) => '$channel' - '#' - '${hex.encode(utf8.encoder.fuse(sha256).convert(secret).bytes)}'; SpinifyJWT jwt = SpinifyJWT( sub: username, exp: now + (24 * 60 * 60), @@ -46,12 +42,7 @@ class AuthenticationRepositoryImpl implements IAuthenticationRepository { info: { 'username': username, }, - channels: [ - switch (secret) { - null || '' => channel, - String secret => encodeChannel(secret), - } - ], + channels: [channel], ); return jwt.encode(token); case UnauthenticatedUser _: @@ -64,19 +55,20 @@ class AuthenticationRepositoryImpl implements IAuthenticationRepository { @override Future signIn(SignInData data) { - String encryptedChannel(String channel, String secret) => '${data.channel}' - '#' - '${hex.encode(utf8.encoder.fuse(sha256).convert(secret).bytes)}'; + String buildChannelName(String channel, [String? secret]) => + switch (secret) { + null || '' => channel, + String secret => '$channel' + '#' + '${hex.encode(utf8.encoder.fuse(sha256).convert(secret).bytes)}', + }; return Future.sync( () => _userController.add( _user = User.authenticated( username: data.username, endpoint: data.endpoint, token: data.token, - channel: switch (data.secret) { - null || '' => data.channel, - String secret => encryptedChannel(data.channel, secret), - }, + channel: buildChannelName(data.channel, data.secret), secret: switch (data.secret) { null || '' => null, String secret => secret, diff --git a/example/lib/src/feature/chat/controller/chat_messages_controller.dart b/example/lib/src/feature/chat/controller/chat_messages_controller.dart index 7c81053..a908068 100644 --- a/example/lib/src/feature/chat/controller/chat_messages_controller.dart +++ b/example/lib/src/feature/chat/controller/chat_messages_controller.dart @@ -1,22 +1,43 @@ +import 'dart:async'; +import 'dart:collection'; + import 'package:l/l.dart'; import 'package:spinifyapp/src/common/controller/droppable_controller_concurrency.dart'; import 'package:spinifyapp/src/common/controller/state_controller.dart'; import 'package:spinifyapp/src/feature/authentication/model/user.dart'; import 'package:spinifyapp/src/feature/chat/controller/chat_messages_state.dart'; import 'package:spinifyapp/src/feature/chat/data/chat_repository.dart'; +import 'package:spinifyapp/src/feature/chat/model/message.dart'; final class ChatMessagesController extends StateController with DroppableControllerConcurrency { - ChatMessagesController({required IChatRepository repository}) - : _repository = repository, - super(initialState: ChatMessagesState.initial); + ChatMessagesController( + {required AuthenticatedUser user, required IChatRepository repository}) + : _user = user, + _repository = repository, + super(initialState: ChatMessagesState.initial) { + final AuthenticatedUser(:channel, :secret) = user; + _messagesSubscription = + _repository.getMessages(channel, secret).listen(_onMessage); + } + final AuthenticatedUser _user; final IChatRepository _repository; + late final StreamSubscription _messagesSubscription; + late final Set _messages = SplayTreeSet.of( + state.data, + (a, b) => a.compareTo(b), + ); + + void _onMessage(Message message) { + setState(state.copyWith( + data: (_messages..add(message)).toList(growable: false))); + } - void sendMessage(AuthenticatedUser user, String message) => handle( + void sendMessage(String message) => handle( () async { l.v6('Sending message'); - await _repository.sendMessage(user, message); + await _repository.sendMessage(_user, message); setState(ChatMessagesState.successful( data: state.data, message: 'Message sent')); l.v6('Message sent'); @@ -35,6 +56,7 @@ final class ChatMessagesController extends StateController @override void dispose() { + _messagesSubscription.cancel(); _repository.disconnect(); super.dispose(); } diff --git a/example/lib/src/feature/chat/controller/chat_messages_state.dart b/example/lib/src/feature/chat/controller/chat_messages_state.dart index 1acf9d1..07649e3 100644 --- a/example/lib/src/feature/chat/controller/chat_messages_state.dart +++ b/example/lib/src/feature/chat/controller/chat_messages_state.dart @@ -1,9 +1,8 @@ import 'package:meta/meta.dart'; +import 'package:spinifyapp/src/feature/chat/model/message.dart'; -/// {@template chat_messages_state_placeholder} -/// Entity placeholder for ChatMessagesState -/// {@endtemplate} -typedef ChatMessagesEntity = Object; +/// Chat messages entity. +typedef ChatMessages = List; /// {@template chat_messages_state} /// ChatMessagesState. @@ -12,28 +11,28 @@ sealed class ChatMessagesState extends _$ChatMessagesStateBase { /// Idling state /// {@macro chat_messages_state} const factory ChatMessagesState.idle({ - required ChatMessagesEntity? data, + required ChatMessages data, String message, }) = ChatMessagesState$Idle; /// Processing /// {@macro chat_messages_state} const factory ChatMessagesState.processing({ - required ChatMessagesEntity? data, + required ChatMessages data, String message, }) = ChatMessagesState$Processing; /// Successful /// {@macro chat_messages_state} const factory ChatMessagesState.successful({ - required ChatMessagesEntity? data, + required ChatMessages data, String message, }) = ChatMessagesState$Successful; /// An error has occurred /// {@macro chat_messages_state} const factory ChatMessagesState.error({ - required ChatMessagesEntity? data, + required ChatMessages data, String message, }) = ChatMessagesState$Error; @@ -41,7 +40,7 @@ sealed class ChatMessagesState extends _$ChatMessagesStateBase { const ChatMessagesState({required super.data, required super.message}); static ChatMessagesState get initial => - const ChatMessagesState.idle(data: null); + const ChatMessagesState.idle(data: []); } /// Idling state @@ -49,6 +48,16 @@ sealed class ChatMessagesState extends _$ChatMessagesStateBase { final class ChatMessagesState$Idle extends ChatMessagesState { /// {@nodoc} const ChatMessagesState$Idle({required super.data, super.message = 'Idling'}); + + @override + ChatMessagesState$Idle copyWith({ + ChatMessages? data, + String? message, + }) => + ChatMessagesState$Idle( + data: data ?? this.data, + message: message ?? this.message, + ); } /// Processing @@ -57,6 +66,16 @@ final class ChatMessagesState$Processing extends ChatMessagesState { /// {@nodoc} const ChatMessagesState$Processing( {required super.data, super.message = 'Processing'}); + + @override + ChatMessagesState$Processing copyWith({ + ChatMessages? data, + String? message, + }) => + ChatMessagesState$Processing( + data: data ?? this.data, + message: message ?? this.message, + ); } /// Successful @@ -65,6 +84,16 @@ final class ChatMessagesState$Successful extends ChatMessagesState { /// {@nodoc} const ChatMessagesState$Successful( {required super.data, super.message = 'Successful'}); + + @override + ChatMessagesState$Successful copyWith({ + ChatMessages? data, + String? message, + }) => + ChatMessagesState$Successful( + data: data ?? this.data, + message: message ?? this.message, + ); } /// Error @@ -73,6 +102,16 @@ final class ChatMessagesState$Error extends ChatMessagesState { /// {@nodoc} const ChatMessagesState$Error( {required super.data, super.message = 'An error has occurred.'}); + + @override + ChatMessagesState$Error copyWith({ + ChatMessages? data, + String? message, + }) => + ChatMessagesState$Error( + data: data ?? this.data, + message: message ?? this.message, + ); } /// Pattern matching for [ChatMessagesState]. @@ -87,15 +126,12 @@ abstract base class _$ChatMessagesStateBase { /// Data entity payload. @nonVirtual - final ChatMessagesEntity? data; + final ChatMessages data; /// Message or state description. @nonVirtual final String message; - /// Has data? - bool get hasData => data != null; - /// If an error has occurred? bool get hasError => maybeMap(orElse: () => false, error: (_) => true); @@ -106,6 +142,12 @@ abstract base class _$ChatMessagesStateBase { /// Is in idle state? bool get isIdling => !isProcessing; + /// Copy with new data. + ChatMessagesState copyWith({ + ChatMessages? data, + String? message, + }); + /// Pattern matching for [ChatMessagesState]. R map({ required ChatMessagesStateMatch idle, diff --git a/example/lib/src/feature/chat/data/chat_repository.dart b/example/lib/src/feature/chat/data/chat_repository.dart index 9a38d7a..c078d64 100644 --- a/example/lib/src/feature/chat/data/chat_repository.dart +++ b/example/lib/src/feature/chat/data/chat_repository.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:l/l.dart'; import 'package:spinify/spinify.dart'; @@ -8,7 +9,8 @@ import 'package:spinifyapp/src/feature/chat/model/message.dart'; /// Chat repository abstract interface class IChatRepository { - Stream get messages; + /// Receive messages stream + Stream getMessages(String channel, [String? secret]); /// Connection state ChatConnectionState get connectionState; @@ -27,12 +29,7 @@ abstract interface class IChatRepository { } final class ChatRepositorySpinifyImpl implements IChatRepository { - ChatRepositorySpinifyImpl({required Spinify spinify}) : _spinify = spinify { - // TODO(plugfox): remove - spinify.stream.publications.listen((event) { - l.s('publications: ${const PlainMessageDecoder().convert(event.data).text} '); - }, cancelOnError: false); - } + ChatRepositorySpinifyImpl({required Spinify spinify}) : _spinify = spinify; /// Centrifugo client final Spinify _spinify; @@ -53,7 +50,24 @@ final class ChatRepositorySpinifyImpl implements IChatRepository { }; @override - Stream get messages => throw UnimplementedError(); + Stream getMessages(String channel, [String? secret]) { + void ignoreErrors(Object error, StackTrace? stackTrace) { + l.w('Error receiving message: $error', stackTrace); + } + + final Converter, Message> decoder; + if (secret != null && secret.isNotEmpty) { + decoder = EncryptedMessageDecoder(secretKey: secret); + } else { + decoder = const PlainMessageDecoder(); + } + + return _spinify.stream.publications + .where((event) => event.channel == channel) + .map>((event) => event.data) + .map(decoder.convert) + .handleError(ignoreErrors); + } @override Future connect(String url) => _spinify.connect(url); diff --git a/example/lib/src/feature/chat/model/message.dart b/example/lib/src/feature/chat/model/message.dart index 27655df..104ba96 100644 --- a/example/lib/src/feature/chat/model/message.dart +++ b/example/lib/src/feature/chat/model/message.dart @@ -4,7 +4,7 @@ import 'package:crypto/crypto.dart'; import 'package:meta/meta.dart'; @immutable -sealed class Message { +sealed class Message implements Comparable { const Message({ required this.author, required this.text, @@ -59,6 +59,9 @@ final class PlainMessage extends Message { @override String get type => 'plain'; + @override + int compareTo(Message other) => createdAt.compareTo(other.createdAt); + @override Map toJson() => { 'type': type, @@ -80,7 +83,7 @@ final class EncryptedMessage extends Message { factory EncryptedMessage.fromJson(Map json) { if (json case { - 'type': 'plain', + 'type': 'encrypted', 'author': String author, 'text': String text, 'version': int version, @@ -95,6 +98,9 @@ final class EncryptedMessage extends Message { throw const FormatException('Invalid message type'); } + @override + int compareTo(Message other) => createdAt.compareTo(other.createdAt); + @override String get type => 'encrypted'; diff --git a/example/lib/src/feature/chat/widget/chat_room.dart b/example/lib/src/feature/chat/widget/chat_room.dart index 14dcb2d..84d19b4 100644 --- a/example/lib/src/feature/chat/widget/chat_room.dart +++ b/example/lib/src/feature/chat/widget/chat_room.dart @@ -24,7 +24,7 @@ class ChatRoom extends StatefulWidget { /// State for widget ChatRoom. class _ChatRoomState extends State { late final ChatConnectionController _connectionController; - late final ChatMessagesController _messagesController; + late ChatMessagesController _messagesController; final TextEditingController _textEditingController = TextEditingController(); @override @@ -32,7 +32,8 @@ class _ChatRoomState extends State { super.initState(); final repository = DependenciesScope.of(context).chatRepository; _connectionController = ChatConnectionController(repository: repository); - _messagesController = ChatMessagesController(repository: repository); + _messagesController = + ChatMessagesController(user: widget.user, repository: repository); _connectionController.connect(widget.user.endpoint); } @@ -42,6 +43,11 @@ class _ChatRoomState extends State { if (oldWidget.user != widget.user) { _connectionController.disconnect(); _connectionController.connect(widget.user.endpoint); + _messagesController.dispose(); + _messagesController = ChatMessagesController( + user: widget.user, + repository: DependenciesScope.of(context).chatRepository, + ); } } @@ -57,15 +63,18 @@ class _ChatRoomState extends State { Widget build(BuildContext context) => Column( children: [ Expanded( - child: RepaintBoundary( - child: ListView.builder( - scrollDirection: Axis.vertical, - padding: - const EdgeInsets.symmetric(vertical: 16, horizontal: 8), - reverse: true, - itemCount: 1000, - itemBuilder: (context, index) => ListTile( - title: Text('Item $index'), + child: StateConsumer( + controller: _messagesController, + builder: (context, messagesState, child) => RepaintBoundary( + child: ListView.builder( + scrollDirection: Axis.vertical, + padding: + const EdgeInsets.symmetric(vertical: 16, horizontal: 8), + reverse: true, + itemCount: messagesState.data.length, + itemBuilder: (context, index) => Card( + child: Text(messagesState.data[index].text), + ), ), ), ), @@ -84,6 +93,8 @@ class _ChatRoomState extends State { builder: (context, connectionState, _) => StateConsumer( controller: _messagesController, + buildWhen: (previous, current) => + !(previous.isIdling && current.isIdling), listener: (context, previous, current) { switch (current) { case ChatMessagesState$Successful _: @@ -133,10 +144,8 @@ class _ChatRoomState extends State { }, ), onPressed: enabled - ? () => _messagesController.sendMessage( - widget.user, - value.text, - ) + ? () => _messagesController + .sendMessage(value.text) : null, ); }, From 838b73e51bc49c6ae134d3309ae8a7f3191973de Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 6 Sep 2023 17:52:46 +0400 Subject: [PATCH 38/46] Update chat message --- example/lib/src/common/util/color_util.dart | 29 +++++++ example/lib/src/common/util/date_util.dart | 27 +++++++ .../controller/chat_messages_controller.dart | 2 +- .../src/feature/chat/widget/chat_room.dart | 81 ++++++++++++++++++- 4 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 example/lib/src/common/util/color_util.dart create mode 100644 example/lib/src/common/util/date_util.dart diff --git a/example/lib/src/common/util/color_util.dart b/example/lib/src/common/util/color_util.dart new file mode 100644 index 0000000..5c16622 --- /dev/null +++ b/example/lib/src/common/util/color_util.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +sealed class ColorUtil { + /// Get list of colors with length [count]. + static List getColors(int count) { + final primariesLength = Colors.primaries.length; + if (count <= primariesLength) return Colors.primaries.take(count).toList(); + + final colors = List.filled(count, Colors.transparent); + final step = count / (primariesLength - 1); + + var index = 0; + for (var i = 0; i < primariesLength - 1; i++) { + for (var j = 0; j < step; j++) { + final color1 = Colors.primaries[i], color2 = Colors.primaries[i + 1]; + colors[index] = Color.lerp(color1, color2, j / step)!; + index++; + if (index == count) return colors; + } + } + + while (index < count) { + colors[index] = Colors.primaries.last; + index++; + } + + return colors; + } +} diff --git a/example/lib/src/common/util/date_util.dart b/example/lib/src/common/util/date_util.dart new file mode 100644 index 0000000..cd9945e --- /dev/null +++ b/example/lib/src/common/util/date_util.dart @@ -0,0 +1,27 @@ +import 'package:intl/intl.dart' as intl; + +sealed class DateUtil { + /// Format date + static String format( + DateTime? date, { + intl.DateFormat? format, + String fallback = '-', + }) { + if (date == null) return fallback; + if (format != null) return format.format(date); + final now = DateTime.now(); + final today = now.copyWith( + hour: 0, minute: 0, second: 0, millisecond: 0, microsecond: 0); + if (date.isAfter(today)) return intl.DateFormat.Hms().format(date); + if (date.isAfter(today.subtract(const Duration(days: 7)))) { + return intl.DateFormat(intl.DateFormat.WEEKDAY).format(date); + } + return intl.DateFormat.yMd().format(date); + } +} + +extension DateUtilX on DateTime { + /// Format date + String format({intl.DateFormat? format}) => + DateUtil.format(this, format: format); +} diff --git a/example/lib/src/feature/chat/controller/chat_messages_controller.dart b/example/lib/src/feature/chat/controller/chat_messages_controller.dart index a908068..295da6b 100644 --- a/example/lib/src/feature/chat/controller/chat_messages_controller.dart +++ b/example/lib/src/feature/chat/controller/chat_messages_controller.dart @@ -26,7 +26,7 @@ final class ChatMessagesController extends StateController late final StreamSubscription _messagesSubscription; late final Set _messages = SplayTreeSet.of( state.data, - (a, b) => a.compareTo(b), + (a, b) => b.compareTo(a), ); void _onMessage(Message message) { diff --git a/example/lib/src/feature/chat/widget/chat_room.dart b/example/lib/src/feature/chat/widget/chat_room.dart index 84d19b4..488f736 100644 --- a/example/lib/src/feature/chat/widget/chat_room.dart +++ b/example/lib/src/feature/chat/widget/chat_room.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:spinifyapp/src/common/controller/state_consumer.dart'; +import 'package:spinifyapp/src/common/util/date_util.dart'; import 'package:spinifyapp/src/feature/authentication/model/user.dart'; import 'package:spinifyapp/src/feature/chat/controller/chat_connection_controller.dart'; import 'package:spinifyapp/src/feature/chat/controller/chat_connection_state.dart'; import 'package:spinifyapp/src/feature/chat/controller/chat_messages_controller.dart'; import 'package:spinifyapp/src/feature/chat/controller/chat_messages_state.dart'; +import 'package:spinifyapp/src/feature/chat/model/message.dart'; import 'package:spinifyapp/src/feature/dependencies/widget/dependencies_scope.dart'; /// {@template chat_screen} @@ -72,8 +74,9 @@ class _ChatRoomState extends State { const EdgeInsets.symmetric(vertical: 16, horizontal: 8), reverse: true, itemCount: messagesState.data.length, - itemBuilder: (context, index) => Card( - child: Text(messagesState.data[index].text), + itemBuilder: (context, index) => ChatMessageBubble( + message: messagesState.data[index], + currentUser: widget.user, ), ), ), @@ -161,3 +164,77 @@ class _ChatRoomState extends State { ], ); } + +/// {@template chat_room} +/// ChatMessageBubble widget. +/// {@endtemplate} +class ChatMessageBubble extends StatelessWidget { + /// {@macro chat_room} + const ChatMessageBubble( + {required this.message, required this.currentUser, super.key}); + + final Message message; + final AuthenticatedUser currentUser; + + static const List _$colors = Colors.primaries; + static Color _getColorForUsername(String username) => + _$colors[username.codeUnitAt(0) % _$colors.length]; + + @override + Widget build(BuildContext context) => Align( + alignment: message.author == currentUser.username + ? Alignment.centerRight + : Alignment.centerLeft, + child: ConstrainedBox( + constraints: BoxConstraints.loose( + const Size.fromWidth(512), + ), + child: Card( + margin: const EdgeInsets.symmetric(vertical: 8), + child: Stack( + fit: StackFit.loose, + children: [ + Positioned( + top: 4, + left: 8, + child: Text( + message.author, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + color: _getColorForUsername(message.author), + letterSpacing: 1, + height: 1, + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(8, 16, 8, 14), + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 128), + child: Text(message.text), + ), + ), + Positioned( + bottom: 4, + right: 4, + child: Text( + message.createdAt.format(), + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: const TextStyle( + fontSize: 10, + color: Colors.grey, + letterSpacing: 1.2, + height: 1, + ), + ), + ), + ], + ), + ), + ), + ); +} From d4281de96333b9d7abca9083abcecd459045c296 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 2 Nov 2023 16:46:22 +0400 Subject: [PATCH 39/46] Bump version to 0.0.1-pre.7 --- CHANGELOG.md | 2 +- pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 752444d..58fce67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,3 @@ -## 0.0.1-pre.6 +## 0.0.1-pre.7 - **ADDED**: Initial release diff --git a/pubspec.yaml b/pubspec.yaml index 2079d1b..bd1564c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ description: > Dart client to communicate with Centrifuge and Centrifugo from Flutter and VM over WebSockets -version: 0.0.1-pre.6 +version: 0.0.1-pre.7 homepage: https://centrifugal.dev From a47330c58eddd2cc97287dd2a105d4c934efd747 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 2 Nov 2023 18:55:30 +0400 Subject: [PATCH 40/46] Update dependencies --- example/.fvm/fvm_config.json | 4 ++-- example/lib/src/common/widget/app.dart | 3 ++- example/lib/src/feature/chat/model/message.dart | 6 ++++++ example/pubspec.yaml | 2 +- pubspec.yaml | 6 +++--- 5 files changed, 14 insertions(+), 7 deletions(-) diff --git a/example/.fvm/fvm_config.json b/example/.fvm/fvm_config.json index 2038915..41959e9 100644 --- a/example/.fvm/fvm_config.json +++ b/example/.fvm/fvm_config.json @@ -1,4 +1,4 @@ { - "flutterSdkVersion": "beta", + "flutterSdkVersion": "stable", "flavors": {} -} \ No newline at end of file +} diff --git a/example/lib/src/common/widget/app.dart b/example/lib/src/common/widget/app.dart index 458a2bf..ff27bb4 100644 --- a/example/lib/src/common/widget/app.dart +++ b/example/lib/src/common/widget/app.dart @@ -40,7 +40,8 @@ class App extends StatelessWidget { const Locale('en', 'US'), builder: (context, child) => MediaQuery( data: MediaQuery.of(context).copyWith( - textScaler: TextScaler.noScaling, + /* textScaler: TextScaler.noScaling, */ + textScaleFactor: 1, ), child: WindowScope( /* title: Localization.of(context).title, */ diff --git a/example/lib/src/feature/chat/model/message.dart b/example/lib/src/feature/chat/model/message.dart index 104ba96..4367fa9 100644 --- a/example/lib/src/feature/chat/model/message.dart +++ b/example/lib/src/feature/chat/model/message.dart @@ -70,6 +70,9 @@ final class PlainMessage extends Message { 'version': version, 'createdAt': createdAt.millisecondsSinceEpoch ~/ 1000, }; + + @override + String toString() => '$author: $text'; } final class EncryptedMessage extends Message { @@ -112,6 +115,9 @@ final class EncryptedMessage extends Message { 'version': version, 'createdAt': createdAt.millisecondsSinceEpoch ~/ 1000, }; + + @override + String toString() => '$author: $text'; } @immutable diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 802f9dc..fe83978 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -91,7 +91,7 @@ dev_dependencies: #flutter_gen_runner: ^5.3.1 # Linting - flutter_lints: ^2.0.1 + flutter_lints: ^3.0.0 flutter: diff --git a/pubspec.yaml b/pubspec.yaml index 282a3b6..17e4f48 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,7 +49,7 @@ dependencies: ws: ^1.0.0-pre.6 # Protocol Buffers - protobuf: ^3.0.0 + protobuf: ^3.1.0 # Utilities crypto: ^3.0.3 @@ -60,5 +60,5 @@ dependencies: dev_dependencies: build_runner: ^2.4.6 pubspec_generator: ^4.0.0 - lints: ">=2.0.1 <4.0.0" - test: ^1.24.2 + lints: ^3.0.0 + test: ^1.24.4 From fed92c157b2b1dbde417dd01530b70436fdf7d59 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 2 Nov 2023 19:01:32 +0400 Subject: [PATCH 41/46] Do not get dependencies in example directory --- .github/workflows/checkout.yml | 4 +-- lib/src/subscription/subscription_state.dart | 27 +++++++------------- 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/.github/workflows/checkout.yml b/.github/workflows/checkout.yml index 883528b..6ede96e 100644 --- a/.github/workflows/checkout.yml +++ b/.github/workflows/checkout.yml @@ -37,7 +37,7 @@ jobs: run: working-directory: ./ container: - image: dart:beta + image: dart:stable timeout-minutes: 10 steps: - name: 🚂 Get latest code @@ -58,7 +58,7 @@ jobs: - name: 👷 Install Dependencies timeout-minutes: 1 run: | - dart pub get + dart pub get --no-example - name: 🔎 Check format timeout-minutes: 1 diff --git a/lib/src/subscription/subscription_state.dart b/lib/src/subscription/subscription_state.dart index b3b7f1e..f059652 100644 --- a/lib/src/subscription/subscription_state.dart +++ b/lib/src/subscription/subscription_state.dart @@ -60,12 +60,9 @@ final class SpinifySubscriptionState$Unsubscribed required this.code, required this.reason, DateTime? timestamp, - ({fixnum.Int64 offset, String epoch})? since, - bool recoverable = false, - }) : super( - timestamp: timestamp ?? DateTime.now(), - since: since, - recoverable: recoverable); + super.since, + super.recoverable = false, + }) : super(timestamp: timestamp ?? DateTime.now()); @override String get type => 'unsubscribed'; @@ -126,12 +123,9 @@ final class SpinifySubscriptionState$Subscribing /// {@nodoc} SpinifySubscriptionState$Subscribing({ DateTime? timestamp, - ({fixnum.Int64 offset, String epoch})? since, - bool recoverable = false, - }) : super( - timestamp: timestamp ?? DateTime.now(), - since: since, - recoverable: recoverable); + super.since, + super.recoverable = false, + }) : super(timestamp: timestamp ?? DateTime.now()); @override String get type => 'subscribing'; @@ -179,13 +173,10 @@ final class SpinifySubscriptionState$Subscribed /// {@nodoc} SpinifySubscriptionState$Subscribed({ DateTime? timestamp, - ({fixnum.Int64 offset, String epoch})? since, - bool recoverable = false, + super.since, + super.recoverable = false, this.ttl, - }) : super( - timestamp: timestamp ?? DateTime.now(), - since: since, - recoverable: recoverable); + }) : super(timestamp: timestamp ?? DateTime.now()); @override String get type => 'subscribed'; From 0833776f085308aba0ea9f31755cab185bf2d78f Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 14 Feb 2024 01:13:37 +0400 Subject: [PATCH 42/46] Update setting.json --- .vscode/settings.json | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 4cb6ca1..c9f5533 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,15 +5,15 @@ "editor.suggest.snippetsPreventQuickSuggestions": false, "editor.suggestSelection": "first", "editor.tabCompletion": "onlySnippets", - "editor.wordBasedSuggestions": false, + "editor.wordBasedSuggestions": "off", "editor.selectionHighlight": false, "editor.defaultFormatter": "Dart-Code.dart-code", "editor.formatOnSave": true, "editor.formatOnType": true, "editor.formatOnPaste": true, "editor.codeActionsOnSave": { - "source.fixAll": true, - "source.organizeImports": true + "source.fixAll": "explicit", + "source.organizeImports": "explicit" }, "editor.quickSuggestions": { "comments": "on", @@ -21,7 +21,9 @@ "other": "on" }, "editor.links": true, - "editor.rulers": [80] + "editor.rulers": [ + 80 + ] }, "dart.lineLength": 80, "dart.doNotFormat": [ @@ -36,10 +38,8 @@ "**.pbjson.dart", "**/generated/**" ], - // Flutter Version Manager //"dart.flutterSdkPath": ".fvm/flutter_sdk", - // Remove .fvm files from search "search.exclude": { //"**/.fvm": true, @@ -47,7 +47,6 @@ "coverage": true, "build": true }, - // Remove from file watching "files.watcherExclude": { //"**/.fvm": true, @@ -55,7 +54,6 @@ "coverage": true, "build": true }, - // Causes the debug view to automatically appear when a breakpoint is hit. This // setting is global and not configurable per-language. "debug.openDebug": "openOnDebugBreak", @@ -83,4 +81,4 @@ ] } } */ -} +} \ No newline at end of file From dca6b5b7dc6f95c2fd67746b46aa2534fe33a936 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 14 Feb 2024 01:35:02 +0400 Subject: [PATCH 43/46] Update --- Makefile | 5 ++- example/ios/Flutter/Debug.xcconfig | 1 + example/ios/Flutter/Release.xcconfig | 1 + example/ios/Podfile | 44 +++++++++++++++++++ example/macos/Flutter/Flutter-Debug.xcconfig | 1 + .../macos/Flutter/Flutter-Release.xcconfig | 1 + example/macos/Podfile | 43 ++++++++++++++++++ 7 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 example/ios/Podfile create mode 100644 example/macos/Podfile diff --git a/Makefile b/Makefile index 21c6fca..9c25ddd 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: format get test publish deploy centrifugo-up centrifugo-down coverage analyze check pana generate +.PHONY: format get outdated test publish deploy centrifugo-up centrifugo-down coverage analyze check pana generate format: @echo "Formatting the code" @@ -8,6 +8,9 @@ format: get: @dart pub get +outdated: + @dart pub outdated --show-all --dev-dependencies --dependency-overrides --transitive --no-prereleases + test: get @dart test --debug --coverage=.coverage --platform chrome,vm diff --git a/example/ios/Flutter/Debug.xcconfig b/example/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/example/ios/Flutter/Debug.xcconfig +++ b/example/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/example/ios/Flutter/Release.xcconfig b/example/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/example/ios/Flutter/Release.xcconfig +++ b/example/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/example/ios/Podfile b/example/ios/Podfile new file mode 100644 index 0000000..d97f17e --- /dev/null +++ b/example/ios/Podfile @@ -0,0 +1,44 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '12.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/example/macos/Flutter/Flutter-Debug.xcconfig b/example/macos/Flutter/Flutter-Debug.xcconfig index c2efd0b..4b81f9b 100644 --- a/example/macos/Flutter/Flutter-Debug.xcconfig +++ b/example/macos/Flutter/Flutter-Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/example/macos/Flutter/Flutter-Release.xcconfig b/example/macos/Flutter/Flutter-Release.xcconfig index c2efd0b..5caa9d1 100644 --- a/example/macos/Flutter/Flutter-Release.xcconfig +++ b/example/macos/Flutter/Flutter-Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/example/macos/Podfile b/example/macos/Podfile new file mode 100644 index 0000000..c795730 --- /dev/null +++ b/example/macos/Podfile @@ -0,0 +1,43 @@ +platform :osx, '10.14' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end From 9b24337a625d1d121746cb62f3fc2ca21342a05d Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 2 May 2024 12:40:09 +0400 Subject: [PATCH 44/46] Update editor settings in .vscode/settings.json --- .vscode/settings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 4cb6ca1..37f7269 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,15 +5,15 @@ "editor.suggest.snippetsPreventQuickSuggestions": false, "editor.suggestSelection": "first", "editor.tabCompletion": "onlySnippets", - "editor.wordBasedSuggestions": false, + "editor.wordBasedSuggestions": "off", "editor.selectionHighlight": false, "editor.defaultFormatter": "Dart-Code.dart-code", "editor.formatOnSave": true, "editor.formatOnType": true, "editor.formatOnPaste": true, "editor.codeActionsOnSave": { - "source.fixAll": true, - "source.organizeImports": true + "source.fixAll": "explicit", + "source.organizeImports": "explicit" }, "editor.quickSuggestions": { "comments": "on", From b2433a0c1a5df623936c000b3a6aa8c95abaa848 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 2 May 2024 12:41:10 +0400 Subject: [PATCH 45/46] Remove nodoc comments --- example/lib/src/common/constant/config.dart | 2 -- .../sequential_controller_concurrency.dart | 13 -------- .../controller/authentication_state.dart | 7 ---- .../controller/chat_connection_state.dart | 8 ----- .../chat/controller/chat_messages_state.dart | 10 ------ lib/src/client/disconnect_code.dart | 2 -- lib/src/client/spinify.dart | 27 --------------- lib/src/client/state.dart | 2 -- lib/src/model/jwt.dart | 3 -- lib/src/model/refresh_result.dart | 4 --- .../client_subscription_impl.dart | 29 ---------------- .../client_subscription_manager.dart | 12 ------- .../server_subscription_impl.dart | 26 --------------- .../server_subscription_manager.dart | 11 ------- lib/src/subscription/subscription_state.dart | 5 --- lib/src/subscription/unsubscribe_code.dart | 1 - lib/src/transport/transport_interface.dart | 19 ----------- .../transport/transport_protobuf_codec.dart | 6 ---- lib/src/transport/ws_protobuf_transport.dart | 33 ------------------- lib/src/util/event_queue.dart | 13 -------- lib/src/util/logger.dart | 7 ---- lib/src/util/notifier.dart | 8 ----- lib/src/util/speed_meter.dart | 5 --- 23 files changed, 253 deletions(-) diff --git a/example/lib/src/common/constant/config.dart b/example/lib/src/common/constant/config.dart index 772fef8..972977d 100644 --- a/example/lib/src/common/constant/config.dart +++ b/example/lib/src/common/constant/config.dart @@ -52,10 +52,8 @@ enum EnvironmentFlavor { /// Production production('production'); - /// {@nodoc} const EnvironmentFlavor(this.value); - /// {@nodoc} factory EnvironmentFlavor.from(String? value) => switch (value?.trim().toLowerCase()) { 'local' || 'loc' || 'lcl' || 'l' => development, diff --git a/example/lib/src/common/controller/sequential_controller_concurrency.dart b/example/lib/src/common/controller/sequential_controller_concurrency.dart index e03b9a3..25abf47 100644 --- a/example/lib/src/common/controller/sequential_controller_concurrency.dart +++ b/example/lib/src/common/controller/sequential_controller_concurrency.dart @@ -46,9 +46,7 @@ base mixin SequentialControllerConcurrency on Controller { ); } -/// {@nodoc} final class _ControllerEventQueue { - /// {@nodoc} _ControllerEventQueue(); final DoubleLinkedQueue<_SequentialTask> _queue = @@ -57,11 +55,9 @@ final class _ControllerEventQueue { 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); @@ -72,14 +68,12 @@ final class _ControllerEventQueue { /// 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 { @@ -104,23 +98,17 @@ final class _ControllerEventQueue { }); } -/// {@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) { @@ -129,7 +117,6 @@ class _SequentialTask { return result; } - /// {@nodoc} void reject(Object error, [StackTrace? stackTrace]) { if (_completer.isCompleted) return; _completer.completeError(error, stackTrace); diff --git a/example/lib/src/feature/authentication/controller/authentication_state.dart b/example/lib/src/feature/authentication/controller/authentication_state.dart index 225b314..4ffa238 100644 --- a/example/lib/src/feature/authentication/controller/authentication_state.dart +++ b/example/lib/src/feature/authentication/controller/authentication_state.dart @@ -25,10 +25,8 @@ sealed class AuthenticationState extends _$AuthenticationStateBase { } /// Idling state -/// {@nodoc} final class AuthenticationState$Idle extends AuthenticationState with _$AuthenticationState { - /// {@nodoc} const AuthenticationState$Idle( {required super.user, super.message = 'Idling', this.error}); @@ -37,10 +35,8 @@ final class AuthenticationState$Idle extends AuthenticationState } /// Processing -/// {@nodoc} final class AuthenticationState$Processing extends AuthenticationState with _$AuthenticationState { - /// {@nodoc} const AuthenticationState$Processing( {required super.user, super.message = 'Processing'}); @@ -48,17 +44,14 @@ final class AuthenticationState$Processing extends AuthenticationState 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. diff --git a/example/lib/src/feature/chat/controller/chat_connection_state.dart b/example/lib/src/feature/chat/controller/chat_connection_state.dart index cac8687..16f6468 100644 --- a/example/lib/src/feature/chat/controller/chat_connection_state.dart +++ b/example/lib/src/feature/chat/controller/chat_connection_state.dart @@ -27,23 +27,17 @@ sealed class ChatConnectionState extends _$ChatConnectionStateBase { } /// Disconnected -/// {@nodoc} final class ChatConnectionState$Disconnected extends ChatConnectionState { - /// {@nodoc} const ChatConnectionState$Disconnected({super.message = 'Disconnected'}); } /// Connecting -/// {@nodoc} final class ChatConnectionState$Connecting extends ChatConnectionState { - /// {@nodoc} const ChatConnectionState$Connecting({super.message = 'Connecting'}); } /// Connected -/// {@nodoc} final class ChatConnectionState$Connected extends ChatConnectionState { - /// {@nodoc} const ChatConnectionState$Connected({super.message = 'Connected'}); } @@ -51,10 +45,8 @@ final class ChatConnectionState$Connected extends ChatConnectionState { typedef ChatConnectionStateMatch = R Function( S state); -/// {@nodoc} @immutable abstract base class _$ChatConnectionStateBase { - /// {@nodoc} const _$ChatConnectionStateBase({required this.message}); /// Message or state description. diff --git a/example/lib/src/feature/chat/controller/chat_messages_state.dart b/example/lib/src/feature/chat/controller/chat_messages_state.dart index 07649e3..93e9ce3 100644 --- a/example/lib/src/feature/chat/controller/chat_messages_state.dart +++ b/example/lib/src/feature/chat/controller/chat_messages_state.dart @@ -44,9 +44,7 @@ sealed class ChatMessagesState extends _$ChatMessagesStateBase { } /// Idling state -/// {@nodoc} final class ChatMessagesState$Idle extends ChatMessagesState { - /// {@nodoc} const ChatMessagesState$Idle({required super.data, super.message = 'Idling'}); @override @@ -61,9 +59,7 @@ final class ChatMessagesState$Idle extends ChatMessagesState { } /// Processing -/// {@nodoc} final class ChatMessagesState$Processing extends ChatMessagesState { - /// {@nodoc} const ChatMessagesState$Processing( {required super.data, super.message = 'Processing'}); @@ -79,9 +75,7 @@ final class ChatMessagesState$Processing extends ChatMessagesState { } /// Successful -/// {@nodoc} final class ChatMessagesState$Successful extends ChatMessagesState { - /// {@nodoc} const ChatMessagesState$Successful( {required super.data, super.message = 'Successful'}); @@ -97,9 +91,7 @@ final class ChatMessagesState$Successful extends ChatMessagesState { } /// Error -/// {@nodoc} final class ChatMessagesState$Error extends ChatMessagesState { - /// {@nodoc} const ChatMessagesState$Error( {required super.data, super.message = 'An error has occurred.'}); @@ -118,10 +110,8 @@ final class ChatMessagesState$Error extends ChatMessagesState { typedef ChatMessagesStateMatch = R Function( S state); -/// {@nodoc} @immutable abstract base class _$ChatMessagesStateBase { - /// {@nodoc} const _$ChatMessagesStateBase({required this.data, required this.message}); /// Data entity payload. diff --git a/lib/src/client/disconnect_code.dart b/lib/src/client/disconnect_code.dart index f6e4ed3..ca6b097 100644 --- a/lib/src/client/disconnect_code.dart +++ b/lib/src/client/disconnect_code.dart @@ -11,7 +11,6 @@ import 'package:meta/meta.dart'; /// /// Client implementation can use codes <3000 for client-side /// specific disconnect reasons. -/// {@nodoc} @internal enum DisconnectCode { /// Disconnect called @@ -32,7 +31,6 @@ enum DisconnectCode { /// Unsubscribe error unsubscribeError(5, 'unsubscribe error'); - /// {@nodoc} const DisconnectCode(this.code, this.reason); /// Disconnect code. diff --git a/lib/src/client/spinify.dart b/lib/src/client/spinify.dart index 8db7a26..09b62be 100644 --- a/lib/src/client/spinify.dart +++ b/lib/src/client/spinify.dart @@ -81,10 +81,8 @@ final class Spinify extends SpinifyBase static SpinifyObserver? observer; } -/// {@nodoc} @internal abstract base class SpinifyBase implements ISpinify { - /// {@nodoc} SpinifyBase(SpinifyConfig config) : _config = config { _transport = SpinifyWSPBTransport( config: config, @@ -94,22 +92,18 @@ abstract base class SpinifyBase implements ISpinify { /// Internal transport responsible /// for sending, receiving, encoding and decoding data from the server. - /// {@nodoc} @nonVirtual late final ISpinifyTransport _transport; /// Spinify config. - /// {@nodoc} @nonVirtual final SpinifyConfig _config; /// Manager responsible for client-side subscriptions. - /// {@nodoc} late final ClientSubscriptionManager _clientSubscriptionManager = ClientSubscriptionManager(_transport); /// Manager responsible for client-side subscriptions. - /// {@nodoc} late final ServerSubscriptionManager _serverSubscriptionManager = ServerSubscriptionManager(_transport); @@ -124,7 +118,6 @@ abstract base class SpinifyBase implements ISpinify { /// Init spinify client, override this method to add custom logic. /// This method is called in constructor. - /// {@nodoc} @protected @mustCallSuper void _initSpinify() { @@ -134,7 +127,6 @@ abstract base class SpinifyBase implements ISpinify { /// Called when connection established. /// Right before [SpinifyState$Connected] state. - /// {@nodoc} @protected @mustCallSuper void _onConnected(SpinifyState$Connected state) { @@ -144,7 +136,6 @@ abstract base class SpinifyBase implements ISpinify { /// Called when connection lost. /// Right before [SpinifyState$Disconnected] state. - /// {@nodoc} @protected @mustCallSuper void _onDisconnected(SpinifyState$Disconnected state) { @@ -166,7 +157,6 @@ abstract base class SpinifyBase implements ISpinify { /// Mixin responsible for event receiving and distribution by controllers /// and streams to subscribers. -/// {@nodoc} base mixin SpinifyEventReceiverMixin on SpinifyBase, SpinifyStateMixin { @protected @nonVirtual @@ -216,7 +206,6 @@ base mixin SpinifyEventReceiverMixin on SpinifyBase, SpinifyStateMixin { } /// Router for all events. - /// {@nodoc} @protected @nonVirtual @pragma('vm:prefer-inline') @@ -290,11 +279,9 @@ base mixin SpinifyEventReceiverMixin on SpinifyBase, SpinifyStateMixin { } /// Mixin responsible for spinify states -/// {@nodoc} @internal base mixin SpinifyStateMixin on SpinifyBase, SpinifyErrorsMixin { /// Refresh timer. - /// {@nodoc} Timer? _refreshTimer; @override @@ -347,7 +334,6 @@ base mixin SpinifyStateMixin on SpinifyBase, SpinifyErrorsMixin { StreamController.broadcast(); /// Refresh connection token when ttl is expired. - /// {@nodoc} void _setRefreshTimer(DateTime? ttl) { _refreshTimer?.cancel(); _refreshTimer = null; @@ -359,7 +345,6 @@ base mixin SpinifyStateMixin on SpinifyBase, SpinifyErrorsMixin { } /// Refresh token for subscription. - /// {@nodoc} void _refreshToken() => Future(() async { try { _refreshTimer?.cancel(); @@ -391,7 +376,6 @@ base mixin SpinifyStateMixin on SpinifyBase, SpinifyErrorsMixin { } /// Mixin responsible for errors stream. -/// {@nodoc} @internal base mixin SpinifyErrorsMixin on SpinifyBase { @protected @@ -401,7 +385,6 @@ base mixin SpinifyErrorsMixin on SpinifyBase { } /// Mixin responsible for connection. -/// {@nodoc} @internal base mixin SpinifyConnectionMixin on SpinifyBase, SpinifyErrorsMixin, SpinifyStateMixin { @@ -497,7 +480,6 @@ base mixin SpinifyConnectionMixin } /// Mixin responsible for sending asynchronous messages. -/// {@nodoc} @internal base mixin SpinifySendMixin on SpinifyBase, SpinifyErrorsMixin { @override @@ -517,7 +499,6 @@ base mixin SpinifySendMixin on SpinifyBase, SpinifyErrorsMixin { } /// Mixin responsible for client-side subscriptions. -/// {@nodoc} @internal base mixin SpinifyClientSubscriptionMixin on SpinifyBase, SpinifyErrorsMixin { @override @@ -580,7 +561,6 @@ base mixin SpinifyClientSubscriptionMixin on SpinifyBase, SpinifyErrorsMixin { } /// Mixin responsible for server-side subscriptions. -/// {@nodoc} @internal base mixin SpinifyServerSubscriptionMixin on SpinifyBase { @override @@ -603,7 +583,6 @@ base mixin SpinifyServerSubscriptionMixin on SpinifyBase { } /// Mixin responsible for publications. -/// {@nodoc} @internal base mixin SpinifyPublicationsMixin on SpinifyBase, SpinifyErrorsMixin, SpinifyClientSubscriptionMixin { @@ -627,7 +606,6 @@ base mixin SpinifyPublicationsMixin } /// Mixin responsible for presence. -/// {@nodoc} base mixin SpinifyPresenceMixin on SpinifyBase, SpinifyErrorsMixin { @override Future presence(String channel) async { @@ -667,7 +645,6 @@ base mixin SpinifyPresenceMixin on SpinifyBase, SpinifyErrorsMixin { } /// Mixin responsible for history. -/// {@nodoc} base mixin SpinifyHistoryMixin on SpinifyBase, SpinifyErrorsMixin { @override Future history( @@ -699,7 +676,6 @@ base mixin SpinifyHistoryMixin on SpinifyBase, SpinifyErrorsMixin { } /// Mixin responsible for history. -/// {@nodoc} base mixin SpinifyRPCMixin on SpinifyBase, SpinifyErrorsMixin { @override Future> rpc(String method, List data) async { @@ -721,7 +697,6 @@ base mixin SpinifyRPCMixin on SpinifyBase, SpinifyErrorsMixin { } /// Responsible for metrics. -/// {@nodoc} @internal base mixin SpinifyMetricsMixin on SpinifyBase, SpinifyStateMixin { int _connectsTotal = 0, _connectsSuccessful = 0, _disconnects = 0; @@ -786,10 +761,8 @@ base mixin SpinifyMetricsMixin on SpinifyBase, SpinifyStateMixin { /// Mixin responsible for queue. /// SHOULD BE LAST MIXIN. -/// {@nodoc} @internal base mixin SpinifyQueueMixin on SpinifyBase { - /// {@nodoc} final SpinifyEventQueue _eventQueue = SpinifyEventQueue(); @override diff --git a/lib/src/client/state.dart b/lib/src/client/state.dart index f209fbf..2dea951 100644 --- a/lib/src/client/state.dart +++ b/lib/src/client/state.dart @@ -416,10 +416,8 @@ final class SpinifyState$Closed extends SpinifyState { /// {@category Entity} typedef SpinifyStateMatch = R Function(S state); -/// {@nodoc} @immutable abstract base class _$SpinifyStateBase { - /// {@nodoc} const _$SpinifyStateBase(this.timestamp); /// Represents the current state type. diff --git a/lib/src/model/jwt.dart b/lib/src/model/jwt.dart index f772efd..0787190 100644 --- a/lib/src/model/jwt.dart +++ b/lib/src/model/jwt.dart @@ -47,7 +47,6 @@ sealed class SpinifyJWT { factory SpinifyJWT.decode(String jwt, [String? secret]) = _SpinifyJWTImpl.decode; - /// {@nodoc} const SpinifyJWT._(); /// This is a standard JWT claim which must contain @@ -398,9 +397,7 @@ final class _SpinifyJWTImpl extends SpinifyJWT { /// A converter that converts Base64-encoded strings /// to unpadded Base64-encoded strings. -/// {@nodoc} class _UnpaddedBase64Converter extends Converter { - /// {@nodoc} const _UnpaddedBase64Converter(); @override diff --git a/lib/src/model/refresh_result.dart b/lib/src/model/refresh_result.dart index bff42dd..f66b0eb 100644 --- a/lib/src/model/refresh_result.dart +++ b/lib/src/model/refresh_result.dart @@ -1,10 +1,8 @@ import 'package:meta/meta.dart'; -/// {@nodoc} @internal @immutable final class SpinifyRefreshResult { - /// {@nodoc} const SpinifyRefreshResult({ required this.expires, this.client, @@ -25,11 +23,9 @@ final class SpinifyRefreshResult { final DateTime? ttl; } -/// {@nodoc} @internal @immutable final class SpinifySubRefreshResult { - /// {@nodoc} const SpinifySubRefreshResult({ required this.expires, this.ttl, diff --git a/lib/src/subscription/client_subscription_impl.dart b/lib/src/subscription/client_subscription_impl.dart index 640c720..3ad363b 100644 --- a/lib/src/subscription/client_subscription_impl.dart +++ b/lib/src/subscription/client_subscription_impl.dart @@ -29,7 +29,6 @@ import 'package:spinify/src/util/event_queue.dart'; import 'package:spinify/src/util/logger.dart' as logger; /// Client-side subscription implementation. -/// {@nodoc} @internal final class SpinifyClientSubscriptionImpl extends SpinifyClientSubscriptionBase with @@ -40,7 +39,6 @@ final class SpinifyClientSubscriptionImpl extends SpinifyClientSubscriptionBase SpinifyClientSubscriptionHistoryMixin, SpinifyClientSubscriptionPresenceMixin, SpinifyClientSubscriptionQueueMixin { - /// {@nodoc} SpinifyClientSubscriptionImpl({ required super.channel, required super.transportWeakRef, @@ -48,11 +46,9 @@ final class SpinifyClientSubscriptionImpl extends SpinifyClientSubscriptionBase }) : super(config: config ?? const SpinifySubscriptionConfig.byDefault()); } -/// {@nodoc} @internal abstract base class SpinifyClientSubscriptionBase extends SpinifyClientSubscription { - /// {@nodoc} SpinifyClientSubscriptionBase({ required this.channel, required WeakReference transportWeakRef, @@ -75,21 +71,17 @@ abstract base class SpinifyClientSubscriptionBase }; /// Weak reference to transport. - /// {@nodoc} @nonVirtual late final WeakReference _transportWeakRef; /// Internal transport responsible /// for sending, receiving, encoding and decoding data from the server. - /// {@nodoc} ISpinifyTransport get _transport => _transportWeakRef.target!; /// Subscription config. - /// {@nodoc} final SpinifySubscriptionConfig _config; /// Init subscription. - /// {@nodoc} @protected @mustCallSuper void _initSubscription() { @@ -102,24 +94,20 @@ abstract base class SpinifyClientSubscriptionBase /// - `unsubscribed` /// - `subscribing` /// - `subscribed` - /// {@nodoc} @override SpinifySubscriptionState get state => _state; late SpinifySubscriptionState _state; /// Stream of subscription states. - /// {@nodoc} @override late final SpinifySubscriptionStateStream states = SpinifySubscriptionStateStream(_stateController.stream); /// States controller. - /// {@nodoc} final StreamController _stateController = StreamController.broadcast(); /// Set new state. - /// {@nodoc} void _setState(SpinifySubscriptionState state) { if (_state == state) return; final previousState = _state; @@ -128,14 +116,12 @@ abstract base class SpinifyClientSubscriptionBase } /// Notify about new publication. - /// {@nodoc} @nonVirtual void _handlePublication(SpinifyPublication publication) { final offset = publication.offset; if (offset != null && offset > _offset) _offset = offset; } - /// {@nodoc} @internal @mustCallSuper Future close([int code = 0, String reason = 'closed']) async { @@ -156,7 +142,6 @@ abstract base class SpinifyClientSubscriptionBase /// Mixin responsible for event receiving and distribution by controllers /// and streams to subscribers. -/// {@nodoc} base mixin SpinifyClientSubscriptionEventReceiverMixin on SpinifyClientSubscriptionBase { @protected @@ -207,7 +192,6 @@ base mixin SpinifyClientSubscriptionEventReceiverMixin /// Handle push event from server for the specific channel. /// Called from `SpinifyClientSubscriptionsManager.onPush` - /// {@nodoc} @internal @nonVirtual void onPush(SpinifyChannelPush push) { @@ -255,7 +239,6 @@ base mixin SpinifyClientSubscriptionEventReceiverMixin } /// Mixin responsible for errors stream. -/// {@nodoc} @internal base mixin SpinifyClientSubscriptionErrorsMixin on SpinifyClientSubscriptionBase { @@ -266,16 +249,13 @@ base mixin SpinifyClientSubscriptionErrorsMixin } /// Mixin responsible for subscribing. -/// {@nodoc} @internal base mixin SpinifyClientSubscriptionSubscribeMixin on SpinifyClientSubscriptionBase, SpinifyClientSubscriptionErrorsMixin { /// Refresh timer. - /// {@nodoc} Timer? _refreshTimer; /// Start subscribing to a channel - /// {@nodoc} @override Future subscribe() async { logger.fine('Subscribing to $channel'); @@ -330,7 +310,6 @@ base mixin SpinifyClientSubscriptionSubscribeMixin } /// Await for subscription to be ready. - /// {@nodoc} @override FutureOr ready() async { try { @@ -374,7 +353,6 @@ base mixin SpinifyClientSubscriptionSubscribeMixin } /// Unsubscribe from a channel - /// {@nodoc} @override Future unsubscribe( [int code = 0, String reason = 'unsubscribe called']) async { @@ -408,7 +386,6 @@ base mixin SpinifyClientSubscriptionSubscribeMixin } /// Refresh subscription when ttl is expired. - /// {@nodoc} void _setRefreshTimer(DateTime? ttl) { _refreshTimer?.cancel(); _refreshTimer = null; @@ -420,7 +397,6 @@ base mixin SpinifyClientSubscriptionSubscribeMixin } /// Refresh token for subscription. - /// {@nodoc} void _refreshToken() => Future(() async { logger.fine('Refreshing subscription token for $channel'); try { @@ -466,7 +442,6 @@ base mixin SpinifyClientSubscriptionSubscribeMixin } /// Mixin responsible for publishing. -/// {@nodoc} @internal base mixin SpinifyClientSubscriptionPublishingMixin on SpinifyClientSubscriptionBase, SpinifyClientSubscriptionErrorsMixin { @@ -487,7 +462,6 @@ base mixin SpinifyClientSubscriptionPublishingMixin } /// Mixin responsible for history. -/// {@nodoc} @internal base mixin SpinifyClientSubscriptionHistoryMixin on SpinifyClientSubscriptionBase, SpinifyClientSubscriptionErrorsMixin { @@ -518,7 +492,6 @@ base mixin SpinifyClientSubscriptionHistoryMixin } /// Mixin responsible for presence. -/// {@nodoc} @internal base mixin SpinifyClientSubscriptionPresenceMixin on SpinifyClientSubscriptionBase, SpinifyClientSubscriptionErrorsMixin { @@ -557,11 +530,9 @@ base mixin SpinifyClientSubscriptionPresenceMixin /// Mixin responsible for queue. /// SHOULD BE LAST MIXIN. -/// {@nodoc} @internal base mixin SpinifyClientSubscriptionQueueMixin on SpinifyClientSubscriptionBase { - /// {@nodoc} final SpinifyEventQueue _eventQueue = SpinifyEventQueue(); @override diff --git a/lib/src/subscription/client_subscription_manager.dart b/lib/src/subscription/client_subscription_manager.dart index e809316..702ff1f 100644 --- a/lib/src/subscription/client_subscription_manager.dart +++ b/lib/src/subscription/client_subscription_manager.dart @@ -10,15 +10,12 @@ import 'package:spinify/src/subscription/subscription_state.dart'; import 'package:spinify/src/transport/transport_interface.dart'; /// Responsible for managing client-side subscriptions. -/// {@nodoc} @internal final class ClientSubscriptionManager { - /// {@nodoc} ClientSubscriptionManager(ISpinifyTransport transport) : _transportWeakRef = WeakReference(transport); /// Spinify client weak reference. - /// {@nodoc} final WeakReference _transportWeakRef; /// Subscriptions count. @@ -45,7 +42,6 @@ final class ClientSubscriptionManager { /// Subscriptions registry (channel -> subscription). /// Channel : SpinifyClientSubscription - /// {@nodoc} final Map _channelSubscriptions = {}; @@ -53,7 +49,6 @@ final class ClientSubscriptionManager { /// `newSubscription(channel, config)` allocates a new Subscription /// in the registry or throws an exception if the Subscription /// is already there. We will discuss common Subscription options below. - /// {@nodoc} SpinifyClientSubscription newSubscription( String channel, SpinifySubscriptionConfig? config, @@ -76,7 +71,6 @@ final class ClientSubscriptionManager { /// Returns all registered subscriptions, /// so you can iterate over all and do some action if required /// (for example, you want to unsubscribe/remove all subscriptions). - /// {@nodoc} Map get subscriptions => UnmodifiableMapView({ for (final entry in _channelSubscriptions.entries) @@ -85,7 +79,6 @@ final class ClientSubscriptionManager { /// Remove the [SpinifyClientSubscription] from internal registry /// and unsubscribe from [SpinifyClientSubscription.channel]. - /// {@nodoc} Future removeSubscription( SpinifyClientSubscription subscription, ) async { @@ -113,7 +106,6 @@ final class ClientSubscriptionManager { } /// Establish all subscriptions for the specific client. - /// {@nodoc} void subscribeAll() { for (final entry in _channelSubscriptions.values) { entry.subscribe().ignore(); @@ -122,7 +114,6 @@ final class ClientSubscriptionManager { /// Disconnect all subscriptions for the specific client /// from internal registry. - /// {@nodoc} void unsubscribeAll([ int code = 0, String reason = 'connection closed', @@ -133,7 +124,6 @@ final class ClientSubscriptionManager { } /// Remove all subscriptions for the specific client from internal registry. - /// {@nodoc} void close([ int code = 0, String reason = 'client closed', @@ -145,7 +135,6 @@ final class ClientSubscriptionManager { } /// Handle push event from server for the specific channel. - /// {@nodoc} @internal void onPush(SpinifyChannelPush push) => _channelSubscriptions[push.channel]?.onPush(push); @@ -155,7 +144,6 @@ final class ClientSubscriptionManager { /// /// You need to call [SpinifyClientSubscription.subscribe] /// to start receiving events - /// {@nodoc} SpinifyClientSubscription? operator [](String channel) => _channelSubscriptions[channel]; } diff --git a/lib/src/subscription/server_subscription_impl.dart b/lib/src/subscription/server_subscription_impl.dart index 555098e..0cb6b3c 100644 --- a/lib/src/subscription/server_subscription_impl.dart +++ b/lib/src/subscription/server_subscription_impl.dart @@ -27,7 +27,6 @@ import 'package:spinify/src/util/event_queue.dart'; import 'package:spinify/src/util/logger.dart' as logger; /// Server-side subscription implementation. -/// {@nodoc} @internal final class SpinifyServerSubscriptionImpl extends SpinifyServerSubscriptionBase with @@ -38,18 +37,15 @@ final class SpinifyServerSubscriptionImpl extends SpinifyServerSubscriptionBase SpinifyServerSubscriptionHistoryMixin, SpinifyServerSubscriptionPresenceMixin, SpinifyServerSubscriptionQueueMixin { - /// {@nodoc} SpinifyServerSubscriptionImpl({ required super.channel, required super.transportWeakRef, }); } -/// {@nodoc} @internal abstract base class SpinifyServerSubscriptionBase extends SpinifyServerSubscription { - /// {@nodoc} SpinifyServerSubscriptionBase({ required this.channel, required WeakReference transportWeakRef, @@ -71,17 +67,14 @@ abstract base class SpinifyServerSubscriptionBase fixnum.Int64 _offset = fixnum.Int64.ZERO; /// Weak reference to transport. - /// {@nodoc} @nonVirtual late final WeakReference _transportWeakRef; /// Internal transport responsible /// for sending, receiving, encoding and decoding data from the server. - /// {@nodoc} ISpinifyTransport get _transport => _transportWeakRef.target!; /// Init subscription. - /// {@nodoc} @protected @mustCallSuper void _initSubscription() { @@ -93,24 +86,20 @@ abstract base class SpinifyServerSubscriptionBase /// - `unsubscribed` /// - `subscribing` /// - `subscribed` - /// {@nodoc} @override SpinifySubscriptionState get state => _state; late SpinifySubscriptionState _state; /// Stream of subscription states. - /// {@nodoc} @override late final SpinifySubscriptionStateStream states = SpinifySubscriptionStateStream(_stateController.stream); /// States controller. - /// {@nodoc} final StreamController _stateController = StreamController.broadcast(); /// Set new state. - /// {@nodoc} void _setState(SpinifySubscriptionState state) { if (_state == state) return; final previousState = _state; @@ -119,14 +108,12 @@ abstract base class SpinifyServerSubscriptionBase } /// Notify about new publication. - /// {@nodoc} @nonVirtual void _handlePublication(SpinifyPublication publication) { final offset = publication.offset; if (offset != null && offset > _offset) _offset = offset; } - /// {@nodoc} @internal @mustCallSuper Future close([int code = 0, String reason = 'closed']) async { @@ -146,7 +133,6 @@ abstract base class SpinifyServerSubscriptionBase /// Mixin responsible for event receiving and distribution by controllers /// and streams to subscribers. -/// {@nodoc} base mixin SpinifyServerSubscriptionEventReceiverMixin on SpinifyServerSubscriptionBase { @protected @@ -197,7 +183,6 @@ base mixin SpinifyServerSubscriptionEventReceiverMixin /// Handle push event from server for the specific channel. /// Called from `SpinifyClientSubscriptionsManager.onPush` - /// {@nodoc} @internal @nonVirtual void onPush(SpinifyChannelPush push) { @@ -255,7 +240,6 @@ base mixin SpinifyServerSubscriptionEventReceiverMixin } /// Mixin responsible for errors stream. -/// {@nodoc} @internal base mixin SpinifyServerSubscriptionErrorsMixin on SpinifyServerSubscriptionBase { @@ -266,12 +250,10 @@ base mixin SpinifyServerSubscriptionErrorsMixin } /// Mixin responsible for ready method. -/// {@nodoc} @internal base mixin SpinifyServerSubscriptionReadyMixin on SpinifyServerSubscriptionBase, SpinifyServerSubscriptionErrorsMixin { /// Await for subscription to be ready. - /// {@nodoc} @override FutureOr ready() async { try { @@ -301,7 +283,6 @@ base mixin SpinifyServerSubscriptionReadyMixin } /// Mark subscription as ready. - /// {@nodoc} void setSubscribed() { if (!state.isSubscribed) _setState(SpinifySubscriptionState.subscribed( @@ -311,7 +292,6 @@ base mixin SpinifyServerSubscriptionReadyMixin } /// Mark subscription as subscribing. - /// {@nodoc} void setSubscribing() { if (!state.isSubscribing) _setState(SpinifySubscriptionState.subscribing( @@ -321,7 +301,6 @@ base mixin SpinifyServerSubscriptionReadyMixin } /// Mark subscription as unsubscribed. - /// {@nodoc} void setUnsubscribed(int code, String reason) { if (!state.isUnsubscribed) _setState(SpinifySubscriptionState.unsubscribed( @@ -341,7 +320,6 @@ base mixin SpinifyServerSubscriptionReadyMixin } /// Mixin responsible for publishing. -/// {@nodoc} @internal base mixin SpinifyServerSubscriptionPublishingMixin on SpinifyServerSubscriptionBase, SpinifyServerSubscriptionErrorsMixin { @@ -362,7 +340,6 @@ base mixin SpinifyServerSubscriptionPublishingMixin } /// Mixin responsible for history. -/// {@nodoc} @internal base mixin SpinifyServerSubscriptionHistoryMixin on SpinifyServerSubscriptionBase, SpinifyServerSubscriptionErrorsMixin { @@ -393,7 +370,6 @@ base mixin SpinifyServerSubscriptionHistoryMixin } /// Mixin responsible for presence. -/// {@nodoc} @internal base mixin SpinifyServerSubscriptionPresenceMixin on SpinifyServerSubscriptionBase, SpinifyServerSubscriptionErrorsMixin { @@ -432,11 +408,9 @@ base mixin SpinifyServerSubscriptionPresenceMixin /// Mixin responsible for queue. /// SHOULD BE LAST MIXIN. -/// {@nodoc} @internal base mixin SpinifyServerSubscriptionQueueMixin on SpinifyServerSubscriptionBase { - /// {@nodoc} final SpinifyEventQueue _eventQueue = SpinifyEventQueue(); @override diff --git a/lib/src/subscription/server_subscription_manager.dart b/lib/src/subscription/server_subscription_manager.dart index a679981..3d96bf3 100644 --- a/lib/src/subscription/server_subscription_manager.dart +++ b/lib/src/subscription/server_subscription_manager.dart @@ -10,15 +10,12 @@ import 'package:spinify/src/subscription/subscription_state.dart'; import 'package:spinify/src/transport/transport_interface.dart'; /// Responsible for managing client-side subscriptions. -/// {@nodoc} @internal final class ServerSubscriptionManager { - /// {@nodoc} ServerSubscriptionManager(ISpinifyTransport transport) : _transportWeakRef = WeakReference(transport); /// Spinify client weak reference. - /// {@nodoc} final WeakReference _transportWeakRef; /// Subscriptions count. @@ -45,7 +42,6 @@ final class ServerSubscriptionManager { /// Subscriptions registry (channel -> subscription). /// Channel : SpinifyClientSubscription - /// {@nodoc} final Map _channelSubscriptions = {}; @@ -53,7 +49,6 @@ final class ServerSubscriptionManager { /// Returns all registered subscriptions, /// so you can iterate over all and do some action if required /// (for example, you want to unsubscribe/remove all subscriptions). - /// {@nodoc} Map get subscriptions => UnmodifiableMapView({ for (final entry in _channelSubscriptions.entries) @@ -89,7 +84,6 @@ final class ServerSubscriptionManager { /// Called when subscribed to a server-side channel upon Client moving to /// connected state or during connection lifetime if server sends Subscribe /// push message. - /// {@nodoc} void setSubscribedAll() { for (final entry in _channelSubscriptions.values) { if (entry.state.isSubscribed) continue; @@ -99,7 +93,6 @@ final class ServerSubscriptionManager { /// Called when existing connection lost (Client reconnects) or Client /// explicitly disconnected. Client continue keeping server-side subscription /// registry with stream position information where applicable. - /// {@nodoc} void setSubscribingAll() { for (final entry in _channelSubscriptions.values) { if (entry.state.isSubscribing) continue; @@ -109,7 +102,6 @@ final class ServerSubscriptionManager { /// Called when server sent unsubscribe push or server-side subscription /// previously existed in SDK registry disappeared upon Client reconnect. - /// {@nodoc} void setUnsubscribedAll([int code = 0, String reason = 'unsubscribed']) { for (final entry in _channelSubscriptions.values) { if (entry.state.isUnsubscribed) continue; @@ -118,7 +110,6 @@ final class ServerSubscriptionManager { } /// Close all subscriptions. - /// {@nodoc} void close([ int code = 0, String reason = 'client closed', @@ -130,7 +121,6 @@ final class ServerSubscriptionManager { } /// Handle push event from server for the specific channel. - /// {@nodoc} @internal void onPush(SpinifyChannelPush push) => _channelSubscriptions[push.channel]?.onPush(push); @@ -140,7 +130,6 @@ final class ServerSubscriptionManager { /// /// You need to call [SpinifyClientSubscription.subscribe] /// to start receiving events - /// {@nodoc} SpinifyServerSubscription? operator [](String channel) => _channelSubscriptions[channel]; } diff --git a/lib/src/subscription/subscription_state.dart b/lib/src/subscription/subscription_state.dart index f059652..bf1113b 100644 --- a/lib/src/subscription/subscription_state.dart +++ b/lib/src/subscription/subscription_state.dart @@ -55,7 +55,6 @@ sealed class SpinifySubscriptionState extends _$SpinifySubscriptionStateBase { /// {@category Entity} final class SpinifySubscriptionState$Unsubscribed extends SpinifySubscriptionState { - /// {@nodoc} SpinifySubscriptionState$Unsubscribed({ required this.code, required this.reason, @@ -120,7 +119,6 @@ final class SpinifySubscriptionState$Unsubscribed /// {@category Entity} final class SpinifySubscriptionState$Subscribing extends SpinifySubscriptionState { - /// {@nodoc} SpinifySubscriptionState$Subscribing({ DateTime? timestamp, super.since, @@ -170,7 +168,6 @@ final class SpinifySubscriptionState$Subscribing /// {@category Entity} final class SpinifySubscriptionState$Subscribed extends SpinifySubscriptionState { - /// {@nodoc} SpinifySubscriptionState$Subscribed({ DateTime? timestamp, super.since, @@ -228,10 +225,8 @@ final class SpinifySubscriptionState$Subscribed typedef SpinifySubscriptionStateMatch = R Function(S state); -/// {@nodoc} @immutable abstract base class _$SpinifySubscriptionStateBase { - /// {@nodoc} const _$SpinifySubscriptionStateBase({ required this.timestamp, required this.since, diff --git a/lib/src/subscription/unsubscribe_code.dart b/lib/src/subscription/unsubscribe_code.dart index 2e54fb0..3901aaa 100644 --- a/lib/src/subscription/unsubscribe_code.dart +++ b/lib/src/subscription/unsubscribe_code.dart @@ -1,7 +1,6 @@ import 'package:meta/meta.dart'; /// Unsubscribe codes. -/// {@nodoc} @internal enum UnsubscribeCode { /// Disconnect called diff --git a/lib/src/transport/transport_interface.dart b/lib/src/transport/transport_interface.dart index 6cd4191..19efe67 100644 --- a/lib/src/transport/transport_interface.dart +++ b/lib/src/transport/transport_interface.dart @@ -14,37 +14,29 @@ import 'package:spinify/src/subscription/subscription_config.dart'; import 'package:spinify/src/util/notifier.dart'; /// Class responsible for sending and receiving data from the server. -/// {@nodoc} @internal abstract interface class ISpinifyTransport { /// Current state - /// {@nodoc} SpinifyState get state; /// State observable. - /// {@nodoc} abstract final SpinifyListenable states; /// Spinify events. - /// {@nodoc} abstract final SpinifyListenable events; /// Received bytes count & size. - /// {@nodoc} ({BigInt count, BigInt size}) get received; /// Transferred bytes count & size. - /// {@nodoc} ({BigInt count, BigInt size}) get transferred; /// Message response timeout in milliseconds. - /// {@nodoc} ({int min, int avg, int max}) get speed; /// Connect to the server. /// [url] is a URL of endpoint. /// [subs] is a list of server-side subscriptions to subscribe on connect. - /// {@nodoc} Future connect( String url, ServerSubscriptionManager serverSubscriptionManager, @@ -53,11 +45,9 @@ abstract interface class ISpinifyTransport { /// Send asynchronous message to a server. This method makes sense /// only when using Centrifuge library for Go on a server side. In Centrifuge /// asynchronous message handler does not exist. - /// {@nodoc} Future sendAsyncMessage(List data); /// Subscribe on channel with optional [since] position. - /// {@nodoc} Future subscribe( String channel, SpinifySubscriptionConfig config, @@ -65,19 +55,16 @@ abstract interface class ISpinifyTransport { ); /// Unsubscribe from channel. - /// {@nodoc} Future unsubscribe( String channel, SpinifySubscriptionConfig config, ); /// Publish data to channel. - /// {@nodoc} Future publish(String channel, List data); /// Fetch publication history inside a channel. /// Only for channels where history is enabled. - /// {@nodoc} Future history( String channel, { int? limit, @@ -86,24 +73,19 @@ abstract interface class ISpinifyTransport { }); /// Fetch presence information inside a channel. - /// {@nodoc} Future presence(String channel); /// Fetch presence stats information inside a channel. - /// {@nodoc} Future presenceStats(String channel); /// Disconnect from the server. /// e.g. code: 0, reason: 'disconnect called' - /// {@nodoc} Future disconnect(int code, String reason); /// Send refresh token command to server. - /// {@nodoc} Future sendRefresh(String token); /// Send subscription channel refresh token command to server. - /// {@nodoc} Future sendSubRefresh( String channel, String token, @@ -114,6 +96,5 @@ abstract interface class ISpinifyTransport { /// Permanent close connection to the server and /// free all allocated resources. - /// {@nodoc} Future close(); } diff --git a/lib/src/transport/transport_protobuf_codec.dart b/lib/src/transport/transport_protobuf_codec.dart index ec51f7c..5d27b99 100644 --- a/lib/src/transport/transport_protobuf_codec.dart +++ b/lib/src/transport/transport_protobuf_codec.dart @@ -5,10 +5,8 @@ import 'package:protobuf/protobuf.dart' as pb; import 'package:spinify/src/transport/protobuf/client.pb.dart' as pb; import 'package:spinify/src/util/logger.dart' as logger; -/// {@nodoc} @internal final class TransportProtobufCodec extends Codec> { - /// {@nodoc} const TransportProtobufCodec(); @override @@ -20,10 +18,8 @@ final class TransportProtobufCodec extends Codec> { const TransportProtobufEncoder(); } -/// {@nodoc} @internal final class TransportProtobufEncoder extends Converter> { - /// {@nodoc} const TransportProtobufEncoder(); @override @@ -39,11 +35,9 @@ final class TransportProtobufEncoder extends Converter> { } } -/// {@nodoc} @internal final class TransportProtobufDecoder extends Converter, Iterable> { - /// {@nodoc} const TransportProtobufDecoder(); @override diff --git a/lib/src/transport/ws_protobuf_transport.dart b/lib/src/transport/ws_protobuf_transport.dart index b329d8c..d774702 100644 --- a/lib/src/transport/ws_protobuf_transport.dart +++ b/lib/src/transport/ws_protobuf_transport.dart @@ -36,10 +36,8 @@ import 'package:spinify/src/util/notifier.dart'; import 'package:spinify/src/util/speed_meter.dart'; import 'package:ws/ws.dart'; -/// {@nodoc} @internal abstract base class SpinifyWSPBTransportBase implements ISpinifyTransport { - /// {@nodoc} SpinifyWSPBTransportBase({ required SpinifyConfig config, }) : _config = config, @@ -63,13 +61,11 @@ abstract base class SpinifyWSPBTransportBase implements ISpinifyTransport { } /// Protocols for websocket. - /// {@nodoc} static const List _$protocolsSpinifyProtobuf = [ 'centrifuge-protobuf' ]; /// Spinify config. - /// {@nodoc} final SpinifyConfig _config; @override @@ -77,13 +73,11 @@ abstract base class SpinifyWSPBTransportBase implements ISpinifyTransport { SpinifyChangeNotifier(); /// Init transport, override this method to add custom logic. - /// {@nodoc} @protected @mustCallSuper void _initTransport() {} /// Websocket client. - /// {@nodoc} @nonVirtual final WebSocketClient _webSocket; @@ -116,7 +110,6 @@ abstract base class SpinifyWSPBTransportBase implements ISpinifyTransport { /// Class responsible for sending and receiving data from the server /// through the Protobuf & WebSocket protocol. -/// {@nodoc} @internal // ignore: lines_longer_than_80_chars final class SpinifyWSPBTransport = SpinifyWSPBTransportBase @@ -130,23 +123,19 @@ final class SpinifyWSPBTransport = SpinifyWSPBTransportBase SpinifyWSPBHandlerMixin; /// Stored completer for responses. -/// {@nodoc} typedef _ReplyCompleter = ({ void Function(pb.Reply reply) complete, void Function(Object error, StackTrace stackTrace) fail, }); /// Mixin responsible for holding reply completers. -/// {@nodoc} @internal base mixin SpinifyWSPBReplyMixin on SpinifyWSPBTransportBase { /// Completers for messages by id. /// Contains timer for timeout and completer for response. - /// {@nodoc} final Map _replyCompleters = {}; /// Observe reply future by command id. - /// {@nodoc} Future _awaitReply(int commandId, [Duration? timeout]) { final completer = Completer.sync(); final timeoutTimer = timeout != null && timeout > Duration.zero @@ -183,12 +172,10 @@ base mixin SpinifyWSPBReplyMixin on SpinifyWSPBTransportBase { } /// Complete reply by id. - /// {@nodoc} void _completeReply(pb.Reply reply) => _replyCompleters.remove(reply.id)?.complete(reply); /// Fail all replies. - /// {@nodoc} void _failAllReplies(Object error, StackTrace stackTrace) { for (final completer in _replyCompleters.values) { completer.fail(error, stackTrace); @@ -198,12 +185,10 @@ base mixin SpinifyWSPBReplyMixin on SpinifyWSPBTransportBase { } /// Mixin responsible for sending data through websocket with protobuf. -/// {@nodoc} @internal base mixin SpinifyWSPBSenderMixin on SpinifyWSPBTransportBase, SpinifyWSPBReplyMixin { /// Encoder protobuf commands to bytes. - /// {@nodoc} static const Converter> _commandEncoder = TransportProtobufEncoder(); @@ -214,10 +199,8 @@ base mixin SpinifyWSPBSenderMixin ({int min, int avg, int max}) get speed => _speedMeter.speed; /// Counter for messages. - /// {@nodoc} int _messageId = 1; - /// {@nodoc} @nonVirtual @protected Future _sendMessage sendAsyncMessage(List data) => _sendAsyncMessage(pb.Message()..data = data); - /// {@nodoc} @nonVirtual @protected Future _sendAsyncMessage( @@ -342,7 +324,6 @@ base mixin SpinifyWSPBSenderMixin } /// Mixin responsible for connection. -/// {@nodoc} @internal base mixin SpinifyWSPBConnectionMixin on @@ -479,13 +460,11 @@ base mixin SpinifyWSPBConnectionMixin } /// Handler for websocket states. -/// {@nodoc} @internal base mixin SpinifyWSPBStateHandlerMixin on SpinifyWSPBTransportBase, SpinifyWSPBReplyMixin { // Subscribe to websocket state after first connection. /// Subscription to websocket state. - /// {@nodoc} StreamSubscription? _webSocketClosedStateSubscription; @override @@ -500,7 +479,6 @@ base mixin SpinifyWSPBStateHandlerMixin closeReason: 'Not connected yet', ); - /// {@nodoc} @override @nonVirtual final SpinifyChangeNotifier states = SpinifyChangeNotifier(); @@ -511,7 +489,6 @@ base mixin SpinifyWSPBStateHandlerMixin } /// Change state of spinify client. - /// {@nodoc} @protected @nonVirtual void _setState(SpinifyState state) { @@ -566,7 +543,6 @@ base mixin SpinifyWSPBStateHandlerMixin } /// Handler for websocket messages and decode protobuf. -/// {@nodoc} @internal base mixin SpinifyWSPBHandlerMixin on @@ -574,12 +550,10 @@ base mixin SpinifyWSPBHandlerMixin SpinifyWSPBSenderMixin, SpinifyWSPBPingPongMixin { /// Encoder protobuf commands to bytes. - /// {@nodoc} static const Converter, Iterable> _replyDecoder = TransportProtobufDecoder(); /// Subscription to websocket messages/data. - /// {@nodoc} StreamSubscription>? _webSocketMessageSubscription; BigInt _receivedCount = BigInt.zero; @@ -601,7 +575,6 @@ base mixin SpinifyWSPBHandlerMixin return super.connect(url, serverSubscriptionManager); } - /// {@nodoc} @protected @nonVirtual @pragma('vm:prefer-inline') @@ -755,7 +728,6 @@ base mixin SpinifyWSPBHandlerMixin } /// Mixin responsible for spinify subscriptions. -/// {@nodoc} @internal base mixin SpinifyWSPBSubscription on SpinifyWSPBTransportBase, SpinifyWSPBSenderMixin { @@ -958,7 +930,6 @@ base mixin SpinifyWSPBSubscription /// When client does not receive ping from a server for some /// time it can consider connection broken and try to reconnect. /// Usually a server sends pings every 25 seconds. -/// {@nodoc} @internal base mixin SpinifyWSPBPingPongMixin on SpinifyWSPBTransportBase { @protected @@ -978,7 +949,6 @@ base mixin SpinifyWSPBPingPongMixin on SpinifyWSPBTransportBase { /// Start or restart keepalive timer, /// you should restart it after each received ping message. /// Or connection will be closed by timeout. - /// {@nodoc} @protected @nonVirtual void _restartPingTimer() { @@ -1006,11 +976,9 @@ base mixin SpinifyWSPBPingPongMixin on SpinifyWSPBTransportBase { } } -/// {@nodoc} final List _emptyPublicationsList = List.empty(growable: false); -/// {@nodoc} @internal SpinifyPublication Function(pb.Publication publication) $publicationDecode( String channel, @@ -1027,7 +995,6 @@ SpinifyPublication Function(pb.Publication publication) $publicationDecode( ); } -/// {@nodoc} @internal SpinifyClientInfo $decodeClientInfo(pb.ClientInfo info) => SpinifyClientInfo( client: info.client, diff --git a/lib/src/util/event_queue.dart b/lib/src/util/event_queue.dart index 266b177..3ae6f61 100644 --- a/lib/src/util/event_queue.dart +++ b/lib/src/util/event_queue.dart @@ -4,10 +4,8 @@ import 'dart:collection'; import 'package:meta/meta.dart'; import 'package:spinify/src/util/logger.dart'; -/// {@nodoc} @internal final class SpinifyEventQueue { - /// {@nodoc} SpinifyEventQueue(); final DoubleLinkedQueue> _queue = @@ -16,7 +14,6 @@ final class SpinifyEventQueue { bool _isClosed = false; /// Push it at the end of the queue. - /// {@nodoc} Future push(String id, FutureOr Function() fn) { final task = SpinifyTask(id, fn); _queue.add(task); @@ -27,14 +24,12 @@ final class SpinifyEventQueue { /// 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 { @@ -66,27 +61,20 @@ final class SpinifyEventQueue { }); } -/// {@nodoc} @internal class SpinifyTask { - /// {@nodoc} SpinifyTask(this.id, FutureOr Function() fn) : _fn = fn, _completer = Completer(); - /// {@nodoc} final Completer _completer; - /// {@nodoc} final String id; - /// {@nodoc} final FutureOr Function() _fn; - /// {@nodoc} Future get future => _completer.future; - /// {@nodoc} FutureOr call() async { final result = await _fn(); if (!_completer.isCompleted) { @@ -95,7 +83,6 @@ class SpinifyTask { return result; } - /// {@nodoc} void reject(Object error, [StackTrace? stackTrace]) { if (_completer.isCompleted) return; // coverage:ignore-line _completer.completeError(error, stackTrace); diff --git a/lib/src/util/logger.dart b/lib/src/util/logger.dart index 1d7e971..b800f2b 100644 --- a/lib/src/util/logger.dart +++ b/lib/src/util/logger.dart @@ -5,7 +5,6 @@ import 'package:meta/meta.dart'; /// Constants used to debug the Spinify client. /// --dart-define=dev.plugfox.spinify.debug=true -/// {@nodoc} @internal bool get $enableLogging => const bool.fromEnvironment( @@ -15,33 +14,27 @@ bool get $enableLogging => Zone.current[#dev.plugfox.spinify.log] == true; /// Tracing information -/// {@nodoc} @internal final void Function(Object? message) fine = _logAll('FINE', 500); /// Static configuration messages -/// {@nodoc} @internal final void Function(Object? message) config = _logAll('CONF', 700); /// Iformational messages -/// {@nodoc} @internal final void Function(Object? message) info = _logAll('INFO', 800); /// Potential problems -/// {@nodoc} @internal final void Function(Object exception, [StackTrace? stackTrace, String? reason]) warning = _logAll('WARN', 900); /// Serious failures -/// {@nodoc} @internal final void Function(Object error, [StackTrace stackTrace, String? reason]) severe = _logAll('ERR!', 1000); -/// {@nodoc} void Function( Object? message, [ StackTrace? stackTrace, diff --git a/lib/src/util/notifier.dart b/lib/src/util/notifier.dart index 7ca4e3d..07aeeaf 100644 --- a/lib/src/util/notifier.dart +++ b/lib/src/util/notifier.dart @@ -1,38 +1,30 @@ import 'package:meta/meta.dart'; /// Notify about value changes. -/// {@nodoc} typedef ValueChanged = void Function(T value); /// Notify about value changes. -/// {@nodoc} @internal abstract interface class SpinifyListenable { /// Add listener. - /// {@nodoc} void addListener(ValueChanged listener); /// Remove listener. - /// {@nodoc} void removeListener(ValueChanged listener); } /// Notify about value changes. -/// {@nodoc} @internal final class SpinifyChangeNotifier implements SpinifyListenable { /// Notify about value changes. - /// {@nodoc} SpinifyChangeNotifier(); /// Notify about value changes. - /// {@nodoc} void notify(T value) { for (var i = 0; i < _listeners.length; i++) _listeners[i](value); } /// Listeners. - /// {@nodoc} final List> _listeners = >[]; @override diff --git a/lib/src/util/speed_meter.dart b/lib/src/util/speed_meter.dart index 928a3ea..da0bcaf 100644 --- a/lib/src/util/speed_meter.dart +++ b/lib/src/util/speed_meter.dart @@ -2,21 +2,17 @@ import 'dart:math' as math; import 'package:meta/meta.dart'; -/// {@nodoc} @internal class SpinifySpeedMeter { - /// {@nodoc} SpinifySpeedMeter(this.size) : _speeds = List.filled(size, 0); /// Size of the speed meter - /// {@nodoc} final int size; final List _speeds; int _pointer = 0; int _count = 0; /// Add new speed in ms - /// {@nodoc} void add(num speed) { _speeds[_pointer] = speed.toInt(); _pointer = (_pointer + 1) % size; @@ -24,7 +20,6 @@ class SpinifySpeedMeter { } /// Get speed in ms - /// {@nodoc} ({int min, int avg, int max}) get speed { if (_count == 0) return (min: 0, avg: 0, max: 0); var sum = _speeds.first, min = sum, max = sum; From 0cd3e3954f10fca6330794cde8e1a274b4d7328d Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Thu, 2 May 2024 12:49:46 +0400 Subject: [PATCH 46/46] Update analysis options and localization.dart files, refactor Controller class, and fix linting issues in various files --- analysis_options.yaml | 3 --- example/lib/src/common/controller/controller.dart | 7 +++++-- example/lib/src/common/localization/localization.dart | 2 +- example/lib/src/common/widget/app.dart | 2 +- lib/src/model/channel_push.dart | 2 +- lib/src/model/event.dart | 2 +- lib/src/subscription/subscription_state.dart | 3 +++ lib/src/transport/ws_protobuf_transport.dart | 2 ++ 8 files changed, 14 insertions(+), 9 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index 907ca29..218404e 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -76,7 +76,6 @@ linter: avoid_js_rounded_ints: true avoid_print: true avoid_renaming_method_parameters: true - avoid_returning_null_for_future: true avoid_returning_null_for_void: true avoid_single_cascade_in_expression_statements: true avoid_slow_async_io: true @@ -140,7 +139,6 @@ linter: # Pedantic 1.9.0 always_declare_return_types: true - always_require_non_null_named_parameters: true annotate_overrides: true avoid_empty_else: true avoid_init_to_null: true @@ -204,7 +202,6 @@ linter: prefer_mixin: true use_setters_to_change_properties: true avoid_setters_without_getters: true - avoid_returning_null: true avoid_returning_this: true type_annotate_public_apis: true avoid_types_on_closure_parameters: true diff --git a/example/lib/src/common/controller/controller.dart b/example/lib/src/common/controller/controller.dart index 731fc37..1864cbc 100644 --- a/example/lib/src/common/controller/controller.dart +++ b/example/lib/src/common/controller/controller.dart @@ -26,14 +26,16 @@ abstract interface class IControllerObserver { void onDispose(IController controller); /// Called on any state change in the controller. - void onStateChanged(IController controller, Object prevState, Object nextState); + 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} +/// {@macro controller} abstract base class Controller with ChangeNotifier implements IController { + /// {@macro controller} Controller() { runZonedGuarded( () => Controller.observer?.onCreate(this), @@ -44,6 +46,7 @@ abstract base class Controller with ChangeNotifier implements IController { /// Controller observer static IControllerObserver? observer; + /// Whether the controller is disposed. bool get isDisposed => _$isDisposed; bool _$isDisposed = false; diff --git a/example/lib/src/common/localization/localization.dart b/example/lib/src/common/localization/localization.dart index 1eada77..80600fc 100644 --- a/example/lib/src/common/localization/localization.dart +++ b/example/lib/src/common/localization/localization.dart @@ -31,7 +31,7 @@ final class Localization extends generated.GeneratedLocalization { /// Get language by code. static ({String name, String nativeName})? getLanguageByCode(String code) => switch (_isoLangs[code]) { - (:String name, :String nativeName) => ( + (String name, String nativeName) => ( name: name, nativeName: nativeName ), diff --git a/example/lib/src/common/widget/app.dart b/example/lib/src/common/widget/app.dart index ff27bb4..59046b9 100644 --- a/example/lib/src/common/widget/app.dart +++ b/example/lib/src/common/widget/app.dart @@ -41,7 +41,7 @@ class App extends StatelessWidget { builder: (context, child) => MediaQuery( data: MediaQuery.of(context).copyWith( /* textScaler: TextScaler.noScaling, */ - textScaleFactor: 1, + textScaler: const TextScaler.linear(1), ), child: WindowScope( /* title: Localization.of(context).title, */ diff --git a/lib/src/model/channel_push.dart b/lib/src/model/channel_push.dart index 29e8208..5fb7f44 100644 --- a/lib/src/model/channel_push.dart +++ b/lib/src/model/channel_push.dart @@ -8,7 +8,7 @@ import 'package:spinify/src/model/event.dart'; /// {@subCategory Push} @immutable abstract base class SpinifyChannelPush extends SpinifyEvent { - /// {@template spinify_channel_push} + /// {@macro spinify_channel_push} const SpinifyChannelPush({ required super.timestamp, required this.channel, diff --git a/lib/src/model/event.dart b/lib/src/model/event.dart index 08fca41..837e025 100644 --- a/lib/src/model/event.dart +++ b/lib/src/model/event.dart @@ -6,7 +6,7 @@ import 'package:meta/meta.dart'; /// {@category Event} @immutable abstract base class SpinifyEvent implements Comparable { - /// {@template spinify_event} + /// {@macro spinify_event} const SpinifyEvent({ required this.timestamp, }); diff --git a/lib/src/subscription/subscription_state.dart b/lib/src/subscription/subscription_state.dart index bf1113b..31a1f66 100644 --- a/lib/src/subscription/subscription_state.dart +++ b/lib/src/subscription/subscription_state.dart @@ -55,6 +55,7 @@ sealed class SpinifySubscriptionState extends _$SpinifySubscriptionStateBase { /// {@category Entity} final class SpinifySubscriptionState$Unsubscribed extends SpinifySubscriptionState { + /// {@macro subscription_state} SpinifySubscriptionState$Unsubscribed({ required this.code, required this.reason, @@ -119,6 +120,7 @@ final class SpinifySubscriptionState$Unsubscribed /// {@category Entity} final class SpinifySubscriptionState$Subscribing extends SpinifySubscriptionState { + /// {@macro subscription_state} SpinifySubscriptionState$Subscribing({ DateTime? timestamp, super.since, @@ -168,6 +170,7 @@ final class SpinifySubscriptionState$Subscribing /// {@category Entity} final class SpinifySubscriptionState$Subscribed extends SpinifySubscriptionState { + /// {@macro subscription_state} SpinifySubscriptionState$Subscribed({ DateTime? timestamp, super.since, diff --git a/lib/src/transport/ws_protobuf_transport.dart b/lib/src/transport/ws_protobuf_transport.dart index d774702..9d1c28b 100644 --- a/lib/src/transport/ws_protobuf_transport.dart +++ b/lib/src/transport/ws_protobuf_transport.dart @@ -979,6 +979,7 @@ base mixin SpinifyWSPBPingPongMixin on SpinifyWSPBTransportBase { final List _emptyPublicationsList = List.empty(growable: false); +/// Decode protobuf messages to Spinify models. @internal SpinifyPublication Function(pb.Publication publication) $publicationDecode( String channel, @@ -995,6 +996,7 @@ SpinifyPublication Function(pb.Publication publication) $publicationDecode( ); } +/// Decode protobuf client info to Spinify client info. @internal SpinifyClientInfo $decodeClientInfo(pb.ClientInfo info) => SpinifyClientInfo( client: info.client,