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:
@@ -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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user