Display used and reserved limits capacity

This commit is contained in:
Stephan D committed 2026-09-12 10:05:20 +02:00
1 parent ec43d34b4b
commit 2e81c43091
14 files changed
+287 -5

No files matched your search

@@ -991,6 +991,36 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Status could not be refreshed. The status shown is the last known result.'**
String get kycStatusStale;
/// No description provided for @kycUsed.
///
/// In en, this message translates to:
/// **'Used'**
String get kycUsed;
/// No description provided for @kycReserved.
///
/// In en, this message translates to:
/// **'Reserved'**
String get kycReserved;
/// No description provided for @kycUsageHelp.
///
/// In en, this message translates to:
/// **'Used is completed activity. Reserved is held for pending transactions. Both count toward your limit.'**
String get kycUsageHelp;
/// No description provided for @kycOccupied.
///
/// In en, this message translates to:
/// **'Used + reserved'**
String get kycOccupied;
/// No description provided for @kycNoCapacity.
///
/// In en, this message translates to:
/// **'No capacity available'**
String get kycNoCapacity;
}
class _AppLocalizationsDelegate
@@ -502,4 +502,20 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get kycStatusStale =>
'Status could not be refreshed. The status shown is the last known result.';
@override
String get kycUsed => 'Used';
@override
String get kycReserved => 'Reserved';
@override
String get kycUsageHelp =>
'Used is completed activity. Reserved is held for pending transactions. Both count toward your limit.';
@override
String get kycOccupied => 'Used + reserved';
@override
String get kycNoCapacity => 'No capacity available';
}
@@ -503,4 +503,20 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get kycStatusStale =>
'Не удалось обновить статус. Показан последний известный результат.';
@override
String get kycUsed => 'Использовано';
@override
String get kycReserved => 'Зарезервировано';
@override
String get kycUsageHelp =>
'Использовано — завершённые операции. Зарезервировано — суммы, удержанные для ожидающих операций. Обе суммы учитываются в лимите.';
@override
String get kycOccupied => 'Использовано + зарезервировано';
@override
String get kycNoCapacity => 'Доступный объём исчерпан';
}
+25
View File
@@ -49,6 +49,8 @@ class LimitsPanel extends StatelessWidget {
),
if (data.simulated)
Text(l.kycLimitsDemo, style: t.textTheme.bodySmall),
const SizedBox(height: 12),
Text(l.kycUsageHelp, style: t.textTheme.bodySmall),
if (data.rows.isEmpty) Text(l.kycLimitsEmpty),
for (final row in data.rows) ...[
const Divider(height: 32),
@@ -67,6 +69,13 @@ class LimitsPanel extends StatelessWidget {
row.currentMinor,
row.currency,
),
_amount(context, l.kycUsed, row.usedMinor, row.currency),
_amount(
context,
l.kycReserved,
row.reservedMinor,
row.currency,
),
_amount(
context,
l.kycRemaining,
@@ -82,6 +91,22 @@ class LimitsPanel extends StatelessWidget {
),
],
),
const SizedBox(height: 16),
LinearProgressIndicator(
key: ValueKey('usage-${row.period.name}'),
value: row.occupiedFraction,
minHeight: 8,
borderRadius: BorderRadius.circular(4),
semanticsLabel:
'${row.period == LimitPeriod.day ? l.kycDay : l.kycMonth}: ${l.kycOccupied}',
),
if (row.remainingMinor <= 0) ...[
const SizedBox(height: 8),
Text(
l.kycNoCapacity,
style: TextStyle(color: t.colorScheme.error),
),
],
],
],
],
+6 -1
View File
@@ -148,5 +148,10 @@
"kycDemoDeny": "Decline",
"kycPreview": "Explore verification & limits",
"kycChooseFirst": "Choose a photo to continue.",
"kycStatusStale": "Status could not be refreshed. The status shown is the last known result."
"kycStatusStale": "Status could not be refreshed. The status shown is the last known result.",
"kycUsed": "Used",
"kycReserved": "Reserved",
"kycUsageHelp": "Used is completed activity. Reserved is held for pending transactions. Both count toward your limit.",
"kycOccupied": "Used + reserved",
"kycNoCapacity": "No capacity available"
}
+6 -1
View File
@@ -148,5 +148,10 @@
"kycDemoDeny": "Отклонить",
"kycPreview": "Посмотреть верификацию и лимиты",
"kycChooseFirst": "Выберите фото для продолжения.",
"kycStatusStale": "Не удалось обновить статус. Показан последний известный результат."
"kycStatusStale": "Не удалось обновить статус. Показан последний известный результат.",
"kycUsed": "Использовано",
"kycReserved": "Зарезервировано",
"kycUsageHelp": "Использовано — завершённые операции. Зарезервировано — суммы, удержанные для ожидающих операций. Обе суммы учитываются в лимите.",
"kycOccupied": "Использовано + зарезервировано",
"kycNoCapacity": "Доступный объём исчерпан"
}
+86
View File
@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'package:sxchnge_theme/sxchnge_theme.dart';
import 'package:sxchnge_app/kyc/limits.dart';
import 'package:sxchnge_app/generated/i18n/app_localizations.dart';
class Service implements LimitsService {
Service(this.row);
final VisibleLimit row;
@override
Future<Limits> fetch() async =>
Limits(tier: AccountTier.basic, rows: [row], simulated: true);
}
void main() {
for (final locale in ['en', 'ru']) {
testWidgets(
'usage and authoritative available capacity fit narrow $locale layout',
(tester) async {
tester.view.physicalSize = const Size(320, 1800);
tester.view.devicePixelRatio = 1;
tester.platformDispatcher.textScaleFactorTestValue = 1.6;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
final provider = LimitsProvider(
Service(
const VisibleLimit(
period: LimitPeriod.day,
currency: 'EUR',
basicMinor: 10000,
verifiedMinor: 250000,
currentMinor: 10000,
remainingMinor: 5500,
usedMinor: 3000,
reservedMinor: 1000,
),
),
);
addTearDown(provider.dispose);
await provider.refresh();
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: provider,
child: MaterialApp(
theme: SxchngeTheme.light(),
locale: Locale(locale),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(
body: SingleChildScrollView(child: LimitsPanel()),
),
),
),
);
await tester.pumpAndSettle();
expect(
find.text(locale == 'en' ? 'Used' : 'Использовано'),
findsOneWidget,
);
expect(
find.text(locale == 'en' ? 'Reserved' : 'Зарезервировано'),
findsOneWidget,
);
expect(
tester
.widget<LinearProgressIndicator>(
find.byKey(const ValueKey('usage-day')),
)
.value,
0.4,
);
expect(find.textContaining('55'), findsOneWidget);
expect(tester.takeException(), null);
},
);
}
}
+5
View File
@@ -36,3 +36,8 @@ Photo selection uses Flutter's [file_selector](https://pub.dev/packages/file_sel
and [image metadata decoding](https://pub.dev/documentation/image/latest/image/Decoder-class.html).
For native macOS consumers enable the user-selected read-only file entitlement;
iOS/Android camera permissions are not required for this file-picker-only flow.
Visible limit rows also expose `usedMinor` (committed activity) and `reservedMinor`
(pending holds). `remainingMinor` is independently provided by the backend and
must not be inferred as cap minus those fields. The display-only occupied ratio
includes used plus reserved and is bounded for progress rendering.
@@ -10,6 +10,8 @@ class VisibleLimit {
required this.verifiedMinor,
required this.currentMinor,
required this.remainingMinor,
required this.usedMinor,
required this.reservedMinor,
});
final LimitPeriod period;
final String currency;
@@ -17,6 +19,13 @@ class VisibleLimit {
final int verifiedMinor;
final int currentMinor;
final int remainingMinor;
final int usedMinor;
final int reservedMinor;
/// Presentation only: remaining capacity and payment authorization stay server-owned.
double get occupiedFraction => currentMinor <= 0
? 0
: ((usedMinor + reservedMinor) / currentMinor).clamp(0.0, 1.0);
}
class Limits {
@@ -76,7 +76,9 @@ class DummyLimitsService implements LimitsService {
10000,
250000,
approved ? 250000 : 10000,
approved ? 250000 : 10000,
(approved ? 250000 : 10000) - 4000,
3000,
1000,
),
LimitDTO(
'month',
@@ -84,7 +86,9 @@ class DummyLimitsService implements LimitsService {
50000,
1000000,
approved ? 1000000 : 50000,
approved ? 1000000 : 50000,
(approved ? 1000000 : 50000) - 19000,
18000,
1000,
),
],
),
@@ -38,10 +38,12 @@ class LimitDTO {
this.verified,
this.current,
this.remaining,
this.used,
this.reserved,
);
final String period;
final String currency;
final int basic, verified, current, remaining;
final int basic, verified, current, remaining, used, reserved;
}
class LimitsMapper {
@@ -57,6 +59,8 @@ class LimitsMapper {
verifiedMinor: r.verified,
currentMinor: r.current,
remainingMinor: r.remaining,
usedMinor: r.used,
reservedMinor: r.reserved,
),
)
.toList(),
@@ -0,0 +1,47 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
import 'package:sxchnge_kyc/src/transport/contracts.dart';
void main() {
test('mapping preserves backend usage and independent remaining value', () {
final data = LimitsMapper.fromDTO(
const LimitsDTO(
tier: 'basic',
simulated: false,
rows: [LimitDTO('day', 'EUR', 10000, 250000, 10000, 5500, 3000, 1000)],
),
);
final row = data.rows.single;
expect(row.usedMinor, 3000);
expect(row.reservedMinor, 1000);
expect(row.remainingMinor, 5500);
expect(row.occupiedFraction, 0.4);
});
test('capacity visualization handles unused, exhausted and zero caps', () {
VisibleLimit row(int cap, int used, int reserved) => VisibleLimit(
period: LimitPeriod.day,
currency: 'EUR',
basicMinor: cap,
verifiedMinor: cap,
currentMinor: cap,
remainingMinor: 0,
usedMinor: used,
reservedMinor: reserved,
);
expect(row(10000, 0, 0).occupiedFraction, 0);
expect(row(10000, 8000, 2000).occupiedFraction, 1);
expect(row(10000, 12000, 2000).occupiedFraction, 1);
expect(row(0, 0, 0).occupiedFraction, 0);
});
test('stub provides nonzero daily and monthly usage', () async {
final identity = DummyIdentityService(delay: Duration.zero);
final service = DummyLimitsService(identity);
final data = await service.fetch();
expect(data.rows.first.usedMinor, 3000);
expect(data.rows.first.reservedMinor, 1000);
expect(data.rows.first.remainingMinor, 6000);
expect(data.rows.last.usedMinor, 18000);
expect(data.rows.last.remainingMinor, 31000);
});
}