Author SHA1 Message Date
athul rdandClaude Opus 5.5 91a9e4f9a4 Tests: add flutter_test and money formatting tests
The template counter test could never compile (no flutter_test dev
dependency, no MyApp). Replace it with unit tests for formatMoney
(INR byte-identical to the old formatter, AED, BHD) and currency parsing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:34:56 +05:30
athul rdandClaude Opus 5.5 2e0d825c24 Payslips: format amounts in the org currency
/api/v1/me/payslips items and /api/v1/me/profile now carry the org base
currency. Payslip and Profile read it; the payslips screen formats with
the payslip currency, else the last profile's, else INR. INR output is
unchanged (rupee sign, lakh grouping, no decimals); other currencies show
the ISO code with their own minor units (AED 1,234.50, BHD 1,234.500).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 22:34:56 +05:30
7 changed files with 173 additions and 40 deletions
+17 -3
View File
@@ -14,6 +14,8 @@ class Profile {
final String gid, fullName, role; final String gid, fullName, role;
final String? employeeCode, departmentName, designationTitle, reportingManagerName; final String? employeeCode, departmentName, designationTitle, reportingManagerName;
final bool isManager; final bool isManager;
/// Org base currency (ISO 4217); null on older servers ⇒ INR.
final String? currency;
Profile.fromJson(Map<String, dynamic> j) Profile.fromJson(Map<String, dynamic> j)
: gid = j['gid'] ?? '', : gid = j['gid'] ?? '',
fullName = j['fullName'] ?? '', fullName = j['fullName'] ?? '',
@@ -22,7 +24,8 @@ class Profile {
departmentName = j['departmentName'], departmentName = j['departmentName'],
designationTitle = j['designationTitle'], designationTitle = j['designationTitle'],
reportingManagerName = j['reportingManagerName'], reportingManagerName = j['reportingManagerName'],
isManager = j['isManager'] == true; isManager = j['isManager'] == true,
currency = j['currency'] as String?;
} }
class Today { class Today {
@@ -81,13 +84,16 @@ class LeaveRequest {
class Payslip { class Payslip {
final String gid, periodMonth, status; final String gid, periodMonth, status;
final num gross, totalDeductions, netPay; final num gross, totalDeductions, netPay;
/// Org base currency (ISO 4217); null on older servers ⇒ INR.
final String? currency;
Payslip.fromJson(Map<String, dynamic> j) Payslip.fromJson(Map<String, dynamic> j)
: gid = j['gid'] ?? '', : gid = j['gid'] ?? '',
periodMonth = j['periodMonth'] ?? '', periodMonth = j['periodMonth'] ?? '',
status = j['status'] ?? '', status = j['status'] ?? '',
gross = j['gross'] ?? 0, gross = j['gross'] ?? 0,
totalDeductions = j['totalDeductions'] ?? 0, totalDeductions = j['totalDeductions'] ?? 0,
netPay = j['netPay'] ?? 0; netPay = j['netPay'] ?? 0,
currency = j['currency'] as String?;
} }
class TeamMember { class TeamMember {
@@ -110,6 +116,9 @@ class ApiService {
final Settings settings; final Settings settings;
ApiService(this.settings); ApiService(this.settings);
/// Org base currency from the last profile seen (login or /me/profile).
String? currency;
Uri _uri(String p, [Map<String, String>? q]) => Uri _uri(String p, [Map<String, String>? q]) =>
Uri.parse('${Settings.baseUrl}$p').replace(queryParameters: q); Uri.parse('${Settings.baseUrl}$p').replace(queryParameters: q);
@@ -135,12 +144,17 @@ class ApiService {
throw ApiException(_msg(body) ?? 'Invalid email or PIN', res.statusCode); throw ApiException(_msg(body) ?? 'Invalid email or PIN', res.statusCode);
} }
final p = Profile.fromJson(body['profile'] as Map<String, dynamic>); final p = Profile.fromJson(body['profile'] as Map<String, dynamic>);
currency = p.currency ?? currency;
await settings.saveSession(token: body['accessToken'] as String, name: p.fullName, role: p.role); await settings.saveSession(token: body['accessToken'] as String, name: p.fullName, role: p.role);
return p; return p;
} }
// ---- me ---- // ---- me ----
Future<Profile> profile() async => Profile.fromJson(await _get('/api/v1/me/profile')); Future<Profile> profile() async {
final p = Profile.fromJson(await _get('/api/v1/me/profile'));
currency = p.currency ?? currency;
return p;
}
Future<Today> today() async => Today.fromJson(await _get('/api/v1/me/attendance/today')); Future<Today> today() async => Today.fromJson(await _get('/api/v1/me/attendance/today'));
Future<void> checkIn({double? lat, double? lng, String? selfie}) => Future<void> checkIn({double? lat, double? lng, String? selfie}) =>
+18
View File
@@ -0,0 +1,18 @@
import 'package:intl/intl.dart';
/// Formats a payroll amount in the organisation's currency.
///
/// [currency] is the ISO 4217 code the server sends on payslips / profile
/// (`currency`). Missing or blank ⇒ INR, which keeps the original output:
/// rupee sign, Indian lakh grouping, no decimals (e.g. `₹1,23,457`).
/// Other currencies print the ISO code and the currency's own minor units
/// (e.g. `AED 12,345.50`, `BHD 1,234.500`).
String formatMoney(num v, [String? currency]) {
final code = (currency ?? '').trim().toUpperCase();
if (code.isEmpty || code == 'INR') {
return '₹${NumberFormat('#,##,###').format(v)}';
}
// NumberFormat.currency picks the ISO default fraction digits for `name`
// (BHD/KWD/OMR 3, JPY 0, most others 2).
return NumberFormat.currency(locale: 'en', name: code, symbol: '$code ').format(v);
}
+7 -5
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../api_service.dart'; import '../api_service.dart';
import '../money.dart';
import '../theme.dart'; import '../theme.dart';
class PayslipsScreen extends StatefulWidget { class PayslipsScreen extends StatefulWidget {
@@ -40,7 +41,8 @@ class _PayslipsScreenState extends State<PayslipsScreen> {
} }
} }
String _inr(num v) => '₹${NumberFormat('#,##,###').format(v)}'; /// Payslip currency, else the profile's, else INR (older servers).
String _money(Payslip p, num v) => formatMoney(v, p.currency ?? widget.api.currency);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -62,7 +64,7 @@ class _PayslipsScreenState extends State<PayslipsScreen> {
title: Text(_month(p.periodMonth), style: const TextStyle(fontWeight: FontWeight.w600)), title: Text(_month(p.periodMonth), style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: const Text('Net pay'), subtitle: const Text('Net pay'),
trailing: Column(mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, children: [ trailing: Column(mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, children: [
Text(_inr(p.netPay), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: AppColors.emerald)), Text(_money(p, p.netPay), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: AppColors.emerald)),
Text(p.status, style: const TextStyle(fontSize: 11, color: AppColors.muted)), Text(p.status, style: const TextStyle(fontSize: 11, color: AppColors.muted)),
]), ]),
onTap: () => showModalBottomSheet( onTap: () => showModalBottomSheet(
@@ -72,10 +74,10 @@ class _PayslipsScreenState extends State<PayslipsScreen> {
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Text(_month(p.periodMonth), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.navy)), Text(_month(p.periodMonth), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.navy)),
const SizedBox(height: 16), const SizedBox(height: 16),
_row('Gross earnings', _inr(p.gross)), _row('Gross earnings', _money(p, p.gross)),
_row('Total deductions', '− ${_inr(p.totalDeductions)}'), _row('Total deductions', '− ${_money(p, p.totalDeductions)}'),
const Divider(height: 24), const Divider(height: 24),
_row('Net pay', _inr(p.netPay), bold: true), _row('Net pay', _money(p, p.netPay), bold: true),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('Full PDF payslip is available on the Scalar360 web portal.', const Text('Full PDF payslip is available on the Scalar360 web portal.',
style: TextStyle(fontSize: 12, color: AppColors.muted)), style: TextStyle(fontSize: 12, color: AppColors.muted)),
+87 -2
View File
@@ -33,6 +33,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.1" version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters: characters:
dependency: transitive dependency: transitive
description: description:
@@ -61,10 +69,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: clock name: clock
sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.3" version: "1.1.2"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@@ -105,6 +113,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.9" version: "1.0.9"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi: ffi:
dependency: transitive dependency: transitive
description: description:
@@ -198,6 +214,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.35" version: "2.0.35"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins: flutter_web_plugins:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -363,6 +384,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.12.0" version: "4.12.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints: lints:
dependency: transitive dependency: transitive
description: description:
@@ -371,6 +416,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.0" version: "4.0.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.19"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
@@ -528,6 +581,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.2" version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner: string_scanner:
dependency: transitive dependency: transitive
description: description:
@@ -544,6 +613,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.2" version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -640,6 +717,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78"
url: "https://pub.dev"
source: hosted
version: "15.4.0"
web: web:
dependency: transitive dependency: transitive
description: description:
+2
View File
@@ -20,6 +20,8 @@ dependencies:
cupertino_icons: ^1.0.6 cupertino_icons: ^1.0.6
dev_dependencies: dev_dependencies:
flutter_test:
sdk: flutter
flutter_launcher_icons: ^0.14.0 flutter_launcher_icons: ^0.14.0
flutter_native_splash: ^2.4.0 flutter_native_splash: ^2.4.0
flutter_lints: ^4.0.0 flutter_lints: ^4.0.0
+42
View File
@@ -0,0 +1,42 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:intl/intl.dart';
import 'package:scalar_attendance/api_service.dart';
import 'package:scalar_attendance/money.dart';
void main() {
// The formatter the payslips screen used before org currencies.
String legacyInr(num v) => '₹${NumberFormat('#,##,###').format(v)}';
group('formatMoney', () {
test('missing / INR currency is byte-identical to the old rupee output', () {
for (final v in [0, 7, 999, 1000, 54321, 123456.7, 1234567, 98765432.49]) {
expect(formatMoney(v), legacyInr(v));
expect(formatMoney(v, null), legacyInr(v));
expect(formatMoney(v, ''), legacyInr(v));
expect(formatMoney(v, 'INR'), legacyInr(v));
expect(formatMoney(v, 'inr'), legacyInr(v));
}
expect(formatMoney(1234567), '₹12,34,567');
});
test('AED uses the ISO code, western grouping and 2 decimals', () {
expect(formatMoney(12345.5, 'AED'), 'AED 12,345.50');
expect(formatMoney(0, 'AED'), 'AED 0.00');
});
test('BHD uses 3 decimals', () {
expect(formatMoney(1234.5, 'BHD'), 'BHD 1,234.500');
});
});
group('payslip / profile currency', () {
test('parsed when present, null on older servers', () {
final withCur = Payslip.fromJson({'gid': 'p1', 'netPay': 10, 'currency': 'AED'});
final without = Payslip.fromJson({'gid': 'p1', 'netPay': 10});
expect(withCur.currency, 'AED');
expect(without.currency, isNull);
expect(Profile.fromJson({'currency': 'SAR'}).currency, 'SAR');
expect(Profile.fromJson({}).currency, isNull);
});
});
}
-30
View File
@@ -1,30 +0,0 @@
// This is a basic Flutter widget test.
//
// To perform an interaction with a widget in your test, use the WidgetTester
// utility in the flutter_test package. For example, you can send tap and scroll
// gestures. You can also use WidgetTester to find child widgets in the widget
// tree, read text, and verify that the values of widget properties are correct.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:scalar_attendance/main.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
// Verify that our counter starts at 0.
expect(find.text('0'), findsOneWidget);
expect(find.text('1'), findsNothing);
// Tap the '+' icon and trigger a frame.
await tester.tap(find.byIcon(Icons.add));
await tester.pump();
// Verify that our counter has incremented.
expect(find.text('0'), findsNothing);
expect(find.text('1'), findsOneWidget);
});
}