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
97 changes: 95 additions & 2 deletions lib/src/dynamic/builders/content_builders.dart
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,13 @@ class ContentBuilders {
/// - `iconSize`: icon size in logical px (default 20).
/// - `label`: small caption text (falls back to `label`).
/// - `value`: prominent text (falls back to `value`).
/// - `highlight`: when `true`, renders the value in the theme primary color.
/// - `highlight`: when `true`, renders the value in the theme primary color
/// (shorthand for valueColor = theme.primary).
/// - `valueColor`: hex color string (e.g. "#FF8800") for the value text.
/// - `valueFontSize`: value font size in px (e.g. 18).
/// - `valueFontWeight`: value weight β€” "bold" | "normal" | "w500" | 600 | …
/// - `valueMaxLines`: clamp the value to N lines (default unlimited).
/// - `valueOverflow`: "ellipsis" | "clip" | "visible" (default clip).
static Widget detailRow(BuildContext ctx, UIComponent c, controller) {
final props = c.props;
final label = (props['label'] ?? c.label ?? '').toString();
Expand All @@ -71,7 +77,15 @@ class ContentBuilders {
),
label: label,
value: value,
valueColor: highlight ? Theme.of(ctx).colorScheme.primary : null,
valueColor: highlight
? Theme.of(ctx).colorScheme.primary
: _colorOf(props['valueColor']),
valueFontSize: _toDouble(props['valueFontSize']),
valueFontWeight: _weightOf(props['valueFontWeight']),
valueMaxLines: props['valueMaxLines'] is int
? props['valueMaxLines'] as int
: null,
valueOverflow: _overflowOf(props['valueOverflow']),
);
}
}
Expand All @@ -81,3 +95,82 @@ double? _toDouble(dynamic v) {
if (v is num) return v.toDouble();
return double.tryParse(v.toString());
}

/// Parse a JSON color (hex string) into a [Color]. Returns null when unset.
Color? _colorOf(dynamic v) {
if (v == null) return null;
final s = v.toString();
var hex = s.replaceFirst('#', '');
if (hex.isEmpty) return null;
if (hex.length == 6) hex = 'FF$hex'; // #RRGGBB β†’ opaque
final parsed = int.tryParse(hex, radix: 16);
return parsed != null ? Color(parsed) : null;
}

/// Parse a font weight from name or number. Returns null when unset.
FontWeight? _weightOf(dynamic v) {
if (v == null) return null;
if (v is int) {
return FontWeight.values.firstWhere(
(w) => w.index == (v ~/ 100).clamp(0, 8),
orElse: () => FontWeight.normal,
);
}
switch (v.toString().toLowerCase()) {
case 'bold':
case 'w700':
case '700':
return FontWeight.bold;
case 'normal':
case 'w400':
case '400':
return FontWeight.normal;
case 'w100':
case '100':
case 'thin':
return FontWeight.w100;
case 'w200':
case '200':
case 'extralight':
return FontWeight.w200;
case 'w300':
case '300':
case 'light':
return FontWeight.w300;
case 'w500':
case '500':
case 'medium':
return FontWeight.w500;
case 'w600':
case '600':
case 'semibold':
return FontWeight.w600;
case 'w800':
case '800':
case 'extrabold':
return FontWeight.w800;
case 'w900':
case '900':
case 'black':
return FontWeight.w900;
default:
return null;
}
}

/// Parse a [TextOverflow] by name. Returns null when unset.
TextOverflow? _overflowOf(dynamic v) {
if (v == null) return null;
switch (v.toString().toLowerCase()) {
case 'ellipsis':
return TextOverflow.ellipsis;
case 'clip':
return TextOverflow.clip;
case 'visible':
return TextOverflow.visible;
case 'fade':
return TextOverflow.fade;
default:
return null;
}
}
59 changes: 53 additions & 6 deletions lib/src/widgets/coflui_detail_row.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import '../theme/coflui_typography.dart';
///
/// Used in detail views, summaries, and read-only info layouts. The [icon]
/// sits on the leading edge, [label] is a small muted caption, and [value]
/// is the prominent text. Pass [valueColor] to highlight (e.g. success/error).
/// is the prominent text.
///
/// **Value styling**: pass [valueStyle] for full control (size, weight, color,
/// family, letterSpacing, decoration, …). The shorthand [valueColor] /
/// [valueFontSize] / [valueFontWeight] props merge ON TOP of [valueStyle]
/// for convenience β€” so you can mix-and-match.
///
/// [icon] is a [Widget] (typically a [CofluiIcon] or [Icon]) so it accepts
/// any source: Material icon, SVG, PNG asset, or network URL. Pass `null`
Expand All @@ -18,6 +23,8 @@ import '../theme/coflui_typography.dart';
/// icon: CofluiIcon.icon(Icons.person, size: 20),
/// label: 'Name',
/// value: 'Budi',
/// valueFontWeight: FontWeight.bold,
/// valueFontSize: 18,
/// )
/// ```
class CofluiDetailRow extends StatelessWidget {
Expand All @@ -31,9 +38,29 @@ class CofluiDetailRow extends StatelessWidget {
/// Prominent value text.
final String value;

/// Optional override for the value text color (e.g. theme primary).
/// Base text style for the value. Defaults to body typography.
/// Override individual properties via [valueColor] / [valueFontSize] /
/// [valueFontWeight] / [valueFontFamily] β€” those merge on top.
final TextStyle? valueStyle;

/// Value text color override. Merged on top of [valueStyle].
final Color? valueColor;

/// Value font size override. Merged on top of [valueStyle].
final double? valueFontSize;

/// Value font weight override. Merged on top of [valueStyle].
final FontWeight? valueFontWeight;

/// Value font family override. Merged on top of [valueStyle].
final String? valueFontFamily;

/// Value text overflow behavior. Defaults to clip (single line).
final TextOverflow? valueOverflow;

/// Max lines for the value. Defaults to null (unbounded).
final int? valueMaxLines;

/// Spacing between the icon and the text column. Defaults to 12.
/// Ignored when [icon] is null.
final double iconGap;
Expand All @@ -46,11 +73,32 @@ class CofluiDetailRow extends StatelessWidget {
this.icon,
required this.label,
required this.value,
this.valueStyle,
this.valueColor,
this.valueFontSize,
this.valueFontWeight,
this.valueFontFamily,
this.valueOverflow,
this.valueMaxLines,
this.iconGap = 12,
this.labelGap = 2,
});

/// Build the effective value TextStyle by merging all overrides.
TextStyle get _effectiveValueStyle => TextStyle(
fontSize: CofluiTypography.body,
color: CofluiColors.onSurface,
)
// Base style from caller (if any)
.merge(valueStyle)
// Convenience overrides β€” applied last so they win.
.copyWith(
color: valueColor,
fontSize: valueFontSize,
fontWeight: valueFontWeight,
fontFamily: valueFontFamily,
);

@override
Widget build(BuildContext context) {
return Row(
Expand All @@ -74,10 +122,9 @@ class CofluiDetailRow extends StatelessWidget {
SizedBox(height: labelGap),
Text(
value,
style: TextStyle(
fontSize: CofluiTypography.body,
color: valueColor ?? CofluiColors.onSurface,
),
style: _effectiveValueStyle,
overflow: valueOverflow,
maxLines: valueMaxLines,
),
],
),
Expand Down
40 changes: 40 additions & 0 deletions test/widgets/dynamic_new_builders_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,46 @@ void main() {
expect(icon.size, 32);
});

testWidgets('detailRow value styling via JSON props', (tester) async {
await _pumpDynamic(tester, [
{
'id': 'd',
'type': 'detail_row',
'props': {
'icon': 'payments',
'label': 'Amount',
'value': 'Rp 1.000.000',
'valueFontSize': 20,
'valueFontWeight': 'bold',
'valueColor': '#088ECE',
},
},
]);
final text = tester.widget<Text>(find.text('Rp 1.000.000'));
expect(text.style?.fontSize, 20);
expect(text.style?.fontWeight, FontWeight.bold);
expect(text.style?.color, const Color(0xFF088ECE));
});

testWidgets('detailRow valueMaxLines + valueOverflow via JSON', (tester) async {
await _pumpDynamic(tester, [
{
'id': 'd',
'type': 'detail_row',
'props': {
'icon': 'description',
'label': 'Desc',
'value': 'long text that should ellipsis',
'valueMaxLines': 1,
'valueOverflow': 'ellipsis',
},
},
]);
final text = tester.widget<Text>(find.text('long text that should ellipsis'));
expect(text.maxLines, 1);
expect(text.overflow, TextOverflow.ellipsis);
});

testWidgets('detailRow renders without icon when omitted', (tester) async {
await _pumpDynamic(tester, [
{
Expand Down
34 changes: 34 additions & 0 deletions test/widgets/new_widgets_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,40 @@ void main() {
expect(find.byType(Icon), findsNothing);
});

testWidgets('value styling props honored', (tester) async {
await pump(
tester,
const CofluiDetailRow(
label: 'Amount',
value: 'Rp 1.000.000',
valueFontSize: 20,
valueFontWeight: FontWeight.bold,
valueColor: Color(0xFF088ECE),
),
);
final text = tester.widget<Text>(find.text('Rp 1.000.000'));
expect(text.style?.fontSize, 20);
expect(text.style?.fontWeight, FontWeight.bold);
expect(text.style?.color, const Color(0xFF088ECE));
});

testWidgets('valueMaxLines + valueOverflow applied', (tester) async {
await pump(
tester,
const CofluiDetailRow(
label: 'Desc',
value: 'very long text that should ellipsis at 1 line',
valueMaxLines: 1,
valueOverflow: TextOverflow.ellipsis,
),
);
final text = tester.widget<Text>(
find.text('very long text that should ellipsis at 1 line'),
);
expect(text.maxLines, 1);
expect(text.overflow, TextOverflow.ellipsis);
});

testWidgets('accepts a CofluiIcon (any source) as icon', (tester) async {
await pump(
tester,
Expand Down
Loading