init
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
|||||||
|
.dart_tool/
|
||||||
|
.packages
|
||||||
|
build/
|
||||||
|
.flutter-plugins
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
ios/
|
||||||
|
android/
|
||||||
|
web/
|
||||||
|
macos/
|
||||||
|
linux/
|
||||||
|
windows/
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Scalar360 Attendance (Flutter) — Employee self-service
|
||||||
|
|
||||||
|
Per-employee mobile app for **Scalar360 People Operations**. Employees sign in with
|
||||||
|
their work email + PIN and:
|
||||||
|
- **Clock in / out** with GPS geo-fence (and optional selfie)
|
||||||
|
- View **leave** balances, apply for leave, track requests
|
||||||
|
- View **payslips**
|
||||||
|
- **Managers** additionally approve their team's leave and see the team's status
|
||||||
|
|
||||||
|
> Separate app — no relation to `fist_attendance`. Talks only to the Scalar360 API
|
||||||
|
> using a dedicated **employee** auth (`/api/v1/employee/auth` + `/api/v1/me`).
|
||||||
|
|
||||||
|
```
|
||||||
|
Employee app ──(employee JWT)──▶ https://api.scalar360.in
|
||||||
|
sign in POST /api/v1/employee/auth/login { identifier: email, pin }
|
||||||
|
clock in/out POST /api/v1/me/attendance/check-in|check-out { lat, lng, selfie? }
|
||||||
|
my leave GET/POST /api/v1/me/leave/...
|
||||||
|
payslips GET /api/v1/me/payslips
|
||||||
|
team (manager) GET /api/v1/me/team, /me/team/leave, approve|reject
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup (HR, once per employee)
|
||||||
|
In the Scalar360 web app: **People Operations → Employees → open employee → Account & access** →
|
||||||
|
set a **Login email**, choose **Role** (Employee/Manager) and **Reporting manager**, save,
|
||||||
|
then **Generate login PIN** and share it with the employee. Set the office geo-fence in
|
||||||
|
**People Operations → Settings → Attendance** (office latitude/longitude, radius, *Require geo-fence*).
|
||||||
|
|
||||||
|
## Build & run
|
||||||
|
```bash
|
||||||
|
cd scalar_attendance
|
||||||
|
flutter create . # generates android/ ios/ (keeps lib/ & pubspec.yaml)
|
||||||
|
flutter pub get
|
||||||
|
flutter run # use a real device for GPS + camera
|
||||||
|
```
|
||||||
|
|
||||||
|
### Permissions (add after `flutter create .`)
|
||||||
|
**Android** — `android/app/src/main/AndroidManifest.xml` inside `<manifest>`:
|
||||||
|
```xml
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
```
|
||||||
|
**iOS** — `ios/Runner/Info.plist`:
|
||||||
|
```xml
|
||||||
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
|
<string>Used to confirm you are at the office when you check in.</string>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>Used to capture a selfie when you check in.</string>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Requires an employee account with a login email + PIN (set by HR as above).
|
||||||
|
- Geo-fence is enforced by the server: if *Require geo-fence* is on, a check-in outside
|
||||||
|
the office radius is rejected with the distance.
|
||||||
|
- The full PDF payslip is available on the web portal; the app shows the breakdown.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
include: package:flutter_lints/flutter.yaml
|
||||||
|
|
||||||
|
linter:
|
||||||
|
rules:
|
||||||
|
prefer_const_constructors: true
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'settings.dart';
|
||||||
|
|
||||||
|
class ApiException implements Exception {
|
||||||
|
final String message;
|
||||||
|
final int? status;
|
||||||
|
ApiException(this.message, [this.status]);
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Profile {
|
||||||
|
final String gid, fullName, role;
|
||||||
|
final String? employeeCode, departmentName, designationTitle, reportingManagerName;
|
||||||
|
final bool isManager;
|
||||||
|
Profile.fromJson(Map<String, dynamic> j)
|
||||||
|
: gid = j['gid'] ?? '',
|
||||||
|
fullName = j['fullName'] ?? '',
|
||||||
|
role = j['role'] ?? 'EMPLOYEE',
|
||||||
|
employeeCode = j['employeeCode'],
|
||||||
|
departmentName = j['departmentName'],
|
||||||
|
designationTitle = j['designationTitle'],
|
||||||
|
reportingManagerName = j['reportingManagerName'],
|
||||||
|
isManager = j['isManager'] == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Today {
|
||||||
|
final String? status, checkIn, checkOut, date;
|
||||||
|
final num? hours, otHours;
|
||||||
|
final int? lateMinutes;
|
||||||
|
final bool checkedIn;
|
||||||
|
Today.fromJson(Map<String, dynamic> j)
|
||||||
|
: status = j['status'],
|
||||||
|
checkIn = j['checkIn'],
|
||||||
|
checkOut = j['checkOut'],
|
||||||
|
date = j['date'],
|
||||||
|
hours = j['hours'],
|
||||||
|
otHours = j['otHours'],
|
||||||
|
lateMinutes = j['lateMinutes'],
|
||||||
|
checkedIn = j['checkedIn'] == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LeaveBalance {
|
||||||
|
final String leaveTypeGid, leaveTypeName;
|
||||||
|
final bool paid;
|
||||||
|
final num allocated, used, available;
|
||||||
|
LeaveBalance.fromJson(Map<String, dynamic> j)
|
||||||
|
: leaveTypeGid = j['leaveTypeGid'] ?? '',
|
||||||
|
leaveTypeName = j['leaveTypeName'] ?? '',
|
||||||
|
paid = j['paid'] == true,
|
||||||
|
allocated = j['allocated'] ?? 0,
|
||||||
|
used = j['used'] ?? 0,
|
||||||
|
available = j['available'] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LeaveType {
|
||||||
|
final String gid, name;
|
||||||
|
final bool paid;
|
||||||
|
LeaveType.fromJson(Map<String, dynamic> j)
|
||||||
|
: gid = j['gid'] ?? '',
|
||||||
|
name = j['name'] ?? '',
|
||||||
|
paid = j['paid'] == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LeaveRequest {
|
||||||
|
final String gid, status;
|
||||||
|
final String? leaveTypeName, fromDate, toDate, reason, employeeName;
|
||||||
|
final num? days;
|
||||||
|
LeaveRequest.fromJson(Map<String, dynamic> j)
|
||||||
|
: gid = j['gid'] ?? '',
|
||||||
|
status = j['status'] ?? '',
|
||||||
|
leaveTypeName = j['leaveTypeName'],
|
||||||
|
fromDate = j['fromDate'],
|
||||||
|
toDate = j['toDate'],
|
||||||
|
reason = j['reason'],
|
||||||
|
employeeName = j['employeeName'],
|
||||||
|
days = j['days'];
|
||||||
|
}
|
||||||
|
|
||||||
|
class Payslip {
|
||||||
|
final String gid, periodMonth, status;
|
||||||
|
final num gross, totalDeductions, netPay;
|
||||||
|
Payslip.fromJson(Map<String, dynamic> j)
|
||||||
|
: gid = j['gid'] ?? '',
|
||||||
|
periodMonth = j['periodMonth'] ?? '',
|
||||||
|
status = j['status'] ?? '',
|
||||||
|
gross = j['gross'] ?? 0,
|
||||||
|
totalDeductions = j['totalDeductions'] ?? 0,
|
||||||
|
netPay = j['netPay'] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TeamMember {
|
||||||
|
final String employeeGid, employeeName, status;
|
||||||
|
final String? employeeCode, checkIn, checkOut;
|
||||||
|
final num? hours;
|
||||||
|
final bool checkedIn;
|
||||||
|
TeamMember.fromJson(Map<String, dynamic> j)
|
||||||
|
: employeeGid = j['employeeGid'] ?? '',
|
||||||
|
employeeName = j['employeeName'] ?? '',
|
||||||
|
status = j['status'] ?? '—',
|
||||||
|
employeeCode = j['employeeCode'],
|
||||||
|
checkIn = j['checkIn'],
|
||||||
|
checkOut = j['checkOut'],
|
||||||
|
hours = j['hours'],
|
||||||
|
checkedIn = j['checkedIn'] == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiService {
|
||||||
|
final Settings settings;
|
||||||
|
ApiService(this.settings);
|
||||||
|
|
||||||
|
Uri _uri(String p, [Map<String, String>? q]) =>
|
||||||
|
Uri.parse('${settings.baseUrl}$p').replace(queryParameters: q);
|
||||||
|
|
||||||
|
Map<String, String> get _h => {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
if ((settings.token ?? '').isNotEmpty) 'Authorization': 'Bearer ${settings.token}',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- auth ----
|
||||||
|
Future<Profile> login(String identifier, String pin) async {
|
||||||
|
http.Response res;
|
||||||
|
try {
|
||||||
|
res = await http
|
||||||
|
.post(_uri('/api/v1/employee/auth/login'),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({'identifier': identifier.trim(), 'pin': pin}))
|
||||||
|
.timeout(const Duration(seconds: 20));
|
||||||
|
} catch (_) {
|
||||||
|
throw ApiException('Cannot reach server. Check the URL and connection.');
|
||||||
|
}
|
||||||
|
final body = _json(res);
|
||||||
|
if (res.statusCode != 200 || body is! Map) {
|
||||||
|
throw ApiException(_msg(body) ?? 'Invalid email or PIN', res.statusCode);
|
||||||
|
}
|
||||||
|
final p = Profile.fromJson(body['profile'] as Map<String, dynamic>);
|
||||||
|
await settings.saveSession(token: body['accessToken'] as String, name: p.fullName, role: p.role);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- me ----
|
||||||
|
Future<Profile> profile() async => Profile.fromJson(await _get('/api/v1/me/profile'));
|
||||||
|
Future<Today> today() async => Today.fromJson(await _get('/api/v1/me/attendance/today'));
|
||||||
|
|
||||||
|
Future<void> checkIn({double? lat, double? lng, String? selfie}) =>
|
||||||
|
_post('/api/v1/me/attendance/check-in', _geo(lat, lng, selfie));
|
||||||
|
Future<void> checkOut({double? lat, double? lng, String? selfie}) =>
|
||||||
|
_post('/api/v1/me/attendance/check-out', _geo(lat, lng, selfie));
|
||||||
|
Map<String, dynamic> _geo(double? lat, double? lng, String? selfie) => {
|
||||||
|
if (lat != null) 'lat': lat,
|
||||||
|
if (lng != null) 'lng': lng,
|
||||||
|
if (selfie != null) 'selfie': selfie,
|
||||||
|
};
|
||||||
|
|
||||||
|
Future<List<LeaveType>> leaveTypes() async =>
|
||||||
|
(await _get('/api/v1/me/leave/types') as List).map((e) => LeaveType.fromJson(e)).toList();
|
||||||
|
Future<List<LeaveBalance>> leaveBalances() async =>
|
||||||
|
(await _get('/api/v1/me/leave/balances') as List).map((e) => LeaveBalance.fromJson(e)).toList();
|
||||||
|
Future<List<LeaveRequest>> myLeave() async =>
|
||||||
|
(await _get('/api/v1/me/leave/requests') as List).map((e) => LeaveRequest.fromJson(e)).toList();
|
||||||
|
Future<void> applyLeave(String typeGid, String from, String to, String reason) =>
|
||||||
|
_post('/api/v1/me/leave/requests', {'leaveTypeGid': typeGid, 'fromDate': from, 'toDate': to, 'reason': reason});
|
||||||
|
|
||||||
|
Future<List<Payslip>> payslips() async =>
|
||||||
|
(await _get('/api/v1/me/payslips') as List).map((e) => Payslip.fromJson(e)).toList();
|
||||||
|
|
||||||
|
Future<List<TeamMember>> team() async =>
|
||||||
|
(await _get('/api/v1/me/team') as List).map((e) => TeamMember.fromJson(e)).toList();
|
||||||
|
Future<List<LeaveRequest>> teamLeave() async =>
|
||||||
|
(await _get('/api/v1/me/team/leave') as List).map((e) => LeaveRequest.fromJson(e)).toList();
|
||||||
|
Future<void> approve(String gid) => _post('/api/v1/me/team/leave/$gid/approve', {});
|
||||||
|
Future<void> reject(String gid) => _post('/api/v1/me/team/leave/$gid/reject', {});
|
||||||
|
|
||||||
|
// ---- helpers ----
|
||||||
|
Future<dynamic> _get(String p, [Map<String, String>? q]) async {
|
||||||
|
http.Response r;
|
||||||
|
try {
|
||||||
|
r = await http.get(_uri(p, q), headers: _h).timeout(const Duration(seconds: 20));
|
||||||
|
} catch (_) {
|
||||||
|
throw ApiException('Network error');
|
||||||
|
}
|
||||||
|
return _handle(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<dynamic> _post(String p, Map<String, dynamic> body) async {
|
||||||
|
http.Response r;
|
||||||
|
try {
|
||||||
|
r = await http.post(_uri(p), headers: _h, body: jsonEncode(body)).timeout(const Duration(seconds: 20));
|
||||||
|
} catch (_) {
|
||||||
|
throw ApiException('Network error');
|
||||||
|
}
|
||||||
|
return _handle(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic _handle(http.Response r) {
|
||||||
|
if (r.statusCode == 401) throw ApiException('Session expired. Please sign in again.', 401);
|
||||||
|
if (r.statusCode == 204 || r.body.isEmpty) return null;
|
||||||
|
final body = _json(r);
|
||||||
|
if (r.statusCode >= 400) throw ApiException(_msg(body) ?? 'Request failed (${r.statusCode})', r.statusCode);
|
||||||
|
if (body is Map && body.containsKey('data') && body.containsKey('success')) return body['data'];
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic _json(http.Response r) {
|
||||||
|
try {
|
||||||
|
return jsonDecode(r.body);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _msg(dynamic b) => (b is Map && b['message'] is String) ? b['message'] as String : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'api_service.dart';
|
||||||
|
import 'settings.dart';
|
||||||
|
import 'theme.dart';
|
||||||
|
import 'screens/login_screen.dart';
|
||||||
|
import 'screens/home_screen.dart';
|
||||||
|
|
||||||
|
Future<void> main() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
final settings = Settings();
|
||||||
|
await settings.load();
|
||||||
|
runApp(ScalarEssApp(settings: settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
class ScalarEssApp extends StatelessWidget {
|
||||||
|
final Settings settings;
|
||||||
|
const ScalarEssApp({super.key, required this.settings});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final api = ApiService(settings);
|
||||||
|
return MaterialApp(
|
||||||
|
title: 'Scalar360 Attendance',
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
theme: buildTheme(),
|
||||||
|
home: settings.isLoggedIn
|
||||||
|
? HomeScreen(api: api, settings: settings)
|
||||||
|
: LoginScreen(api: api, settings: settings),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import '../api_service.dart';
|
||||||
|
import '../theme.dart';
|
||||||
|
|
||||||
|
class ClockScreen extends StatefulWidget {
|
||||||
|
final ApiService api;
|
||||||
|
final VoidCallback onAuthError;
|
||||||
|
const ClockScreen({super.key, required this.api, required this.onAuthError});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ClockScreen> createState() => _ClockScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ClockScreenState extends State<ClockScreen> {
|
||||||
|
Today? _today;
|
||||||
|
bool _loading = true;
|
||||||
|
bool _busy = false;
|
||||||
|
String? _selfie;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
try {
|
||||||
|
final t = await widget.api.today();
|
||||||
|
if (mounted) setState(() { _today = t; _loading = false; });
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (e.status == 401) return widget.onAuthError();
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Position> _position() async {
|
||||||
|
if (!await Geolocator.isLocationServiceEnabled()) {
|
||||||
|
throw ApiException('Turn on location services to check in');
|
||||||
|
}
|
||||||
|
var perm = await Geolocator.checkPermission();
|
||||||
|
if (perm == LocationPermission.denied) perm = await Geolocator.requestPermission();
|
||||||
|
if (perm == LocationPermission.denied || perm == LocationPermission.deniedForever) {
|
||||||
|
throw ApiException('Location permission is required to check in');
|
||||||
|
}
|
||||||
|
return Geolocator.getCurrentPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _selfieCapture() async {
|
||||||
|
try {
|
||||||
|
final XFile? x = await ImagePicker().pickImage(
|
||||||
|
source: ImageSource.camera, preferredCameraDevice: CameraDevice.front, maxWidth: 640, imageQuality: 55);
|
||||||
|
if (x != null) {
|
||||||
|
final b = await x.readAsBytes();
|
||||||
|
setState(() => _selfie = 'data:image/jpeg;base64,${base64Encode(b)}');
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
_snack('Could not open camera');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _punch(bool checkIn) async {
|
||||||
|
setState(() => _busy = true);
|
||||||
|
try {
|
||||||
|
final pos = await _position();
|
||||||
|
if (checkIn) {
|
||||||
|
await widget.api.checkIn(lat: pos.latitude, lng: pos.longitude, selfie: _selfie);
|
||||||
|
} else {
|
||||||
|
await widget.api.checkOut(lat: pos.latitude, lng: pos.longitude, selfie: _selfie);
|
||||||
|
}
|
||||||
|
_selfie = null;
|
||||||
|
await _load();
|
||||||
|
_snack(checkIn ? 'Checked in' : 'Checked out');
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (e.status == 401) return widget.onAuthError();
|
||||||
|
_snack(e.message);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _snack(String m) {
|
||||||
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(m)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_loading) return const Center(child: CircularProgressIndicator(color: AppColors.emerald));
|
||||||
|
final t = _today;
|
||||||
|
final checkedIn = t?.checkedIn ?? false;
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _load,
|
||||||
|
color: AppColors.emerald,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 150, height: 150,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: (checkedIn ? AppColors.emerald : AppColors.muted).withValues(alpha: 0.10),
|
||||||
|
border: Border.all(color: (checkedIn ? AppColors.emerald : AppColors.border), width: 3),
|
||||||
|
),
|
||||||
|
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||||
|
Icon(checkedIn ? Icons.work_history : Icons.co_present, color: checkedIn ? AppColors.emerald : AppColors.muted, size: 40),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(checkedIn ? 'Working' : (t?.checkOut != null ? 'Done' : 'Not in'),
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, color: checkedIn ? AppColors.emerald : AppColors.navy)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(mainAxisAlignment: MainAxisAlignment.spaceAround, children: [
|
||||||
|
_stat('In', t?.checkIn ?? '—'),
|
||||||
|
_stat('Out', t?.checkOut ?? '—'),
|
||||||
|
_stat('Hours', t?.hours == null ? '—' : '${t!.hours}'),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (t?.lateMinutes != null && t!.lateMinutes! > 0)
|
||||||
|
Center(child: Text('Late by ${t.lateMinutes} min', style: const TextStyle(color: AppColors.danger, fontSize: 12))),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
// selfie row
|
||||||
|
Row(children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _selfieCapture,
|
||||||
|
child: Container(
|
||||||
|
width: 54, height: 54,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surface, borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
image: _selfie != null ? DecorationImage(image: MemoryImage(base64Decode(_selfie!.split(',').last)), fit: BoxFit.cover) : null,
|
||||||
|
),
|
||||||
|
child: _selfie == null ? const Icon(Icons.camera_alt_outlined, color: AppColors.muted) : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(_selfie == null ? 'Tap to add a selfie (if your office requires one)' : 'Selfie attached',
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppColors.muted)),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _busy ? null : () => _punch(!checkedIn),
|
||||||
|
style: FilledButton.styleFrom(backgroundColor: checkedIn ? AppColors.danger : AppColors.emerald),
|
||||||
|
icon: _busy
|
||||||
|
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||||
|
: Icon(checkedIn ? Icons.logout : Icons.login),
|
||||||
|
label: Text(_busy ? 'Please wait…' : (checkedIn ? 'Check out' : 'Check in')),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
const Center(child: Text('Your location is captured to confirm office presence.',
|
||||||
|
textAlign: TextAlign.center, style: TextStyle(fontSize: 11, color: AppColors.muted))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _stat(String label, String value) {
|
||||||
|
return Column(children: [
|
||||||
|
Text(value, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.navy)),
|
||||||
|
Text(label, style: const TextStyle(fontSize: 11, color: AppColors.muted)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../api_service.dart';
|
||||||
|
import '../settings.dart';
|
||||||
|
import '../theme.dart';
|
||||||
|
import 'clock_screen.dart';
|
||||||
|
import 'leave_screen.dart';
|
||||||
|
import 'payslips_screen.dart';
|
||||||
|
import 'team_screen.dart';
|
||||||
|
import 'login_screen.dart';
|
||||||
|
import 'settings_screen.dart';
|
||||||
|
|
||||||
|
class HomeScreen extends StatefulWidget {
|
||||||
|
final ApiService api;
|
||||||
|
final Settings settings;
|
||||||
|
const HomeScreen({super.key, required this.api, required this.settings});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HomeScreen> createState() => _HomeScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomeScreenState extends State<HomeScreen> {
|
||||||
|
int _index = 0;
|
||||||
|
|
||||||
|
void _logout() async {
|
||||||
|
await widget.settings.clearSession();
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.pushReplacement(context,
|
||||||
|
MaterialPageRoute(builder: (_) => LoginScreen(api: widget.api, settings: widget.settings)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final manager = widget.settings.isManager;
|
||||||
|
final tabs = <_Tab>[
|
||||||
|
_Tab('Clock', Icons.access_time_outlined, Icons.access_time_filled, ClockScreen(api: widget.api, onAuthError: _logout)),
|
||||||
|
_Tab('Leave', Icons.event_note_outlined, Icons.event_note, LeaveScreen(api: widget.api, onAuthError: _logout)),
|
||||||
|
_Tab('Payslips', Icons.receipt_long_outlined, Icons.receipt_long, PayslipsScreen(api: widget.api, onAuthError: _logout)),
|
||||||
|
if (manager) _Tab('Team', Icons.groups_outlined, Icons.groups, TeamScreen(api: widget.api, onAuthError: _logout)),
|
||||||
|
];
|
||||||
|
final i = _index.clamp(0, tabs.length - 1);
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
titleSpacing: 20,
|
||||||
|
title: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text(tabs[i].label, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
|
if ((widget.settings.name ?? '').isNotEmpty)
|
||||||
|
Text(widget.settings.name!, style: const TextStyle(fontSize: 12, color: AppColors.muted, fontWeight: FontWeight.normal)),
|
||||||
|
]),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.settings_outlined),
|
||||||
|
onPressed: () => Navigator.push(context,
|
||||||
|
MaterialPageRoute(builder: (_) => SettingsScreen(settings: widget.settings, onLogout: _logout))),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: IndexedStack(index: i, children: tabs.map((t) => t.screen).toList()),
|
||||||
|
bottomNavigationBar: NavigationBar(
|
||||||
|
selectedIndex: i,
|
||||||
|
onDestinationSelected: (v) => setState(() => _index = v),
|
||||||
|
destinations: tabs
|
||||||
|
.map((t) => NavigationDestination(icon: Icon(t.icon), selectedIcon: Icon(t.selected), label: t.label))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Tab {
|
||||||
|
final String label;
|
||||||
|
final IconData icon, selected;
|
||||||
|
final Widget screen;
|
||||||
|
_Tab(this.label, this.icon, this.selected, this.screen);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../api_service.dart';
|
||||||
|
import '../theme.dart';
|
||||||
|
|
||||||
|
class LeaveScreen extends StatefulWidget {
|
||||||
|
final ApiService api;
|
||||||
|
final VoidCallback onAuthError;
|
||||||
|
const LeaveScreen({super.key, required this.api, required this.onAuthError});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LeaveScreen> createState() => _LeaveScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LeaveScreenState extends State<LeaveScreen> {
|
||||||
|
List<LeaveBalance> _balances = [];
|
||||||
|
List<LeaveRequest> _requests = [];
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
try {
|
||||||
|
final b = await widget.api.leaveBalances();
|
||||||
|
final r = await widget.api.myLeave();
|
||||||
|
if (mounted) setState(() { _balances = b; _requests = r; _loading = false; });
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (e.status == 401) return widget.onAuthError();
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _apply() async {
|
||||||
|
final types = await widget.api.leaveTypes().catchError((_) => <LeaveType>[]);
|
||||||
|
if (!mounted || types.isEmpty) {
|
||||||
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('No leave types set up')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final ok = await showModalBottomSheet<bool>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (_) => _ApplySheet(api: widget.api, types: types),
|
||||||
|
);
|
||||||
|
if (ok == true) _load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
onPressed: _apply, backgroundColor: AppColors.emerald,
|
||||||
|
icon: const Icon(Icons.add), label: const Text('Apply'),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
body: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator(color: AppColors.emerald))
|
||||||
|
: RefreshIndicator(
|
||||||
|
onRefresh: _load,
|
||||||
|
color: AppColors.emerald,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 90),
|
||||||
|
children: [
|
||||||
|
const Text('Balances', style: TextStyle(fontWeight: FontWeight.w600, color: AppColors.navy)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (_balances.isEmpty)
|
||||||
|
const Text('No leave types.', style: TextStyle(color: AppColors.muted))
|
||||||
|
else
|
||||||
|
..._balances.map((b) => Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(b.leaveTypeName, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
subtitle: Text('${b.used} used of ${b.allocated}'),
|
||||||
|
trailing: Text('${b.available}',
|
||||||
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.emerald)),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('My requests', style: TextStyle(fontWeight: FontWeight.w600, color: AppColors.navy)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (_requests.isEmpty)
|
||||||
|
const Text('No requests yet.', style: TextStyle(color: AppColors.muted))
|
||||||
|
else
|
||||||
|
..._requests.map((r) => Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: ListTile(
|
||||||
|
title: Text('${r.leaveTypeName ?? 'Leave'} · ${r.days ?? ''} day(s)'),
|
||||||
|
subtitle: Text('${r.fromDate} → ${r.toDate}${r.reason != null ? '\n${r.reason}' : ''}'),
|
||||||
|
isThreeLine: r.reason != null,
|
||||||
|
trailing: _statusChip(r.status),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _statusChip(String s) {
|
||||||
|
final c = s == 'APPROVED' ? AppColors.emerald : s == 'REJECTED' ? AppColors.danger : AppColors.gold;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(color: c.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Text(s, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: c)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ApplySheet extends StatefulWidget {
|
||||||
|
final ApiService api;
|
||||||
|
final List<LeaveType> types;
|
||||||
|
const _ApplySheet({required this.api, required this.types});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ApplySheet> createState() => _ApplySheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ApplySheetState extends State<_ApplySheet> {
|
||||||
|
String? _type;
|
||||||
|
DateTime? _from, _to;
|
||||||
|
final _reason = TextEditingController();
|
||||||
|
bool _busy = false;
|
||||||
|
|
||||||
|
Future<void> _pick(bool from) async {
|
||||||
|
final d = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: DateTime.now(),
|
||||||
|
firstDate: DateTime.now().subtract(const Duration(days: 30)),
|
||||||
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||||
|
);
|
||||||
|
if (d != null) setState(() => from ? _from = d : _to = d);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_type == null || _from == null || _to == null) return;
|
||||||
|
setState(() => _busy = true);
|
||||||
|
final fmt = DateFormat('yyyy-MM-dd');
|
||||||
|
try {
|
||||||
|
await widget.api.applyLeave(_type!, fmt.format(_from!), fmt.format(_to!), _reason.text);
|
||||||
|
if (mounted) Navigator.pop(context, true);
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
|
||||||
|
setState(() => _busy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final fmt = DateFormat('dd MMM yyyy');
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||||
|
const Text('Apply for leave', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.navy)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: _type,
|
||||||
|
decoration: const InputDecoration(labelText: 'Leave type'),
|
||||||
|
items: widget.types.map((t) => DropdownMenuItem(value: t.gid, child: Text(t.name))).toList(),
|
||||||
|
onChanged: (v) => setState(() => _type = v),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(children: [
|
||||||
|
Expanded(child: OutlinedButton(onPressed: () => _pick(true), child: Text(_from == null ? 'From' : fmt.format(_from!)))),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(child: OutlinedButton(onPressed: () => _pick(false), child: Text(_to == null ? 'To' : fmt.format(_to!)))),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(controller: _reason, decoration: const InputDecoration(labelText: 'Reason'), maxLines: 2),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _busy ? null : _submit,
|
||||||
|
child: _busy
|
||||||
|
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||||
|
: const Text('Submit request'),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../api_service.dart';
|
||||||
|
import '../settings.dart';
|
||||||
|
import '../theme.dart';
|
||||||
|
import 'home_screen.dart';
|
||||||
|
import 'settings_screen.dart';
|
||||||
|
|
||||||
|
class LoginScreen extends StatefulWidget {
|
||||||
|
final ApiService api;
|
||||||
|
final Settings settings;
|
||||||
|
const LoginScreen({super.key, required this.api, required this.settings});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LoginScreen> createState() => _LoginScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginScreenState extends State<LoginScreen> {
|
||||||
|
final _email = TextEditingController();
|
||||||
|
final _pin = TextEditingController();
|
||||||
|
bool _busy = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
Future<void> _login() async {
|
||||||
|
if (_email.text.trim().isEmpty || _pin.text.isEmpty) {
|
||||||
|
setState(() => _error = 'Enter your work email and PIN');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() { _busy = true; _error = null; });
|
||||||
|
try {
|
||||||
|
await widget.api.login(_email.text, _pin.text);
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.pushReplacement(context,
|
||||||
|
MaterialPageRoute(builder: (_) => HomeScreen(api: widget.api, settings: widget.settings)));
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
setState(() => _error = e.message);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
|
child: Center(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
||||||
|
Row(children: [
|
||||||
|
Container(
|
||||||
|
width: 40, height: 40,
|
||||||
|
decoration: BoxDecoration(color: AppColors.emerald, borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: const Icon(Icons.badge_outlined, color: Colors.white),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
const Text('Scalar360', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.navy)),
|
||||||
|
]),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.settings_outlined, color: AppColors.muted),
|
||||||
|
onPressed: () => Navigator.push(context,
|
||||||
|
MaterialPageRoute(builder: (_) => SettingsScreen(settings: widget.settings))),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
const Text('Employee sign in',
|
||||||
|
style: TextStyle(fontSize: 26, fontWeight: FontWeight.bold, color: AppColors.navy)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Text('Use your work email and the PIN from HR', style: TextStyle(color: AppColors.muted)),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
TextField(
|
||||||
|
controller: _email,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
autocorrect: false,
|
||||||
|
decoration: const InputDecoration(labelText: 'Work email', prefixIcon: Icon(Icons.mail_outline)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
TextField(
|
||||||
|
controller: _pin,
|
||||||
|
obscureText: true,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
onSubmitted: (_) => _login(),
|
||||||
|
decoration: const InputDecoration(labelText: 'PIN', prefixIcon: Icon(Icons.pin_outlined)),
|
||||||
|
),
|
||||||
|
if (_error != null) ...[
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(color: AppColors.danger.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(12)),
|
||||||
|
child: Row(children: [
|
||||||
|
const Icon(Icons.error_outline, color: AppColors.danger, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text(_error!, style: const TextStyle(color: AppColors.danger))),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _busy ? null : _login,
|
||||||
|
child: _busy
|
||||||
|
? const SizedBox(height: 22, width: 22, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||||
|
: const Text('Sign in'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('Server: ${widget.settings.baseUrl}',
|
||||||
|
textAlign: TextAlign.center, style: const TextStyle(color: AppColors.muted, fontSize: 12)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import '../api_service.dart';
|
||||||
|
import '../theme.dart';
|
||||||
|
|
||||||
|
class PayslipsScreen extends StatefulWidget {
|
||||||
|
final ApiService api;
|
||||||
|
final VoidCallback onAuthError;
|
||||||
|
const PayslipsScreen({super.key, required this.api, required this.onAuthError});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PayslipsScreen> createState() => _PayslipsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PayslipsScreenState extends State<PayslipsScreen> {
|
||||||
|
List<Payslip> _rows = [];
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
try {
|
||||||
|
final r = await widget.api.payslips();
|
||||||
|
if (mounted) setState(() { _rows = r; _loading = false; });
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (e.status == 401) return widget.onAuthError();
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _month(String m) {
|
||||||
|
try {
|
||||||
|
return DateFormat('MMMM yyyy').format(DateTime.parse('$m-01'));
|
||||||
|
} catch (_) {
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _inr(num v) => '₹${NumberFormat('#,##,###').format(v)}';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_loading) return const Center(child: CircularProgressIndicator(color: AppColors.emerald));
|
||||||
|
if (_rows.isEmpty) {
|
||||||
|
return const Center(child: Text('No payslips yet.', style: TextStyle(color: AppColors.muted)));
|
||||||
|
}
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _load,
|
||||||
|
color: AppColors.emerald,
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: _rows.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final p = _rows[i];
|
||||||
|
return Card(
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(_month(p.periodMonth), style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
subtitle: const Text('Net pay'),
|
||||||
|
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(p.status, style: const TextStyle(fontSize: 11, color: AppColors.muted)),
|
||||||
|
]),
|
||||||
|
onTap: () => showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [
|
||||||
|
Text(_month(p.periodMonth), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: AppColors.navy)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_row('Gross earnings', _inr(p.gross)),
|
||||||
|
_row('Total deductions', '− ${_inr(p.totalDeductions)}'),
|
||||||
|
const Divider(height: 24),
|
||||||
|
_row('Net pay', _inr(p.netPay), bold: true),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('Full PDF payslip is available on the Scalar360 web portal.',
|
||||||
|
style: TextStyle(fontSize: 12, color: AppColors.muted)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _row(String l, String v, {bool bold = false}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
||||||
|
Text(l, style: TextStyle(color: bold ? AppColors.navy : AppColors.muted, fontWeight: bold ? FontWeight.bold : FontWeight.normal)),
|
||||||
|
Text(v, style: TextStyle(fontWeight: FontWeight.w600, color: bold ? AppColors.emerald : AppColors.navy, fontSize: bold ? 18 : 14)),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../settings.dart';
|
||||||
|
import '../theme.dart';
|
||||||
|
|
||||||
|
class SettingsScreen extends StatefulWidget {
|
||||||
|
final Settings settings;
|
||||||
|
final VoidCallback? onLogout;
|
||||||
|
const SettingsScreen({super.key, required this.settings, this.onLogout});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
|
late final TextEditingController _url = TextEditingController(text: widget.settings.baseUrl);
|
||||||
|
|
||||||
|
void _save() {
|
||||||
|
widget.settings.baseUrl = _url.text.trim();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Saved')));
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final name = widget.settings.name;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Settings')),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
children: [
|
||||||
|
if (name != null && name.isNotEmpty)
|
||||||
|
Card(
|
||||||
|
child: ListTile(
|
||||||
|
leading: const CircleAvatar(backgroundColor: AppColors.emerald, child: Icon(Icons.person, color: Colors.white)),
|
||||||
|
title: Text(name, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
subtitle: Text(widget.settings.isManager ? 'Manager' : 'Employee'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
const Text('Server URL', style: TextStyle(fontWeight: FontWeight.w600, color: AppColors.navy)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: _url,
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
autocorrect: false,
|
||||||
|
decoration: const InputDecoration(prefixIcon: Icon(Icons.link), hintText: Settings.defaultBaseUrl),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
const Text('The Scalar360 API base URL. Default is api.scalar360.in.',
|
||||||
|
style: TextStyle(fontSize: 12, color: AppColors.muted)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton(onPressed: _save, child: const Text('Save')),
|
||||||
|
if (widget.onLogout != null) ...[
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: widget.onLogout,
|
||||||
|
icon: const Icon(Icons.logout, color: AppColors.danger),
|
||||||
|
label: const Text('Sign out', style: TextStyle(color: AppColors.danger)),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(50),
|
||||||
|
side: const BorderSide(color: AppColors.border),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../api_service.dart';
|
||||||
|
import '../theme.dart';
|
||||||
|
|
||||||
|
class TeamScreen extends StatefulWidget {
|
||||||
|
final ApiService api;
|
||||||
|
final VoidCallback onAuthError;
|
||||||
|
const TeamScreen({super.key, required this.api, required this.onAuthError});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TeamScreen> createState() => _TeamScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TeamScreenState extends State<TeamScreen> {
|
||||||
|
List<TeamMember> _team = [];
|
||||||
|
List<LeaveRequest> _pending = [];
|
||||||
|
bool _loading = true;
|
||||||
|
bool _busy = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
try {
|
||||||
|
final t = await widget.api.team();
|
||||||
|
final p = await widget.api.teamLeave();
|
||||||
|
if (mounted) setState(() { _team = t; _pending = p; _loading = false; });
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (e.status == 401) return widget.onAuthError();
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _decide(String gid, bool approve) async {
|
||||||
|
setState(() => _busy = true);
|
||||||
|
try {
|
||||||
|
approve ? await widget.api.approve(gid) : await widget.api.reject(gid);
|
||||||
|
await _load();
|
||||||
|
} on ApiException catch (e) {
|
||||||
|
if (e.status == 401) return widget.onAuthError();
|
||||||
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message)));
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busy = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _sc(String? s) => statusColor(s);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_loading) return const Center(child: CircularProgressIndicator(color: AppColors.emerald));
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _load,
|
||||||
|
color: AppColors.emerald,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
if (_pending.isNotEmpty) ...[
|
||||||
|
const Text('Pending approvals', style: TextStyle(fontWeight: FontWeight.w600, color: AppColors.navy)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
..._pending.map((r) => Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Row(children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text(r.employeeName ?? '—', style: const TextStyle(fontWeight: FontWeight.w600, color: AppColors.navy)),
|
||||||
|
Text('${r.leaveTypeName ?? 'Leave'} · ${r.days ?? ''}d · ${r.fromDate} → ${r.toDate}',
|
||||||
|
style: const TextStyle(fontSize: 12, color: AppColors.muted)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: _busy ? null : () => _decide(r.gid, true),
|
||||||
|
icon: const Icon(Icons.check_circle_outline, color: AppColors.emerald),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: _busy ? null : () => _decide(r.gid, false),
|
||||||
|
icon: const Icon(Icons.cancel_outlined, color: AppColors.danger),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
const Text('Team today', style: TextStyle(fontWeight: FontWeight.w600, color: AppColors.navy)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (_team.isEmpty)
|
||||||
|
const Text('No team members.', style: TextStyle(color: AppColors.muted))
|
||||||
|
else
|
||||||
|
..._team.map((m) => Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: AppColors.emerald.withValues(alpha: 0.12),
|
||||||
|
child: Text(_initials(m.employeeName), style: const TextStyle(color: AppColors.emerald, fontWeight: FontWeight.bold, fontSize: 13)),
|
||||||
|
),
|
||||||
|
title: Text(m.employeeName, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
subtitle: Text('In ${m.checkIn ?? '—'} · Out ${m.checkOut ?? '—'}${m.hours != null ? ' · ${m.hours}h' : ''}'),
|
||||||
|
trailing: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(color: _sc(m.status).withValues(alpha: 0.12), borderRadius: BorderRadius.circular(20)),
|
||||||
|
child: Text((m.status).replaceAll('_', ' '), style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: _sc(m.status))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _initials(String name) {
|
||||||
|
final p = name.trim().split(RegExp(r'\s+')).where((x) => x.isNotEmpty).toList();
|
||||||
|
return p.isEmpty ? '?' : p.take(2).map((x) => x[0].toUpperCase()).join();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Persisted app settings + employee session.
|
||||||
|
class Settings {
|
||||||
|
static const _kBaseUrl = 'base_url';
|
||||||
|
static const _kToken = 'emp_token';
|
||||||
|
static const _kName = 'emp_name';
|
||||||
|
static const _kRole = 'emp_role';
|
||||||
|
|
||||||
|
static const defaultBaseUrl = 'https://api.scalar360.in';
|
||||||
|
|
||||||
|
late SharedPreferences _prefs;
|
||||||
|
Future<void> load() async => _prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
String get baseUrl => (_prefs.getString(_kBaseUrl) ?? defaultBaseUrl).trim();
|
||||||
|
set baseUrl(String v) => _prefs.setString(_kBaseUrl, v.trim());
|
||||||
|
|
||||||
|
String? get token => _prefs.getString(_kToken);
|
||||||
|
String? get name => _prefs.getString(_kName);
|
||||||
|
String get role => _prefs.getString(_kRole) ?? 'EMPLOYEE';
|
||||||
|
bool get isManager => role == 'MANAGER';
|
||||||
|
bool get isLoggedIn => (token ?? '').isNotEmpty;
|
||||||
|
|
||||||
|
Future<void> saveSession({required String token, String? name, String? role}) async {
|
||||||
|
await _prefs.setString(_kToken, token);
|
||||||
|
if (name != null) await _prefs.setString(_kName, name);
|
||||||
|
if (role != null) await _prefs.setString(_kRole, role);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearSession() async {
|
||||||
|
await _prefs.remove(_kToken);
|
||||||
|
await _prefs.remove(_kName);
|
||||||
|
await _prefs.remove(_kRole);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Scalar360 palette (mirrors the web app tokens).
|
||||||
|
class AppColors {
|
||||||
|
static const emerald = Color(0xFF00BE73);
|
||||||
|
static const navy = Color(0xFF1F2937);
|
||||||
|
static const muted = Color(0xFF8A94A6);
|
||||||
|
static const border = Color(0xFFE4E9F0);
|
||||||
|
static const surface = Color(0xFFF7F9FC);
|
||||||
|
static const card = Colors.white;
|
||||||
|
static const danger = Color(0xFFEF4444);
|
||||||
|
static const gold = Color(0xFFC9A227);
|
||||||
|
static const navyAccent = Color(0xFF3B82F6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Status pill colours.
|
||||||
|
Color statusColor(String? s) {
|
||||||
|
switch (s) {
|
||||||
|
case 'PRESENT':
|
||||||
|
return AppColors.emerald;
|
||||||
|
case 'ABSENT':
|
||||||
|
return AppColors.danger;
|
||||||
|
case 'HALF_DAY':
|
||||||
|
return AppColors.gold;
|
||||||
|
case 'LEAVE':
|
||||||
|
return AppColors.navyAccent;
|
||||||
|
default:
|
||||||
|
return AppColors.muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeData buildTheme() {
|
||||||
|
final base = ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: ColorScheme.fromSeed(
|
||||||
|
seedColor: AppColors.emerald,
|
||||||
|
primary: AppColors.emerald,
|
||||||
|
brightness: Brightness.light,
|
||||||
|
),
|
||||||
|
scaffoldBackgroundColor: AppColors.surface,
|
||||||
|
fontFamily: 'Roboto',
|
||||||
|
);
|
||||||
|
return base.copyWith(
|
||||||
|
appBarTheme: const AppBarTheme(
|
||||||
|
backgroundColor: AppColors.surface,
|
||||||
|
foregroundColor: AppColors.navy,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: false,
|
||||||
|
),
|
||||||
|
cardTheme: CardThemeData(
|
||||||
|
color: AppColors.card,
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
side: const BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppColors.card,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
borderSide: const BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
borderSide: const BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
borderSide: const BorderSide(color: AppColors.emerald, width: 1.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
filledButtonTheme: FilledButtonThemeData(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.emerald,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
minimumSize: const Size.fromHeight(50),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||||
|
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
+485
@@ -0,0 +1,485 @@
|
|||||||
|
# Generated by pub
|
||||||
|
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||||
|
packages:
|
||||||
|
async:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: async
|
||||||
|
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.13.1"
|
||||||
|
characters:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: characters
|
||||||
|
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.1"
|
||||||
|
clock:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: clock
|
||||||
|
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.2"
|
||||||
|
collection:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: collection
|
||||||
|
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.19.1"
|
||||||
|
cross_file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: cross_file
|
||||||
|
sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.3.5+4"
|
||||||
|
crypto:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: crypto
|
||||||
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.7"
|
||||||
|
cupertino_icons:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: cupertino_icons
|
||||||
|
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.9"
|
||||||
|
ffi:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: ffi
|
||||||
|
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
file:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file
|
||||||
|
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.1"
|
||||||
|
file_selector_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_linux
|
||||||
|
sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.4"
|
||||||
|
file_selector_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_macos
|
||||||
|
sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.5"
|
||||||
|
file_selector_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_platform_interface
|
||||||
|
sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.7.0"
|
||||||
|
file_selector_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: file_selector_windows
|
||||||
|
sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.9.3+5"
|
||||||
|
fixnum:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: fixnum
|
||||||
|
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
|
flutter:
|
||||||
|
dependency: "direct main"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
flutter_lints:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: flutter_lints
|
||||||
|
sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.0"
|
||||||
|
flutter_plugin_android_lifecycle:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_plugin_android_lifecycle
|
||||||
|
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.35"
|
||||||
|
flutter_web_plugins:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
geolocator:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: geolocator
|
||||||
|
sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "13.0.4"
|
||||||
|
geolocator_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geolocator_android
|
||||||
|
sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.6.2"
|
||||||
|
geolocator_apple:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geolocator_apple
|
||||||
|
sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.14"
|
||||||
|
geolocator_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geolocator_platform_interface
|
||||||
|
sha256: cdb082e4f048b69da244117b7914cc60d2a8897546ffaa4f2529c786ded7aee2
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.2.8"
|
||||||
|
geolocator_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geolocator_web
|
||||||
|
sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.4"
|
||||||
|
geolocator_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: geolocator_windows
|
||||||
|
sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.5"
|
||||||
|
http:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: http
|
||||||
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.6.0"
|
||||||
|
http_parser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_parser
|
||||||
|
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.1.2"
|
||||||
|
image_picker:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: image_picker
|
||||||
|
sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.3"
|
||||||
|
image_picker_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_android
|
||||||
|
sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.8.13+19"
|
||||||
|
image_picker_for_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_for_web
|
||||||
|
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.1"
|
||||||
|
image_picker_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_ios
|
||||||
|
sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.8.13+6"
|
||||||
|
image_picker_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_linux
|
||||||
|
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2"
|
||||||
|
image_picker_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_macos
|
||||||
|
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2+1"
|
||||||
|
image_picker_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_platform_interface
|
||||||
|
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.11.1"
|
||||||
|
image_picker_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: image_picker_windows
|
||||||
|
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.2.2"
|
||||||
|
intl:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: intl
|
||||||
|
sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.19.0"
|
||||||
|
lints:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: lints
|
||||||
|
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.0.0"
|
||||||
|
material_color_utilities:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: material_color_utilities
|
||||||
|
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.13.0"
|
||||||
|
meta:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: meta
|
||||||
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.18.0"
|
||||||
|
mime:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: mime
|
||||||
|
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.0"
|
||||||
|
path:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path
|
||||||
|
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.9.1"
|
||||||
|
path_provider_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_linux
|
||||||
|
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.2"
|
||||||
|
path_provider_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_platform_interface
|
||||||
|
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.3"
|
||||||
|
path_provider_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: path_provider_windows
|
||||||
|
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.0"
|
||||||
|
platform:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: platform
|
||||||
|
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.6"
|
||||||
|
plugin_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: plugin_platform_interface
|
||||||
|
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.8"
|
||||||
|
shared_preferences:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: shared_preferences
|
||||||
|
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.5"
|
||||||
|
shared_preferences_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_android
|
||||||
|
sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.27"
|
||||||
|
shared_preferences_foundation:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_foundation
|
||||||
|
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.5.6"
|
||||||
|
shared_preferences_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_linux
|
||||||
|
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
|
shared_preferences_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_platform_interface
|
||||||
|
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.2"
|
||||||
|
shared_preferences_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_web
|
||||||
|
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.3"
|
||||||
|
shared_preferences_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: shared_preferences_windows
|
||||||
|
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.4.1"
|
||||||
|
sky_engine:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
|
source_span:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: source_span
|
||||||
|
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.10.2"
|
||||||
|
string_scanner:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: string_scanner
|
||||||
|
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.1"
|
||||||
|
term_glyph:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: term_glyph
|
||||||
|
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.2"
|
||||||
|
typed_data:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: typed_data
|
||||||
|
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0"
|
||||||
|
uuid:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: uuid
|
||||||
|
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.6.0"
|
||||||
|
vector_math:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: vector_math
|
||||||
|
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.2.0"
|
||||||
|
web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: web
|
||||||
|
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
|
xdg_directories:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xdg_directories
|
||||||
|
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.0"
|
||||||
|
sdks:
|
||||||
|
dart: ">=3.12.0 <4.0.0"
|
||||||
|
flutter: ">=3.44.0"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
name: scalar_attendance
|
||||||
|
description: Scalar360 employee self-service — clock in/out, leave, payslips, approvals.
|
||||||
|
publish_to: "none"
|
||||||
|
version: 1.0.0+1
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: ">=3.0.0 <4.0.0"
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
http: ^1.2.0
|
||||||
|
shared_preferences: ^2.2.0
|
||||||
|
intl: ^0.19.0
|
||||||
|
geolocator: ^13.0.1
|
||||||
|
image_picker: ^1.1.2
|
||||||
|
cupertino_icons: ^1.0.6
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_lints: ^4.0.0
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
uses-material-design: true
|
||||||
Reference in New Issue
Block a user