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,254 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart';
import '../../core/auth/auth_provider.dart';
// ── Model ────────────────────────────────────────────────────────────────────
enum TableStatus { free, busy, reserved, cleaning }
class TableItem {
const TableItem({
required this.id,
required this.number,
required this.status,
this.guestLabel,
this.orderTotal,
});
final String id;
final String number;
final TableStatus status;
final String? guestLabel;
final double? orderTotal;
factory TableItem.fromJson(Map<String, dynamic> j) {
final rawStatus = (j['status'] as String? ?? '').toLowerCase();
final status = switch (rawStatus) {
'busy' => TableStatus.busy,
'reserved' => TableStatus.reserved,
'cleaning' => TableStatus.cleaning,
_ => TableStatus.free,
};
final order = j['currentOrder'] as Map?;
return TableItem(
id: j['id'] as String,
number: j['number'] as String,
status: status,
guestLabel: order?['guestLabel'] as String?,
orderTotal: (order?['total'] as num?)?.toDouble(),
);
}
}
// ── Provider ─────────────────────────────────────────────────────────────────
final tableBoardProvider =
FutureProvider.autoDispose<List<TableItem>>((ref) async {
final session = ref.watch(authProvider);
if (session == null) return [];
final client = ref.watch(apiClientProvider);
final params = <String, dynamic>{'activeOnly': 'false'};
if (session.branchId != null) params['branchId'] = session.branchId;
final res = await client.dio.get<Map<String, dynamic>>(
'/api/cafes/${session.cafeId}/tables/board',
queryParameters: params,
);
final raw = res.data?['data'] as List? ?? [];
return raw
.map((e) => TableItem.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
});
// ── Screen ───────────────────────────────────────────────────────────────────
class TableBoardScreen extends ConsumerWidget {
const TableBoardScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final boardAsync = ref.watch(tableBoardProvider);
return Scaffold(
appBar: AppBar(
title: const Text('وضعیت میزها'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => ref.invalidate(tableBoardProvider),
),
],
),
body: boardAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('خطا در بارگذاری میزها'),
const SizedBox(height: 12),
FilledButton(
onPressed: () => ref.invalidate(tableBoardProvider),
child: const Text('تلاش مجدد'),
),
],
),
),
data: (tables) => tables.isEmpty
? const Center(child: Text('میزی یافت نشد'))
: _TableGrid(tables: tables),
),
);
}
}
class _TableGrid extends StatelessWidget {
const _TableGrid({required this.tables});
final List<TableItem> tables;
@override
Widget build(BuildContext context) {
final free = tables.where((t) => t.status == TableStatus.free).length;
final busy = tables.where((t) => t.status == TableStatus.busy).length;
final cleaning =
tables.where((t) => t.status == TableStatus.cleaning).length;
return Column(
children: [
_StatusBar(free: free, busy: busy, cleaning: cleaning),
Expanded(
child: GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 1,
),
itemCount: tables.length,
itemBuilder: (_, i) => _TableCard(table: tables[i]),
),
),
],
);
}
}
class _StatusBar extends StatelessWidget {
const _StatusBar({
required this.free,
required this.busy,
required this.cleaning,
});
final int free;
final int busy;
final int cleaning;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
color: Theme.of(context).colorScheme.surfaceContainerLow,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_Chip(label: 'آزاد', count: free, color: Colors.green),
_Chip(label: 'اشغال', count: busy, color: Colors.orange),
_Chip(label: 'نظافت', count: cleaning, color: Colors.blue),
],
),
);
}
}
class _Chip extends StatelessWidget {
const _Chip(
{required this.label, required this.count, required this.color});
final String label;
final int count;
final Color color;
@override
Widget build(BuildContext context) {
return Row(children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text('$label: $count',
style: Theme.of(context).textTheme.labelMedium),
]);
}
}
class _TableCard extends StatelessWidget {
const _TableCard({required this.table});
final TableItem table;
Color _bgColor(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return switch (table.status) {
TableStatus.busy => Colors.orange.withOpacity(0.15),
TableStatus.cleaning => Colors.blue.withOpacity(0.12),
TableStatus.reserved => cs.primaryContainer.withOpacity(0.4),
TableStatus.free => cs.surfaceContainerHighest,
};
}
Color _borderColor() => switch (table.status) {
TableStatus.busy => Colors.orange,
TableStatus.cleaning => Colors.blue,
TableStatus.reserved => Colors.purple,
TableStatus.free => Colors.transparent,
};
String get _statusLabel => switch (table.status) {
TableStatus.busy => 'اشغال',
TableStatus.cleaning => 'نظافت',
TableStatus.reserved => 'رزرو',
TableStatus.free => 'آزاد',
};
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
decoration: BoxDecoration(
color: _bgColor(context),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: _borderColor(), width: 1.5),
),
padding: const EdgeInsets.all(8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
table.number,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(_statusLabel,
style: theme.textTheme.labelSmall
?.copyWith(color: _borderColor())),
if (table.guestLabel != null) ...[
const SizedBox(height: 2),
Text(
table.guestLabel!,
style: theme.textTheme.labelSmall,
overflow: TextOverflow.ellipsis,
),
],
],
),
);
}
}