diff --git a/lib/main.dart b/lib/main.dart index 7c91061..41dabb5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import 'services/auth_service.dart'; +import 'services/checklist_service.dart'; import 'services/prefs_service.dart'; import 'services/theming_service.dart'; import 'views/home/home_view.dart'; @@ -18,7 +19,10 @@ void main() async { await AuthService.instance.loadCredentials(); await PrefsService.instance.load(); if (AuthService.instance.isLoggedIn) { - await ThemingService.instance.fetchTheme(); + await Future.wait([ + ThemingService.instance.fetchTheme(), + ChecklistService.instance.loadFromDisk(), + ]); } runApp(const PantryApp()); } diff --git a/lib/models/category.dart b/lib/models/category.dart new file mode 100644 index 0000000..2598dc3 --- /dev/null +++ b/lib/models/category.dart @@ -0,0 +1,32 @@ +class Category { + final int id; + final int houseId; + final String name; + final String icon; + final String color; + final int sortOrder; + final int createdAt; + final int updatedAt; + + const Category({ + required this.id, + required this.houseId, + required this.name, + required this.icon, + required this.color, + required this.sortOrder, + required this.createdAt, + required this.updatedAt, + }); + + factory Category.fromJson(Map json) => Category( + id: json['id'] as int, + houseId: json['houseId'] as int, + name: json['name'] as String, + icon: json['icon'] as String, + color: json['color'] as String, + sortOrder: json['sortOrder'] as int, + createdAt: json['createdAt'] as int, + updatedAt: json['updatedAt'] as int, + ); +} diff --git a/lib/models/checklist.dart b/lib/models/checklist.dart new file mode 100644 index 0000000..364717a --- /dev/null +++ b/lib/models/checklist.dart @@ -0,0 +1,138 @@ +class ChecklistList { + final int id; + final int houseId; + final String name; + final String? description; + final String icon; + final int sortOrder; + final int createdAt; + final int updatedAt; + + const ChecklistList({ + required this.id, + required this.houseId, + required this.name, + this.description, + required this.icon, + required this.sortOrder, + required this.createdAt, + required this.updatedAt, + }); + + factory ChecklistList.fromJson(Map json) => ChecklistList( + id: json['id'] as int, + houseId: json['houseId'] as int, + name: json['name'] as String, + description: json['description'] as String?, + icon: json['icon'] as String, + sortOrder: json['sortOrder'] as int, + createdAt: json['createdAt'] as int, + updatedAt: json['updatedAt'] as int, + ); + + Map toJson() => { + 'id': id, + 'houseId': houseId, + 'name': name, + 'description': description, + 'icon': icon, + 'sortOrder': sortOrder, + 'createdAt': createdAt, + 'updatedAt': updatedAt, + }; +} + +class ListItem { + final int id; + final int listId; + final String name; + final int? categoryId; + final String? quantity; + final bool done; + final int? doneAt; + final String? doneBy; + final String? rrule; + final bool repeatFromCompletion; + final int? nextDueAt; + final int? imageFileId; + final String? imageUploadedBy; + final int sortOrder; + final int createdAt; + final int updatedAt; + + const ListItem({ + required this.id, + required this.listId, + required this.name, + this.categoryId, + this.quantity, + required this.done, + this.doneAt, + this.doneBy, + this.rrule, + required this.repeatFromCompletion, + this.nextDueAt, + this.imageFileId, + this.imageUploadedBy, + required this.sortOrder, + required this.createdAt, + required this.updatedAt, + }); + + factory ListItem.fromJson(Map json) => ListItem( + id: json['id'] as int, + listId: json['listId'] as int, + name: json['name'] as String, + categoryId: json['categoryId'] as int?, + quantity: json['quantity'] as String?, + done: json['done'] as bool, + doneAt: json['doneAt'] as int?, + doneBy: json['doneBy'] as String?, + rrule: json['rrule'] as String?, + repeatFromCompletion: json['repeatFromCompletion'] as bool, + nextDueAt: json['nextDueAt'] as int?, + imageFileId: json['imageFileId'] as int?, + imageUploadedBy: json['imageUploadedBy'] as String?, + sortOrder: json['sortOrder'] as int, + createdAt: json['createdAt'] as int, + updatedAt: json['updatedAt'] as int, + ); + + Map toJson() => { + 'id': id, + 'listId': listId, + 'name': name, + 'categoryId': categoryId, + 'quantity': quantity, + 'done': done, + 'doneAt': doneAt, + 'doneBy': doneBy, + 'rrule': rrule, + 'repeatFromCompletion': repeatFromCompletion, + 'nextDueAt': nextDueAt, + 'imageFileId': imageFileId, + 'imageUploadedBy': imageUploadedBy, + 'sortOrder': sortOrder, + 'createdAt': createdAt, + 'updatedAt': updatedAt, + }; + + ListItem copyWith({bool? done, int? doneAt, String? doneBy}) => ListItem( + id: id, + listId: listId, + name: name, + categoryId: categoryId, + quantity: quantity, + done: done ?? this.done, + doneAt: doneAt ?? this.doneAt, + doneBy: doneBy ?? this.doneBy, + rrule: rrule, + repeatFromCompletion: repeatFromCompletion, + nextDueAt: nextDueAt, + imageFileId: imageFileId, + imageUploadedBy: imageUploadedBy, + sortOrder: sortOrder, + createdAt: createdAt, + updatedAt: updatedAt, + ); +} diff --git a/lib/services/api_client.dart b/lib/services/api_client.dart index 3c5e7c5..b04f3f3 100644 --- a/lib/services/api_client.dart +++ b/lib/services/api_client.dart @@ -34,44 +34,44 @@ class ApiClient { } Map get _headers => { - ..._credentials.basicAuthHeaders, - 'Accept': 'application/json', - 'Content-Type': 'application/json', - }; + ..._credentials.basicAuthHeaders, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }; - Future get( + Future get( String path, { Map? query, - required T Function(dynamic json) fromJson, + required T Function(D data) fromJson, }) async { final response = await http.get(_uri(path, query), headers: _headers); - return _handleResponse(response, fromJson); + return _handleResponse(response, fromJson); } - Future post( + Future post( String path, { Map? body, - required T Function(dynamic json) fromJson, + required T Function(D data) fromJson, }) async { final response = await http.post( _uri(path), headers: _headers, body: body != null ? jsonEncode(body) : null, ); - return _handleResponse(response, fromJson); + return _handleResponse(response, fromJson); } - Future put( + Future put( String path, { Map? body, - required T Function(dynamic json) fromJson, + required T Function(D data) fromJson, }) async { final response = await http.put( _uri(path), headers: _headers, body: body != null ? jsonEncode(body) : null, ); - return _handleResponse(response, fromJson); + return _handleResponse(response, fromJson); } Future delete(String path) async { @@ -81,11 +81,16 @@ class ApiClient { } } - T _handleResponse(http.Response response, T Function(dynamic) fromJson) { + Uri buildUri(String path, [Map? query]) => _uri(path, query); + + Map get authHeaders => _credentials.basicAuthHeaders; + + T _handleResponse(http.Response response, T Function(D) fromJson) { if (response.statusCode >= 400) { throw ApiException(response.statusCode, response.body); } final json = jsonDecode(response.body); - return fromJson(json); + final data = json['ocs']?['data'] ?? json; + return fromJson(data as D); } } diff --git a/lib/services/category_service.dart b/lib/services/category_service.dart new file mode 100644 index 0000000..701a602 --- /dev/null +++ b/lib/services/category_service.dart @@ -0,0 +1,16 @@ +import 'package:pantry/models/category.dart'; +import 'package:pantry/services/api_client.dart'; + +class CategoryService { + CategoryService._(); + static final CategoryService instance = CategoryService._(); + + Future> getCategories(int houseId) async { + return ApiClient.instance.get>( + '/houses/$houseId/categories', + fromJson: (data) => data + .map((e) => Category.fromJson(e as Map)) + .toList(), + ); + } +} diff --git a/lib/services/checklist_service.dart b/lib/services/checklist_service.dart new file mode 100644 index 0000000..858001f --- /dev/null +++ b/lib/services/checklist_service.dart @@ -0,0 +1,135 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:pantry/models/checklist.dart'; +import 'package:pantry/services/api_client.dart'; +import 'package:path_provider/path_provider.dart'; + +class ChecklistService { + ChecklistService._(); + static final ChecklistService instance = ChecklistService._(); + + final Map> _itemCache = {}; + List? _listsCache; + int? _listsCacheHouseId; + int? selectedListId; + + static const _cacheFileName = 'checklist_cache.json'; + + Future get _cacheFile async { + final dir = await getApplicationDocumentsDirectory(); + return File('${dir.path}/$_cacheFileName'); + } + + Future loadFromDisk() async { + try { + final file = await _cacheFile; + if (!await file.exists()) return; + final json = + jsonDecode(await file.readAsString()) as Map; + + _listsCacheHouseId = json['houseId'] as int?; + selectedListId = json['selectedListId'] as int?; + + if (json['lists'] != null) { + _listsCache = (json['lists'] as List) + .map((e) => ChecklistList.fromJson(e as Map)) + .toList(); + } + + if (json['items'] != null) { + final items = json['items'] as Map; + for (final entry in items.entries) { + _itemCache[int.parse(entry.key)] = (entry.value as List) + .map((e) => ListItem.fromJson(e as Map)) + .toList(); + } + } + } catch (e) { + debugPrint('[ChecklistService] Failed to load cache from disk: $e'); + } + } + + Future _saveToDisk() async { + try { + final json = { + 'houseId': _listsCacheHouseId, + 'selectedListId': selectedListId, + 'lists': _listsCache?.map((l) => l.toJson()).toList(), + 'items': _itemCache.map( + (k, v) => MapEntry(k.toString(), v.map((i) => i.toJson()).toList()), + ), + }; + final file = await _cacheFile; + await file.writeAsString(jsonEncode(json)); + } catch (e) { + debugPrint('[ChecklistService] Failed to save cache to disk: $e'); + } + } + + List? getCachedLists(int houseId) => + _listsCacheHouseId == houseId ? _listsCache : null; + + void cacheLists(int houseId, List lists) { + _listsCache = lists; + _listsCacheHouseId = houseId; + _saveToDisk(); + } + + List? getCachedItems(int listId) => _itemCache[listId]; + + void cacheItems(int listId, List items) { + _itemCache[listId] = items; + _saveToDisk(); + } + + void invalidateCache({int? keepListId}) { + if (keepListId != null) { + _itemCache.removeWhere((id, _) => id != keepListId); + } else { + _itemCache.clear(); + } + _listsCache = null; + _listsCacheHouseId = null; + _saveToDisk(); + } + + Future> getLists(int houseId) async { + return ApiClient.instance.get>( + '/houses/$houseId/lists', + fromJson: (data) => data + .map((e) => ChecklistList.fromJson(e as Map)) + .toList(), + ); + } + + Future> getItems(int houseId, int listId) async { + return ApiClient.instance.get>( + '/houses/$houseId/lists/$listId/items', + fromJson: (data) => data + .map((e) => ListItem.fromJson(e as Map)) + .toList(), + ); + } + + Uri itemImagePreviewUri( + int houseId, + int fileId, + String owner, { + int size = 128, + }) { + return ApiClient.instance.buildUri('/houses/$houseId/image-preview', { + 'fileId': fileId.toString(), + 'owner': owner, + 'size': size.toString(), + }); + } + + Future toggleItem(int houseId, int listId, int itemId) async { + return ApiClient.instance.post, ListItem>( + '/houses/$houseId/lists/$listId/items/$itemId/toggle', + fromJson: (data) => ListItem.fromJson(data), + ); + } +} diff --git a/lib/services/house_service.dart b/lib/services/house_service.dart index fe962d3..1ae171e 100644 --- a/lib/services/house_service.dart +++ b/lib/services/house_service.dart @@ -6,14 +6,10 @@ class HouseService { static final HouseService instance = HouseService._(); Future> getHouses() async { - return ApiClient.instance.get( + return ApiClient.instance.get>( '/houses', - fromJson: (json) { - final data = json['ocs']['data'] as List; - return data - .map((e) => House.fromJson(e as Map)) - .toList(); - }, + fromJson: (data) => + data.map((e) => House.fromJson(e as Map)).toList(), ); } } diff --git a/lib/utils/category_icons.dart b/lib/utils/category_icons.dart new file mode 100644 index 0000000..6d56497 --- /dev/null +++ b/lib/utils/category_icons.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +const _iconMap = { + 'tag': Icons.label, + 'food': Icons.lunch_dining, + 'fruit': Icons.apple, + 'vegetable': Icons.grass, + 'bakery': Icons.bakery_dining, + 'dairy': Icons.egg_alt, + 'meat': Icons.kebab_dining, + 'fish': Icons.set_meal, + 'snacks': Icons.breakfast_dining, + 'cookie': Icons.cookie, + 'drinks': Icons.wine_bar, + 'coffee': Icons.coffee, + 'frozen': Icons.ac_unit, + 'household': Icons.cleaning_services, + 'pets': Icons.pets, + 'baby': Icons.child_friendly, + 'home': Icons.home, + 'leaf': Icons.eco, + 'pizza': Icons.local_pizza, +}; + +const defaultCategoryIcon = Icons.label; + +IconData categoryIcon(String? key) { + return _iconMap[key ?? ''] ?? defaultCategoryIcon; +} diff --git a/lib/utils/checklist_icons.dart b/lib/utils/checklist_icons.dart new file mode 100644 index 0000000..214d3fd --- /dev/null +++ b/lib/utils/checklist_icons.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; + +const _iconMap = { + 'clipboard-check': Icons.assignment_turned_in, + 'clipboard-list': Icons.assignment, + 'format-list-checks': Icons.checklist, + 'cart': Icons.shopping_cart, + 'basket': Icons.shopping_basket, + 'star': Icons.star, + 'heart': Icons.favorite, + 'home': Icons.home, + 'calendar': Icons.calendar_today, + 'bell': Icons.notifications, + 'flag': Icons.flag, + 'bookmark': Icons.bookmark, + 'pin': Icons.push_pin, + 'map-marker': Icons.place, + 'briefcase': Icons.work, + 'wrench': Icons.build, + 'silverware': Icons.restaurant, + 'coffee': Icons.coffee, + 'gift': Icons.card_giftcard, + 'book': Icons.menu_book, + 'school': Icons.school, + 'palette': Icons.palette, + 'camera': Icons.camera_alt, + 'music': Icons.music_note, + 'gamepad': Icons.sports_esports, + 'run': Icons.directions_run, + 'dumbbell': Icons.fitness_center, + 'pill': Icons.medication, + 'paw': Icons.pets, + 'flower': Icons.local_florist, + 'tree': Icons.park, + 'broom': Icons.cleaning_services, + 'lightbulb': Icons.lightbulb, + 'package': Icons.inventory_2, + 'car': Icons.directions_car, + 'bike': Icons.directions_bike, + 'beach': Icons.beach_access, + 'tag': Icons.label, +}; + +const defaultChecklistIcon = Icons.assignment_turned_in; + +IconData checklistIcon(String? key) { + return _iconMap[key ?? ''] ?? defaultChecklistIcon; +} diff --git a/lib/views/checklists/checklist_item_tile.dart b/lib/views/checklists/checklist_item_tile.dart new file mode 100644 index 0000000..cb6cef5 --- /dev/null +++ b/lib/views/checklists/checklist_item_tile.dart @@ -0,0 +1,321 @@ +import 'package:flutter/material.dart'; +import 'package:pantry/models/category.dart' as models; +import 'package:pantry/models/checklist.dart'; +import 'package:pantry/services/auth_service.dart'; +import 'package:pantry/services/checklist_service.dart'; +import 'package:pantry/utils/category_icons.dart'; +import 'package:pantry/widgets/image_preview.dart'; + +class ChecklistItemTile extends StatelessWidget { + final ListItem item; + final models.Category? category; + final int houseId; + final ValueChanged onToggle; + + const ChecklistItemTile({ + super.key, + required this.item, + this.category, + required this.houseId, + required this.onToggle, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final dimmed = item.done; + + return Opacity( + opacity: dimmed ? 0.5 : 1.0, + child: InkWell( + onTap: () => onToggle(item), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + child: Row( + children: [ + Checkbox(value: item.done, onChanged: (_) => onToggle(item)), + if (item.imageFileId != null) ...[ + GestureDetector( + onTap: () => _showImagePreview(context), + child: Hero( + tag: 'item-image-${item.id}', + child: _ItemImage( + houseId: houseId, + fileId: item.imageFileId!, + owner: item.imageUploadedBy ?? '', + ), + ), + ), + const SizedBox(width: 8), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + style: theme.textTheme.bodyLarge?.copyWith( + decoration: dimmed ? TextDecoration.lineThrough : null, + ), + ), + if (_hasBadges) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Wrap( + spacing: 4, + runSpacing: 4, + children: _buildBadges(context), + ), + ), + ], + ), + ), + _MoreMenuButton(item: item), + ], + ), + ), + ), + ); + } + + void _showImagePreview(BuildContext context) { + final uri = ChecklistService.instance.itemImagePreviewUri( + houseId, + item.imageFileId!, + item.imageUploadedBy ?? '', + size: 1024, + ); + ImagePreview.show( + context, + imageUrl: uri.toString(), + heroTag: 'item-image-${item.id}', + headers: AuthService.instance.credentials?.basicAuthHeaders ?? {}, + ); + } + + bool get _hasBadges => + (item.quantity != null && item.quantity!.isNotEmpty) || + category != null || + (item.rrule != null && item.rrule!.isNotEmpty); + + List _buildBadges(BuildContext context) { + final badges = []; + final theme = Theme.of(context); + + if (item.quantity != null && item.quantity!.isNotEmpty) { + badges.add( + _Badge( + icon: Icons.close, + label: item.quantity!, + color: theme.colorScheme.surfaceContainerHighest, + textColor: theme.colorScheme.onSurface, + ), + ); + } + + if (category != null) { + final catColor = + _parseColor(category!.color) ?? theme.colorScheme.primary; + badges.add( + _Badge( + icon: categoryIcon(category!.icon), + label: category!.name, + color: catColor.withAlpha(30), + textColor: catColor, + ), + ); + } + + if (item.rrule != null && item.rrule!.isNotEmpty) { + badges.add( + _Badge( + icon: Icons.event_repeat, + label: _formatRrule(item.rrule!), + color: theme.colorScheme.surfaceContainerHighest, + textColor: theme.colorScheme.onSurface, + ), + ); + } + + return badges; + } + + static Color? _parseColor(String hex) { + if (hex.isEmpty) return null; + hex = hex.replaceFirst('#', ''); + if (hex.length == 6) hex = 'FF$hex'; + final value = int.tryParse(hex, radix: 16); + return value != null ? Color(value) : null; + } + + static String _formatRrule(String rrule) { + // Simple human-readable rrule summary + final parts = rrule.split(';'); + final map = {}; + for (final part in parts) { + final kv = part.split('='); + if (kv.length == 2) map[kv[0]] = kv[1]; + } + + final freq = map['FREQ']?.toLowerCase(); + final interval = int.tryParse(map['INTERVAL'] ?? '1') ?? 1; + final byDay = map['BYDAY']; + + if (freq == null) return rrule; + + final dayNames = { + 'MO': 'Monday', + 'TU': 'Tuesday', + 'WE': 'Wednesday', + 'TH': 'Thursday', + 'FR': 'Friday', + 'SA': 'Saturday', + 'SU': 'Sunday', + }; + + const singularNames = { + 'daily': 'day', + 'weekly': 'week', + 'monthly': 'month', + 'yearly': 'year', + }; + const pluralNames = { + 'daily': 'days', + 'weekly': 'weeks', + 'monthly': 'months', + 'yearly': 'years', + }; + + String prefix; + if (interval == 1) { + prefix = 'every ${singularNames[freq] ?? freq}'; + } else { + prefix = 'every $interval ${pluralNames[freq] ?? freq}'; + } + + if (byDay != null && freq == 'weekly') { + final days = byDay.split(',').map((d) => dayNames[d] ?? d).join(', '); + return '$prefix on $days'; + } + + return prefix; + } +} + +class _ItemImage extends StatelessWidget { + final int houseId; + final int fileId; + final String owner; + + const _ItemImage({ + required this.houseId, + required this.fileId, + required this.owner, + }); + + @override + Widget build(BuildContext context) { + final uri = ChecklistService.instance.itemImagePreviewUri( + houseId, + fileId, + owner, + size: 64, + ); + final headers = AuthService.instance.credentials?.basicAuthHeaders ?? {}; + + return ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.network( + uri.toString(), + headers: headers, + width: 40, + height: 40, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => const SizedBox( + width: 40, + height: 40, + child: Icon(Icons.broken_image_outlined, size: 20), + ), + ), + ); + } +} + +class _Badge extends StatelessWidget { + final IconData? icon; + final String label; + final Color color; + final Color textColor; + + const _Badge({ + this.icon, + required this.label, + required this.color, + required this.textColor, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 12, color: textColor), + const SizedBox(width: 3), + ], + Text(label, style: TextStyle(fontSize: 11, color: textColor)), + ], + ), + ); + } +} + +class _MoreMenuButton extends StatelessWidget { + final ListItem item; + + const _MoreMenuButton({required this.item}); + + @override + Widget build(BuildContext context) { + return PopupMenuButton( + icon: Icon( + Icons.more_horiz, + size: 20, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'edit', + child: Row( + children: [ + Icon(Icons.edit, size: 18), + SizedBox(width: 8), + Text('Edit item'), + ], + ), + ), + const PopupMenuItem( + value: 'remove', + child: Row( + children: [ + Icon(Icons.delete, size: 18), + SizedBox(width: 8), + Text('Remove item'), + ], + ), + ), + ], + onSelected: (value) { + // TODO: Implement edit/remove + }, + ); + } +} diff --git a/lib/views/checklists/checklists_controller.dart b/lib/views/checklists/checklists_controller.dart new file mode 100644 index 0000000..31cf595 --- /dev/null +++ b/lib/views/checklists/checklists_controller.dart @@ -0,0 +1,156 @@ +import 'package:flutter/foundation.dart'; +import 'package:pantry/models/category.dart' as models; +import 'package:pantry/models/checklist.dart'; +import 'package:pantry/services/category_service.dart'; +import 'package:pantry/services/checklist_service.dart'; + +class ChecklistsController extends ChangeNotifier { + final int houseId; + + ChecklistsController({required this.houseId}); + + List _lists = []; + List get lists => _lists; + + ChecklistList? _currentList; + ChecklistList? get currentList => _currentList; + + List _items = []; + List get items => _items; + + Map _categories = {}; + Map get categories => _categories; + + bool _isLoading = true; + bool get isLoading => _isLoading; + + String? _error; + String? get error => _error; + + ChecklistService get _service => ChecklistService.instance; + + Future load() async { + _error = null; + + // Restore from cache immediately + final cachedLists = _service.getCachedLists(houseId); + if (cachedLists != null && _lists.isEmpty) { + _lists = cachedLists; + if (_lists.isNotEmpty) { + final savedId = _service.selectedListId; + _currentList = + (savedId != null + ? _lists.cast().firstWhere( + (l) => l!.id == savedId, + orElse: () => null, + ) + : null) ?? + _lists.first; + final cached = _service.getCachedItems(_currentList!.id); + if (cached != null) { + _items = cached; + _isLoading = false; + notifyListeners(); + } + } + } + + if (_lists.isEmpty) { + _isLoading = true; + notifyListeners(); + } + + try { + final results = await Future.wait([ + _service.getLists(houseId), + CategoryService.instance.getCategories(houseId), + ]); + + _lists = results[0] as List; + _service.cacheLists(houseId, _lists); + final cats = results[1] as List; + _categories = {for (final c in cats) c.id: c}; + + if (_lists.isNotEmpty) { + final target = _currentList != null + ? _lists.cast().firstWhere( + (l) => l!.id == _currentList!.id, + orElse: () => null, + ) ?? + _lists.first + : _lists.first; + await selectList(target); + } else { + _isLoading = false; + notifyListeners(); + } + } catch (e) { + debugPrint('[ChecklistsController] Failed to load: $e'); + _error = 'Failed to load checklists.'; + _isLoading = false; + notifyListeners(); + } + } + + Future selectList(ChecklistList list) async { + _currentList = list; + _service.selectedListId = list.id; + + // Show cached items immediately, or spinner if no cache for this list + final cached = _service.getCachedItems(list.id); + if (cached != null) { + _items = cached; + _isLoading = false; + notifyListeners(); + } else { + _items = []; + _isLoading = true; + notifyListeners(); + } + + // Fetch fresh data in background + try { + final freshItems = await _service.getItems(houseId, list.id); + _service.cacheItems(list.id, freshItems); + if (_currentList?.id == list.id) { + _items = freshItems; + _isLoading = false; + notifyListeners(); + } + } catch (e) { + debugPrint('[ChecklistsController] Failed to load items: $e'); + if (cached == null) { + _error = 'Failed to load items.'; + _isLoading = false; + notifyListeners(); + } + } + } + + Future refresh() async { + await load(); + _service.invalidateCache(keepListId: _currentList?.id); + } + + Future toggleItem(ListItem item) async { + final index = _items.indexWhere((i) => i.id == item.id); + if (index == -1) return; + + // Optimistic update + _items[index] = item.copyWith(done: !item.done); + _service.cacheItems(item.listId, List.of(_items)); + notifyListeners(); + + try { + final updated = await _service.toggleItem(houseId, item.listId, item.id); + _items[index] = updated; + _service.cacheItems(item.listId, List.of(_items)); + notifyListeners(); + } catch (e) { + // Revert on failure + _items[index] = item; + _service.cacheItems(item.listId, List.of(_items)); + notifyListeners(); + } + } +} diff --git a/lib/views/checklists/checklists_view.dart b/lib/views/checklists/checklists_view.dart new file mode 100644 index 0000000..36cbd91 --- /dev/null +++ b/lib/views/checklists/checklists_view.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:pantry/models/checklist.dart'; +import 'package:pantry/utils/checklist_icons.dart'; +import 'checklist_item_tile.dart'; +import 'checklists_controller.dart'; + +class ChecklistsView extends StatefulWidget { + final int houseId; + + const ChecklistsView({super.key, required this.houseId}); + + @override + State createState() => _ChecklistsViewState(); +} + +class _ChecklistsViewState extends State { + late final _controller = ChecklistsController(houseId: widget.houseId); + + @override + void initState() { + super.initState(); + _controller.load(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: _controller, + child: const _ChecklistsBody(), + ); + } +} + +class _ChecklistsBody extends StatelessWidget { + const _ChecklistsBody(); + + @override + Widget build(BuildContext context) { + final controller = context.watch(); + + if (controller.isLoading && controller.lists.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (controller.error != null && controller.lists.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(controller.error!, textAlign: TextAlign.center), + const SizedBox(height: 16), + FilledButton( + onPressed: controller.load, + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + + if (controller.lists.isEmpty) { + return const Center(child: Text('No checklists yet.')); + } + + Widget itemsArea; + if (controller.isLoading) { + itemsArea = const Center(child: CircularProgressIndicator()); + } else if (controller.error != null) { + itemsArea = Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(controller.error!, textAlign: TextAlign.center), + const SizedBox(height: 16), + FilledButton( + onPressed: controller.load, + child: const Text('Retry'), + ), + ], + ), + ), + ); + } else { + itemsArea = RefreshIndicator( + onRefresh: controller.refresh, + child: _ItemList(controller: controller), + ); + } + + return Column( + children: [ + _ListSelector( + lists: controller.lists, + currentList: controller.currentList, + onSelected: controller.selectList, + ), + Expanded(child: itemsArea), + ], + ); + } +} + +class _ListSelector extends StatelessWidget { + final List lists; + final ChecklistList? currentList; + final ValueChanged onSelected; + + const _ListSelector({ + required this.lists, + required this.currentList, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: DropdownButtonFormField( + initialValue: currentList?.id, + decoration: const InputDecoration( + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + isDense: true, + ), + items: lists + .map( + (list) => DropdownMenuItem( + value: list.id, + child: Row( + children: [ + Icon(checklistIcon(list.icon), size: 20), + const SizedBox(width: 8), + Flexible( + child: Text(list.name, overflow: TextOverflow.ellipsis), + ), + ], + ), + ), + ) + .toList(), + selectedItemBuilder: (context) => lists + .map( + (list) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(checklistIcon(list.icon), size: 20), + const SizedBox(width: 8), + Text(list.name, overflow: TextOverflow.ellipsis), + ], + ), + ) + .toList(), + onChanged: (id) { + if (id == null) return; + final list = lists.firstWhere((l) => l.id == id); + onSelected(list); + }, + ), + ); + } +} + +class _ItemList extends StatelessWidget { + final ChecklistsController controller; + + const _ItemList({required this.controller}); + + @override + Widget build(BuildContext context) { + final unchecked = controller.items.where((i) => !i.done).toList(); + final checked = controller.items.where((i) => i.done).toList(); + + if (controller.items.isEmpty) { + return ListView( + children: const [ + SizedBox(height: 100), + Center(child: Text('No items in this list.')), + ], + ); + } + + return ListView( + children: [ + for (final item in unchecked) + ChecklistItemTile( + key: ValueKey(item.id), + item: item, + category: item.categoryId != null + ? controller.categories[item.categoryId] + : null, + houseId: controller.houseId, + onToggle: controller.toggleItem, + ), + if (checked.isNotEmpty) ...[ + const Divider(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Text( + 'Completed (${checked.length})', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + for (final item in checked) + ChecklistItemTile( + key: ValueKey(item.id), + item: item, + category: item.categoryId != null + ? controller.categories[item.categoryId] + : null, + houseId: controller.houseId, + onToggle: controller.toggleItem, + ), + ], + ], + ); + } +} diff --git a/lib/views/home/home_view.dart b/lib/views/home/home_view.dart index fc5e93e..a39d6fa 100644 --- a/lib/views/home/home_view.dart +++ b/lib/views/home/home_view.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import 'package:pantry/models/house.dart'; import 'package:pantry/services/auth_service.dart'; +import 'package:pantry/views/checklists/checklists_view.dart'; import 'home_controller.dart'; class HomeView extends StatefulWidget { @@ -112,10 +113,13 @@ class _HomeViewBodyState extends State<_HomeViewBody> { ); } - // TODO: Replace with actual tab content + final houseId = controller.currentHouse!.id; switch (_tabIndex) { case 0: - return const Center(child: Text('Checklists')); + return ChecklistsView( + key: ValueKey('checklists-$houseId'), + houseId: houseId, + ); case 1: return const Center(child: Text('Photo Board')); case 2: diff --git a/lib/widgets/image_preview.dart b/lib/widgets/image_preview.dart new file mode 100644 index 0000000..f9eb6e4 --- /dev/null +++ b/lib/widgets/image_preview.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; + +class ImagePreview extends StatelessWidget { + final String imageUrl; + final Map headers; + final String heroTag; + + const ImagePreview({ + super.key, + required this.imageUrl, + required this.heroTag, + this.headers = const {}, + }); + + static void show( + BuildContext context, { + required String imageUrl, + required String heroTag, + Map headers = const {}, + }) { + Navigator.of(context).push( + PageRouteBuilder( + opaque: false, + barrierColor: Colors.black87, + barrierDismissible: true, + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 300), + pageBuilder: (context, _, _) => ImagePreview( + imageUrl: imageUrl, + heroTag: heroTag, + headers: headers, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + elevation: 0, + ), + body: InteractiveViewer( + minScale: 0.5, + maxScale: 4.0, + clipBehavior: Clip.none, + child: Center( + child: Hero( + tag: heroTag, + child: Image.network( + imageUrl, + headers: headers, + fit: BoxFit.contain, + errorBuilder: (_, _, _) => const Icon( + Icons.broken_image_outlined, + size: 64, + color: Colors.white54, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 732b3aa..bb71bb5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -361,7 +361,7 @@ packages: source: hosted version: "1.1.0" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" diff --git a/pubspec.yaml b/pubspec.yaml index 4b80689..e50c69b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: flutter_secure_storage: ^10.0.0 flutter_svg: ^2.2.4 wakelock_plus: ^1.5.1 + path_provider: ^2.1.5 dev_dependencies: flutter_test: