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,49 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'api_config.dart';
const _tokenKey = 'waiter_access_token';
final _storageProvider = Provider<FlutterSecureStorage>(
(_) => const FlutterSecureStorage(),
);
final apiClientProvider = Provider<ApiClient>((ref) {
final storage = ref.watch(_storageProvider);
return ApiClient(storage: storage);
});
class ApiClient {
ApiClient({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage() {
_dio = Dio(BaseOptions(
baseUrl: apiBaseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
headers: {'Content-Type': 'application/json'},
));
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await _storage.read(key: _tokenKey);
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
));
}
final FlutterSecureStorage _storage;
late final Dio _dio;
Dio get dio => _dio;
Future<void> saveToken(String token) =>
_storage.write(key: _tokenKey, value: token);
Future<String?> readToken() => _storage.read(key: _tokenKey);
Future<void> clearToken() => _storage.delete(key: _tokenKey);
}
@@ -0,0 +1,4 @@
const String apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'https://localhost:7208',
);
@@ -0,0 +1,59 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/api_client.dart';
import 'auth_state.dart';
final authProvider =
StateNotifierProvider<AuthNotifier, WaiterSession?>((ref) {
return AuthNotifier(ref.watch(apiClientProvider));
});
class AuthNotifier extends StateNotifier<WaiterSession?> {
AuthNotifier(this._client) : super(null) {
_restore();
}
final ApiClient _client;
Future<void> _restore() async {
// Check stored token and validate it's still usable
final token = await _client.readToken();
if (token == null || token.isEmpty) return;
try {
final res = await _client.dio.get<Map<String, dynamic>>('/api/auth/me');
final data = res.data?['data'] as Map<String, dynamic>?;
if (data == null) return;
// Merge stored token with fetched profile
final json = {...data, 'accessToken': token};
state = WaiterSession.fromJson(json);
} catch (_) {
// Token expired or network error — stay logged out
}
}
Future<String> sendOtp(String phone) async {
final res = await _client.dio.post<Map<String, dynamic>>(
'/api/auth/send-otp',
data: {'phone': phone},
);
final data = res.data?['data'] as Map<String, dynamic>?;
return (data?['sessionId'] ?? '') as String;
}
Future<void> verifyOtp(String phone, String otp) async {
final res = await _client.dio.post<Map<String, dynamic>>(
'/api/auth/verify-otp',
data: {'phone': phone, 'otp': otp},
);
final data = res.data?['data'] as Map<String, dynamic>?;
if (data == null) throw Exception('AUTH_FAILED');
final session = WaiterSession.fromJson(data);
await _client.saveToken(session.accessToken);
state = session;
}
Future<void> logout() async {
await _client.clearToken();
state = null;
}
}
@@ -0,0 +1,28 @@
class WaiterSession {
const WaiterSession({
required this.accessToken,
required this.cafeId,
required this.userId,
required this.role,
this.branchId,
this.actor,
});
final String accessToken;
final String cafeId;
final String userId;
final String role;
final String? branchId;
final String? actor;
factory WaiterSession.fromJson(Map<String, dynamic> json) => WaiterSession(
accessToken: json['accessToken'] as String,
cafeId: json['cafeId'] as String,
userId: json['userId'] as String,
role: json['role'] as String,
branchId: json['branchId'] as String?,
actor: json['actor'] as String?,
);
String get displayName => actor ?? userId;
}
@@ -0,0 +1,95 @@
import 'dart:async';
import 'package:signalr_netcore/signalr_client.dart';
import '../api/api_config.dart';
/// Events broadcast from the KDS hub.
class HubNotification {
const HubNotification({
required this.id,
required this.type,
required this.title,
this.body,
this.tableNumber,
this.referenceId,
required this.createdAt,
});
final String id;
final String type;
final String title;
final String? body;
final String? tableNumber;
final String? referenceId;
final DateTime createdAt;
factory HubNotification.fromMap(Map<String, dynamic> m) => HubNotification(
id: (m['id'] ?? '') as String,
type: (m['type'] ?? '') as String,
title: (m['title'] ?? '') as String,
body: m['body'] as String?,
tableNumber: m['tableNumber'] as String?,
referenceId: m['referenceId'] as String?,
createdAt: DateTime.tryParse(m['createdAt'] as String? ?? '') ??
DateTime.now(),
);
}
class WaiterHubClient {
WaiterHubClient({required this.cafeId, required this.accessToken});
final String cafeId;
final String accessToken;
HubConnection? _connection;
final _notificationController =
StreamController<HubNotification>.broadcast();
Stream<HubNotification> get notifications => _notificationController.stream;
bool get isConnected =>
_connection?.state == HubConnectionState.Connected;
Future<void> connect() async {
final hubUrl = '$apiBaseUrl/hubs/kds';
_connection = HubConnectionBuilder()
.withUrl(
hubUrl,
options: HttpConnectionOptions(
accessTokenFactory: () async => accessToken,
skipNegotiation: false,
),
)
.withAutomaticReconnect()
.build();
_connection!.on('NotificationReceived', _onNotification);
_connection!.onclose(({error}) async {
// Auto-reconnect handled by withAutomaticReconnect
});
try {
await _connection!.start();
await _connection!.invoke('JoinCafe', args: [cafeId]);
} catch (_) {
// Will retry via automatic reconnect
}
}
void _onNotification(List<Object?>? args) {
if (args == null || args.isEmpty) return;
final raw = args[0];
if (raw is! Map) return;
final m = Map<String, dynamic>.from(raw);
_notificationController.add(HubNotification.fromMap(m));
}
Future<void> dispose() async {
await _connection?.stop();
await _notificationController.close();
}
}
@@ -0,0 +1,22 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_provider.dart';
import 'hub_client.dart';
/// Keeps a single live hub connection alive while the user is authenticated.
final hubClientProvider = Provider<WaiterHubClient?>((ref) {
final session = ref.watch(authProvider);
if (session == null) return null;
final client = WaiterHubClient(
cafeId: session.cafeId,
accessToken: session.accessToken,
);
// Connect asynchronously; provider consumers will react to stream events.
client.connect();
ref.onDispose(client.dispose);
return client;
});
@@ -0,0 +1,31 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../features/auth/login_screen.dart';
import '../../features/home/home_screen.dart';
import '../auth/auth_provider.dart';
final appRouterProvider = Provider<GoRouter>((ref) {
final auth = ref.watch(authProvider);
return GoRouter(
initialLocation: auth != null ? '/home' : '/login',
redirect: (context, state) {
final loggedIn = ref.read(authProvider) != null;
final goingToLogin = state.matchedLocation == '/login';
if (!loggedIn && !goingToLogin) return '/login';
if (loggedIn && goingToLogin) return '/home';
return null;
},
routes: [
GoRoute(
path: '/login',
builder: (_, __) => const LoginScreen(),
),
GoRoute(
path: '/home',
builder: (_, __) => const HomeScreen(),
),
],
);
});
@@ -0,0 +1,213 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/auth/auth_provider.dart';
class LoginScreen extends ConsumerStatefulWidget {
const LoginScreen({super.key});
@override
ConsumerState<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends ConsumerState<LoginScreen> {
final _phoneCtrl = TextEditingController();
final _otpCtrl = TextEditingController();
final _phoneFocus = FocusNode();
final _otpFocus = FocusNode();
bool _otpSent = false;
bool _loading = false;
String? _error;
@override
void dispose() {
_phoneCtrl.dispose();
_otpCtrl.dispose();
_phoneFocus.dispose();
_otpFocus.dispose();
super.dispose();
}
Future<void> _sendOtp() async {
final phone = _phoneCtrl.text.trim();
if (phone.isEmpty) {
setState(() => _error = 'شماره موبایل را وارد کنید');
return;
}
setState(() {
_loading = true;
_error = null;
});
try {
await ref.read(authProvider.notifier).sendOtp(phone);
setState(() {
_otpSent = true;
_loading = false;
});
_otpFocus.requestFocus();
} catch (e) {
setState(() {
_loading = false;
_error = 'ارسال کد ناموفق بود. دوباره تلاش کنید.';
});
}
}
Future<void> _verify() async {
final phone = _phoneCtrl.text.trim();
final otp = _otpCtrl.text.trim();
if (otp.length < 4) {
setState(() => _error = 'کد تأیید را وارد کنید');
return;
}
setState(() {
_loading = true;
_error = null;
});
try {
await ref.read(authProvider.notifier).verifyOtp(phone, otp);
if (mounted) context.go('/home');
} catch (e) {
setState(() {
_loading = false;
_error = 'کد اشتباه یا منقضی شده است';
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Directionality(
textDirection: TextDirection.rtl,
child: Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Spacer(),
// Brand
Center(
child: Column(
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Icon(
Icons.notifications_active_rounded,
size: 38,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 16),
Text(
'میزی — گارسون',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 6),
Text(
'برای دریافت اعلان‌های میز وارد شوید',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
const Spacer(),
// Phone field
TextField(
controller: _phoneCtrl,
focusNode: _phoneFocus,
keyboardType: TextInputType.phone,
enabled: !_otpSent,
textDirection: TextDirection.ltr,
decoration: InputDecoration(
labelText: 'شماره موبایل',
hintText: '09121234567',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.phone),
suffixIcon: _otpSent
? IconButton(
icon: const Icon(Icons.edit),
onPressed: () =>
setState(() => _otpSent = false),
tooltip: 'ویرایش شماره',
)
: null,
),
),
const SizedBox(height: 14),
// OTP field
if (_otpSent) ...[
TextField(
controller: _otpCtrl,
focusNode: _otpFocus,
keyboardType: TextInputType.number,
textDirection: TextDirection.ltr,
maxLength: 6,
decoration: const InputDecoration(
labelText: 'کد تأیید',
hintText: '۶ رقم',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.lock_outline),
),
),
const SizedBox(height: 4),
],
// Error
if (_error != null) ...[
const SizedBox(height: 8),
Text(
_error!,
style: TextStyle(
color: theme.colorScheme.error, fontSize: 13),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 16),
// Action button
FilledButton(
onPressed: _loading
? null
: (_otpSent ? _verify : _sendOtp),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: _loading
? const SizedBox(
height: 20,
width: 20,
child:
CircularProgressIndicator(strokeWidth: 2.5),
)
: Text(_otpSent ? 'ورود' : 'ارسال کد'),
),
const SizedBox(height: 32),
],
),
),
),
),
);
}
}
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../notifications/notification_provider.dart';
import '../notifications/notifications_screen.dart';
import '../shift/shift_screen.dart';
import '../tables/table_board_screen.dart';
class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key});
@override
ConsumerState<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends ConsumerState<HomeScreen> {
int _tab = 0;
static const _screens = [
NotificationsScreen(),
TableBoardScreen(),
ShiftScreen(),
];
@override
Widget build(BuildContext context) {
final unread = ref.watch(
notificationProvider.select((s) => s.unreadCount),
);
return Directionality(
textDirection: TextDirection.rtl,
child: Scaffold(
body: IndexedStack(index: _tab, children: _screens),
bottomNavigationBar: NavigationBar(
selectedIndex: _tab,
onDestinationSelected: (i) => setState(() => _tab = i),
destinations: [
NavigationDestination(
icon: Badge(
isLabelVisible: unread > 0,
label: Text(unread > 9 ? '۹+' : '$unread'),
child: const Icon(Icons.notifications_outlined),
),
selectedIcon: Badge(
isLabelVisible: unread > 0,
label: Text(unread > 9 ? '۹+' : '$unread'),
child: const Icon(Icons.notifications),
),
label: 'اعلان‌ها',
),
const NavigationDestination(
icon: Icon(Icons.table_restaurant_outlined),
selectedIcon: Icon(Icons.table_restaurant),
label: 'میزها',
),
const NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: 'شیفت من',
),
],
),
),
);
}
}
@@ -0,0 +1,159 @@
import 'dart:async';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/api_client.dart';
import '../../core/auth/auth_provider.dart';
import '../../core/hub/hub_provider.dart';
import 'waiter_notification.dart';
// ── Local notification setup ────────────────────────────────────────────────
final _localNotifications = FlutterLocalNotificationsPlugin();
Future<void> initLocalNotifications() async {
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
const ios = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
await _localNotifications.initialize(
const InitializationSettings(android: android, iOS: ios),
);
}
Future<void> _showLocalNotification(WaiterNotification n) async {
const android = AndroidNotificationDetails(
'meezi_waiter_channel',
'میزی — گارسون',
channelDescription: 'اعلان‌های میزی برای گارسون',
importance: Importance.max,
priority: Priority.high,
playSound: true,
);
const ios = DarwinNotificationDetails(presentSound: true, presentAlert: true);
await _localNotifications.show(
n.createdAt.millisecondsSinceEpoch ~/ 1000,
n.title,
n.body,
const NotificationDetails(android: android, iOS: ios),
);
}
// ── Notification state ───────────────────────────────────────────────────────
class NotificationState {
const NotificationState({
this.items = const [],
this.isLoading = false,
});
final List<WaiterNotification> items;
final bool isLoading;
int get unreadCount => items.where((n) => !n.isRead).length;
NotificationState copyWith({
List<WaiterNotification>? items,
bool? isLoading,
}) =>
NotificationState(
items: items ?? this.items,
isLoading: isLoading ?? this.isLoading,
);
}
final notificationProvider =
StateNotifierProvider<NotificationNotifier, NotificationState>((ref) {
final notifier = NotificationNotifier(ref);
notifier._init();
return notifier;
});
class NotificationNotifier extends StateNotifier<NotificationState> {
NotificationNotifier(this._ref) : super(const NotificationState());
final Ref _ref;
StreamSubscription<dynamic>? _hubSub;
void _init() {
_fetchFromApi();
_subscribeHub();
}
void _subscribeHub() {
_hubSub?.cancel();
final hub = _ref.read(hubClientProvider);
if (hub == null) return;
_hubSub = hub.notifications.listen((event) {
final n = WaiterNotification.fromHub(event);
state = state.copyWith(items: [n, ...state.items]);
_showLocalNotification(n);
});
}
Future<void> _fetchFromApi() async {
final session = _ref.read(authProvider);
if (session == null) return;
state = state.copyWith(isLoading: true);
try {
final client = _ref.read(apiClientProvider);
final res = await client.dio.get<Map<String, dynamic>>(
'/api/cafes/${session.cafeId}/notifications',
queryParameters: {'limit': 50},
);
final raw = res.data?['data'] as List?;
final items = raw
?.map((e) =>
WaiterNotification.fromJson(Map<String, dynamic>.from(e as Map)))
.toList() ??
[];
state = state.copyWith(items: items, isLoading: false);
} catch (_) {
state = state.copyWith(isLoading: false);
}
}
Future<void> refresh() => _fetchFromApi();
Future<void> markRead(String id) async {
final session = _ref.read(authProvider);
if (session == null) return;
try {
final client = _ref.read(apiClientProvider);
await client.dio.post(
'/api/cafes/${session.cafeId}/notifications/read',
data: {'ids': [id]},
);
} catch (_) {}
final updated = state.items.map((n) {
if (n.id == id) n.isRead = true;
return n;
}).toList();
state = state.copyWith(items: updated);
}
Future<void> markAllRead() async {
final session = _ref.read(authProvider);
if (session == null) return;
try {
final client = _ref.read(apiClientProvider);
await client.dio.post(
'/api/cafes/${session.cafeId}/notifications/read',
data: {'all': true},
);
} catch (_) {}
final updated = state.items.map((n) {
n.isRead = true;
return n;
}).toList();
state = state.copyWith(items: updated);
}
@override
void dispose() {
_hubSub?.cancel();
super.dispose();
}
}
@@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shamsi_date/shamsi_date.dart';
import 'notification_provider.dart';
import 'waiter_notification.dart';
class NotificationsScreen extends ConsumerWidget {
const NotificationsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(notificationProvider);
final notifier = ref.read(notificationProvider.notifier);
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('اعلان‌ها'),
actions: [
if (state.unreadCount > 0)
TextButton(
onPressed: notifier.markAllRead,
child: const Text('همه خوانده شد'),
),
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'بارگذاری مجدد',
onPressed: notifier.refresh,
),
],
),
body: state.isLoading
? const Center(child: CircularProgressIndicator())
: state.items.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.notifications_none,
size: 56,
color: theme.colorScheme.onSurface.withOpacity(0.3)),
const SizedBox(height: 12),
const Text('اعلانی وجود ندارد',
style: TextStyle(fontSize: 15)),
],
),
)
: RefreshIndicator(
onRefresh: notifier.refresh,
child: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: state.items.length,
itemBuilder: (_, i) {
final n = state.items[i];
return _NotificationTile(
notification: n,
onTap: () => notifier.markRead(n.id),
);
},
),
),
);
}
}
class _NotificationTile extends StatelessWidget {
const _NotificationTile({
required this.notification,
required this.onTap,
});
final WaiterNotification notification;
final VoidCallback onTap;
IconData get _icon {
if (notification.isCallWaiter) return Icons.notifications_active;
if (notification.isNewOrder) return Icons.restaurant;
if (notification.isOrderReady) return Icons.check_circle_outline;
return Icons.notifications;
}
Color _iconColor(BuildContext context) {
final cs = Theme.of(context).colorScheme;
if (notification.isCallWaiter) return cs.error;
if (notification.isNewOrder) return cs.primary;
if (notification.isOrderReady) return Colors.green;
return cs.onSurfaceVariant;
}
String get _timeLabel {
try {
final j = Jalali.fromDateTime(notification.createdAt.toLocal());
final f = j.formatter;
return '${f.d} ${f.mN}${notification.createdAt.toLocal().hour.toString().padLeft(2, '0')}:${notification.createdAt.toLocal().minute.toString().padLeft(2, '0')}';
} catch (_) {
return '';
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isUnread = !notification.isRead;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Material(
color: isUnread
? theme.colorScheme.primaryContainer.withOpacity(0.25)
: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(16),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: _iconColor(context).withOpacity(0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(_icon,
color: _iconColor(context), size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
notification.title,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: isUnread
? FontWeight.bold
: FontWeight.normal,
),
),
),
if (isUnread)
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
shape: BoxShape.circle,
),
),
],
),
if (notification.body != null) ...[
const SizedBox(height: 3),
Text(
notification.body!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 4),
Row(
children: [
if (notification.tableNumber != null) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'میز ${notification.tableNumber}',
style: theme.textTheme.labelSmall,
),
),
const SizedBox(width: 6),
],
Text(
_timeLabel,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
],
),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,51 @@
import '../../core/hub/hub_client.dart';
class WaiterNotification {
WaiterNotification({
required this.id,
required this.type,
required this.title,
this.body,
this.tableNumber,
this.referenceId,
required this.createdAt,
this.isRead = false,
});
final String id;
final String type;
final String title;
final String? body;
final String? tableNumber;
final String? referenceId;
final DateTime createdAt;
bool isRead;
factory WaiterNotification.fromHub(HubNotification n) => WaiterNotification(
id: n.id,
type: n.type,
title: n.title,
body: n.body,
tableNumber: n.tableNumber,
referenceId: n.referenceId,
createdAt: n.createdAt,
);
factory WaiterNotification.fromJson(Map<String, dynamic> json) =>
WaiterNotification(
id: json['id'] as String,
type: json['type'] as String,
title: json['title'] as String,
body: json['body'] as String?,
tableNumber: json['tableNumber'] as String?,
referenceId: json['referenceId'] as String?,
createdAt:
DateTime.tryParse(json['createdAt'] as String? ?? '') ??
DateTime.now(),
isRead: json['isRead'] as bool? ?? false,
);
bool get isCallWaiter => type == 'table_call_waiter';
bool get isNewOrder => type == 'guest_order_new';
bool get isOrderReady => type == 'guest_order_ready';
}
@@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shamsi_date/shamsi_date.dart';
import '../../core/api/api_client.dart';
import '../../core/auth/auth_provider.dart';
final _shiftProvider =
FutureProvider.autoDispose<Map<String, dynamic>?>((ref) async {
final session = ref.watch(authProvider);
if (session == null) return null;
final client = ref.watch(apiClientProvider);
try {
final res = await client.dio.get<Map<String, dynamic>>(
'/api/cafes/${session.cafeId}/employees/${session.userId}/shift/today',
);
return res.data?['data'] as Map<String, dynamic>?;
} catch (_) {
return null;
}
});
class ShiftScreen extends ConsumerStatefulWidget {
const ShiftScreen({super.key});
@override
ConsumerState<ShiftScreen> createState() => _ShiftScreenState();
}
class _ShiftScreenState extends ConsumerState<ShiftScreen> {
String? _message;
bool _busy = false;
Future<void> _clock(bool isIn) async {
final session = ref.read(authProvider);
if (session == null) return;
setState(() => _busy = true);
try {
final client = ref.read(apiClientProvider);
final path = isIn ? 'clock-in' : 'clock-out';
await client.dio.post(
'/api/cafes/${session.cafeId}/employees/${session.userId}/attendance/$path',
);
ref.invalidate(_shiftProvider);
setState(() => _message = isIn ? 'ورود ثبت شد ✓' : 'خروج ثبت شد ✓');
} catch (_) {
setState(() => _message = 'خطا — اتصال را بررسی کنید');
} finally {
setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final session = ref.watch(authProvider);
final shiftAsync = ref.watch(_shiftProvider);
final theme = Theme.of(context);
final todayJ = Jalali.now();
return Scaffold(
appBar: AppBar(
title: const Text('شیفت من'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
tooltip: 'خروج از حساب',
onPressed: () async {
await ref.read(authProvider.notifier).logout();
},
),
],
),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
// Profile card
if (session != null)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
CircleAvatar(
radius: 26,
backgroundColor:
theme.colorScheme.primaryContainer,
child: Text(
session.displayName.isNotEmpty
? session.displayName[0].toUpperCase()
: '؟',
style: TextStyle(
fontSize: 22,
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 14),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(session.displayName,
style: theme.textTheme.titleMedium),
Text(session.role,
style: theme.textTheme.bodySmall?.copyWith(
color:
theme.colorScheme.onSurfaceVariant)),
],
),
],
),
),
),
const SizedBox(height: 16),
// Date
Text(
'امروز: ${todayJ.formatter.d} ${todayJ.formatter.mN} ${todayJ.formatter.y}',
style: theme.textTheme.titleSmall
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 12),
// Shift info
shiftAsync.when(
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (_, __) =>
const Text('شیفت در دسترس نیست'),
data: (shift) => Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('شیفت',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant)),
const SizedBox(height: 4),
Text(
shift?['label'] as String? ?? 'شیفت تعریف نشده',
style: theme.textTheme.bodyLarge,
),
if (shift?['startTime'] != null) ...[
const SizedBox(height: 4),
Text(
'${shift!['startTime']}${shift['endTime'] ?? ''}',
style: theme.textTheme.bodySmall,
),
],
],
),
),
),
),
const SizedBox(height: 20),
// Clock in/out buttons
Row(
children: [
Expanded(
child: FilledButton.icon(
icon: const Icon(Icons.login),
label: const Text('ورود'),
onPressed: _busy ? null : () => _clock(true),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.logout),
label: const Text('خروج'),
onPressed: _busy ? null : () => _clock(false),
),
),
],
),
if (_message != null) ...[
const SizedBox(height: 16),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Text(
_message!,
style: TextStyle(color: theme.colorScheme.onPrimaryContainer),
textAlign: TextAlign.center,
),
),
],
],
),
);
}
}
@@ -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,
),
],
],
),
);
}
}
+51
View File
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/router/app_router.dart';
import 'features/notifications/notification_provider.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await initLocalNotifications();
runApp(const ProviderScope(child: MeeziWaiterApp()));
}
class MeeziWaiterApp extends ConsumerWidget {
const MeeziWaiterApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(appRouterProvider);
return MaterialApp.router(
title: 'میزی — گارسون',
debugShowCheckedModeBanner: false,
locale: const Locale('fa'),
supportedLocales: const [Locale('fa'), Locale('ar'), Locale('en')],
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF10B981), // emerald-500
brightness: Brightness.light,
),
useMaterial3: true,
fontFamily: 'Vazirmatn',
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF10B981),
brightness: Brightness.dark,
),
useMaterial3: true,
fontFamily: 'Vazirmatn',
),
themeMode: ThemeMode.system,
routerConfig: router,
);
}
}