diff --git a/lib/data/local/hive_database.dart b/lib/data/local/hive_database.dart index b358e40..7795409 100644 --- a/lib/data/local/hive_database.dart +++ b/lib/data/local/hive_database.dart @@ -1,6 +1,7 @@ import 'package:hive_flutter/hive_flutter.dart'; import '../../features/inventory/models/food_item.dart'; import '../../features/settings/models/app_settings.dart'; +import '../../features/settings/models/household.dart'; /// Singleton class to manage Hive database class HiveDatabase { @@ -17,6 +18,7 @@ class HiveDatabase { Hive.registerAdapter(LocationAdapter()); Hive.registerAdapter(ExpirationStatusAdapter()); Hive.registerAdapter(AppSettingsAdapter()); + Hive.registerAdapter(HouseholdAdapter()); _initialized = true; } @@ -48,6 +50,29 @@ class HiveDatabase { return box.getAt(0)!; } + /// Get the households box + static Future> getHouseholdsBox() async { + if (!Hive.isBoxOpen('households')) { + return await Hive.openBox('households'); + } + return Hive.box('households'); + } + + /// Get household by ID + static Future getHousehold(String id) async { + final box = await getHouseholdsBox(); + return box.values.firstWhere( + (h) => h.id == id, + orElse: () => throw Exception('Household not found'), + ); + } + + /// Save household + static Future saveHousehold(Household household) async { + final box = await getHouseholdsBox(); + await box.put(household.id, household); + } + /// Clear all data static Future clearAll() async { final box = await getFoodBox(); diff --git a/lib/features/inventory/repositories/inventory_repository_impl.dart b/lib/features/inventory/repositories/inventory_repository_impl.dart index 6d897aa..d4a4bf0 100644 --- a/lib/features/inventory/repositories/inventory_repository_impl.dart +++ b/lib/features/inventory/repositories/inventory_repository_impl.dart @@ -1,5 +1,6 @@ import 'package:hive/hive.dart'; import '../../../data/local/hive_database.dart'; +import '../../settings/models/app_settings.dart'; import '../models/food_item.dart'; import 'inventory_repository.dart'; @@ -7,10 +8,32 @@ import 'inventory_repository.dart'; class InventoryRepositoryImpl implements InventoryRepository { Future> get _box async => await HiveDatabase.getFoodBox(); + /// Get the current household ID from settings + Future get _currentHouseholdId async { + final settings = await HiveDatabase.getSettings(); + return settings.currentHouseholdId; + } + + /// Filter items by current household + /// If user is in a household, only show items from that household + /// If user is not in a household, only show items without a household + List _filterByHousehold(Iterable items, String? householdId) { + return items.where((item) { + if (householdId == null) { + // User not in household - show items without household + return item.householdId == null; + } else { + // User in household - show items from that household + return item.householdId == householdId; + } + }).toList(); + } + @override Future> getAllItems() async { final box = await _box; - return box.values.toList(); + final householdId = await _currentHouseholdId; + return _filterByHousehold(box.values, householdId); } @override @@ -41,7 +64,9 @@ class InventoryRepositoryImpl implements InventoryRepository { @override Future> getItemsByLocation(Location location) async { final box = await _box; - return box.values + final householdId = await _currentHouseholdId; + final filteredItems = _filterByHousehold(box.values, householdId); + return filteredItems .where((item) => item.location == location) .toList(); } @@ -49,8 +74,10 @@ class InventoryRepositoryImpl implements InventoryRepository { @override Future> getItemsExpiringWithinDays(int days) async { final box = await _box; + final householdId = await _currentHouseholdId; + final filteredItems = _filterByHousehold(box.values, householdId); final targetDate = DateTime.now().add(Duration(days: days)); - return box.values + return filteredItems .where((item) => item.expirationDate.isBefore(targetDate) && item.expirationDate.isAfter(DateTime.now())) @@ -61,7 +88,9 @@ class InventoryRepositoryImpl implements InventoryRepository { @override Future> getExpiredItems() async { final box = await _box; - return box.values + final householdId = await _currentHouseholdId; + final filteredItems = _filterByHousehold(box.values, householdId); + return filteredItems .where((item) => item.expirationDate.isBefore(DateTime.now())) .toList() ..sort((a, b) => a.expirationDate.compareTo(b.expirationDate)); @@ -70,8 +99,10 @@ class InventoryRepositoryImpl implements InventoryRepository { @override Future> searchItemsByName(String query) async { final box = await _box; + final householdId = await _currentHouseholdId; + final filteredItems = _filterByHousehold(box.values, householdId); final lowerQuery = query.toLowerCase(); - return box.values + return filteredItems .where((item) => item.name.toLowerCase().contains(lowerQuery)) .toList(); } @@ -79,32 +110,40 @@ class InventoryRepositoryImpl implements InventoryRepository { @override Future getItemCount() async { final box = await _box; - return box.length; + final householdId = await _currentHouseholdId; + final filteredItems = _filterByHousehold(box.values, householdId); + return filteredItems.length; } @override Stream> watchAllItems() async* { final box = await _box; - yield box.values.toList(); + final householdId = await _currentHouseholdId; + yield _filterByHousehold(box.values, householdId); await for (final _ in box.watch()) { - yield box.values.toList(); + final currentHouseholdId = await _currentHouseholdId; + yield _filterByHousehold(box.values, currentHouseholdId); } } @override Stream> watchExpiringItems(int days) async* { final box = await _box; + final householdId = await _currentHouseholdId; final targetDate = DateTime.now().add(Duration(days: days)); - yield box.values + final filteredItems = _filterByHousehold(box.values, householdId); + yield filteredItems .where((item) => item.expirationDate.isBefore(targetDate) && item.expirationDate.isAfter(DateTime.now())) .toList(); await for (final _ in box.watch()) { - yield box.values + final currentHouseholdId = await _currentHouseholdId; + final currentFilteredItems = _filterByHousehold(box.values, currentHouseholdId); + yield currentFilteredItems .where((item) => item.expirationDate.isBefore(targetDate) && item.expirationDate.isAfter(DateTime.now())) diff --git a/lib/features/inventory/screens/add_item_screen.dart b/lib/features/inventory/screens/add_item_screen.dart index 065e1e1..797fb55 100644 --- a/lib/features/inventory/screens/add_item_screen.dart +++ b/lib/features/inventory/screens/add_item_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import '../../../core/constants/colors.dart'; +import '../../../data/local/hive_database.dart'; import '../models/food_item.dart'; import '../controllers/inventory_controller.dart'; import '../services/barcode_service.dart'; @@ -118,6 +119,9 @@ class _AddItemScreenState extends ConsumerState { Future _saveItem() async { if (_formKey.currentState!.validate()) { + // Get current household ID from settings + final settings = await HiveDatabase.getSettings(); + final item = FoodItem() ..name = _nameController.text.trim() ..barcode = _barcode @@ -132,6 +136,7 @@ class _AddItemScreenState extends ConsumerState { ..notes = _notesController.text.trim().isEmpty ? null : _notesController.text.trim() + ..householdId = settings.currentHouseholdId ..lastModified = DateTime.now(); try { diff --git a/lib/features/settings/models/app_settings.dart b/lib/features/settings/models/app_settings.dart index 2cdc013..763e83e 100644 --- a/lib/features/settings/models/app_settings.dart +++ b/lib/features/settings/models/app_settings.dart @@ -19,11 +19,19 @@ class AppSettings extends HiveObject { @HiveField(4) String sortBy; // 'expiration', 'name', 'location' + @HiveField(5) + String? userName; // User's name for household sharing + + @HiveField(6) + String? currentHouseholdId; // ID of the household they're in + AppSettings({ this.discordWebhookUrl, this.expirationAlertsEnabled = true, this.discordNotificationsEnabled = false, this.defaultView = 'grid', this.sortBy = 'expiration', + this.userName, + this.currentHouseholdId, }); } diff --git a/lib/features/settings/models/app_settings.g.dart b/lib/features/settings/models/app_settings.g.dart index a941d68..8018ac3 100644 --- a/lib/features/settings/models/app_settings.g.dart +++ b/lib/features/settings/models/app_settings.g.dart @@ -22,13 +22,15 @@ class AppSettingsAdapter extends TypeAdapter { discordNotificationsEnabled: fields[2] as bool, defaultView: fields[3] as String, sortBy: fields[4] as String, + userName: fields[5] as String?, + currentHouseholdId: fields[6] as String?, ); } @override void write(BinaryWriter writer, AppSettings obj) { writer - ..writeByte(5) + ..writeByte(7) ..writeByte(0) ..write(obj.discordWebhookUrl) ..writeByte(1) @@ -38,7 +40,11 @@ class AppSettingsAdapter extends TypeAdapter { ..writeByte(3) ..write(obj.defaultView) ..writeByte(4) - ..write(obj.sortBy); + ..write(obj.sortBy) + ..writeByte(5) + ..write(obj.userName) + ..writeByte(6) + ..write(obj.currentHouseholdId); } @override diff --git a/lib/features/settings/models/household.dart b/lib/features/settings/models/household.dart new file mode 100644 index 0000000..24208f6 --- /dev/null +++ b/lib/features/settings/models/household.dart @@ -0,0 +1,45 @@ +import 'package:hive/hive.dart'; + +part 'household.g.dart'; + +@HiveType(typeId: 4) +class Household extends HiveObject { + @HiveField(0) + late String id; // Unique household code + + @HiveField(1) + late String name; + + @HiveField(2) + late String ownerName; // Person who created the household + + @HiveField(3) + late DateTime createdAt; + + @HiveField(4) + List members; // List of member names + + Household({ + required this.id, + required this.name, + required this.ownerName, + DateTime? createdAt, + List? members, + }) : createdAt = createdAt ?? DateTime.now(), + members = members ?? []; + + /// Generate a random household code + static String generateCode() { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + final random = DateTime.now().millisecondsSinceEpoch; + var code = ''; + var seed = random; + + for (var i = 0; i < 6; i++) { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + code += chars[seed % chars.length]; + } + + return code; + } +} diff --git a/lib/features/settings/models/household.g.dart b/lib/features/settings/models/household.g.dart new file mode 100644 index 0000000..68c54a3 --- /dev/null +++ b/lib/features/settings/models/household.g.dart @@ -0,0 +1,53 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'household.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class HouseholdAdapter extends TypeAdapter { + @override + final int typeId = 4; + + @override + Household read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return Household( + id: fields[0] as String, + name: fields[1] as String, + ownerName: fields[2] as String, + createdAt: fields[3] as DateTime?, + members: (fields[4] as List?)?.cast(), + ); + } + + @override + void write(BinaryWriter writer, Household obj) { + writer + ..writeByte(5) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.name) + ..writeByte(2) + ..write(obj.ownerName) + ..writeByte(3) + ..write(obj.createdAt) + ..writeByte(4) + ..write(obj.members); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is HouseholdAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/features/settings/screens/household_screen.dart b/lib/features/settings/screens/household_screen.dart new file mode 100644 index 0000000..7e6be84 --- /dev/null +++ b/lib/features/settings/screens/household_screen.dart @@ -0,0 +1,490 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../../../core/constants/colors.dart'; +import '../../../data/local/hive_database.dart'; +import '../models/app_settings.dart'; +import '../models/household.dart'; + +class HouseholdScreen extends StatefulWidget { + const HouseholdScreen({super.key}); + + @override + State createState() => _HouseholdScreenState(); +} + +class _HouseholdScreenState extends State { + AppSettings? _settings; + Household? _household; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _loadData() async { + final settings = await HiveDatabase.getSettings(); + Household? household; + + if (settings.currentHouseholdId != null) { + try { + household = await HiveDatabase.getHousehold(settings.currentHouseholdId!); + } catch (e) { + // Household not found + } + } + + setState(() { + _settings = settings; + _household = household; + _isLoading = false; + }); + } + + Future _createHousehold() async { + if (_settings!.userName == null || _settings!.userName!.isEmpty) { + _showNameInputDialog(); + return; + } + + final nameController = TextEditingController(); + + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Create Household'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: 'Household Name', + hintText: 'e.g., Smith Family, Roommates', + ), + textCapitalization: TextCapitalization.words, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, nameController.text), + child: const Text('Create'), + ), + ], + ), + ); + + if (result != null && result.isNotEmpty) { + final household = Household( + id: Household.generateCode(), + name: result, + ownerName: _settings!.userName!, + members: [_settings!.userName!], + ); + + await HiveDatabase.saveHousehold(household); + + _settings!.currentHouseholdId = household.id; + await _settings!.save(); + + await _loadData(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Household created! Code: ${household.id}'), + backgroundColor: AppColors.success, + duration: const Duration(seconds: 5), + ), + ); + } + } + } + + Future _joinHousehold() async { + if (_settings!.userName == null || _settings!.userName!.isEmpty) { + _showNameInputDialog(); + return; + } + + final codeController = TextEditingController(); + + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Join Household'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Enter the household code shared with you:'), + const SizedBox(height: 16), + TextField( + controller: codeController, + decoration: const InputDecoration( + labelText: 'Household Code', + hintText: '6-character code', + ), + textCapitalization: TextCapitalization.characters, + maxLength: 6, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, codeController.text), + child: const Text('Join'), + ), + ], + ), + ); + + if (result != null && result.isNotEmpty) { + try { + final household = await HiveDatabase.getHousehold(result.toUpperCase()); + + if (household != null) { + if (!household.members.contains(_settings!.userName!)) { + household.members.add(_settings!.userName!); + await household.save(); + } + + _settings!.currentHouseholdId = household.id; + await _settings!.save(); + + 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, + ), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Household not found. Check the code and try again.'), + backgroundColor: AppColors.error, + ), + ); + } + } + } + } + + Future _showNameInputDialog() async { + final nameController = TextEditingController(text: _settings!.userName); + + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Enter Your Name'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Please enter your name to use household sharing:'), + const SizedBox(height: 16), + TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: 'Your Name', + hintText: 'e.g., Sarah', + ), + 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) { + _settings!.userName = result; + await _settings!.save(); + setState(() {}); + } + } + + Future _leaveHousehold() async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Leave Household?'), + content: const Text( + 'You will no longer see items from this household. You can rejoin later with the household code.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text( + 'Leave', + style: TextStyle(color: AppColors.error), + ), + ), + ], + ), + ); + + if (confirm == true && _household != null) { + _household!.members.remove(_settings!.userName); + await _household!.save(); + + _settings!.currentHouseholdId = null; + await _settings!.save(); + + await _loadData(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Left household'), + backgroundColor: AppColors.success, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + appBar: AppBar( + title: const Text('Household Sharing'), + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + ), + body: _household == null ? _buildNoHousehold() : _buildHouseholdInfo(), + ); + } + + Widget _buildNoHousehold() { + return Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.group, + size: 80, + color: AppColors.primary, + ), + const SizedBox(height: 24), + const Text( + 'Share Your Inventory', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + const Text( + 'Create or join a household to share your kitchen inventory with family or roommates!', + style: TextStyle(fontSize: 16), + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _createHousehold, + icon: const Icon(Icons.add), + label: const Text('Create Household'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _joinHousehold, + icon: const Icon(Icons.login), + label: const Text('Join Household'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primary, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ), + ], + ), + ); + } + + Widget _buildHouseholdInfo() { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.home, color: AppColors.primary, size: 32), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _household!.name, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'Owner: ${_household!.ownerName}', + style: TextStyle( + color: Colors.grey[600], + ), + ), + ], + ), + ), + ], + ), + const Divider(height: 32), + const Text( + 'Household Code', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _household!.id, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + letterSpacing: 4, + ), + textAlign: TextAlign.center, + ), + ), + ), + const SizedBox(width: 8), + IconButton( + onPressed: () { + Clipboard.setData(ClipboardData(text: _household!.id)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Code copied to clipboard!'), + duration: Duration(seconds: 2), + ), + ); + }, + icon: const Icon(Icons.copy), + color: AppColors.primary, + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'Share this code with others to let them join your household', + style: TextStyle(fontSize: 12), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + const Text( + 'Members', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + ..._household!.members.map((member) => Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: AppColors.primary, + child: Text( + member[0].toUpperCase(), + style: const TextStyle(color: Colors.white), + ), + ), + title: Text(member), + trailing: member == _household!.ownerName + ? const Chip( + label: Text('Owner'), + backgroundColor: AppColors.primary, + labelStyle: TextStyle(color: Colors.white), + ) + : null, + ), + )), + const SizedBox(height: 24), + OutlinedButton.icon( + onPressed: _leaveHousehold, + icon: const Icon(Icons.exit_to_app), + label: const Text('Leave Household'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.error, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ], + ); + } +} diff --git a/lib/features/settings/screens/settings_screen.dart b/lib/features/settings/screens/settings_screen.dart index e8a710c..9e63bc0 100644 --- a/lib/features/settings/screens/settings_screen.dart +++ b/lib/features/settings/screens/settings_screen.dart @@ -6,6 +6,7 @@ import '../models/app_settings.dart'; import '../../notifications/services/discord_service.dart'; import 'privacy_policy_screen.dart'; import 'terms_of_service_screen.dart'; +import 'household_screen.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -91,6 +92,29 @@ class _SettingsScreenState extends State { const Divider(), + // Sharing Section + _buildSectionHeader('Sharing'), + ListTile( + title: const Text('Household Sharing'), + subtitle: Text(_settings!.currentHouseholdId != null + ? 'Connected to household' + : 'Share inventory with family'), + leading: const Icon(Icons.group, color: AppColors.primary), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const HouseholdScreen(), + ), + ); + // Reload settings after returning from household screen + _loadSettings(); + }, + ), + + const Divider(), + // Display Section _buildSectionHeader('Display'), ListTile(