Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}},
Expand Down
10 changes: 8 additions & 2 deletions lib/l10n/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions lib/l10n/app_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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?';
Expand Down
7 changes: 5 additions & 2 deletions lib/l10n/app_localizations_zh.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 重置卡?';
Expand Down
5 changes: 3 additions & 2 deletions lib/l10n/app_zh.arb
Original file line number Diff line number Diff line change
Expand Up @@ -3602,8 +3602,9 @@
"claudeResetClears": "清零:{windows}",
"claudeResetWindowUsed": "{window}(已用 {percent}%)",
"claudeResetWeeklyOf": "{model} 每周",
"claudeResetNext": "下一张",
"claudeResetUse": "使用一张…",
"claudeResetNext": "默认",
"claudeResetNextHint": "服务器默认先用这张",
"claudeResetUse": "使用这张…",
"claudeResetConfirm": "用掉一张 Claude 重置卡?",
"claudeResetConfirmDetail": "「{label}」会把 {windows} 立即清零。卡用掉后不能退回,这些额度现在还没用完的部分也就作废了。",
"claudeResetPaused": "已暂停",
Expand Down
46 changes: 27 additions & 19 deletions lib/models/claude_resets.dart
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ class ClaudeResets {
final String? reason;
final List<ClaudeResetGrant> 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;

Expand All @@ -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<ClaudeResetGrant> 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).
Expand Down Expand Up @@ -248,8 +253,11 @@ class ClaudeResetsService extends ChangeNotifier {
Future<void>? _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<void> refresh({bool force = false}) {
if (_loading case final loading?) return loading;
Expand Down Expand Up @@ -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<ClaudeResetOutcome> 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',
Expand All @@ -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));
}
Expand Down
156 changes: 108 additions & 48 deletions lib/settings/pages/models_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand All @@ -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),
Expand All @@ -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)),
),
],
),
Expand All @@ -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
Expand Down
Loading
Loading