commit 19a79d6126e71aba635d4a893d3d152fe0fd03f4 Author: athul rd <49590824+athulrd@users.noreply.github.com> Date: Mon Aug 10 17:01:30 2026 +0530 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0858ddd --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.dart_tool/ +.packages +build/ +.flutter-plugins +.flutter-plugins-dependencies +*.iml +.idea/ +.DS_Store +ios/ +android/ +web/ +macos/ +linux/ +windows/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..def8dec --- /dev/null +++ b/README.md @@ -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 ``: +```xml + + + +``` +**iOS** — `ios/Runner/Info.plist`: +```xml +NSLocationWhenInUseUsageDescription +Used to confirm you are at the office when you check in. +NSCameraUsageDescription +Used to capture a selfie when you check in. +``` + +## 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. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..7c410f9 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + prefer_const_constructors: true diff --git a/lib/api_service.dart b/lib/api_service.dart new file mode 100644 index 0000000..8a0ddaf --- /dev/null +++ b/lib/api_service.dart @@ -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 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 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 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 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 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 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 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? q]) => + Uri.parse('${settings.baseUrl}$p').replace(queryParameters: q); + + Map get _h => { + 'Content-Type': 'application/json', + if ((settings.token ?? '').isNotEmpty) 'Authorization': 'Bearer ${settings.token}', + }; + + // ---- auth ---- + Future 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); + await settings.saveSession(token: body['accessToken'] as String, name: p.fullName, role: p.role); + return p; + } + + // ---- me ---- + Future profile() async => Profile.fromJson(await _get('/api/v1/me/profile')); + Future today() async => Today.fromJson(await _get('/api/v1/me/attendance/today')); + + Future checkIn({double? lat, double? lng, String? selfie}) => + _post('/api/v1/me/attendance/check-in', _geo(lat, lng, selfie)); + Future checkOut({double? lat, double? lng, String? selfie}) => + _post('/api/v1/me/attendance/check-out', _geo(lat, lng, selfie)); + Map _geo(double? lat, double? lng, String? selfie) => { + if (lat != null) 'lat': lat, + if (lng != null) 'lng': lng, + if (selfie != null) 'selfie': selfie, + }; + + Future> leaveTypes() async => + (await _get('/api/v1/me/leave/types') as List).map((e) => LeaveType.fromJson(e)).toList(); + Future> leaveBalances() async => + (await _get('/api/v1/me/leave/balances') as List).map((e) => LeaveBalance.fromJson(e)).toList(); + Future> myLeave() async => + (await _get('/api/v1/me/leave/requests') as List).map((e) => LeaveRequest.fromJson(e)).toList(); + Future applyLeave(String typeGid, String from, String to, String reason) => + _post('/api/v1/me/leave/requests', {'leaveTypeGid': typeGid, 'fromDate': from, 'toDate': to, 'reason': reason}); + + Future> payslips() async => + (await _get('/api/v1/me/payslips') as List).map((e) => Payslip.fromJson(e)).toList(); + + Future> team() async => + (await _get('/api/v1/me/team') as List).map((e) => TeamMember.fromJson(e)).toList(); + Future> teamLeave() async => + (await _get('/api/v1/me/team/leave') as List).map((e) => LeaveRequest.fromJson(e)).toList(); + Future approve(String gid) => _post('/api/v1/me/team/leave/$gid/approve', {}); + Future reject(String gid) => _post('/api/v1/me/team/leave/$gid/reject', {}); + + // ---- helpers ---- + Future _get(String p, [Map? 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 _post(String p, Map 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; +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..5553a39 --- /dev/null +++ b/lib/main.dart @@ -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 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), + ); + } +} diff --git a/lib/screens/clock_screen.dart b/lib/screens/clock_screen.dart new file mode 100644 index 0000000..3dfdd7e --- /dev/null +++ b/lib/screens/clock_screen.dart @@ -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 createState() => _ClockScreenState(); +} + +class _ClockScreenState extends State { + Today? _today; + bool _loading = true; + bool _busy = false; + String? _selfie; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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() 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 _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 _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)), + ]); + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart new file mode 100644 index 0000000..4113f60 --- /dev/null +++ b/lib/screens/home_screen.dart @@ -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 createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + 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); +} diff --git a/lib/screens/leave_screen.dart b/lib/screens/leave_screen.dart new file mode 100644 index 0000000..e786ed0 --- /dev/null +++ b/lib/screens/leave_screen.dart @@ -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 createState() => _LeaveScreenState(); +} + +class _LeaveScreenState extends State { + List _balances = []; + List _requests = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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 _apply() async { + final types = await widget.api.leaveTypes().catchError((_) => []); + if (!mounted || types.isEmpty) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('No leave types set up'))); + return; + } + final ok = await showModalBottomSheet( + 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 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 _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 _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( + 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'), + ), + ]), + ), + ); + } +} diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart new file mode 100644 index 0000000..a10df36 --- /dev/null +++ b/lib/screens/login_screen.dart @@ -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 createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _email = TextEditingController(); + final _pin = TextEditingController(); + bool _busy = false; + String? _error; + + Future _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)), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/payslips_screen.dart b/lib/screens/payslips_screen.dart new file mode 100644 index 0000000..f5ac025 --- /dev/null +++ b/lib/screens/payslips_screen.dart @@ -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 createState() => _PayslipsScreenState(); +} + +class _PayslipsScreenState extends State { + List _rows = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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)), + ]), + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart new file mode 100644 index 0000000..8c7d997 --- /dev/null +++ b/lib/screens/settings_screen.dart @@ -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 createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + 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)), + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/screens/team_screen.dart b/lib/screens/team_screen.dart new file mode 100644 index 0000000..e0cbe45 --- /dev/null +++ b/lib/screens/team_screen.dart @@ -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 createState() => _TeamScreenState(); +} + +class _TeamScreenState extends State { + List _team = []; + List _pending = []; + bool _loading = true; + bool _busy = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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 _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(); + } +} diff --git a/lib/settings.dart b/lib/settings.dart new file mode 100644 index 0000000..534a123 --- /dev/null +++ b/lib/settings.dart @@ -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 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 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 clearSession() async { + await _prefs.remove(_kToken); + await _prefs.remove(_kName); + await _prefs.remove(_kRole); + } +} diff --git a/lib/theme.dart b/lib/theme.dart new file mode 100644 index 0000000..48a0298 --- /dev/null +++ b/lib/theme.dart @@ -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), + ), + ), + ); +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..d5970db --- /dev/null +++ b/pubspec.lock @@ -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" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..12ef940 --- /dev/null +++ b/pubspec.yaml @@ -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