v1.3.0+4: FOSS Compliance + Dark Mode + Enhanced Settings
✨ Major Features: - Dark mode toggle with app-wide theme switching - Sort inventory by Expiration Date, Name, or Location - Toggle between Grid and List view for inventory - Export inventory data to CSV with share functionality - Custom sage leaf app icon with adaptive icon support 🔄 FOSS Compliance (F-Droid Ready): - Replaced Firebase with Supabase (open-source backend) - Anonymous authentication (no user accounts required) - Cloud-first with hosted Supabase as default - Optional self-hosting support - 100% FOSS-compliant dependencies 🎨 UI/UX Improvements: - Dynamic version display from package.json (was hardcoded) - Added edit buttons for household and user names - Removed non-functional search button - Replaced Recipes placeholder with Settings button - Improved settings organization with clear sections 📦 Dependencies: Added: - supabase_flutter: ^2.8.4 (FOSS backend sync) - package_info_plus: ^8.1.0 (dynamic version) - csv: ^6.0.0 (data export) - share_plus: ^10.1.2 (file sharing) - image: ^4.5.4 (dev, icon generation) Removed: - firebase_core (replaced with Supabase) - cloud_firestore (replaced with Supabase) 🗑️ Cleanup: - Removed Firebase setup files and google-services.json - Removed unimplemented features (Recipes, Search) - Removed firebase_household_service.dart - Removed inventory_sync_service.dart (replaced with Supabase) 📄 New Files: - lib/features/household/services/supabase_household_service.dart - web/privacy-policy.html (Play Store requirement) - web/terms-of-service.html (Play Store requirement) - PLAY_STORE_LISTING.md (marketing copy) - tool/generate_icons.dart (icon generation script) - assets/icon/sage_leaf.png (1024x1024) - assets/icon/sage_leaf_foreground.png (adaptive icon) 🐛 Bug Fixes: - Fixed version display showing hardcoded "1.0.0" - Fixed Sort By and Default View showing static text - Fixed ConsumerWidget build signatures - Fixed Location.displayName import issues - Added clearAllData method to Hive database 📊 Stats: +1,728 additions, -756 deletions across 42 files 🤖 Generated with Claude Code (https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/constants/colors.dart';
|
||||
import '../../../data/local/hive_database.dart';
|
||||
import '../../household/services/inventory_sync_service.dart';
|
||||
import '../../inventory/controllers/inventory_controller.dart';
|
||||
import '../../inventory/screens/add_item_screen.dart';
|
||||
import '../../inventory/screens/barcode_scanner_screen.dart';
|
||||
@@ -10,55 +8,11 @@ import '../../inventory/screens/inventory_screen.dart';
|
||||
import '../../settings/screens/settings_screen.dart';
|
||||
|
||||
/// Home screen - Dashboard with expiring items and quick actions
|
||||
class HomeScreen extends ConsumerStatefulWidget {
|
||||
class HomeScreen extends ConsumerWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
final _syncService = InventorySyncService();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startSyncIfNeeded();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_syncService.removeSyncCallback(_onItemsSync);
|
||||
_syncService.stopSync();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _startSyncIfNeeded() async {
|
||||
final settings = await HiveDatabase.getSettings();
|
||||
if (settings.currentHouseholdId != null) {
|
||||
try {
|
||||
// Register callback to refresh UI when items sync
|
||||
_syncService.addSyncCallback(_onItemsSync);
|
||||
|
||||
await _syncService.startSync(settings.currentHouseholdId!);
|
||||
print('🔄 Started syncing inventory for household: ${settings.currentHouseholdId}');
|
||||
} catch (e) {
|
||||
print('Failed to start sync: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _onItemsSync() {
|
||||
if (mounted) {
|
||||
// Refresh all inventory providers when Firebase syncs
|
||||
ref.invalidate(itemCountProvider);
|
||||
ref.invalidate(expiringSoonProvider);
|
||||
print('✅ UI refreshed after Firebase sync');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final itemCount = ref.watch(itemCountProvider);
|
||||
final expiringSoon = ref.watch(expiringSoonProvider);
|
||||
|
||||
@@ -216,14 +170,14 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
Expanded(
|
||||
child: _buildActionCard(
|
||||
context,
|
||||
icon: Icons.book,
|
||||
label: 'Recipes',
|
||||
icon: Icons.settings,
|
||||
label: 'Settings',
|
||||
color: AppColors.primaryLight,
|
||||
onTap: () {
|
||||
// TODO: Navigate to recipes
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Recipes coming soon!'),
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SettingsScreen(),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
@@ -1,199 +0,0 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import '../../settings/models/household.dart';
|
||||
import '../../../features/inventory/models/food_item.dart';
|
||||
|
||||
/// Service for managing household data in Firestore
|
||||
class FirebaseHouseholdService {
|
||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||
|
||||
/// Create a new household in Firestore
|
||||
Future<Household> createHousehold(String name, String ownerName) async {
|
||||
final household = Household(
|
||||
id: Household.generateCode(),
|
||||
name: name,
|
||||
ownerName: ownerName,
|
||||
createdAt: DateTime.now(),
|
||||
members: [ownerName],
|
||||
);
|
||||
|
||||
await _firestore.collection('households').doc(household.id).set({
|
||||
'id': household.id,
|
||||
'name': household.name,
|
||||
'ownerName': household.ownerName,
|
||||
'createdAt': household.createdAt.toIso8601String(),
|
||||
'members': household.members,
|
||||
});
|
||||
|
||||
return household;
|
||||
}
|
||||
|
||||
/// Get household by code from Firestore
|
||||
Future<Household?> getHousehold(String code) async {
|
||||
try {
|
||||
final doc = await _firestore.collection('households').doc(code).get();
|
||||
|
||||
if (!doc.exists) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final data = doc.data()!;
|
||||
final household = Household(
|
||||
id: data['id'] as String,
|
||||
name: data['name'] as String,
|
||||
ownerName: data['ownerName'] as String,
|
||||
createdAt: DateTime.parse(data['createdAt'] as String),
|
||||
members: List<String>.from(data['members'] as List),
|
||||
);
|
||||
|
||||
return household;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Join a household (add member)
|
||||
Future<bool> joinHousehold(String code, String memberName) async {
|
||||
try {
|
||||
final docRef = _firestore.collection('households').doc(code);
|
||||
final doc = await docRef.get();
|
||||
|
||||
if (!doc.exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final members = List<String>.from(doc.data()!['members'] as List);
|
||||
if (!members.contains(memberName)) {
|
||||
members.add(memberName);
|
||||
await docRef.update({'members': members});
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave a household (remove member)
|
||||
Future<void> leaveHousehold(String code, String memberName) async {
|
||||
final docRef = _firestore.collection('households').doc(code);
|
||||
final doc = await docRef.get();
|
||||
|
||||
if (doc.exists) {
|
||||
final members = List<String>.from(doc.data()!['members'] as List);
|
||||
members.remove(memberName);
|
||||
|
||||
if (members.isEmpty) {
|
||||
// Delete household if no members left
|
||||
await docRef.delete();
|
||||
} else {
|
||||
await docRef.update({'members': members});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add food item to household in Firestore
|
||||
Future<void> addFoodItem(String householdId, FoodItem item, String itemKey) async {
|
||||
await _firestore
|
||||
.collection('households')
|
||||
.doc(householdId)
|
||||
.collection('items')
|
||||
.doc(itemKey.toString())
|
||||
.set({
|
||||
'name': item.name,
|
||||
'barcode': item.barcode,
|
||||
'quantity': item.quantity,
|
||||
'unit': item.unit,
|
||||
'purchaseDate': item.purchaseDate.toIso8601String(),
|
||||
'expirationDate': item.expirationDate.toIso8601String(),
|
||||
'locationIndex': item.locationIndex,
|
||||
'category': item.category,
|
||||
'photoUrl': item.photoUrl,
|
||||
'notes': item.notes,
|
||||
'userId': item.userId,
|
||||
'householdId': item.householdId,
|
||||
'lastModified': item.lastModified?.toIso8601String(),
|
||||
'syncedToCloud': true,
|
||||
});
|
||||
}
|
||||
|
||||
/// Update food item in Firestore
|
||||
Future<void> updateFoodItem(String householdId, FoodItem item, String itemKey) async {
|
||||
await _firestore
|
||||
.collection('households')
|
||||
.doc(householdId)
|
||||
.collection('items')
|
||||
.doc(itemKey.toString())
|
||||
.update({
|
||||
'name': item.name,
|
||||
'barcode': item.barcode,
|
||||
'quantity': item.quantity,
|
||||
'unit': item.unit,
|
||||
'purchaseDate': item.purchaseDate.toIso8601String(),
|
||||
'expirationDate': item.expirationDate.toIso8601String(),
|
||||
'locationIndex': item.locationIndex,
|
||||
'category': item.category,
|
||||
'photoUrl': item.photoUrl,
|
||||
'notes': item.notes,
|
||||
'lastModified': DateTime.now().toIso8601String(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Delete food item from Firestore
|
||||
Future<void> deleteFoodItem(String householdId, String itemKey) async {
|
||||
await _firestore
|
||||
.collection('households')
|
||||
.doc(householdId)
|
||||
.collection('items')
|
||||
.doc(itemKey.toString())
|
||||
.delete();
|
||||
}
|
||||
|
||||
/// Stream household items from Firestore
|
||||
Stream<List<Map<String, dynamic>>> streamHouseholdItems(String householdId) {
|
||||
return _firestore
|
||||
.collection('households')
|
||||
.doc(householdId)
|
||||
.collection('items')
|
||||
.snapshots()
|
||||
.map((snapshot) {
|
||||
return snapshot.docs.map((doc) {
|
||||
final data = doc.data();
|
||||
data['firestoreId'] = doc.id;
|
||||
return data;
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
/// Sync local items to Firestore
|
||||
Future<void> syncItemsToFirestore(String householdId, List<FoodItem> items) async {
|
||||
final batch = _firestore.batch();
|
||||
final collection = _firestore
|
||||
.collection('households')
|
||||
.doc(householdId)
|
||||
.collection('items');
|
||||
|
||||
for (final item in items) {
|
||||
if (item.householdId == householdId && item.key != null) {
|
||||
final docRef = collection.doc(item.key.toString());
|
||||
batch.set(docRef, {
|
||||
'name': item.name,
|
||||
'barcode': item.barcode,
|
||||
'quantity': item.quantity,
|
||||
'unit': item.unit,
|
||||
'purchaseDate': item.purchaseDate.toIso8601String(),
|
||||
'expirationDate': item.expirationDate.toIso8601String(),
|
||||
'locationIndex': item.locationIndex,
|
||||
'category': item.category,
|
||||
'photoUrl': item.photoUrl,
|
||||
'notes': item.notes,
|
||||
'userId': item.userId,
|
||||
'householdId': item.householdId,
|
||||
'lastModified': item.lastModified?.toIso8601String(),
|
||||
'syncedToCloud': true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
}
|
||||
}
|
@@ -1,136 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../../../data/local/hive_database.dart';
|
||||
import '../../inventory/models/food_item.dart';
|
||||
|
||||
/// Service for syncing inventory items with Firebase in real-time
|
||||
class InventorySyncService {
|
||||
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
||||
StreamSubscription? _itemsSubscription;
|
||||
final _syncCallbacks = <VoidCallback>[];
|
||||
|
||||
/// Register a callback to be called when sync occurs
|
||||
void addSyncCallback(VoidCallback callback) {
|
||||
_syncCallbacks.add(callback);
|
||||
}
|
||||
|
||||
/// Remove a sync callback
|
||||
void removeSyncCallback(VoidCallback callback) {
|
||||
_syncCallbacks.remove(callback);
|
||||
}
|
||||
|
||||
/// Start listening to household items from Firebase
|
||||
Future<void> startSync(String householdId) async {
|
||||
await stopSync(); // Stop any existing subscription
|
||||
|
||||
print('📡 Starting Firebase sync for household: $householdId');
|
||||
|
||||
_itemsSubscription = _firestore
|
||||
.collection('households')
|
||||
.doc(householdId)
|
||||
.collection('items')
|
||||
.snapshots()
|
||||
.listen((snapshot) async {
|
||||
print('🔄 Received ${snapshot.docs.length} items from Firebase');
|
||||
await _handleItemsUpdate(snapshot, householdId);
|
||||
|
||||
// Notify listeners
|
||||
for (final callback in _syncCallbacks) {
|
||||
callback();
|
||||
}
|
||||
}, onError: (error) {
|
||||
print('❌ Firebase sync error: $error');
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop listening to Firebase updates
|
||||
Future<void> stopSync() async {
|
||||
await _itemsSubscription?.cancel();
|
||||
_itemsSubscription = null;
|
||||
}
|
||||
|
||||
/// Handle updates from Firebase
|
||||
Future<void> _handleItemsUpdate(
|
||||
QuerySnapshot snapshot,
|
||||
String householdId,
|
||||
) async {
|
||||
print('📦 Processing ${snapshot.docs.length} items from Firebase');
|
||||
final box = await HiveDatabase.getFoodBox();
|
||||
|
||||
// Track Firebase item IDs
|
||||
final firebaseItemIds = <String>{};
|
||||
int newItems = 0;
|
||||
int updatedItems = 0;
|
||||
|
||||
for (final doc in snapshot.docs) {
|
||||
firebaseItemIds.add(doc.id);
|
||||
final data = doc.data() as Map<String, dynamic>;
|
||||
|
||||
// Check if item exists in local Hive
|
||||
final itemKey = int.tryParse(doc.id);
|
||||
if (itemKey != null) {
|
||||
final existingItem = box.get(itemKey);
|
||||
|
||||
// Create or update item
|
||||
final item = _createFoodItemFromData(data, householdId);
|
||||
|
||||
if (existingItem == null) {
|
||||
// New item from Firebase - add to local Hive with specific key
|
||||
await box.put(itemKey, item);
|
||||
newItems++;
|
||||
print('➕ Added new item from Firebase: ${item.name} (key: $itemKey)');
|
||||
} else {
|
||||
// Update existing item if Firebase version is newer
|
||||
final firebaseModified = DateTime.parse(data['lastModified'] as String);
|
||||
final localModified = existingItem.lastModified ?? DateTime(2000);
|
||||
|
||||
if (firebaseModified.isAfter(localModified)) {
|
||||
// Firebase version is newer - update local
|
||||
await box.put(itemKey, item);
|
||||
updatedItems++;
|
||||
print('🔄 Updated item from Firebase: ${item.name} (key: $itemKey)');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print('📊 Sync stats: $newItems new, $updatedItems updated');
|
||||
|
||||
// Delete items that no longer exist in Firebase
|
||||
final itemsToDelete = <int>[];
|
||||
for (final item in box.values) {
|
||||
if (item.householdId == householdId && item.key != null) {
|
||||
if (!firebaseItemIds.contains(item.key.toString())) {
|
||||
itemsToDelete.add(item.key!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (itemsToDelete.isNotEmpty) {
|
||||
print('🗑️ Deleting ${itemsToDelete.length} items that no longer exist in Firebase');
|
||||
for (final key in itemsToDelete) {
|
||||
await box.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create FoodItem from Firebase data
|
||||
FoodItem _createFoodItemFromData(Map<String, dynamic> data, String householdId) {
|
||||
return FoodItem()
|
||||
..name = data['name'] as String
|
||||
..barcode = data['barcode'] as String?
|
||||
..quantity = data['quantity'] as int
|
||||
..unit = data['unit'] as String?
|
||||
..purchaseDate = DateTime.parse(data['purchaseDate'] as String)
|
||||
..expirationDate = DateTime.parse(data['expirationDate'] as String)
|
||||
..locationIndex = data['locationIndex'] as int
|
||||
..category = data['category'] as String?
|
||||
..photoUrl = data['photoUrl'] as String?
|
||||
..notes = data['notes'] as String?
|
||||
..userId = data['userId'] as String?
|
||||
..householdId = householdId
|
||||
..lastModified = DateTime.parse(data['lastModified'] as String)
|
||||
..syncedToCloud = true;
|
||||
}
|
||||
}
|
227
lib/features/household/services/supabase_household_service.dart
Normal file
227
lib/features/household/services/supabase_household_service.dart
Normal file
@@ -0,0 +1,227 @@
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import '../../settings/models/household.dart';
|
||||
import '../../../features/inventory/models/food_item.dart';
|
||||
|
||||
/// FOSS-compliant household sync using Supabase (open source Firebase alternative)
|
||||
/// Users can use free Supabase cloud tier OR self-host their own instance!
|
||||
class SupabaseHouseholdService {
|
||||
final SupabaseClient _client = Supabase.instance.client;
|
||||
|
||||
/// Check if user is authenticated with Supabase
|
||||
bool get isAuthenticated => _client.auth.currentUser != null;
|
||||
|
||||
/// Create a new household in Supabase
|
||||
Future<Household> createHousehold(String name, String ownerName) async {
|
||||
// Ensure we're signed in anonymously
|
||||
await signInAnonymously();
|
||||
|
||||
final household = Household(
|
||||
id: Household.generateCode(),
|
||||
name: name,
|
||||
ownerName: ownerName,
|
||||
createdAt: DateTime.now(),
|
||||
members: [ownerName],
|
||||
);
|
||||
|
||||
await _client.from('households').insert({
|
||||
'id': household.id,
|
||||
'name': household.name,
|
||||
'owner_name': household.ownerName,
|
||||
'created_at': household.createdAt.toIso8601String(),
|
||||
'members': household.members,
|
||||
});
|
||||
|
||||
print('✅ Household created: ${household.id}');
|
||||
return household;
|
||||
}
|
||||
|
||||
/// Get household by ID
|
||||
Future<Household?> getHousehold(String householdId) async {
|
||||
// Ensure we're signed in anonymously
|
||||
await signInAnonymously();
|
||||
|
||||
final response = await _client
|
||||
.from('households')
|
||||
.select()
|
||||
.eq('id', householdId)
|
||||
.single();
|
||||
|
||||
if (response == null) return null;
|
||||
|
||||
return Household(
|
||||
id: response['id'],
|
||||
name: response['name'],
|
||||
ownerName: response['owner_name'],
|
||||
createdAt: DateTime.parse(response['created_at']),
|
||||
members: List<String>.from(response['members']),
|
||||
);
|
||||
}
|
||||
|
||||
/// Join an existing household
|
||||
Future<Household> joinHousehold(String householdId, String userName) async {
|
||||
// Ensure we're signed in anonymously
|
||||
await signInAnonymously();
|
||||
|
||||
// Get current household
|
||||
final household = await getHousehold(householdId);
|
||||
if (household == null) {
|
||||
throw Exception('Household not found');
|
||||
}
|
||||
|
||||
// Add user to members if not already there
|
||||
if (!household.members.contains(userName)) {
|
||||
final updatedMembers = [...household.members, userName];
|
||||
|
||||
await _client.from('households').update({
|
||||
'members': updatedMembers,
|
||||
}).eq('id', householdId);
|
||||
|
||||
print('✅ Joined household: $householdId');
|
||||
|
||||
// Return updated household
|
||||
household.members = updatedMembers;
|
||||
return household;
|
||||
}
|
||||
|
||||
return household;
|
||||
}
|
||||
|
||||
/// Leave a household
|
||||
Future<void> leaveHousehold(String householdId, String userName) async {
|
||||
final household = await getHousehold(householdId);
|
||||
if (household == null) return;
|
||||
|
||||
final updatedMembers = household.members.where((m) => m != userName).toList();
|
||||
|
||||
await _client.from('households').update({
|
||||
'members': updatedMembers,
|
||||
}).eq('id', householdId);
|
||||
|
||||
print('✅ Left household: $householdId');
|
||||
}
|
||||
|
||||
/// Update household name
|
||||
Future<void> updateHouseholdName(String householdId, String newName) async {
|
||||
// Ensure we're signed in anonymously
|
||||
await signInAnonymously();
|
||||
|
||||
await _client.from('households').update({
|
||||
'name': newName,
|
||||
}).eq('id', householdId);
|
||||
|
||||
print('✅ Updated household name: $newName');
|
||||
}
|
||||
|
||||
/// Add food item to household inventory
|
||||
Future<void> addFoodItem(String householdId, FoodItem item, String localKey) async {
|
||||
// Ensure we're signed in anonymously
|
||||
await signInAnonymously();
|
||||
|
||||
await _client.from('food_items').insert({
|
||||
'household_id': householdId,
|
||||
'local_key': localKey,
|
||||
'name': item.name,
|
||||
'category': item.category,
|
||||
'barcode': item.barcode,
|
||||
'quantity': item.quantity,
|
||||
'unit': item.unit,
|
||||
'purchase_date': item.purchaseDate.toIso8601String(),
|
||||
'expiration_date': item.expirationDate.toIso8601String(),
|
||||
'notes': item.notes,
|
||||
'last_modified': item.lastModified?.toIso8601String() ?? DateTime.now().toIso8601String(),
|
||||
});
|
||||
|
||||
print('✅ Synced item to Supabase: ${item.name}');
|
||||
}
|
||||
|
||||
/// Update food item in household inventory
|
||||
Future<void> updateFoodItem(String householdId, FoodItem item, String localKey) async {
|
||||
await _client.from('food_items').update({
|
||||
'name': item.name,
|
||||
'category': item.category,
|
||||
'barcode': item.barcode,
|
||||
'quantity': item.quantity,
|
||||
'unit': item.unit,
|
||||
'purchase_date': item.purchaseDate.toIso8601String(),
|
||||
'expiration_date': item.expirationDate.toIso8601String(),
|
||||
'notes': item.notes,
|
||||
'last_modified': item.lastModified?.toIso8601String() ?? DateTime.now().toIso8601String(),
|
||||
}).eq('household_id', householdId).eq('local_key', localKey);
|
||||
|
||||
print('✅ Updated item in Supabase: ${item.name}');
|
||||
}
|
||||
|
||||
/// Delete food item from household inventory
|
||||
Future<void> deleteFoodItem(String householdId, String localKey) async {
|
||||
await _client
|
||||
.from('food_items')
|
||||
.delete()
|
||||
.eq('household_id', householdId)
|
||||
.eq('local_key', localKey);
|
||||
|
||||
print('✅ Deleted item from Supabase');
|
||||
}
|
||||
|
||||
/// Get all food items for a household
|
||||
Future<List<FoodItem>> getHouseholdItems(String householdId) async {
|
||||
final response = await _client
|
||||
.from('food_items')
|
||||
.select()
|
||||
.eq('household_id', householdId);
|
||||
|
||||
return (response as List).map<FoodItem>((item) {
|
||||
final foodItem = FoodItem();
|
||||
foodItem.name = item['name'];
|
||||
foodItem.category = item['category'];
|
||||
foodItem.barcode = item['barcode'];
|
||||
foodItem.quantity = item['quantity'];
|
||||
foodItem.unit = item['unit'];
|
||||
foodItem.purchaseDate = DateTime.parse(item['purchase_date']);
|
||||
foodItem.expirationDate = DateTime.parse(item['expiration_date']);
|
||||
foodItem.notes = item['notes'];
|
||||
foodItem.lastModified = DateTime.parse(item['last_modified']);
|
||||
foodItem.householdId = item['household_id'];
|
||||
return foodItem;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Subscribe to real-time updates for household items
|
||||
/// Returns a stream that emits whenever items change
|
||||
Stream<List<FoodItem>> subscribeToHouseholdItems(String householdId) {
|
||||
return _client
|
||||
.from('food_items')
|
||||
.stream(primaryKey: ['household_id', 'local_key'])
|
||||
.eq('household_id', householdId)
|
||||
.map((data) {
|
||||
return data.map<FoodItem>((item) {
|
||||
final foodItem = FoodItem();
|
||||
foodItem.name = item['name'];
|
||||
foodItem.category = item['category'];
|
||||
foodItem.barcode = item['barcode'];
|
||||
foodItem.quantity = item['quantity'];
|
||||
foodItem.unit = item['unit'];
|
||||
foodItem.purchaseDate = DateTime.parse(item['purchase_date']);
|
||||
foodItem.expirationDate = DateTime.parse(item['expiration_date']);
|
||||
foodItem.notes = item['notes'];
|
||||
foodItem.lastModified = DateTime.parse(item['last_modified']);
|
||||
foodItem.householdId = item['household_id'];
|
||||
return foodItem;
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
/// Sign in anonymously (no account needed!)
|
||||
/// This lets users sync without creating accounts
|
||||
Future<void> signInAnonymously() async {
|
||||
if (!isAuthenticated) {
|
||||
await _client.auth.signInAnonymously();
|
||||
print('✅ Signed in anonymously to Supabase');
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign out
|
||||
Future<void> signOut() async {
|
||||
await _client.auth.signOut();
|
||||
print('✅ Signed out from Supabase');
|
||||
}
|
||||
}
|
@@ -1,13 +1,13 @@
|
||||
import 'package:hive/hive.dart';
|
||||
import '../../../data/local/hive_database.dart';
|
||||
import '../../settings/models/app_settings.dart';
|
||||
import '../../household/services/firebase_household_service.dart';
|
||||
import '../../household/services/supabase_household_service.dart';
|
||||
import '../models/food_item.dart';
|
||||
import 'inventory_repository.dart';
|
||||
|
||||
/// Hive implementation of InventoryRepository with Firebase sync
|
||||
/// Hive implementation of InventoryRepository with Supabase sync (FOSS!)
|
||||
class InventoryRepositoryImpl implements InventoryRepository {
|
||||
final _firebaseService = FirebaseHouseholdService();
|
||||
final _supabaseService = SupabaseHouseholdService();
|
||||
Future<Box<FoodItem>> get _box async => await HiveDatabase.getFoodBox();
|
||||
|
||||
/// Get the current household ID from settings
|
||||
@@ -52,21 +52,21 @@ class InventoryRepositoryImpl implements InventoryRepository {
|
||||
|
||||
print('📝 Added item to Hive: ${item.name}, key=${item.key}, householdId=${item.householdId}');
|
||||
|
||||
// Sync to Firebase if in a household
|
||||
// Sync to Supabase if in a household
|
||||
if (item.householdId != null && item.key != null) {
|
||||
print('🚀 Uploading item to Firebase: ${item.name} (key: ${item.key})');
|
||||
print('🚀 Uploading item to Supabase: ${item.name} (key: ${item.key})');
|
||||
try {
|
||||
await _firebaseService.addFoodItem(
|
||||
await _supabaseService.addFoodItem(
|
||||
item.householdId!,
|
||||
item,
|
||||
item.key.toString(),
|
||||
);
|
||||
print('✅ Successfully uploaded to Firebase');
|
||||
print('✅ Successfully uploaded to Supabase');
|
||||
} catch (e) {
|
||||
print('❌ Failed to sync item to Firebase: $e');
|
||||
print('❌ Failed to sync item to Supabase: $e');
|
||||
}
|
||||
} else {
|
||||
print('⚠️ Skipping Firebase sync: householdId=${item.householdId}, key=${item.key}');
|
||||
print('⚠️ Skipping Supabase sync: householdId=${item.householdId}, key=${item.key}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,16 +75,16 @@ class InventoryRepositoryImpl implements InventoryRepository {
|
||||
item.lastModified = DateTime.now();
|
||||
await item.save();
|
||||
|
||||
// Sync to Firebase if in a household
|
||||
// Sync to Supabase if in a household
|
||||
if (item.householdId != null && item.key != null) {
|
||||
try {
|
||||
await _firebaseService.updateFoodItem(
|
||||
await _supabaseService.updateFoodItem(
|
||||
item.householdId!,
|
||||
item,
|
||||
item.key.toString(),
|
||||
);
|
||||
} catch (e) {
|
||||
print('Failed to sync item update to Firebase: $e');
|
||||
print('Failed to sync item update to Supabase: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,15 +94,15 @@ class InventoryRepositoryImpl implements InventoryRepository {
|
||||
final box = await _box;
|
||||
final item = box.get(id);
|
||||
|
||||
// Sync deletion to Firebase if in a household
|
||||
// Sync deletion to Supabase if in a household
|
||||
if (item != null && item.householdId != null) {
|
||||
try {
|
||||
await _firebaseService.deleteFoodItem(
|
||||
await _supabaseService.deleteFoodItem(
|
||||
item.householdId!,
|
||||
id.toString(),
|
||||
);
|
||||
} catch (e) {
|
||||
print('Failed to sync item deletion to Firebase: $e');
|
||||
print('Failed to sync item deletion to Supabase: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -17,14 +17,6 @@ class InventoryScreen extends ConsumerWidget {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('📦 Inventory'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () {
|
||||
// TODO: Search functionality
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: inventoryState.when(
|
||||
data: (items) {
|
||||
|
@@ -25,6 +25,15 @@ class AppSettings extends HiveObject {
|
||||
@HiveField(6)
|
||||
String? currentHouseholdId; // ID of the household they're in
|
||||
|
||||
@HiveField(7)
|
||||
String? supabaseUrl; // Supabase project URL (can use free tier OR self-hosted!)
|
||||
|
||||
@HiveField(8)
|
||||
String? supabaseAnonKey; // Supabase anonymous key (public, safe to store)
|
||||
|
||||
@HiveField(9)
|
||||
bool darkModeEnabled; // Dark mode toggle
|
||||
|
||||
AppSettings({
|
||||
this.discordWebhookUrl,
|
||||
this.expirationAlertsEnabled = true,
|
||||
@@ -33,5 +42,8 @@ class AppSettings extends HiveObject {
|
||||
this.sortBy = 'expiration',
|
||||
this.userName,
|
||||
this.currentHouseholdId,
|
||||
this.supabaseUrl,
|
||||
this.supabaseAnonKey,
|
||||
this.darkModeEnabled = false,
|
||||
});
|
||||
}
|
||||
|
@@ -24,13 +24,16 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
|
||||
sortBy: fields[4] as String,
|
||||
userName: fields[5] as String?,
|
||||
currentHouseholdId: fields[6] as String?,
|
||||
supabaseUrl: fields[7] as String?,
|
||||
supabaseAnonKey: fields[8] as String?,
|
||||
darkModeEnabled: fields[9] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, AppSettings obj) {
|
||||
writer
|
||||
..writeByte(7)
|
||||
..writeByte(10)
|
||||
..writeByte(0)
|
||||
..write(obj.discordWebhookUrl)
|
||||
..writeByte(1)
|
||||
@@ -44,7 +47,13 @@ class AppSettingsAdapter extends TypeAdapter<AppSettings> {
|
||||
..writeByte(5)
|
||||
..write(obj.userName)
|
||||
..writeByte(6)
|
||||
..write(obj.currentHouseholdId);
|
||||
..write(obj.currentHouseholdId)
|
||||
..writeByte(7)
|
||||
..write(obj.supabaseUrl)
|
||||
..writeByte(8)
|
||||
..write(obj.supabaseAnonKey)
|
||||
..writeByte(9)
|
||||
..write(obj.darkModeEnabled);
|
||||
}
|
||||
|
||||
@override
|
||||
|
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../../../core/constants/colors.dart';
|
||||
import '../../../data/local/hive_database.dart';
|
||||
import '../../household/services/firebase_household_service.dart';
|
||||
import '../../household/services/supabase_household_service.dart';
|
||||
import '../models/app_settings.dart';
|
||||
import '../models/household.dart';
|
||||
|
||||
@@ -14,7 +14,7 @@ class HouseholdScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HouseholdScreenState extends State<HouseholdScreen> {
|
||||
final _firebaseService = FirebaseHouseholdService();
|
||||
final _supabaseService = SupabaseHouseholdService();
|
||||
AppSettings? _settings;
|
||||
Household? _household;
|
||||
bool _isLoading = true;
|
||||
@@ -31,8 +31,8 @@ class _HouseholdScreenState extends State<HouseholdScreen> {
|
||||
|
||||
if (settings.currentHouseholdId != null) {
|
||||
try {
|
||||
// Load from Firebase
|
||||
household = await _firebaseService.getHousehold(settings.currentHouseholdId!);
|
||||
// Load from Supabase
|
||||
household = await _supabaseService.getHousehold(settings.currentHouseholdId!);
|
||||
} catch (e) {
|
||||
// Household not found
|
||||
}
|
||||
@@ -86,7 +86,7 @@ class _HouseholdScreenState extends State<HouseholdScreen> {
|
||||
if (result != null && result.isNotEmpty) {
|
||||
try {
|
||||
// Create household in Firebase
|
||||
final household = await _firebaseService.createHousehold(result, _settings!.userName!);
|
||||
final household = await _supabaseService.createHousehold(result, _settings!.userName!);
|
||||
|
||||
// Also save to local Hive for offline access
|
||||
await HiveDatabase.saveHousehold(household);
|
||||
@@ -164,40 +164,24 @@ class _HouseholdScreenState extends State<HouseholdScreen> {
|
||||
try {
|
||||
final code = result.toUpperCase();
|
||||
|
||||
// Join household in Firebase
|
||||
final success = await _firebaseService.joinHousehold(code, _settings!.userName!);
|
||||
// Join household in Supabase
|
||||
final household = await _supabaseService.joinHousehold(code, _settings!.userName!);
|
||||
|
||||
if (success) {
|
||||
// Load the household data
|
||||
final household = await _firebaseService.getHousehold(code);
|
||||
// Save to local Hive for offline access
|
||||
await HiveDatabase.saveHousehold(household);
|
||||
|
||||
if (household != null) {
|
||||
// Save to local Hive for offline access
|
||||
await HiveDatabase.saveHousehold(household);
|
||||
_settings!.currentHouseholdId = household.id;
|
||||
await _settings!.save();
|
||||
|
||||
_settings!.currentHouseholdId = household.id;
|
||||
await _settings!.save();
|
||||
await _loadData();
|
||||
|
||||
await _loadData();
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Joined ${household.name}!'),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Household not found. Check the code and try again.'),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Joined ${household.name}!'),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
@@ -254,6 +238,66 @@ class _HouseholdScreenState extends State<HouseholdScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _editHouseholdName() async {
|
||||
final nameController = TextEditingController(text: _household!.name);
|
||||
|
||||
final result = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Edit Household Name'),
|
||||
content: TextField(
|
||||
controller: nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Household Name',
|
||||
hintText: 'e.g., Smith Family',
|
||||
),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, nameController.text),
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null && result.isNotEmpty && result != _household!.name) {
|
||||
try {
|
||||
// Update in Supabase
|
||||
await _supabaseService.updateHouseholdName(_household!.id, result);
|
||||
|
||||
// Update local
|
||||
_household!.name = result;
|
||||
await HiveDatabase.saveHousehold(_household!);
|
||||
|
||||
setState(() {});
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Household name updated!'),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error updating name: $e'),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _leaveHousehold() async {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -280,7 +324,7 @@ class _HouseholdScreenState extends State<HouseholdScreen> {
|
||||
|
||||
if (confirm == true && _household != null) {
|
||||
// Leave household in Firebase
|
||||
await _firebaseService.leaveHousehold(_household!.id, _settings!.userName!);
|
||||
await _supabaseService.leaveHousehold(_household!.id, _settings!.userName!);
|
||||
|
||||
_settings!.currentHouseholdId = null;
|
||||
await _settings!.save();
|
||||
@@ -392,18 +436,40 @@ class _HouseholdScreenState extends State<HouseholdScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_household!.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_household!.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, size: 20),
|
||||
onPressed: _editHouseholdName,
|
||||
tooltip: 'Edit household name',
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'Owner: ${_household!.ownerName}',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'You: ${_settings!.userName ?? "Not set"}',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, size: 18),
|
||||
onPressed: _showNameInputDialog,
|
||||
tooltip: 'Edit your name',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@@ -1,9 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:io';
|
||||
import 'package:csv/csv.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import '../../../core/constants/colors.dart';
|
||||
import '../../../core/constants/app_icon.dart';
|
||||
import '../../../data/local/hive_database.dart';
|
||||
import '../models/app_settings.dart';
|
||||
import '../../notifications/services/discord_service.dart';
|
||||
import '../../inventory/repositories/inventory_repository_impl.dart';
|
||||
import '../../inventory/models/food_item.dart';
|
||||
import 'privacy_policy_screen.dart';
|
||||
import 'terms_of_service_screen.dart';
|
||||
import 'household_screen.dart';
|
||||
@@ -19,11 +27,20 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final _discordService = DiscordService();
|
||||
AppSettings? _settings;
|
||||
bool _isLoading = true;
|
||||
String _appVersion = '1.3.0';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSettings();
|
||||
_loadAppVersion();
|
||||
}
|
||||
|
||||
Future<void> _loadAppVersion() async {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
setState(() {
|
||||
_appVersion = packageInfo.version;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
@@ -117,17 +134,27 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
// Display Section
|
||||
_buildSectionHeader('Display'),
|
||||
SwitchListTile(
|
||||
title: const Text('Dark Mode'),
|
||||
subtitle: const Text('Reduce eye strain with dark theme'),
|
||||
value: _settings!.darkModeEnabled,
|
||||
onChanged: (value) {
|
||||
setState(() => _settings!.darkModeEnabled = value);
|
||||
_saveSettings();
|
||||
},
|
||||
activeColor: AppColors.primary,
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Default View'),
|
||||
subtitle: const Text('Grid'),
|
||||
subtitle: Text(_settings!.defaultView == 'grid' ? 'Grid' : 'List'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {},
|
||||
onTap: _showDefaultViewDialog,
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Sort By'),
|
||||
subtitle: const Text('Expiration Date'),
|
||||
subtitle: Text(_getSortByDisplayName(_settings!.sortBy)),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {},
|
||||
onTap: _showSortByDialog,
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
@@ -138,7 +165,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
title: const Text('Export Data'),
|
||||
subtitle: const Text('Export your inventory to CSV'),
|
||||
leading: const Icon(Icons.file_download, color: AppColors.primary),
|
||||
onTap: () {},
|
||||
onTap: _exportData,
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Clear All Data'),
|
||||
@@ -158,15 +185,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// TODO: Clear data
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('All data cleared'),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
onPressed: () async {
|
||||
// Clear all data from Hive
|
||||
await HiveDatabase.clearAllData();
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('All data cleared successfully'),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text(
|
||||
'Clear',
|
||||
@@ -187,9 +218,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
title: Text('App Name'),
|
||||
subtitle: Text('Sage - Kitchen Management'),
|
||||
),
|
||||
const ListTile(
|
||||
title: Text('Version'),
|
||||
subtitle: Text('1.0.0'),
|
||||
ListTile(
|
||||
title: const Text('Version'),
|
||||
subtitle: Text(_appVersion),
|
||||
),
|
||||
const ListTile(
|
||||
title: Text('Developer'),
|
||||
@@ -233,7 +264,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
showLicensePage(
|
||||
context: context,
|
||||
applicationName: 'Sage',
|
||||
applicationVersion: '1.0.0',
|
||||
applicationVersion: _appVersion,
|
||||
applicationIcon: const SageLeafIcon(
|
||||
size: 64,
|
||||
color: AppColors.primary,
|
||||
@@ -262,6 +293,189 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _exportData() async {
|
||||
try {
|
||||
final repository = InventoryRepositoryImpl();
|
||||
final items = await repository.getAllItems();
|
||||
|
||||
if (items.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No items to export!')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Create CSV data
|
||||
List<List<dynamic>> csvData = [
|
||||
['Name', 'Category', 'Location', 'Quantity', 'Unit', 'Barcode', 'Purchase Date', 'Expiration Date', 'Notes'],
|
||||
];
|
||||
|
||||
for (var item in items) {
|
||||
csvData.add([
|
||||
item.name,
|
||||
item.category ?? '',
|
||||
item.location.displayName,
|
||||
item.quantity,
|
||||
item.unit ?? '',
|
||||
item.barcode ?? '',
|
||||
DateFormat('yyyy-MM-dd').format(item.purchaseDate),
|
||||
DateFormat('yyyy-MM-dd').format(item.expirationDate),
|
||||
item.notes ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
// Convert to CSV string
|
||||
String csv = const ListToCsvConverter().convert(csvData);
|
||||
|
||||
// Save to temporary file
|
||||
final directory = await getTemporaryDirectory();
|
||||
final timestamp = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
|
||||
final filePath = '${directory.path}/sage_inventory_$timestamp.csv';
|
||||
final file = File(filePath);
|
||||
await file.writeAsString(csv);
|
||||
|
||||
// Share the file
|
||||
await Share.shareXFiles(
|
||||
[XFile(filePath)],
|
||||
subject: 'Sage Inventory Export',
|
||||
text: 'Exported ${items.length} items from Sage Kitchen Manager',
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Exported ${items.length} items!'),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error exporting data: $e'),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _getSortByDisplayName(String sortBy) {
|
||||
switch (sortBy) {
|
||||
case 'expiration':
|
||||
return 'Expiration Date';
|
||||
case 'name':
|
||||
return 'Name';
|
||||
case 'location':
|
||||
return 'Location';
|
||||
default:
|
||||
return 'Expiration Date';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showDefaultViewDialog() async {
|
||||
final result = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Default View'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
title: const Text('Grid'),
|
||||
leading: Radio<String>(
|
||||
value: 'grid',
|
||||
groupValue: _settings!.defaultView,
|
||||
onChanged: (value) => Navigator.pop(context, value),
|
||||
activeColor: AppColors.primary,
|
||||
),
|
||||
onTap: () => Navigator.pop(context, 'grid'),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('List'),
|
||||
leading: Radio<String>(
|
||||
value: 'list',
|
||||
groupValue: _settings!.defaultView,
|
||||
onChanged: (value) => Navigator.pop(context, value),
|
||||
activeColor: AppColors.primary,
|
||||
),
|
||||
onTap: () => Navigator.pop(context, 'list'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
setState(() => _settings!.defaultView = result);
|
||||
await _saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showSortByDialog() async {
|
||||
final result = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Sort By'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
title: const Text('Expiration Date'),
|
||||
leading: Radio<String>(
|
||||
value: 'expiration',
|
||||
groupValue: _settings!.sortBy,
|
||||
onChanged: (value) => Navigator.pop(context, value),
|
||||
activeColor: AppColors.primary,
|
||||
),
|
||||
onTap: () => Navigator.pop(context, 'expiration'),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Name'),
|
||||
leading: Radio<String>(
|
||||
value: 'name',
|
||||
groupValue: _settings!.sortBy,
|
||||
onChanged: (value) => Navigator.pop(context, value),
|
||||
activeColor: AppColors.primary,
|
||||
),
|
||||
onTap: () => Navigator.pop(context, 'name'),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Location'),
|
||||
leading: Radio<String>(
|
||||
value: 'location',
|
||||
groupValue: _settings!.sortBy,
|
||||
onChanged: (value) => Navigator.pop(context, value),
|
||||
activeColor: AppColors.primary,
|
||||
),
|
||||
onTap: () => Navigator.pop(context, 'location'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
setState(() => _settings!.sortBy = result);
|
||||
await _saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
void _showDiscordSetup() {
|
||||
final webhookController = TextEditingController(
|
||||
text: _discordService.webhookUrl ?? '',
|
||||
|
Reference in New Issue
Block a user