| 1 | import 'dart:ui'; |
| 2 | |
| 3 | import 'package:cake_wallet/di.dart'; |
| 4 | import 'package:cake_wallet/generated/i18n.dart'; |
| 5 | import 'package:cake_wallet/monero/monero.dart'; |
| 6 | import 'package:cake_wallet/new-ui/long_press_popup.dart'; |
| 7 | import 'package:cake_wallet/new-ui/widgets/addresses_page/address_info.dart'; |
| 8 | import 'package:cake_wallet/new-ui/widgets/addresses_page/address_label_input.dart'; |
| 9 | import 'package:cake_wallet/new-ui/widgets/coins_page/cards/balance_card.dart'; |
| 10 | import 'package:cake_wallet/new-ui/widgets/long_press_menu.dart'; |
| 11 | import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; |
| 12 | import 'package:cake_wallet/routes.dart'; |
| 13 | import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; |
| 14 | import 'package:cake_wallet/utils/address_formatter.dart'; |
| 15 | import 'package:cake_wallet/utils/show_pop_up.dart'; |
| 16 | import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; |
| 17 | import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_item.dart'; |
| 18 | import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_view_model.dart'; |
| 19 | import 'package:cake_wallet/wownero/wownero.dart'; |
| 20 | import 'package:cw_core/card_design.dart'; |
| 21 | import 'package:cw_core/wallet_type.dart'; |
| 22 | import 'package:flutter/material.dart'; |
| 23 | import 'package:flutter/semantics.dart'; |
| 24 | import 'package:flutter_mobx/flutter_mobx.dart'; |
| 25 | |
| 26 | import 'package:cake_wallet/utils/list_item.dart'; |
| 27 | import 'package:mobx/mobx.dart'; |
| 28 | import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; |
| 29 | |
| 30 | class NewAddressesPage extends StatefulWidget { |
| 31 | const NewAddressesPage( |
| 32 | {super.key, |
| 33 | required this.addressListViewModel, |
| 34 | required this.dashboardViewModel, |
| 35 | required this.showHidden}); |
| 36 | |
| 37 | final WalletAddressListViewModel addressListViewModel; |
| 38 | final DashboardViewModel dashboardViewModel; |
| 39 | final bool showHidden; |
| 40 | |
| 41 | @override |
| 42 | State<NewAddressesPage> createState() => _NewAddressesPageState(); |
| 43 | } |
| 44 | |
| 45 | class _NewAddressesPageState extends State<NewAddressesPage> { |
| 46 | List<ListItem> getItems(List<ListItem> list) { |
| 47 | return list.where((element) { |
| 48 | if (element is WalletAddressListItem) { |
| 49 | if (!element.isHidden && !widget.showHidden) return true; |
| 50 | if (element.isHidden && widget.showHidden) return true; |
| 51 | return false; |
| 52 | } |
| 53 | return false; |
| 54 | }).toList(); |
| 55 | } |
| 56 | |
| 57 | List<ListItem> items = []; |
| 58 | |
| 59 | late final TextEditingController _searchController; |
| 60 | |
| 61 | CardDesign? design; |
| 62 | |
| 63 | void updateItems() { |
| 64 | setState(() { |
| 65 | items = getItems(widget.addressListViewModel.forceRecomputeItems); |
| 66 | }); |
| 67 | } |
| 68 | |
| 69 | @override |
| 70 | void initState() { |
| 71 | super.initState(); |
| 72 | |
| 73 | items = getItems(widget.addressListViewModel.items); |
| 74 | _searchController = TextEditingController() |
| 75 | ..addListener(() { |
| 76 | setState(() {}); |
| 77 | }); |
| 78 | |
| 79 | WidgetsBinding.instance.addPostFrameCallback((_) { |
| 80 | reaction((context) => widget.dashboardViewModel.cardDesigns.first, (value) { |
| 81 | setState(() { |
| 82 | design = value; |
| 83 | }); |
| 84 | }); |
| 85 | }); |
| 86 | } |
| 87 | |
| 88 | @override |
| 89 | Widget build(BuildContext context) { |
| 90 | final filteredItems = items.where((item) { |
| 91 | if (item is! WalletAddressListItem) return false; |
| 92 | if (_searchController.text.isEmpty) return true; |
| 93 | if (item.name == null || item.name!.isEmpty) { |
| 94 | return (item.address.contains(_searchController.text.toLowerCase())); |
| 95 | } |
| 96 | return (item.name ?? '').toLowerCase().contains(_searchController.text.toLowerCase()); |
| 97 | }).toList(); |
| 98 | |
| 99 | return Container( |
| 100 | decoration: BoxDecoration( |
| 101 | color: Theme.of(context).colorScheme.surface, |
| 102 | borderRadius: BorderRadius.vertical(top: Radius.circular(16))), |
| 103 | child: Column( |
| 104 | spacing: 12.0, |
| 105 | children: [ |
| 106 | ModalTopBar( |
| 107 | title: widget.showHidden ? S.of(context).hidden_addresses : S.of(context).addresses, |
| 108 | leadingIcon: Icon(Icons.arrow_back), |
| 109 | leadingSemanticLabel: S.of(context).seed_alert_back, |
| 110 | onLeadingPressed: Navigator.of(context).pop, |
| 111 | onTrailingPressed: () {}), |
| 112 | Expanded( |
| 113 | child: Stack( |
| 114 | children: [ |
| 115 | CustomScrollView( |
| 116 | controller: ModalScrollController.of(context), |
| 117 | slivers: [ |
| 118 | if (!widget.showHidden) |
| 119 | SliverToBoxAdapter( |
| 120 | child: Column( |
| 121 | spacing: 16, |
| 122 | children: [ |
| 123 | if (widget.dashboardViewModel.type == WalletType.monero || |
| 124 | widget.dashboardViewModel.type == WalletType.wownero) |
| 125 | Observer( |
| 126 | builder: (_) => AccountPreviewHeader( |
| 127 | dashboardViewModel: widget.dashboardViewModel, |
| 128 | design: design, |
| 129 | )), |
| 130 | Text( |
| 131 | S.of(context).long_press_edit_address, |
| 132 | style: TextStyle( |
| 133 | fontSize: 10, |
| 134 | color: Theme.of(context).colorScheme.onSurfaceVariant), |
| 135 | ), |
| 136 | ShowHiddenButton() |
| 137 | ], |
| 138 | ), |
| 139 | ), |
| 140 | SliverPadding( |
| 141 | padding: EdgeInsets.only(bottom: 64), |
| 142 | sliver: SliverList.separated( |
| 143 | itemCount: filteredItems.length, |
| 144 | itemBuilder: (context, index) { |
| 145 | final item = filteredItems[index]; |
| 146 | if (item is WalletAddressListItem) { |
| 147 | return Observer( |
| 148 | builder: (_) => AddressRow( |
| 149 | selected: |
| 150 | item.address == widget.addressListViewModel.address.address, |
| 151 | first: widget.showHidden && index == 0, |
| 152 | last: index == filteredItems.length - 1, |
| 153 | item: item, |
| 154 | onSelect: () { |
| 155 | widget.addressListViewModel.setAddress(item); |
| 156 | }, |
| 157 | onLabelChanged: updateItems, |
| 158 | onAddressHidden: () async { |
| 159 | await widget.addressListViewModel.toggleHideAddress(item); |
| 160 | updateItems(); |
| 161 | }, |
| 162 | walletType: widget.addressListViewModel.type, |
| 163 | hasBalance: widget.addressListViewModel.isBalanceAvailable, |
| 164 | hasReceived: widget.addressListViewModel.isReceivedAvailable, |
| 165 | ), |
| 166 | ); |
| 167 | } |
| 168 | return SizedBox.shrink(); |
| 169 | }, |
| 170 | separatorBuilder: (_, __) => Padding( |
| 171 | padding: EdgeInsets.symmetric(horizontal: 16), |
| 172 | child: Container( |
| 173 | height: 1, |
| 174 | color: Theme.of(context).colorScheme.surfaceContainerHigh, |
| 175 | ), |
| 176 | ), |
| 177 | ), |
| 178 | ), |
| 179 | SliverToBoxAdapter(child: SizedBox(height: 72)) |
| 180 | ], |
| 181 | ), |
| 182 | SafeArea( |
| 183 | child: Padding( |
| 184 | padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), |
| 185 | child: AddressSearchBox(controller: _searchController)), |
| 186 | ), |
| 187 | ], |
| 188 | ), |
| 189 | ) |
| 190 | ], |
| 191 | ), |
| 192 | ); |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | class AddressSearchBox extends StatelessWidget { |
| 197 | const AddressSearchBox({super.key, required this.controller}); |
| 198 | |
| 199 | final TextEditingController controller; |
| 200 | |
| 201 | @override |
| 202 | Widget build(BuildContext context) { |
| 203 | return Align( |
| 204 | alignment: Alignment.bottomCenter, |
| 205 | child: Padding( |
| 206 | padding: const EdgeInsets.symmetric(horizontal: 30.0, vertical: 18.0), |
| 207 | child: ClipRRect( |
| 208 | borderRadius: BorderRadius.circular(999999), |
| 209 | child: BackdropFilter( |
| 210 | filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), |
| 211 | child: Container( |
| 212 | decoration: BoxDecoration( |
| 213 | color: Theme.of(context).colorScheme.surfaceContainerHigh.withAlpha(128), |
| 214 | border: Border.all( |
| 215 | color: Theme.of(context).colorScheme.surfaceContainerHighest, width: 1), |
| 216 | borderRadius: BorderRadius.circular(99999)), |
| 217 | // The hint names the field only while it is empty, so the label is |
| 218 | // supplied once there is text to keep exactly one announcement. |
| 219 | child: MergeSemantics( |
| 220 | child: Semantics( |
| 221 | label: controller.text.isEmpty ? null : S.of(context).search, |
| 222 | child: TextField( |
| 223 | controller: controller, |
| 224 | decoration: InputDecoration( |
| 225 | hintText: S.of(context).search, |
| 226 | hintStyle: TextStyle( |
| 227 | fontWeight: FontWeight.w600, |
| 228 | ), |
| 229 | prefixIcon: ExcludeSemantics(child: Icon(Icons.search)), |
| 230 | filled: false, |
| 231 | enabledBorder: OutlineInputBorder( |
| 232 | borderRadius: BorderRadius.circular(99999), |
| 233 | borderSide: BorderSide.none, |
| 234 | ), |
| 235 | focusedBorder: OutlineInputBorder( |
| 236 | borderRadius: BorderRadius.circular(99999), |
| 237 | borderSide: BorderSide.none, |
| 238 | ), |
| 239 | contentPadding: EdgeInsets.symmetric(vertical: 8), |
| 240 | ), |
| 241 | ), |
| 242 | ), |
| 243 | ), |
| 244 | ), |
| 245 | ), |
| 246 | ), |
| 247 | ), |
| 248 | ); |
| 249 | } |
| 250 | } |
| 251 | |
| 252 | class AccountPreviewHeader extends StatelessWidget { |
| 253 | AccountPreviewHeader({super.key, required this.dashboardViewModel, required this.design}); |
| 254 | |
| 255 | final DashboardViewModel dashboardViewModel; |
| 256 | final CardDesign? design; |
| 257 | |
| 258 | @override |
| 259 | Widget build(BuildContext context) { |
| 260 | return Container( |
| 261 | height: 64, |
| 262 | width: MediaQuery.of(context).size.width * 0.9, |
| 263 | decoration: BoxDecoration( |
| 264 | color: Theme.of(context).colorScheme.surfaceContainer, |
| 265 | borderRadius: BorderRadius.circular(16)), |
| 266 | child: Padding( |
| 267 | padding: EdgeInsets.all(12.0), |
| 268 | child: Row( |
| 269 | mainAxisAlignment: MainAxisAlignment.spaceBetween, |
| 270 | children: [ |
| 271 | Row( |
| 272 | spacing: 10, |
| 273 | children: [ |
| 274 | Observer( |
| 275 | builder: (_) => BalanceCard( |
| 276 | borderRadius: 5, width: 50, design: design ?? CardDesign.genericDefault), |
| 277 | ), |
| 278 | Column( |
| 279 | crossAxisAlignment: CrossAxisAlignment.start, |
| 280 | mainAxisAlignment: MainAxisAlignment.center, |
| 281 | children: [ |
| 282 | Text( |
| 283 | monero?.getCurrentAccount(dashboardViewModel.wallet).label ?? |
| 284 | wownero?.getCurrentAccount(dashboardViewModel.wallet).label ?? |
| 285 | "", |
| 286 | style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.primary), |
| 287 | ), |
| 288 | Text( |
| 289 | dashboardViewModel.wallet.name, |
| 290 | style: TextStyle( |
| 291 | fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant), |
| 292 | ) |
| 293 | ], |
| 294 | ), |
| 295 | ], |
| 296 | ), |
| 297 | Row( |
| 298 | spacing: 12, |
| 299 | children: [ |
| 300 | Container( |
| 301 | width: 1, |
| 302 | height: 40, |
| 303 | decoration: |
| 304 | BoxDecoration(color: Theme.of(context).colorScheme.surfaceContainerHigh), |
| 305 | ), |
| 306 | Text(dashboardViewModel.balanceViewModel.balances.values.first.availableBalance) |
| 307 | ], |
| 308 | ) |
| 309 | ], |
| 310 | ), |
| 311 | ), |
| 312 | ); |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | class AddressRow extends StatelessWidget { |
| 317 | const AddressRow({ |
| 318 | super.key, |
| 319 | required this.selected, |
| 320 | required this.first, |
| 321 | required this.last, |
| 322 | required this.item, |
| 323 | required this.onSelect, |
| 324 | required this.walletType, |
| 325 | required this.onLabelChanged, |
| 326 | required this.onAddressHidden, |
| 327 | required this.hasBalance, |
| 328 | required this.hasReceived, |
| 329 | }); |
| 330 | |
| 331 | final bool selected; |
| 332 | final bool first; |
| 333 | final bool last; |
| 334 | final WalletAddressListItem item; |
| 335 | final VoidCallback onSelect; |
| 336 | final VoidCallback onLabelChanged; |
| 337 | final VoidCallback onAddressHidden; |
| 338 | final WalletType walletType; |
| 339 | final bool hasBalance; |
| 340 | final bool hasReceived; |
| 341 | |
| 342 | @override |
| 343 | Widget build(BuildContext context) { |
| 344 | final hasLabel = item.name != null && item.name!.isNotEmpty; |
| 345 | final hideLabel = item.isHidden ? S.of(context).unhide_address : S.of(context).hide_address; |
| 346 | |
| 347 | Future<void> editLabel() async { |
| 348 | final res = await showPopUp( |
| 349 | context: context, builder: (context) => getIt.get<AddressLabelInputPopup>(param1: item)); |
| 350 | |
| 351 | if (res != null) { |
| 352 | onLabelChanged(); |
| 353 | } |
| 354 | } |
| 355 | |
| 356 | Future<void> showInfo() async { |
| 357 | await showPopUp( |
| 358 | context: context, builder: (context) => getIt.get<AddressInfoPopup>(param1: item)); |
| 359 | } |
| 360 | |
| 361 | // The row is a single control: selection on tap, and the actions that are |
| 362 | // otherwise reachable only by long press exposed as custom actions. |
| 363 | return MergeSemantics( |
| 364 | child: Semantics( |
| 365 | button: true, |
| 366 | selected: selected, |
| 367 | customSemanticsActions: <CustomSemanticsAction, VoidCallback>{ |
| 368 | CustomSemanticsAction(label: S.of(context).set_label): editLabel, |
| 369 | CustomSemanticsAction(label: hideLabel): onAddressHidden, |
| 370 | CustomSemanticsAction(label: S.of(context).info): showInfo, |
| 371 | }, |
| 372 | child: GestureDetector( |
| 373 | onTap: onSelect, |
| 374 | child: Padding( |
| 375 | padding: const EdgeInsets.symmetric(horizontal: 16.0), |
| 376 | child: LongPressPopupBuilder( |
| 377 | popup: LongPressMenu( |
| 378 | items: [ |
| 379 | LongPressMenuItem( |
| 380 | label: S.of(context).set_label, |
| 381 | iconPath: "assets/new-ui/address_set_label.svg", |
| 382 | onSelected: () { |
| 383 | Navigator.of(context, rootNavigator: true).pop(); |
| 384 | editLabel(); |
| 385 | }), |
| 386 | LongPressMenuItem( |
| 387 | label: hideLabel, |
| 388 | iconPath: "assets/new-ui/address_hide.svg", |
| 389 | onSelected: () { |
| 390 | Navigator.of(context, rootNavigator: true).pop(); |
| 391 | onAddressHidden(); |
| 392 | }, |
| 393 | color: Theme.of(context).colorScheme.error), |
| 394 | LongPressMenuItem( |
| 395 | label: S.of(context).info, |
| 396 | iconPath: "assets/images/info_icon.svg", |
| 397 | onSelected: () { |
| 398 | Navigator.of(context, rootNavigator: true).pop(); |
| 399 | showInfo(); |
| 400 | }), |
| 401 | ], |
| 402 | ), |
| 403 | child: AnimatedContainer( |
| 404 | duration: Duration(milliseconds: 150), |
| 405 | decoration: BoxDecoration( |
| 406 | color: selected |
| 407 | ? Theme.of(context).colorScheme.surfaceContainerHigh |
| 408 | : Theme.of(context).colorScheme.surfaceContainer, |
| 409 | borderRadius: BorderRadius.vertical( |
| 410 | top: Radius.circular(first ? 16 : 0), |
| 411 | bottom: Radius.circular(last ? 16 : 0), |
| 412 | ), |
| 413 | ), |
| 414 | child: Padding( |
| 415 | padding: const EdgeInsets.all(12.0), |
| 416 | child: Row( |
| 417 | spacing: 8, |
| 418 | mainAxisSize: MainAxisSize.max, |
| 419 | mainAxisAlignment: MainAxisAlignment.spaceBetween, |
| 420 | children: [ |
| 421 | Expanded( |
| 422 | child: Column( |
| 423 | children: [ |
| 424 | Row( |
| 425 | spacing: 4, |
| 426 | children: [ |
| 427 | if (hasLabel) |
| 428 | Text( |
| 429 | item.name!, |
| 430 | style: TextStyle( |
| 431 | fontSize: 12, |
| 432 | color: Theme.of(context).colorScheme.onSurface, |
| 433 | fontWeight: FontWeight.w500), |
| 434 | ), |
| 435 | Expanded( |
| 436 | child: AddressFormatter.buildSegmentedAddress( |
| 437 | address: item.address, |
| 438 | walletType: walletType, |
| 439 | textAlign: TextAlign.left, |
| 440 | evenTextStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( |
| 441 | fontSize: 12, |
| 442 | fontWeight: FontWeight.w400, |
| 443 | color: hasLabel |
| 444 | ? Theme.of(context).colorScheme.onSurfaceVariant |
| 445 | : Theme.of(context).colorScheme.onSurface, |
| 446 | ), |
| 447 | shouldTruncate: (hasLabel)), |
| 448 | ), |
| 449 | ], |
| 450 | ), |
| 451 | Row( |
| 452 | mainAxisSize: MainAxisSize.max, |
| 453 | mainAxisAlignment: MainAxisAlignment.spaceBetween, |
| 454 | children: [ |
| 455 | Text( |
| 456 | "${S.of(context).transactions}: ${item.txCount}", |
| 457 | style: TextStyle( |
| 458 | fontSize: 12, |
| 459 | color: Theme.of(context).colorScheme.onSurfaceVariant), |
| 460 | ), |
| 461 | Text( |
| 462 | "${hasReceived ? S.of(context).received : S.of(context).balance}: ${item.balance}", |
| 463 | style: TextStyle( |
| 464 | fontSize: 12, |
| 465 | color: Theme.of(context).colorScheme.onSurfaceVariant)), |
| 466 | ], |
| 467 | ) |
| 468 | ], |
| 469 | ), |
| 470 | ), |
| 471 | // The row's selected state carries this information. |
| 472 | if (selected) |
| 473 | ExcludeSemantics( |
| 474 | child: CakeImageWidget(imageUrl: "assets/new-ui/checkmark.svg")) |
| 475 | ], |
| 476 | ), |
| 477 | ), |
| 478 | ), |
| 479 | ), |
| 480 | ), |
| 481 | ), |
| 482 | ), |
| 483 | ); |
| 484 | } |
| 485 | } |
| 486 | |
| 487 | class ShowHiddenButton extends StatelessWidget { |
| 488 | const ShowHiddenButton({super.key}); |
| 489 | |
| 490 | @override |
| 491 | Widget build(BuildContext context) { |
| 492 | return Padding( |
| 493 | padding: const EdgeInsets.symmetric(horizontal: 16.0), |
| 494 | child: Column( |
| 495 | children: [ |
| 496 | Material( |
| 497 | child: MergeSemantics( |
| 498 | child: Semantics( |
| 499 | button: true, |
| 500 | child: InkWell( |
| 501 | onTap: () { |
| 502 | Navigator.of(context).pushNamed(Routes.receiveAddresses, arguments: true); |
| 503 | }, |
| 504 | child: Container( |
| 505 | height: 48, |
| 506 | decoration: BoxDecoration( |
| 507 | color: Theme.of(context).colorScheme.surfaceContainer, |
| 508 | borderRadius: BorderRadius.vertical(top: Radius.circular(16)), |
| 509 | ), |
| 510 | child: Padding( |
| 511 | padding: const EdgeInsets.symmetric(horizontal: 16.0), |
| 512 | child: Row( |
| 513 | mainAxisAlignment: MainAxisAlignment.spaceBetween, |
| 514 | children: [ |
| 515 | Text(S.of(context).show_hidden_addresses), |
| 516 | ExcludeSemantics( |
| 517 | child: RotatedBox( |
| 518 | quarterTurns: 1, |
| 519 | child: |
| 520 | CakeImageWidget(imageUrl: "assets/new-ui/dropdown_arrow.svg")), |
| 521 | ) |
| 522 | ], |
| 523 | ), |
| 524 | ), |
| 525 | ), |
| 526 | ), |
| 527 | ), |
| 528 | ), |
| 529 | ), |
| 530 | Container( |
| 531 | height: 1, |
| 532 | color: Theme.of(context).colorScheme.surfaceContainerHigh, |
| 533 | ), |
| 534 | ], |
| 535 | ), |
| 536 | ); |
| 537 | } |
| 538 | } |