141 lines
4.6 KiB
Dart
141 lines
4.6 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import 'package:notas/models/note.dart';
|
|
|
|
// Small presentational widget for a note inside the grid.
|
|
// Keep this widget lightweight and layout-agnostic: it should not force
|
|
// width/height constraints (so it works inside different parent layouts
|
|
// like MasonryGridView or Draggable feedback). Visual styling only.
|
|
|
|
class NoteCard extends StatefulWidget {
|
|
const NoteCard({
|
|
super.key,
|
|
required this.note,
|
|
this.onTap,
|
|
this.isDragging = false,
|
|
this.borderColor,
|
|
});
|
|
|
|
final Note note;
|
|
final VoidCallback? onTap;
|
|
final bool isDragging;
|
|
final Color? borderColor;
|
|
|
|
@override
|
|
State<NoteCard> createState() => _NoteCardState();
|
|
}
|
|
|
|
class _NoteCardState extends State<NoteCard> {
|
|
bool _isPressed = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final bool showGrabbing = widget.isDragging || _isPressed;
|
|
|
|
return MouseRegion(
|
|
cursor: showGrabbing ? SystemMouseCursors.grabbing : SystemMouseCursors.grab,
|
|
child: GestureDetector(
|
|
onTapDown: widget.onTap == null
|
|
? null
|
|
: (_) {
|
|
setState(() {
|
|
_isPressed = true;
|
|
});
|
|
},
|
|
onTapUp: widget.onTap == null
|
|
? null
|
|
: (_) {
|
|
setState(() {
|
|
_isPressed = false;
|
|
});
|
|
},
|
|
onTapCancel: widget.onTap == null
|
|
? null
|
|
: () {
|
|
setState(() {
|
|
_isPressed = false;
|
|
});
|
|
},
|
|
onTap: widget.onTap,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: const Color.fromRGBO(24, 25, 26, 1),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(
|
|
color: widget.borderColor ?? Colors.white24,
|
|
width: 1,
|
|
),
|
|
),
|
|
child: LayoutBuilder(
|
|
builder: (BuildContext context, BoxConstraints constraints) {
|
|
// Estimate whether the body will exceed 20 lines without always
|
|
// running the expensive TextPainter layout. This heuristic counts
|
|
// newline characters and estimates wrapped lines based on an
|
|
// average characters-per-line to handle many short lines well.
|
|
final List<String> rawLines = widget.note.body.split('\n');
|
|
const int avgCharsPerLine = 40;
|
|
int estimatedLines = 0;
|
|
for (final String line in rawLines) {
|
|
estimatedLines += (line.trim().length ~/ avgCharsPerLine) + 1;
|
|
}
|
|
|
|
final bool needsPreciseMeasurement = estimatedLines > 20;
|
|
final bool isBodyTruncated;
|
|
|
|
if (needsPreciseMeasurement) {
|
|
final TextPainter textPainter = TextPainter(
|
|
text: TextSpan(
|
|
text: widget.note.body,
|
|
style: const TextStyle(color: Colors.white70, fontSize: 14),
|
|
),
|
|
maxLines: 20,
|
|
textDirection: TextDirection.ltr,
|
|
)..layout(maxWidth: constraints.maxWidth);
|
|
|
|
isBodyTruncated = textPainter.didExceedMaxLines;
|
|
} else {
|
|
isBodyTruncated = false;
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
widget.note.title,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
widget.note.body,
|
|
style: const TextStyle(color: Colors.white70, fontSize: 14),
|
|
maxLines: 20,
|
|
overflow: TextOverflow.clip,
|
|
),
|
|
if (isBodyTruncated) ...[
|
|
const SizedBox(height: 4),
|
|
const Text(
|
|
'...',
|
|
style: TextStyle(
|
|
color: Colors.white54,
|
|
fontSize: 18,
|
|
height: 1,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} |