Enhance UI with Material Design 3 and fix cart quantity handling

UI Improvements:
- Menu items displayed as attractive cards with icons and better typography
- Restaurant selection upgraded to card-based layout with shadows
- Animated pulsing beacon scanner with gradient effect
- Enhanced item customization sheet with drag handle and pill-style pricing
- Category headers with highlighted background and borders
- Business and service point names now shown in app bar

Persistent Login:
- Created AuthStorage service for credential persistence using SharedPreferences
- Auto-restore authentication on app launch
- Seamless login flow: scan → browse → login on cart add
- Users stay logged in across app restarts

Cart Functionality Fixes:
- Fixed duplicate item handling: now properly increments quantity
- Prevented adding inactive items by skipping unselected modifiers
- Fixed self-referential items (item cannot be its own child)
- Added debug logging for cart state tracking
- Success messages now show accurate item counts

Technical Improvements:
- AppState tracks business/service point names for display
- Beacon scanner passes location names through navigation
- Quantity calculation checks existing cart items before adding
- Better null safety with firstOrNull pattern

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
John Mizerek 2025-12-31 09:40:23 -08:00
parent c445664df8
commit 0a8c12c1d3
8 changed files with 403 additions and 71 deletions

View file

@ -2,7 +2,9 @@ import "package:flutter/foundation.dart";
class AppState extends ChangeNotifier { class AppState extends ChangeNotifier {
int? _selectedBusinessId; int? _selectedBusinessId;
String? _selectedBusinessName;
int? _selectedServicePointId; int? _selectedServicePointId;
String? _selectedServicePointName;
int? _userId; int? _userId;
@ -12,7 +14,9 @@ class AppState extends ChangeNotifier {
int? get selectedBusinessId => _selectedBusinessId; int? get selectedBusinessId => _selectedBusinessId;
String? get selectedBusinessName => _selectedBusinessName;
int? get selectedServicePointId => _selectedServicePointId; int? get selectedServicePointId => _selectedServicePointId;
String? get selectedServicePointName => _selectedServicePointName;
int? get userId => _userId; int? get userId => _userId;
bool get isLoggedIn => _userId != null && _userId! > 0; bool get isLoggedIn => _userId != null && _userId! > 0;
@ -44,9 +48,16 @@ class AppState extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void setBusinessAndServicePoint(int businessId, int servicePointId) { void setBusinessAndServicePoint(
int businessId,
int servicePointId, {
String? businessName,
String? servicePointName,
}) {
_selectedBusinessId = businessId; _selectedBusinessId = businessId;
_selectedBusinessName = businessName;
_selectedServicePointId = servicePointId; _selectedServicePointId = servicePointId;
_selectedServicePointName = servicePointName;
_cartOrderId = null; _cartOrderId = null;
_cartOrderUuid = null; _cartOrderUuid = null;

View file

@ -15,7 +15,7 @@ class BeaconScanScreen extends StatefulWidget {
State<BeaconScanScreen> createState() => _BeaconScanScreenState(); State<BeaconScanScreen> createState() => _BeaconScanScreenState();
} }
class _BeaconScanScreenState extends State<BeaconScanScreen> { class _BeaconScanScreenState extends State<BeaconScanScreen> with SingleTickerProviderStateMixin {
String _status = 'Initializing...'; String _status = 'Initializing...';
bool _permissionsGranted = false; bool _permissionsGranted = false;
bool _scanning = false; bool _scanning = false;
@ -24,12 +24,28 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> {
final Map<String, List<int>> _beaconRssiSamples = {}; // UUID -> List of RSSI values final Map<String, List<int>> _beaconRssiSamples = {}; // UUID -> List of RSSI values
final Map<String, int> _beaconDetectionCount = {}; // UUID -> detection count final Map<String, int> _beaconDetectionCount = {}; // UUID -> detection count
late AnimationController _pulseController;
late Animation<double> _pulseAnimation;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_pulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat(reverse: true);
_pulseAnimation = Tween<double>(begin: 0.8, end: 1.2).animate(
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
);
_startScanFlow(); _startScanFlow();
} }
@override
void dispose() {
_pulseController.dispose();
super.dispose();
}
Future<void> _startScanFlow() async { Future<void> _startScanFlow() async {
// Step 1: Request permissions // Step 1: Request permissions
setState(() => _status = 'Requesting permissions...'); setState(() => _status = 'Requesting permissions...');
@ -221,7 +237,12 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> {
// Update app state with selected business and service point // Update app state with selected business and service point
final appState = context.read<AppState>(); final appState = context.read<AppState>();
appState.setBusinessAndServicePoint(mapping.businessId, mapping.servicePointId); appState.setBusinessAndServicePoint(
mapping.businessId,
mapping.servicePointId,
businessName: mapping.businessName,
servicePointName: mapping.servicePointName,
);
// Update API business ID for headers // Update API business ID for headers
Api.setBusinessId(mapping.businessId); Api.setBusinessId(mapping.businessId);
@ -291,7 +312,27 @@ class _BeaconScanScreenState extends State<BeaconScanScreen> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
if (_scanning) if (_scanning)
const CircularProgressIndicator(color: Colors.white) ScaleTransition(
scale: _pulseAnimation,
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
Colors.blue.withAlpha(102),
Colors.blue.withAlpha(26),
],
),
),
child: const Icon(
Icons.bluetooth_searching,
color: Colors.white,
size: 48,
),
),
)
else if (_permissionsGranted) else if (_permissionsGranted)
const Icon(Icons.bluetooth_searching, color: Colors.white70, size: 64) const Icon(Icons.bluetooth_searching, color: Colors.white70, size: 64)
else else

View file

@ -4,6 +4,7 @@ import "package:provider/provider.dart";
import "../app/app_router.dart"; import "../app/app_router.dart";
import "../app/app_state.dart"; import "../app/app_state.dart";
import "../services/api.dart"; import "../services/api.dart";
import "../services/auth_storage.dart";
class LoginScreen extends StatefulWidget { class LoginScreen extends StatefulWidget {
const LoginScreen({super.key}); const LoginScreen({super.key});
@ -45,6 +46,12 @@ class _LoginScreenState extends State<LoginScreen> {
if (!mounted) return; if (!mounted) return;
// Save credentials for persistent login
await AuthStorage.saveAuth(
userId: result.userId,
token: result.token,
);
final appState = context.read<AppState>(); final appState = context.read<AppState>();
appState.setUserId(result.userId); appState.setUserId(result.userId);

View file

@ -81,9 +81,12 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
if (item.isRootItem) { if (item.isRootItem) {
_itemsByCategory.putIfAbsent(item.categoryId, () => []).add(item); _itemsByCategory.putIfAbsent(item.categoryId, () => []).add(item);
} else { } else {
// Prevent an item from being its own child
if (item.itemId != item.parentItemId) {
_itemsByParent.putIfAbsent(item.parentItemId, () => []).add(item); _itemsByParent.putIfAbsent(item.parentItemId, () => []).add(item);
} }
} }
}
// Sort items within each category by sortOrder // Sort items within each category by sortOrder
for (final list in _itemsByCategory.values) { for (final list in _itemsByCategory.values) {
@ -105,10 +108,28 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final appState = context.watch<AppState>(); final appState = context.watch<AppState>();
final businessName = appState.selectedBusinessName ?? "Menu";
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text("Menu"), title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
businessName,
style: const TextStyle(fontSize: 18),
),
if (appState.selectedServicePointName != null)
Text(
appState.selectedServicePointName!,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
),
),
],
),
actions: [ actions: [
IconButton( IconButton(
icon: Badge( icon: Badge(
@ -171,8 +192,18 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( Container(
padding: const EdgeInsets.all(16), width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 24, 16, 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outlineVariant,
width: 1,
),
),
),
child: Text( child: Text(
categoryName, categoryName,
style: Theme.of(context).textTheme.titleLarge?.copyWith( style: Theme.of(context).textTheme.titleLarge?.copyWith(
@ -194,22 +225,17 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
Widget _buildMenuItem(MenuItem item) { Widget _buildMenuItem(MenuItem item) {
final hasModifiers = _itemsByParent.containsKey(item.itemId); final hasModifiers = _itemsByParent.containsKey(item.itemId);
return ListTile( return Container(
title: Text(item.name), margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
subtitle: item.description.isNotEmpty decoration: BoxDecoration(
? Text( color: Theme.of(context).colorScheme.surface,
item.description, borderRadius: BorderRadius.circular(12),
maxLines: 2, border: Border.all(
overflow: TextOverflow.ellipsis, color: Theme.of(context).colorScheme.outlineVariant.withAlpha(128),
)
: null,
trailing: Text(
"\$${item.price.toStringAsFixed(2)}",
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
), ),
), ),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () { onTap: () {
if (hasModifiers) { if (hasModifiers) {
_showItemCustomization(item); _showItemCustomization(item);
@ -217,6 +243,83 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
_addToCart(item, {}); _addToCart(item, {});
} }
}, },
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// Food icon
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.restaurant,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 16),
// Item details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
if (item.description.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
item.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
if (hasModifiers) ...[
const SizedBox(height: 4),
Text(
"Customizable",
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
],
],
),
),
const SizedBox(width: 12),
// Price and add button
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
"\$${item.price.toStringAsFixed(2)}",
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 4),
Icon(
Icons.add_circle,
color: Theme.of(context).colorScheme.primary,
size: 20,
),
],
),
],
),
),
),
); );
} }
@ -302,13 +405,23 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
// ignore: avoid_print // ignore: avoid_print
print("DEBUG: About to add root item ${item.itemId} to cart ${cart.orderId}"); print("DEBUG: About to add root item ${item.itemId} to cart ${cart.orderId}");
// Add root item // Check if this item already exists in the cart (as a root item)
final existingItem = cart.lineItems.where(
(li) => li.itemId == item.itemId && li.parentOrderLineItemId == 0 && !li.isDeleted
).firstOrNull;
final newQuantity = (existingItem?.quantity ?? 0) + 1;
// ignore: avoid_print
print("DEBUG: Existing quantity: ${existingItem?.quantity ?? 0}, new quantity: $newQuantity");
// Add root item (or update quantity if it exists)
cart = await Api.setLineItem( cart = await Api.setLineItem(
orderId: cart.orderId, orderId: cart.orderId,
parentOrderLineItemId: 0, parentOrderLineItemId: 0,
itemId: item.itemId, itemId: item.itemId,
isSelected: true, isSelected: true,
quantity: 1, quantity: newQuantity,
); );
// ignore: avoid_print // ignore: avoid_print
@ -337,13 +450,18 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
// Refresh cart to get final state // Refresh cart to get final state
cart = await Api.getCart(orderId: cart.orderId); cart = await Api.getCart(orderId: cart.orderId);
// ignore: avoid_print
print("DEBUG: Final cart state - itemCount=${cart.itemCount}, lineItems=${cart.lineItems.length}");
print("DEBUG: Root items: ${cart.lineItems.where((li) => li.parentOrderLineItemId == 0 && !li.isDeleted).map((li) => 'ItemID=${li.itemId}, Qty=${li.quantity}, Deleted=${li.isDeleted}').join(', ')}");
appState.updateCartItemCount(cart.itemCount); appState.updateCartItemCount(cart.itemCount);
if (!mounted) return; if (!mounted) return;
final message = selectedModifierIds.isEmpty final message = selectedModifierIds.isEmpty
? "Added ${item.name} to cart" ? "Added ${item.name} to cart (${cart.itemCount} item${cart.itemCount == 1 ? '' : 's'})"
: "Added ${item.name} with ${selectedModifierIds.length} customizations"; : "Added ${item.name} with ${selectedModifierIds.length} customizations (${cart.itemCount} item${cart.itemCount == 1 ? '' : 's'})";
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)), SnackBar(content: Text(message)),
@ -374,6 +492,13 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
for (final child in children) { for (final child in children) {
final isSelected = selectedItemIds.contains(child.itemId); final isSelected = selectedItemIds.contains(child.itemId);
// Only add selected items to the cart
if (!isSelected) {
// ignore: avoid_print
print("DEBUG: Skipping unselected child ItemID=${child.itemId} (${child.name})");
continue;
}
// ignore: avoid_print // ignore: avoid_print
print("DEBUG: Processing child ItemID=${child.itemId} (${child.name}), isSelected=$isSelected"); print("DEBUG: Processing child ItemID=${child.itemId} (${child.name}), isSelected=$isSelected");
@ -382,14 +507,12 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
orderId: orderId, orderId: orderId,
parentOrderLineItemId: parentOrderLineItemId, parentOrderLineItemId: parentOrderLineItemId,
itemId: child.itemId, itemId: child.itemId,
isSelected: isSelected, isSelected: true,
); );
// ignore: avoid_print // ignore: avoid_print
print("DEBUG: setLineItem response: cart has ${cart.lineItems.length} line items"); print("DEBUG: setLineItem response: cart has ${cart.lineItems.length} line items");
// Recursively add grandchildren if this modifier was selected
if (isSelected) {
// Find the OrderLineItemID of this modifier we just added // Find the OrderLineItemID of this modifier we just added
final childLineItem = cart.lineItems.lastWhere( final childLineItem = cart.lineItems.lastWhere(
(li) => li.itemId == child.itemId && li.parentOrderLineItemId == parentOrderLineItemId && !li.isDeleted, (li) => li.itemId == child.itemId && li.parentOrderLineItemId == parentOrderLineItemId && !li.isDeleted,
@ -399,6 +522,7 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
// ignore: avoid_print // ignore: avoid_print
print("DEBUG: Child modifier OrderLineItemID=${childLineItem.orderLineItemId}"); print("DEBUG: Child modifier OrderLineItemID=${childLineItem.orderLineItemId}");
// Recursively add grandchildren
await _addModifiersRecursively( await _addModifiersRecursively(
orderId, orderId,
childLineItem.orderLineItemId, childLineItem.orderLineItemId,
@ -408,7 +532,6 @@ class _MenuBrowseScreenState extends State<MenuBrowseScreen> {
} }
} }
} }
}
/// Recursive item customization sheet with full rule support /// Recursive item customization sheet with full rule support
class _ItemCustomizationSheet extends StatefulWidget { class _ItemCustomizationSheet extends StatefulWidget {
@ -518,34 +641,62 @@ class _ItemCustomizationSheetState extends State<_ItemCustomizationSheet> {
children: [ children: [
// Header // Header
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
boxShadow: [ border: Border(
BoxShadow( bottom: BorderSide(
color: Colors.black.withAlpha(25), color: Theme.of(context).colorScheme.outlineVariant,
blurRadius: 4, width: 1,
offset: const Offset(0, 2), ),
), ),
],
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row(
children: [
Container(
width: 12,
height: 4,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.onSurfaceVariant,
borderRadius: BorderRadius.circular(2),
),
),
],
),
const SizedBox(height: 16),
Text( Text(
widget.item.name, widget.item.name,
style: Theme.of(context).textTheme.titleLarge?.copyWith( style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
if (widget.item.description.isNotEmpty) ...[ if (widget.item.description.isNotEmpty) ...[
const SizedBox(height: 8),
Text(widget.item.description),
],
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
"Base price: \$${widget.item.price.toStringAsFixed(2)}", widget.item.description,
style: const TextStyle(fontWeight: FontWeight.w500), style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Text(
"Base: \$${widget.item.price.toStringAsFixed(2)}",
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(context).colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
), ),
], ],
), ),

View file

@ -111,15 +111,77 @@ class _RestaurantSelectScreenState extends State<RestaurantSelectScreen> {
); );
} }
return ListView.separated( return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: items.length, itemCount: items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, i) { itemBuilder: (context, i) {
final r = items[i]; final r = items[i];
return ListTile( return Container(
title: Text(r.name), margin: const EdgeInsets.only(bottom: 12),
trailing: const Icon(Icons.chevron_right), decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(15),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Material(
borderRadius: BorderRadius.circular(16),
color: Theme.of(context).colorScheme.surface,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () => _selectBusinessAndContinue(r), onTap: () => _selectBusinessAndContinue(r),
child: Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.store,
size: 32,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
r.name,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
"Tap to view menu",
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
Icon(
Icons.arrow_forward_ios,
color: Theme.of(context).colorScheme.onSurfaceVariant,
size: 20,
),
],
),
),
),
),
); );
}, },
); );

View file

@ -4,6 +4,8 @@ import "package:provider/provider.dart";
import "../app/app_router.dart"; import "../app/app_router.dart";
import "../app/app_state.dart"; import "../app/app_state.dart";
import "../services/api.dart";
import "../services/auth_storage.dart";
class SplashScreen extends StatefulWidget { class SplashScreen extends StatefulWidget {
const SplashScreen({super.key}); const SplashScreen({super.key});
@ -19,7 +21,18 @@ class _SplashScreenState extends State<SplashScreen> {
void initState() { void initState() {
super.initState(); super.initState();
_timer = Timer(const Duration(milliseconds: 2400), () { _timer = Timer(const Duration(milliseconds: 2400), () async {
if (!mounted) return;
// Check for saved authentication credentials
final credentials = await AuthStorage.loadAuth();
if (credentials != null) {
// Restore authentication state
Api.setAuthToken(credentials.token);
final appState = context.read<AppState>();
appState.setUserId(credentials.userId);
}
if (!mounted) return; if (!mounted) return;
// Always go to beacon scan first - allows browsing without login // Always go to beacon scan first - allows browsing without login

View file

@ -5,6 +5,7 @@ import "../models/cart.dart";
import "../models/menu_item.dart"; import "../models/menu_item.dart";
import "../models/restaurant.dart"; import "../models/restaurant.dart";
import "../models/service_point.dart"; import "../models/service_point.dart";
import "auth_storage.dart";
class ApiRawResponse { class ApiRawResponse {
final int statusCode; final int statusCode;
@ -197,9 +198,10 @@ class Api {
return response; return response;
} }
static void logout() { static Future<void> logout() async {
setAuthToken(null); setAuthToken(null);
clearCookies(); clearCookies();
await AuthStorage.clearAuth();
} }
// ------------------------- // -------------------------

View file

@ -0,0 +1,45 @@
import 'package:shared_preferences/shared_preferences.dart';
class AuthStorage {
static const _keyUserId = 'auth_user_id';
static const _keyUserToken = 'auth_user_token';
/// Save authentication credentials
static Future<void> saveAuth({
required int userId,
required String token,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_keyUserId, userId);
await prefs.setString(_keyUserToken, token);
}
/// Load saved authentication credentials
static Future<AuthCredentials?> loadAuth() async {
final prefs = await SharedPreferences.getInstance();
final userId = prefs.getInt(_keyUserId);
final token = prefs.getString(_keyUserToken);
if (userId != null && token != null && token.isNotEmpty) {
return AuthCredentials(userId: userId, token: token);
}
return null;
}
/// Clear authentication credentials (logout)
static Future<void> clearAuth() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyUserId);
await prefs.remove(_keyUserToken);
}
}
class AuthCredentials {
final int userId;
final String token;
const AuthCredentials({
required this.userId,
required this.token,
});
}