Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/lib/theme/app_theme_data.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions app/lib/theme/compact_theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 18 additions & 3 deletions app/lib/widgets/clipboard_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ class _ClipboardCardState extends State<ClipboardCard> {
String _cachedExt = '';
String _displayContent = '';
bool _sourceAvailable = true;
bool _isRichText = false;
bool _hasFormatting = false;

static const _doubleTapTimeout = Duration(milliseconds: 300);

Expand Down Expand Up @@ -112,6 +114,10 @@ class _ClipboardCardState extends State<ClipboardCard> {
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
Expand Down Expand Up @@ -255,9 +261,13 @@ class _ClipboardCardState extends State<ClipboardCard> {
}
}

// 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
Expand Down Expand Up @@ -544,7 +554,12 @@ class _ClipboardCardState extends State<ClipboardCard> {
),
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,
),
Expand Down
163 changes: 161 additions & 2 deletions app/test/widgets/clipboard_card_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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: () {},
Expand All @@ -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 {
Expand Down
25 changes: 25 additions & 0 deletions core/lib/models/clipboard_item.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io';

import 'package:uuid/uuid.dart';
Expand Down Expand Up @@ -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<String, dynamic>) return false;
final value = decoded[key];
return value is String && value.isNotEmpty;
} catch (_) {
return false;
}
}

ClipboardItem copyWith({
String? content,
ClipboardContentType? type,
Expand Down
37 changes: 31 additions & 6 deletions core/lib/services/clipboard_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>? rtfBytes,
List<int>? htmlBytes,
) {
final meta = <String, Object?>{};
if (current != null && current.isNotEmpty) {
try {
final decoded = jsonDecode(current);
if (decoded is Map<String, dynamic>) 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<ClipboardItem?> processText(
String content,
ClipboardContentType type, {
Expand All @@ -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;
Expand All @@ -189,22 +217,19 @@ 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);
return updated;
}
}

final meta = <String, Object>{};
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);
Expand Down
Loading
Loading