init
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user