mirror of
https://gitlab.com/Zer0interrupt/recipe-manager.git
synced 2026-11-02 07:28:25 -05:00
291 lines
8.8 KiB
Dart
291 lines
8.8 KiB
Dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'api/api_client.dart';
|
|
import 'models/recipe.dart';
|
|
import 'models/shopping_list.dart';
|
|
import 'storage/secure_storage_service.dart';
|
|
|
|
final secureStorageProvider = Provider<SecureStorageService>((ref) {
|
|
return SecureStorageService();
|
|
});
|
|
|
|
final apiClientProvider = Provider<ApiClient>((ref) {
|
|
final storage = ref.watch(secureStorageProvider);
|
|
return ApiClient(storage);
|
|
});
|
|
|
|
// Authentication State Notifier
|
|
class AuthState {
|
|
final bool isAuthenticated;
|
|
final bool isLoading;
|
|
final String? errorMessage;
|
|
final Map<String, dynamic>? user;
|
|
|
|
const AuthState({
|
|
required this.isAuthenticated,
|
|
this.isLoading = false,
|
|
this.errorMessage,
|
|
this.user,
|
|
});
|
|
|
|
factory AuthState.initial() => const AuthState(isAuthenticated: false);
|
|
factory AuthState.loading() => const AuthState(isAuthenticated: false, isLoading: true);
|
|
factory AuthState.authenticated(Map<String, dynamic> user) =>
|
|
AuthState(isAuthenticated: true, user: user);
|
|
factory AuthState.unauthenticated([String? error]) =>
|
|
AuthState(isAuthenticated: false, errorMessage: error);
|
|
}
|
|
|
|
class AuthNotifier extends StateNotifier<AuthState> {
|
|
final ApiClient _api;
|
|
final SecureStorageService _storage;
|
|
|
|
AuthNotifier(this._api, this._storage) : super(AuthState.initial()) {
|
|
checkInitialAuth();
|
|
}
|
|
|
|
Future<void> checkInitialAuth() async {
|
|
final isValid = await _storage.hasValidSession();
|
|
if (isValid) {
|
|
final user = await _storage.getUser();
|
|
state = AuthState.authenticated(user ?? {'username': 'Chef'});
|
|
} else {
|
|
state = AuthState.unauthenticated();
|
|
}
|
|
}
|
|
|
|
Future<bool> login({
|
|
required String serverUrl,
|
|
required String username,
|
|
required String password,
|
|
required String deviceName,
|
|
}) async {
|
|
state = AuthState.loading();
|
|
try {
|
|
final res = await _api.login(
|
|
serverUrl: serverUrl,
|
|
username: username,
|
|
password: password,
|
|
deviceName: deviceName,
|
|
);
|
|
final user = res['user'] as Map<String, dynamic>;
|
|
state = AuthState.authenticated(user);
|
|
return true;
|
|
} catch (e) {
|
|
state = AuthState.unauthenticated(e.toString());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<void> logout() async {
|
|
await _api.logout();
|
|
state = AuthState.unauthenticated();
|
|
}
|
|
}
|
|
|
|
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
|
|
final api = ref.watch(apiClientProvider);
|
|
final storage = ref.watch(secureStorageProvider);
|
|
return AuthNotifier(api, storage);
|
|
});
|
|
|
|
// Recipe List State Notifier
|
|
class RecipeListNotifier extends StateNotifier<AsyncValue<List<Recipe>>> {
|
|
final ApiClient _api;
|
|
|
|
RecipeListNotifier(this._api) : super(const AsyncValue.loading()) {
|
|
loadRecipes();
|
|
}
|
|
|
|
Future<void> loadRecipes() async {
|
|
state = const AsyncValue.loading();
|
|
try {
|
|
final recipes = await _api.fetchRecipes();
|
|
state = AsyncValue.data(recipes);
|
|
} catch (e, st) {
|
|
state = AsyncValue.error(e, st);
|
|
}
|
|
}
|
|
|
|
Future<void> addOrUpdate(Recipe recipe) async {
|
|
try {
|
|
final saved = await _api.saveRecipe(recipe);
|
|
final current = state.value ?? [];
|
|
final idx = current.indexWhere((r) => r.id == saved.id);
|
|
if (idx >= 0) {
|
|
current[idx] = saved;
|
|
state = AsyncValue.data([...current]);
|
|
} else {
|
|
state = AsyncValue.data([saved, ...current]);
|
|
}
|
|
} catch (e) {
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<void> remove(String id) async {
|
|
await _api.deleteRecipe(id);
|
|
final current = state.value ?? [];
|
|
state = AsyncValue.data(current.where((r) => r.id != id).toList());
|
|
}
|
|
}
|
|
|
|
final recipeListProvider =
|
|
StateNotifierProvider<RecipeListNotifier, AsyncValue<List<Recipe>>>((ref) {
|
|
final api = ref.watch(apiClientProvider);
|
|
return RecipeListNotifier(api);
|
|
});
|
|
|
|
// Community / Public Recipes State Notifier
|
|
class PublicRecipesNotifier extends StateNotifier<AsyncValue<List<Recipe>>> {
|
|
final ApiClient _api;
|
|
|
|
PublicRecipesNotifier(this._api) : super(const AsyncValue.loading()) {
|
|
loadPublicRecipes();
|
|
}
|
|
|
|
Future<void> loadPublicRecipes() async {
|
|
state = const AsyncValue.loading();
|
|
try {
|
|
final recipes = await _api.getPublicRecipes();
|
|
state = AsyncValue.data(recipes);
|
|
} catch (e, st) {
|
|
state = AsyncValue.error(e, st);
|
|
}
|
|
}
|
|
|
|
Future<Recipe> cloneRecipe(String id, WidgetRef? ref) async {
|
|
final cloned = await _api.cloneRecipe(id);
|
|
if (ref != null) {
|
|
ref.read(recipeListProvider.notifier).loadRecipes();
|
|
}
|
|
return cloned;
|
|
}
|
|
}
|
|
|
|
final publicRecipesProvider =
|
|
StateNotifierProvider<PublicRecipesNotifier, AsyncValue<List<Recipe>>>((ref) {
|
|
final api = ref.watch(apiClientProvider);
|
|
return PublicRecipesNotifier(api);
|
|
});
|
|
|
|
// Shopping Lists State Notifier
|
|
class ShoppingListsNotifier extends StateNotifier<AsyncValue<List<ShoppingList>>> {
|
|
final ApiClient _api;
|
|
String? _activeListId;
|
|
|
|
ShoppingListsNotifier(this._api) : super(const AsyncValue.loading()) {
|
|
loadLists();
|
|
}
|
|
|
|
String? get activeListId => _activeListId;
|
|
|
|
void setActiveListId(String id) {
|
|
_activeListId = id;
|
|
// Trigger notification if data present
|
|
if (state.hasValue) {
|
|
state = AsyncValue.data([...state.value!]);
|
|
}
|
|
}
|
|
|
|
Future<void> loadLists() async {
|
|
state = const AsyncValue.loading();
|
|
try {
|
|
final data = await _api.getShoppingLists();
|
|
final lists = data.map((json) => ShoppingList.fromJson(json as Map<String, dynamic>)).toList();
|
|
if (lists.isNotEmpty && (_activeListId == null || !lists.any((l) => l.id == _activeListId))) {
|
|
_activeListId = lists.first.id;
|
|
}
|
|
state = AsyncValue.data(lists);
|
|
} catch (e, st) {
|
|
state = AsyncValue.error(e, st);
|
|
}
|
|
}
|
|
|
|
Future<void> createList(String name) async {
|
|
final res = await _api.createShoppingList(name);
|
|
final created = ShoppingList.fromJson(res);
|
|
_activeListId = created.id;
|
|
final current = state.value ?? [];
|
|
state = AsyncValue.data([...current, created]);
|
|
}
|
|
|
|
Future<void> deleteList(String id) async {
|
|
await _api.deleteShoppingList(id);
|
|
final current = (state.value ?? []).where((l) => l.id != id).toList();
|
|
if (_activeListId == id) {
|
|
_activeListId = current.isNotEmpty ? current.first.id : null;
|
|
}
|
|
state = AsyncValue.data(current);
|
|
}
|
|
|
|
Future<void> toggleItemCheck(String listId, String itemId, bool checked) async {
|
|
// Optimistic local update
|
|
final current = state.value ?? [];
|
|
final listIdx = current.indexWhere((l) => l.id == listId);
|
|
if (listIdx >= 0) {
|
|
final list = current[listIdx];
|
|
final itemIdx = list.items.indexWhere((i) => i.id == itemId);
|
|
if (itemIdx >= 0) {
|
|
final updatedItems = [...list.items];
|
|
updatedItems[itemIdx] = updatedItems[itemIdx].copyWith(checked: checked);
|
|
current[listIdx] = ShoppingList(
|
|
id: list.id,
|
|
name: list.name,
|
|
userId: list.userId,
|
|
createdBy: list.createdBy,
|
|
shareToken: list.shareToken,
|
|
createdAt: list.createdAt,
|
|
updatedAt: DateTime.now().toIso8601String(),
|
|
visitorCount: list.visitorCount,
|
|
items: updatedItems,
|
|
);
|
|
state = AsyncValue.data([...current]);
|
|
}
|
|
}
|
|
|
|
try {
|
|
final updatedJson = await _api.checkShoppingListItems(listId, [itemId], checked);
|
|
final updatedList = ShoppingList.fromJson(updatedJson);
|
|
_replaceList(updatedList);
|
|
} catch (e) {
|
|
// Reload on failure to revert optimistic update
|
|
loadLists();
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<void> addItems(String listId, List<String> items, {String? recipeId, String? recipeTitle}) async {
|
|
final updatedJson = await _api.addShoppingListItems(listId, items, recipeId: recipeId, recipeTitle: recipeTitle);
|
|
final updatedList = ShoppingList.fromJson(updatedJson);
|
|
_replaceList(updatedList);
|
|
}
|
|
|
|
Future<void> deleteItems(String listId, List<String> itemIds) async {
|
|
final updatedJson = await _api.deleteShoppingListItems(listId, itemIds);
|
|
final updatedList = ShoppingList.fromJson(updatedJson);
|
|
_replaceList(updatedList);
|
|
}
|
|
|
|
Future<void> clearList(String listId, {bool onlyChecked = true}) async {
|
|
final updatedJson = await _api.clearShoppingList(listId, onlyChecked: onlyChecked);
|
|
final updatedList = ShoppingList.fromJson(updatedJson);
|
|
_replaceList(updatedList);
|
|
}
|
|
|
|
void _replaceList(ShoppingList updated) {
|
|
final current = state.value ?? [];
|
|
final idx = current.indexWhere((l) => l.id == updated.id);
|
|
if (idx >= 0) {
|
|
current[idx] = updated;
|
|
state = AsyncValue.data([...current]);
|
|
} else {
|
|
state = AsyncValue.data([...current, updated]);
|
|
}
|
|
}
|
|
}
|
|
|
|
final shoppingListsProvider =
|
|
StateNotifierProvider<ShoppingListsNotifier, AsyncValue<List<ShoppingList>>>((ref) {
|
|
final api = ref.watch(apiClientProvider);
|
|
return ShoppingListsNotifier(api);
|
|
});
|