diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index 8cd29d61..ce14668d 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -108,7 +108,7 @@ class AppDatabase { Future open() async { _database = await openDatabase(_internalPath!, - version: 4, onCreate: _createDatabase, onUpgrade: _onUpgrade); + version: 5, onCreate: _createDatabase, onUpgrade: _onUpgrade); await EntriesProvider.instance.load(); await EntryImagesProvider.instance.load(); @@ -331,6 +331,7 @@ CREATE TABLE $imagesTable ( '''); await _createTagTables(db); + await _createTemplateTagTable(db); await TagsProvider.instance.createDefaultTags(); await _createWelcomeEntry(); @@ -446,6 +447,19 @@ CREATE TABLE $entryTagsTable ( '''); } + Future _createTemplateTagTable(Database db) async { + await db.execute(''' +CREATE TABLE $templateTagsTable ( + ${TemplateTagFields.id} INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + ${TemplateTagFields.templateId} INTEGER NOT NULL, + ${TemplateTagFields.tagId} INTEGER NOT NULL, + ${TemplateTagFields.timeCreate} DATETIME NOT NULL DEFAULT (DATETIME('now')), + FOREIGN KEY (${TemplateTagFields.templateId}) REFERENCES $templatesTable (id), + FOREIGN KEY (${TemplateTagFields.tagId}) REFERENCES $tagsTable (id) +) +'''); + } + void _onUpgrade(Database db, int oldVersion, int newVersion) async { _database = db; // In this case, oldVersion is 1, newVersion is 2 @@ -515,5 +529,8 @@ DROP TABLE old_entries; await _createTagTables(db); await TagsProvider.instance.createDefaultTags(); } + if (oldVersion <= 4) { + await _createTemplateTagTable(db); + } } } diff --git a/lib/database/template_tag_dao.dart b/lib/database/template_tag_dao.dart new file mode 100644 index 00000000..bcbefbbd --- /dev/null +++ b/lib/database/template_tag_dao.dart @@ -0,0 +1,43 @@ +import 'package:daily_you/database/app_database.dart'; +import 'package:daily_you/models/tag.dart'; + +class TemplateTagDao { + static Future> getAll() async { + final db = AppDatabase.instance.database!; + final result = await db.query(templateTagsTable); + return result.map((json) => TemplateTag.fromJson(json)).toList(); + } + + static Future add(TemplateTag templateTag) async { + final db = AppDatabase.instance.database!; + final id = await db.insert(templateTagsTable, templateTag.toJson()); + return templateTag.copy(id: id); + } + + static Future remove(int id) async { + final db = AppDatabase.instance.database!; + await db.delete( + templateTagsTable, + where: '${TemplateTagFields.id} = ?', + whereArgs: [id], + ); + } + + static Future removeAllForTemplate(int templateId) async { + final db = AppDatabase.instance.database!; + await db.delete( + templateTagsTable, + where: '${TemplateTagFields.templateId} = ?', + whereArgs: [templateId], + ); + } + + static Future removeAllForTag(int tagId) async { + final db = AppDatabase.instance.database!; + await db.delete( + templateTagsTable, + where: '${TemplateTagFields.tagId} = ?', + whereArgs: [tagId], + ); + } +} diff --git a/lib/models/tag.dart b/lib/models/tag.dart index c55b91ee..ad853e5d 100644 --- a/lib/models/tag.dart +++ b/lib/models/tag.dart @@ -2,6 +2,7 @@ import 'package:daily_you/models/tag_icon_type.dart'; const String tagsTable = 'tags'; const String entryTagsTable = 'entry_tags'; +const String templateTagsTable = 'template_tags'; enum TagType { label, @@ -169,3 +170,53 @@ class EntryTag { EntryTagFields.timeCreate: timeCreate.toIso8601String(), }; } + +class TemplateTagFields { + static const List values = [id, templateId, tagId, timeCreate]; + static const String id = 'id'; + static const String templateId = 'template_id'; + static const String tagId = 'tag_id'; + static const String timeCreate = 'time_create'; +} + +class TemplateTag { + final int? id; + final int templateId; + final int tagId; + final DateTime timeCreate; + + const TemplateTag({ + this.id, + required this.templateId, + required this.tagId, + required this.timeCreate, + }); + + TemplateTag copy({ + int? id, + int? templateId, + int? tagId, + DateTime? timeCreate, + }) => + TemplateTag( + id: id ?? this.id, + templateId: templateId ?? this.templateId, + tagId: tagId ?? this.tagId, + timeCreate: timeCreate ?? this.timeCreate, + ); + + static TemplateTag fromJson(Map json) => TemplateTag( + id: json[TemplateTagFields.id] as int?, + templateId: json[TemplateTagFields.templateId] as int, + tagId: json[TemplateTagFields.tagId] as int, + timeCreate: + DateTime.parse(json[TemplateTagFields.timeCreate] as String), + ); + + Map toJson() => { + TemplateTagFields.id: id, + TemplateTagFields.templateId: templateId, + TemplateTagFields.tagId: tagId, + TemplateTagFields.timeCreate: timeCreate.toIso8601String(), + }; +} diff --git a/lib/pages/edit_entry_page.dart b/lib/pages/edit_entry_page.dart index 6e8f0a03..bfa55656 100644 --- a/lib/pages/edit_entry_page.dart +++ b/lib/pages/edit_entry_page.dart @@ -7,13 +7,15 @@ import 'package:daily_you/notification_manager.dart'; import 'package:daily_you/models/tag.dart'; import 'package:daily_you/providers/entries_provider.dart'; import 'package:daily_you/providers/entry_images_provider.dart'; +import 'package:daily_you/models/template.dart'; import 'package:daily_you/providers/tags_provider.dart'; import 'package:daily_you/providers/templates_provider.dart'; -import 'package:daily_you/widgets/entry_tag_chips.dart'; +import 'package:daily_you/widgets/tag_attachment_source.dart'; +import 'package:daily_you/widgets/tag_grouped_chip_list.dart'; import 'package:daily_you/widgets/tag_picker_dialog.dart'; import 'package:daily_you/widgets/tag_chip.dart'; -import 'package:daily_you/widgets/tracker_value_dialog.dart'; import 'package:daily_you/time_manager.dart'; +import 'package:provider/provider.dart'; import 'package:daily_you/pages/full_screen_text_editor_page.dart'; import 'package:daily_you/widgets/edit_toolbar.dart'; import 'package:daily_you/widgets/entry_image_editable_list.dart'; @@ -64,6 +66,9 @@ class _AddEditEntryPageState extends State bool _newEntry = false; bool _creatingNewEntry = false; Timer? _debounceTimer; + late TagAttachmentSource _tagSource; + // Tag ids seeded from the default template, or already on the entry + late Set _seededTagIds; Future _initEntry() async { if (widget.entry == null) { @@ -73,9 +78,15 @@ class _AddEditEntryPageState extends State : (widget.overrideCreateDate ?? DateTime.now()); var text = ""; final defaultTemplate = TemplatesProvider.instance.getDefaultTemplate(); + final defaultTagIds = []; if (defaultTemplate != null) { text = defaultTemplate.text ?? ""; + defaultTagIds.addAll(TagsProvider.instance + .getTemplateTagsForTemplate(defaultTemplate.id!) + .map((templateTag) => templateTag.tagId)); } + _tagSource = TagAttachmentSource( + supportsValues: true, initialTagIds: defaultTagIds); _entry = Entry( text: text, mood: null, @@ -88,7 +99,11 @@ class _AddEditEntryPageState extends State } else { _entry = widget.entry!; id = _entry.id ?? -1; + _tagSource = TagAttachmentSource.fromEntryTags( + TagsProvider.instance.getEntryTagsForEntry(id)); } + _seededTagIds = _tagSource.attachedTagIds.toSet(); + _tagSource.addListener(_onTagsChanged); _lastMood = _entry.mood; mood = _entry.mood; _lastEntryDate = _entry.timeCreate; @@ -126,6 +141,8 @@ class _AddEditEntryPageState extends State _textEditingController.dispose(); _undoController.dispose(); _debounceTimer?.cancel(); + _tagSource.removeListener(_onTagsChanged); + _tagSource.dispose(); super.dispose(); } @@ -261,6 +278,7 @@ class _AddEditEntryPageState extends State controller: _textEditingController, undoController: _undoController, focusNode: _focusNode, + onTemplateInserted: _applyInsertedTemplateTags, trailer: _buildTagsButton(context, theme), ), ), @@ -306,28 +324,40 @@ class _AddEditEntryPageState extends State "save-entry", Duration(seconds: 5), () => _saveEntry()); }), ), - EntryTagChips( - entryId: id, - padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8), - chipBuilder: (tag, entryTag) => TagChip( + _buildTagChips(), + ], + ), + ); + } + + Widget _buildTagChips() { + const padding = EdgeInsets.only(left: 8, right: 8, bottom: 8); + return ListenableBuilder( + listenable: _tagSource, + builder: (context, _) { + final attachedIds = _tagSource.attachedTagIds; + if (attachedIds.isEmpty) return const SizedBox.shrink(); + final tagsProvider = Provider.of(context); + final attachedSet = attachedIds.toSet(); + final tagPool = tagsProvider.tags + .where((tag) => attachedSet.contains(tag.id)) + .toList(); + final sections = tagsProvider.buildSections('', tagPool: tagPool); + return Padding( + padding: padding, + child: TagGroupedChipList( + sections: sections, + chipBuilder: (tag) => TagChip( tag: tag, - value: entryTag.value, + value: _tagSource.valueFor(tag.id!), onTap: tag.tagType == TagType.tracker - ? () async { - final newValue = await showTrackerValueDialog( - context, tag, - initialValue: entryTag.value); - if (newValue != null) { - await TagsProvider.instance - .updateEntryTag(entryTag.copy(value: newValue)); - } - } + ? () => _tagSource.editValue(context, tag) : null, - onRemove: () => TagsProvider.instance.removeEntryTag(entryTag), + onRemove: () => _tagSource.detach(tag.id!), ), - ) - ], - ), + ), + ); + }, ); } @@ -396,17 +426,11 @@ class _AddEditEntryPageState extends State Widget _buildTagsButton(BuildContext context, ThemeData theme) { return IconButton( onPressed: () async { - if (id == -1) { - await _saveEntry(forceCreate: true); - } - if (id == -1) return; - if (context.mounted) { - await showDialog( - context: context, - builder: (_) => - TagPickerDialog(mode: TagPickerMode.addToEntry, entryId: id), - ); - } + await showDialog( + context: context, + builder: (_) => + TagPickerDialog(mode: TagPickerMode.attach, source: _tagSource), + ); }, icon: Icon( Icons.local_offer_rounded, @@ -498,7 +522,7 @@ class _AddEditEntryPageState extends State await _saveEntry(); } - Future _saveEntry({bool forceCreate = false}) async { + Future _saveEntry() async { // Saving is guarded since quickly entering and exiting the app could trigger // multiple async saves. if (_savingEntry == false) { @@ -515,11 +539,13 @@ class _AddEditEntryPageState extends State final hasMoodChange = updatedEntry.mood != _lastMood; final hasDateChange = updatedEntry.timeCreate != _lastEntryDate; + final hasTagChange = _tagsExceedSeed(); + if (_newEntry) { - if (forceCreate || - hasTextChange || + if (hasTextChange || hasMoodChange || hasDateChange || + hasTagChange || _currentImages.isNotEmpty) { if (Platform.isAndroid && TimeManager.isSameDay(DateTime.now(), updatedEntry.timeCreate)) { @@ -531,6 +557,8 @@ class _AddEditEntryPageState extends State _lastText = _entry.text; _lastMood = _entry.mood; _lastEntryDate = _entry.timeCreate; + await TagsProvider.instance + .setEntryTags(id, _tagSource.toEntryTags(id)); } } else { if (hasTextChange || hasMoodChange || hasDateChange) { @@ -543,6 +571,8 @@ class _AddEditEntryPageState extends State _lastEntryDate = updatedEntry.timeCreate; await EntriesProvider.instance.update(updatedEntry); } + await TagsProvider.instance + .setEntryTags(id, _tagSource.toEntryTags(id)); } // Images will update if they changed await _saveOrUpdateImage(id); @@ -595,6 +625,30 @@ class _AddEditEntryPageState extends State } } + void _onTagsChanged() { + EasyDebounce.debounce( + "save-entry", const Duration(seconds: 5), () => _saveEntry()); + } + + // Whether the working set holds anything beyond the seeded default tags + bool _tagsExceedSeed() { + for (final tagId in _tagSource.attachedTagIds) { + if (!_seededTagIds.contains(tagId)) return true; + if (_tagSource.valueFor(tagId) != null) return true; + } + return false; + } + + void _applyInsertedTemplateTags(Template template) { + if (template.id == null) return; + final templateTagIds = TagsProvider.instance + .getTemplateTagsForTemplate(template.id!) + .map((templateTag) => templateTag.tagId); + for (final tagId in templateTagIds) { + _tagSource.addTagId(tagId); + } + } + Future _addImage(List imgPaths) async { for (var imgPath in imgPaths) { // Add image to the end by giving it the lowest rank diff --git a/lib/providers/tags_provider.dart b/lib/providers/tags_provider.dart index 9d64f15c..8fc50b11 100644 --- a/lib/providers/tags_provider.dart +++ b/lib/providers/tags_provider.dart @@ -4,6 +4,7 @@ import 'package:daily_you/database/app_database.dart'; import 'package:daily_you/database/entry_tag_dao.dart'; import 'package:daily_you/database/tag_category_dao.dart'; import 'package:daily_you/database/tag_dao.dart'; +import 'package:daily_you/database/template_tag_dao.dart'; import 'package:daily_you/l10n/generated/app_localizations.dart'; import 'package:daily_you/models/tag.dart'; import 'package:daily_you/models/tag_category.dart'; @@ -31,6 +32,11 @@ class TagsProvider with ChangeNotifier { Map> _entryTagsByEntry = const {}; + List _templateTags = List.empty(); + List get templateTags => _templateTags; + + Map> _templateTagsByTemplate = const {}; + void _setEntryTags(List updated) { _entryTags = updated; final grouped = >{}; @@ -40,10 +46,20 @@ class TagsProvider with ChangeNotifier { _entryTagsByEntry = grouped; } + void _setTemplateTags(List updated) { + _templateTags = updated; + final grouped = >{}; + for (final templateTag in updated) { + (grouped[templateTag.templateId] ??= []).add(templateTag); + } + _templateTagsByTemplate = grouped; + } + Future load() async { categories = await TagCategoryDao.getAll(); tags = await TagDao.getAll(); _setEntryTags(await EntryTagDao.getAll()); + _setTemplateTags(await TemplateTagDao.getAll()); notifyListeners(); } @@ -100,6 +116,10 @@ class TagsProvider with ChangeNotifier { await EntryTagDao.removeAllForTag(tag.id!); _setEntryTags( entryTags.where((entryTag) => entryTag.tagId != tag.id).toList()); + await TemplateTagDao.removeAllForTag(tag.id!); + _setTemplateTags(templateTags + .where((templateTag) => templateTag.tagId != tag.id) + .toList()); await TagDao.remove(tag.id!); tags = tags.where((existing) => existing.id != tag.id).toList(); await AppDatabase.instance.updateExternalDatabase(); @@ -165,6 +185,107 @@ class TagsProvider with ChangeNotifier { return _entryTagsByEntry[entryId] ?? const []; } + /// Reconciles the tags stored for [entryId] with [desired], adding, removing, + /// and updating rows so the database matches. + Future setEntryTags(int entryId, List desired) async { + final current = getEntryTagsForEntry(entryId); + final currentByTag = { + for (final entryTag in current) entryTag.tagId: entryTag + }; + final desiredByTag = { + for (final entryTag in desired) entryTag.tagId: entryTag + }; + var changed = false; + + for (final entryTag in current) { + if (!desiredByTag.containsKey(entryTag.tagId)) { + await EntryTagDao.remove(entryTag.id!); + changed = true; + } + } + + final result = []; + for (final wanted in desired) { + final existing = currentByTag[wanted.tagId]; + if (existing == null) { + result.add(await EntryTagDao.add(EntryTag( + entryId: entryId, + tagId: wanted.tagId, + value: wanted.value, + timeCreate: wanted.timeCreate, + ))); + changed = true; + } else if (existing.value != wanted.value) { + final updated = EntryTag( + id: existing.id, + entryId: entryId, + tagId: wanted.tagId, + value: wanted.value, + timeCreate: existing.timeCreate, + ); + await EntryTagDao.update(updated); + result.add(updated); + changed = true; + } else { + result.add(existing); + } + } + + if (!changed) return; + + final retained = + entryTags.where((entryTag) => entryTag.entryId != entryId).toList(); + _setEntryTags([...retained, ...result]); + await AppDatabase.instance.updateExternalDatabase(); + notifyListeners(); + } + + /// Reconciles the tags stored for [templateId] with [tagIds], adding and + /// removing rows so the database matches. + Future setTemplateTags(int templateId, List tagIds) async { + final current = getTemplateTagsForTemplate(templateId); + final currentIds = current.map((templateTag) => templateTag.tagId).toSet(); + final desiredIds = tagIds.toSet(); + + for (final templateTag in current) { + if (!desiredIds.contains(templateTag.tagId)) { + await TemplateTagDao.remove(templateTag.id!); + } + } + + final added = []; + for (final tagId in tagIds) { + if (currentIds.contains(tagId)) continue; + added.add(await TemplateTagDao.add(TemplateTag( + templateId: templateId, + tagId: tagId, + timeCreate: DateTime.now(), + ))); + } + + final retained = templateTags + .where((templateTag) => + templateTag.templateId != templateId || + desiredIds.contains(templateTag.tagId)) + .toList(); + _setTemplateTags([...retained, ...added]); + await AppDatabase.instance.updateExternalDatabase(); + notifyListeners(); + } + + Future removeAllTemplateTagsForTemplate(int templateId) async { + await TemplateTagDao.removeAllForTemplate(templateId); + _setTemplateTags(templateTags + .where((templateTag) => templateTag.templateId != templateId) + .toList()); + await AppDatabase.instance.updateExternalDatabase(); + notifyListeners(); + } + + List getTemplateTagsForTemplate(int templateId) { + return _templateTagsByTemplate[templateId] ?? const []; + } + int entryCountForTag(int tagId) { return entryTags .where((entryTag) => entryTag.tagId == tagId) @@ -216,10 +337,15 @@ class TagsProvider with ChangeNotifier { tags.where((tag) => tag.categoryId == category.id).toList(); for (final tag in affected) { await EntryTagDao.removeAllForTag(tag.id!); + await TemplateTagDao.removeAllForTag(tag.id!); } _setEntryTags(entryTags .where((entryTag) => !affected.any((tag) => tag.id == entryTag.tagId)) .toList()); + _setTemplateTags(templateTags + .where((templateTag) => + !affected.any((tag) => tag.id == templateTag.tagId)) + .toList()); for (final tag in affected) { await TagDao.remove(tag.id!); } diff --git a/lib/providers/templates_provider.dart b/lib/providers/templates_provider.dart index 39b5df3c..9db4c30f 100644 --- a/lib/providers/templates_provider.dart +++ b/lib/providers/templates_provider.dart @@ -4,6 +4,7 @@ import 'package:daily_you/l10n/generated/app_localizations.dart'; import 'package:daily_you/database/app_database.dart'; import 'package:daily_you/database/template_dao.dart'; import 'package:daily_you/models/template.dart'; +import 'package:daily_you/providers/tags_provider.dart'; import 'package:flutter/material.dart'; class TemplatesProvider with ChangeNotifier { @@ -21,15 +22,18 @@ class TemplatesProvider with ChangeNotifier { // CRUD operations - Future add(Template template) async { + Future