diff --git a/app/lib/theme/app_theme_data.dart b/app/lib/theme/app_theme_data.dart index 127e764b..3d0277c7 100644 --- a/app/lib/theme/app_theme_data.dart +++ b/app/lib/theme/app_theme_data.dart @@ -207,6 +207,7 @@ class AppThemeSizing { class AppThemeIcons { const AppThemeIcons({ required this.text, + required this.textRich, required this.image, required this.link, required this.file, @@ -232,6 +233,11 @@ class AppThemeIcons { }); final IconData text; + + /// Variante para texto copiado con estilos. Mismo peso visual y mismo color + /// que [text]: solo cambia el glyph, para no teñir de otro color la mayoría + /// del historial (que es texto plano) por marcar la excepción. + final IconData textRich; final IconData image; final IconData link; final IconData file; diff --git a/app/lib/theme/compact_theme.dart b/app/lib/theme/compact_theme.dart index 1f84fecf..63fc0a10 100644 --- a/app/lib/theme/compact_theme.dart +++ b/app/lib/theme/compact_theme.dart @@ -95,6 +95,7 @@ class CompactTheme extends AppThemeData { @override AppThemeIcons get icons => const AppThemeIcons( text: Icons.text_snippet_outlined, + textRich: Icons.text_format_rounded, image: Icons.image_outlined, link: Icons.link_rounded, file: Icons.insert_drive_file_outlined, diff --git a/app/lib/widgets/clipboard_card.dart b/app/lib/widgets/clipboard_card.dart index 1ace2d13..8dc87e78 100644 --- a/app/lib/widgets/clipboard_card.dart +++ b/app/lib/widgets/clipboard_card.dart @@ -64,6 +64,8 @@ class _ClipboardCardState extends State { String _cachedExt = ''; String _displayContent = ''; bool _sourceAvailable = true; + bool _isRichText = false; + bool _hasFormatting = false; static const _doubleTapTimeout = Duration(milliseconds: 300); @@ -112,6 +114,10 @@ class _ClipboardCardState extends State { void _recomputeDerived() { final item = widget.item; _cachedMetadata = _parseMetadata(item); + // Both parse the metadata JSON, so they are resolved here rather than on + // every build, alongside the other derived values. + _isRichText = item.hasRichText; + _hasFormatting = item.hasFormatting; _cachedExt = _getExtForItem(item); _displayContent = item.content.length <= _maxDisplayChars ? item.content @@ -255,9 +261,13 @@ class _ClipboardCardState extends State { } } + // Offered only when there is formatting to strip: on a clip the OS never + // gave styles to, "paste as plain text" is identical to a normal paste and + // the button is just noise. bool get _isPlainPasteable => - widget.item.type == ClipboardContentType.text || - widget.item.type == ClipboardContentType.link; + _hasFormatting && + (widget.item.type == ClipboardContentType.text || + widget.item.type == ClipboardContentType.link); // Real on-disk paths backing this item, for drag-out. Image content is a // single file; file/folder/audio/video may carry several paths joined by @@ -544,7 +554,12 @@ class _ClipboardCardState extends State { ), child: Center( child: Icon( - theme.icons.forContentType(item.type.value), + // Rich text only swaps the glyph, never the color: the tint + // stays the type's own. Restricted to plain text because for + // a link or JSON the type itself is the more useful signal. + _isRichText && item.type == ClipboardContentType.text + ? theme.icons.textRich + : theme.icons.forContentType(item.type.value), size: 16, color: typeColor, ), diff --git a/app/test/widgets/clipboard_card_test.dart b/app/test/widgets/clipboard_card_test.dart index 578e93ae..ef75cf8d 100644 --- a/app/test/widgets/clipboard_card_test.dart +++ b/app/test/widgets/clipboard_card_test.dart @@ -121,6 +121,117 @@ void main() { expect(find.byType(ClipboardCard), findsOneWidget); }); + testWidgets('plain text uses the plain glyph', (tester) async { + await tester.pumpWidget( + wrapWidget( + ClipboardCard( + item: _makeTextItem(), + onTap: () {}, + onPin: () {}, + onDelete: () {}, + onLabelColor: (_, _) {}, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.text_snippet_outlined), findsOneWidget); + expect(find.byIcon(Icons.text_format_rounded), findsNothing); + }); + + testWidgets('rich text swaps the glyph but keeps the type color', ( + tester, + ) async { + final plain = _makeTextItem(); + final rich = plain.copyWith(metadata: '{"rtf":"e1xydGYx"}'); + + await tester.pumpWidget( + wrapWidget( + ClipboardCard( + item: rich, + onTap: () {}, + onPin: () {}, + onDelete: () {}, + onLabelColor: (_, _) {}, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.text_format_rounded), findsOneWidget); + expect(find.byIcon(Icons.text_snippet_outlined), findsNothing); + }); + + testWidgets('html-only metadata keeps the plain glyph', (tester) async { + final item = _makeTextItem().copyWith(metadata: '{"html":"PGh0bWw+"}'); + + await tester.pumpWidget( + wrapWidget( + ClipboardCard( + item: item, + onTap: () {}, + onPin: () {}, + onDelete: () {}, + onLabelColor: (_, _) {}, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.text_snippet_outlined), findsOneWidget); + }); + + testWidgets('a styled link keeps its link glyph', (tester) async { + // The type is the stronger signal for non-plain-text kinds, so rich + // formatting must not override it. + final item = ClipboardItem( + content: 'https://example.com', + type: ClipboardContentType.link, + ).copyWith(metadata: '{"rtf":"e1xydGYx"}'); + + await tester.pumpWidget( + wrapWidget( + ClipboardCard( + item: item, + onTap: () {}, + onPin: () {}, + onDelete: () {}, + onLabelColor: (_, _) {}, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.link_rounded), findsOneWidget); + expect(find.byIcon(Icons.text_format_rounded), findsNothing); + }); + + testWidgets('glyph updates when metadata changes in place', (tester) async { + final plain = _makeTextItem(); + + Widget build(ClipboardItem item) => wrapWidget( + ClipboardCard( + item: item, + onTap: () {}, + onPin: () {}, + onDelete: () {}, + onLabelColor: (_, _) {}, + ), + ); + + await tester.pumpWidget(build(plain)); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.text_snippet_outlined), findsOneWidget); + + // A re-copy with styles reuses the same item, so the cached flag must be + // recomputed rather than kept from the first build. + await tester.pumpWidget( + build(plain.copyWith(metadata: '{"rtf":"e1xydGYx"}')), + ); + await tester.pumpAndSettle(); + expect(find.byIcon(Icons.text_format_rounded), findsOneWidget); + }); + testWidgets('double-tap triggers onTap', (tester) async { var tapCount = 0; var selectCount = 0; @@ -420,12 +531,14 @@ void main() { } }); - testWidgets('onPastePlain callback exposed for text type', (tester) async { + testWidgets('onPastePlain callback exposed for formatted text', ( + tester, + ) async { var plainCount = 0; await tester.pumpWidget( wrapWidget( ClipboardCard( - item: _makeTextItem(), + item: _makeTextItem().copyWith(metadata: '{"rtf":"e1xydGYx"}'), onTap: () {}, onPin: () {}, onDelete: () {}, @@ -436,6 +549,52 @@ void main() { ); await tester.pumpAndSettle(); expect(find.byType(ClipboardCard), findsOneWidget); + expect(find.byIcon(Icons.notes_rounded), findsOneWidget); + }); + + testWidgets('plain paste button is hidden when there is no formatting', ( + tester, + ) async { + await tester.pumpWidget( + wrapWidget( + ClipboardCard( + item: _makeTextItem(), + onTap: () {}, + onPin: () {}, + onDelete: () {}, + onLabelColor: (_, _) {}, + onPastePlain: () {}, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.notes_rounded), findsNothing); + }); + + testWidgets('html-only metadata still offers the plain paste button', ( + tester, + ) async { + // The writer restores html too, so a normal paste would carry formatting + // even though the card shows the plain glyph. + final item = _makeTextItem().copyWith(metadata: '{"html":"PGh0bWw+"}'); + + await tester.pumpWidget( + wrapWidget( + ClipboardCard( + item: item, + onTap: () {}, + onPin: () {}, + onDelete: () {}, + onLabelColor: (_, _) {}, + onPastePlain: () {}, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.notes_rounded), findsOneWidget); + expect(find.byIcon(Icons.text_snippet_outlined), findsOneWidget); }); testWidgets('file type item renders filename', (tester) async { diff --git a/core/lib/models/clipboard_item.dart b/core/lib/models/clipboard_item.dart index 06977288..7a0dd10d 100644 --- a/core/lib/models/clipboard_item.dart +++ b/core/lib/models/clipboard_item.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:uuid/uuid.dart'; @@ -65,6 +66,30 @@ class ClipboardItem { type == ClipboardContentType.audio || type == ClipboardContentType.video; + /// True cuando el clip se copió con estilos. Solo se mira `rtf`: casi todo lo + /// copiado desde un navegador arrastra `html` aunque el texto sea plano, así + /// que esa clave no distingue y como señal visual sería ruido. + bool get hasRichText => _hasMetadataPayload('rtf'); + + /// True cuando pegar el clip tal cual restauraría algún formato. A diferencia + /// de [hasRichText] sí cuenta `html`, porque el writer también lo devuelve al + /// portapapeles: esto es lo que decide si "pegar sin formato" tiene efecto. + bool get hasFormatting => + _hasMetadataPayload('rtf') || _hasMetadataPayload('html'); + + bool _hasMetadataPayload(String key) { + final raw = metadata; + if (raw == null || raw.isEmpty) return false; + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) return false; + final value = decoded[key]; + return value is String && value.isNotEmpty; + } catch (_) { + return false; + } + } + ClipboardItem copyWith({ String? content, ClipboardContentType? type, diff --git a/core/lib/services/clipboard_service.dart b/core/lib/services/clipboard_service.dart index 1d80b854..692fb976 100644 --- a/core/lib/services/clipboard_service.dart +++ b/core/lib/services/clipboard_service.dart @@ -155,6 +155,31 @@ class ClipboardService { return elapsed < pasteIgnoreWindowMs; } + /// Rebuilds the `rtf`/`html` keys from the copy being processed. + /// + /// These keys describe the *last* copy, so re-copying the same text as plain + /// must drop a stale RTF: otherwise the item would keep claiming a format the + /// clipboard no longer carries, and pasting would restore it. Keys owned by + /// other flows (media metadata) are preserved. + String? _mergeFormatMetadata( + String? current, + List? rtfBytes, + List? htmlBytes, + ) { + final meta = {}; + if (current != null && current.isNotEmpty) { + try { + final decoded = jsonDecode(current); + if (decoded is Map) meta.addAll(decoded); + } catch (_) {} + } + meta.remove('rtf'); + meta.remove('html'); + if (rtfBytes != null) meta['rtf'] = base64Encode(rtfBytes); + if (htmlBytes != null) meta['html'] = base64Encode(htmlBytes); + return meta.isEmpty ? null : jsonEncode(meta); + } + Future processText( String content, ClipboardContentType type, { @@ -174,7 +199,10 @@ class ClipboardService { resolvedType, ); if (existing != null) { - final updated = existing.copyWith(modifiedAt: DateTime.now().toUtc()); + final updated = existing.copyWith( + modifiedAt: DateTime.now().toUtc(), + metadata: _mergeFormatMetadata(existing.metadata, rtfBytes, htmlBytes), + ); await _repository.update(updated); _itemReactivated.add(updated); return updated; @@ -189,6 +217,7 @@ class ClipboardService { final updated = legacy.copyWith( type: resolvedType, modifiedAt: DateTime.now().toUtc(), + metadata: _mergeFormatMetadata(legacy.metadata, rtfBytes, htmlBytes), ); await _repository.update(updated); _itemReactivated.add(updated); @@ -196,15 +225,11 @@ class ClipboardService { } } - final meta = {}; - if (rtfBytes != null) meta['rtf'] = base64Encode(rtfBytes); - if (htmlBytes != null) meta['html'] = base64Encode(htmlBytes); - final item = ClipboardItem( content: content, type: resolvedType, appSource: source, - metadata: meta.isNotEmpty ? jsonEncode(meta) : null, + metadata: _mergeFormatMetadata(null, rtfBytes, htmlBytes), ); await _repository.save(item); _itemAdded.add(item); diff --git a/core/test/clipboard_item_test.dart b/core/test/clipboard_item_test.dart index 3a83e6c3..0171ca20 100644 --- a/core/test/clipboard_item_test.dart +++ b/core/test/clipboard_item_test.dart @@ -186,4 +186,64 @@ void main() { }, ); }); + + group('ClipboardItem.hasRichText', () { + ClipboardItem itemWith(String? metadata) => ClipboardItem( + content: 'x', + type: ClipboardContentType.text, + ).copyWith(metadata: metadata); + + test('is true when metadata carries a non-empty rtf key', () { + expect(itemWith('{"rtf":"e1xydGYx"}').hasRichText, isTrue); + }); + + test('is false when rtf is present but empty', () { + expect(itemWith('{"rtf":""}').hasRichText, isFalse); + }); + + test('is false when only html is present', () { + // Copying from a browser drags text/html along even for plain text, so + // html alone must not promote an item to rich. + expect(itemWith('{"html":"PGh0bWw+"}').hasRichText, isFalse); + }); + + test('is false when there is no metadata', () { + expect(itemWith(null).hasRichText, isFalse); + expect(itemWith('').hasRichText, isFalse); + }); + + test('is false on malformed or non-map metadata', () { + expect(itemWith('not json').hasRichText, isFalse); + expect(itemWith('[1,2,3]').hasRichText, isFalse); + }); + + test('is false when rtf holds a non-string value', () { + expect(itemWith('{"rtf":42}').hasRichText, isFalse); + }); + }); + + group('ClipboardItem.hasFormatting', () { + ClipboardItem itemWith(String? metadata) => ClipboardItem( + content: 'x', + type: ClipboardContentType.text, + ).copyWith(metadata: metadata); + + test('is true for rtf', () { + expect(itemWith('{"rtf":"e1xydGYx"}').hasFormatting, isTrue); + }); + + test('is true for html alone', () { + // Unlike hasRichText: the writer restores html to the clipboard, so a + // normal paste would carry formatting and stripping it is meaningful. + final item = itemWith('{"html":"PGh0bWw+"}'); + expect(item.hasFormatting, isTrue); + expect(item.hasRichText, isFalse); + }); + + test('is false when no format payload is attached', () { + expect(itemWith(null).hasFormatting, isFalse); + expect(itemWith('{"duration":42}').hasFormatting, isFalse); + expect(itemWith('{"rtf":"","html":""}').hasFormatting, isFalse); + }); + }); } diff --git a/core/test/clipboard_service_test.dart b/core/test/clipboard_service_test.dart index ef990628..d82c9d02 100644 --- a/core/test/clipboard_service_test.dart +++ b/core/test/clipboard_service_test.dart @@ -99,6 +99,58 @@ void main() { ); expect(result!.metadata, isNull); }); + + test('re-copying with styles promotes a plain item to rich', () async { + final plain = await service.processText( + 'same text', + ClipboardContentType.text, + ); + expect(plain!.hasRichText, isFalse); + + final rich = await service.processText( + 'same text', + ClipboardContentType.text, + rtfBytes: [0x7B, 0x5C, 0x72, 0x74, 0x66], + ); + + expect(rich!.id, equals(plain.id)); + expect(rich.hasRichText, isTrue); + }); + + test('re-copying as plain clears a stale rtf', () async { + final rich = await service.processText( + 'same text', + ClipboardContentType.text, + rtfBytes: [0x7B, 0x5C, 0x72, 0x74, 0x66], + ); + expect(rich!.hasRichText, isTrue); + + final plain = await service.processText( + 'same text', + ClipboardContentType.text, + ); + + expect(plain!.id, equals(rich.id)); + expect(plain.hasRichText, isFalse); + expect(plain.metadata, isNull); + }); + + test('metadata refresh preserves keys owned by other flows', () async { + final first = await service.processText( + 'media caption', + ClipboardContentType.text, + ); + await service.updateMetadata(first!.id, '{"duration":42}'); + + final second = await service.processText( + 'media caption', + ClipboardContentType.text, + rtfBytes: [0x7B, 0x5C, 0x72, 0x74, 0x66], + ); + + expect(second!.metadata, contains('duration')); + expect(second.hasRichText, isTrue); + }); }); group('ClipboardService.processImage', () {