diff --git a/server/crates/arbiter-server/src/grpc/user_agent.rs b/server/crates/arbiter-server/src/grpc/user_agent.rs index 6855d54..2742660 100644 --- a/server/crates/arbiter-server/src/grpc/user_agent.rs +++ b/server/crates/arbiter-server/src/grpc/user_agent.rs @@ -43,7 +43,7 @@ use kameo::{ error::SendError, }; use tonic::Status; -use tracing::{info, warn}; +use tracing::{error, info, warn}; use crate::{ actors::{ @@ -91,6 +91,7 @@ async fn dispatch_loop( tokio::select! { oob = receiver.recv() => { let Some(oob) = oob else { + warn!("Out-of-band message channel closed"); return; }; @@ -104,10 +105,11 @@ async fn dispatch_loop( return; }; - if dispatch_conn_message(&mut bi, &actor, &mut request_tracker, conn) + if let Err(e) = dispatch_conn_message(&mut bi, &actor, &mut request_tracker, conn) .await - .is_err() + { + error!(error = ?e, "Error handling user agent message"); return; } } @@ -677,10 +679,7 @@ pub async fn start( let actor = UserAgentSession::spawn(UserAgentSession::new(conn, Box::new(oob_adapter))); let actor_for_cleanup = actor.clone(); - let _ = defer(move || { - actor_for_cleanup.kill(); - }); - info!(?pubkey, "User authenticated successfully"); dispatch_loop(bi, actor, oob_receiver, request_tracker).await; + actor_for_cleanup.kill(); } diff --git a/useragent/lib/features/callouts/active_callout.dart b/useragent/lib/features/callouts/active_callout.dart new file mode 100644 index 0000000..7d4ef83 --- /dev/null +++ b/useragent/lib/features/callouts/active_callout.dart @@ -0,0 +1,16 @@ +import 'package:arbiter/features/callouts/callout_event.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'active_callout.freezed.dart'; + +@freezed +abstract class ActiveCallout with _$ActiveCallout { + const factory ActiveCallout({ + required String id, + required String title, + required String description, + String? iconUrl, + required DateTime addedAt, + required CalloutData data, + }) = _ActiveCallout; +} diff --git a/useragent/lib/features/callouts/active_callout.freezed.dart b/useragent/lib/features/callouts/active_callout.freezed.dart new file mode 100644 index 0000000..eb25ff6 --- /dev/null +++ b/useragent/lib/features/callouts/active_callout.freezed.dart @@ -0,0 +1,304 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'active_callout.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$ActiveCallout { + + String get id; String get title; String get description; String? get iconUrl; DateTime get addedAt; CalloutData get data; +/// Create a copy of ActiveCallout +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ActiveCalloutCopyWith get copyWith => _$ActiveCalloutCopyWithImpl(this as ActiveCallout, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ActiveCallout&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.iconUrl, iconUrl) || other.iconUrl == iconUrl)&&(identical(other.addedAt, addedAt) || other.addedAt == addedAt)&&(identical(other.data, data) || other.data == data)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,title,description,iconUrl,addedAt,data); + +@override +String toString() { + return 'ActiveCallout(id: $id, title: $title, description: $description, iconUrl: $iconUrl, addedAt: $addedAt, data: $data)'; +} + + +} + +/// @nodoc +abstract mixin class $ActiveCalloutCopyWith<$Res> { + factory $ActiveCalloutCopyWith(ActiveCallout value, $Res Function(ActiveCallout) _then) = _$ActiveCalloutCopyWithImpl; +@useResult +$Res call({ + String id, String title, String description, String? iconUrl, DateTime addedAt, CalloutData data +}); + + +$CalloutDataCopyWith<$Res> get data; + +} +/// @nodoc +class _$ActiveCalloutCopyWithImpl<$Res> + implements $ActiveCalloutCopyWith<$Res> { + _$ActiveCalloutCopyWithImpl(this._self, this._then); + + final ActiveCallout _self; + final $Res Function(ActiveCallout) _then; + +/// Create a copy of ActiveCallout +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? title = null,Object? description = null,Object? iconUrl = freezed,Object? addedAt = null,Object? data = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,iconUrl: freezed == iconUrl ? _self.iconUrl : iconUrl // ignore: cast_nullable_to_non_nullable +as String?,addedAt: null == addedAt ? _self.addedAt : addedAt // ignore: cast_nullable_to_non_nullable +as DateTime,data: null == data ? _self.data : data // ignore: cast_nullable_to_non_nullable +as CalloutData, + )); +} +/// Create a copy of ActiveCallout +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$CalloutDataCopyWith<$Res> get data { + + return $CalloutDataCopyWith<$Res>(_self.data, (value) { + return _then(_self.copyWith(data: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [ActiveCallout]. +extension ActiveCalloutPatterns on ActiveCallout { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ActiveCallout value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ActiveCallout() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ActiveCallout value) $default,){ +final _that = this; +switch (_that) { +case _ActiveCallout(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ActiveCallout value)? $default,){ +final _that = this; +switch (_that) { +case _ActiveCallout() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String title, String description, String? iconUrl, DateTime addedAt, CalloutData data)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ActiveCallout() when $default != null: +return $default(_that.id,_that.title,_that.description,_that.iconUrl,_that.addedAt,_that.data);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String id, String title, String description, String? iconUrl, DateTime addedAt, CalloutData data) $default,) {final _that = this; +switch (_that) { +case _ActiveCallout(): +return $default(_that.id,_that.title,_that.description,_that.iconUrl,_that.addedAt,_that.data);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String title, String description, String? iconUrl, DateTime addedAt, CalloutData data)? $default,) {final _that = this; +switch (_that) { +case _ActiveCallout() when $default != null: +return $default(_that.id,_that.title,_that.description,_that.iconUrl,_that.addedAt,_that.data);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _ActiveCallout implements ActiveCallout { + const _ActiveCallout({required this.id, required this.title, required this.description, this.iconUrl, required this.addedAt, required this.data}); + + +@override final String id; +@override final String title; +@override final String description; +@override final String? iconUrl; +@override final DateTime addedAt; +@override final CalloutData data; + +/// Create a copy of ActiveCallout +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ActiveCalloutCopyWith<_ActiveCallout> get copyWith => __$ActiveCalloutCopyWithImpl<_ActiveCallout>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ActiveCallout&&(identical(other.id, id) || other.id == id)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.iconUrl, iconUrl) || other.iconUrl == iconUrl)&&(identical(other.addedAt, addedAt) || other.addedAt == addedAt)&&(identical(other.data, data) || other.data == data)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,title,description,iconUrl,addedAt,data); + +@override +String toString() { + return 'ActiveCallout(id: $id, title: $title, description: $description, iconUrl: $iconUrl, addedAt: $addedAt, data: $data)'; +} + + +} + +/// @nodoc +abstract mixin class _$ActiveCalloutCopyWith<$Res> implements $ActiveCalloutCopyWith<$Res> { + factory _$ActiveCalloutCopyWith(_ActiveCallout value, $Res Function(_ActiveCallout) _then) = __$ActiveCalloutCopyWithImpl; +@override @useResult +$Res call({ + String id, String title, String description, String? iconUrl, DateTime addedAt, CalloutData data +}); + + +@override $CalloutDataCopyWith<$Res> get data; + +} +/// @nodoc +class __$ActiveCalloutCopyWithImpl<$Res> + implements _$ActiveCalloutCopyWith<$Res> { + __$ActiveCalloutCopyWithImpl(this._self, this._then); + + final _ActiveCallout _self; + final $Res Function(_ActiveCallout) _then; + +/// Create a copy of ActiveCallout +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? title = null,Object? description = null,Object? iconUrl = freezed,Object? addedAt = null,Object? data = null,}) { + return _then(_ActiveCallout( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String,iconUrl: freezed == iconUrl ? _self.iconUrl : iconUrl // ignore: cast_nullable_to_non_nullable +as String?,addedAt: null == addedAt ? _self.addedAt : addedAt // ignore: cast_nullable_to_non_nullable +as DateTime,data: null == data ? _self.data : data // ignore: cast_nullable_to_non_nullable +as CalloutData, + )); +} + +/// Create a copy of ActiveCallout +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$CalloutDataCopyWith<$Res> get data { + + return $CalloutDataCopyWith<$Res>(_self.data, (value) { + return _then(_self.copyWith(data: value)); + }); +} +} + +// dart format on diff --git a/useragent/lib/features/callouts/callout_event.dart b/useragent/lib/features/callouts/callout_event.dart new file mode 100644 index 0000000..8dbb0c2 --- /dev/null +++ b/useragent/lib/features/callouts/callout_event.dart @@ -0,0 +1,25 @@ +import 'package:arbiter/proto/client.pb.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hooks_riverpod/experimental/mutation.dart'; + +part 'callout_event.freezed.dart'; + +@freezed +sealed class CalloutData with _$CalloutData { + const factory CalloutData.connectApproval({ + required String pubkey, + required ClientInfo clientInfo, + }) = ConnectApprovalData; +} + +@freezed +sealed class CalloutEvent with _$CalloutEvent { + const factory CalloutEvent.added({ + required String id, + required CalloutData data, + }) = CalloutEventAdded; + + const factory CalloutEvent.cancelled({ + required String id, + }) = CalloutEventCancelled; +} diff --git a/useragent/lib/features/callouts/callout_event.freezed.dart b/useragent/lib/features/callouts/callout_event.freezed.dart new file mode 100644 index 0000000..5e97fad --- /dev/null +++ b/useragent/lib/features/callouts/callout_event.freezed.dart @@ -0,0 +1,602 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'callout_event.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$CalloutData { + + String get pubkey; ClientInfo get clientInfo; +/// Create a copy of CalloutData +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CalloutDataCopyWith get copyWith => _$CalloutDataCopyWithImpl(this as CalloutData, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CalloutData&&(identical(other.pubkey, pubkey) || other.pubkey == pubkey)&&(identical(other.clientInfo, clientInfo) || other.clientInfo == clientInfo)); +} + + +@override +int get hashCode => Object.hash(runtimeType,pubkey,clientInfo); + +@override +String toString() { + return 'CalloutData(pubkey: $pubkey, clientInfo: $clientInfo)'; +} + + +} + +/// @nodoc +abstract mixin class $CalloutDataCopyWith<$Res> { + factory $CalloutDataCopyWith(CalloutData value, $Res Function(CalloutData) _then) = _$CalloutDataCopyWithImpl; +@useResult +$Res call({ + String pubkey, ClientInfo clientInfo +}); + + + + +} +/// @nodoc +class _$CalloutDataCopyWithImpl<$Res> + implements $CalloutDataCopyWith<$Res> { + _$CalloutDataCopyWithImpl(this._self, this._then); + + final CalloutData _self; + final $Res Function(CalloutData) _then; + +/// Create a copy of CalloutData +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? pubkey = null,Object? clientInfo = null,}) { + return _then(_self.copyWith( +pubkey: null == pubkey ? _self.pubkey : pubkey // ignore: cast_nullable_to_non_nullable +as String,clientInfo: null == clientInfo ? _self.clientInfo : clientInfo // ignore: cast_nullable_to_non_nullable +as ClientInfo, + )); +} + +} + + +/// Adds pattern-matching-related methods to [CalloutData]. +extension CalloutDataPatterns on CalloutData { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( ConnectApprovalData value)? connectApproval,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case ConnectApprovalData() when connectApproval != null: +return connectApproval(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( ConnectApprovalData value) connectApproval,}){ +final _that = this; +switch (_that) { +case ConnectApprovalData(): +return connectApproval(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( ConnectApprovalData value)? connectApproval,}){ +final _that = this; +switch (_that) { +case ConnectApprovalData() when connectApproval != null: +return connectApproval(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function( String pubkey, ClientInfo clientInfo)? connectApproval,required TResult orElse(),}) {final _that = this; +switch (_that) { +case ConnectApprovalData() when connectApproval != null: +return connectApproval(_that.pubkey,_that.clientInfo);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function( String pubkey, ClientInfo clientInfo) connectApproval,}) {final _that = this; +switch (_that) { +case ConnectApprovalData(): +return connectApproval(_that.pubkey,_that.clientInfo);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function( String pubkey, ClientInfo clientInfo)? connectApproval,}) {final _that = this; +switch (_that) { +case ConnectApprovalData() when connectApproval != null: +return connectApproval(_that.pubkey,_that.clientInfo);case _: + return null; + +} +} + +} + +/// @nodoc + + +class ConnectApprovalData implements CalloutData { + const ConnectApprovalData({required this.pubkey, required this.clientInfo}); + + +@override final String pubkey; +@override final ClientInfo clientInfo; + +/// Create a copy of CalloutData +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ConnectApprovalDataCopyWith get copyWith => _$ConnectApprovalDataCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ConnectApprovalData&&(identical(other.pubkey, pubkey) || other.pubkey == pubkey)&&(identical(other.clientInfo, clientInfo) || other.clientInfo == clientInfo)); +} + + +@override +int get hashCode => Object.hash(runtimeType,pubkey,clientInfo); + +@override +String toString() { + return 'CalloutData.connectApproval(pubkey: $pubkey, clientInfo: $clientInfo)'; +} + + +} + +/// @nodoc +abstract mixin class $ConnectApprovalDataCopyWith<$Res> implements $CalloutDataCopyWith<$Res> { + factory $ConnectApprovalDataCopyWith(ConnectApprovalData value, $Res Function(ConnectApprovalData) _then) = _$ConnectApprovalDataCopyWithImpl; +@override @useResult +$Res call({ + String pubkey, ClientInfo clientInfo +}); + + + + +} +/// @nodoc +class _$ConnectApprovalDataCopyWithImpl<$Res> + implements $ConnectApprovalDataCopyWith<$Res> { + _$ConnectApprovalDataCopyWithImpl(this._self, this._then); + + final ConnectApprovalData _self; + final $Res Function(ConnectApprovalData) _then; + +/// Create a copy of CalloutData +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? pubkey = null,Object? clientInfo = null,}) { + return _then(ConnectApprovalData( +pubkey: null == pubkey ? _self.pubkey : pubkey // ignore: cast_nullable_to_non_nullable +as String,clientInfo: null == clientInfo ? _self.clientInfo : clientInfo // ignore: cast_nullable_to_non_nullable +as ClientInfo, + )); +} + + +} + +/// @nodoc +mixin _$CalloutEvent { + + String get id; +/// Create a copy of CalloutEvent +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CalloutEventCopyWith get copyWith => _$CalloutEventCopyWithImpl(this as CalloutEvent, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CalloutEvent&&(identical(other.id, id) || other.id == id)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id); + +@override +String toString() { + return 'CalloutEvent(id: $id)'; +} + + +} + +/// @nodoc +abstract mixin class $CalloutEventCopyWith<$Res> { + factory $CalloutEventCopyWith(CalloutEvent value, $Res Function(CalloutEvent) _then) = _$CalloutEventCopyWithImpl; +@useResult +$Res call({ + String id +}); + + + + +} +/// @nodoc +class _$CalloutEventCopyWithImpl<$Res> + implements $CalloutEventCopyWith<$Res> { + _$CalloutEventCopyWithImpl(this._self, this._then); + + final CalloutEvent _self; + final $Res Function(CalloutEvent) _then; + +/// Create a copy of CalloutEvent +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [CalloutEvent]. +extension CalloutEventPatterns on CalloutEvent { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap({TResult Function( CalloutEventAdded value)? added,TResult Function( CalloutEventCancelled value)? cancelled,required TResult orElse(),}){ +final _that = this; +switch (_that) { +case CalloutEventAdded() when added != null: +return added(_that);case CalloutEventCancelled() when cancelled != null: +return cancelled(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map({required TResult Function( CalloutEventAdded value) added,required TResult Function( CalloutEventCancelled value) cancelled,}){ +final _that = this; +switch (_that) { +case CalloutEventAdded(): +return added(_that);case CalloutEventCancelled(): +return cancelled(_that);} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull({TResult? Function( CalloutEventAdded value)? added,TResult? Function( CalloutEventCancelled value)? cancelled,}){ +final _that = this; +switch (_that) { +case CalloutEventAdded() when added != null: +return added(_that);case CalloutEventCancelled() when cancelled != null: +return cancelled(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen({TResult Function( String id, CalloutData data)? added,TResult Function( String id)? cancelled,required TResult orElse(),}) {final _that = this; +switch (_that) { +case CalloutEventAdded() when added != null: +return added(_that.id,_that.data);case CalloutEventCancelled() when cancelled != null: +return cancelled(_that.id);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when({required TResult Function( String id, CalloutData data) added,required TResult Function( String id) cancelled,}) {final _that = this; +switch (_that) { +case CalloutEventAdded(): +return added(_that.id,_that.data);case CalloutEventCancelled(): +return cancelled(_that.id);} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull({TResult? Function( String id, CalloutData data)? added,TResult? Function( String id)? cancelled,}) {final _that = this; +switch (_that) { +case CalloutEventAdded() when added != null: +return added(_that.id,_that.data);case CalloutEventCancelled() when cancelled != null: +return cancelled(_that.id);case _: + return null; + +} +} + +} + +/// @nodoc + + +class CalloutEventAdded implements CalloutEvent { + const CalloutEventAdded({required this.id, required this.data}); + + +@override final String id; + final CalloutData data; + +/// Create a copy of CalloutEvent +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CalloutEventAddedCopyWith get copyWith => _$CalloutEventAddedCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CalloutEventAdded&&(identical(other.id, id) || other.id == id)&&(identical(other.data, data) || other.data == data)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id,data); + +@override +String toString() { + return 'CalloutEvent.added(id: $id, data: $data)'; +} + + +} + +/// @nodoc +abstract mixin class $CalloutEventAddedCopyWith<$Res> implements $CalloutEventCopyWith<$Res> { + factory $CalloutEventAddedCopyWith(CalloutEventAdded value, $Res Function(CalloutEventAdded) _then) = _$CalloutEventAddedCopyWithImpl; +@override @useResult +$Res call({ + String id, CalloutData data +}); + + +$CalloutDataCopyWith<$Res> get data; + +} +/// @nodoc +class _$CalloutEventAddedCopyWithImpl<$Res> + implements $CalloutEventAddedCopyWith<$Res> { + _$CalloutEventAddedCopyWithImpl(this._self, this._then); + + final CalloutEventAdded _self; + final $Res Function(CalloutEventAdded) _then; + +/// Create a copy of CalloutEvent +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? data = null,}) { + return _then(CalloutEventAdded( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,data: null == data ? _self.data : data // ignore: cast_nullable_to_non_nullable +as CalloutData, + )); +} + +/// Create a copy of CalloutEvent +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$CalloutDataCopyWith<$Res> get data { + + return $CalloutDataCopyWith<$Res>(_self.data, (value) { + return _then(_self.copyWith(data: value)); + }); +} +} + +/// @nodoc + + +class CalloutEventCancelled implements CalloutEvent { + const CalloutEventCancelled({required this.id}); + + +@override final String id; + +/// Create a copy of CalloutEvent +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$CalloutEventCancelledCopyWith get copyWith => _$CalloutEventCancelledCopyWithImpl(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is CalloutEventCancelled&&(identical(other.id, id) || other.id == id)); +} + + +@override +int get hashCode => Object.hash(runtimeType,id); + +@override +String toString() { + return 'CalloutEvent.cancelled(id: $id)'; +} + + +} + +/// @nodoc +abstract mixin class $CalloutEventCancelledCopyWith<$Res> implements $CalloutEventCopyWith<$Res> { + factory $CalloutEventCancelledCopyWith(CalloutEventCancelled value, $Res Function(CalloutEventCancelled) _then) = _$CalloutEventCancelledCopyWithImpl; +@override @useResult +$Res call({ + String id +}); + + + + +} +/// @nodoc +class _$CalloutEventCancelledCopyWithImpl<$Res> + implements $CalloutEventCancelledCopyWith<$Res> { + _$CalloutEventCancelledCopyWithImpl(this._self, this._then); + + final CalloutEventCancelled _self; + final $Res Function(CalloutEventCancelled) _then; + +/// Create a copy of CalloutEvent +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,}) { + return _then(CalloutEventCancelled( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/useragent/lib/features/callouts/callout_manager.dart b/useragent/lib/features/callouts/callout_manager.dart new file mode 100644 index 0000000..a411304 --- /dev/null +++ b/useragent/lib/features/callouts/callout_manager.dart @@ -0,0 +1,57 @@ +import 'package:arbiter/features/callouts/active_callout.dart'; +import 'package:arbiter/features/callouts/callout_event.dart'; +import 'package:arbiter/features/callouts/types/sdk_connect_approve.dart' + as connect_approve; +import 'package:arbiter/proto/client.pb.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'callout_manager.g.dart'; + +@Riverpod(keepAlive: true) +class CalloutManager extends _$CalloutManager { + @override + Map build() { + ref.listen(connect_approve.connectApproveEventsProvider, (_, next) { + next.whenData(_processEvent); + }); + return {}; + } + + void _processEvent(CalloutEvent event) { + switch (event) { + case CalloutEventAdded(:final id, :final data): + state = {...state, id: _toActiveCallout(id, data)}; + case CalloutEventCancelled(:final id): + state = {...state}..remove(id); + } + } + + Future sendDecision(String id, bool approved) async { + final callout = state[id]; + if (callout == null) return; + switch (callout.data) { + case ConnectApprovalData(:final pubkey): + await connect_approve.sendDecision(ref, pubkey, approved); + } + dismiss(id); + } + + void dismiss(String id) { + state = {...state}..remove(id); + } +} + +ActiveCallout _toActiveCallout(String id, CalloutData data) => switch (data) { + ConnectApprovalData(:final clientInfo) => ActiveCallout( + id: id, + title: 'Connection Request', + description: _clientDisplayName(clientInfo) != null + ? '${_clientDisplayName(clientInfo)} is requesting a connection.' + : 'An SDK client is requesting a connection.', + addedAt: DateTime.now(), + data: data, + ), +}; + +String? _clientDisplayName(ClientInfo info) => + info.hasName() && info.name.isNotEmpty ? info.name : null; diff --git a/useragent/lib/features/callouts/callout_manager.g.dart b/useragent/lib/features/callouts/callout_manager.g.dart new file mode 100644 index 0000000..d5d4097 --- /dev/null +++ b/useragent/lib/features/callouts/callout_manager.g.dart @@ -0,0 +1,67 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'callout_manager.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(CalloutManager) +final calloutManagerProvider = CalloutManagerProvider._(); + +final class CalloutManagerProvider + extends $NotifierProvider> { + CalloutManagerProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'calloutManagerProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$calloutManagerHash(); + + @$internal + @override + CalloutManager create() => CalloutManager(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Map value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$calloutManagerHash() => r'1d42ddcd9e5b8669a7ec08709b9dde9df6865bda'; + +abstract class _$CalloutManager extends $Notifier> { + Map build(); + @$mustCallSuper + @override + void runBuild() { + final ref = + this.ref + as $Ref, Map>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier< + Map, + Map + >, + Map, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/useragent/lib/features/callouts/show_callout.dart b/useragent/lib/features/callouts/show_callout.dart new file mode 100644 index 0000000..5fd8ac0 --- /dev/null +++ b/useragent/lib/features/callouts/show_callout.dart @@ -0,0 +1,99 @@ +import 'package:arbiter/features/callouts/callout_event.dart'; +import 'package:arbiter/features/callouts/callout_manager.dart'; +import 'package:arbiter/screens/callouts/sdk_connect.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +Future showCallout(BuildContext context, WidgetRef ref, String id) async { + final data = ref.read(calloutManagerProvider)[id]?.data; + if (data == null) return; + + await showGeneralDialog( + context: context, + barrierDismissible: false, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierColor: Colors.transparent, + transitionDuration: const Duration(milliseconds: 320), + pageBuilder: (_, animation, _) => _CalloutOverlay( + id: id, + data: data, + animation: animation, + ), + ); +} + +class _CalloutOverlay extends ConsumerWidget { + const _CalloutOverlay({ + required this.id, + required this.data, + required this.animation, + }); + + final String id; + final CalloutData data; + final Animation animation; + + @override + Widget build(BuildContext context, WidgetRef ref) { + ref.listen( + calloutManagerProvider.select((map) => map.containsKey(id)), + (wasPresent, isPresent) { + if (wasPresent == true && !isPresent && context.mounted) { + Navigator.of(context).pop(); + } + }, + ); + + final content = switch (data) { + ConnectApprovalData(:final pubkey, :final clientInfo) => SdkConnectCallout( + pubkey: pubkey, + clientInfo: clientInfo, + onAccept: () => ref.read(calloutManagerProvider.notifier).sendDecision(id, true), + onDecline: () => ref.read(calloutManagerProvider.notifier).sendDecision(id, false), + ), + }; + + final barrierAnim = CurvedAnimation( + parent: animation, + curve: const Interval(0, 0.3125, curve: Curves.easeOut), + ); + final popupAnim = CurvedAnimation( + parent: animation, + curve: const Interval(0.3125, 1, curve: Curves.easeOutCubic), + ); + + return Material( + type: MaterialType.transparency, + child: Stack( + children: [ + Positioned.fill( + child: AnimatedBuilder( + animation: barrierAnim, + builder: (_, __) => ColoredBox( + color: Colors.black.withValues(alpha: 0.35 * barrierAnim.value), + ), + ), + ), + SafeArea( + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.all(16), + child: FadeTransition( + opacity: popupAnim, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.08), + end: Offset.zero, + ).animate(popupAnim), + child: content, + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/useragent/lib/features/callouts/show_callout_list.dart b/useragent/lib/features/callouts/show_callout_list.dart new file mode 100644 index 0000000..de235b8 --- /dev/null +++ b/useragent/lib/features/callouts/show_callout_list.dart @@ -0,0 +1,218 @@ +import 'package:arbiter/features/callouts/active_callout.dart'; +import 'package:arbiter/features/callouts/callout_manager.dart'; +import 'package:arbiter/features/callouts/show_callout.dart'; +import 'package:arbiter/theme/palette.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sizer/sizer.dart'; +import 'package:timeago/timeago.dart' as timeago; + +Future showCalloutList(BuildContext context, WidgetRef ref) async { + final selectedId = await showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierColor: Colors.transparent, + transitionDuration: const Duration(milliseconds: 280), + pageBuilder: (_, animation, __) => _CalloutListOverlay(animation: animation), + ); + + if (selectedId != null && context.mounted) { + await showCallout(context, ref, selectedId); + } +} + +class _CalloutListOverlay extends ConsumerWidget { + const _CalloutListOverlay({required this.animation}); + + final Animation animation; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final callouts = ref.watch(calloutManagerProvider); + + final barrierAnim = CurvedAnimation( + parent: animation, + curve: const Interval(0, 0.3, curve: Curves.easeOut), + ); + final panelAnim = CurvedAnimation( + parent: animation, + curve: const Interval(0.3, 1, curve: Curves.easeOutCubic), + ); + + return Material( + type: MaterialType.transparency, + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => Navigator.of(context).pop(), + child: AnimatedBuilder( + animation: barrierAnim, + builder: (_, __) => ColoredBox( + color: Colors.black.withValues(alpha: 0.35 * barrierAnim.value), + ), + ), + ), + ), + SafeArea( + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: EdgeInsets.all(1.6.h), + child: FadeTransition( + opacity: panelAnim, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.08), + end: Offset.zero, + ).animate(panelAnim), + child: GestureDetector( + onTap: () {}, + child: _CalloutListPanel(callouts: callouts), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _CalloutListPanel extends StatelessWidget { + const _CalloutListPanel({required this.callouts}); + + final Map callouts; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + width: double.infinity, + constraints: BoxConstraints(maxHeight: 48.h), + decoration: BoxDecoration( + color: Palette.cream, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Palette.line), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(2.h, 2.h, 2.h, 1.2.h), + child: Text( + 'Notifications', + style: theme.textTheme.titleMedium?.copyWith( + color: Palette.ink, + fontWeight: FontWeight.w800, + ), + ), + ), + if (callouts.isEmpty) + Padding( + padding: EdgeInsets.fromLTRB(2.h, 0, 2.h, 2.h), + child: Text( + 'No pending notifications.', + style: theme.textTheme.bodyMedium?.copyWith( + color: Palette.ink.withValues(alpha: 0.50), + ), + ), + ) + else + Flexible( + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB(1.2.h, 0, 1.2.h, 1.2.h), + child: Column( + spacing: 0.5.h, + children: [ + for (final entry in callouts.values) + _CalloutListEntry( + callout: entry, + onTap: () => Navigator.of(context).pop(entry.id), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _CalloutListEntry extends StatelessWidget { + const _CalloutListEntry({required this.callout, required this.onTap}); + + final ActiveCallout callout; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 1.2.h, vertical: 1.2.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Palette.line), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 1.2.h, + children: [ + if (callout.iconUrl != null) + CircleAvatar( + radius: 2.2.h, + backgroundColor: Palette.line, + backgroundImage: NetworkImage(callout.iconUrl!), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 0.3.h, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + callout.title, + style: theme.textTheme.bodyMedium?.copyWith( + color: Palette.ink, + fontWeight: FontWeight.w700, + ), + ), + ), + Text( + timeago.format(callout.addedAt), + style: theme.textTheme.bodySmall?.copyWith( + color: Palette.ink.withValues(alpha: 0.45), + ), + ), + ], + ), + Text( + callout.description, + style: theme.textTheme.bodySmall?.copyWith( + color: Palette.ink.withValues(alpha: 0.65), + height: 1.4, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/useragent/lib/features/callouts/types/sdk_connect_approve.dart b/useragent/lib/features/callouts/types/sdk_connect_approve.dart new file mode 100644 index 0000000..17481e7 --- /dev/null +++ b/useragent/lib/features/callouts/types/sdk_connect_approve.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; + +import 'package:arbiter/features/callouts/callout_event.dart'; +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/providers/connection/connection_manager.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'sdk_connect_approve.g.dart'; + +@riverpod +Stream connectApproveEvents(Ref ref) async* { + final connection = await ref.watch(connectionManagerProvider.future); + if (connection == null) return; + + await for (final message in connection.outOfBandMessages) { + switch (message.whichPayload()) { + case UserAgentResponse_Payload.sdkClientConnectionRequest: + final body = message.sdkClientConnectionRequest; + final id = base64Encode(body.pubkey); + yield CalloutEvent.added( + id: 'connect_approve:$id', + data: CalloutData.connectApproval( + pubkey: id, + clientInfo: body.info, + ), + ); + + case UserAgentResponse_Payload.sdkClientConnectionCancel: + final id = base64Encode(message.sdkClientConnectionCancel.pubkey); + yield CalloutEvent.cancelled(id: 'connect_approve:$id'); + + default: + break; + } + } +} + +Future sendDecision(Ref ref, String pubkey, bool approved) async { + final connection = await ref.watch(connectionManagerProvider.future); + if (connection == null) return; + + final bytes = base64Decode(pubkey); + + final req = UserAgentRequest(sdkClientConnectionResponse: SdkClientConnectionResponse( + approved: approved, + pubkey: bytes + )); + + await connection.tell(req); + +} \ No newline at end of file diff --git a/useragent/lib/features/callouts/types/sdk_connect_approve.g.dart b/useragent/lib/features/callouts/types/sdk_connect_approve.g.dart new file mode 100644 index 0000000..94444f6 --- /dev/null +++ b/useragent/lib/features/callouts/types/sdk_connect_approve.g.dart @@ -0,0 +1,50 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sdk_connect_approve.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(connectApproveEvents) +final connectApproveEventsProvider = ConnectApproveEventsProvider._(); + +final class ConnectApproveEventsProvider + extends + $FunctionalProvider< + AsyncValue, + CalloutEvent, + Stream + > + with $FutureModifier, $StreamProvider { + ConnectApproveEventsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'connectApproveEventsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$connectApproveEventsHash(); + + @$internal + @override + $StreamProviderElement $createElement( + $ProviderPointer pointer, + ) => $StreamProviderElement(pointer); + + @override + Stream create(Ref ref) { + return connectApproveEvents(ref); + } +} + +String _$connectApproveEventsHash() => + r'6a0998288afc0836a7c1701a983f64c33d318fd6'; diff --git a/useragent/lib/features/connection/auth.dart b/useragent/lib/features/connection/auth.dart index 937285a..5d88178 100644 --- a/useragent/lib/features/connection/auth.dart +++ b/useragent/lib/features/connection/auth.dart @@ -66,7 +66,7 @@ Future connectAndAuthorize( KeyAlgorithm.ed25519 => KeyType.KEY_TYPE_ED25519, }, ); - final response = await connection.request( + final response = await connection.ask( UserAgentRequest(authChallengeRequest: req), ); talker.info( @@ -94,7 +94,7 @@ Future connectAndAuthorize( ); final signature = await key.sign(challenge); - final solutionResponse = await connection.request( + final solutionResponse = await connection.ask( UserAgentRequest(authChallengeSolution: AuthChallengeSolution(signature: signature)), ); diff --git a/useragent/lib/features/connection/connection.dart b/useragent/lib/features/connection/connection.dart index b5d4a38..9427c83 100644 --- a/useragent/lib/features/connection/connection.dart +++ b/useragent/lib/features/connection/connection.dart @@ -29,7 +29,7 @@ class Connection { Stream get outOfBandMessages => _outOfBandMessages.stream; - Future request(UserAgentRequest message) async { + Future ask(UserAgentRequest message) async { _ensureOpen(); final requestId = _nextRequestId++; @@ -49,7 +49,23 @@ class Connection { return completer.future; } + Future tell(UserAgentRequest message) async { + _ensureOpen(); + + final requestId = _nextRequestId++; + message.id = requestId; + + talker.debug('Sending message: ${message.toDebugString()}'); + + try { + _tx.add(message); + } catch (error, stackTrace) { + talker.error('Failed to send message: $error', error, stackTrace); + } + } + Future close() async { + talker.debug('Closing connection...'); final rxSubscription = _rxSubscription; if (rxSubscription == null) { return; @@ -86,6 +102,7 @@ class Connection { } void _handleDone() { + talker.debug('Connection closed by server.'); if (_rxSubscription == null) { return; } diff --git a/useragent/lib/features/connection/evm.dart b/useragent/lib/features/connection/evm.dart index aae5a9d..efd6328 100644 --- a/useragent/lib/features/connection/evm.dart +++ b/useragent/lib/features/connection/evm.dart @@ -4,7 +4,7 @@ import 'package:arbiter/proto/user_agent.pb.dart'; import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart'; Future> listEvmWallets(Connection connection) async { - final response = await connection.request( + final response = await connection.ask( UserAgentRequest(evmWalletList: Empty()), ); if (!response.hasEvmWalletList()) { @@ -25,7 +25,7 @@ Future> listEvmWallets(Connection connection) async { } Future createEvmWallet(Connection connection) async { - final response = await connection.request( + final response = await connection.ask( UserAgentRequest(evmWalletCreate: Empty()), ); if (!response.hasEvmWalletCreate()) { diff --git a/useragent/lib/features/connection/evm/grants.dart b/useragent/lib/features/connection/evm/grants.dart index 338f0a8..168644d 100644 --- a/useragent/lib/features/connection/evm/grants.dart +++ b/useragent/lib/features/connection/evm/grants.dart @@ -7,7 +7,7 @@ import 'package:protobuf/well_known_types/google/protobuf/timestamp.pb.dart'; Future> listEvmGrants(Connection connection) async { final request = EvmGrantListRequest(); - final response = await connection.request( + final response = await connection.ask( UserAgentRequest(evmGrantList: request), ); if (!response.hasEvmGrantList()) { @@ -43,7 +43,7 @@ Future createEvmGrant( } Future deleteEvmGrant(Connection connection, int grantId) async { - final response = await connection.request( + final response = await connection.ask( UserAgentRequest(evmGrantDelete: EvmGrantDeleteRequest(grantId: grantId)), ); if (!response.hasEvmGrantDelete()) { diff --git a/useragent/lib/features/connection/vault.dart b/useragent/lib/features/connection/vault.dart index ae57243..d2f3f83 100644 --- a/useragent/lib/features/connection/vault.dart +++ b/useragent/lib/features/connection/vault.dart @@ -10,7 +10,7 @@ Future bootstrapVault( ) async { final encryptedKey = await _encryptVaultKeyMaterial(connection, password); - final response = await connection.request( + final response = await connection.ask( UserAgentRequest( bootstrapEncryptedKey: BootstrapEncryptedKey( nonce: encryptedKey.nonce, @@ -31,7 +31,7 @@ Future bootstrapVault( Future unsealVault(Connection connection, String password) async { final encryptedKey = await _encryptVaultKeyMaterial(connection, password); - final response = await connection.request( + final response = await connection.ask( UserAgentRequest( unsealEncryptedKey: UnsealEncryptedKey( nonce: encryptedKey.nonce, @@ -56,7 +56,7 @@ Future<_EncryptedVaultKey> _encryptVaultKeyMaterial( final clientKeyPair = await keyExchange.newKeyPair(); final clientPublicKey = await clientKeyPair.extractPublicKey(); - final handshakeResponse = await connection.request( + final handshakeResponse = await connection.ask( UserAgentRequest(unsealStart: UnsealStart(clientPubkey: clientPublicKey.bytes)), ); if (!handshakeResponse.hasUnsealStartResponse()) { diff --git a/useragent/lib/providers/connection/connection_manager.dart b/useragent/lib/providers/connection/connection_manager.dart index 9eda993..49b673d 100644 --- a/useragent/lib/providers/connection/connection_manager.dart +++ b/useragent/lib/providers/connection/connection_manager.dart @@ -14,7 +14,7 @@ class ConnectionManager extends _$ConnectionManager { Future build() async { final serverInfo = await ref.watch(serverInfoProvider.future); final key = await ref.watch(keyProvider.future); - final token = ref.watch(bootstrapTokenProvider); + final token = ref.read(bootstrapTokenProvider); if (serverInfo == null || key == null) { return null; diff --git a/useragent/lib/providers/sdk_clients/list.dart b/useragent/lib/providers/sdk_clients/list.dart index 507c451..a06fd7d 100644 --- a/useragent/lib/providers/sdk_clients/list.dart +++ b/useragent/lib/providers/sdk_clients/list.dart @@ -12,7 +12,7 @@ Future?> sdkClients(Ref ref) async { return null; } - final resp = await connection.request( + final resp = await connection.ask( UserAgentRequest(sdkClientList: Empty()), ); diff --git a/useragent/lib/providers/sdk_clients/list.g.dart b/useragent/lib/providers/sdk_clients/list.g.dart index 0a69fea..e65feb2 100644 --- a/useragent/lib/providers/sdk_clients/list.g.dart +++ b/useragent/lib/providers/sdk_clients/list.g.dart @@ -48,4 +48,4 @@ final class SdkClientsProvider } } -String _$sdkClientsHash() => r'833c249d9cc2f83921453e0ece354a9a2d9f4482'; +String _$sdkClientsHash() => r'9b50ef901a7b68e4e604d6d0b4777dbd3e6499e1'; diff --git a/useragent/lib/providers/vault_state.dart b/useragent/lib/providers/vault_state.dart index edb189e..eefc536 100644 --- a/useragent/lib/providers/vault_state.dart +++ b/useragent/lib/providers/vault_state.dart @@ -13,7 +13,7 @@ Future vaultState(Ref ref) async { return null; } - final resp = await conn.request(UserAgentRequest(queryVaultState: Empty())); + final resp = await conn.ask(UserAgentRequest(queryVaultState: Empty())); if (resp.whichPayload() != UserAgentResponse_Payload.vaultState) { talker.warning('Expected vault state response, got ${resp.whichPayload()}'); return null; diff --git a/useragent/lib/providers/vault_state.g.dart b/useragent/lib/providers/vault_state.g.dart index 7d0bd98..6bc2cc9 100644 --- a/useragent/lib/providers/vault_state.g.dart +++ b/useragent/lib/providers/vault_state.g.dart @@ -46,4 +46,4 @@ final class VaultStateProvider } } -String _$vaultStateHash() => r'97085e49bc3a296e36fa6c04a8f4c9abafac0835'; +String _$vaultStateHash() => r'81887aa99a3e928efd73dbe85caf81284c9f5803'; diff --git a/useragent/lib/screens/callouts/sdk_connect.dart b/useragent/lib/screens/callouts/sdk_connect.dart new file mode 100644 index 0000000..c26fd36 --- /dev/null +++ b/useragent/lib/screens/callouts/sdk_connect.dart @@ -0,0 +1,151 @@ +import 'package:arbiter/proto/client.pb.dart'; +import 'package:arbiter/theme/palette.dart'; +import 'package:flutter/material.dart'; +import 'package:sizer/sizer.dart'; + +class SdkConnectCallout extends StatelessWidget { + const SdkConnectCallout({ + super.key, + required this.pubkey, + required this.clientInfo, + this.onAccept, + this.onDecline, + }); + + final String pubkey; + final ClientInfo clientInfo; + final VoidCallback? onAccept; + final VoidCallback? onDecline; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final name = clientInfo.hasName() && clientInfo.name.isNotEmpty + ? clientInfo.name + : _shortPubkey(pubkey); + + final hasDescription = + clientInfo.hasDescription() && clientInfo.description.isNotEmpty; + final hasVersion = + clientInfo.hasVersion() && clientInfo.version.isNotEmpty; + final showInfoCard = hasDescription || hasVersion; + + return Container( + decoration: BoxDecoration( + color: Palette.cream, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: Palette.line), + ), + padding: EdgeInsets.all(2.4.h), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + spacing: 1.6.h, + children: [ + // if (clientInfo.iconUrl != null) + // CircleAvatar( + // radius: 36, + // backgroundColor: Palette.line, + // backgroundImage: NetworkImage(iconUrl!), + // ), + Column( + crossAxisAlignment: CrossAxisAlignment.center, + spacing: 0.4.h, + children: [ + Text( + name, + textAlign: TextAlign.center, + style: theme.textTheme.titleLarge?.copyWith( + color: Palette.ink, + fontWeight: FontWeight.w800, + ), + ), + Text( + 'is requesting a connection', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: Palette.ink.withValues(alpha: 0.55), + ), + ), + ], + ), + if (showInfoCard) + Container( + width: double.infinity, + decoration: BoxDecoration( + color: Palette.ink.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Palette.line), + ), + padding: EdgeInsets.symmetric( + horizontal: 1.6.w, + vertical: 1.2.h, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 0.6.h, + children: [ + if (hasDescription) + Text( + clientInfo.description, + style: theme.textTheme.bodyMedium?.copyWith( + color: Palette.ink.withValues(alpha: 0.80), + height: 1.5, + ), + ), + if (hasVersion) + Text( + 'v${clientInfo.version}', + style: theme.textTheme.bodySmall?.copyWith( + color: Palette.ink.withValues(alpha: 0.50), + ), + ), + ], + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Expanded( + child: OutlinedButton( + onPressed: onDecline, + style: OutlinedButton.styleFrom( + foregroundColor: Palette.coral, + side: BorderSide( + color: Palette.coral.withValues(alpha: 0.50), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + padding: EdgeInsets.symmetric(vertical: 1.4.h), + ), + child: const Text('Decline'), + ), + ), + Expanded( + child: FilledButton( + onPressed: onAccept, + style: FilledButton.styleFrom( + backgroundColor: Palette.ink, + foregroundColor: Palette.cream, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + padding: EdgeInsets.symmetric(vertical: 1.4.h), + ), + child: const Text('Accept'), + ), + ), + ], + ), + ], + ), + ); + } +} + +String _shortPubkey(String base64Pubkey) { + if (base64Pubkey.length < 12) return base64Pubkey; + return '${base64Pubkey.substring(0, 8)}…${base64Pubkey.substring(base64Pubkey.length - 4)}'; +} diff --git a/useragent/lib/screens/dashboard.dart b/useragent/lib/screens/dashboard.dart index a9a6156..33830e4 100644 --- a/useragent/lib/screens/dashboard.dart +++ b/useragent/lib/screens/dashboard.dart @@ -1,7 +1,11 @@ +import 'package:arbiter/features/callouts/callout_manager.dart'; +import 'package:arbiter/features/callouts/show_callout_list.dart'; import 'package:arbiter/router.gr.dart'; +import 'package:arbiter/theme/palette.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; const breakpoints = MaterialAdaptiveBreakpoints(); @@ -17,7 +21,6 @@ class DashboardRouter extends StatelessWidget { routes: routes, transitionBuilder: (context, child, animation) => FadeTransition( opacity: animation, - // the passed child is technically our animated selected-tab page child: child, ), builder: (context, child) { @@ -53,8 +56,54 @@ class DashboardRouter extends StatelessWidget { selectedIndex: currentActive, transitionDuration: const Duration(milliseconds: 800), internalAnimations: true, + trailingNavRail: const _CalloutBell(), ); }, ); } } + +class _CalloutBell extends ConsumerWidget { + const _CalloutBell({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final count = ref.watch( + calloutManagerProvider.select((map) => map.length), + ); + + return IconButton( + onPressed: () => showCalloutList(context, ref), + icon: Stack( + clipBehavior: Clip.none, + children: [ + Icon( + count > 0 ? Icons.notifications : Icons.notifications_outlined, + color: Palette.ink, + ), + if (count > 0) + Positioned( + top: -2, + right: -4, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + count > 99 ? '99+' : '$count', + style: const TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.w800, + height: 1.2, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/useragent/lib/screens/dashboard/clients/table.dart b/useragent/lib/screens/dashboard/clients/table.dart index ac1e944..8bdb88d 100644 --- a/useragent/lib/screens/dashboard/clients/table.dart +++ b/useragent/lib/screens/dashboard/clients/table.dart @@ -8,17 +8,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:arbiter/theme/palette.dart'; import 'package:sizer/sizer.dart'; -// ─── Palette ────────────────────────────────────────────────────────────────── - -class _Palette { - static const ink = Color(0xFF15263C); - static const coral = Color(0xFFE26254); - static const cream = Color(0xFFFFFAF4); - static const line = Color(0x1A15263C); -} - // ─── Column width getters ───────────────────────────────────────────────────── double get _accentStripWidth => 0.8.w; @@ -92,8 +84,8 @@ class _StatePanel extends StatelessWidget { return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), - color: _Palette.cream.withValues(alpha: 0.92), - border: Border.all(color: _Palette.line), + color: Palette.cream.withValues(alpha: 0.92), + border: Border.all(color: Palette.line), ), child: Padding( padding: EdgeInsets.all(2.8.h), @@ -107,12 +99,12 @@ class _StatePanel extends StatelessWidget { child: const CircularProgressIndicator(strokeWidth: 2.5), ) else - Icon(icon, size: 34, color: _Palette.coral), + Icon(icon, size: 34, color: Palette.coral), SizedBox(height: 1.8.h), Text( title, style: theme.textTheme.headlineSmall?.copyWith( - color: _Palette.ink, + color: Palette.ink, fontWeight: FontWeight.w800, ), ), @@ -120,7 +112,7 @@ class _StatePanel extends StatelessWidget { Text( body, style: theme.textTheme.bodyLarge?.copyWith( - color: _Palette.ink.withValues(alpha: 0.72), + color: Palette.ink.withValues(alpha: 0.72), height: 1.5, ), ), @@ -155,8 +147,8 @@ class _Header extends StatelessWidget { padding: EdgeInsets.symmetric(horizontal: 1.6.w, vertical: 1.2.h), decoration: BoxDecoration( borderRadius: BorderRadius.circular(18), - color: _Palette.cream, - border: Border.all(color: _Palette.line), + color: Palette.cream, + border: Border.all(color: Palette.line), ), child: Row( children: [ @@ -164,7 +156,7 @@ class _Header extends StatelessWidget { child: Text( 'SDK Clients', style: theme.textTheme.titleMedium?.copyWith( - color: _Palette.ink, + color: Palette.ink, fontWeight: FontWeight.w800, ), ), @@ -173,7 +165,7 @@ class _Header extends StatelessWidget { Text( 'Syncing', style: theme.textTheme.bodySmall?.copyWith( - color: _Palette.ink.withValues(alpha: 0.62), + color: Palette.ink.withValues(alpha: 0.62), fontWeight: FontWeight.w700, ), ), @@ -182,8 +174,8 @@ class _Header extends StatelessWidget { OutlinedButton.icon( onPressed: () => onRefresh(), style: OutlinedButton.styleFrom( - foregroundColor: _Palette.ink, - side: BorderSide(color: _Palette.line), + foregroundColor: Palette.ink, + side: BorderSide(color: Palette.line), padding: EdgeInsets.symmetric( horizontal: 1.4.w, vertical: 1.2.h, @@ -209,7 +201,7 @@ class _ClientTableHeader extends StatelessWidget { @override Widget build(BuildContext context) { final style = Theme.of(context).textTheme.labelLarge?.copyWith( - color: _Palette.ink.withValues(alpha: 0.72), + color: Palette.ink.withValues(alpha: 0.72), fontWeight: FontWeight.w800, letterSpacing: 0.3, ); @@ -218,7 +210,7 @@ class _ClientTableHeader extends StatelessWidget { padding: EdgeInsets.symmetric(vertical: 1.4.h), decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), - color: _Palette.ink.withValues(alpha: 0.04), + color: Palette.ink.withValues(alpha: 0.04), ), child: Row( children: [ @@ -257,7 +249,7 @@ class _ClientTableRow extends HookWidget { final expanded = useState(false); final accent = _accentColor(client.pubkey); final theme = Theme.of(context); - final muted = _Palette.ink.withValues(alpha: 0.62); + final muted = Palette.ink.withValues(alpha: 0.62); final name = client.info.name.isEmpty ? '—' : client.info.name; final version = client.info.version.isEmpty ? '—' : client.info.version; @@ -301,7 +293,7 @@ class _ClientTableRow extends HookWidget { child: Text( '${client.id}', style: theme.textTheme.bodyLarge?.copyWith( - color: _Palette.ink, + color: Palette.ink, ), ), ), @@ -313,7 +305,7 @@ class _ClientTableRow extends HookWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( - color: _Palette.ink, + color: Palette.ink, ), ), ), @@ -395,7 +387,7 @@ class _ClientTableRow extends HookWidget { child: Text( _shortPubkey(client.pubkey), style: theme.textTheme.bodySmall?.copyWith( - color: _Palette.ink, + color: Palette.ink, fontFamily: 'monospace', ), ), @@ -444,8 +436,8 @@ class _ClientTable extends StatelessWidget { return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), - color: _Palette.cream.withValues(alpha: 0.92), - border: Border.all(color: _Palette.line), + color: Palette.cream.withValues(alpha: 0.92), + border: Border.all(color: Palette.line), ), child: Padding( padding: EdgeInsets.all(2.h), @@ -459,7 +451,7 @@ class _ClientTable extends StatelessWidget { Text( 'Registered clients', style: theme.textTheme.titleLarge?.copyWith( - color: _Palette.ink, + color: Palette.ink, fontWeight: FontWeight.w800, ), ), @@ -467,7 +459,7 @@ class _ClientTable extends StatelessWidget { Text( 'Every entry here has authenticated with Arbiter at least once.', style: theme.textTheme.bodyMedium?.copyWith( - color: _Palette.ink.withValues(alpha: 0.70), + color: Palette.ink.withValues(alpha: 0.70), height: 1.4, ), ), @@ -564,7 +556,7 @@ class ClientsScreen extends HookConsumerWidget { return Scaffold( body: SafeArea( child: RefreshIndicator.adaptive( - color: _Palette.ink, + color: Palette.ink, backgroundColor: Colors.white, onRefresh: refresh, child: ListView( diff --git a/useragent/lib/screens/dashboard/evm/evm.dart b/useragent/lib/screens/dashboard/evm/evm.dart index f6967d0..fac14aa 100644 --- a/useragent/lib/screens/dashboard/evm/evm.dart +++ b/useragent/lib/screens/dashboard/evm/evm.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:arbiter/proto/evm.pb.dart'; +import 'package:arbiter/theme/palette.dart'; import 'package:arbiter/providers/connection/connection_manager.dart'; import 'package:arbiter/providers/evm/evm.dart'; import 'package:auto_route/auto_route.dart'; @@ -89,7 +90,7 @@ class EvmScreen extends HookConsumerWidget { return Scaffold( body: SafeArea( child: RefreshIndicator.adaptive( - color: _Palette.ink, + color: Palette.ink, backgroundColor: Colors.white, onRefresh: refreshWallets, child: ListView( @@ -114,13 +115,6 @@ class EvmScreen extends HookConsumerWidget { } } -class _Palette { - static const ink = Color(0xFF15263C); - static const coral = Color(0xFFE26254); - static const cream = Color(0xFFFFFAF4); - static const line = Color(0x1A15263C); -} - double get _accentStripWidth => 0.8.w; double get _cellHorizontalPadding => 1.8.w; double get _walletColumnWidth => 18.w; @@ -148,8 +142,8 @@ class _Header extends StatelessWidget { padding: EdgeInsets.symmetric(horizontal: 1.6.w, vertical: 1.2.h), decoration: BoxDecoration( borderRadius: BorderRadius.circular(18), - color: _Palette.cream, - border: Border.all(color: _Palette.line), + color: Palette.cream, + border: Border.all(color: Palette.line), ), child: Row( children: [ @@ -157,7 +151,7 @@ class _Header extends StatelessWidget { child: Text( 'EVM Wallet Vault', style: theme.textTheme.titleMedium?.copyWith( - color: _Palette.ink, + color: Palette.ink, fontWeight: FontWeight.w800, ), ), @@ -166,7 +160,7 @@ class _Header extends StatelessWidget { Text( 'Syncing', style: theme.textTheme.bodySmall?.copyWith( - color: _Palette.ink.withValues(alpha: 0.62), + color: Palette.ink.withValues(alpha: 0.62), fontWeight: FontWeight.w700, ), ), @@ -175,7 +169,7 @@ class _Header extends StatelessWidget { FilledButton.icon( onPressed: isCreating ? null : () => onCreate(), style: FilledButton.styleFrom( - backgroundColor: _Palette.ink, + backgroundColor: Palette.ink, foregroundColor: Colors.white, padding: EdgeInsets.symmetric(horizontal: 1.4.w, vertical: 1.2.h), shape: RoundedRectangleBorder( @@ -195,8 +189,8 @@ class _Header extends StatelessWidget { OutlinedButton.icon( onPressed: () => onRefresh(), style: OutlinedButton.styleFrom( - foregroundColor: _Palette.ink, - side: BorderSide(color: _Palette.line), + foregroundColor: Palette.ink, + side: BorderSide(color: Palette.line), padding: EdgeInsets.symmetric(horizontal: 1.4.w, vertical: 1.2.h), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), @@ -223,8 +217,8 @@ class _WalletTable extends StatelessWidget { return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), - color: _Palette.cream.withValues(alpha: 0.92), - border: Border.all(color: _Palette.line), + color: Palette.cream.withValues(alpha: 0.92), + border: Border.all(color: Palette.line), ), child: Padding( padding: EdgeInsets.all(2.h), @@ -238,7 +232,7 @@ class _WalletTable extends StatelessWidget { Text( 'Managed wallets', style: theme.textTheme.titleLarge?.copyWith( - color: _Palette.ink, + color: Palette.ink, fontWeight: FontWeight.w800, ), ), @@ -246,7 +240,7 @@ class _WalletTable extends StatelessWidget { Text( 'Every address here is generated and held by Arbiter.', style: theme.textTheme.bodyMedium?.copyWith( - color: _Palette.ink.withValues(alpha: 0.70), + color: Palette.ink.withValues(alpha: 0.70), height: 1.4, ), ), @@ -288,7 +282,7 @@ class _WalletTableHeader extends StatelessWidget { @override Widget build(BuildContext context) { final style = Theme.of(context).textTheme.labelLarge?.copyWith( - color: _Palette.ink.withValues(alpha: 0.72), + color: Palette.ink.withValues(alpha: 0.72), fontWeight: FontWeight.w800, letterSpacing: 0.3, ); @@ -297,7 +291,7 @@ class _WalletTableHeader extends StatelessWidget { padding: EdgeInsets.symmetric(vertical: 1.4.h), decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), - color: _Palette.ink.withValues(alpha: 0.04), + color: Palette.ink.withValues(alpha: 0.04), ), child: Row( children: [ @@ -328,10 +322,10 @@ class _WalletTableRow extends StatelessWidget { final rowHeight = 5.h; final walletStyle = Theme.of( context, - ).textTheme.bodyLarge?.copyWith(color: _Palette.ink); + ).textTheme.bodyLarge?.copyWith(color: Palette.ink); final addressStyle = Theme.of( context, - ).textTheme.bodyMedium?.copyWith(color: _Palette.ink); + ).textTheme.bodyMedium?.copyWith(color: Palette.ink); return Container( height: rowHeight, @@ -420,8 +414,8 @@ class _StatePanel extends StatelessWidget { return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), - color: _Palette.cream.withValues(alpha: 0.92), - border: Border.all(color: _Palette.line), + color: Palette.cream.withValues(alpha: 0.92), + border: Border.all(color: Palette.line), ), child: Padding( padding: EdgeInsets.all(2.8.h), @@ -435,12 +429,12 @@ class _StatePanel extends StatelessWidget { child: CircularProgressIndicator(strokeWidth: 2.5), ) else - Icon(icon, size: 34, color: _Palette.coral), + Icon(icon, size: 34, color: Palette.coral), SizedBox(height: 1.8.h), Text( title, style: theme.textTheme.headlineSmall?.copyWith( - color: _Palette.ink, + color: Palette.ink, fontWeight: FontWeight.w800, ), ), @@ -448,7 +442,7 @@ class _StatePanel extends StatelessWidget { Text( body, style: theme.textTheme.bodyLarge?.copyWith( - color: _Palette.ink.withValues(alpha: 0.72), + color: Palette.ink.withValues(alpha: 0.72), height: 1.5, ), ), diff --git a/useragent/lib/theme/palette.dart b/useragent/lib/theme/palette.dart new file mode 100644 index 0000000..1b87a9b --- /dev/null +++ b/useragent/lib/theme/palette.dart @@ -0,0 +1,8 @@ +import 'package:flutter/material.dart'; + +class Palette { + static const ink = Color(0xFF15263C); + static const coral = Color(0xFFE26254); + static const cream = Color(0xFFFFFAF4); + static const line = Color(0x1A15263C); +} diff --git a/useragent/macos/Runner.xcodeproj/project.pbxproj b/useragent/macos/Runner.xcodeproj/project.pbxproj index 12c3f47..02de01c 100644 --- a/useragent/macos/Runner.xcodeproj/project.pbxproj +++ b/useragent/macos/Runner.xcodeproj/project.pbxproj @@ -581,6 +581,7 @@ DEVELOPMENT_TEAM = 8L884L537J; ENABLE_APP_SANDBOX = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; LD_RUNPATH_SEARCH_PATHS = ( @@ -724,6 +725,7 @@ DEVELOPMENT_TEAM = 8L884L537J; ENABLE_APP_SANDBOX = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; LD_RUNPATH_SEARCH_PATHS = ( @@ -752,6 +754,7 @@ DEVELOPMENT_TEAM = 8L884L537J; ENABLE_APP_SANDBOX = YES; ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/useragent/macos/Runner/DebugProfile.entitlements b/useragent/macos/Runner/DebugProfile.entitlements index da92e01..fbad023 100644 --- a/useragent/macos/Runner/DebugProfile.entitlements +++ b/useragent/macos/Runner/DebugProfile.entitlements @@ -4,7 +4,5 @@ keychain-access-groups - com.apple.security.network.client - diff --git a/useragent/macos/Runner/Release.entitlements b/useragent/macos/Runner/Release.entitlements index 02b114d..fbad023 100644 --- a/useragent/macos/Runner/Release.entitlements +++ b/useragent/macos/Runner/Release.entitlements @@ -2,11 +2,7 @@ - com.apple.security.app-sandbox - keychain-access-groups - com.apple.security.network.client - diff --git a/useragent/pubspec.lock b/useragent/pubspec.lock index 71f69d1..1bbf08a 100644 --- a/useragent/pubspec.lock +++ b/useragent/pubspec.lock @@ -529,6 +529,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" io: dependency: transitive description: @@ -1062,6 +1070,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.12" + timeago: + dependency: "direct main" + description: + name: timeago + sha256: b05159406a97e1cbb2b9ee4faa9fb096fe0e2dfcd8b08fcd2a00553450d3422e + url: "https://pub.dev" + source: hosted + version: "3.7.1" typed_data: dependency: transitive description: diff --git a/useragent/pubspec.yaml b/useragent/pubspec.yaml index 9775044..85201b6 100644 --- a/useragent/pubspec.yaml +++ b/useragent/pubspec.yaml @@ -33,6 +33,7 @@ dependencies: protobuf: ^6.0.0 freezed_annotation: ^3.1.0 json_annotation: ^4.9.0 + timeago: ^3.7.1 dev_dependencies: flutter_test: