CW-1513/CW-1610: Add account archival and UI improvements (#3600)

* add ability to hide accounts

* remove import

* merge

* merge

* fix loading accounts when styleSettings are not saved yet

* merge

* auto-reformat

* small fixes

* proper reload on card unhide

* block hiding last card

* fixup

* revert formatting-only changes

* liner fixes

* fix overflows

* card layout as separate class to prevent index errors

* fix mini card on addresses page

* block opening customizers if wallet is syncing

* switch to BlocProvider.value to prevent auto dispose

* imports

* fix fiat balance

* Improve homescreen layout and account access

* Add homescreen layout regression tests

* improve account management w/ education + archival flows

* reorganize wallet-specific settings

* preserve account archival during dismissal

* fix: align account visuals with design feedback

* minor account & archival visual corrections

* simplify wallet settings + account UI state

* generalise education carousel & dismissal tracking

* refactor account UI flows + minor improvements

* minor refactors and improvement

* pass account balances as Money and format at display

* use fresh full balance for account archival check

* refactor BalanceCard w/ MoneyText + cleanup

* minor card_customizer cleanup

* rework card customizer flow

---------

Co-authored-by: Robert Malikowski <malikowskirobert@gmail.com>
Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>
This commit is contained in:
Patrick
2026-10-10 02:07:41 +02:00
committed by GitHub
co-authored by Robert Malikowski Omar Hatem
parent 9f1f82fdd4
commit 20f1ceb651
108 changed files with 6632 additions and 1392 deletions
@@ -1,11 +1,8 @@
import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart';
import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item_toggle.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/new-ui/pages/coin_control_page.dart';
import 'package:cake_wallet/new-ui/widgets/modal_header.dart';
import 'package:cake_wallet/new-ui/widgets/modal_page_wrapper.dart';
import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/src/screens/base_page.dart';
import 'package:cake_wallet/src/widgets/new_list_row/new_list_section.dart';
import 'package:cake_wallet/view_model/settings/privacy_settings_view_model.dart';
@@ -75,29 +72,6 @@ class PrivacyPage extends BasePage {
_privacySettingsViewModel.setUseLightning(val);
}),
],
"": [
if (_privacySettingsViewModel.hasSilentPaymentsScanning)
ListItemRegularRow(
iconPath: "assets/new-ui/settings_row_icons/silent-payments.svg",
keyValue: "silent_payments",
label: S.of(context).silent_payments,
onTap: () =>
Navigator.of(context).pushNamed(Routes.silentPaymentsSettings)),
if (_privacySettingsViewModel.hasMWEB)
ListItemRegularRow(
iconPath: "assets/new-ui/settings_row_icons/mweb.svg",
keyValue: "mweb",
label: "MWEB",
onTap: () => Navigator.of(context).pushNamed(Routes.mwebSettings)),
if (_privacySettingsViewModel.hasCoinControl)
ListItemRegularRow(
iconPath: "assets/new-ui/settings_row_icons/coin-control.svg",
keyValue: "coin_control",
label: "Coin Control",
onTap: () => Navigator.of(context).pushNamed(Routes.unspentCoinsList,
arguments:
CoinControlPageArgs(canEdit: false, coinTypeToSpendFrom: null))),
],
}),
],
);
@@ -1,20 +1,16 @@
import "package:cake_wallet/core/execution_state.dart";
import "package:cake_wallet/generated/i18n.dart";
import "package:cake_wallet/new-ui/widgets/modal_grab_handle.dart";
import "package:cake_wallet/new-ui/widgets/new_primary_button.dart";
import "package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart";
import "package:cake_wallet/src/widgets/alert_with_one_action.dart";
import "package:cake_wallet/src/widgets/cake_image_widget.dart";
import "package:cake_wallet/view_model/wallet_account_list/account_edit_or_create_view_model.dart";
import "package:cw_core/generate_name.dart";
import "package:flutter/material.dart";
import "package:flutter/services.dart";
import "package:flutter_mobx/flutter_mobx.dart";
class AccountCreationModal extends StatefulWidget {
const AccountCreationModal({
required this.viewModel,
super.key,
});
const AccountCreationModal({required this.viewModel, super.key});
final WalletAccountEditOrCreateViewModel viewModel;
@@ -26,14 +22,59 @@ class _AccountCreationModalState extends State<AccountCreationModal> {
static const int maxAccountNameLength = 25;
final TextEditingController _controller = TextEditingController();
bool _loading = false;
@override
void initState() {
super.initState();
_controller.text = widget.viewModel.label;
_controller.addListener(() {
widget.viewModel.label = _controller.text;
});
bool get _canContinue =>
_controller.text.trim().isNotEmpty && _controller.text.length <= maxAccountNameLength;
Future<void> _generateAccountName() async {
final generatedName = await generateName();
if (!mounted) {
return;
}
setState(() => _controller.text = generatedName.length > maxAccountNameLength
? generatedName.substring(0, maxAccountNameLength)
: generatedName);
}
Future<void> _save() async {
if (_loading || !_canContinue) {
return;
}
setState(() => _loading = true);
widget.viewModel.label = _controller.text;
late final String errorMessage;
try {
await widget.viewModel.save();
if (!mounted) {
return;
}
final state = widget.viewModel.state;
if (state is ExecutedSuccessfullyState) {
Navigator.of(context).pop(true);
return;
}
errorMessage = state is FailureState ? state.error : S.of(context).error_while_processing;
} catch (error) {
errorMessage = error.toString();
}
if (!mounted) {
return;
}
setState(() => _loading = false);
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertWithOneAction(
alertTitle: S.of(dialogContext).error,
alertContent: errorMessage,
buttonText: S.of(dialogContext).ok,
buttonAction: Navigator.of(dialogContext).pop,
),
);
}
@override
@@ -42,105 +83,117 @@ class _AccountCreationModalState extends State<AccountCreationModal> {
super.dispose();
}
Future<void> _onSubmit() async {
if (widget.viewModel.state is IsExecutingState) return;
if (_controller.text.trim().isEmpty || _controller.text.length > maxAccountNameLength) return;
await widget.viewModel.save();
if (!mounted) return;
if (widget.viewModel.state is ExecutedSuccessfullyState) {
Navigator.of(context).pop(true);
}
}
@override
Widget build(BuildContext context) => Container(
Widget build(BuildContext context) => PopScope(
canPop: !_loading,
child: _buildContent(context),
);
Widget _buildContent(BuildContext context) => Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(30))),
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(30)),
),
child: SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const ModalGrabHandle(),
ModalTopBar(
title: widget.viewModel.isEdit
? S.of(context).edit_account
: S.of(context).create_account,
title: S.of(context).create_account,
trailingIcon: const Icon(Icons.close),
trailingSemanticLabel: S.of(context).close,
onTrailingPressed: Navigator.of(context).maybePop,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 18),
child: Column(
spacing: 50,
children: [
const SizedBox(),
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(16),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(18, 32, 18, 24),
child: Column(
spacing: 24,
children: [
const CakeImageWidget(
imageUrl: "assets/new-ui/account_education/create_account.svg",
width: 125,
height: 125,
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
maxLength: maxAccountNameLength,
inputFormatters: [
LengthLimitingTextInputFormatter(maxAccountNameLength),
],
decoration: InputDecoration(
hintText: S.of(context).account_name,
counterText: "",
),
),
),
Padding(
padding: const EdgeInsets.all(12),
child: GestureDetector(
onTap: () async {
final generated = await generateName();
_controller.text = generated.length > maxAccountNameLength
? generated.substring(0, maxAccountNameLength)
: generated;
},
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(5)),
child: CakeImageWidget(
imageUrl: "assets/new-ui/randomize.svg",
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.primary, BlendMode.srcIn),
Text(
S.of(context).account_creation_description,
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
maxLength: maxAccountNameLength,
inputFormatters: [
LengthLimitingTextInputFormatter(maxAccountNameLength)
],
textInputAction: TextInputAction.done,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
hintText: S.of(context).account_name,
counterText: "",
),
),
),
)
],
),
),
ValueListenableBuilder<TextEditingValue>(
valueListenable: _controller,
builder: (context, value, _) => Observer(
builder: (_) => NewPrimaryButton(
onPressed: _onSubmit,
text: S.of(context).continue_text,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
disabled: value.text.trim().isEmpty ||
value.text.length > maxAccountNameLength,
isLoading: widget.viewModel.state is IsExecutingState,
Padding(
padding: const EdgeInsets.all(12),
child: Semantics(
button: true,
label: S.of(context).generate_name,
onTap: _generateAccountName,
child: ExcludeSemantics(
child: GestureDetector(
onTap: _generateAccountName,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(5),
),
child: CakeImageWidget(
imageUrl: "assets/new-ui/randomize.svg",
colorFilter: ColorFilter.mode(
Theme.of(context).colorScheme.primary,
BlendMode.srcIn,
),
),
),
),
),
),
),
],
),
),
),
const SizedBox(),
],
],
),
),
)
),
Padding(
padding: const EdgeInsets.fromLTRB(18, 0, 18, 12),
child: SizedBox(
width: double.infinity,
child: NewPrimaryButton(
onPressed: _save,
text: S.of(context).continue_text,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
isLoading: _loading,
disabled: !_canContinue,
),
),
),
],
),
),