diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 71d19f88e..60bd4896d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -8603,8 +8603,9 @@ "@claudeResetWindowUsed": {"placeholders": {"window": {"type": "String"}, "percent": {"type": "int"}}}, "claudeResetWeeklyOf": "{model} weekly", "@claudeResetWeeklyOf": {"placeholders": {"model": {"type": "String"}}}, - "claudeResetNext": "Next", - "claudeResetUse": "Use One…", + "claudeResetNext": "Default", + "claudeResetNextHint": "The one the server would take first", + "claudeResetUse": "Use This One…", "claudeResetConfirm": "Use a Claude limit reset?", "claudeResetConfirmDetail": "“{label}” sets {windows} back to nothing used, at once. The reset can't be given back, and what is left of these limits now is lost.", "@claudeResetConfirmDetail": {"placeholders": {"label": {"type": "String"}, "windows": {"type": "String"}}}, diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d162b46bb..0306819e1 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -21676,13 +21676,19 @@ abstract class AppLocalizations { /// No description provided for @claudeResetNext. /// /// In en, this message translates to: - /// **'Next'** + /// **'Default'** String get claudeResetNext; + /// No description provided for @claudeResetNextHint. + /// + /// In en, this message translates to: + /// **'The one the server would take first'** + String get claudeResetNextHint; + /// No description provided for @claudeResetUse. /// /// In en, this message translates to: - /// **'Use One…'** + /// **'Use This One…'** String get claudeResetUse; /// No description provided for @claudeResetConfirm. diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 6f1221e0c..ad590eae3 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -13288,10 +13288,13 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get claudeResetNext => 'Next'; + String get claudeResetNext => 'Default'; @override - String get claudeResetUse => 'Use One…'; + String get claudeResetNextHint => 'The one the server would take first'; + + @override + String get claudeResetUse => 'Use This One…'; @override String get claudeResetConfirm => 'Use a Claude limit reset?'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 4393c6ce6..0ba7d8e62 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -12573,10 +12573,13 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String get claudeResetNext => '下一张'; + String get claudeResetNext => '默认'; @override - String get claudeResetUse => '使用一张…'; + String get claudeResetNextHint => '服务器默认先用这张'; + + @override + String get claudeResetUse => '使用这张…'; @override String get claudeResetConfirm => '用掉一张 Claude 重置卡?'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 25b67af76..fbc00c485 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -3602,8 +3602,9 @@ "claudeResetClears": "清零:{windows}", "claudeResetWindowUsed": "{window}(已用 {percent}%)", "claudeResetWeeklyOf": "{model} 每周", - "claudeResetNext": "下一张", - "claudeResetUse": "使用一张…", + "claudeResetNext": "默认", + "claudeResetNextHint": "服务器默认先用这张", + "claudeResetUse": "使用这张…", "claudeResetConfirm": "用掉一张 Claude 重置卡?", "claudeResetConfirmDetail": "「{label}」会把 {windows} 立即清零。卡用掉后不能退回,这些额度现在还没用完的部分也就作废了。", "claudeResetPaused": "已暂停", diff --git a/lib/models/claude_resets.dart b/lib/models/claude_resets.dart index 25f2d71f2..d4c09507a 100644 --- a/lib/models/claude_resets.dart +++ b/lib/models/claude_resets.dart @@ -99,7 +99,7 @@ class ClaudeResets { final String? reason; final List grants; - /// The one the server would have used next: the one used. + /// The one the server would take first, of those the user may pick. final String? nextGrantId; final DateTime? cooldownUntil; @@ -120,16 +120,21 @@ class ClaudeResets { int get count => live.fold(0, (sum, grant) => sum + grant.left); - /// The grant a reset is taken from now, if one can be. - ClaudeResetGrant? get next { - if (!eligible || cooldownUntil?.isAfter(at) == true) return null; - for (final grant in live) { - if (grant.id == nextGrantId && !grant.paused && grant.usableNow) { - return grant; - } - } - return null; - } + /// Whether a reset can be taken from [grant] now, whichever the user + /// picks: while the server names one it would take at all. + bool canUse(ClaudeResetGrant grant) => + eligible && + cooldownUntil?.isAfter(at) != true && + nextGrantId != null && + grant.liveAt(at) && + !grant.paused && + grant.usableNow; + + /// Those a reset can be taken from now. + List get usable => [ + for (final grant in live) + if (canUse(grant)) grant, + ]; /// From a `/api/oauth/usage?cedar_ember=1` answer; null when it has no /// such block (not offered to this account, or not asked). @@ -248,8 +253,11 @@ class ClaudeResetsService extends ChangeNotifier { Future? _loading; DateTime? _askedAt; - bool get using => _using; - bool _using = false; + bool get using => _usingId != null; + + /// The grant one is being used of. + String? get usingId => _usingId; + String? _usingId; Future refresh({bool force = false}) { if (_loading case final loading?) return loading; @@ -278,15 +286,15 @@ class ClaudeResetsService extends ChangeNotifier { } } - /// Uses one of [grant], as it still is the one next once asked again: what - /// the server answered. Never sent twice: a failure is told, not retried. + /// Uses one of [grant], as it still can be once asked again: what the + /// server answered. Never sent twice: a failure is told, not retried. Future use(ClaudeResetGrant grant) async { - if (_using) return const ClaudeResetOutcome('busy'); - _using = true; + if (using) return const ClaudeResetOutcome('busy'); + _usingId = grant.id; notifyListeners(); try { await refresh(force: true); - if (_resets?.next?.id != grant.id) { + if (_resets?.usable.any((usable) => usable.id == grant.id) != true) { return ClaudeResetOutcome( 'unavailable', reason: _error ?? 'no longer offered', @@ -299,7 +307,7 @@ class ClaudeResetsService extends ChangeNotifier { } on ClaudeResetsException catch (error) { return ClaudeResetOutcome('error', reason: error.message); } finally { - _using = false; + _usingId = null; // What is left of them, and of the windows, now. unawaited(refresh(force: true)); } diff --git a/lib/settings/pages/models_page.dart b/lib/settings/pages/models_page.dart index d6d0c7a65..5455aa214 100644 --- a/lib/settings/pages/models_page.dart +++ b/lib/settings/pages/models_page.dart @@ -2632,10 +2632,9 @@ class _ClaudeResetsLabel extends StatelessWidget { final faint = SettingsText.description.copyWith( color: SettingsColors.textSecondary, ); - final next = resets.next; final why = switch (resets) { _ when service.signedOut => l10n.claudeResetSignedOut, - _ when next != null => null, + _ when resets.usable.isNotEmpty => null, ClaudeResets(:final cooldownUntil?) when cooldownUntil.isAfter(DateTime.now()) => l10n.claudeResetCooldown(_time(cooldownUntil)), @@ -2662,31 +2661,7 @@ class _ClaudeResetsLabel extends StatelessWidget { Text(l10n.claudeResetsDetail, style: faint), for (final grant in resets.live) ...[ const SizedBox(height: 8), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Text( - l10n.claudeResetClears(_clears(l10n, resets, grant)), - style: SettingsText.description, - ), - ), - if (grant.id == next?.id) ...[ - const SizedBox(width: 6), - ModelBadge(l10n.claudeResetNext), - ], - ], - ), - Text( - [ - ?grant.label, - if (grant.left > 1) '×${grant.left}', - if (grant.endsAt case final at?) - l10n.modelsResetCreditExpires(_time(at)), - if (grant.paused) l10n.claudeResetPaused, - ].join(' · '), - style: faint, - ), + _grant(context, resets, grant), ], if (why != null) ...[ const SizedBox(height: 8), @@ -2698,27 +2673,13 @@ class _ClaudeResetsLabel extends StatelessWidget { ), ], const SizedBox(height: 8), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - IdeButton( - label: l10n.claudeResetUse, - icon: Codicons.debugRestart, - spinning: service.using, - onPressed: next == null || service.using - ? null - : () => unawaited(_use(context, next)), - ), - const SizedBox(width: 8), - IdeButton( - label: l10n.claudeResetsRefresh, - secondary: true, - spinning: service.loading && !service.using, - onPressed: service.loading - ? null - : () => unawaited(service.refresh(force: true)), - ), - ], + IdeButton( + label: l10n.claudeResetsRefresh, + secondary: true, + spinning: service.loading && !service.using, + onPressed: service.loading + ? null + : () => unawaited(service.refresh(force: true)), ), ], ), @@ -2737,6 +2698,105 @@ class _ClaudeResetsLabel extends StatelessWidget { ), ); } + + /// One grant, framed, with the button that uses it while it can be; the + /// one the server would take first tagged so. + Widget _grant( + BuildContext context, + ClaudeResets resets, + ClaudeResetGrant grant, + ) { + final l10n = context.l10n; + final usable = resets.canUse(grant); + final faint = SettingsText.description.copyWith( + color: SettingsColors.textSecondary, + ); + final clears = l10n.claudeResetClears(_clears(l10n, resets, grant)); + final meta = [ + if (grant.left > 1) '×${grant.left}', + if (grant.endsAt case final at?) l10n.modelsResetCreditExpires(_time(at)), + if (grant.paused) l10n.claudeResetPaused, + ]; + return _GrantFrame( + lit: usable, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + grant.label ?? clears, + style: SettingsText.description.copyWith( + color: SettingsColors.textPrimary, + fontWeight: FontWeight.w600, + ), + ), + ), + if (grant.id == resets.nextGrantId) ...[ + const SizedBox(width: 6), + ModelBadge( + l10n.claudeResetNext, + tooltip: l10n.claudeResetNextHint, + ), + ], + ], + ), + if (grant.label != null) Text(clears, style: faint), + if (meta.isNotEmpty) Text(meta.join(' · '), style: faint), + if (usable) ...[ + const SizedBox(height: 8), + IdeButton( + label: l10n.claudeResetUse, + icon: Codicons.debugRestart, + spinning: service.usingId == grant.id, + onPressed: service.using + ? null + : () => unawaited(_use(context, grant)), + ), + ], + ], + ), + ); + } +} + +/// A grant's frame: in the accent under the pointer when [lit], so which +/// one its button uses is plain. +class _GrantFrame extends StatefulWidget { + const _GrantFrame({required this.lit, required this.child}); + + final bool lit; + final Widget child; + + @override + State<_GrantFrame> createState() => _GrantFrameState(); +} + +class _GrantFrameState extends State<_GrantFrame> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final lit = widget.lit && _hovered; + return MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(10, 8, 10, 8), + decoration: BoxDecoration( + color: lit ? AppColors.accent.withValues(alpha: 0.1) : null, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: lit ? AppColors.accent : SettingsColors.stroke, + ), + ), + child: widget.child, + ), + ); + } } /// A window of an account's quota: how long it is, a bar of how much of diff --git a/test/models/claude_resets_test.dart b/test/models/claude_resets_test.dart index d7986d0b7..e8fe7134c 100644 --- a/test/models/claude_resets_test.dart +++ b/test/models/claude_resets_test.dart @@ -98,7 +98,12 @@ void main() { 'launch-2610-fable', ]); expect(resets.count, 2); - expect(resets.next?.id, 'full-reset-2611'); + expect(resets.nextGrantId, 'full-reset-2611'); + // Either can be picked. + expect(resets.usable.map((g) => g.id), [ + 'full-reset-2611', + 'launch-2610-fable', + ]); expect(resets.scopedModel, 'Fable'); final full = resets.live.first; expect(full.clears, [ @@ -111,30 +116,22 @@ void main() { }); test('none to use while ineligible, cooling down, or none named next', () { - expect( - ClaudeResets.fromUsage(claudeUsageWithResets(eligible: false), now)!.next, - isNull, - ); - expect( - ClaudeResets.fromUsage( - claudeUsageWithResets(cooldownUntil: '2026-11-11T03:00:00Z'), - now, - )!.next, - isNull, - ); - expect( - ClaudeResets.fromUsage(claudeUsageWithResets(next: null), now)!.next, - isNull, - ); + for (final usage in [ + claudeUsageWithResets(eligible: false), + claudeUsageWithResets(cooldownUntil: '2026-11-11T03:00:00Z'), + claudeUsageWithResets(next: null), + ]) { + expect(ClaudeResets.fromUsage(usage, now)!.usable, isEmpty); + } // No block: not offered to this account. expect(ClaudeResets.fromUsage({'five_hour': null}, now), isNull); }); - test('a reset is used once, of the grant still next', () async { + test('a reset is used once, of the grant picked', () async { final api = FakeClaudeResetsApi(claudeUsageWithResets()); final service = ClaudeResetsService(api, now: () => now); await service.refresh(); - final grant = service.resets!.next!; + final grant = service.resets!.usable.first; final outcome = await service.use(grant); expect(outcome.reset, isTrue); expect(outcome.cleared, ['five_hour', 'seven_day']); @@ -145,12 +142,22 @@ void main() { ); }); - test('not used when the grant is no longer the next', () async { + test('one not the server\'s first is used as picked', () async { + final api = FakeClaudeResetsApi(claudeUsageWithResets()); + final service = ClaudeResetsService(api, now: () => now); + await service.refresh(); + final fable = service.resets!.usable.last; + expect(fable.id, 'launch-2610-fable'); + expect((await service.use(fable)).reset, isTrue); + expect(api.claims.single.$1, 'launch-2610-fable'); + }); + + test('not used when it can no longer be, asked again', () async { final api = FakeClaudeResetsApi(claudeUsageWithResets()); final service = ClaudeResetsService(api, now: () => now); await service.refresh(); - final grant = service.resets!.next!; - api.answer = claudeUsageWithResets(next: 'launch-2610-fable'); + final grant = service.resets!.usable.first; + api.answer = claudeUsageWithResets(cooldownUntil: '2026-11-11T03:00:00Z'); final outcome = await service.use(grant); expect(outcome.reset, isFalse); expect(api.claims, isEmpty); @@ -161,7 +168,7 @@ void main() { ..claimAnswer = {'result': 'not_limited', 'reason': 'under the limit'}; final service = ClaudeResetsService(api, now: () => now); await service.refresh(); - final outcome = await service.use(service.resets!.next!); + final outcome = await service.use(service.resets!.usable.first); expect(outcome.reset, isFalse); expect(outcome.result, 'not_limited'); expect(outcome.reason, 'under the limit'); diff --git a/test/settings/models_page_test.dart b/test/settings/models_page_test.dart index c2950f3d4..41f85963b 100644 --- a/test/settings/models_page_test.dart +++ b/test/settings/models_page_test.dart @@ -259,10 +259,12 @@ void main() { ), findsOneWidget, ); - expect(find.text('Next'), findsOneWidget); + expect(find.text('Default'), findsOneWidget); + // Each one its own button: the first is the full reset. + expect(find.text('Use This One…'), findsNWidgets(2)); // Cancelled: nothing sent. - await tester.tap(find.text('Use One…')); + await tester.tap(find.text('Use This One…').first); await tester.pumpAndSettle(); expect(find.text('Use a Claude limit reset?'), findsOneWidget); expect( @@ -275,11 +277,16 @@ void main() { await mouse.removePointer(); await hover(); - await tester.tap(find.text('Use One…')); + // The other one picked: that one used. + await tester.tap(find.text('Use This One…').last); await tester.pumpAndSettle(); + expect( + find.textContaining('New model launch: one Fable reset'), + findsWidgets, + ); await tester.tap(find.text('Reset Now')); await tester.pumpAndSettle(); - expect(api.claims.single.$1, 'full-reset-2611'); + expect(api.claims.single.$1, 'launch-2610-fable'); expect(find.text('Reset: 5h, Weekly'), findsOneWidget); await tester.tap(find.text('OK')); await tester.pumpAndSettle();