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