Compare commits
6 Commits
enforcing-
...
f461d945cb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f461d945cb | ||
|
|
aa2df4adcb | ||
|
|
43412094b7 | ||
|
|
dc80abda98 | ||
|
|
137ff53bba | ||
|
|
700545be17 |
11
.claude/memory/feedback_widget_decomposition.md
Normal file
11
.claude/memory/feedback_widget_decomposition.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
name: Widget decomposition and provider subscriptions
|
||||||
|
description: Prefer splitting screens into multiple focused files/widgets; each widget subscribes to its own relevant providers
|
||||||
|
type: feedback
|
||||||
|
---
|
||||||
|
|
||||||
|
Split screens into multiple smaller widgets across multiple files. Each widget should subscribe only to the providers it needs (`ref.watch` at lowest possible level), rather than having one large screen widget that watches everything and passes data down as parameters.
|
||||||
|
|
||||||
|
**Why:** Reduces unnecessary rebuilds; improves readability; each file has one clear responsibility.
|
||||||
|
|
||||||
|
**How to apply:** When building a new screen, identify which sub-widgets need their own provider subscriptions and extract them into separate files (e.g., `widgets/grant_card.dart` watches enrichment providers itself, rather than the screen doing it and passing resolved strings down).
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ scripts/__pycache__/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.cargo/config.toml
|
.cargo/config.toml
|
||||||
.vscode/
|
.vscode/
|
||||||
|
docs/
|
||||||
|
|||||||
@@ -132,17 +132,22 @@ message SdkClientConnectionCancel {
|
|||||||
bytes pubkey = 1;
|
bytes pubkey = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message WalletAccess {
|
||||||
|
int32 wallet_id = 1;
|
||||||
|
int32 sdk_client_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
message SdkClientWalletAccess {
|
message SdkClientWalletAccess {
|
||||||
int32 client_id = 1;
|
int32 id = 1;
|
||||||
int32 wallet_id = 2;
|
WalletAccess access = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message SdkClientGrantWalletAccess {
|
message SdkClientGrantWalletAccess {
|
||||||
repeated SdkClientWalletAccess accesses = 1;
|
repeated WalletAccess accesses = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
message SdkClientRevokeWalletAccess {
|
message SdkClientRevokeWalletAccess {
|
||||||
repeated SdkClientWalletAccess accesses = 1;
|
repeated int32 accesses = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ListWalletAccessResponse {
|
message ListWalletAccessResponse {
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ use rand::{SeedableRng, rng, rngs::StdRng};
|
|||||||
use crate::{
|
use crate::{
|
||||||
actors::keyholder::{CreateNew, Decrypt, KeyHolder},
|
actors::keyholder::{CreateNew, Decrypt, KeyHolder},
|
||||||
db::{
|
db::{
|
||||||
self, DatabasePool,
|
self, DatabaseError, DatabasePool,
|
||||||
models::{self, SqliteTimestamp},
|
models::{self, SqliteTimestamp},
|
||||||
schema,
|
schema,
|
||||||
},
|
},
|
||||||
evm::{
|
evm::{
|
||||||
self, ListGrantsError, RunKind,
|
self, RunKind,
|
||||||
policies::{
|
policies::{
|
||||||
FullGrant, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
|
FullGrant, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
|
||||||
ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
|
ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
|
||||||
@@ -33,11 +33,7 @@ pub enum SignTransactionError {
|
|||||||
|
|
||||||
#[error("Database error: {0}")]
|
#[error("Database error: {0}")]
|
||||||
#[diagnostic(code(arbiter::evm::sign::database))]
|
#[diagnostic(code(arbiter::evm::sign::database))]
|
||||||
Database(#[from] diesel::result::Error),
|
Database(#[from] DatabaseError),
|
||||||
|
|
||||||
#[error("Database pool error: {0}")]
|
|
||||||
#[diagnostic(code(arbiter::evm::sign::pool))]
|
|
||||||
Pool(#[from] db::PoolError),
|
|
||||||
|
|
||||||
#[error("Keyholder error: {0}")]
|
#[error("Keyholder error: {0}")]
|
||||||
#[diagnostic(code(arbiter::evm::sign::keyholder))]
|
#[diagnostic(code(arbiter::evm::sign::keyholder))]
|
||||||
@@ -68,15 +64,7 @@ pub enum Error {
|
|||||||
|
|
||||||
#[error("Database error: {0}")]
|
#[error("Database error: {0}")]
|
||||||
#[diagnostic(code(arbiter::evm::database))]
|
#[diagnostic(code(arbiter::evm::database))]
|
||||||
Database(#[from] diesel::result::Error),
|
Database(#[from] DatabaseError),
|
||||||
|
|
||||||
#[error("Database pool error: {0}")]
|
|
||||||
#[diagnostic(code(arbiter::evm::database_pool))]
|
|
||||||
DatabasePool(#[from] db::PoolError),
|
|
||||||
|
|
||||||
#[error("Grant creation error: {0}")]
|
|
||||||
#[diagnostic(code(arbiter::evm::creation))]
|
|
||||||
Creation(#[from] evm::CreationError),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Actor)]
|
#[derive(Actor)]
|
||||||
@@ -116,7 +104,7 @@ impl EvmActor {
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| Error::KeyholderSend)?;
|
.map_err(|_| Error::KeyholderSend)?;
|
||||||
|
|
||||||
let mut conn = self.db.get().await?;
|
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
||||||
let wallet_id = insert_into(schema::evm_wallet::table)
|
let wallet_id = insert_into(schema::evm_wallet::table)
|
||||||
.values(&models::NewEvmWallet {
|
.values(&models::NewEvmWallet {
|
||||||
address: address.as_slice().to_vec(),
|
address: address.as_slice().to_vec(),
|
||||||
@@ -124,18 +112,20 @@ impl EvmActor {
|
|||||||
})
|
})
|
||||||
.returning(schema::evm_wallet::id)
|
.returning(schema::evm_wallet::id)
|
||||||
.get_result(&mut conn)
|
.get_result(&mut conn)
|
||||||
.await?;
|
.await
|
||||||
|
.map_err(DatabaseError::from)?;
|
||||||
|
|
||||||
Ok((wallet_id, address))
|
Ok((wallet_id, address))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[message]
|
#[message]
|
||||||
pub async fn list_wallets(&self) -> Result<Vec<(i32, Address)>, Error> {
|
pub async fn list_wallets(&self) -> Result<Vec<(i32, Address)>, Error> {
|
||||||
let mut conn = self.db.get().await?;
|
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
||||||
let rows: Vec<models::EvmWallet> = schema::evm_wallet::table
|
let rows: Vec<models::EvmWallet> = schema::evm_wallet::table
|
||||||
.select(models::EvmWallet::as_select())
|
.select(models::EvmWallet::as_select())
|
||||||
.load(&mut conn)
|
.load(&mut conn)
|
||||||
.await?;
|
.await
|
||||||
|
.map_err(DatabaseError::from)?;
|
||||||
|
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -151,7 +141,7 @@ impl EvmActor {
|
|||||||
&mut self,
|
&mut self,
|
||||||
basic: SharedGrantSettings,
|
basic: SharedGrantSettings,
|
||||||
grant: SpecificGrant,
|
grant: SpecificGrant,
|
||||||
) -> Result<i32, evm::CreationError> {
|
) -> Result<i32, DatabaseError> {
|
||||||
match grant {
|
match grant {
|
||||||
SpecificGrant::EtherTransfer(settings) => {
|
SpecificGrant::EtherTransfer(settings) => {
|
||||||
self.engine
|
self.engine
|
||||||
@@ -174,22 +164,23 @@ impl EvmActor {
|
|||||||
|
|
||||||
#[message]
|
#[message]
|
||||||
pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> {
|
pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> {
|
||||||
let mut conn = self.db.get().await?;
|
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
||||||
diesel::update(schema::evm_basic_grant::table)
|
diesel::update(schema::evm_basic_grant::table)
|
||||||
.filter(schema::evm_basic_grant::id.eq(grant_id))
|
.filter(schema::evm_basic_grant::id.eq(grant_id))
|
||||||
.set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
|
.set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
|
||||||
.execute(&mut conn)
|
.execute(&mut conn)
|
||||||
.await?;
|
.await
|
||||||
|
.map_err(DatabaseError::from)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[message]
|
#[message]
|
||||||
pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
|
pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
|
||||||
match self.engine.list_all_grants().await {
|
Ok(self
|
||||||
Ok(grants) => Ok(grants),
|
.engine
|
||||||
Err(ListGrantsError::Database(db)) => Err(Error::Database(db)),
|
.list_all_grants()
|
||||||
Err(ListGrantsError::Pool(pool)) => Err(Error::DatabasePool(pool)),
|
.await
|
||||||
}
|
.map_err(DatabaseError::from)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[message]
|
#[message]
|
||||||
@@ -199,13 +190,14 @@ impl EvmActor {
|
|||||||
wallet_address: Address,
|
wallet_address: Address,
|
||||||
transaction: TxEip1559,
|
transaction: TxEip1559,
|
||||||
) -> Result<SpecificMeaning, SignTransactionError> {
|
) -> Result<SpecificMeaning, SignTransactionError> {
|
||||||
let mut conn = self.db.get().await?;
|
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
||||||
let wallet = schema::evm_wallet::table
|
let wallet = schema::evm_wallet::table
|
||||||
.select(models::EvmWallet::as_select())
|
.select(models::EvmWallet::as_select())
|
||||||
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice()))
|
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice()))
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.await
|
.await
|
||||||
.optional()?
|
.optional()
|
||||||
|
.map_err(DatabaseError::from)?
|
||||||
.ok_or(SignTransactionError::WalletNotFound)?;
|
.ok_or(SignTransactionError::WalletNotFound)?;
|
||||||
let wallet_access = schema::evm_wallet_access::table
|
let wallet_access = schema::evm_wallet_access::table
|
||||||
.select(models::EvmWalletAccess::as_select())
|
.select(models::EvmWalletAccess::as_select())
|
||||||
@@ -213,7 +205,8 @@ impl EvmActor {
|
|||||||
.filter(schema::evm_wallet_access::client_id.eq(client_id))
|
.filter(schema::evm_wallet_access::client_id.eq(client_id))
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.await
|
.await
|
||||||
.optional()?
|
.optional()
|
||||||
|
.map_err(DatabaseError::from)?
|
||||||
.ok_or(SignTransactionError::WalletNotFound)?;
|
.ok_or(SignTransactionError::WalletNotFound)?;
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
@@ -232,13 +225,14 @@ impl EvmActor {
|
|||||||
wallet_address: Address,
|
wallet_address: Address,
|
||||||
mut transaction: TxEip1559,
|
mut transaction: TxEip1559,
|
||||||
) -> Result<Signature, SignTransactionError> {
|
) -> Result<Signature, SignTransactionError> {
|
||||||
let mut conn = self.db.get().await?;
|
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
||||||
let wallet = schema::evm_wallet::table
|
let wallet = schema::evm_wallet::table
|
||||||
.select(models::EvmWallet::as_select())
|
.select(models::EvmWallet::as_select())
|
||||||
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice()))
|
.filter(schema::evm_wallet::address.eq(wallet_address.as_slice()))
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.await
|
.await
|
||||||
.optional()?
|
.optional()
|
||||||
|
.map_err(DatabaseError::from)?
|
||||||
.ok_or(SignTransactionError::WalletNotFound)?;
|
.ok_or(SignTransactionError::WalletNotFound)?;
|
||||||
let wallet_access = schema::evm_wallet_access::table
|
let wallet_access = schema::evm_wallet_access::table
|
||||||
.select(models::EvmWalletAccess::as_select())
|
.select(models::EvmWalletAccess::as_select())
|
||||||
@@ -246,7 +240,8 @@ impl EvmActor {
|
|||||||
.filter(schema::evm_wallet_access::client_id.eq(client_id))
|
.filter(schema::evm_wallet_access::client_id.eq(client_id))
|
||||||
.first(&mut conn)
|
.first(&mut conn)
|
||||||
.await
|
.await
|
||||||
.optional()?
|
.optional()
|
||||||
|
.map_err(DatabaseError::from)?
|
||||||
.ok_or(SignTransactionError::WalletNotFound)?;
|
.ok_or(SignTransactionError::WalletNotFound)?;
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,6 @@ use crate::{
|
|||||||
db::{self, models::KeyType},
|
db::{self, models::KeyType},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct EvmAccessEntry {
|
|
||||||
pub wallet_id: i32,
|
|
||||||
pub sdk_client_id: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Abstraction over Ed25519 / ECDSA-secp256k1 / RSA public keys used during the auth handshake.
|
/// Abstraction over Ed25519 / ECDSA-secp256k1 / RSA public keys used during the auth handshake.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum AuthPublicKey {
|
pub enum AuthPublicKey {
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ use x25519_dalek::{EphemeralSecret, PublicKey};
|
|||||||
|
|
||||||
use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer;
|
use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer;
|
||||||
use crate::actors::keyholder::KeyHolderState;
|
use crate::actors::keyholder::KeyHolderState;
|
||||||
use crate::actors::user_agent::EvmAccessEntry;
|
|
||||||
use crate::actors::user_agent::session::Error;
|
use crate::actors::user_agent::session::Error;
|
||||||
use crate::db::models::{ProgramClient, ProgramClientMetadata};
|
use crate::db::models::{
|
||||||
|
CoreEvmWalletAccess, EvmWalletAccess, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata,
|
||||||
|
};
|
||||||
use crate::db::schema::evm_wallet_access;
|
use crate::db::schema::evm_wallet_access;
|
||||||
use crate::evm::policies::{Grant, SpecificGrant};
|
use crate::evm::policies::{Grant, SpecificGrant};
|
||||||
use crate::safe_cell::SafeCell;
|
use crate::safe_cell::SafeCell;
|
||||||
@@ -304,8 +305,6 @@ impl UserAgentSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#[messages]
|
#[messages]
|
||||||
impl UserAgentSession {
|
impl UserAgentSession {
|
||||||
#[message]
|
#[message]
|
||||||
@@ -360,20 +359,16 @@ impl UserAgentSession {
|
|||||||
#[message]
|
#[message]
|
||||||
pub(crate) async fn handle_grant_evm_wallet_access(
|
pub(crate) async fn handle_grant_evm_wallet_access(
|
||||||
&mut self,
|
&mut self,
|
||||||
entries: Vec<EvmAccessEntry>,
|
entries: Vec<NewEvmWalletAccess>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let mut conn = self.props.db.get().await?;
|
let mut conn = self.props.db.get().await?;
|
||||||
conn.transaction(|conn| {
|
conn.transaction(|conn| {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
use crate::db::models::NewEvmWalletAccess;
|
|
||||||
use crate::db::schema::evm_wallet_access;
|
use crate::db::schema::evm_wallet_access;
|
||||||
|
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
diesel::insert_into(evm_wallet_access::table)
|
diesel::insert_into(evm_wallet_access::table)
|
||||||
.values(&NewEvmWalletAccess {
|
.values(&entry)
|
||||||
wallet_id: entry.wallet_id,
|
|
||||||
client_id: entry.sdk_client_id,
|
|
||||||
})
|
|
||||||
.on_conflict_do_nothing()
|
.on_conflict_do_nothing()
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -389,7 +384,7 @@ impl UserAgentSession {
|
|||||||
#[message]
|
#[message]
|
||||||
pub(crate) async fn handle_revoke_evm_wallet_access(
|
pub(crate) async fn handle_revoke_evm_wallet_access(
|
||||||
&mut self,
|
&mut self,
|
||||||
entries: Vec<EvmAccessEntry>,
|
entries: Vec<i32>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let mut conn = self.props.db.get().await?;
|
let mut conn = self.props.db.get().await?;
|
||||||
conn.transaction(|conn| {
|
conn.transaction(|conn| {
|
||||||
@@ -397,11 +392,7 @@ impl UserAgentSession {
|
|||||||
use crate::db::schema::evm_wallet_access;
|
use crate::db::schema::evm_wallet_access;
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
diesel::delete(evm_wallet_access::table)
|
diesel::delete(evm_wallet_access::table)
|
||||||
.filter(
|
.filter(evm_wallet_access::wallet_id.eq(entry))
|
||||||
evm_wallet_access::wallet_id
|
|
||||||
.eq(entry.wallet_id)
|
|
||||||
.and(evm_wallet_access::client_id.eq(entry.sdk_client_id)),
|
|
||||||
)
|
|
||||||
.execute(conn)
|
.execute(conn)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
@@ -414,19 +405,15 @@ impl UserAgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[message]
|
#[message]
|
||||||
pub(crate) async fn handle_list_wallet_access(&mut self) -> Result<Vec<EvmAccessEntry>, Error> {
|
pub(crate) async fn handle_list_wallet_access(
|
||||||
|
&mut self,
|
||||||
|
) -> Result<Vec<EvmWalletAccess>, Error> {
|
||||||
let mut conn = self.props.db.get().await?;
|
let mut conn = self.props.db.get().await?;
|
||||||
use crate::db::schema::evm_wallet_access;
|
use crate::db::schema::evm_wallet_access;
|
||||||
let access_entries = evm_wallet_access::table
|
let access_entries = evm_wallet_access::table
|
||||||
.select((evm_wallet_access::wallet_id, evm_wallet_access::client_id))
|
.select(EvmWalletAccess::as_select())
|
||||||
.load::<(i32, i32)>(&mut conn)
|
.load::<_>(&mut conn)
|
||||||
.await?
|
.await?;
|
||||||
.into_iter()
|
|
||||||
.map(|(wallet_id, sdk_client_id)| EvmAccessEntry {
|
|
||||||
wallet_id,
|
|
||||||
sdk_client_id,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
Ok(access_entries)
|
Ok(access_entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,6 +193,12 @@ pub struct EvmWallet {
|
|||||||
omit(id, created_at),
|
omit(id, created_at),
|
||||||
attributes_with = "deriveless"
|
attributes_with = "deriveless"
|
||||||
)]
|
)]
|
||||||
|
#[view(
|
||||||
|
CoreEvmWalletAccess,
|
||||||
|
derive(Insertable),
|
||||||
|
omit(created_at),
|
||||||
|
attributes_with = "deriveless"
|
||||||
|
)]
|
||||||
pub struct EvmWalletAccess {
|
pub struct EvmWalletAccess {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub wallet_id: i32,
|
pub wallet_id: i32,
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ use alloy::{
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite};
|
use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite};
|
||||||
use diesel_async::{AsyncConnection, RunQueryDsl};
|
use diesel_async::{AsyncConnection, RunQueryDsl};
|
||||||
|
use tracing_subscriber::registry::Data;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
db::{
|
db::{
|
||||||
self,
|
self, DatabaseError,
|
||||||
models::{
|
models::{
|
||||||
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
|
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
|
||||||
},
|
},
|
||||||
@@ -30,12 +31,8 @@ mod utils;
|
|||||||
/// Errors that can only occur once the transaction meaning is known (during policy evaluation)
|
/// Errors that can only occur once the transaction meaning is known (during policy evaluation)
|
||||||
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
|
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
|
||||||
pub enum PolicyError {
|
pub enum PolicyError {
|
||||||
#[error("Database connection pool error")]
|
#[error("Database error")]
|
||||||
#[diagnostic(code(arbiter_server::evm::policy_error::pool))]
|
Error(#[from] crate::db::DatabaseError),
|
||||||
Pool(#[from] db::PoolError),
|
|
||||||
#[error("Database returned error")]
|
|
||||||
#[diagnostic(code(arbiter_server::evm::policy_error::database))]
|
|
||||||
Database(#[from] diesel::result::Error),
|
|
||||||
#[error("Transaction violates policy: {0:?}")]
|
#[error("Transaction violates policy: {0:?}")]
|
||||||
#[diagnostic(code(arbiter_server::evm::policy_error::violation))]
|
#[diagnostic(code(arbiter_server::evm::policy_error::violation))]
|
||||||
Violations(Vec<EvalViolation>),
|
Violations(Vec<EvalViolation>),
|
||||||
@@ -57,16 +54,6 @@ pub enum VetError {
|
|||||||
Evaluated(SpecificMeaning, #[source] PolicyError),
|
Evaluated(SpecificMeaning, #[source] PolicyError),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
|
|
||||||
pub enum SignError {
|
|
||||||
#[error("Database connection pool error")]
|
|
||||||
#[diagnostic(code(arbiter_server::evm::database_error))]
|
|
||||||
Pool(#[from] db::PoolError),
|
|
||||||
#[error("Database returned error")]
|
|
||||||
#[diagnostic(code(arbiter_server::evm::database_error))]
|
|
||||||
Database(#[from] diesel::result::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
|
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
|
||||||
pub enum AnalyzeError {
|
pub enum AnalyzeError {
|
||||||
#[error("Engine doesn't support granting permissions for contract creation")]
|
#[error("Engine doesn't support granting permissions for contract creation")]
|
||||||
@@ -78,28 +65,6 @@ pub enum AnalyzeError {
|
|||||||
UnsupportedTransactionType,
|
UnsupportedTransactionType,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
|
|
||||||
pub enum CreationError {
|
|
||||||
#[error("Database connection pool error")]
|
|
||||||
#[diagnostic(code(arbiter_server::evm::creation_error::database_error))]
|
|
||||||
Pool(#[from] db::PoolError),
|
|
||||||
|
|
||||||
#[error("Database returned error")]
|
|
||||||
#[diagnostic(code(arbiter_server::evm::creation_error::database_error))]
|
|
||||||
Database(#[from] diesel::result::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
|
|
||||||
pub enum ListGrantsError {
|
|
||||||
#[error("Database connection pool error")]
|
|
||||||
#[diagnostic(code(arbiter_server::evm::list_grants_error::pool))]
|
|
||||||
Pool(#[from] db::PoolError),
|
|
||||||
|
|
||||||
#[error("Database returned error")]
|
|
||||||
#[diagnostic(code(arbiter_server::evm::list_grants_error::database))]
|
|
||||||
Database(#[from] diesel::result::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Controls whether a transaction should be executed or only validated
|
/// Controls whether a transaction should be executed or only validated
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum RunKind {
|
pub enum RunKind {
|
||||||
@@ -167,16 +132,22 @@ impl Engine {
|
|||||||
meaning: &P::Meaning,
|
meaning: &P::Meaning,
|
||||||
run_kind: RunKind,
|
run_kind: RunKind,
|
||||||
) -> Result<(), PolicyError> {
|
) -> Result<(), PolicyError> {
|
||||||
let mut conn = self.db.get().await?;
|
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
|
||||||
|
|
||||||
let grant = P::try_find_grant(&context, &mut conn)
|
let grant = P::try_find_grant(&context, &mut conn)
|
||||||
.await?
|
.await
|
||||||
|
.map_err(DatabaseError::from)?
|
||||||
.ok_or(PolicyError::NoMatchingGrant)?;
|
.ok_or(PolicyError::NoMatchingGrant)?;
|
||||||
|
|
||||||
let mut violations =
|
let mut violations =
|
||||||
check_shared_constraints(&context, &grant.shared, grant.shared_grant_id, &mut conn)
|
check_shared_constraints(&context, &grant.shared, grant.shared_grant_id, &mut conn)
|
||||||
.await?;
|
.await
|
||||||
violations.extend(P::evaluate(&context, meaning, &grant, &mut conn).await?);
|
.map_err(DatabaseError::from)?;
|
||||||
|
violations.extend(
|
||||||
|
P::evaluate(&context, meaning, &grant, &mut conn)
|
||||||
|
.await
|
||||||
|
.map_err(DatabaseError::from)?,
|
||||||
|
);
|
||||||
|
|
||||||
if !violations.is_empty() {
|
if !violations.is_empty() {
|
||||||
return Err(PolicyError::Violations(violations));
|
return Err(PolicyError::Violations(violations));
|
||||||
@@ -200,7 +171,8 @@ impl Engine {
|
|||||||
QueryResult::Ok(())
|
QueryResult::Ok(())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.await?;
|
.await
|
||||||
|
.map_err(DatabaseError::from)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -215,7 +187,7 @@ impl Engine {
|
|||||||
pub async fn create_grant<P: Policy>(
|
pub async fn create_grant<P: Policy>(
|
||||||
&self,
|
&self,
|
||||||
full_grant: FullGrant<P::Settings>,
|
full_grant: FullGrant<P::Settings>,
|
||||||
) -> Result<i32, CreationError> {
|
) -> Result<i32, DatabaseError> {
|
||||||
let mut conn = self.db.get().await?;
|
let mut conn = self.db.get().await?;
|
||||||
|
|
||||||
let id = conn
|
let id = conn
|
||||||
@@ -261,7 +233,7 @@ impl Engine {
|
|||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_all_grants(&self) -> Result<Vec<Grant<SpecificGrant>>, ListGrantsError> {
|
pub async fn list_all_grants(&self) -> Result<Vec<Grant<SpecificGrant>>, DatabaseError> {
|
||||||
let mut conn = self.db.get().await?;
|
let mut conn = self.db.get().await?;
|
||||||
|
|
||||||
let mut grants: Vec<Grant<SpecificGrant>> = Vec::new();
|
let mut grants: Vec<Grant<SpecificGrant>> = Vec::new();
|
||||||
|
|||||||
@@ -45,10 +45,15 @@ use crate::{
|
|||||||
user_agent::{
|
user_agent::{
|
||||||
OutOfBand, UserAgentConnection, UserAgentSession,
|
OutOfBand, UserAgentConnection, UserAgentSession,
|
||||||
session::connection::{
|
session::connection::{
|
||||||
BootstrapError, HandleBootstrapEncryptedKey, HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete, HandleGrantEvmWalletAccess, HandleGrantList, HandleListWalletAccess, HandleNewClientApprove, HandleQueryVaultState, HandleRevokeEvmWalletAccess, HandleSdkClientList, HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError
|
BootstrapError, HandleBootstrapEncryptedKey, HandleEvmWalletCreate,
|
||||||
|
HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete,
|
||||||
|
HandleGrantEvmWalletAccess, HandleGrantList, HandleListWalletAccess,
|
||||||
|
HandleNewClientApprove, HandleQueryVaultState, HandleRevokeEvmWalletAccess,
|
||||||
|
HandleSdkClientList, HandleUnsealEncryptedKey, HandleUnsealRequest, UnsealError,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
db::models::{CoreEvmWalletAccess, NewEvmWalletAccess},
|
||||||
grpc::{Convert, TryConvert, request_tracker::RequestTracker},
|
grpc::{Convert, TryConvert, request_tracker::RequestTracker},
|
||||||
};
|
};
|
||||||
mod auth;
|
mod auth;
|
||||||
@@ -383,7 +388,8 @@ async fn dispatch_inner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
UserAgentRequestPayload::GrantWalletAccess(SdkClientGrantWalletAccess { accesses }) => {
|
UserAgentRequestPayload::GrantWalletAccess(SdkClientGrantWalletAccess { accesses }) => {
|
||||||
let entries = accesses.try_convert()?;
|
let entries: Vec<NewEvmWalletAccess> =
|
||||||
|
accesses.into_iter().map(|a| a.convert()).collect();
|
||||||
|
|
||||||
match actor.ask(HandleGrantEvmWalletAccess { entries }).await {
|
match actor.ask(HandleGrantEvmWalletAccess { entries }).await {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
@@ -398,9 +404,7 @@ async fn dispatch_inner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
UserAgentRequestPayload::RevokeWalletAccess(SdkClientRevokeWalletAccess { accesses }) => {
|
UserAgentRequestPayload::RevokeWalletAccess(SdkClientRevokeWalletAccess { accesses }) => {
|
||||||
let entries = accesses.try_convert()?;
|
match actor.ask(HandleRevokeEvmWalletAccess { entries: accesses }).await {
|
||||||
|
|
||||||
match actor.ask(HandleRevokeEvmWalletAccess { entries }).await {
|
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
info!("Successfully revoked wallet access");
|
info!("Successfully revoked wallet access");
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
|
use alloy::primitives::{Address, U256};
|
||||||
use arbiter_proto::proto::evm::{
|
use arbiter_proto::proto::evm::{
|
||||||
EtherTransferSettings as ProtoEtherTransferSettings,
|
EtherTransferSettings as ProtoEtherTransferSettings, SharedSettings as ProtoSharedSettings,
|
||||||
SharedSettings as ProtoSharedSettings,
|
SpecificGrant as ProtoSpecificGrant, TokenTransferSettings as ProtoTokenTransferSettings,
|
||||||
SpecificGrant as ProtoSpecificGrant,
|
TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit,
|
||||||
TokenTransferSettings as ProtoTokenTransferSettings,
|
|
||||||
TransactionRateLimit as ProtoTransactionRateLimit,
|
|
||||||
VolumeRateLimit as ProtoVolumeRateLimit,
|
|
||||||
specific_grant::Grant as ProtoSpecificGrantType,
|
specific_grant::Grant as ProtoSpecificGrantType,
|
||||||
};
|
};
|
||||||
use arbiter_proto::proto::user_agent::SdkClientWalletAccess;
|
use arbiter_proto::proto::user_agent::{SdkClientWalletAccess, WalletAccess};
|
||||||
use alloy::primitives::{Address, U256};
|
|
||||||
use chrono::{DateTime, TimeZone, Utc};
|
use chrono::{DateTime, TimeZone, Utc};
|
||||||
use prost_types::Timestamp as ProtoTimestamp;
|
use prost_types::Timestamp as ProtoTimestamp;
|
||||||
use tonic::Status;
|
use tonic::Status;
|
||||||
|
|
||||||
use crate::actors::user_agent::EvmAccessEntry;
|
use crate::db::models::{CoreEvmWalletAccess, NewEvmWallet, NewEvmWalletAccess};
|
||||||
|
use crate::grpc::Convert;
|
||||||
use crate::{
|
use crate::{
|
||||||
evm::policies::{
|
evm::policies::{
|
||||||
SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit,
|
SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit, ether_transfer,
|
||||||
ether_transfer, token_transfers,
|
token_transfers,
|
||||||
},
|
},
|
||||||
grpc::TryConvert,
|
grpc::TryConvert,
|
||||||
};
|
};
|
||||||
@@ -79,8 +77,14 @@ impl TryConvert for ProtoSharedSettings {
|
|||||||
Ok(SharedGrantSettings {
|
Ok(SharedGrantSettings {
|
||||||
wallet_access_id: self.wallet_access_id,
|
wallet_access_id: self.wallet_access_id,
|
||||||
chain: self.chain_id,
|
chain: self.chain_id,
|
||||||
valid_from: self.valid_from.map(ProtoTimestamp::try_convert).transpose()?,
|
valid_from: self
|
||||||
valid_until: self.valid_until.map(ProtoTimestamp::try_convert).transpose()?,
|
.valid_from
|
||||||
|
.map(ProtoTimestamp::try_convert)
|
||||||
|
.transpose()?,
|
||||||
|
valid_until: self
|
||||||
|
.valid_until
|
||||||
|
.map(ProtoTimestamp::try_convert)
|
||||||
|
.transpose()?,
|
||||||
max_gas_fee_per_gas: self
|
max_gas_fee_per_gas: self
|
||||||
.max_gas_fee_per_gas
|
.max_gas_fee_per_gas
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -136,17 +140,29 @@ impl TryConvert for ProtoSpecificGrant {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryConvert for Vec<SdkClientWalletAccess> {
|
impl Convert for WalletAccess {
|
||||||
type Output = Vec<EvmAccessEntry>;
|
type Output = NewEvmWalletAccess;
|
||||||
type Error = Status;
|
|
||||||
|
|
||||||
fn try_convert(self) -> Result<Vec<EvmAccessEntry>, Status> {
|
fn convert(self) -> Self::Output {
|
||||||
Ok(self
|
NewEvmWalletAccess {
|
||||||
.into_iter()
|
wallet_id: self.wallet_id,
|
||||||
.map(|SdkClientWalletAccess { client_id, wallet_id }| EvmAccessEntry {
|
client_id: self.sdk_client_id,
|
||||||
wallet_id,
|
}
|
||||||
sdk_client_id: client_id,
|
}
|
||||||
})
|
}
|
||||||
.collect())
|
|
||||||
|
impl TryConvert for SdkClientWalletAccess {
|
||||||
|
type Output = CoreEvmWalletAccess;
|
||||||
|
type Error = Status;
|
||||||
|
|
||||||
|
fn try_convert(self) -> Result<CoreEvmWalletAccess, Status> {
|
||||||
|
let Some(access) = self.access else {
|
||||||
|
return Err(Status::invalid_argument("Missing wallet access entry"));
|
||||||
|
};
|
||||||
|
Ok(CoreEvmWalletAccess {
|
||||||
|
wallet_id: access.wallet_id,
|
||||||
|
client_id: access.sdk_client_id,
|
||||||
|
id: self.id,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ use arbiter_proto::proto::{
|
|||||||
TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit,
|
TransactionRateLimit as ProtoTransactionRateLimit, VolumeRateLimit as ProtoVolumeRateLimit,
|
||||||
specific_grant::Grant as ProtoSpecificGrantType,
|
specific_grant::Grant as ProtoSpecificGrantType,
|
||||||
},
|
},
|
||||||
user_agent::SdkClientWalletAccess as ProtoSdkClientWalletAccess,
|
user_agent::{SdkClientWalletAccess as ProtoSdkClientWalletAccess, WalletAccess},
|
||||||
};
|
};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use prost_types::Timestamp as ProtoTimestamp;
|
use prost_types::Timestamp as ProtoTimestamp;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
actors::user_agent::EvmAccessEntry,
|
db::models::EvmWalletAccess,
|
||||||
evm::policies::{SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit},
|
evm::policies::{SharedGrantSettings, SpecificGrant, TransactionRateLimit, VolumeRateLimit},
|
||||||
grpc::Convert,
|
grpc::Convert,
|
||||||
};
|
};
|
||||||
@@ -96,13 +96,16 @@ impl Convert for SpecificGrant {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Convert for EvmAccessEntry {
|
impl Convert for EvmWalletAccess {
|
||||||
type Output = ProtoSdkClientWalletAccess;
|
type Output = ProtoSdkClientWalletAccess;
|
||||||
|
|
||||||
fn convert(self) -> Self::Output {
|
fn convert(self) -> Self::Output {
|
||||||
ProtoSdkClientWalletAccess {
|
Self::Output {
|
||||||
client_id: self.sdk_client_id,
|
id: self.id,
|
||||||
wallet_id: self.wallet_id,
|
access: Some(WalletAccess {
|
||||||
|
wallet_id: self.wallet_id,
|
||||||
|
sdk_client_id: self.client_id,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Client Wallet Access Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add a dedicated client details screen under `Clients` where operators can view a client and manage the set of accessible EVM wallets.
|
||||||
|
|
||||||
|
**Architecture:** Keep the existing `Clients` list as the entry point and add a focused details route/screen for one `SdkClientEntry`. Use Riverpod providers for the wallet inventory, client-scoped access draft, and save mutation. Because the current proto surface does not expose client-wallet-access RPCs, implement the UI and provider boundaries with an explicit unsupported save path instead of faking persistence.
|
||||||
|
|
||||||
|
**Tech Stack:** Flutter, AutoRoute, hooks_riverpod/riverpod, flutter_test
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add focused tests for client-details draft behavior
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `test/screens/dashboard/clients/details/client_wallet_access_controller_test.dart`
|
||||||
|
- Create: `test/screens/dashboard/clients/details/client_details_screen_test.dart`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing controller test**
|
||||||
|
- [ ] **Step 2: Run the controller test to verify it fails**
|
||||||
|
- [ ] **Step 3: Write the failing screen test**
|
||||||
|
- [ ] **Step 4: Run the screen test to verify it fails**
|
||||||
|
|
||||||
|
### Task 2: Add client-details state and data helpers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `lib/providers/sdk_clients/details.dart`
|
||||||
|
- Create: `lib/providers/sdk_clients/details.g.dart`
|
||||||
|
- Create: `lib/providers/sdk_clients/wallet_access.dart`
|
||||||
|
- Create: `lib/providers/sdk_clients/wallet_access.g.dart`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add provider types for selected client lookup**
|
||||||
|
- [ ] **Step 2: Add provider/notifier types for wallet-access draft state**
|
||||||
|
- [ ] **Step 3: Implement unsupported save mutation boundary**
|
||||||
|
- [ ] **Step 4: Run controller tests to make them pass**
|
||||||
|
|
||||||
|
### Task 3: Build the client-details UI with granular widgets
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `lib/screens/dashboard/clients/details/client_details.dart`
|
||||||
|
- Create: `lib/screens/dashboard/clients/details/widgets/client_details_header.dart`
|
||||||
|
- Create: `lib/screens/dashboard/clients/details/widgets/client_summary_card.dart`
|
||||||
|
- Create: `lib/screens/dashboard/clients/details/widgets/wallet_access_section.dart`
|
||||||
|
- Create: `lib/screens/dashboard/clients/details/widgets/wallet_access_search_field.dart`
|
||||||
|
- Create: `lib/screens/dashboard/clients/details/widgets/wallet_access_list.dart`
|
||||||
|
- Create: `lib/screens/dashboard/clients/details/widgets/wallet_access_tile.dart`
|
||||||
|
- Create: `lib/screens/dashboard/clients/details/widgets/wallet_access_save_bar.dart`
|
||||||
|
- Create: `lib/screens/dashboard/clients/details/widgets/client_details_state_panel.dart`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build the screen shell and summary widgets**
|
||||||
|
- [ ] **Step 2: Build the wallet-access list/search/save widgets**
|
||||||
|
- [ ] **Step 3: Keep widget files granular and avoid hardcoded sizes**
|
||||||
|
- [ ] **Step 4: Run the screen tests to make them pass**
|
||||||
|
|
||||||
|
### Task 4: Wire navigation from the clients list
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `lib/router.dart`
|
||||||
|
- Modify: `lib/router.gr.dart`
|
||||||
|
- Modify: `lib/screens/dashboard/clients/table.dart`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the client-details route**
|
||||||
|
- [ ] **Step 2: Add a row affordance to open the client-details screen**
|
||||||
|
- [ ] **Step 3: Keep the existing list usable as an overview**
|
||||||
|
- [ ] **Step 4: Run targeted screen tests again**
|
||||||
|
|
||||||
|
### Task 5: Regenerate code and verify the feature
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: generated files as required by build tools
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run code generation**
|
||||||
|
- [ ] **Step 2: Run widget/provider tests**
|
||||||
|
- [ ] **Step 3: Run Flutter analysis on touched code**
|
||||||
|
- [ ] **Step 4: Review for requirement coverage and report the backend save limitation clearly**
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
# Client Wallet Access Design
|
||||||
|
|
||||||
|
Date: 2026-03-25
|
||||||
|
Status: Proposed
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a client-centric UI that lets an operator choose which EVM wallets are visible to a given SDK client.
|
||||||
|
|
||||||
|
The mental model is:
|
||||||
|
|
||||||
|
> For this SDK client, choose which wallets it can see.
|
||||||
|
|
||||||
|
This UI should live under the existing `Clients` area, not under `Wallets`, because the permission is being edited from the client's perspective.
|
||||||
|
|
||||||
|
## Current Context
|
||||||
|
|
||||||
|
The current Flutter app has:
|
||||||
|
|
||||||
|
- A top-level dashboard with `Wallets`, `Clients`, and `About`
|
||||||
|
- A `Clients` screen that currently acts as a registry/list of `SdkClientEntry`
|
||||||
|
- A `Wallets` screen that lists managed EVM wallets
|
||||||
|
- An EVM grant creation flow that still manually asks for `Client ID`
|
||||||
|
|
||||||
|
Relevant observations from the current codebase:
|
||||||
|
|
||||||
|
- `SdkClientEntry` is already a richer admin-facing object than `WalletEntry`
|
||||||
|
- `WalletEntry` is currently minimal and not suited to owning the relationship UI
|
||||||
|
- The `Clients` screen already presents expandable client rows, which makes it the most natural entry point for a details view
|
||||||
|
|
||||||
|
## Chosen Approach
|
||||||
|
|
||||||
|
Use a dedicated client details screen.
|
||||||
|
|
||||||
|
From the `Clients` list, the operator opens one client and lands on a screen dedicated to that client. That screen includes a wallet access section that shows:
|
||||||
|
|
||||||
|
- Client identity and metadata
|
||||||
|
- Current wallet access selection
|
||||||
|
- A searchable/selectable list of available wallets
|
||||||
|
- Save feedback and error states
|
||||||
|
|
||||||
|
This is preferred over inline editing or a modal because it scales better when more capabilities are added later, such as:
|
||||||
|
|
||||||
|
- Search
|
||||||
|
- Bulk actions
|
||||||
|
- Explanatory copy
|
||||||
|
- Access summaries
|
||||||
|
- Future permission categories beyond wallet visibility
|
||||||
|
|
||||||
|
## User Experience
|
||||||
|
|
||||||
|
### Entry
|
||||||
|
|
||||||
|
The operator starts on the existing `Clients` screen.
|
||||||
|
|
||||||
|
Each client row gains a clear affordance to open details, for example:
|
||||||
|
|
||||||
|
- Tapping the row
|
||||||
|
- A trailing button such as `Manage access`
|
||||||
|
|
||||||
|
The existing list remains the overview surface. Editing does not happen inline.
|
||||||
|
|
||||||
|
### Client Details Screen
|
||||||
|
|
||||||
|
The screen is focused on a single client and should contain:
|
||||||
|
|
||||||
|
1. A lightweight header with back navigation
|
||||||
|
2. A client summary section
|
||||||
|
3. A wallet access section
|
||||||
|
4. Save/status feedback
|
||||||
|
|
||||||
|
The wallet access section is the core interaction:
|
||||||
|
|
||||||
|
- Show all available EVM wallets
|
||||||
|
- Show which wallets are currently accessible to this client
|
||||||
|
- Allow toggling access on/off
|
||||||
|
- Allow filtering/searching wallets when the list grows
|
||||||
|
- Show empty/loading/error states
|
||||||
|
|
||||||
|
### Save Model
|
||||||
|
|
||||||
|
Use an explicit save action rather than auto-save.
|
||||||
|
|
||||||
|
Reasons:
|
||||||
|
|
||||||
|
- Permission changes are administrative and should feel deliberate
|
||||||
|
- Multiple checkbox changes can be staged together
|
||||||
|
- It creates a clear place for pending, success, and failure states
|
||||||
|
|
||||||
|
The screen should track:
|
||||||
|
|
||||||
|
- Original selection from the server
|
||||||
|
- Current local selection in the form
|
||||||
|
- Whether there are unsaved changes
|
||||||
|
|
||||||
|
## Information Architecture
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
|
||||||
|
Add a nested route under the dashboard clients area for client details.
|
||||||
|
|
||||||
|
Conceptually:
|
||||||
|
|
||||||
|
- `Clients` remains the list screen
|
||||||
|
- `Client Details` becomes the edit/manage screen for one client
|
||||||
|
|
||||||
|
This keeps the current top-level tabs intact and avoids turning wallet access into a global dashboard concern.
|
||||||
|
|
||||||
|
### Screen Ownership
|
||||||
|
|
||||||
|
Wallet visibility is owned by the client details screen, not by the wallets screen.
|
||||||
|
|
||||||
|
The wallets screen can remain focused on wallet inventory and wallet creation.
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
Use Riverpod.
|
||||||
|
|
||||||
|
State should be split by concern instead of managed in one large widget:
|
||||||
|
|
||||||
|
- Provider for the client list
|
||||||
|
- Provider for the wallet list
|
||||||
|
- Provider for the selected client details data
|
||||||
|
- Provider or notifier for wallet-access editing state
|
||||||
|
- Mutation/provider for saving wallet access changes
|
||||||
|
|
||||||
|
Recommended shape:
|
||||||
|
|
||||||
|
- One provider fetches the wallet inventory
|
||||||
|
- One provider fetches wallet access for a specific client
|
||||||
|
- One notifier owns the editable selection set for the client details form
|
||||||
|
- One mutation performs save and refreshes dependent providers
|
||||||
|
|
||||||
|
The editing provider should expose:
|
||||||
|
|
||||||
|
- Current selected wallet identifiers
|
||||||
|
- Original selected wallet identifiers
|
||||||
|
- `hasChanges`
|
||||||
|
- `isSaving`
|
||||||
|
- Validation or request error message when relevant
|
||||||
|
|
||||||
|
This keeps the UI declarative and prevents the screen widget from holding all state locally.
|
||||||
|
|
||||||
|
## Data Model Assumptions
|
||||||
|
|
||||||
|
The UI assumes there is or will be a backend/API surface equivalent to:
|
||||||
|
|
||||||
|
- List SDK clients
|
||||||
|
- List EVM wallets
|
||||||
|
- Read wallet access entries for one client
|
||||||
|
- Replace or update wallet access entries for one client
|
||||||
|
|
||||||
|
The screen should work with wallet identifiers that are stable from the backend perspective. If the backend only exposes positional IDs today, that should be normalized before binding the UI tightly to list index order.
|
||||||
|
|
||||||
|
This is important because the current grant creation screen derives `walletId` from list position, which is not a robust long-term UI contract.
|
||||||
|
|
||||||
|
## Layout and Styling Constraints
|
||||||
|
|
||||||
|
Implementation must follow these constraints:
|
||||||
|
|
||||||
|
- Use Riverpod for screen state and mutations
|
||||||
|
- Do not hardcode widths and heights
|
||||||
|
- Prefer layout driven by padding, constraints, flex, wrapping, and intrinsic content
|
||||||
|
- Keep widgets granular; a widget should not exceed roughly 50 lines
|
||||||
|
- Do not place all client-details widgets into a single file
|
||||||
|
- Create a dedicated widgets folder for the client details screen
|
||||||
|
- Reuse existing UI patterns and helper widgets where it is reasonable, but do not force reuse when it harms clarity
|
||||||
|
|
||||||
|
Recommended implementation structure:
|
||||||
|
|
||||||
|
- `lib/screens/dashboard/clients/details/`
|
||||||
|
- `lib/screens/dashboard/clients/details/client_details.dart`
|
||||||
|
- `lib/screens/dashboard/clients/details/widgets/...`
|
||||||
|
|
||||||
|
## Widget Decomposition
|
||||||
|
|
||||||
|
The client details feature should be composed from small widgets with single responsibilities.
|
||||||
|
|
||||||
|
Suggested widget split:
|
||||||
|
|
||||||
|
- `ClientDetailsScreen`
|
||||||
|
- `ClientDetailsScaffold`
|
||||||
|
- `ClientDetailsHeader`
|
||||||
|
- `ClientSummaryCard`
|
||||||
|
- `WalletAccessSection`
|
||||||
|
- `WalletAccessSearchField`
|
||||||
|
- `WalletAccessList`
|
||||||
|
- `WalletAccessListItem`
|
||||||
|
- `WalletAccessEmptyState`
|
||||||
|
- `WalletAccessErrorState`
|
||||||
|
- `WalletAccessSaveBar`
|
||||||
|
|
||||||
|
If useful, existing generic state panels or cards from the current screens can be adapted or extracted, but only where that reduces duplication without making the code harder to follow.
|
||||||
|
|
||||||
|
## Interaction Details
|
||||||
|
|
||||||
|
### Client Summary
|
||||||
|
|
||||||
|
Display the client's:
|
||||||
|
|
||||||
|
- Name
|
||||||
|
- ID
|
||||||
|
- Version
|
||||||
|
- Description
|
||||||
|
- Public key summary
|
||||||
|
- Registration date
|
||||||
|
|
||||||
|
This gives the operator confidence that they are editing the intended client.
|
||||||
|
|
||||||
|
### Wallet Access List
|
||||||
|
|
||||||
|
Each wallet item should show enough identity to make selection safe:
|
||||||
|
|
||||||
|
- Human-readable label if one exists in the backend later
|
||||||
|
- Otherwise the wallet address
|
||||||
|
- Optional secondary metadata if available later
|
||||||
|
|
||||||
|
Each item should have a clear selected/unselected control, most likely a checkbox.
|
||||||
|
|
||||||
|
### Unsaved Changes
|
||||||
|
|
||||||
|
When the current selection differs from the original selection:
|
||||||
|
|
||||||
|
- Show a save bar or action row
|
||||||
|
- Enable `Save`
|
||||||
|
- Optionally show `Reset` or `Discard`
|
||||||
|
|
||||||
|
When there are no changes:
|
||||||
|
|
||||||
|
- Save action is disabled or visually deemphasized
|
||||||
|
|
||||||
|
### Loading and Errors
|
||||||
|
|
||||||
|
The screen should independently handle:
|
||||||
|
|
||||||
|
- Client not found
|
||||||
|
- Wallet list unavailable
|
||||||
|
- Wallet access unavailable
|
||||||
|
- Save failure
|
||||||
|
- Empty wallet inventory
|
||||||
|
|
||||||
|
These states should be explicit in the UI rather than collapsed into a blank screen.
|
||||||
|
|
||||||
|
## Reuse Guidance
|
||||||
|
|
||||||
|
Reasonable reuse candidates from the current codebase:
|
||||||
|
|
||||||
|
- Existing color/theme primitives
|
||||||
|
- Existing state/empty panels if they can be extracted cleanly
|
||||||
|
- Existing wallet formatting helpers, if they are generalized
|
||||||
|
|
||||||
|
Reuse should not be prioritized over good boundaries. If the existing widget is too coupled to another screen, create a new focused widget instead.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
Plan for widget and provider-level coverage.
|
||||||
|
|
||||||
|
At minimum, implementation should be testable for:
|
||||||
|
|
||||||
|
- Rendering client summary
|
||||||
|
- Rendering preselected wallet access
|
||||||
|
- Toggling wallet selection
|
||||||
|
- Dirty state detection
|
||||||
|
- Save success refresh flow
|
||||||
|
- Save failure preserving local edits
|
||||||
|
- Empty/loading/error states
|
||||||
|
|
||||||
|
Given the current test directory is empty, this feature is a good place to establish basic screen/provider tests rather than relying only on manual verification.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
The following are not required for the first version unless backend requirements force them:
|
||||||
|
|
||||||
|
- Cross-client bulk editing
|
||||||
|
- Wallet-side permission management
|
||||||
|
- Audit history UI
|
||||||
|
- Role templates
|
||||||
|
- Non-EVM asset permissions
|
||||||
|
|
||||||
|
## Recommendation Summary
|
||||||
|
|
||||||
|
Implement wallet access management as a dedicated client details screen under `Clients`.
|
||||||
|
|
||||||
|
This gives the cleanest product model:
|
||||||
|
|
||||||
|
- `Clients` answers "who is this app/client?"
|
||||||
|
- `Wallet access` answers "what wallets can it see?"
|
||||||
|
|
||||||
|
It also gives the best technical path for Riverpod-managed state, granular widget decomposition, and future expansion without crowding the existing client list UI.
|
||||||
@@ -29,17 +29,27 @@ Future<List<GrantEntry>> listEvmGrants(Connection connection) async {
|
|||||||
|
|
||||||
Future<int> createEvmGrant(
|
Future<int> createEvmGrant(
|
||||||
Connection connection, {
|
Connection connection, {
|
||||||
required int clientId,
|
required SharedSettings sharedSettings,
|
||||||
required int walletId,
|
|
||||||
required Int64 chainId,
|
|
||||||
DateTime? validFrom,
|
|
||||||
DateTime? validUntil,
|
|
||||||
List<int>? maxGasFeePerGas,
|
|
||||||
List<int>? maxPriorityFeePerGas,
|
|
||||||
TransactionRateLimit? rateLimit,
|
|
||||||
required SpecificGrant specific,
|
required SpecificGrant specific,
|
||||||
}) async {
|
}) async {
|
||||||
throw UnimplementedError('EVM grant creation is not yet implemented.');
|
final request = UserAgentRequest(
|
||||||
|
evmGrantCreate: EvmGrantCreateRequest(
|
||||||
|
shared: sharedSettings,
|
||||||
|
specific: specific,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final resp = await connection.ask(request);
|
||||||
|
|
||||||
|
if (!resp.hasEvmGrantCreate()) {
|
||||||
|
throw Exception(
|
||||||
|
'Expected EVM grant create response, got ${resp.whichPayload()}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = resp.evmGrantCreate;
|
||||||
|
|
||||||
|
return result.grantId;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteEvmGrant(Connection connection, int grantId) async {
|
Future<void> deleteEvmGrant(Connection connection, int grantId) async {
|
||||||
|
|||||||
72
useragent/lib/features/connection/evm/wallet_access.dart
Normal file
72
useragent/lib/features/connection/evm/wallet_access.dart
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import 'package:arbiter/features/connection/connection.dart';
|
||||||
|
import 'package:arbiter/proto/user_agent.pb.dart';
|
||||||
|
import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart';
|
||||||
|
|
||||||
|
Future<Set<int>> readClientWalletAccess(
|
||||||
|
Connection connection, {
|
||||||
|
required int clientId,
|
||||||
|
}) async {
|
||||||
|
final response = await connection.ask(
|
||||||
|
UserAgentRequest(listWalletAccess: Empty()),
|
||||||
|
);
|
||||||
|
if (!response.hasListWalletAccessResponse()) {
|
||||||
|
throw Exception(
|
||||||
|
'Expected list wallet access response, got ${response.whichPayload()}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
for (final entry in response.listWalletAccessResponse.accesses)
|
||||||
|
if (entry.access.sdkClientId == clientId) entry.access.walletId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<SdkClientWalletAccess>> listAllWalletAccesses(
|
||||||
|
Connection connection,
|
||||||
|
) async {
|
||||||
|
final response = await connection.ask(
|
||||||
|
UserAgentRequest(listWalletAccess: Empty()),
|
||||||
|
);
|
||||||
|
if (!response.hasListWalletAccessResponse()) {
|
||||||
|
throw Exception(
|
||||||
|
'Expected list wallet access response, got ${response.whichPayload()}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response.listWalletAccessResponse.accesses.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> writeClientWalletAccess(
|
||||||
|
Connection connection, {
|
||||||
|
required int clientId,
|
||||||
|
required Set<int> walletIds,
|
||||||
|
}) async {
|
||||||
|
final current = await readClientWalletAccess(connection, clientId: clientId);
|
||||||
|
|
||||||
|
final toGrant = walletIds.difference(current);
|
||||||
|
final toRevoke = current.difference(walletIds);
|
||||||
|
|
||||||
|
if (toGrant.isNotEmpty) {
|
||||||
|
await connection.tell(
|
||||||
|
UserAgentRequest(
|
||||||
|
grantWalletAccess: SdkClientGrantWalletAccess(
|
||||||
|
accesses: [
|
||||||
|
for (final walletId in toGrant)
|
||||||
|
WalletAccess(sdkClientId: clientId, walletId: walletId),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toRevoke.isNotEmpty) {
|
||||||
|
await connection.tell(
|
||||||
|
UserAgentRequest(
|
||||||
|
revokeWalletAccess: SdkClientRevokeWalletAccess(
|
||||||
|
accesses: [
|
||||||
|
for (final walletId in toRevoke)
|
||||||
|
walletId
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1072,14 +1072,81 @@ class SdkClientConnectionCancel extends $pb.GeneratedMessage {
|
|||||||
void clearPubkey() => $_clearField(1);
|
void clearPubkey() => $_clearField(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
class SdkClientWalletAccess extends $pb.GeneratedMessage {
|
class WalletAccess extends $pb.GeneratedMessage {
|
||||||
factory SdkClientWalletAccess({
|
factory WalletAccess({
|
||||||
$core.int? clientId,
|
|
||||||
$core.int? walletId,
|
$core.int? walletId,
|
||||||
|
$core.int? sdkClientId,
|
||||||
}) {
|
}) {
|
||||||
final result = create();
|
final result = create();
|
||||||
if (clientId != null) result.clientId = clientId;
|
|
||||||
if (walletId != null) result.walletId = walletId;
|
if (walletId != null) result.walletId = walletId;
|
||||||
|
if (sdkClientId != null) result.sdkClientId = sdkClientId;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
WalletAccess._();
|
||||||
|
|
||||||
|
factory WalletAccess.fromBuffer($core.List<$core.int> data,
|
||||||
|
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||||
|
create()..mergeFromBuffer(data, registry);
|
||||||
|
factory WalletAccess.fromJson($core.String json,
|
||||||
|
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||||
|
create()..mergeFromJson(json, registry);
|
||||||
|
|
||||||
|
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
||||||
|
_omitMessageNames ? '' : 'WalletAccess',
|
||||||
|
package:
|
||||||
|
const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'),
|
||||||
|
createEmptyInstance: create)
|
||||||
|
..aI(1, _omitFieldNames ? '' : 'walletId')
|
||||||
|
..aI(2, _omitFieldNames ? '' : 'sdkClientId')
|
||||||
|
..hasRequiredFields = false;
|
||||||
|
|
||||||
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
|
WalletAccess clone() => deepCopy();
|
||||||
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
|
WalletAccess copyWith(void Function(WalletAccess) updates) =>
|
||||||
|
super.copyWith((message) => updates(message as WalletAccess))
|
||||||
|
as WalletAccess;
|
||||||
|
|
||||||
|
@$core.override
|
||||||
|
$pb.BuilderInfo get info_ => _i;
|
||||||
|
|
||||||
|
@$core.pragma('dart2js:noInline')
|
||||||
|
static WalletAccess create() => WalletAccess._();
|
||||||
|
@$core.override
|
||||||
|
WalletAccess createEmptyInstance() => create();
|
||||||
|
@$core.pragma('dart2js:noInline')
|
||||||
|
static WalletAccess getDefault() => _defaultInstance ??=
|
||||||
|
$pb.GeneratedMessage.$_defaultFor<WalletAccess>(create);
|
||||||
|
static WalletAccess? _defaultInstance;
|
||||||
|
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
$core.int get walletId => $_getIZ(0);
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
set walletId($core.int value) => $_setSignedInt32(0, value);
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
$core.bool hasWalletId() => $_has(0);
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
void clearWalletId() => $_clearField(1);
|
||||||
|
|
||||||
|
@$pb.TagNumber(2)
|
||||||
|
$core.int get sdkClientId => $_getIZ(1);
|
||||||
|
@$pb.TagNumber(2)
|
||||||
|
set sdkClientId($core.int value) => $_setSignedInt32(1, value);
|
||||||
|
@$pb.TagNumber(2)
|
||||||
|
$core.bool hasSdkClientId() => $_has(1);
|
||||||
|
@$pb.TagNumber(2)
|
||||||
|
void clearSdkClientId() => $_clearField(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
class SdkClientWalletAccess extends $pb.GeneratedMessage {
|
||||||
|
factory SdkClientWalletAccess({
|
||||||
|
$core.int? id,
|
||||||
|
WalletAccess? access,
|
||||||
|
}) {
|
||||||
|
final result = create();
|
||||||
|
if (id != null) result.id = id;
|
||||||
|
if (access != null) result.access = access;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1097,8 +1164,9 @@ class SdkClientWalletAccess extends $pb.GeneratedMessage {
|
|||||||
package:
|
package:
|
||||||
const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'),
|
const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'),
|
||||||
createEmptyInstance: create)
|
createEmptyInstance: create)
|
||||||
..aI(1, _omitFieldNames ? '' : 'clientId')
|
..aI(1, _omitFieldNames ? '' : 'id')
|
||||||
..aI(2, _omitFieldNames ? '' : 'walletId')
|
..aOM<WalletAccess>(2, _omitFieldNames ? '' : 'access',
|
||||||
|
subBuilder: WalletAccess.create)
|
||||||
..hasRequiredFields = false;
|
..hasRequiredFields = false;
|
||||||
|
|
||||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
@@ -1122,27 +1190,29 @@ class SdkClientWalletAccess extends $pb.GeneratedMessage {
|
|||||||
static SdkClientWalletAccess? _defaultInstance;
|
static SdkClientWalletAccess? _defaultInstance;
|
||||||
|
|
||||||
@$pb.TagNumber(1)
|
@$pb.TagNumber(1)
|
||||||
$core.int get clientId => $_getIZ(0);
|
$core.int get id => $_getIZ(0);
|
||||||
@$pb.TagNumber(1)
|
@$pb.TagNumber(1)
|
||||||
set clientId($core.int value) => $_setSignedInt32(0, value);
|
set id($core.int value) => $_setSignedInt32(0, value);
|
||||||
@$pb.TagNumber(1)
|
@$pb.TagNumber(1)
|
||||||
$core.bool hasClientId() => $_has(0);
|
$core.bool hasId() => $_has(0);
|
||||||
@$pb.TagNumber(1)
|
@$pb.TagNumber(1)
|
||||||
void clearClientId() => $_clearField(1);
|
void clearId() => $_clearField(1);
|
||||||
|
|
||||||
@$pb.TagNumber(2)
|
@$pb.TagNumber(2)
|
||||||
$core.int get walletId => $_getIZ(1);
|
WalletAccess get access => $_getN(1);
|
||||||
@$pb.TagNumber(2)
|
@$pb.TagNumber(2)
|
||||||
set walletId($core.int value) => $_setSignedInt32(1, value);
|
set access(WalletAccess value) => $_setField(2, value);
|
||||||
@$pb.TagNumber(2)
|
@$pb.TagNumber(2)
|
||||||
$core.bool hasWalletId() => $_has(1);
|
$core.bool hasAccess() => $_has(1);
|
||||||
@$pb.TagNumber(2)
|
@$pb.TagNumber(2)
|
||||||
void clearWalletId() => $_clearField(2);
|
void clearAccess() => $_clearField(2);
|
||||||
|
@$pb.TagNumber(2)
|
||||||
|
WalletAccess ensureAccess() => $_ensure(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
class SdkClientGrantWalletAccess extends $pb.GeneratedMessage {
|
class SdkClientGrantWalletAccess extends $pb.GeneratedMessage {
|
||||||
factory SdkClientGrantWalletAccess({
|
factory SdkClientGrantWalletAccess({
|
||||||
$core.Iterable<SdkClientWalletAccess>? accesses,
|
$core.Iterable<WalletAccess>? accesses,
|
||||||
}) {
|
}) {
|
||||||
final result = create();
|
final result = create();
|
||||||
if (accesses != null) result.accesses.addAll(accesses);
|
if (accesses != null) result.accesses.addAll(accesses);
|
||||||
@@ -1163,8 +1233,8 @@ class SdkClientGrantWalletAccess extends $pb.GeneratedMessage {
|
|||||||
package:
|
package:
|
||||||
const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'),
|
const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'),
|
||||||
createEmptyInstance: create)
|
createEmptyInstance: create)
|
||||||
..pPM<SdkClientWalletAccess>(1, _omitFieldNames ? '' : 'accesses',
|
..pPM<WalletAccess>(1, _omitFieldNames ? '' : 'accesses',
|
||||||
subBuilder: SdkClientWalletAccess.create)
|
subBuilder: WalletAccess.create)
|
||||||
..hasRequiredFields = false;
|
..hasRequiredFields = false;
|
||||||
|
|
||||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
@@ -1189,12 +1259,12 @@ class SdkClientGrantWalletAccess extends $pb.GeneratedMessage {
|
|||||||
static SdkClientGrantWalletAccess? _defaultInstance;
|
static SdkClientGrantWalletAccess? _defaultInstance;
|
||||||
|
|
||||||
@$pb.TagNumber(1)
|
@$pb.TagNumber(1)
|
||||||
$pb.PbList<SdkClientWalletAccess> get accesses => $_getList(0);
|
$pb.PbList<WalletAccess> get accesses => $_getList(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
class SdkClientRevokeWalletAccess extends $pb.GeneratedMessage {
|
class SdkClientRevokeWalletAccess extends $pb.GeneratedMessage {
|
||||||
factory SdkClientRevokeWalletAccess({
|
factory SdkClientRevokeWalletAccess({
|
||||||
$core.Iterable<SdkClientWalletAccess>? accesses,
|
$core.Iterable<$core.int>? accesses,
|
||||||
}) {
|
}) {
|
||||||
final result = create();
|
final result = create();
|
||||||
if (accesses != null) result.accesses.addAll(accesses);
|
if (accesses != null) result.accesses.addAll(accesses);
|
||||||
@@ -1215,8 +1285,7 @@ class SdkClientRevokeWalletAccess extends $pb.GeneratedMessage {
|
|||||||
package:
|
package:
|
||||||
const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'),
|
const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'),
|
||||||
createEmptyInstance: create)
|
createEmptyInstance: create)
|
||||||
..pPM<SdkClientWalletAccess>(1, _omitFieldNames ? '' : 'accesses',
|
..p<$core.int>(1, _omitFieldNames ? '' : 'accesses', $pb.PbFieldType.K3)
|
||||||
subBuilder: SdkClientWalletAccess.create)
|
|
||||||
..hasRequiredFields = false;
|
..hasRequiredFields = false;
|
||||||
|
|
||||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
@@ -1242,7 +1311,7 @@ class SdkClientRevokeWalletAccess extends $pb.GeneratedMessage {
|
|||||||
static SdkClientRevokeWalletAccess? _defaultInstance;
|
static SdkClientRevokeWalletAccess? _defaultInstance;
|
||||||
|
|
||||||
@$pb.TagNumber(1)
|
@$pb.TagNumber(1)
|
||||||
$pb.PbList<SdkClientWalletAccess> get accesses => $_getList(0);
|
$pb.PbList<$core.int> get accesses => $_getList(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
class ListWalletAccessResponse extends $pb.GeneratedMessage {
|
class ListWalletAccessResponse extends $pb.GeneratedMessage {
|
||||||
|
|||||||
@@ -418,19 +418,40 @@ final $typed_data.Uint8List sdkClientConnectionCancelDescriptor =
|
|||||||
$convert.base64Decode(
|
$convert.base64Decode(
|
||||||
'ChlTZGtDbGllbnRDb25uZWN0aW9uQ2FuY2VsEhYKBnB1YmtleRgBIAEoDFIGcHVia2V5');
|
'ChlTZGtDbGllbnRDb25uZWN0aW9uQ2FuY2VsEhYKBnB1YmtleRgBIAEoDFIGcHVia2V5');
|
||||||
|
|
||||||
|
@$core.Deprecated('Use walletAccessDescriptor instead')
|
||||||
|
const WalletAccess$json = {
|
||||||
|
'1': 'WalletAccess',
|
||||||
|
'2': [
|
||||||
|
{'1': 'wallet_id', '3': 1, '4': 1, '5': 5, '10': 'walletId'},
|
||||||
|
{'1': 'sdk_client_id', '3': 2, '4': 1, '5': 5, '10': 'sdkClientId'},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Descriptor for `WalletAccess`. Decode as a `google.protobuf.DescriptorProto`.
|
||||||
|
final $typed_data.Uint8List walletAccessDescriptor = $convert.base64Decode(
|
||||||
|
'CgxXYWxsZXRBY2Nlc3MSGwoJd2FsbGV0X2lkGAEgASgFUgh3YWxsZXRJZBIiCg1zZGtfY2xpZW'
|
||||||
|
'50X2lkGAIgASgFUgtzZGtDbGllbnRJZA==');
|
||||||
|
|
||||||
@$core.Deprecated('Use sdkClientWalletAccessDescriptor instead')
|
@$core.Deprecated('Use sdkClientWalletAccessDescriptor instead')
|
||||||
const SdkClientWalletAccess$json = {
|
const SdkClientWalletAccess$json = {
|
||||||
'1': 'SdkClientWalletAccess',
|
'1': 'SdkClientWalletAccess',
|
||||||
'2': [
|
'2': [
|
||||||
{'1': 'client_id', '3': 1, '4': 1, '5': 5, '10': 'clientId'},
|
{'1': 'id', '3': 1, '4': 1, '5': 5, '10': 'id'},
|
||||||
{'1': 'wallet_id', '3': 2, '4': 1, '5': 5, '10': 'walletId'},
|
{
|
||||||
|
'1': 'access',
|
||||||
|
'3': 2,
|
||||||
|
'4': 1,
|
||||||
|
'5': 11,
|
||||||
|
'6': '.arbiter.user_agent.WalletAccess',
|
||||||
|
'10': 'access'
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Descriptor for `SdkClientWalletAccess`. Decode as a `google.protobuf.DescriptorProto`.
|
/// Descriptor for `SdkClientWalletAccess`. Decode as a `google.protobuf.DescriptorProto`.
|
||||||
final $typed_data.Uint8List sdkClientWalletAccessDescriptor = $convert.base64Decode(
|
final $typed_data.Uint8List sdkClientWalletAccessDescriptor = $convert.base64Decode(
|
||||||
'ChVTZGtDbGllbnRXYWxsZXRBY2Nlc3MSGwoJY2xpZW50X2lkGAEgASgFUghjbGllbnRJZBIbCg'
|
'ChVTZGtDbGllbnRXYWxsZXRBY2Nlc3MSDgoCaWQYASABKAVSAmlkEjgKBmFjY2VzcxgCIAEoCz'
|
||||||
'l3YWxsZXRfaWQYAiABKAVSCHdhbGxldElk');
|
'IgLmFyYml0ZXIudXNlcl9hZ2VudC5XYWxsZXRBY2Nlc3NSBmFjY2Vzcw==');
|
||||||
|
|
||||||
@$core.Deprecated('Use sdkClientGrantWalletAccessDescriptor instead')
|
@$core.Deprecated('Use sdkClientGrantWalletAccessDescriptor instead')
|
||||||
const SdkClientGrantWalletAccess$json = {
|
const SdkClientGrantWalletAccess$json = {
|
||||||
@@ -441,7 +462,7 @@ const SdkClientGrantWalletAccess$json = {
|
|||||||
'3': 1,
|
'3': 1,
|
||||||
'4': 3,
|
'4': 3,
|
||||||
'5': 11,
|
'5': 11,
|
||||||
'6': '.arbiter.user_agent.SdkClientWalletAccess',
|
'6': '.arbiter.user_agent.WalletAccess',
|
||||||
'10': 'accesses'
|
'10': 'accesses'
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -450,29 +471,22 @@ const SdkClientGrantWalletAccess$json = {
|
|||||||
/// Descriptor for `SdkClientGrantWalletAccess`. Decode as a `google.protobuf.DescriptorProto`.
|
/// Descriptor for `SdkClientGrantWalletAccess`. Decode as a `google.protobuf.DescriptorProto`.
|
||||||
final $typed_data.Uint8List sdkClientGrantWalletAccessDescriptor =
|
final $typed_data.Uint8List sdkClientGrantWalletAccessDescriptor =
|
||||||
$convert.base64Decode(
|
$convert.base64Decode(
|
||||||
'ChpTZGtDbGllbnRHcmFudFdhbGxldEFjY2VzcxJFCghhY2Nlc3NlcxgBIAMoCzIpLmFyYml0ZX'
|
'ChpTZGtDbGllbnRHcmFudFdhbGxldEFjY2VzcxI8CghhY2Nlc3NlcxgBIAMoCzIgLmFyYml0ZX'
|
||||||
'IudXNlcl9hZ2VudC5TZGtDbGllbnRXYWxsZXRBY2Nlc3NSCGFjY2Vzc2Vz');
|
'IudXNlcl9hZ2VudC5XYWxsZXRBY2Nlc3NSCGFjY2Vzc2Vz');
|
||||||
|
|
||||||
@$core.Deprecated('Use sdkClientRevokeWalletAccessDescriptor instead')
|
@$core.Deprecated('Use sdkClientRevokeWalletAccessDescriptor instead')
|
||||||
const SdkClientRevokeWalletAccess$json = {
|
const SdkClientRevokeWalletAccess$json = {
|
||||||
'1': 'SdkClientRevokeWalletAccess',
|
'1': 'SdkClientRevokeWalletAccess',
|
||||||
'2': [
|
'2': [
|
||||||
{
|
{'1': 'accesses', '3': 1, '4': 3, '5': 5, '10': 'accesses'},
|
||||||
'1': 'accesses',
|
|
||||||
'3': 1,
|
|
||||||
'4': 3,
|
|
||||||
'5': 11,
|
|
||||||
'6': '.arbiter.user_agent.SdkClientWalletAccess',
|
|
||||||
'10': 'accesses'
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Descriptor for `SdkClientRevokeWalletAccess`. Decode as a `google.protobuf.DescriptorProto`.
|
/// Descriptor for `SdkClientRevokeWalletAccess`. Decode as a `google.protobuf.DescriptorProto`.
|
||||||
final $typed_data.Uint8List sdkClientRevokeWalletAccessDescriptor =
|
final $typed_data.Uint8List sdkClientRevokeWalletAccessDescriptor =
|
||||||
$convert.base64Decode(
|
$convert.base64Decode(
|
||||||
'ChtTZGtDbGllbnRSZXZva2VXYWxsZXRBY2Nlc3MSRQoIYWNjZXNzZXMYASADKAsyKS5hcmJpdG'
|
'ChtTZGtDbGllbnRSZXZva2VXYWxsZXRBY2Nlc3MSGgoIYWNjZXNzZXMYASADKAVSCGFjY2Vzc2'
|
||||||
'VyLnVzZXJfYWdlbnQuU2RrQ2xpZW50V2FsbGV0QWNjZXNzUghhY2Nlc3Nlcw==');
|
'Vz');
|
||||||
|
|
||||||
@$core.Deprecated('Use listWalletAccessResponseDescriptor instead')
|
@$core.Deprecated('Use listWalletAccessResponseDescriptor instead')
|
||||||
const ListWalletAccessResponse$json = {
|
const ListWalletAccessResponse$json = {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:arbiter/features/connection/evm.dart';
|
import 'package:arbiter/features/connection/evm.dart' as evm;
|
||||||
import 'package:arbiter/proto/evm.pb.dart';
|
import 'package:arbiter/proto/evm.pb.dart';
|
||||||
import 'package:arbiter/providers/connection/connection_manager.dart';
|
import 'package:arbiter/providers/connection/connection_manager.dart';
|
||||||
|
import 'package:hooks_riverpod/experimental/mutation.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
part 'evm.g.dart';
|
part 'evm.g.dart';
|
||||||
@@ -14,7 +16,7 @@ class Evm extends _$Evm {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return listEvmWallets(connection);
|
return evm.listEvmWallets(connection);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refreshWallets() async {
|
Future<void> refreshWallets() async {
|
||||||
@@ -25,16 +27,21 @@ class Evm extends _$Evm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
state = await AsyncValue.guard(() => listEvmWallets(connection));
|
state = await AsyncValue.guard(() => evm.listEvmWallets(connection));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> createWallet() async {
|
final createEvmWallet = Mutation();
|
||||||
final connection = await ref.read(connectionManagerProvider.future);
|
|
||||||
|
Future<void> executeCreateEvmWallet(MutationTarget target) async {
|
||||||
|
return await createEvmWallet.run(target, (tsx) async {
|
||||||
|
final connection = await tsx.get(connectionManagerProvider.future);
|
||||||
if (connection == null) {
|
if (connection == null) {
|
||||||
throw Exception('Not connected to the server.');
|
throw Exception('Not connected to the server.');
|
||||||
}
|
}
|
||||||
|
|
||||||
await createEvmWallet(connection);
|
await evm.createEvmWallet(connection);
|
||||||
state = await AsyncValue.guard(() => listEvmWallets(connection));
|
|
||||||
}
|
await tsx.get(evmProvider.notifier).refreshWallets();
|
||||||
}
|
});
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ final class EvmProvider
|
|||||||
Evm create() => Evm();
|
Evm create() => Evm();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$evmHash() => r'f5d05bfa7b820d0b96026a47ca47702a3793af5d';
|
String _$evmHash() => r'ca2c9736065c5dc7cc45d8485000dd85dfbfa572';
|
||||||
|
|
||||||
abstract class _$Evm extends $AsyncNotifier<List<WalletEntry>?> {
|
abstract class _$Evm extends $AsyncNotifier<List<WalletEntry>?> {
|
||||||
FutureOr<List<WalletEntry>?> build();
|
FutureOr<List<WalletEntry>?> build();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:fixnum/fixnum.dart';
|
|||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
import 'package:hooks_riverpod/experimental/mutation.dart';
|
import 'package:hooks_riverpod/experimental/mutation.dart';
|
||||||
import 'package:mtcore/markettakers.dart';
|
import 'package:mtcore/markettakers.dart';
|
||||||
|
import 'package:protobuf/well_known_types/google/protobuf/timestamp.pb.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
part 'evm_grants.freezed.dart';
|
part 'evm_grants.freezed.dart';
|
||||||
@@ -73,14 +74,7 @@ class EvmGrants extends _$EvmGrants {
|
|||||||
|
|
||||||
Future<int> executeCreateEvmGrant(
|
Future<int> executeCreateEvmGrant(
|
||||||
MutationTarget ref, {
|
MutationTarget ref, {
|
||||||
required int clientId,
|
required SharedSettings sharedSettings,
|
||||||
required int walletId,
|
|
||||||
required Int64 chainId,
|
|
||||||
DateTime? validFrom,
|
|
||||||
DateTime? validUntil,
|
|
||||||
List<int>? maxGasFeePerGas,
|
|
||||||
List<int>? maxPriorityFeePerGas,
|
|
||||||
TransactionRateLimit? rateLimit,
|
|
||||||
required SpecificGrant specific,
|
required SpecificGrant specific,
|
||||||
}) {
|
}) {
|
||||||
return createEvmGrantMutation.run(ref, (tsx) async {
|
return createEvmGrantMutation.run(ref, (tsx) async {
|
||||||
@@ -91,14 +85,7 @@ Future<int> executeCreateEvmGrant(
|
|||||||
|
|
||||||
final grantId = await createEvmGrant(
|
final grantId = await createEvmGrant(
|
||||||
connection,
|
connection,
|
||||||
clientId: clientId,
|
sharedSettings: sharedSettings,
|
||||||
walletId: walletId,
|
|
||||||
chainId: chainId,
|
|
||||||
validFrom: validFrom,
|
|
||||||
validUntil: validUntil,
|
|
||||||
maxGasFeePerGas: maxGasFeePerGas,
|
|
||||||
maxPriorityFeePerGas: maxPriorityFeePerGas,
|
|
||||||
rateLimit: rateLimit,
|
|
||||||
specific: specific,
|
specific: specific,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
19
useragent/lib/providers/sdk_clients/details.dart
Normal file
19
useragent/lib/providers/sdk_clients/details.dart
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import 'package:arbiter/proto/user_agent.pb.dart';
|
||||||
|
import 'package:arbiter/providers/sdk_clients/list.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
part 'details.g.dart';
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
Future<SdkClientEntry?> clientDetails(Ref ref, int clientId) async {
|
||||||
|
final clients = await ref.watch(sdkClientsProvider.future);
|
||||||
|
if (clients == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (final client in clients) {
|
||||||
|
if (client.id == clientId) {
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
85
useragent/lib/providers/sdk_clients/details.g.dart
Normal file
85
useragent/lib/providers/sdk_clients/details.g.dart
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'details.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
|
||||||
|
@ProviderFor(clientDetails)
|
||||||
|
final clientDetailsProvider = ClientDetailsFamily._();
|
||||||
|
|
||||||
|
final class ClientDetailsProvider
|
||||||
|
extends
|
||||||
|
$FunctionalProvider<
|
||||||
|
AsyncValue<SdkClientEntry?>,
|
||||||
|
SdkClientEntry?,
|
||||||
|
FutureOr<SdkClientEntry?>
|
||||||
|
>
|
||||||
|
with $FutureModifier<SdkClientEntry?>, $FutureProvider<SdkClientEntry?> {
|
||||||
|
ClientDetailsProvider._({
|
||||||
|
required ClientDetailsFamily super.from,
|
||||||
|
required int super.argument,
|
||||||
|
}) : super(
|
||||||
|
retry: null,
|
||||||
|
name: r'clientDetailsProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$clientDetailsHash();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return r'clientDetailsProvider'
|
||||||
|
''
|
||||||
|
'($argument)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$FutureProviderElement<SdkClientEntry?> $createElement(
|
||||||
|
$ProviderPointer pointer,
|
||||||
|
) => $FutureProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<SdkClientEntry?> create(Ref ref) {
|
||||||
|
final argument = this.argument as int;
|
||||||
|
return clientDetails(ref, argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is ClientDetailsProvider && other.argument == argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return argument.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$clientDetailsHash() => r'21449a1a2cc4fa4e65ce761e6342e97c1d957a7a';
|
||||||
|
|
||||||
|
final class ClientDetailsFamily extends $Family
|
||||||
|
with $FunctionalFamilyOverride<FutureOr<SdkClientEntry?>, int> {
|
||||||
|
ClientDetailsFamily._()
|
||||||
|
: super(
|
||||||
|
retry: null,
|
||||||
|
name: r'clientDetailsProvider',
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
isAutoDispose: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
ClientDetailsProvider call(int clientId) =>
|
||||||
|
ClientDetailsProvider._(argument: clientId, from: this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => r'clientDetailsProvider';
|
||||||
|
}
|
||||||
@@ -1,25 +1,174 @@
|
|||||||
|
import 'package:arbiter/features/connection/evm/wallet_access.dart';
|
||||||
import 'package:arbiter/proto/user_agent.pb.dart';
|
import 'package:arbiter/proto/evm.pb.dart';
|
||||||
import 'package:arbiter/providers/connection/connection_manager.dart';
|
import 'package:arbiter/providers/connection/connection_manager.dart';
|
||||||
import 'package:mtcore/markettakers.dart';
|
import 'package:arbiter/providers/evm/evm.dart';
|
||||||
import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:hooks_riverpod/experimental/mutation.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
part 'wallet_access.g.dart';
|
part 'wallet_access.g.dart';
|
||||||
|
|
||||||
@riverpod
|
class ClientWalletOption {
|
||||||
Future<List<SdkClientWalletAccess>?> walletAccess(Ref ref) async {
|
const ClientWalletOption({required this.walletId, required this.address});
|
||||||
final connection = await ref.watch(connectionManagerProvider.future);
|
|
||||||
if (connection == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final accesses = await connection.ask(UserAgentRequest(listWalletAccess: Empty()));
|
final int walletId;
|
||||||
|
final String address;
|
||||||
|
}
|
||||||
|
|
||||||
if (accesses.hasListWalletAccessResponse()) {
|
class ClientWalletAccessState {
|
||||||
return accesses.listWalletAccessResponse.accesses.toList();
|
const ClientWalletAccessState({
|
||||||
} else {
|
this.searchQuery = '',
|
||||||
talker.warning('Received unexpected response for listWalletAccess: $accesses');
|
this.originalWalletIds = const {},
|
||||||
return null;
|
this.selectedWalletIds = const {},
|
||||||
|
});
|
||||||
|
|
||||||
|
final String searchQuery;
|
||||||
|
final Set<int> originalWalletIds;
|
||||||
|
final Set<int> selectedWalletIds;
|
||||||
|
|
||||||
|
bool get hasChanges => !setEquals(originalWalletIds, selectedWalletIds);
|
||||||
|
|
||||||
|
ClientWalletAccessState copyWith({
|
||||||
|
String? searchQuery,
|
||||||
|
Set<int>? originalWalletIds,
|
||||||
|
Set<int>? selectedWalletIds,
|
||||||
|
}) {
|
||||||
|
return ClientWalletAccessState(
|
||||||
|
searchQuery: searchQuery ?? this.searchQuery,
|
||||||
|
originalWalletIds: originalWalletIds ?? this.originalWalletIds,
|
||||||
|
selectedWalletIds: selectedWalletIds ?? this.selectedWalletIds,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final saveClientWalletAccessMutation = Mutation<void>();
|
||||||
|
|
||||||
|
abstract class ClientWalletAccessRepository {
|
||||||
|
Future<Set<int>> fetchSelectedWalletIds(int clientId);
|
||||||
|
Future<void> saveSelectedWalletIds(int clientId, Set<int> walletIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServerClientWalletAccessRepository
|
||||||
|
implements ClientWalletAccessRepository {
|
||||||
|
ServerClientWalletAccessRepository(this.ref);
|
||||||
|
|
||||||
|
final Ref ref;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Set<int>> fetchSelectedWalletIds(int clientId) async {
|
||||||
|
final connection = await ref.read(connectionManagerProvider.future);
|
||||||
|
if (connection == null) {
|
||||||
|
throw Exception('Not connected to the server.');
|
||||||
|
}
|
||||||
|
return readClientWalletAccess(connection, clientId: clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveSelectedWalletIds(int clientId, Set<int> walletIds) async {
|
||||||
|
final connection = await ref.read(connectionManagerProvider.future);
|
||||||
|
if (connection == null) {
|
||||||
|
throw Exception('Not connected to the server.');
|
||||||
|
}
|
||||||
|
await writeClientWalletAccess(
|
||||||
|
connection,
|
||||||
|
clientId: clientId,
|
||||||
|
walletIds: walletIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
ClientWalletAccessRepository clientWalletAccessRepository(Ref ref) {
|
||||||
|
return ServerClientWalletAccessRepository(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
Future<List<ClientWalletOption>> clientWalletOptions(Ref ref) async {
|
||||||
|
final wallets = await ref.watch(evmProvider.future) ?? const <WalletEntry>[];
|
||||||
|
return [
|
||||||
|
for (var index = 0; index < wallets.length; index++)
|
||||||
|
ClientWalletOption(
|
||||||
|
walletId: index + 1,
|
||||||
|
address: formatWalletAddress(wallets[index].address),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
Future<Set<int>> clientWalletAccessSelection(Ref ref, int clientId) async {
|
||||||
|
final repository = ref.watch(clientWalletAccessRepositoryProvider);
|
||||||
|
return repository.fetchSelectedWalletIds(clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
class ClientWalletAccessController extends _$ClientWalletAccessController {
|
||||||
|
@override
|
||||||
|
ClientWalletAccessState build(int clientId) {
|
||||||
|
final selection = ref.read(clientWalletAccessSelectionProvider(clientId));
|
||||||
|
|
||||||
|
void sync(AsyncValue<Set<int>> value) {
|
||||||
|
value.when(data: hydrate, error: (_, _) {}, loading: () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
ref.listen<AsyncValue<Set<int>>>(
|
||||||
|
clientWalletAccessSelectionProvider(clientId),
|
||||||
|
(_, next) => sync(next),
|
||||||
|
);
|
||||||
|
return selection.when(
|
||||||
|
data: (walletIds) => ClientWalletAccessState(
|
||||||
|
originalWalletIds: Set.of(walletIds),
|
||||||
|
selectedWalletIds: Set.of(walletIds),
|
||||||
|
),
|
||||||
|
error: (error, _) => const ClientWalletAccessState(),
|
||||||
|
loading: () => const ClientWalletAccessState(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void hydrate(Set<int> selectedWalletIds) {
|
||||||
|
state = state.copyWith(
|
||||||
|
originalWalletIds: Set.of(selectedWalletIds),
|
||||||
|
selectedWalletIds: Set.of(selectedWalletIds),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSearchQuery(String value) {
|
||||||
|
state = state.copyWith(searchQuery: value);
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleWallet(int walletId) {
|
||||||
|
final next = Set<int>.of(state.selectedWalletIds);
|
||||||
|
if (!next.add(walletId)) {
|
||||||
|
next.remove(walletId);
|
||||||
|
}
|
||||||
|
state = state.copyWith(selectedWalletIds: next);
|
||||||
|
}
|
||||||
|
|
||||||
|
void discardChanges() {
|
||||||
|
state = state.copyWith(selectedWalletIds: Set.of(state.originalWalletIds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> executeSaveClientWalletAccess(
|
||||||
|
MutationTarget ref, {
|
||||||
|
required int clientId,
|
||||||
|
}) {
|
||||||
|
final mutation = saveClientWalletAccessMutation(clientId);
|
||||||
|
return mutation.run(ref, (tsx) async {
|
||||||
|
final repository = tsx.get(clientWalletAccessRepositoryProvider);
|
||||||
|
final controller = tsx.get(
|
||||||
|
clientWalletAccessControllerProvider(clientId).notifier,
|
||||||
|
);
|
||||||
|
final selectedWalletIds = tsx
|
||||||
|
.get(clientWalletAccessControllerProvider(clientId))
|
||||||
|
.selectedWalletIds;
|
||||||
|
await repository.saveSelectedWalletIds(clientId, selectedWalletIds);
|
||||||
|
controller.hydrate(selectedWalletIds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String formatWalletAddress(List<int> bytes) {
|
||||||
|
final hex = bytes
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
return '0x$hex';
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,43 +9,272 @@ part of 'wallet_access.dart';
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
// ignore_for_file: type=lint, type=warning
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
|
||||||
@ProviderFor(walletAccess)
|
@ProviderFor(clientWalletAccessRepository)
|
||||||
final walletAccessProvider = WalletAccessProvider._();
|
final clientWalletAccessRepositoryProvider =
|
||||||
|
ClientWalletAccessRepositoryProvider._();
|
||||||
|
|
||||||
final class WalletAccessProvider
|
final class ClientWalletAccessRepositoryProvider
|
||||||
extends
|
extends
|
||||||
$FunctionalProvider<
|
$FunctionalProvider<
|
||||||
AsyncValue<List<SdkClientWalletAccess>?>,
|
ClientWalletAccessRepository,
|
||||||
List<SdkClientWalletAccess>?,
|
ClientWalletAccessRepository,
|
||||||
FutureOr<List<SdkClientWalletAccess>?>
|
ClientWalletAccessRepository
|
||||||
>
|
>
|
||||||
with
|
with $Provider<ClientWalletAccessRepository> {
|
||||||
$FutureModifier<List<SdkClientWalletAccess>?>,
|
ClientWalletAccessRepositoryProvider._()
|
||||||
$FutureProvider<List<SdkClientWalletAccess>?> {
|
|
||||||
WalletAccessProvider._()
|
|
||||||
: super(
|
: super(
|
||||||
from: null,
|
from: null,
|
||||||
argument: null,
|
argument: null,
|
||||||
retry: null,
|
retry: null,
|
||||||
name: r'walletAccessProvider',
|
name: r'clientWalletAccessRepositoryProvider',
|
||||||
isAutoDispose: true,
|
isAutoDispose: true,
|
||||||
dependencies: null,
|
dependencies: null,
|
||||||
$allTransitiveDependencies: null,
|
$allTransitiveDependencies: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String debugGetCreateSourceHash() => _$walletAccessHash();
|
String debugGetCreateSourceHash() => _$clientWalletAccessRepositoryHash();
|
||||||
|
|
||||||
@$internal
|
@$internal
|
||||||
@override
|
@override
|
||||||
$FutureProviderElement<List<SdkClientWalletAccess>?> $createElement(
|
$ProviderElement<ClientWalletAccessRepository> $createElement(
|
||||||
|
$ProviderPointer pointer,
|
||||||
|
) => $ProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ClientWalletAccessRepository create(Ref ref) {
|
||||||
|
return clientWalletAccessRepository(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(ClientWalletAccessRepository value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<ClientWalletAccessRepository>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$clientWalletAccessRepositoryHash() =>
|
||||||
|
r'bbc332284bc36a8b5d807bd5c45362b6b12b19e7';
|
||||||
|
|
||||||
|
@ProviderFor(clientWalletOptions)
|
||||||
|
final clientWalletOptionsProvider = ClientWalletOptionsProvider._();
|
||||||
|
|
||||||
|
final class ClientWalletOptionsProvider
|
||||||
|
extends
|
||||||
|
$FunctionalProvider<
|
||||||
|
AsyncValue<List<ClientWalletOption>>,
|
||||||
|
List<ClientWalletOption>,
|
||||||
|
FutureOr<List<ClientWalletOption>>
|
||||||
|
>
|
||||||
|
with
|
||||||
|
$FutureModifier<List<ClientWalletOption>>,
|
||||||
|
$FutureProvider<List<ClientWalletOption>> {
|
||||||
|
ClientWalletOptionsProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'clientWalletOptionsProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$clientWalletOptionsHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$FutureProviderElement<List<ClientWalletOption>> $createElement(
|
||||||
$ProviderPointer pointer,
|
$ProviderPointer pointer,
|
||||||
) => $FutureProviderElement(pointer);
|
) => $FutureProviderElement(pointer);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
FutureOr<List<SdkClientWalletAccess>?> create(Ref ref) {
|
FutureOr<List<ClientWalletOption>> create(Ref ref) {
|
||||||
return walletAccess(ref);
|
return clientWalletOptions(ref);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$walletAccessHash() => r'954aae12d2d18999efaa97d01be983bf849f2296';
|
String _$clientWalletOptionsHash() =>
|
||||||
|
r'32183c2b281e2a41400de07f2381132a706815ab';
|
||||||
|
|
||||||
|
@ProviderFor(clientWalletAccessSelection)
|
||||||
|
final clientWalletAccessSelectionProvider =
|
||||||
|
ClientWalletAccessSelectionFamily._();
|
||||||
|
|
||||||
|
final class ClientWalletAccessSelectionProvider
|
||||||
|
extends
|
||||||
|
$FunctionalProvider<AsyncValue<Set<int>>, Set<int>, FutureOr<Set<int>>>
|
||||||
|
with $FutureModifier<Set<int>>, $FutureProvider<Set<int>> {
|
||||||
|
ClientWalletAccessSelectionProvider._({
|
||||||
|
required ClientWalletAccessSelectionFamily super.from,
|
||||||
|
required int super.argument,
|
||||||
|
}) : super(
|
||||||
|
retry: null,
|
||||||
|
name: r'clientWalletAccessSelectionProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$clientWalletAccessSelectionHash();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return r'clientWalletAccessSelectionProvider'
|
||||||
|
''
|
||||||
|
'($argument)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$FutureProviderElement<Set<int>> $createElement($ProviderPointer pointer) =>
|
||||||
|
$FutureProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<Set<int>> create(Ref ref) {
|
||||||
|
final argument = this.argument as int;
|
||||||
|
return clientWalletAccessSelection(ref, argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is ClientWalletAccessSelectionProvider &&
|
||||||
|
other.argument == argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return argument.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$clientWalletAccessSelectionHash() =>
|
||||||
|
r'f33705ee7201cd9b899cc058d6642de85a22b03e';
|
||||||
|
|
||||||
|
final class ClientWalletAccessSelectionFamily extends $Family
|
||||||
|
with $FunctionalFamilyOverride<FutureOr<Set<int>>, int> {
|
||||||
|
ClientWalletAccessSelectionFamily._()
|
||||||
|
: super(
|
||||||
|
retry: null,
|
||||||
|
name: r'clientWalletAccessSelectionProvider',
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
isAutoDispose: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
ClientWalletAccessSelectionProvider call(int clientId) =>
|
||||||
|
ClientWalletAccessSelectionProvider._(argument: clientId, from: this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => r'clientWalletAccessSelectionProvider';
|
||||||
|
}
|
||||||
|
|
||||||
|
@ProviderFor(ClientWalletAccessController)
|
||||||
|
final clientWalletAccessControllerProvider =
|
||||||
|
ClientWalletAccessControllerFamily._();
|
||||||
|
|
||||||
|
final class ClientWalletAccessControllerProvider
|
||||||
|
extends
|
||||||
|
$NotifierProvider<
|
||||||
|
ClientWalletAccessController,
|
||||||
|
ClientWalletAccessState
|
||||||
|
> {
|
||||||
|
ClientWalletAccessControllerProvider._({
|
||||||
|
required ClientWalletAccessControllerFamily super.from,
|
||||||
|
required int super.argument,
|
||||||
|
}) : super(
|
||||||
|
retry: null,
|
||||||
|
name: r'clientWalletAccessControllerProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$clientWalletAccessControllerHash();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return r'clientWalletAccessControllerProvider'
|
||||||
|
''
|
||||||
|
'($argument)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
ClientWalletAccessController create() => ClientWalletAccessController();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(ClientWalletAccessState value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<ClientWalletAccessState>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is ClientWalletAccessControllerProvider &&
|
||||||
|
other.argument == argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return argument.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$clientWalletAccessControllerHash() =>
|
||||||
|
r'45bff81382fec3e8610190167b55667a7dfc1111';
|
||||||
|
|
||||||
|
final class ClientWalletAccessControllerFamily extends $Family
|
||||||
|
with
|
||||||
|
$ClassFamilyOverride<
|
||||||
|
ClientWalletAccessController,
|
||||||
|
ClientWalletAccessState,
|
||||||
|
ClientWalletAccessState,
|
||||||
|
ClientWalletAccessState,
|
||||||
|
int
|
||||||
|
> {
|
||||||
|
ClientWalletAccessControllerFamily._()
|
||||||
|
: super(
|
||||||
|
retry: null,
|
||||||
|
name: r'clientWalletAccessControllerProvider',
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
isAutoDispose: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
ClientWalletAccessControllerProvider call(int clientId) =>
|
||||||
|
ClientWalletAccessControllerProvider._(argument: clientId, from: this);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => r'clientWalletAccessControllerProvider';
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _$ClientWalletAccessController
|
||||||
|
extends $Notifier<ClientWalletAccessState> {
|
||||||
|
late final _$args = ref.$arg as int;
|
||||||
|
int get clientId => _$args;
|
||||||
|
|
||||||
|
ClientWalletAccessState build(int clientId);
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final ref =
|
||||||
|
this.ref as $Ref<ClientWalletAccessState, ClientWalletAccessState>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<ClientWalletAccessState, ClientWalletAccessState>,
|
||||||
|
ClientWalletAccessState,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleCreate(ref, () => build(_$args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
22
useragent/lib/providers/sdk_clients/wallet_access_list.dart
Normal file
22
useragent/lib/providers/sdk_clients/wallet_access_list.dart
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import 'package:arbiter/features/connection/evm/wallet_access.dart';
|
||||||
|
import 'package:arbiter/proto/user_agent.pb.dart';
|
||||||
|
import 'package:arbiter/providers/connection/connection_manager.dart';
|
||||||
|
import 'package:mtcore/markettakers.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
part 'wallet_access_list.g.dart';
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
Future<List<SdkClientWalletAccess>?> walletAccessList(Ref ref) async {
|
||||||
|
final connection = await ref.watch(connectionManagerProvider.future);
|
||||||
|
if (connection == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await listAllWalletAccesses(connection);
|
||||||
|
} catch (e, st) {
|
||||||
|
talker.handle(e, st);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'wallet_access_list.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
|
||||||
|
@ProviderFor(walletAccessList)
|
||||||
|
final walletAccessListProvider = WalletAccessListProvider._();
|
||||||
|
|
||||||
|
final class WalletAccessListProvider
|
||||||
|
extends
|
||||||
|
$FunctionalProvider<
|
||||||
|
AsyncValue<List<SdkClientWalletAccess>?>,
|
||||||
|
List<SdkClientWalletAccess>?,
|
||||||
|
FutureOr<List<SdkClientWalletAccess>?>
|
||||||
|
>
|
||||||
|
with
|
||||||
|
$FutureModifier<List<SdkClientWalletAccess>?>,
|
||||||
|
$FutureProvider<List<SdkClientWalletAccess>?> {
|
||||||
|
WalletAccessListProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'walletAccessListProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$walletAccessListHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$FutureProviderElement<List<SdkClientWalletAccess>?> $createElement(
|
||||||
|
$ProviderPointer pointer,
|
||||||
|
) => $FutureProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<List<SdkClientWalletAccess>?> create(Ref ref) {
|
||||||
|
return walletAccessList(ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$walletAccessListHash() => r'c06006d6792ae463105a539723e9bb396192f96b';
|
||||||
@@ -10,6 +10,7 @@ class Router extends RootStackRouter {
|
|||||||
AutoRoute(page: ServerInfoSetupRoute.page, path: '/server-info'),
|
AutoRoute(page: ServerInfoSetupRoute.page, path: '/server-info'),
|
||||||
AutoRoute(page: ServerConnectionRoute.page, path: '/server-connection'),
|
AutoRoute(page: ServerConnectionRoute.page, path: '/server-connection'),
|
||||||
AutoRoute(page: VaultSetupRoute.page, path: '/vault'),
|
AutoRoute(page: VaultSetupRoute.page, path: '/vault'),
|
||||||
|
AutoRoute(page: ClientDetailsRoute.page, path: '/clients/:clientId'),
|
||||||
AutoRoute(page: CreateEvmGrantRoute.page, path: '/evm-grants/create'),
|
AutoRoute(page: CreateEvmGrantRoute.page, path: '/evm-grants/create'),
|
||||||
|
|
||||||
AutoRoute(
|
AutoRoute(
|
||||||
@@ -18,6 +19,7 @@ class Router extends RootStackRouter {
|
|||||||
children: [
|
children: [
|
||||||
AutoRoute(page: EvmRoute.page, path: 'evm'),
|
AutoRoute(page: EvmRoute.page, path: 'evm'),
|
||||||
AutoRoute(page: ClientsRoute.page, path: 'clients'),
|
AutoRoute(page: ClientsRoute.page, path: 'clients'),
|
||||||
|
AutoRoute(page: EvmGrantsRoute.page, path: 'grants'),
|
||||||
AutoRoute(page: AboutRoute.page, path: 'about'),
|
AutoRoute(page: AboutRoute.page, path: 'about'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,29 +9,32 @@
|
|||||||
// coverage:ignore-file
|
// coverage:ignore-file
|
||||||
|
|
||||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
import 'package:arbiter/proto/user_agent.pb.dart' as _i13;
|
import 'package:arbiter/proto/user_agent.pb.dart' as _i15;
|
||||||
import 'package:arbiter/screens/bootstrap.dart' as _i2;
|
import 'package:arbiter/screens/bootstrap.dart' as _i2;
|
||||||
import 'package:arbiter/screens/dashboard.dart' as _i6;
|
import 'package:arbiter/screens/dashboard.dart' as _i7;
|
||||||
import 'package:arbiter/screens/dashboard/about.dart' as _i1;
|
import 'package:arbiter/screens/dashboard/about.dart' as _i1;
|
||||||
import 'package:arbiter/screens/dashboard/clients/details.dart' as _i3;
|
import 'package:arbiter/screens/dashboard/clients/details.dart' as _i3;
|
||||||
import 'package:arbiter/screens/dashboard/clients/table.dart' as _i4;
|
import 'package:arbiter/screens/dashboard/clients/details/client_details.dart'
|
||||||
import 'package:arbiter/screens/dashboard/evm/evm.dart' as _i7;
|
as _i4;
|
||||||
import 'package:arbiter/screens/dashboard/evm/grants/grant_create.dart' as _i5;
|
import 'package:arbiter/screens/dashboard/clients/table.dart' as _i5;
|
||||||
import 'package:arbiter/screens/server_connection.dart' as _i8;
|
import 'package:arbiter/screens/dashboard/evm/evm.dart' as _i9;
|
||||||
import 'package:arbiter/screens/server_info_setup.dart' as _i9;
|
import 'package:arbiter/screens/dashboard/evm/grants/grant_create.dart' as _i6;
|
||||||
import 'package:arbiter/screens/vault_setup.dart' as _i10;
|
import 'package:arbiter/screens/dashboard/evm/grants/grants.dart' as _i8;
|
||||||
import 'package:auto_route/auto_route.dart' as _i11;
|
import 'package:arbiter/screens/server_connection.dart' as _i10;
|
||||||
import 'package:flutter/material.dart' as _i12;
|
import 'package:arbiter/screens/server_info_setup.dart' as _i11;
|
||||||
|
import 'package:arbiter/screens/vault_setup.dart' as _i12;
|
||||||
|
import 'package:auto_route/auto_route.dart' as _i13;
|
||||||
|
import 'package:flutter/material.dart' as _i14;
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i1.AboutScreen]
|
/// [_i1.AboutScreen]
|
||||||
class AboutRoute extends _i11.PageRouteInfo<void> {
|
class AboutRoute extends _i13.PageRouteInfo<void> {
|
||||||
const AboutRoute({List<_i11.PageRouteInfo>? children})
|
const AboutRoute({List<_i13.PageRouteInfo>? children})
|
||||||
: super(AboutRoute.name, initialChildren: children);
|
: super(AboutRoute.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'AboutRoute';
|
static const String name = 'AboutRoute';
|
||||||
|
|
||||||
static _i11.PageInfo page = _i11.PageInfo(
|
static _i13.PageInfo page = _i13.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i1.AboutScreen();
|
return const _i1.AboutScreen();
|
||||||
@@ -41,13 +44,13 @@ class AboutRoute extends _i11.PageRouteInfo<void> {
|
|||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i2.Bootstrap]
|
/// [_i2.Bootstrap]
|
||||||
class Bootstrap extends _i11.PageRouteInfo<void> {
|
class Bootstrap extends _i13.PageRouteInfo<void> {
|
||||||
const Bootstrap({List<_i11.PageRouteInfo>? children})
|
const Bootstrap({List<_i13.PageRouteInfo>? children})
|
||||||
: super(Bootstrap.name, initialChildren: children);
|
: super(Bootstrap.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'Bootstrap';
|
static const String name = 'Bootstrap';
|
||||||
|
|
||||||
static _i11.PageInfo page = _i11.PageInfo(
|
static _i13.PageInfo page = _i13.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i2.Bootstrap();
|
return const _i2.Bootstrap();
|
||||||
@@ -57,11 +60,11 @@ class Bootstrap extends _i11.PageRouteInfo<void> {
|
|||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i3.ClientDetails]
|
/// [_i3.ClientDetails]
|
||||||
class ClientDetails extends _i11.PageRouteInfo<ClientDetailsArgs> {
|
class ClientDetails extends _i13.PageRouteInfo<ClientDetailsArgs> {
|
||||||
ClientDetails({
|
ClientDetails({
|
||||||
_i12.Key? key,
|
_i14.Key? key,
|
||||||
required _i13.SdkClientEntry client,
|
required _i15.SdkClientEntry client,
|
||||||
List<_i11.PageRouteInfo>? children,
|
List<_i13.PageRouteInfo>? children,
|
||||||
}) : super(
|
}) : super(
|
||||||
ClientDetails.name,
|
ClientDetails.name,
|
||||||
args: ClientDetailsArgs(key: key, client: client),
|
args: ClientDetailsArgs(key: key, client: client),
|
||||||
@@ -70,7 +73,7 @@ class ClientDetails extends _i11.PageRouteInfo<ClientDetailsArgs> {
|
|||||||
|
|
||||||
static const String name = 'ClientDetails';
|
static const String name = 'ClientDetails';
|
||||||
|
|
||||||
static _i11.PageInfo page = _i11.PageInfo(
|
static _i13.PageInfo page = _i13.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
final args = data.argsAs<ClientDetailsArgs>();
|
final args = data.argsAs<ClientDetailsArgs>();
|
||||||
@@ -82,9 +85,9 @@ class ClientDetails extends _i11.PageRouteInfo<ClientDetailsArgs> {
|
|||||||
class ClientDetailsArgs {
|
class ClientDetailsArgs {
|
||||||
const ClientDetailsArgs({this.key, required this.client});
|
const ClientDetailsArgs({this.key, required this.client});
|
||||||
|
|
||||||
final _i12.Key? key;
|
final _i14.Key? key;
|
||||||
|
|
||||||
final _i13.SdkClientEntry client;
|
final _i15.SdkClientEntry client;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
@@ -103,77 +106,145 @@ class ClientDetailsArgs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i4.ClientsScreen]
|
/// [_i4.ClientDetailsScreen]
|
||||||
class ClientsRoute extends _i11.PageRouteInfo<void> {
|
class ClientDetailsRoute extends _i13.PageRouteInfo<ClientDetailsRouteArgs> {
|
||||||
const ClientsRoute({List<_i11.PageRouteInfo>? children})
|
ClientDetailsRoute({
|
||||||
|
_i14.Key? key,
|
||||||
|
required int clientId,
|
||||||
|
List<_i13.PageRouteInfo>? children,
|
||||||
|
}) : super(
|
||||||
|
ClientDetailsRoute.name,
|
||||||
|
args: ClientDetailsRouteArgs(key: key, clientId: clientId),
|
||||||
|
rawPathParams: {'clientId': clientId},
|
||||||
|
initialChildren: children,
|
||||||
|
);
|
||||||
|
|
||||||
|
static const String name = 'ClientDetailsRoute';
|
||||||
|
|
||||||
|
static _i13.PageInfo page = _i13.PageInfo(
|
||||||
|
name,
|
||||||
|
builder: (data) {
|
||||||
|
final pathParams = data.inheritedPathParams;
|
||||||
|
final args = data.argsAs<ClientDetailsRouteArgs>(
|
||||||
|
orElse: () =>
|
||||||
|
ClientDetailsRouteArgs(clientId: pathParams.getInt('clientId')),
|
||||||
|
);
|
||||||
|
return _i4.ClientDetailsScreen(key: args.key, clientId: args.clientId);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ClientDetailsRouteArgs {
|
||||||
|
const ClientDetailsRouteArgs({this.key, required this.clientId});
|
||||||
|
|
||||||
|
final _i14.Key? key;
|
||||||
|
|
||||||
|
final int clientId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'ClientDetailsRouteArgs{key: $key, clientId: $clientId}';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
if (other is! ClientDetailsRouteArgs) return false;
|
||||||
|
return key == other.key && clientId == other.clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => key.hashCode ^ clientId.hashCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [_i5.ClientsScreen]
|
||||||
|
class ClientsRoute extends _i13.PageRouteInfo<void> {
|
||||||
|
const ClientsRoute({List<_i13.PageRouteInfo>? children})
|
||||||
: super(ClientsRoute.name, initialChildren: children);
|
: super(ClientsRoute.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'ClientsRoute';
|
static const String name = 'ClientsRoute';
|
||||||
|
|
||||||
static _i11.PageInfo page = _i11.PageInfo(
|
static _i13.PageInfo page = _i13.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i4.ClientsScreen();
|
return const _i5.ClientsScreen();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i5.CreateEvmGrantScreen]
|
/// [_i6.CreateEvmGrantScreen]
|
||||||
class CreateEvmGrantRoute extends _i11.PageRouteInfo<void> {
|
class CreateEvmGrantRoute extends _i13.PageRouteInfo<void> {
|
||||||
const CreateEvmGrantRoute({List<_i11.PageRouteInfo>? children})
|
const CreateEvmGrantRoute({List<_i13.PageRouteInfo>? children})
|
||||||
: super(CreateEvmGrantRoute.name, initialChildren: children);
|
: super(CreateEvmGrantRoute.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'CreateEvmGrantRoute';
|
static const String name = 'CreateEvmGrantRoute';
|
||||||
|
|
||||||
static _i11.PageInfo page = _i11.PageInfo(
|
static _i13.PageInfo page = _i13.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i5.CreateEvmGrantScreen();
|
return const _i6.CreateEvmGrantScreen();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i6.DashboardRouter]
|
/// [_i7.DashboardRouter]
|
||||||
class DashboardRouter extends _i11.PageRouteInfo<void> {
|
class DashboardRouter extends _i13.PageRouteInfo<void> {
|
||||||
const DashboardRouter({List<_i11.PageRouteInfo>? children})
|
const DashboardRouter({List<_i13.PageRouteInfo>? children})
|
||||||
: super(DashboardRouter.name, initialChildren: children);
|
: super(DashboardRouter.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'DashboardRouter';
|
static const String name = 'DashboardRouter';
|
||||||
|
|
||||||
static _i11.PageInfo page = _i11.PageInfo(
|
static _i13.PageInfo page = _i13.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i6.DashboardRouter();
|
return const _i7.DashboardRouter();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i7.EvmScreen]
|
/// [_i8.EvmGrantsScreen]
|
||||||
class EvmRoute extends _i11.PageRouteInfo<void> {
|
class EvmGrantsRoute extends _i13.PageRouteInfo<void> {
|
||||||
const EvmRoute({List<_i11.PageRouteInfo>? children})
|
const EvmGrantsRoute({List<_i13.PageRouteInfo>? children})
|
||||||
|
: super(EvmGrantsRoute.name, initialChildren: children);
|
||||||
|
|
||||||
|
static const String name = 'EvmGrantsRoute';
|
||||||
|
|
||||||
|
static _i13.PageInfo page = _i13.PageInfo(
|
||||||
|
name,
|
||||||
|
builder: (data) {
|
||||||
|
return const _i8.EvmGrantsScreen();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// generated route for
|
||||||
|
/// [_i9.EvmScreen]
|
||||||
|
class EvmRoute extends _i13.PageRouteInfo<void> {
|
||||||
|
const EvmRoute({List<_i13.PageRouteInfo>? children})
|
||||||
: super(EvmRoute.name, initialChildren: children);
|
: super(EvmRoute.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'EvmRoute';
|
static const String name = 'EvmRoute';
|
||||||
|
|
||||||
static _i11.PageInfo page = _i11.PageInfo(
|
static _i13.PageInfo page = _i13.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i7.EvmScreen();
|
return const _i9.EvmScreen();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i8.ServerConnectionScreen]
|
/// [_i10.ServerConnectionScreen]
|
||||||
class ServerConnectionRoute
|
class ServerConnectionRoute
|
||||||
extends _i11.PageRouteInfo<ServerConnectionRouteArgs> {
|
extends _i13.PageRouteInfo<ServerConnectionRouteArgs> {
|
||||||
ServerConnectionRoute({
|
ServerConnectionRoute({
|
||||||
_i12.Key? key,
|
_i14.Key? key,
|
||||||
String? arbiterUrl,
|
String? arbiterUrl,
|
||||||
List<_i11.PageRouteInfo>? children,
|
List<_i13.PageRouteInfo>? children,
|
||||||
}) : super(
|
}) : super(
|
||||||
ServerConnectionRoute.name,
|
ServerConnectionRoute.name,
|
||||||
args: ServerConnectionRouteArgs(key: key, arbiterUrl: arbiterUrl),
|
args: ServerConnectionRouteArgs(key: key, arbiterUrl: arbiterUrl),
|
||||||
@@ -182,13 +253,13 @@ class ServerConnectionRoute
|
|||||||
|
|
||||||
static const String name = 'ServerConnectionRoute';
|
static const String name = 'ServerConnectionRoute';
|
||||||
|
|
||||||
static _i11.PageInfo page = _i11.PageInfo(
|
static _i13.PageInfo page = _i13.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
final args = data.argsAs<ServerConnectionRouteArgs>(
|
final args = data.argsAs<ServerConnectionRouteArgs>(
|
||||||
orElse: () => const ServerConnectionRouteArgs(),
|
orElse: () => const ServerConnectionRouteArgs(),
|
||||||
);
|
);
|
||||||
return _i8.ServerConnectionScreen(
|
return _i10.ServerConnectionScreen(
|
||||||
key: args.key,
|
key: args.key,
|
||||||
arbiterUrl: args.arbiterUrl,
|
arbiterUrl: args.arbiterUrl,
|
||||||
);
|
);
|
||||||
@@ -199,7 +270,7 @@ class ServerConnectionRoute
|
|||||||
class ServerConnectionRouteArgs {
|
class ServerConnectionRouteArgs {
|
||||||
const ServerConnectionRouteArgs({this.key, this.arbiterUrl});
|
const ServerConnectionRouteArgs({this.key, this.arbiterUrl});
|
||||||
|
|
||||||
final _i12.Key? key;
|
final _i14.Key? key;
|
||||||
|
|
||||||
final String? arbiterUrl;
|
final String? arbiterUrl;
|
||||||
|
|
||||||
@@ -220,33 +291,33 @@ class ServerConnectionRouteArgs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i9.ServerInfoSetupScreen]
|
/// [_i11.ServerInfoSetupScreen]
|
||||||
class ServerInfoSetupRoute extends _i11.PageRouteInfo<void> {
|
class ServerInfoSetupRoute extends _i13.PageRouteInfo<void> {
|
||||||
const ServerInfoSetupRoute({List<_i11.PageRouteInfo>? children})
|
const ServerInfoSetupRoute({List<_i13.PageRouteInfo>? children})
|
||||||
: super(ServerInfoSetupRoute.name, initialChildren: children);
|
: super(ServerInfoSetupRoute.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'ServerInfoSetupRoute';
|
static const String name = 'ServerInfoSetupRoute';
|
||||||
|
|
||||||
static _i11.PageInfo page = _i11.PageInfo(
|
static _i13.PageInfo page = _i13.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i9.ServerInfoSetupScreen();
|
return const _i11.ServerInfoSetupScreen();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// generated route for
|
/// generated route for
|
||||||
/// [_i10.VaultSetupScreen]
|
/// [_i12.VaultSetupScreen]
|
||||||
class VaultSetupRoute extends _i11.PageRouteInfo<void> {
|
class VaultSetupRoute extends _i13.PageRouteInfo<void> {
|
||||||
const VaultSetupRoute({List<_i11.PageRouteInfo>? children})
|
const VaultSetupRoute({List<_i13.PageRouteInfo>? children})
|
||||||
: super(VaultSetupRoute.name, initialChildren: children);
|
: super(VaultSetupRoute.name, initialChildren: children);
|
||||||
|
|
||||||
static const String name = 'VaultSetupRoute';
|
static const String name = 'VaultSetupRoute';
|
||||||
|
|
||||||
static _i11.PageInfo page = _i11.PageInfo(
|
static _i13.PageInfo page = _i13.PageInfo(
|
||||||
name,
|
name,
|
||||||
builder: (data) {
|
builder: (data) {
|
||||||
return const _i10.VaultSetupScreen();
|
return const _i12.VaultSetupScreen();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
|
|
||||||
const breakpoints = MaterialAdaptiveBreakpoints();
|
const breakpoints = MaterialAdaptiveBreakpoints();
|
||||||
|
|
||||||
final routes = [const EvmRoute(), const ClientsRoute(), const AboutRoute()];
|
final routes = [
|
||||||
|
const EvmRoute(),
|
||||||
|
const ClientsRoute(),
|
||||||
|
const EvmGrantsRoute(),
|
||||||
|
const AboutRoute(),
|
||||||
|
];
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
class DashboardRouter extends StatelessWidget {
|
class DashboardRouter extends StatelessWidget {
|
||||||
@@ -38,6 +43,11 @@ class DashboardRouter extends StatelessWidget {
|
|||||||
selectedIcon: Icon(Icons.devices_other),
|
selectedIcon: Icon(Icons.devices_other),
|
||||||
label: "Clients",
|
label: "Clients",
|
||||||
),
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.policy_outlined),
|
||||||
|
selectedIcon: Icon(Icons.policy),
|
||||||
|
label: "Grants",
|
||||||
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.info_outline),
|
icon: Icon(Icons.info_outline),
|
||||||
selectedIcon: Icon(Icons.info),
|
selectedIcon: Icon(Icons.info),
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import 'package:arbiter/providers/sdk_clients/details.dart';
|
||||||
|
import 'package:arbiter/proto/user_agent.pb.dart';
|
||||||
|
import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_content.dart';
|
||||||
|
import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_state_panel.dart';
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
@RoutePage()
|
||||||
|
class ClientDetailsScreen extends ConsumerWidget {
|
||||||
|
const ClientDetailsScreen({super.key, @pathParam required this.clientId});
|
||||||
|
|
||||||
|
final int clientId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final clientAsync = ref.watch(clientDetailsProvider(clientId));
|
||||||
|
return Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: clientAsync.when(
|
||||||
|
data: (client) =>
|
||||||
|
_ClientDetailsState(clientId: clientId, client: client),
|
||||||
|
error: (error, _) => ClientDetailsStatePanel(
|
||||||
|
title: 'Client unavailable',
|
||||||
|
body: error.toString(),
|
||||||
|
icon: Icons.sync_problem,
|
||||||
|
),
|
||||||
|
loading: () => const ClientDetailsStatePanel(
|
||||||
|
title: 'Loading client',
|
||||||
|
body: 'Pulling client details from Arbiter.',
|
||||||
|
icon: Icons.hourglass_top,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ClientDetailsState extends StatelessWidget {
|
||||||
|
const _ClientDetailsState({required this.clientId, required this.client});
|
||||||
|
|
||||||
|
final int clientId;
|
||||||
|
final SdkClientEntry? client;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (client == null) {
|
||||||
|
return const ClientDetailsStatePanel(
|
||||||
|
title: 'Client not found',
|
||||||
|
body: 'The selected SDK client is no longer available.',
|
||||||
|
icon: Icons.person_off_outlined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ClientDetailsContent(clientId: clientId, client: client!);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import 'package:arbiter/proto/user_agent.pb.dart';
|
||||||
|
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
|
||||||
|
import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_header.dart';
|
||||||
|
import 'package:arbiter/screens/dashboard/clients/details/widgets/client_summary_card.dart';
|
||||||
|
import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_save_bar.dart';
|
||||||
|
import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_section.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/experimental/mutation.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
class ClientDetailsContent extends ConsumerWidget {
|
||||||
|
const ClientDetailsContent({
|
||||||
|
super.key,
|
||||||
|
required this.clientId,
|
||||||
|
required this.client,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int clientId;
|
||||||
|
final SdkClientEntry client;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final state = ref.watch(clientWalletAccessControllerProvider(clientId));
|
||||||
|
final notifier = ref.read(
|
||||||
|
clientWalletAccessControllerProvider(clientId).notifier,
|
||||||
|
);
|
||||||
|
final saveMutation = ref.watch(saveClientWalletAccessMutation(clientId));
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
const ClientDetailsHeader(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ClientSummaryCard(client: client),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
WalletAccessSection(
|
||||||
|
clientId: clientId,
|
||||||
|
state: state,
|
||||||
|
accessSelectionAsync: ref.watch(
|
||||||
|
clientWalletAccessSelectionProvider(clientId),
|
||||||
|
),
|
||||||
|
isSavePending: saveMutation is MutationPending,
|
||||||
|
onSearchChanged: notifier.setSearchQuery,
|
||||||
|
onToggleWallet: notifier.toggleWallet,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
WalletAccessSaveBar(
|
||||||
|
state: state,
|
||||||
|
saveMutation: saveMutation,
|
||||||
|
onDiscard: notifier.discardChanges,
|
||||||
|
onSave: () => executeSaveClientWalletAccess(ref, clientId: clientId),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class ClientDetailsHeader extends StatelessWidget {
|
||||||
|
const ClientDetailsHeader({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
BackButton(onPressed: () => Navigator.of(context).maybePop()),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Client Details',
|
||||||
|
style: theme.textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import 'package:arbiter/theme/palette.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class ClientDetailsStatePanel extends StatelessWidget {
|
||||||
|
const ClientDetailsStatePanel({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.body,
|
||||||
|
required this.icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final String body;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Palette.cream,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(color: Palette.line),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: Palette.coral),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(title, style: theme.textTheme.titleLarge),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(body, textAlign: TextAlign.center),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import 'package:arbiter/proto/user_agent.pb.dart';
|
||||||
|
import 'package:arbiter/theme/palette.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class ClientSummaryCard extends StatelessWidget {
|
||||||
|
const ClientSummaryCard({super.key, required this.client});
|
||||||
|
|
||||||
|
final SdkClientEntry client;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Palette.cream,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(color: Palette.line),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
client.info.name,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(client.info.description),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Wrap(
|
||||||
|
runSpacing: 8,
|
||||||
|
spacing: 16,
|
||||||
|
children: [
|
||||||
|
_Fact(label: 'Client ID', value: '${client.id}'),
|
||||||
|
_Fact(label: 'Version', value: client.info.version),
|
||||||
|
_Fact(
|
||||||
|
label: 'Registered',
|
||||||
|
value: _formatDate(client.createdAt),
|
||||||
|
),
|
||||||
|
_Fact(label: 'Pubkey', value: _shortPubkey(client.pubkey)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Fact extends StatelessWidget {
|
||||||
|
const _Fact({required this.label, required this.value});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label, style: theme.textTheme.labelMedium),
|
||||||
|
Text(value.isEmpty ? '—' : value, style: theme.textTheme.bodyMedium),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(int unixSecs) {
|
||||||
|
final dt = DateTime.fromMillisecondsSinceEpoch(unixSecs * 1000).toLocal();
|
||||||
|
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _shortPubkey(List<int> bytes) {
|
||||||
|
final hex = bytes
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
if (hex.length < 12) {
|
||||||
|
return '0x$hex';
|
||||||
|
}
|
||||||
|
return '0x${hex.substring(0, 8)}...${hex.substring(hex.length - 4)}';
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
|
||||||
|
import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_tile.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class WalletAccessList extends StatelessWidget {
|
||||||
|
const WalletAccessList({
|
||||||
|
super.key,
|
||||||
|
required this.options,
|
||||||
|
required this.selectedWalletIds,
|
||||||
|
required this.enabled,
|
||||||
|
required this.onToggleWallet,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<ClientWalletOption> options;
|
||||||
|
final Set<int> selectedWalletIds;
|
||||||
|
final bool enabled;
|
||||||
|
final ValueChanged<int> onToggleWallet;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
for (final option in options)
|
||||||
|
WalletAccessTile(
|
||||||
|
option: option,
|
||||||
|
value: selectedWalletIds.contains(option.walletId),
|
||||||
|
enabled: enabled,
|
||||||
|
onChanged: () => onToggleWallet(option.walletId),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
|
||||||
|
import 'package:arbiter/theme/palette.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/experimental/mutation.dart';
|
||||||
|
|
||||||
|
class WalletAccessSaveBar extends StatelessWidget {
|
||||||
|
const WalletAccessSaveBar({
|
||||||
|
super.key,
|
||||||
|
required this.state,
|
||||||
|
required this.saveMutation,
|
||||||
|
required this.onDiscard,
|
||||||
|
required this.onSave,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ClientWalletAccessState state;
|
||||||
|
final MutationState<void> saveMutation;
|
||||||
|
final VoidCallback onDiscard;
|
||||||
|
final Future<void> Function() onSave;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isPending = saveMutation is MutationPending;
|
||||||
|
final errorText = switch (saveMutation) {
|
||||||
|
MutationError(:final error) => error.toString(),
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Palette.cream,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(color: Palette.line),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (errorText != null) ...[
|
||||||
|
Text(errorText, style: TextStyle(color: Palette.coral)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: state.hasChanges && !isPending ? onDiscard : null,
|
||||||
|
child: const Text('Reset'),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: state.hasChanges && !isPending ? onSave : null,
|
||||||
|
child: Text(isPending ? 'Saving...' : 'Save changes'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class WalletAccessSearchField extends StatelessWidget {
|
||||||
|
const WalletAccessSearchField({
|
||||||
|
super.key,
|
||||||
|
required this.searchQuery,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String searchQuery;
|
||||||
|
final ValueChanged<String> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return TextFormField(
|
||||||
|
initialValue: searchQuery,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Search wallets',
|
||||||
|
prefixIcon: Icon(Icons.search),
|
||||||
|
),
|
||||||
|
onChanged: onChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
|
||||||
|
import 'package:arbiter/screens/dashboard/clients/details/widgets/client_details_state_panel.dart';
|
||||||
|
import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_list.dart';
|
||||||
|
import 'package:arbiter/screens/dashboard/clients/details/widgets/wallet_access_search_field.dart';
|
||||||
|
import 'package:arbiter/theme/palette.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
class WalletAccessSection extends ConsumerWidget {
|
||||||
|
const WalletAccessSection({
|
||||||
|
super.key,
|
||||||
|
required this.clientId,
|
||||||
|
required this.state,
|
||||||
|
required this.accessSelectionAsync,
|
||||||
|
required this.isSavePending,
|
||||||
|
required this.onSearchChanged,
|
||||||
|
required this.onToggleWallet,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int clientId;
|
||||||
|
final ClientWalletAccessState state;
|
||||||
|
final AsyncValue<Set<int>> accessSelectionAsync;
|
||||||
|
final bool isSavePending;
|
||||||
|
final ValueChanged<String> onSearchChanged;
|
||||||
|
final ValueChanged<int> onToggleWallet;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final optionsAsync = ref.watch(clientWalletOptionsProvider);
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Palette.cream,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(color: Palette.line),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Wallet access',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('Choose which managed wallets this client can see.'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_WalletAccessBody(
|
||||||
|
clientId: clientId,
|
||||||
|
state: state,
|
||||||
|
accessSelectionAsync: accessSelectionAsync,
|
||||||
|
isSavePending: isSavePending,
|
||||||
|
optionsAsync: optionsAsync,
|
||||||
|
onSearchChanged: onSearchChanged,
|
||||||
|
onToggleWallet: onToggleWallet,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WalletAccessBody extends StatelessWidget {
|
||||||
|
const _WalletAccessBody({
|
||||||
|
required this.clientId,
|
||||||
|
required this.state,
|
||||||
|
required this.accessSelectionAsync,
|
||||||
|
required this.isSavePending,
|
||||||
|
required this.optionsAsync,
|
||||||
|
required this.onSearchChanged,
|
||||||
|
required this.onToggleWallet,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int clientId;
|
||||||
|
final ClientWalletAccessState state;
|
||||||
|
final AsyncValue<Set<int>> accessSelectionAsync;
|
||||||
|
final bool isSavePending;
|
||||||
|
final AsyncValue<List<ClientWalletOption>> optionsAsync;
|
||||||
|
final ValueChanged<String> onSearchChanged;
|
||||||
|
final ValueChanged<int> onToggleWallet;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final selectionState = accessSelectionAsync;
|
||||||
|
if (selectionState.isLoading) {
|
||||||
|
return const ClientDetailsStatePanel(
|
||||||
|
title: 'Loading wallet access',
|
||||||
|
body: 'Pulling the current wallet permissions for this client.',
|
||||||
|
icon: Icons.hourglass_top,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (selectionState.hasError) {
|
||||||
|
return ClientDetailsStatePanel(
|
||||||
|
title: 'Wallet access unavailable',
|
||||||
|
body: selectionState.error.toString(),
|
||||||
|
icon: Icons.lock_outline,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return optionsAsync.when(
|
||||||
|
data: (options) => _WalletAccessLoaded(
|
||||||
|
state: state,
|
||||||
|
isSavePending: isSavePending,
|
||||||
|
options: options,
|
||||||
|
onSearchChanged: onSearchChanged,
|
||||||
|
onToggleWallet: onToggleWallet,
|
||||||
|
),
|
||||||
|
error: (error, _) => ClientDetailsStatePanel(
|
||||||
|
title: 'Wallet list unavailable',
|
||||||
|
body: error.toString(),
|
||||||
|
icon: Icons.sync_problem,
|
||||||
|
),
|
||||||
|
loading: () => const ClientDetailsStatePanel(
|
||||||
|
title: 'Loading wallets',
|
||||||
|
body: 'Pulling the managed wallet inventory.',
|
||||||
|
icon: Icons.hourglass_top,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WalletAccessLoaded extends StatelessWidget {
|
||||||
|
const _WalletAccessLoaded({
|
||||||
|
required this.state,
|
||||||
|
required this.isSavePending,
|
||||||
|
required this.options,
|
||||||
|
required this.onSearchChanged,
|
||||||
|
required this.onToggleWallet,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ClientWalletAccessState state;
|
||||||
|
final bool isSavePending;
|
||||||
|
final List<ClientWalletOption> options;
|
||||||
|
final ValueChanged<String> onSearchChanged;
|
||||||
|
final ValueChanged<int> onToggleWallet;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (options.isEmpty) {
|
||||||
|
return const ClientDetailsStatePanel(
|
||||||
|
title: 'No wallets yet',
|
||||||
|
body: 'Create a managed wallet before assigning client access.',
|
||||||
|
icon: Icons.account_balance_wallet_outlined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
WalletAccessSearchField(
|
||||||
|
searchQuery: state.searchQuery,
|
||||||
|
onChanged: onSearchChanged,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
WalletAccessList(
|
||||||
|
options: _filterOptions(options, state.searchQuery),
|
||||||
|
selectedWalletIds: state.selectedWalletIds,
|
||||||
|
enabled: !isSavePending,
|
||||||
|
onToggleWallet: onToggleWallet,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ClientWalletOption> _filterOptions(
|
||||||
|
List<ClientWalletOption> options,
|
||||||
|
String query,
|
||||||
|
) {
|
||||||
|
if (query.isEmpty) {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
final normalized = query.toLowerCase();
|
||||||
|
return options
|
||||||
|
.where((option) => option.address.toLowerCase().contains(normalized))
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class WalletAccessTile extends StatelessWidget {
|
||||||
|
const WalletAccessTile({
|
||||||
|
super.key,
|
||||||
|
required this.option,
|
||||||
|
required this.value,
|
||||||
|
required this.enabled,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ClientWalletOption option;
|
||||||
|
final bool value;
|
||||||
|
final bool enabled;
|
||||||
|
final VoidCallback onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return CheckboxListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
value: value,
|
||||||
|
onChanged: enabled ? (_) => onChanged() : null,
|
||||||
|
title: Text('Wallet ${option.walletId}'),
|
||||||
|
subtitle: Text(option.address),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'dart:math' as math;
|
|||||||
|
|
||||||
import 'package:arbiter/proto/user_agent.pb.dart';
|
import 'package:arbiter/proto/user_agent.pb.dart';
|
||||||
import 'package:arbiter/providers/connection/connection_manager.dart';
|
import 'package:arbiter/providers/connection/connection_manager.dart';
|
||||||
|
import 'package:arbiter/router.gr.dart';
|
||||||
import 'package:arbiter/providers/sdk_clients/list.dart';
|
import 'package:arbiter/providers/sdk_clients/list.dart';
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -176,10 +177,7 @@ class _Header extends StatelessWidget {
|
|||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: Palette.ink,
|
foregroundColor: Palette.ink,
|
||||||
side: BorderSide(color: Palette.line),
|
side: BorderSide(color: Palette.line),
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(horizontal: 1.4.w, vertical: 1.2.h),
|
||||||
horizontal: 1.4.w,
|
|
||||||
vertical: 1.2.h,
|
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
@@ -215,9 +213,15 @@ class _ClientTableHeader extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(width: _accentStripWidth + _cellHPad),
|
SizedBox(width: _accentStripWidth + _cellHPad),
|
||||||
SizedBox(width: _idColWidth, child: Text('ID', style: style)),
|
SizedBox(
|
||||||
|
width: _idColWidth,
|
||||||
|
child: Text('ID', style: style),
|
||||||
|
),
|
||||||
SizedBox(width: _colGap),
|
SizedBox(width: _colGap),
|
||||||
SizedBox(width: _nameColWidth, child: Text('Name', style: style)),
|
SizedBox(
|
||||||
|
width: _nameColWidth,
|
||||||
|
child: Text('Name', style: style),
|
||||||
|
),
|
||||||
SizedBox(width: _colGap),
|
SizedBox(width: _colGap),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: _versionColWidth,
|
width: _versionColWidth,
|
||||||
@@ -397,9 +401,7 @@ class _ClientTableRow extends HookWidget {
|
|||||||
color: muted,
|
color: muted,
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await Clipboard.setData(
|
await Clipboard.setData(
|
||||||
ClipboardData(
|
ClipboardData(text: _fullPubkey(client.pubkey)),
|
||||||
text: _fullPubkey(client.pubkey),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -410,6 +412,14 @@ class _ClientTableRow extends HookWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
FilledButton.tonal(
|
||||||
|
onPressed: () {
|
||||||
|
context.router.push(
|
||||||
|
ClientDetailsRoute(clientId: client.id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Manage access'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import 'dart:math' as math;
|
|
||||||
|
|
||||||
import 'package:arbiter/proto/evm.pb.dart';
|
import 'package:arbiter/proto/evm.pb.dart';
|
||||||
|
import 'package:arbiter/screens/dashboard/evm/wallets/header.dart';
|
||||||
|
import 'package:arbiter/screens/dashboard/evm/wallets/table.dart';
|
||||||
import 'package:arbiter/theme/palette.dart';
|
import 'package:arbiter/theme/palette.dart';
|
||||||
import 'package:arbiter/providers/connection/connection_manager.dart';
|
|
||||||
import 'package:arbiter/providers/evm/evm.dart';
|
import 'package:arbiter/providers/evm/evm.dart';
|
||||||
|
import 'package:arbiter/widgets/page_header.dart';
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:sizer/sizer.dart';
|
import 'package:sizer/sizer.dart';
|
||||||
|
|
||||||
@@ -16,13 +15,10 @@ class EvmScreen extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final walletsAsync = ref.watch(evmProvider);
|
final evm = ref.watch(evmProvider);
|
||||||
final isCreating = useState(false);
|
|
||||||
|
|
||||||
final wallets = walletsAsync.asData?.value;
|
final wallets = evm.asData?.value;
|
||||||
final loadedWallets = wallets ?? const <WalletEntry>[];
|
final loadedWallets = wallets ?? const <WalletEntry>[];
|
||||||
final isConnected =
|
|
||||||
ref.watch(connectionManagerProvider).asData?.value != null;
|
|
||||||
|
|
||||||
void showMessage(String message) {
|
void showMessage(String message) {
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
@@ -34,28 +30,12 @@ class EvmScreen extends HookConsumerWidget {
|
|||||||
Future<void> refreshWallets() async {
|
Future<void> refreshWallets() async {
|
||||||
try {
|
try {
|
||||||
await ref.read(evmProvider.notifier).refreshWallets();
|
await ref.read(evmProvider.notifier).refreshWallets();
|
||||||
} catch (error) {
|
} catch (e) {
|
||||||
showMessage(_formatError(error));
|
showMessage('Failed to refresh wallets: ${_formatError(e)}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> createWallet() async {
|
final content = switch (evm) {
|
||||||
if (isCreating.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isCreating.value = true;
|
|
||||||
try {
|
|
||||||
await ref.read(evmProvider.notifier).createWallet();
|
|
||||||
showMessage('Wallet created.');
|
|
||||||
} catch (error) {
|
|
||||||
showMessage(_formatError(error));
|
|
||||||
} finally {
|
|
||||||
isCreating.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final content = switch (walletsAsync) {
|
|
||||||
AsyncLoading() when wallets == null => const _StatePanel(
|
AsyncLoading() when wallets == null => const _StatePanel(
|
||||||
icon: Icons.hourglass_top,
|
icon: Icons.hourglass_top,
|
||||||
title: 'Loading wallets',
|
title: 'Loading wallets',
|
||||||
@@ -69,22 +49,14 @@ class EvmScreen extends HookConsumerWidget {
|
|||||||
actionLabel: 'Retry',
|
actionLabel: 'Retry',
|
||||||
onAction: refreshWallets,
|
onAction: refreshWallets,
|
||||||
),
|
),
|
||||||
_ when !isConnected => _StatePanel(
|
AsyncData(:final value) when value == null => _StatePanel(
|
||||||
icon: Icons.portable_wifi_off,
|
icon: Icons.portable_wifi_off,
|
||||||
title: 'No active server connection',
|
title: 'No active server connection',
|
||||||
body: 'Reconnect to Arbiter to list or create EVM wallets.',
|
body: 'Reconnect to Arbiter to list or create EVM wallets.',
|
||||||
actionLabel: 'Refresh',
|
actionLabel: 'Refresh',
|
||||||
onAction: refreshWallets,
|
onAction: () => refreshWallets(),
|
||||||
),
|
),
|
||||||
_ when loadedWallets.isEmpty => _StatePanel(
|
_ => WalletTable(wallets: loadedWallets),
|
||||||
icon: Icons.account_balance_wallet_outlined,
|
|
||||||
title: 'No wallets yet',
|
|
||||||
body:
|
|
||||||
'Create the first vault-backed wallet to start building your EVM registry.',
|
|
||||||
actionLabel: isCreating.value ? 'Creating...' : 'Create wallet',
|
|
||||||
onAction: isCreating.value ? null : createWallet,
|
|
||||||
),
|
|
||||||
_ => _WalletTable(wallets: loadedWallets),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -99,11 +71,14 @@ class EvmScreen extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
padding: EdgeInsets.fromLTRB(2.4.w, 2.4.h, 2.4.w, 3.2.h),
|
padding: EdgeInsets.fromLTRB(2.4.w, 2.4.h, 2.4.w, 3.2.h),
|
||||||
children: [
|
children: [
|
||||||
_Header(
|
PageHeader(
|
||||||
isBusy: walletsAsync.isLoading,
|
title: 'EVM Wallet Vault',
|
||||||
isCreating: isCreating.value,
|
isBusy: evm.isLoading,
|
||||||
onCreate: createWallet,
|
actions: [
|
||||||
onRefresh: refreshWallets,
|
const CreateWalletButton(),
|
||||||
|
SizedBox(width: 1.w),
|
||||||
|
const RefreshWalletButton(),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
SizedBox(height: 1.8.h),
|
SizedBox(height: 1.8.h),
|
||||||
content,
|
content,
|
||||||
@@ -115,281 +90,6 @@ class EvmScreen extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
double get _accentStripWidth => 0.8.w;
|
|
||||||
double get _cellHorizontalPadding => 1.8.w;
|
|
||||||
double get _walletColumnWidth => 18.w;
|
|
||||||
double get _columnGap => 1.8.w;
|
|
||||||
double get _tableMinWidth => 72.w;
|
|
||||||
|
|
||||||
class _Header extends StatelessWidget {
|
|
||||||
const _Header({
|
|
||||||
required this.isBusy,
|
|
||||||
required this.isCreating,
|
|
||||||
required this.onCreate,
|
|
||||||
required this.onRefresh,
|
|
||||||
});
|
|
||||||
|
|
||||||
final bool isBusy;
|
|
||||||
final bool isCreating;
|
|
||||||
final Future<void> Function() onCreate;
|
|
||||||
final Future<void> Function() onRefresh;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
'EVM Wallet Vault',
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
color: Palette.ink,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (isBusy) ...[
|
|
||||||
Text(
|
|
||||||
'Syncing',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: Palette.ink.withValues(alpha: 0.62),
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(width: 1.w),
|
|
||||||
],
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: isCreating ? null : () => onCreate(),
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: Palette.ink,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 1.4.w, vertical: 1.2.h),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
icon: isCreating
|
|
||||||
? SizedBox(
|
|
||||||
width: 1.6.h,
|
|
||||||
height: 1.6.h,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2.2),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.add_circle_outline, size: 18),
|
|
||||||
label: Text(isCreating ? 'Creating...' : 'Create'),
|
|
||||||
),
|
|
||||||
SizedBox(width: 1.w),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () => onRefresh(),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
icon: const Icon(Icons.refresh, size: 18),
|
|
||||||
label: const Text('Refresh'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _WalletTable extends StatelessWidget {
|
|
||||||
const _WalletTable({required this.wallets});
|
|
||||||
|
|
||||||
final List<WalletEntry> wallets;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
color: Palette.cream.withValues(alpha: 0.92),
|
|
||||||
border: Border.all(color: Palette.line),
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.all(2.h),
|
|
||||||
child: LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final tableWidth = math.max(_tableMinWidth, constraints.maxWidth);
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Managed wallets',
|
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
|
||||||
color: Palette.ink,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: 0.6.h),
|
|
||||||
Text(
|
|
||||||
'Every address here is generated and held by Arbiter.',
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: Palette.ink.withValues(alpha: 0.70),
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: 1.6.h),
|
|
||||||
SingleChildScrollView(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
child: SizedBox(
|
|
||||||
width: tableWidth,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
const _WalletTableHeader(),
|
|
||||||
SizedBox(height: 1.h),
|
|
||||||
for (var i = 0; i < wallets.length; i++)
|
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
bottom: i == wallets.length - 1 ? 0 : 1.h,
|
|
||||||
),
|
|
||||||
child: _WalletTableRow(
|
|
||||||
wallet: wallets[i],
|
|
||||||
index: i,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _WalletTableHeader extends StatelessWidget {
|
|
||||||
const _WalletTableHeader();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final style = Theme.of(context).textTheme.labelLarge?.copyWith(
|
|
||||||
color: Palette.ink.withValues(alpha: 0.72),
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
letterSpacing: 0.3,
|
|
||||||
);
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
padding: EdgeInsets.symmetric(vertical: 1.4.h),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
color: Palette.ink.withValues(alpha: 0.04),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
SizedBox(width: _accentStripWidth + _cellHorizontalPadding),
|
|
||||||
SizedBox(
|
|
||||||
width: _walletColumnWidth,
|
|
||||||
child: Text('Wallet', style: style),
|
|
||||||
),
|
|
||||||
SizedBox(width: _columnGap),
|
|
||||||
Expanded(child: Text('Address', style: style)),
|
|
||||||
SizedBox(width: _cellHorizontalPadding),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _WalletTableRow extends StatelessWidget {
|
|
||||||
const _WalletTableRow({required this.wallet, required this.index});
|
|
||||||
|
|
||||||
final WalletEntry wallet;
|
|
||||||
final int index;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final accent = _accentColor(wallet.address);
|
|
||||||
final address = _hexAddress(wallet.address);
|
|
||||||
final rowHeight = 5.h;
|
|
||||||
final walletStyle = Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.bodyLarge?.copyWith(color: Palette.ink);
|
|
||||||
final addressStyle = Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.bodyMedium?.copyWith(color: Palette.ink);
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
height: rowHeight,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
color: accent.withValues(alpha: 0.10),
|
|
||||||
border: Border.all(color: accent.withValues(alpha: 0.28)),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: _accentStripWidth,
|
|
||||||
height: rowHeight,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: accent,
|
|
||||||
borderRadius: const BorderRadius.horizontal(
|
|
||||||
left: Radius.circular(18),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: _cellHorizontalPadding),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: _walletColumnWidth,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 1.2.h,
|
|
||||||
height: 1.2.h,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: accent,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(width: 1.w),
|
|
||||||
Text(
|
|
||||||
'Wallet ${(index + 1).toString().padLeft(2, '0')}',
|
|
||||||
style: walletStyle,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(width: _columnGap),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
address,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: addressStyle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _StatePanel extends StatelessWidget {
|
class _StatePanel extends StatelessWidget {
|
||||||
const _StatePanel({
|
const _StatePanel({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
@@ -461,19 +161,6 @@ class _StatePanel extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _hexAddress(List<int> bytes) {
|
|
||||||
final hex = bytes
|
|
||||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
|
||||||
.join();
|
|
||||||
return '0x$hex';
|
|
||||||
}
|
|
||||||
|
|
||||||
Color _accentColor(List<int> bytes) {
|
|
||||||
final seed = bytes.fold<int>(0, (value, byte) => value + byte);
|
|
||||||
final hue = (seed * 17) % 360;
|
|
||||||
return HSLColor.fromAHSL(1, hue.toDouble(), 0.68, 0.54).toColor();
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatError(Object error) {
|
String _formatError(Object error) {
|
||||||
final message = error.toString();
|
final message = error.toString();
|
||||||
if (message.startsWith('Exception: ')) {
|
if (message.startsWith('Exception: ')) {
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import 'package:arbiter/proto/evm.pb.dart';
|
import 'package:arbiter/proto/evm.pb.dart';
|
||||||
|
import 'package:arbiter/proto/user_agent.pb.dart';
|
||||||
import 'package:arbiter/providers/evm/evm.dart';
|
import 'package:arbiter/providers/evm/evm.dart';
|
||||||
import 'package:arbiter/providers/evm/evm_grants.dart';
|
import 'package:arbiter/providers/evm/evm_grants.dart';
|
||||||
|
import 'package:arbiter/providers/sdk_clients/list.dart';
|
||||||
|
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:fixnum/fixnum.dart';
|
import 'package:fixnum/fixnum.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:hooks_riverpod/experimental/mutation.dart';
|
import 'package:hooks_riverpod/experimental/mutation.dart';
|
||||||
|
import 'package:protobuf/well_known_types/google/protobuf/timestamp.pb.dart';
|
||||||
import 'package:sizer/sizer.dart';
|
import 'package:sizer/sizer.dart';
|
||||||
|
|
||||||
@RoutePage()
|
@RoutePage()
|
||||||
@@ -15,11 +19,10 @@ class CreateEvmGrantScreen extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final wallets = ref.watch(evmProvider).asData?.value ?? const <WalletEntry>[];
|
|
||||||
final createMutation = ref.watch(createEvmGrantMutation);
|
final createMutation = ref.watch(createEvmGrantMutation);
|
||||||
|
|
||||||
final selectedWalletIndex = useState<int?>(wallets.isEmpty ? null : 0);
|
final selectedClientId = useState<int?>(null);
|
||||||
final clientIdController = useTextEditingController();
|
final selectedWalletAccessId = useState<int?>(null);
|
||||||
final chainIdController = useTextEditingController(text: '1');
|
final chainIdController = useTextEditingController(text: '1');
|
||||||
final gasFeeController = useTextEditingController();
|
final gasFeeController = useTextEditingController();
|
||||||
final priorityFeeController = useTextEditingController();
|
final priorityFeeController = useTextEditingController();
|
||||||
@@ -40,14 +43,13 @@ class CreateEvmGrantScreen extends HookConsumerWidget {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
Future<void> submit() async {
|
Future<void> submit() async {
|
||||||
final selectedWallet = selectedWalletIndex.value;
|
final accessId = selectedWalletAccessId.value;
|
||||||
if (selectedWallet == null) {
|
if (accessId == null) {
|
||||||
_showCreateMessage(context, 'At least one wallet is required.');
|
_showCreateMessage(context, 'Select a client and wallet access.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final clientId = int.parse(clientIdController.text.trim());
|
|
||||||
final chainId = Int64.parseInt(chainIdController.text.trim());
|
final chainId = Int64.parseInt(chainIdController.text.trim());
|
||||||
final rateLimit = _buildRateLimit(
|
final rateLimit = _buildRateLimit(
|
||||||
txCountController.text,
|
txCountController.text,
|
||||||
@@ -83,16 +85,25 @@ class CreateEvmGrantScreen extends HookConsumerWidget {
|
|||||||
_ => throw Exception('Unsupported grant type.'),
|
_ => throw Exception('Unsupported grant type.'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
final sharedSettings = SharedSettings(
|
||||||
|
walletAccessId: accessId,
|
||||||
|
chainId: chainId,
|
||||||
|
);
|
||||||
|
if (validFrom.value != null) {
|
||||||
|
sharedSettings.validFrom = _toTimestamp(validFrom.value!);
|
||||||
|
}
|
||||||
|
if (validUntil.value != null) {
|
||||||
|
sharedSettings.validUntil = _toTimestamp(validUntil.value!);
|
||||||
|
}
|
||||||
|
final gasBytes = _optionalBigIntBytes(gasFeeController.text);
|
||||||
|
if (gasBytes != null) sharedSettings.maxGasFeePerGas = gasBytes;
|
||||||
|
final priorityBytes = _optionalBigIntBytes(priorityFeeController.text);
|
||||||
|
if (priorityBytes != null) sharedSettings.maxPriorityFeePerGas = priorityBytes;
|
||||||
|
if (rateLimit != null) sharedSettings.rateLimit = rateLimit;
|
||||||
|
|
||||||
await executeCreateEvmGrant(
|
await executeCreateEvmGrant(
|
||||||
ref,
|
ref,
|
||||||
clientId: clientId,
|
sharedSettings: sharedSettings,
|
||||||
walletId: selectedWallet + 1,
|
|
||||||
chainId: chainId,
|
|
||||||
validFrom: validFrom.value,
|
|
||||||
validUntil: validUntil.value,
|
|
||||||
maxGasFeePerGas: _optionalBigIntBytes(gasFeeController.text),
|
|
||||||
maxPriorityFeePerGas: _optionalBigIntBytes(priorityFeeController.text),
|
|
||||||
rateLimit: rateLimit,
|
|
||||||
specific: specific,
|
specific: specific,
|
||||||
);
|
);
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
@@ -113,22 +124,23 @@ class CreateEvmGrantScreen extends HookConsumerWidget {
|
|||||||
child: ListView(
|
child: ListView(
|
||||||
padding: EdgeInsets.fromLTRB(2.4.w, 2.h, 2.4.w, 3.2.h),
|
padding: EdgeInsets.fromLTRB(2.4.w, 2.h, 2.4.w, 3.2.h),
|
||||||
children: [
|
children: [
|
||||||
_CreateIntroCard(walletCount: wallets.length),
|
const _CreateIntroCard(),
|
||||||
SizedBox(height: 1.8.h),
|
SizedBox(height: 1.8.h),
|
||||||
_CreateSection(
|
_CreateSection(
|
||||||
title: 'Shared grant options',
|
title: 'Shared grant options',
|
||||||
children: [
|
children: [
|
||||||
_WalletPickerField(
|
_ClientPickerField(
|
||||||
wallets: wallets,
|
selectedClientId: selectedClientId.value,
|
||||||
selectedIndex: selectedWalletIndex.value,
|
onChanged: (clientId) {
|
||||||
onChanged: (value) => selectedWalletIndex.value = value,
|
selectedClientId.value = clientId;
|
||||||
|
selectedWalletAccessId.value = null;
|
||||||
|
},
|
||||||
),
|
),
|
||||||
_NumberInputField(
|
_WalletAccessPickerField(
|
||||||
controller: clientIdController,
|
selectedClientId: selectedClientId.value,
|
||||||
label: 'Client ID',
|
selectedAccessId: selectedWalletAccessId.value,
|
||||||
hint: '42',
|
onChanged: (accessId) =>
|
||||||
helper:
|
selectedWalletAccessId.value = accessId,
|
||||||
'Manual for now. The app does not yet expose a client picker.',
|
|
||||||
),
|
),
|
||||||
_NumberInputField(
|
_NumberInputField(
|
||||||
controller: chainIdController,
|
controller: chainIdController,
|
||||||
@@ -204,9 +216,7 @@ class CreateEvmGrantScreen extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _CreateIntroCard extends StatelessWidget {
|
class _CreateIntroCard extends StatelessWidget {
|
||||||
const _CreateIntroCard({required this.walletCount});
|
const _CreateIntroCard();
|
||||||
|
|
||||||
final int walletCount;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -222,7 +232,7 @@ class _CreateIntroCard extends StatelessWidget {
|
|||||||
border: Border.all(color: const Color(0x1A17324A)),
|
border: Border.all(color: const Color(0x1A17324A)),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Compose shared constraints once, then switch between Ether and token transfer rules. $walletCount wallet${walletCount == 1 ? '' : 's'} currently available.',
|
'Pick a client, then select one of the wallet accesses already granted to it. Compose shared constraints once, then switch between Ether and token transfer rules.',
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(height: 1.5),
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(height: 1.5),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -266,37 +276,98 @@ class _CreateSection extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _WalletPickerField extends StatelessWidget {
|
class _ClientPickerField extends ConsumerWidget {
|
||||||
const _WalletPickerField({
|
const _ClientPickerField({
|
||||||
required this.wallets,
|
required this.selectedClientId,
|
||||||
required this.selectedIndex,
|
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<WalletEntry> wallets;
|
final int? selectedClientId;
|
||||||
final int? selectedIndex;
|
|
||||||
final ValueChanged<int?> onChanged;
|
final ValueChanged<int?> onChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final clients =
|
||||||
|
ref.watch(sdkClientsProvider).asData?.value ?? const <SdkClientEntry>[];
|
||||||
|
|
||||||
return DropdownButtonFormField<int>(
|
return DropdownButtonFormField<int>(
|
||||||
initialValue: selectedIndex,
|
value: clients.any((c) => c.id == selectedClientId)
|
||||||
|
? selectedClientId
|
||||||
|
: null,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Wallet',
|
labelText: 'Client',
|
||||||
helperText:
|
|
||||||
'Uses the current wallet order. The API still does not expose stable wallet IDs directly.',
|
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
items: [
|
items: [
|
||||||
for (var i = 0; i < wallets.length; i++)
|
for (final c in clients)
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: i,
|
value: c.id,
|
||||||
child: Text(
|
child: Text(
|
||||||
'Wallet ${(i + 1).toString().padLeft(2, '0')} · ${_shortAddress(wallets[i].address)}',
|
c.info.name.isEmpty ? 'Client #${c.id}' : c.info.name,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
onChanged: wallets.isEmpty ? null : onChanged,
|
onChanged: clients.isEmpty ? null : onChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WalletAccessPickerField extends ConsumerWidget {
|
||||||
|
const _WalletAccessPickerField({
|
||||||
|
required this.selectedClientId,
|
||||||
|
required this.selectedAccessId,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int? selectedClientId;
|
||||||
|
final int? selectedAccessId;
|
||||||
|
final ValueChanged<int?> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final allAccesses =
|
||||||
|
ref.watch(walletAccessListProvider).asData?.value ??
|
||||||
|
const <SdkClientWalletAccess>[];
|
||||||
|
final wallets =
|
||||||
|
ref.watch(evmProvider).asData?.value ?? const <WalletEntry>[];
|
||||||
|
|
||||||
|
final walletById = <int, WalletEntry>{
|
||||||
|
for (final w in wallets) w.id: w,
|
||||||
|
};
|
||||||
|
|
||||||
|
final accesses = selectedClientId == null
|
||||||
|
? const <SdkClientWalletAccess>[]
|
||||||
|
: allAccesses
|
||||||
|
.where((a) => a.access.sdkClientId == selectedClientId)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final effectiveValue =
|
||||||
|
accesses.any((a) => a.id == selectedAccessId) ? selectedAccessId : null;
|
||||||
|
|
||||||
|
return DropdownButtonFormField<int>(
|
||||||
|
value: effectiveValue,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Wallet access',
|
||||||
|
helperText: selectedClientId == null
|
||||||
|
? 'Select a client first'
|
||||||
|
: accesses.isEmpty
|
||||||
|
? 'No wallet accesses for this client'
|
||||||
|
: null,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
for (final a in accesses)
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: a.id,
|
||||||
|
child: Text(() {
|
||||||
|
final wallet = walletById[a.access.walletId];
|
||||||
|
return wallet != null
|
||||||
|
? _shortAddress(wallet.address)
|
||||||
|
: 'Wallet #${a.access.walletId}';
|
||||||
|
}()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: accesses.isEmpty ? null : onChanged,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -735,6 +806,13 @@ class _VolumeLimitValue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timestamp _toTimestamp(DateTime value) {
|
||||||
|
final utc = value.toUtc();
|
||||||
|
return Timestamp()
|
||||||
|
..seconds = Int64(utc.millisecondsSinceEpoch ~/ 1000)
|
||||||
|
..nanos = (utc.microsecondsSinceEpoch % 1000000) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
TransactionRateLimit? _buildRateLimit(String countText, String windowText) {
|
TransactionRateLimit? _buildRateLimit(String countText, String windowText) {
|
||||||
if (countText.trim().isEmpty || windowText.trim().isEmpty) {
|
if (countText.trim().isEmpty || windowText.trim().isEmpty) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
231
useragent/lib/screens/dashboard/evm/grants/grants.dart
Normal file
231
useragent/lib/screens/dashboard/evm/grants/grants.dart
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import 'package:arbiter/proto/evm.pb.dart';
|
||||||
|
import 'package:arbiter/providers/evm/evm_grants.dart';
|
||||||
|
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
|
||||||
|
import 'package:arbiter/router.gr.dart';
|
||||||
|
import 'package:arbiter/screens/dashboard/evm/grants/widgets/grant_card.dart';
|
||||||
|
import 'package:arbiter/theme/palette.dart';
|
||||||
|
import 'package:arbiter/widgets/page_header.dart';
|
||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:sizer/sizer.dart';
|
||||||
|
|
||||||
|
String _formatError(Object error) {
|
||||||
|
final message = error.toString();
|
||||||
|
if (message.startsWith('Exception: ')) {
|
||||||
|
return message.substring('Exception: '.length);
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── State panel ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _StatePanel extends StatelessWidget {
|
||||||
|
const _StatePanel({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.body,
|
||||||
|
this.actionLabel,
|
||||||
|
this.onAction,
|
||||||
|
this.busy = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String body;
|
||||||
|
final String? actionLabel;
|
||||||
|
final Future<void> Function()? onAction;
|
||||||
|
final bool busy;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
color: Palette.cream.withValues(alpha: 0.92),
|
||||||
|
border: Border.all(color: Palette.line),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(2.8.h),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (busy)
|
||||||
|
SizedBox(
|
||||||
|
width: 2.8.h,
|
||||||
|
height: 2.8.h,
|
||||||
|
child: const CircularProgressIndicator(strokeWidth: 2.5),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Icon(icon, size: 34, color: Palette.coral),
|
||||||
|
SizedBox(height: 1.8.h),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.headlineSmall?.copyWith(
|
||||||
|
color: Palette.ink,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 1.h),
|
||||||
|
Text(
|
||||||
|
body,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: Palette.ink.withValues(alpha: 0.72),
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (actionLabel != null && onAction != null) ...[
|
||||||
|
SizedBox(height: 2.h),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () => onAction!(),
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: Text(actionLabel!),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Grant list ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _GrantList extends StatelessWidget {
|
||||||
|
const _GrantList({required this.grants});
|
||||||
|
|
||||||
|
final List<GrantEntry> grants;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < grants.length; i++)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: i == grants.length - 1 ? 0 : 1.8.h,
|
||||||
|
),
|
||||||
|
child: GrantCard(grant: grants[i]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Screen ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@RoutePage()
|
||||||
|
class EvmGrantsScreen extends ConsumerWidget {
|
||||||
|
const EvmGrantsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
// Screen watches only the grant list for top-level state decisions
|
||||||
|
final grantsAsync = ref.watch(evmGrantsProvider);
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
ref.invalidate(walletAccessListProvider);
|
||||||
|
ref.invalidate(evmGrantsProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
void showMessage(String message) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> safeRefresh() async {
|
||||||
|
try {
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
showMessage(_formatError(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final grantsState = grantsAsync.asData?.value;
|
||||||
|
final grants = grantsState?.grants;
|
||||||
|
|
||||||
|
final content = switch (grantsAsync) {
|
||||||
|
AsyncLoading() when grantsState == null => const _StatePanel(
|
||||||
|
icon: Icons.hourglass_top,
|
||||||
|
title: 'Loading grants',
|
||||||
|
body: 'Pulling grant registry from Arbiter.',
|
||||||
|
busy: true,
|
||||||
|
),
|
||||||
|
AsyncError(:final error) => _StatePanel(
|
||||||
|
icon: Icons.sync_problem,
|
||||||
|
title: 'Grant registry unavailable',
|
||||||
|
body: _formatError(error),
|
||||||
|
actionLabel: 'Retry',
|
||||||
|
onAction: safeRefresh,
|
||||||
|
),
|
||||||
|
AsyncData(:final value) when value == null => _StatePanel(
|
||||||
|
icon: Icons.portable_wifi_off,
|
||||||
|
title: 'No active server connection',
|
||||||
|
body: 'Reconnect to Arbiter to list EVM grants.',
|
||||||
|
actionLabel: 'Refresh',
|
||||||
|
onAction: safeRefresh,
|
||||||
|
),
|
||||||
|
_ when grants != null && grants.isEmpty => _StatePanel(
|
||||||
|
icon: Icons.policy_outlined,
|
||||||
|
title: 'No grants yet',
|
||||||
|
body: 'Create a grant to allow SDK clients to sign transactions.',
|
||||||
|
actionLabel: 'Create grant',
|
||||||
|
onAction: () async => context.router.push(const CreateEvmGrantRoute()),
|
||||||
|
),
|
||||||
|
_ => _GrantList(grants: grants ?? const []),
|
||||||
|
};
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: RefreshIndicator.adaptive(
|
||||||
|
color: Palette.ink,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
onRefresh: safeRefresh,
|
||||||
|
child: ListView(
|
||||||
|
physics: const BouncingScrollPhysics(
|
||||||
|
parent: AlwaysScrollableScrollPhysics(),
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.fromLTRB(2.4.w, 2.4.h, 2.4.w, 3.2.h),
|
||||||
|
children: [
|
||||||
|
PageHeader(
|
||||||
|
title: 'EVM Grants',
|
||||||
|
isBusy: grantsAsync.isLoading,
|
||||||
|
actions: [
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: () =>
|
||||||
|
context.router.push(const CreateEvmGrantRoute()),
|
||||||
|
icon: const Icon(Icons.add_rounded),
|
||||||
|
label: const Text('Create grant'),
|
||||||
|
),
|
||||||
|
SizedBox(width: 1.w),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: safeRefresh,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.refresh, size: 18),
|
||||||
|
label: const Text('Refresh'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(height: 1.8.h),
|
||||||
|
content,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import 'package:arbiter/proto/evm.pb.dart';
|
||||||
|
import 'package:arbiter/proto/user_agent.pb.dart';
|
||||||
|
import 'package:arbiter/providers/evm/evm.dart';
|
||||||
|
import 'package:arbiter/providers/evm/evm_grants.dart';
|
||||||
|
import 'package:arbiter/providers/sdk_clients/list.dart';
|
||||||
|
import 'package:arbiter/providers/sdk_clients/wallet_access_list.dart';
|
||||||
|
import 'package:arbiter/theme/palette.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/experimental/mutation.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:sizer/sizer.dart';
|
||||||
|
|
||||||
|
String _shortAddress(List<int> bytes) {
|
||||||
|
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
|
return '0x${hex.substring(0, 6)}...${hex.substring(hex.length - 4)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatError(Object error) {
|
||||||
|
final message = error.toString();
|
||||||
|
if (message.startsWith('Exception: ')) {
|
||||||
|
return message.substring('Exception: '.length);
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
class GrantCard extends ConsumerWidget {
|
||||||
|
const GrantCard({super.key, required this.grant});
|
||||||
|
|
||||||
|
final GrantEntry grant;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
// Enrichment lookups — each watch scopes rebuilds to this card only
|
||||||
|
final walletAccesses =
|
||||||
|
ref.watch(walletAccessListProvider).asData?.value ?? const [];
|
||||||
|
final wallets = ref.watch(evmProvider).asData?.value ?? const [];
|
||||||
|
final clients = ref.watch(sdkClientsProvider).asData?.value ?? const [];
|
||||||
|
final revoking = ref.watch(revokeEvmGrantMutation) is MutationPending;
|
||||||
|
|
||||||
|
final isEther =
|
||||||
|
grant.specific.whichGrant() == SpecificGrant_Grant.etherTransfer;
|
||||||
|
final accent = isEther ? Palette.coral : Palette.token;
|
||||||
|
final typeLabel = isEther ? 'Ether' : 'Token';
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final muted = Palette.ink.withValues(alpha: 0.62);
|
||||||
|
|
||||||
|
// Resolve wallet_access_id → wallet address + client name
|
||||||
|
final accessById = <int, SdkClientWalletAccess>{
|
||||||
|
for (final a in walletAccesses) a.id: a,
|
||||||
|
};
|
||||||
|
final walletById = <int, WalletEntry>{
|
||||||
|
for (final w in wallets) w.id: w,
|
||||||
|
};
|
||||||
|
final clientNameById = <int, String>{
|
||||||
|
for (final c in clients) c.id: c.info.name,
|
||||||
|
};
|
||||||
|
|
||||||
|
final accessId = grant.shared.walletAccessId;
|
||||||
|
final access = accessById[accessId];
|
||||||
|
final wallet = access != null ? walletById[access.access.walletId] : null;
|
||||||
|
|
||||||
|
final walletLabel = wallet != null
|
||||||
|
? _shortAddress(wallet.address)
|
||||||
|
: 'Access #$accessId';
|
||||||
|
|
||||||
|
final clientLabel = () {
|
||||||
|
if (access == null) return '';
|
||||||
|
final name = clientNameById[access.access.sdkClientId] ?? '';
|
||||||
|
return name.isEmpty ? 'Client #${access.access.sdkClientId}' : name;
|
||||||
|
}();
|
||||||
|
|
||||||
|
void showError(String message) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> revoke() async {
|
||||||
|
try {
|
||||||
|
await executeRevokeEvmGrant(ref, grantId: grant.id);
|
||||||
|
} catch (e) {
|
||||||
|
showError(_formatError(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
color: Palette.cream.withValues(alpha: 0.92),
|
||||||
|
border: Border.all(color: Palette.line),
|
||||||
|
),
|
||||||
|
child: IntrinsicHeight(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Accent strip
|
||||||
|
Container(
|
||||||
|
width: 0.8.w,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accent,
|
||||||
|
borderRadius: const BorderRadius.horizontal(
|
||||||
|
left: Radius.circular(24),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Card body
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 1.6.w,
|
||||||
|
vertical: 1.4.h,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Row 1: type badge · chain · spacer · revoke button
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 1.w,
|
||||||
|
vertical: 0.4.h,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accent.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
typeLabel,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: accent,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 1.w),
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 1.w,
|
||||||
|
vertical: 0.4.h,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Palette.ink.withValues(alpha: 0.06),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Chain ${grant.shared.chainId}',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: muted,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (revoking)
|
||||||
|
SizedBox(
|
||||||
|
width: 1.8.h,
|
||||||
|
height: 1.8.h,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Palette.coral,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: revoke,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Palette.coral,
|
||||||
|
side: BorderSide(
|
||||||
|
color: Palette.coral.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 1.w,
|
||||||
|
vertical: 0.6.h,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.block_rounded, size: 16),
|
||||||
|
label: const Text('Revoke'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(height: 0.8.h),
|
||||||
|
// Row 2: wallet address · client name
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
walletLabel,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: Palette.ink,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 0.8.w),
|
||||||
|
child: Text(
|
||||||
|
'·',
|
||||||
|
style: theme.textTheme.bodySmall
|
||||||
|
?.copyWith(color: muted),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
clientLabel,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodySmall
|
||||||
|
?.copyWith(color: muted),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
useragent/lib/screens/dashboard/evm/wallets/header.dart
Normal file
98
useragent/lib/screens/dashboard/evm/wallets/header.dart
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import 'package:arbiter/providers/evm/evm.dart';
|
||||||
|
import 'package:arbiter/theme/palette.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/experimental/mutation.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:sizer/sizer.dart';
|
||||||
|
|
||||||
|
|
||||||
|
class CreateWalletButton extends ConsumerWidget {
|
||||||
|
const CreateWalletButton({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final createWallet = ref.watch(createEvmWallet);
|
||||||
|
final isCreating = createWallet is MutationPending;
|
||||||
|
|
||||||
|
Future<void> handleCreateWallet() async {
|
||||||
|
try {
|
||||||
|
await executeCreateEvmWallet(ref);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('New wallet created successfully.'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to create wallet: ${_formatError(e)}'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return FilledButton.icon(
|
||||||
|
onPressed: isCreating ? null : () => handleCreateWallet(),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Palette.ink,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 1.4.w, vertical: 1.2.h),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||||
|
),
|
||||||
|
icon: isCreating
|
||||||
|
? SizedBox(
|
||||||
|
width: 1.6.h,
|
||||||
|
height: 1.6.h,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2.2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.add_circle_outline, size: 18),
|
||||||
|
label: Text(isCreating ? 'Creating...' : 'Create'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RefreshWalletButton extends ConsumerWidget {
|
||||||
|
const RefreshWalletButton({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
Future<void> handleRefreshWallets() async {
|
||||||
|
try {
|
||||||
|
await ref.read(evmProvider.notifier).refreshWallets();
|
||||||
|
} catch (e) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to refresh wallets: ${_formatError(e)}'),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return OutlinedButton.icon(
|
||||||
|
onPressed: () => handleRefreshWallets(),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
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)),
|
||||||
|
),
|
||||||
|
icon: const Icon(Icons.refresh, size: 18),
|
||||||
|
label: const Text('Refresh'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
String _formatError(Object error) {
|
||||||
|
final message = error.toString();
|
||||||
|
if (message.startsWith('Exception: ')) {
|
||||||
|
return message.substring('Exception: '.length);
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
209
useragent/lib/screens/dashboard/evm/wallets/table.dart
Normal file
209
useragent/lib/screens/dashboard/evm/wallets/table.dart
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
import 'package:arbiter/proto/evm.pb.dart';
|
||||||
|
import 'package:arbiter/theme/palette.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sizer/sizer.dart';
|
||||||
|
|
||||||
|
double get _accentStripWidth => 0.8.w;
|
||||||
|
double get _cellHorizontalPadding => 1.8.w;
|
||||||
|
double get _walletColumnWidth => 18.w;
|
||||||
|
double get _columnGap => 1.8.w;
|
||||||
|
double get _tableMinWidth => 72.w;
|
||||||
|
|
||||||
|
String _hexAddress(List<int> bytes) {
|
||||||
|
final hex = bytes
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join();
|
||||||
|
return '0x$hex';
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _accentColor(List<int> bytes) {
|
||||||
|
final seed = bytes.fold<int>(0, (value, byte) => value + byte);
|
||||||
|
final hue = (seed * 17) % 360;
|
||||||
|
return HSLColor.fromAHSL(1, hue.toDouble(), 0.68, 0.54).toColor();
|
||||||
|
}
|
||||||
|
|
||||||
|
class WalletTable extends StatelessWidget {
|
||||||
|
const WalletTable({super.key, required this.wallets});
|
||||||
|
|
||||||
|
final List<WalletEntry> wallets;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
color: Palette.cream.withValues(alpha: 0.92),
|
||||||
|
border: Border.all(color: Palette.line),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(2.h),
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final tableWidth = math.max(_tableMinWidth, constraints.maxWidth);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Managed wallets',
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
color: Palette.ink,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 0.6.h),
|
||||||
|
Text(
|
||||||
|
'Every address here is generated and held by Arbiter.',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: Palette.ink.withValues(alpha: 0.70),
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 1.6.h),
|
||||||
|
SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: SizedBox(
|
||||||
|
width: tableWidth,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const _WalletTableHeader(),
|
||||||
|
SizedBox(height: 1.h),
|
||||||
|
for (var i = 0; i < wallets.length; i++)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: i == wallets.length - 1 ? 0 : 1.h,
|
||||||
|
),
|
||||||
|
child: _WalletTableRow(
|
||||||
|
wallet: wallets[i],
|
||||||
|
index: i,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WalletTableHeader extends StatelessWidget {
|
||||||
|
const _WalletTableHeader();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final style = Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
|
color: Palette.ink.withValues(alpha: 0.72),
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 1.4.h),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
color: Palette.ink.withValues(alpha: 0.04),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(width: _accentStripWidth + _cellHorizontalPadding),
|
||||||
|
SizedBox(
|
||||||
|
width: _walletColumnWidth,
|
||||||
|
child: Text('Wallet', style: style),
|
||||||
|
),
|
||||||
|
SizedBox(width: _columnGap),
|
||||||
|
Expanded(child: Text('Address', style: style)),
|
||||||
|
SizedBox(width: _cellHorizontalPadding),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WalletTableRow extends StatelessWidget {
|
||||||
|
const _WalletTableRow({required this.wallet, required this.index});
|
||||||
|
|
||||||
|
final WalletEntry wallet;
|
||||||
|
final int index;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final accent = _accentColor(wallet.address);
|
||||||
|
final address = _hexAddress(wallet.address);
|
||||||
|
final rowHeight = 5.h;
|
||||||
|
final walletStyle = Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyLarge?.copyWith(color: Palette.ink);
|
||||||
|
final addressStyle = Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.copyWith(color: Palette.ink);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
height: rowHeight,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
color: accent.withValues(alpha: 0.10),
|
||||||
|
border: Border.all(color: accent.withValues(alpha: 0.28)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: _accentStripWidth,
|
||||||
|
height: rowHeight,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accent,
|
||||||
|
borderRadius: const BorderRadius.horizontal(
|
||||||
|
left: Radius.circular(18),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: _cellHorizontalPadding),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: _walletColumnWidth,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 1.2.h,
|
||||||
|
height: 1.2.h,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: accent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 1.w),
|
||||||
|
Text(
|
||||||
|
'Wallet ${(index + 1).toString().padLeft(2, '0')}',
|
||||||
|
style: walletStyle,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: _columnGap),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
address,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: addressStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,4 +5,5 @@ class Palette {
|
|||||||
static const coral = Color(0xFFE26254);
|
static const coral = Color(0xFFE26254);
|
||||||
static const cream = Color(0xFFFFFAF4);
|
static const cream = Color(0xFFFFFAF4);
|
||||||
static const line = Color(0x1A15263C);
|
static const line = Color(0x1A15263C);
|
||||||
|
static const token = Color(0xFF5C6BC0);
|
||||||
}
|
}
|
||||||
|
|||||||
63
useragent/lib/widgets/page_header.dart
Normal file
63
useragent/lib/widgets/page_header.dart
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import 'package:arbiter/theme/palette.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sizer/sizer.dart';
|
||||||
|
|
||||||
|
class PageHeader extends StatelessWidget {
|
||||||
|
const PageHeader({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
this.isBusy = false,
|
||||||
|
this.busyLabel = 'Syncing',
|
||||||
|
this.actions = const <Widget>[],
|
||||||
|
this.padding,
|
||||||
|
this.backgroundColor,
|
||||||
|
this.borderColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final bool isBusy;
|
||||||
|
final String busyLabel;
|
||||||
|
final List<Widget> actions;
|
||||||
|
final EdgeInsetsGeometry? padding;
|
||||||
|
final Color? backgroundColor;
|
||||||
|
final Color? borderColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding:
|
||||||
|
padding ?? EdgeInsets.symmetric(horizontal: 1.6.w, vertical: 1.2.h),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
color: backgroundColor ?? Palette.cream,
|
||||||
|
border: Border.all(color: borderColor ?? Palette.line),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: Palette.ink,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isBusy) ...[
|
||||||
|
Text(
|
||||||
|
busyLabel,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: Palette.ink.withValues(alpha: 0.62),
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 1.w),
|
||||||
|
],
|
||||||
|
...actions,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import 'package:arbiter/proto/client.pb.dart';
|
||||||
|
import 'package:arbiter/proto/evm.pb.dart';
|
||||||
|
import 'package:arbiter/proto/user_agent.pb.dart';
|
||||||
|
import 'package:arbiter/providers/evm/evm.dart';
|
||||||
|
import 'package:arbiter/providers/sdk_clients/list.dart';
|
||||||
|
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
|
||||||
|
import 'package:arbiter/screens/dashboard/clients/details/client_details.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
class _FakeEvm extends Evm {
|
||||||
|
_FakeEvm(this.wallets);
|
||||||
|
|
||||||
|
final List<WalletEntry> wallets;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<WalletEntry>?> build() async => wallets;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeWalletAccessRepository implements ClientWalletAccessRepository {
|
||||||
|
@override
|
||||||
|
Future<Set<int>> fetchSelectedWalletIds(int clientId) async => {1};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveSelectedWalletIds(int clientId, Set<int> walletIds) async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('renders client summary and wallet access controls', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final client = SdkClientEntry(
|
||||||
|
id: 42,
|
||||||
|
createdAt: 1,
|
||||||
|
info: ClientInfo(
|
||||||
|
name: 'Safe Wallet SDK',
|
||||||
|
version: '1.3.0',
|
||||||
|
description: 'Primary signing client',
|
||||||
|
),
|
||||||
|
pubkey: List.filled(32, 17),
|
||||||
|
);
|
||||||
|
|
||||||
|
final wallets = [
|
||||||
|
WalletEntry(address: List.filled(20, 1)),
|
||||||
|
WalletEntry(address: List.filled(20, 2)),
|
||||||
|
];
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [
|
||||||
|
sdkClientsProvider.overrideWith((ref) async => [client]),
|
||||||
|
evmProvider.overrideWith(() => _FakeEvm(wallets)),
|
||||||
|
clientWalletAccessRepositoryProvider.overrideWithValue(
|
||||||
|
_FakeWalletAccessRepository(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MaterialApp(home: ClientDetailsScreen(clientId: 42)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Safe Wallet SDK'), findsOneWidget);
|
||||||
|
expect(find.text('Wallet access'), findsOneWidget);
|
||||||
|
expect(find.textContaining('0x0101'), findsOneWidget);
|
||||||
|
expect(find.widgetWithText(FilledButton, 'Save changes'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import 'package:arbiter/providers/sdk_clients/wallet_access.dart';
|
||||||
|
import 'package:hooks_riverpod/experimental/mutation.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
class _SuccessRepository implements ClientWalletAccessRepository {
|
||||||
|
Set<int>? savedWalletIds;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Set<int>> fetchSelectedWalletIds(int clientId) async => {1};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveSelectedWalletIds(int clientId, Set<int> walletIds) async {
|
||||||
|
savedWalletIds = walletIds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FailureRepository implements ClientWalletAccessRepository {
|
||||||
|
@override
|
||||||
|
Future<Set<int>> fetchSelectedWalletIds(int clientId) async => const {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> saveSelectedWalletIds(int clientId, Set<int> walletIds) async {
|
||||||
|
throw UnsupportedError('Not supported yet: $walletIds');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('save updates the original selection after toggles', () async {
|
||||||
|
final repository = _SuccessRepository();
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
clientWalletAccessRepositoryProvider.overrideWithValue(repository),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
final controller = container.read(
|
||||||
|
clientWalletAccessControllerProvider(42).notifier,
|
||||||
|
);
|
||||||
|
await container.read(clientWalletAccessSelectionProvider(42).future);
|
||||||
|
controller.toggleWallet(2);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
container
|
||||||
|
.read(clientWalletAccessControllerProvider(42))
|
||||||
|
.selectedWalletIds,
|
||||||
|
{1, 2},
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
container.read(clientWalletAccessControllerProvider(42)).hasChanges,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
|
||||||
|
await executeSaveClientWalletAccess(container, clientId: 42);
|
||||||
|
|
||||||
|
expect(repository.savedWalletIds, {1, 2});
|
||||||
|
expect(
|
||||||
|
container
|
||||||
|
.read(clientWalletAccessControllerProvider(42))
|
||||||
|
.originalWalletIds,
|
||||||
|
{1, 2},
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
container.read(clientWalletAccessControllerProvider(42)).hasChanges,
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('save failure preserves edits and exposes a mutation error', () async {
|
||||||
|
final container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
clientWalletAccessRepositoryProvider.overrideWithValue(
|
||||||
|
_FailureRepository(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
final controller = container.read(
|
||||||
|
clientWalletAccessControllerProvider(42).notifier,
|
||||||
|
);
|
||||||
|
await container.read(clientWalletAccessSelectionProvider(42).future);
|
||||||
|
controller.toggleWallet(3);
|
||||||
|
await expectLater(
|
||||||
|
executeSaveClientWalletAccess(container, clientId: 42),
|
||||||
|
throwsUnsupportedError,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
container
|
||||||
|
.read(clientWalletAccessControllerProvider(42))
|
||||||
|
.selectedWalletIds,
|
||||||
|
{3},
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
container.read(clientWalletAccessControllerProvider(42)).hasChanges,
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
container.read(saveClientWalletAccessMutation(42)),
|
||||||
|
isA<MutationError<void>>(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user