diff --git a/apps/enmeshed/lib/account/my_data/file/file_detail_screen.dart b/apps/enmeshed/lib/account/my_data/file/file_detail_screen.dart index 277c85e22..3a9cf6e36 100644 --- a/apps/enmeshed/lib/account/my_data/file/file_detail_screen.dart +++ b/apps/enmeshed/lib/account/my_data/file/file_detail_screen.dart @@ -3,9 +3,10 @@ import 'package:enmeshed_types/enmeshed_types.dart'; import 'package:enmeshed_ui_kit/enmeshed_ui_kit.dart'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; -import 'package:intl/intl.dart'; import '/core/core.dart'; +import 'modals/edit_file.dart'; +import 'widgets/widgets.dart'; class FileDetailScreen extends StatefulWidget { final String accountId; @@ -20,8 +21,14 @@ class FileDetailScreen extends StatefulWidget { } class _FileDetailScreenState extends State { - FileDVO? _fileDVO; + late final Session _session; + + late FileDVO _fileDVO; + + LocalAttributeDVO? _fileReferenceAttribute; List? _tags; + AttributeTagCollectionDTO? _tagCollection; + bool _isLoadingFile = false; bool _isOpeningFile = false; @@ -29,122 +36,108 @@ class _FileDetailScreenState extends State { void initState() { super.initState(); + _session = GetIt.I.get().getSession(widget.accountId); + _fileDVO = widget.preLoadedFile; + _fileReferenceAttribute = widget.fileReferenceAttribute; _tags = widget.fileReferenceAttribute?.tags; - if (_fileDVO == null) _load(); + _load(); } @override Widget build(BuildContext context) { return Scaffold( resizeToAvoidBottomInset: false, - appBar: AppBar(title: Text(_fileDVO!.title, style: Theme.of(context).textTheme.titleLarge)), + appBar: AppBar(title: Text(_fileDVO.title, style: Theme.of(context).textTheme.titleLarge)), body: SafeArea( child: Padding( - padding: const EdgeInsets.only(top: 8, left: 24, right: 24), - child: - _fileDVO == null - ? const Center(child: CircularProgressIndicator()) - : Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Column( - children: [ - FileIcon(filename: _fileDVO!.filename, color: Theme.of(context).colorScheme.primaryContainer, size: 40), - Gaps.h8, - Text(_fileDVO!.filename, style: Theme.of(context).textTheme.labelLarge), - Text('${bytesText(context: context, bytes: _fileDVO!.filesize)} - ${getFileExtension(_fileDVO!.filename)}'), - ], - ), - ), - Gaps.h24, - if (_tags != null) - Chip( - label: Text(_tags!.join(', '), style: TextStyle(color: Theme.of(context).colorScheme.onSecondaryContainer)), - shape: RoundedRectangleBorder( - borderRadius: const BorderRadius.all(Radius.circular(8)), - side: BorderSide(color: Theme.of(context).colorScheme.secondaryContainer), - ), - backgroundColor: Theme.of(context).colorScheme.secondaryContainer, - padding: EdgeInsets.zero, - labelPadding: const EdgeInsets.symmetric(horizontal: 6), - ), - Row( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${context.l10n.files_owner}: ', - style: Theme.of(context).textTheme.labelLarge!.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - Text( - '${context.l10n.files_createdAt}: ', - style: Theme.of(context).textTheme.labelLarge!.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ], - ), - Gaps.w24, - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(context.i18nTranslate(_fileDVO!.createdBy.name), style: Theme.of(context).textTheme.bodyMedium), - Text(context.i18nTranslate(_formatDate(context, _fileDVO!.createdAt)), style: Theme.of(context).textTheme.bodyMedium), - ], - ), - ], - ), - Gaps.h32, - Row( - children: [ - Gaps.w8, - IconButton( - onPressed: _isLoadingFile || DateTime.parse(_fileDVO!.expiresAt).isBefore(DateTime.now()) ? null : _downloadAndSaveFile, - icon: - _isLoadingFile - ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator()) - : const Icon(Icons.file_download, size: 24), - ), - Gaps.w8, - IconButton( - onPressed: _isOpeningFile || DateTime.parse(_fileDVO!.expiresAt).isBefore(DateTime.now()) ? null : _openFile, - icon: - _isOpeningFile - ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator()) - : const Icon(Icons.open_with, size: 24), - ), - ], - ), - ], + padding: const EdgeInsets.only(top: 16, left: 16, right: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Column( + children: [ + FileIcon(filename: _fileDVO.filename, color: Theme.of(context).colorScheme.primary, size: 40), + Gaps.h8, + Text(_fileDVO.filename, style: Theme.of(context).textTheme.labelLarge), + Text('${bytesText(context: context, bytes: _fileDVO.filesize)} - ${getFileExtension(_fileDVO.filename)}'), + ], + ), + ), + Gaps.h48, + if (_isEditable) ...[ + if (_tags == null || _tags!.isEmpty) + FileTagsContainer(onEditFile: _onEditFilePressed) + else + SelectedTagsSection(tagCollection: _tagCollection!, selectedTagsList: _tags!), + Gaps.h16, + ], + FileInfoContainer(createdBy: _fileDVO.createdBy.name, createdAt: _fileDVO.createdAt), + Gaps.h32, + Row( + spacing: 8, + children: [ + if (_isEditable) IconButton(onPressed: _onEditFilePressed, icon: const Icon(Icons.edit_outlined, size: 24)), + IconButton( + onPressed: _isLoadingFile || DateTime.parse(_fileDVO.expiresAt).isBefore(DateTime.now()) ? null : _downloadAndSaveFile, + icon: _isLoadingFile ? const _LoadingIndicator() : const Icon(Icons.file_download, size: 24), + ), + IconButton( + onPressed: _isOpeningFile || DateTime.parse(_fileDVO.expiresAt).isBefore(DateTime.now()) ? null : _openFile, + icon: _isOpeningFile ? const _LoadingIndicator() : const Icon(Icons.open_with, size: 24), ), + ], + ), + ], + ), ), ), ); } - String _formatDate(BuildContext context, String date) { - final locale = Localizations.localeOf(context); - final parsedDate = DateTime.parse(date).toLocal(); - return DateFormat.yMd(locale.languageCode).format(parsedDate); - } + bool get _isEditable => _fileReferenceAttribute != null && _tagCollection != null; Future _load() async { - final session = GetIt.I.get().getSession(widget.accountId); - final response = await session.transportServices.files.getFile(fileId: widget.preLoadedFile.id); - final expanded = await session.expander.expandFileDTO(response.value); + await _loadFile(); + await _loadTagCollection(); + await _loadTags(); + } + + Future _loadFile() async { + final response = await _session.transportServices.files.getFile(fileId: widget.preLoadedFile.id); + final expanded = await _session.expander.expandFileDTO(response.value); setState(() => _fileDVO = expanded); } + Future _loadTagCollection() async { + final tagCollectionResult = await _session.consumptionServices.attributes.getAttributeTagCollection(); + + if (tagCollectionResult.isError) return; + setState(() => _tagCollection = tagCollectionResult.value); + } + + Future _loadTags({String? attributeId}) async { + if (_fileReferenceAttribute == null) return; + + final response = await _session.consumptionServices.attributes.getAttribute(attributeId: attributeId ?? _fileReferenceAttribute!.id); + final expanded = await _session.expander.expandLocalAttributeDTO(response.value); + + setState(() { + _tags = expanded.tags; + _fileReferenceAttribute = expanded; + }); + } + Future _downloadAndSaveFile() async { setState(() => _isLoadingFile = true); final session = GetIt.I.get().getSession(widget.accountId); await moveFileOnDevice( session: session, - fileDVO: _fileDVO!, + fileDVO: _fileDVO, onError: () { if (mounted) showDownloadFileErrorDialog(context); }, @@ -159,7 +152,7 @@ class _FileDetailScreenState extends State { final session = GetIt.I.get().getSession(widget.accountId); await openFile( session: session, - fileDVO: _fileDVO!, + fileDVO: _fileDVO, onError: () { if (mounted) showDownloadFileErrorDialog(context); }, @@ -167,4 +160,29 @@ class _FileDetailScreenState extends State { if (mounted) setState(() => _isOpeningFile = false); } + + void _onEditFilePressed() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: + (_) => ConstrainedBox( + constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height * 0.9), + child: EditFile( + accountId: widget.accountId, + fileTitle: _fileDVO.title, + fileReferenceAttribute: _fileReferenceAttribute!, + tagCollection: _tagCollection!, + onSave: _loadTags, + ), + ), + ); + } +} + +class _LoadingIndicator extends StatelessWidget { + const _LoadingIndicator(); + + @override + Widget build(BuildContext context) => const SizedBox(width: 24, height: 24, child: CircularProgressIndicator()); } diff --git a/apps/enmeshed/lib/account/my_data/file/files_screen.dart b/apps/enmeshed/lib/account/my_data/file/files_screen.dart index b36b81808..ae0992981 100644 --- a/apps/enmeshed/lib/account/my_data/file/files_screen.dart +++ b/apps/enmeshed/lib/account/my_data/file/files_screen.dart @@ -125,8 +125,12 @@ class _FilesScreenState extends State { onRefresh: () => _loadFiles(syncBefore: true), child: ListView.separated( itemBuilder: - (context, index) => - FileItem(accountId: widget.accountId, fileRecord: _filteredFileRecords[index], trailing: const Icon(Icons.chevron_right)), + (context, index) => FileItem( + accountId: widget.accountId, + fileRecord: _filteredFileRecords[index], + trailing: const Icon(Icons.chevron_right), + reload: _loadFiles, + ), itemCount: _filteredFileRecords.length, separatorBuilder: (context, index) => const Divider(height: 2, indent: 16), ), @@ -251,13 +255,14 @@ class _FilesScreenState extends State { fileRecord: item, query: keyword, accountId: widget.accountId, - onTap: () { + onTap: () async { controller ..clear() ..closeView(null); FocusScope.of(context).unfocus(); - context.push('/account/${widget.accountId}/my-data/files/${item.file.id}', extra: item); + await context.push('/account/${widget.accountId}/my-data/files/${item.file.id}', extra: item); + unawaited(_loadFiles()); }, ), ); diff --git a/apps/enmeshed/lib/account/my_data/file/modals/edit_file.dart b/apps/enmeshed/lib/account/my_data/file/modals/edit_file.dart new file mode 100644 index 000000000..d773fc4a3 --- /dev/null +++ b/apps/enmeshed/lib/account/my_data/file/modals/edit_file.dart @@ -0,0 +1,158 @@ +import 'package:enmeshed_runtime_bridge/enmeshed_runtime_bridge.dart'; +import 'package:enmeshed_types/enmeshed_types.dart'; +import 'package:enmeshed_ui_kit/enmeshed_ui_kit.dart'; +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; + +import '/core/core.dart'; +import '../widgets/widgets.dart'; + +class EditFile extends StatefulWidget { + final String accountId; + final String fileTitle; + final LocalAttributeDVO fileReferenceAttribute; + final void Function({String? attributeId}) onSave; + final AttributeTagCollectionDTO tagCollection; + + const EditFile({ + required this.accountId, + required this.fileTitle, + required this.fileReferenceAttribute, + required this.onSave, + required this.tagCollection, + super.key, + }); + + @override + State createState() => _EditFileState(); +} + +class _EditFileState extends State { + late final TextEditingController _titleController; + bool _loading = false; + + List _selectedTags = []; + + @override + void initState() { + super.initState(); + + _titleController = TextEditingController(text: widget.fileTitle)..addListener(() => setState(() {})); + if (widget.fileReferenceAttribute.tags != null && widget.fileReferenceAttribute.tags!.isNotEmpty) { + _selectedTags = widget.fileReferenceAttribute.tags!; + } + } + + @override + void dispose() { + super.dispose(); + + _titleController.dispose(); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Padding( + padding: EdgeInsets.only(top: 16, left: 24, right: 24, bottom: MediaQuery.viewInsetsOf(context).bottom + 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(context.l10n.files_editFile, style: Theme.of(context).textTheme.titleLarge), + Gaps.h24, + Text(context.l10n.mandatoryField), + Gaps.h24, + TextField( + controller: _titleController, + readOnly: true, + decoration: InputDecoration( + suffixIcon: const IconButton(onPressed: null, icon: Icon(Icons.cancel_outlined)), + labelText: context.l10n.title, + border: OutlineInputBorder( + borderRadius: const BorderRadius.all(Radius.circular(8)), + borderSide: BorderSide(color: Theme.of(context).colorScheme.outline), + ), + focusedBorder: OutlineInputBorder( + borderRadius: const BorderRadius.all(Radius.circular(8)), + borderSide: BorderSide(color: Theme.of(context).colorScheme.primary), + ), + ), + ), + Gaps.h24, + Text(context.l10n.files_assignTags, style: Theme.of(context).textTheme.titleMedium), + if (_isMultiStageTag) ...[ + if (_selectedTags.isEmpty) Gaps.h8 else Gaps.h24, + MultiStageTagsSection(tagCollection: widget.tagCollection, selectedTags: _selectedTags, handleTagSelection: _handleTagSelection), + const Spacer(), + ] else ...[ + Gaps.h24, + AvailableTagsSection(tagCollection: widget.tagCollection, selectedTags: _selectedTags, onTagSelected: _handleTagSelection), + ], + Gaps.h40, + Row( + mainAxisAlignment: MainAxisAlignment.end, + spacing: 8, + children: [ + OutlinedButton(onPressed: () => Navigator.of(context).pop(), child: Text(context.l10n.cancel)), + FilledButton(onPressed: _confirmEnabled ? _confirm : null, child: Text(context.l10n.save)), + ], + ), + ], + ), + ), + if (_loading) ModalLoadingOverlay(text: context.l10n.files_saving, isDialog: false), + ], + ); + } + + bool get _confirmEnabled => _titleController.text.isNotEmpty; + + bool get _isMultiStageTag => widget.tagCollection.tagsForAttributeValueTypes.values.any( + (tags) => tags.values.any((tag) => tag.children != null && tag.children!.isNotEmpty), + ); + + Future _confirm() async { + if (_confirmEnabled) setState(() => _loading = true); + + final session = GetIt.I.get().getSession(widget.accountId); + + final succeedAttributeResult = await session.consumptionServices.attributes.succeedRepositoryAttribute( + predecessorId: widget.fileReferenceAttribute.id, + value: (widget.fileReferenceAttribute as RepositoryAttributeDVO).value as IdentityFileReferenceAttributeValue, + tags: _selectedTags, + ); + + if (succeedAttributeResult.isError) { + if (!mounted) return; + + await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(context.l10n.error, style: Theme.of(context).textTheme.titleLarge), + content: Text(context.l10n.error_succeedAttribute), + ); + }, + ); + + setState(() => _loading = false); + return; + } + + widget.onSave(attributeId: succeedAttributeResult.value.successor.id); + if (mounted) context.pop(); + } + + void _handleTagSelection({required String tagPath, required bool selected}) { + setState(() { + if (selected) { + _selectedTags = [..._selectedTags, tagPath]; + } else { + _selectedTags = _selectedTags.where((tag) => tag != tagPath).toList(); + } + }); + } +} diff --git a/apps/enmeshed/lib/account/my_data/file/utils/get_tag_label.dart b/apps/enmeshed/lib/account/my_data/file/utils/get_tag_label.dart new file mode 100644 index 000000000..9aca04755 --- /dev/null +++ b/apps/enmeshed/lib/account/my_data/file/utils/get_tag_label.dart @@ -0,0 +1,14 @@ +import 'package:enmeshed_types/enmeshed_types.dart'; +import 'package:flutter/material.dart'; + +String getTagLabel(BuildContext context, AttributeTagCollectionDTO? tagCollection, AttributeTagDTO tagData) { + final displayNames = tagData.displayNames; + final currentLocale = Localizations.localeOf(context).languageCode; + + if (tagCollection != null && tagCollection.supportedLanguages.contains(currentLocale) && displayNames.containsKey(currentLocale)) { + return displayNames[currentLocale]!; + } + + // Fallback to English + return displayNames['en']!; +} diff --git a/apps/enmeshed/lib/account/my_data/file/widgets/available_tags_section.dart b/apps/enmeshed/lib/account/my_data/file/widgets/available_tags_section.dart new file mode 100644 index 000000000..0ed328c09 --- /dev/null +++ b/apps/enmeshed/lib/account/my_data/file/widgets/available_tags_section.dart @@ -0,0 +1,31 @@ +import 'package:enmeshed_types/enmeshed_types.dart'; +import 'package:flutter/material.dart'; + +import '../utils/get_tag_label.dart'; + +class AvailableTagsSection extends StatelessWidget { + final AttributeTagCollectionDTO tagCollection; + final List selectedTags; + final void Function({required String tagPath, required bool selected}) onTagSelected; + + const AvailableTagsSection({required this.tagCollection, required this.selectedTags, required this.onTagSelected, super.key}); + + @override + Widget build(BuildContext context) { + final availableTags = tagCollection.tagsForAttributeValueTypes['IdentityFileReference'] ?? {}; + if (availableTags.isEmpty) return const SizedBox.shrink(); + + return Wrap( + spacing: 10, + children: + availableTags.entries.map((entry) { + return ChoiceChip( + label: Text(getTagLabel(context, tagCollection, entry.value)), + selected: selectedTags.contains(entry.key), + showCheckmark: false, + onSelected: (selected) => onTagSelected(tagPath: entry.key, selected: selected), + ); + }).toList(), + ); + } +} diff --git a/apps/enmeshed/lib/account/my_data/file/widgets/file_info_container.dart b/apps/enmeshed/lib/account/my_data/file/widgets/file_info_container.dart new file mode 100644 index 000000000..20bac4ccf --- /dev/null +++ b/apps/enmeshed/lib/account/my_data/file/widgets/file_info_container.dart @@ -0,0 +1,51 @@ +import 'package:enmeshed_ui_kit/enmeshed_ui_kit.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '/core/core.dart'; + +class FileInfoContainer extends StatelessWidget { + final String createdBy; + final String createdAt; + + const FileInfoContainer({required this.createdBy, required this.createdAt, super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), + decoration: BoxDecoration(color: Theme.of(context).colorScheme.primaryContainer, borderRadius: BorderRadius.circular(4)), + child: Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${context.l10n.files_owner}: ', + style: Theme.of(context).textTheme.labelLarge!.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + Text( + '${context.l10n.files_createdAt}: ', + style: Theme.of(context).textTheme.labelLarge!.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ], + ), + Gaps.w24, + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.i18nTranslate(createdBy), style: Theme.of(context).textTheme.bodyMedium), + Text(context.i18nTranslate(_formatDate(context, createdAt)), style: Theme.of(context).textTheme.bodyMedium), + ], + ), + ], + ), + ); + } + + String _formatDate(BuildContext context, String date) { + final locale = Localizations.localeOf(context); + final parsedDate = DateTime.parse(date).toLocal(); + return DateFormat('EEEE, d. MMMM y', locale.toString()).format(parsedDate); + } +} diff --git a/apps/enmeshed/lib/account/my_data/file/widgets/file_tags_container.dart b/apps/enmeshed/lib/account/my_data/file/widgets/file_tags_container.dart new file mode 100644 index 000000000..b09b5fe84 --- /dev/null +++ b/apps/enmeshed/lib/account/my_data/file/widgets/file_tags_container.dart @@ -0,0 +1,27 @@ +import 'package:enmeshed_ui_kit/enmeshed_ui_kit.dart'; +import 'package:flutter/material.dart'; + +import '/core/utils/extensions.dart'; + +class FileTagsContainer extends StatelessWidget { + final VoidCallback onEditFile; + + const FileTagsContainer({required this.onEditFile, super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.only(top: 16, bottom: 24, left: 16, right: 16), + color: Theme.of(context).colorScheme.surfaceContainer, + child: Column( + children: [ + Row(children: [Icon(Icons.warning_rounded, color: context.customColors.warning), Gaps.w8, Text(context.l10n.files_assignTagsTitle)]), + Gaps.h16, + Text(context.l10n.files_assignTagsDescription, style: Theme.of(context).textTheme.bodySmall), + Gaps.h24, + OutlinedButton(onPressed: onEditFile, child: Text(context.l10n.files_assignTagsButton)), + ], + ), + ); + } +} diff --git a/apps/enmeshed/lib/account/my_data/file/widgets/multi_stage_tags_section.dart b/apps/enmeshed/lib/account/my_data/file/widgets/multi_stage_tags_section.dart new file mode 100644 index 000000000..1cf1e8ddb --- /dev/null +++ b/apps/enmeshed/lib/account/my_data/file/widgets/multi_stage_tags_section.dart @@ -0,0 +1,96 @@ +import 'package:enmeshed_types/enmeshed_types.dart'; +import 'package:enmeshed_ui_kit/enmeshed_ui_kit.dart'; +import 'package:flutter/material.dart'; + +import '../utils/get_tag_label.dart'; +import 'widgets.dart'; + +class MultiStageTagsSection extends StatefulWidget { + final AttributeTagCollectionDTO tagCollection; + final List selectedTags; + final void Function({required String tagPath, required bool selected}) handleTagSelection; + + const MultiStageTagsSection({required this.tagCollection, required this.selectedTags, required this.handleTagSelection, super.key}); + + @override + State createState() => _MultiStageTagsSectionState(); +} + +class _MultiStageTagsSectionState extends State { + String _tagString = ''; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.selectedTags.isNotEmpty) + SelectedTagsSection(tagCollection: widget.tagCollection, selectedTagsList: widget.selectedTags, onTagDeleted: widget.handleTagSelection), + + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () { + _tagString = ''; + + final button = context.findRenderObject()! as RenderBox; + final position = button.localToGlobal(Offset.zero); + final overlay = Overlay.of(context).context.findRenderObject()! as RenderBox; + final relativePosition = position - overlay.localToGlobal(Offset.zero); + + final availableTags = widget.tagCollection.tagsForAttributeValueTypes['IdentityFileReference'] ?? {}; + _showTagMenu(context, availableTags, relativePosition, button.size.height); + }, + child: Padding( + padding: const EdgeInsets.all(4), + child: Container( + height: 40, + width: 40, + decoration: BoxDecoration(color: Theme.of(context).colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20)), + child: Icon(Icons.add, color: Theme.of(context).colorScheme.onSurface), + ), + ), + ), + ), + ], + ); + } + + void _showTagMenu(BuildContext context, Map tags, Offset position, double buttonHeight, {bool isFirstLevel = true}) { + final overlay = Overlay.of(context).context.findRenderObject()! as RenderBox; + + showMenu( + context: context, + position: RelativeRect.fromRect((position + Offset(0, buttonHeight)) & Size.zero, Offset.zero & overlay.size), + useRootNavigator: true, + items: + tags.entries.map((entry) { + final hasChildren = entry.value.children != null && entry.value.children!.isNotEmpty; + + return PopupMenuItem( + onTap: () { + if (hasChildren) { + _tagString = isFirstLevel ? entry.key : _tagString = '$_tagString+%+${entry.key}'; + + Future.delayed(const Duration(milliseconds: 100), () { + if (context.mounted) _showTagMenu(context, entry.value.children!, position, buttonHeight, isFirstLevel: false); + }); + } else { + _tagString = '$_tagString+%+${entry.key}'; + if (!widget.selectedTags.any((tag) => tag == _tagString)) widget.handleTagSelection(tagPath: _tagString, selected: true); + } + }, + child: Row( + children: [ + Expanded(child: Text(getTagLabel(context, widget.tagCollection, entry.value), style: Theme.of(context).textTheme.bodyMedium)), + Gaps.w8, + if (hasChildren) const Icon(Icons.chevron_right), + if (!hasChildren && widget.selectedTags.any((tag) => tag == '$_tagString+%+${entry.key}')) const Icon(Icons.check), + ], + ), + ); + }).toList(), + ); + } +} diff --git a/apps/enmeshed/lib/account/my_data/file/widgets/selected_tags_section.dart b/apps/enmeshed/lib/account/my_data/file/widgets/selected_tags_section.dart new file mode 100644 index 000000000..a724b88e8 --- /dev/null +++ b/apps/enmeshed/lib/account/my_data/file/widgets/selected_tags_section.dart @@ -0,0 +1,52 @@ +import 'package:enmeshed_types/enmeshed_types.dart'; +import 'package:flutter/material.dart'; + +import '../utils/get_tag_label.dart'; + +class SelectedTagsSection extends StatelessWidget { + final AttributeTagCollectionDTO tagCollection; + final List selectedTagsList; + final void Function({required String tagPath, required bool selected})? onTagDeleted; + + const SelectedTagsSection({required this.tagCollection, required this.selectedTagsList, this.onTagDeleted, super.key}); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 10, + children: + selectedTagsList.map((tagPath) { + final tag = _getTagByPath(context, tagCollection, tagPath); + + if (tag == null) return const SizedBox.shrink(); + + return Chip( + label: Text( + getTagLabel(context, tagCollection, tag), + style: + onTagDeleted == null + ? Theme.of(context).textTheme.labelSmall?.copyWith(color: Theme.of(context).colorScheme.onSecondaryContainer) + : Theme.of(context).textTheme.labelLarge?.copyWith(color: Theme.of(context).colorScheme.onSecondaryContainer), + ), + deleteIcon: onTagDeleted == null ? null : const Icon(Icons.close), + onDeleted: onTagDeleted == null ? null : () => onTagDeleted!(tagPath: tagPath, selected: false), + side: BorderSide.none, + backgroundColor: Theme.of(context).colorScheme.secondaryContainer, + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + ); + }).toList(), + ); + } + + AttributeTagDTO? _getTagByPath(BuildContext context, AttributeTagCollectionDTO tagCollection, String tagPath) { + if (tagPath.isEmpty) return null; + + final availableTags = tagCollection.tagsForAttributeValueTypes['IdentityFileReference'] ?? {}; + if (!tagPath.contains('+%+')) return availableTags[tagPath]; + + return tagPath.split('+%+').fold(null, (currentTag, part) { + if (currentTag == null) return availableTags[part]; + return currentTag.children?[part]; + }); + } +} diff --git a/apps/enmeshed/lib/account/my_data/file/widgets/widgets.dart b/apps/enmeshed/lib/account/my_data/file/widgets/widgets.dart new file mode 100644 index 000000000..1718cb1b1 --- /dev/null +++ b/apps/enmeshed/lib/account/my_data/file/widgets/widgets.dart @@ -0,0 +1,5 @@ +export 'available_tags_section.dart'; +export 'file_info_container.dart'; +export 'file_tags_container.dart'; +export 'multi_stage_tags_section.dart'; +export 'selected_tags_section.dart'; diff --git a/apps/enmeshed/lib/core/widgets/file_item.dart b/apps/enmeshed/lib/core/widgets/file_item.dart index 2f7ea4d73..4f1210066 100644 --- a/apps/enmeshed/lib/core/widgets/file_item.dart +++ b/apps/enmeshed/lib/core/widgets/file_item.dart @@ -12,8 +12,9 @@ class FileItem extends StatelessWidget { final Widget? trailing; final String? query; final void Function()? onTap; + final VoidCallback? reload; - const FileItem({required this.accountId, required this.fileRecord, this.trailing, this.query, this.onTap, super.key}); + const FileItem({required this.accountId, required this.fileRecord, this.trailing, this.query, this.onTap, this.reload, super.key}); @override Widget build(BuildContext context) { @@ -40,7 +41,12 @@ class FileItem extends StatelessWidget { ), leading: FileIcon(filename: fileRecord.file.filename), trailing: trailing, - onTap: onTap ?? () => context.push('/account/$accountId/my-data/files/${fileRecord.file.id}', extra: fileRecord), + onTap: + onTap ?? + () async { + await context.push('/account/$accountId/my-data/files/${fileRecord.file.id}', extra: fileRecord); + reload?.call(); + }, ); } } diff --git a/apps/enmeshed/lib/core/widgets/upload_file.dart b/apps/enmeshed/lib/core/widgets/upload_file.dart index 9e1d07dd8..8d8f8f1f1 100644 --- a/apps/enmeshed/lib/core/widgets/upload_file.dart +++ b/apps/enmeshed/lib/core/widgets/upload_file.dart @@ -20,7 +20,7 @@ import 'modal_loading_overlay.dart'; class UploadFile extends StatefulWidget { final String accountId; - final void Function(FileDVO) onFileUploaded; + final Future Function(FileDVO) onFileUploaded; final bool popOnUpload; final Widget? leading; @@ -32,7 +32,6 @@ class UploadFile extends StatefulWidget { class _UploadFileState extends State { late final TextEditingController _titleController; - final _tagController = TextEditingController(); File? _selectedFile; bool _loading = false; @@ -48,7 +47,6 @@ class _UploadFileState extends State { @override void dispose() { _titleController.dispose(); - _tagController.dispose(); super.dispose(); } @@ -111,15 +109,6 @@ class _UploadFileState extends State { }), ], ), - Gaps.h8, - TextField( - controller: _tagController, - decoration: InputDecoration( - labelText: context.l10n.files_tag, - border: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(8))), - focusedBorder: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(8))), - ), - ), Gaps.h44, Row( mainAxisAlignment: MainAxisAlignment.end, @@ -169,7 +158,7 @@ class _UploadFileState extends State { final fileReference = await _createFileReferenceAttribute(file); - widget.onFileUploaded(file); + await widget.onFileUploaded(file); if (mounted && widget.popOnUpload) { context.pop(); @@ -227,7 +216,6 @@ class _UploadFileState extends State { final session = GetIt.I.get().getSession(widget.accountId); final createAttributeResult = await session.consumptionServices.attributes.createRepositoryAttribute( value: IdentityFileReferenceAttributeValue(value: file.truncatedReference), - tags: _tagController.text.isNotEmpty ? [_tagController.text] : null, ); // TODO(jkoenig134): error handling diff --git a/apps/enmeshed/lib/l10n/app_de.arb b/apps/enmeshed/lib/l10n/app_de.arb index 281599556..04c081e84 100644 --- a/apps/enmeshed/lib/l10n/app_de.arb +++ b/apps/enmeshed/lib/l10n/app_de.arb @@ -687,6 +687,9 @@ "files_noResults": "Keine Ergebnisse", "files_noResultsDescription": "Für den eingegeben Suchbegriff konnten keine Ergebnisse gefunden werden.", "files_uploadInProgress": "Die Datei wird hochgeladen.", + "files_assignTagsTitle": "Vergeben Sie Tags!", + "files_assignTagsDescription": "Mit Tags bringen Sie Ordnung in Ihre Dateien und Dokumente. So finden Sie jederzeit schnell wieder, was sie brauchen.", + "files_assignTagsButton": "Tags vergeben", "files_uploadFile": "Datei hochladen", "files_selectFile": "Datei auswählen", "files_createdAt": "Erstellt", @@ -705,7 +708,9 @@ "files_fileType_powerPoint": "PowerPoint-Dokumente", "files_fileType_other": "Andere ...", "files_fileSize": "Größe", - "files_tag": "Tag", + "files_assignTags": "Tags vergeben", + "files_editFile": "Dokument bearbeiten", + "files_saving": "Dokument wird gespeichert...", "filter": "Filter", "apply_filter": "Filtern", "deviceOnboarding_title": "Gerät hinzufügen", diff --git a/apps/enmeshed/lib/l10n/app_en.arb b/apps/enmeshed/lib/l10n/app_en.arb index d14aeb72b..57b31fbd3 100644 --- a/apps/enmeshed/lib/l10n/app_en.arb +++ b/apps/enmeshed/lib/l10n/app_en.arb @@ -687,6 +687,9 @@ "files_noResults": "No results", "files_noResultsDescription": "No results could be found for the search query entered.", "files_uploadInProgress": "Uploading the file.", + "files_assignTagsTitle": "Assign tags!", + "files_assignTagsDescription": "With tags, you bring order to your files and documents. So you can always find what you need quickly.", + "files_assignTagsButton": "Assign tags", "files_uploadFile": "Upload File", "files_selectFile": "Select your file", "files_createdAt": "Created", @@ -705,7 +708,9 @@ "files_fileType_powerPoint": "PowerPoint Document", "files_fileType_other": "Other ...", "files_fileSize": "Size", - "files_tag": "Tag", + "files_assignTags": "Assign tags", + "files_editFile": "Edit file", + "files_saving": "File is being saved...", "filter": "Filter", "apply_filter": "Apply filters", "deviceOnboarding_title": "Add device",