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