diff --git a/lib/src/dynamic/builders/content_builders.dart b/lib/src/dynamic/builders/content_builders.dart index ce23728..2e63911 100644 --- a/lib/src/dynamic/builders/content_builders.dart +++ b/lib/src/dynamic/builders/content_builders.dart @@ -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(); @@ -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']), ); } } @@ -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; + } +} diff --git a/lib/src/widgets/coflui_detail_row.dart b/lib/src/widgets/coflui_detail_row.dart index 3d297f9..8b57145 100644 --- a/lib/src/widgets/coflui_detail_row.dart +++ b/lib/src/widgets/coflui_detail_row.dart @@ -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` @@ -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 { @@ -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; @@ -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( @@ -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, ), ], ), diff --git a/test/widgets/dynamic_new_builders_test.dart b/test/widgets/dynamic_new_builders_test.dart index 63cb3c3..f868d2f 100644 --- a/test/widgets/dynamic_new_builders_test.dart +++ b/test/widgets/dynamic_new_builders_test.dart @@ -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(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(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, [ { diff --git a/test/widgets/new_widgets_test.dart b/test/widgets/new_widgets_test.dart index fc3012c..efaad4c 100644 --- a/test/widgets/new_widgets_test.dart +++ b/test/widgets/new_widgets_test.dart @@ -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(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( + 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,