This commit is contained in:
athul rd
2026-08-10 17:01:30 +05:30
commit 19a79d6126
16 changed files with 1790 additions and 0 deletions
+214
View File
@@ -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;
}
+31
View File
@@ -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),
);
}
}
+174
View File
@@ -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)),
]);
}
}
+74
View File
@@ -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);
}
+185
View File
@@ -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'),
),
]),
),
);
}
}
+119
View File
@@ -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)),
],
),
),
),
),
),
);
}
}
+101
View File
@@ -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)),
]),
);
}
}
+70
View File
@@ -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)),
),
),
],
],
),
);
}
}
+119
View File
@@ -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();
}
}
+35
View File
@@ -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);
}
}
+86
View File
@@ -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),
),
),
);
}