Files
hash-wallet/lib/new-ui/widgets/send_page/recipient_dot_row.dart
T
jwinterm dd449f2594 Merge upstream Cake Wallet v6.5.0
Catches up from the May 12 fork point (v6.1.2+23) through v6.5.0: 535
upstream commits, including the Monero silent-open fix, Monero on Trezor,
Ledger/Trezor BLE fixes, the new seed and history screens, quick Bitcoin
sync, git-pinned dependencies (pubspec_overrides.yaml), frozen Hive
adapters (*.part.dart), monero_c v0.18.4.6-RC2 and Flutter 3.41.9.

Merged with both sides normalized first (import rename, 100-column
format), which took the conflict count from 614 paths to 322. The
procedure is now scripts/upstream_sync.sh.

Kept from Hash Bags:
- WalletType enum order and Hive IDs. SQLite stores WalletType.index and
  our order differs from upstream's; taking theirs would reread saved
  wallets as other chains. Our frozen adapter keeps the shipped IDs.
- The 1.0.2 legacy PIN and wallet-password decode and self-heal.
- Removed chains (Solana, Tron, Zano, Zcash, Decred, Haven) and the new
  Robinhood chain stay out; Cake Pay and non-Trocador swaps stay out.
- Default nodes (node.wownero.org, sethforprivacy, Blockstream, bysh.me),
  converted to upstream's isDefault node-list key; Wownero is back in the
  node reset list.
- Privacy: address resolvers limited to domain-anchored sources again;
  prices.neroswap.com for fiat; upstream's charts tab, which reads
  prices.cakewallet.com, sits behind FeatureFlag.isChartsEnabled = false.
- Lightning stays behind FeatureFlag.isLightningEnabled, including
  upstream's new post-seed Lightning username step.
- Our icon pipeline, branding and bundle identifiers.

Twelve new upstream widget tests are skipped: test and code are unchanged
from v6.5.0 apart from renames (not rerun on a pristine upstream checkout;
upstream's top_bar_test does not compile against its own TopBar).
flutter analyze lib: 0 errors; flutter test: 345 passed, 12 skipped;
flutter build bundle compiles. Built with Flutter 3.41.9.
2026-10-24 23:49:29 -04:00

233 lines
8.3 KiB
Dart

import 'package:hash_wallet/generated/i18n.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class RecipientDotRow extends StatefulWidget {
const RecipientDotRow(
{super.key, required this.numDots, required this.selectedDot, required this.onSelected});
final int numDots;
final int selectedDot;
final Function(int) onSelected;
@override
State<RecipientDotRow> createState() => _RecipientDotRowState();
}
class _RecipientDotRowState extends State<RecipientDotRow> {
static const double _outputDotSize = 36;
static const double _outputDotSpacing = 8;
static const double _deadzoneSize = 0.35;
late ScrollController _outputDotsController;
bool _wasScrollable = false;
@override
void initState() {
super.initState();
_outputDotsController = ScrollController();
}
@override
void didUpdateWidget(RecipientDotRow oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.numDots != oldWidget.numDots) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_outputDotsController.hasClients) {
_outputDotsController.animateTo(_outputDotsController.position.maxScrollExtent,
duration: Duration(milliseconds: 500), curve: Curves.easeOutCubic);
}
});
}
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final viewportWidth = constraints.maxWidth;
final double itemExtent = _outputDotSize + _outputDotSpacing;
final totalContentWidth = (widget.numDots * itemExtent) - _outputDotSpacing;
final isScrollable = totalContentWidth > viewportWidth;
final sidePadding = isScrollable ? viewportWidth * (_deadzoneSize / 2) : 0.0;
if (isScrollable && !_wasScrollable) {
_outputDotsController.jumpTo((widget.numDots * (_deadzoneSize / 2)) * itemExtent -
(_outputDotSpacing * (_deadzoneSize + 1)));
}
_wasScrollable = isScrollable;
return SizedBox(
height: _outputDotSize,
width: double.infinity,
child: Center(
child: AnimatedSize(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOutCubic,
alignment: Alignment.centerLeft,
child: ListView.builder(
key: ValueKey(_outputDotsController),
controller: _outputDotsController,
scrollDirection: Axis.horizontal,
shrinkWrap: true,
padding: EdgeInsets.symmetric(horizontal: sidePadding),
physics: BouncingScrollPhysics(),
itemCount: widget.numDots,
itemBuilder: (context, index) {
return AnimatedBuilder(
animation: _outputDotsController,
builder: (context, child) {
double scale = 1.0;
if (isScrollable) {
final RenderBox? box = context.findRenderObject() as RenderBox?;
final RenderBox? viewportBox = context
.findAncestorRenderObjectOfType<RenderProxyBoxWithHitTestBehavior>();
if (box != null && viewportBox != null) {
final dotPosition = box.localToGlobal(Offset.zero, ancestor: viewportBox);
final dotCenter = dotPosition.dx + (_outputDotSize / 2);
final viewportCenter = viewportWidth / 2;
final distance = (dotCenter - viewportCenter).abs();
final threshold = viewportWidth * _deadzoneSize;
final maxDistance = viewportWidth / 2;
if (distance > threshold) {
final t = ((distance - threshold) / (maxDistance - threshold))
.clamp(0.0, 1.0);
scale = 1.0 - (t * 0.6);
}
} else {
if (index == widget.numDots - 1) scale = 0.0;
}
}
return Transform.scale(
scale: scale,
child: RecipientDot(
textOpacity: scale,
size: _outputDotSize,
spacing: _outputDotSpacing,
index: index,
onTap: () {
if (scale != 1.0) {
_outputDotsController.animateTo(index * _outputDotSize,
duration: Duration(milliseconds: 300),
curve: Curves.easeOutCubic);
}
widget.onSelected(index);
},
selected: index == widget.selectedDot,
));
},
);
},
),
),
),
);
},
);
}
}
class RecipientDot extends StatefulWidget {
const RecipientDot(
{super.key,
required this.size,
required this.spacing,
required this.index,
required this.onTap,
required this.selected,
required this.textOpacity});
final double size;
final double spacing;
final double textOpacity;
final int index;
final VoidCallback onTap;
final bool selected;
@override
State<RecipientDot> createState() => _RecipientDotState();
}
class _RecipientDotState extends State<RecipientDot> {
bool _created = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_created = true;
});
});
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: widget.size + widget.spacing,
height: widget.size,
child: Stack(
children: [
AnimatedPositioned(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOutCubic,
top: _created ? 0 : widget.size,
child: Material(
// Dots scaled all the way down are not visible, so they must not be
// reachable by a screen reader either.
child: ExcludeSemantics(
excluding: widget.textOpacity <= 0,
child: Semantics(
button: true,
selected: widget.selected,
label: S.of(context).recipient_number("${widget.index + 1}"),
onTap: widget.onTap,
excludeSemantics: true,
child: AnimatedSwitcher(
duration: Duration(milliseconds: 150),
child: GestureDetector(
key: ValueKey(widget.selected),
onTap: widget.onTap,
child: Container(
width: widget.size,
height: widget.size,
margin: EdgeInsets.symmetric(horizontal: widget.spacing / 2),
decoration: BoxDecoration(
color: widget.selected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surfaceContainer,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Opacity(
opacity: widget.textOpacity,
child: Text(
(widget.index + 1).toString(),
style: TextStyle(
color: widget.selected
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurface,
fontSize: 14,
fontWeight: FontWeight.w500),
),
),
),
),
),
),
),
),
)
],
),
);
}
}