chore: Flutter mobile app, CI, and dev tooling

- mobile/: Flutter/Dart merchant mobile app skeleton
- .github/: GitHub Actions CI workflows
- .dockerignore: exclude host node_modules from build context
- .cursorrules: Cursor IDE project rules
- .claude/: Claude Code project settings and launch config

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-05-27 21:35:27 +03:30
parent 42d4cb896a
commit a85890f30a
52 changed files with 3919 additions and 0 deletions
@@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shamsi_date/shamsi_date.dart';
import '../../core/sync/sync_engine.dart';
import 'hr_api.dart';
import 'hr_providers.dart';
class AttendanceScreen extends ConsumerStatefulWidget {
const AttendanceScreen({super.key});
@override
ConsumerState<AttendanceScreen> createState() => _AttendanceScreenState();
}
class _AttendanceScreenState extends ConsumerState<AttendanceScreen> {
final _reasonController = TextEditingController();
Jalali? _leaveStart;
Jalali? _leaveEnd;
String? _message;
@override
void dispose() {
_reasonController.dispose();
super.dispose();
}
Future<void> _clock(bool isIn) async {
final session = ref.read(hrSessionProvider);
if (session == null) {
setState(() => _message = 'ابتدا وارد شوید');
return;
}
final api = ref.read(hrApiProvider);
final sync = ref.read(syncEngineProvider);
try {
if (isIn) {
await api.clockIn(cafeId: session.cafeId, employeeId: session.employeeId);
} else {
await api.clockOut(cafeId: session.cafeId, employeeId: session.employeeId);
}
ref.invalidate(todayShiftProvider);
setState(() => _message = isIn ? 'ورود ثبت شد' : 'خروج ثبت شد');
} catch (_) {
sync.enqueueAttendance(
action: isIn ? 'clock-in' : 'clock-out',
cafeId: session.cafeId,
employeeId: session.employeeId,
);
setState(() => _message = 'آفلاین ذخیره شد — پس از اتصال همگام‌سازی می‌شود');
}
}
Future<void> _submitLeave() async {
final session = ref.read(hrSessionProvider);
if (session == null || _leaveStart == null || _leaveEnd == null) return;
final api = ref.read(hrApiProvider);
try {
await api.submitLeave(
cafeId: session.cafeId,
employeeId: session.employeeId,
startDate: _formatDate(_leaveStart!),
endDate: _formatDate(_leaveEnd!),
reason: _reasonController.text,
);
setState(() => _message = 'درخواست مرخصی ثبت شد');
} catch (e) {
setState(() => _message = 'خطا در ثبت مرخصی');
}
}
String _formatDate(Jalali d) =>
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
@override
Widget build(BuildContext context) {
final shiftAsync = ref.watch(todayShiftProvider);
final todayJalali = Jalali.now();
return Directionality(
textDirection: TextDirection.rtl,
child: Scaffold(
appBar: AppBar(title: const Text('حضور و غیاب')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'امروز: ${todayJalali.formatter.yyyyMMdd()}',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
shiftAsync.when(
data: (shift) => Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text('شیفت: ${shift?['label'] ?? ''}'),
),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => const Text('شیفت امروز در دسترس نیست'),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: FilledButton(
onPressed: () => _clock(true),
child: const Text('ورود'),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton(
onPressed: () => _clock(false),
child: const Text('خروج'),
),
),
],
),
const Divider(height: 32),
Text('درخواست مرخصی', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
ListTile(
title: Text(_leaveStart == null ? 'از تاریخ' : _formatDate(_leaveStart!)),
trailing: const Icon(Icons.calendar_today),
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime.now().subtract(const Duration(days: 365)),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (picked != null) setState(() => _leaveStart = Jalali.fromDateTime(picked));
},
),
ListTile(
title: Text(_leaveEnd == null ? 'تا تاریخ' : _formatDate(_leaveEnd!)),
trailing: const Icon(Icons.calendar_today),
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime.now().subtract(const Duration(days: 365)),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (picked != null) setState(() => _leaveEnd = Jalali.fromDateTime(picked));
},
),
TextField(
controller: _reasonController,
decoration: const InputDecoration(labelText: 'دلیل'),
maxLines: 2,
),
const SizedBox(height: 12),
FilledButton(onPressed: _submitLeave, child: const Text('ثبت مرخصی')),
if (_message != null) ...[
const SizedBox(height: 16),
Text(_message!, style: TextStyle(color: Theme.of(context).colorScheme.primary)),
],
],
),
),
);
}
}
@@ -0,0 +1,55 @@
import 'package:dio/dio.dart';
import '../../core/api/api_client.dart';
class HrApi {
HrApi(this._client);
final ApiClient _client;
Future<Map<String, dynamic>?> fetchTodayShift({
required String cafeId,
required String employeeId,
}) async {
final res = await _client.dio.get<Map<String, dynamic>>(
'/api/cafes/$cafeId/employees/$employeeId/shift/today',
);
final data = res.data?['data'] as Map<String, dynamic>?;
return data;
}
Future<void> clockIn({
required String cafeId,
required String employeeId,
}) async {
await _client.dio.post(
'/api/cafes/$cafeId/employees/$employeeId/attendance/clock-in',
);
}
Future<void> clockOut({
required String cafeId,
required String employeeId,
}) async {
await _client.dio.post(
'/api/cafes/$cafeId/employees/$employeeId/attendance/clock-out',
);
}
Future<void> submitLeave({
required String cafeId,
required String employeeId,
required String startDate,
required String endDate,
String? reason,
}) async {
await _client.dio.post(
'/api/cafes/$cafeId/employees/$employeeId/leave-requests',
data: {
'startDate': startDate,
'endDate': endDate,
if (reason != null && reason.isNotEmpty) 'reason': reason,
},
);
}
}
@@ -0,0 +1,31 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/sync/sync_engine.dart';
import '../cart/cart_state.dart' show apiClientProvider;
import 'hr_api.dart';
class HrSession {
const HrSession({required this.cafeId, required this.employeeId});
final String cafeId;
final String employeeId;
}
const _demoCafeId = 'cafe_demo_001';
const _demoEmployeeId = 'emp_demo_owner';
final hrApiProvider = Provider<HrApi>((ref) => HrApi(ref.watch(apiClientProvider)));
final syncEngineProvider = Provider<SyncEngine>((ref) => SyncEngine());
final hrSessionProvider = Provider<HrSession?>(
(_) => const HrSession(cafeId: _demoCafeId, employeeId: _demoEmployeeId),
);
final todayShiftProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
final session = ref.watch(hrSessionProvider);
if (session == null) return null;
return ref.watch(hrApiProvider).fetchTodayShift(
cafeId: session.cafeId,
employeeId: session.employeeId,
);
});