Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions lib/core/wallet/data/repositories/wallet_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -375,10 +375,24 @@ class WalletRepository {
throw WalletError.notFound(walletId);
}

// A behavior the wallet already records always wins over the default
// offered here. Filling only the switch that is missing can still land a
// recorded hide-on-home next to a default auto-sweep of off, so the pair
// that results goes through the same rule a deliberate write does and the
// wallet comes back visible instead of hidden while it accumulates.
final filledHideOnHome = metadata.hideOnHome ?? hideOnHome;
final filledAutoSweep = metadata.autoSweepEnabled ?? autoSweepEnabled;
final resolved = resolveWalletBehaviorChange(
hideOnHome: filledHideOnHome ?? false,
autoSweepEnabled: filledAutoSweep ?? false,
);

await _walletMetadataDatasource.store(
metadata.copyWith(
hideOnHome: metadata.hideOnHome ?? hideOnHome,
autoSweepEnabled: metadata.autoSweepEnabled ?? autoSweepEnabled,
hideOnHome: filledHideOnHome == null ? null : resolved.hideOnHome,
autoSweepEnabled: filledAutoSweep == null
? null
: resolved.autoSweepEnabled,
),
);
}
Expand Down
86 changes: 86 additions & 0 deletions lib/core/wallet/ui/wallet_behavior_switches.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import 'package:bb_mobile/core/utils/build_context_x.dart';
import 'package:flutter/material.dart';

/// One wallet's two behavior switches — auto-sweep and hide-on-home — with the
/// rule that couples them stated on the switch it restricts.
///
/// [resolveWalletBehaviorChange] refuses to persist a hidden wallet that keeps
/// its funds, so every surface offering these switches renders them through this
/// widget: hiding is not offered while auto-sweep is off, and it says what it
/// depends on instead of accepting a tap the store will drop and letting the
/// value snap back on the next reload. Unhiding is always offered, so a wallet
/// left hidden by older data can still be brought back.
///
/// It is presentational: the caller owns the writes and supplies the labels its
/// surface uses, but never the refusal copy — that stays in one place.
class WalletBehaviorSwitches extends StatelessWidget {
const WalletBehaviorSwitches({
super.key,
required this.hideOnHome,
required this.autoSweepEnabled,
required this.canHideOnHome,
required this.saving,
required this.autoSweepLabel,
required this.hideOnHomeLabel,
required this.onAutoSweepChanged,
required this.onHideOnHomeChanged,
this.autoSweepInfo,
this.hideOnHomeInfo,
this.autoSweepSwitchKey,
this.hideOnHomeSwitchKey,
});

final bool hideOnHome;
final bool autoSweepEnabled;

/// Whether hiding is currently available — the wallet's own reading of the
/// coupling rule.
final bool canHideOnHome;

/// True while a behavior write is in flight — both switches are inert.
final bool saving;

final String autoSweepLabel;
final String hideOnHomeLabel;
final String? autoSweepInfo;

/// Shown under the hide switch while hiding is available; the rule's own
/// explanation takes its place when it is not.
final String? hideOnHomeInfo;

final Key? autoSweepSwitchKey;
final Key? hideOnHomeSwitchKey;

final ValueChanged<bool> onAutoSweepChanged;
final ValueChanged<bool> onHideOnHomeChanged;

@override
Widget build(BuildContext context) {
final autoSweepInfo = this.autoSweepInfo;
final hideOnHomeSubtitle = canHideOnHome
? hideOnHomeInfo
: context.loc.getPaidWalletHideOnHomeNeedsAutoSweep;
return Column(
children: [
SwitchListTile(
key: autoSweepSwitchKey,
value: autoSweepEnabled,
onChanged: saving ? null : onAutoSweepChanged,
title: Text(autoSweepLabel),
subtitle: autoSweepInfo == null ? null : Text(autoSweepInfo),
),
SwitchListTile(
key: hideOnHomeSwitchKey,
value: hideOnHome,
onChanged: saving || !(canHideOnHome || hideOnHome)
? null
: onHideOnHomeChanged,
title: Text(hideOnHomeLabel),
subtitle: hideOnHomeSubtitle == null
? null
: Text(hideOnHomeSubtitle),
),
],
);
}
}
140 changes: 97 additions & 43 deletions lib/features/btcpay/presentation/btcpay_pairing_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,33 @@ class BtcpayPairingCubit extends Cubit<BtcpayPairingState> {

Future<void> load() async {
if (state.isSubmitting || state.connection != null) return;
emit(
state.copyWith(
status: BtcpayPairingStatus.loading,
clearFailure: true,
showPairingForm: false,
),
);
switch (await _getConnection.execute()) {
case Ok(:final value):
final walletBehaviors = value == null
? const <BtcpayWalletBehaviorViewModel>[]
? const Ok<List<BtcpayWalletBehaviorViewModel>, BtcpayFailure>([])
: await _loadWalletBehaviors(value);
if (isClosed) return;
emit(
state.copyWith(
status: BtcpayPairingStatus.idle,
connection: value == null ? null : _connectionView(value),
walletBehaviors: walletBehaviors,
walletBehaviors: switch (walletBehaviors) {
Ok(:final value) => value,
Err() => state.walletBehaviors,
},
walletBehaviorsStatus: switch (walletBehaviors) {
Ok() => BtcpayWalletBehaviorStatus.loaded,
Err() => BtcpayWalletBehaviorStatus.unavailable,
},
clearConnection: value == null,
clearFailure: true,
),
);
case Err(:final failure):
Expand All @@ -48,7 +63,14 @@ class BtcpayPairingCubit extends Cubit<BtcpayPairingState> {
error: failure.runtimeType,
);
if (isClosed) return;
emit(state.copyWith(status: BtcpayPairingStatus.idle));
emit(
state.copyWith(
status: BtcpayPairingStatus.unavailable,
failure: failure,
clearConnection: true,
showPairingForm: false,
),
);
}
}

Expand Down Expand Up @@ -113,7 +135,14 @@ class BtcpayPairingCubit extends Cubit<BtcpayPairingState> {
state.copyWith(
status: BtcpayPairingStatus.success,
connection: _connectionView(value),
walletBehaviors: walletBehaviors,
walletBehaviors: switch (walletBehaviors) {
Ok(:final value) => value,
Err() => state.walletBehaviors,
},
walletBehaviorsStatus: switch (walletBehaviors) {
Ok() => BtcpayWalletBehaviorStatus.loaded,
Err() => BtcpayWalletBehaviorStatus.unavailable,
},
showPairingForm: false,
),
);
Expand Down Expand Up @@ -143,7 +172,7 @@ class BtcpayPairingCubit extends Cubit<BtcpayPairingState> {
final updated = previous
.map((behavior) {
if (behavior.walletId != walletId) return behavior;
return behavior.copyWith(
return behavior.withRequestedChange(
hideOnHome: hideOnHome,
autoSweepEnabled: autoSweepEnabled,
);
Expand All @@ -160,7 +189,14 @@ class BtcpayPairingCubit extends Cubit<BtcpayPairingState> {
if (isClosed) return;
emit(
state.copyWith(
walletBehaviors: walletBehaviors,
walletBehaviors: switch (walletBehaviors) {
Ok(:final value) => value,
Err() => previous,
},
walletBehaviorsStatus: switch (walletBehaviors) {
Ok() => BtcpayWalletBehaviorStatus.loaded,
Err() => BtcpayWalletBehaviorStatus.unavailable,
},
walletSettingsSaving: false,
),
);
Expand All @@ -182,6 +218,27 @@ class BtcpayPairingCubit extends Cubit<BtcpayPairingState> {
}
}

Future<void> retryWalletBehaviors() async {
if (state.walletSettingsSaving || state.connection == null) return;
emit(
state.copyWith(walletBehaviorsStatus: BtcpayWalletBehaviorStatus.loading),
);
final walletBehaviors = await _loadWalletBehaviors();
if (isClosed) return;
emit(
state.copyWith(
walletBehaviors: switch (walletBehaviors) {
Ok(:final value) => value,
Err() => state.walletBehaviors,
},
walletBehaviorsStatus: switch (walletBehaviors) {
Ok() => BtcpayWalletBehaviorStatus.loaded,
Err() => BtcpayWalletBehaviorStatus.unavailable,
},
),
);
}

/// Storage remains authoritative after every pairing failure. In particular,
/// an explicit rejection must not hide an older valid connection, while an
/// uncertain submission reloads the supervision record saved by the usecase.
Expand Down Expand Up @@ -223,44 +280,41 @@ class BtcpayPairingCubit extends Cubit<BtcpayPairingState> {
);
}

Future<List<BtcpayWalletBehaviorViewModel>> _loadWalletBehaviors([
BtcpayConnection? connection,
]) async {
try {
BtcpayConnection? btcpayConnection = connection;
if (btcpayConnection == null) {
switch (await _getConnection.execute()) {
case Ok(:final value):
btcpayConnection = value;
case Err(:final failure):
log.warning(
'Failed to load the BTCPay connection for wallet settings',
error: failure.runtimeType,
);
return const [];
}
Future<Result<List<BtcpayWalletBehaviorViewModel>, BtcpayFailure>>
_loadWalletBehaviors([BtcpayConnection? connection]) async {
BtcpayConnection? btcpayConnection = connection;
if (btcpayConnection == null) {
switch (await _getConnection.execute()) {
case Ok(:final value):
btcpayConnection = value;
case Err(:final failure):
log.warning(
'Failed to load the BTCPay connection for wallet settings',
error: failure.runtimeType,
);
return Err(failure);
}
final behaviors = await _getWalletBehaviors.execute(
connection: btcpayConnection,
);
return behaviors.map((behavior) {
return BtcpayWalletBehaviorViewModel(
walletId: behavior.wallet.id,
wallet: switch (behavior.network) {
BtcpayWalletNetwork.bitcoin => BtcpayPairingWallet.bitcoin,
BtcpayWalletNetwork.liquid => BtcpayPairingWallet.liquid,
},
hideOnHome: behavior.wallet.hideOnHome,
autoSweepEnabled: behavior.wallet.autoSweepEnabled,
);
}).toList();
} on Exception catch (error, trace) {
log.warning(
'Failed to load BTCPay wallet behavior settings',
error: error.runtimeType,
trace: trace,
);
return const [];
}
if (btcpayConnection == null) {
return const Ok([]);
}
return switch (await _getWalletBehaviors.execute(
connection: btcpayConnection,
)) {
Ok(:final value) => Ok(
value.map((behavior) {
return BtcpayWalletBehaviorViewModel(
walletId: behavior.wallet.id,
wallet: switch (behavior.network) {
BtcpayWalletNetwork.bitcoin => BtcpayPairingWallet.bitcoin,
BtcpayWalletNetwork.liquid => BtcpayPairingWallet.liquid,
},
hideOnHome: behavior.wallet.hideOnHome,
autoSweepEnabled: behavior.wallet.autoSweepEnabled,
);
}).toList(),
),
Err(:final failure) => Err(failure),
};
}
}
Loading