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(),
),
],
);
});