Skip to content

Instantly share code, notes, and snippets.

@AndrewDongminYoo
Last active November 22, 2025 00:27
Show Gist options
  • Select an option

  • Save AndrewDongminYoo/1f1199ac8b6ffbe3f1dff3882e01ab94 to your computer and use it in GitHub Desktop.

Select an option

Save AndrewDongminYoo/1f1199ac8b6ffbe3f1dff3882e01ab94 to your computer and use it in GitHub Desktop.
A JsonConverter between Flutter Color (ARGB) and web/Figma-style hex strings using 'RRGGBBAA' (RGBA) format, optionally prefixed with '#'.
// 🐦 Flutter imports:
import 'package:flutter/material.dart';
// 📦 Package imports:
import 'package:json_annotation/json_annotation.dart';
/// A [JsonConverter] between Flutter [Color] (ARGB)
/// and web/Figma-style hex strings using `RRGGBBAA` (RGBA) format,
/// optionally prefixed with `#`.
///
/// Supported input formats (with or without leading `#`):
/// - RGB (3 chars) -> RRGGBBFF
/// - RGBA (4 chars) -> RRGGBBAA
/// - RRGGBB (6 chars) -> RRGGBBFF
/// - RRGGBBAA (8 chars)
///
/// Examples:
/// * Color(0x66FBEA37) (AARRGGBB) ⇔ "#FBEA3766" (RRGGBBAA)
/// * Color(0x1A000000) ⇔ "#0000001A"
/// * Color(0x33585213) ⇔ "#58521333"
class ColorConverter implements JsonConverter<Color, String> {
const ColorConverter({this.containsHash = true});
/// Whether [toJson] should include a leading `#` in the output string.
///
/// This does **not** affect parsing: [fromJson] accepts both forms.
final bool containsHash;
@override
Color fromJson(String json) {
// 1) Normalize and validate the input string to match the “RRGGBBAA” format.
final rgbaHex = _validateAndNormalize(json); // "RRGGBBAA"
final rgbaValue = int.parse(rgbaHex, radix: 16);
// 2) Convert RGBA (RRGGBBAA) to ARGB (AARRGGBB) values.
final argbValue = _rgbaToArgb(rgbaValue);
// 3) dart:ui's Color uses AARRGGBB (ARGB).
return Color(argbValue);
}
@override
String toJson(Color color) {
return _colorToHex(color);
}
/// Normalizes and validates an input hex string.
///
/// - Trims whitespace
/// - Removes a single leading '#' if present
/// - Accepts 6-digit "RRGGBB" (assumes alpha = 0xFF)
/// - Accepts 8-digit "RRGGBBAA"
///
/// Returns the normalized "RRGGBBAA" hex string (no '#').
String _validateAndNormalize(String input) {
final trimmed = input.trim();
if (trimmed.isEmpty) {
throw ArgumentError('Hex color string must not be empty');
}
// remove optional '#'
final raw = trimmed.startsWith('#') ? trimmed.substring(1) : trimmed;
if (raw.isEmpty) {
throw ArgumentError('Hex color string must not be empty');
}
// Expand shorthand HEX (#RGB, #RGBA)
String hex;
switch (raw.length) {
case 3: // RGB -> RRGGBBFF
hex = '${raw.split('').map((c) => '$c$c').join()}FF';
case 4: // RGBA -> RRGGBBAA
final chars = raw.split('');
hex = chars.map((c) => '$c$c').join();
case 6: // RRGGBB -> RRGGBBFF
hex = '${raw}FF';
case 8: // RRGGBBAA
hex = raw;
default:
throw FormatException(
'Hex color must be 3, 4, 6, or 8 characters. Got ${raw.length}',
input,
);
}
if (int.tryParse(hex, radix: 16) == null) {
throw FormatException('Invalid hex color string: $input', input);
}
return hex.toUpperCase();
}
/// Converts a RGBA int (`RRGGBBAA`) into an ARGB int (`AARRGGBB`) used by [Color].
int _rgbaToArgb(int rgba) {
final r = (rgba >> 24) & 0xFF;
final g = (rgba >> 16) & 0xFF;
final b = (rgba >> 8) & 0xFF;
final a = rgba & 0xFF;
// AARRGGBB
return (a << 24) | (r << 16) | (g << 8) | b;
}
/// Converts a [Color] to a web/Figma-style `RRGGBBAA` hex string, optionally prefixed with '#'.
String _colorToHex(Color color) {
final rgba = _colorToRgbaInt(color);
final hex = rgba.toRadixString(16).padLeft(8, '0').toUpperCase();
final prefix = containsHash ? '#' : '';
return '$prefix$hex';
}
/// Converts a [Color] into a RGBA int (`RRGGBBAA`).
int _colorToRgbaInt(Color color) {
// The red/green/blue/alpha properties are each integers ranging from 0 to 255.
return ((color.r * 255).round().clamp(0, 255) << 24) |
((color.g * 255).round().clamp(0, 255) << 16) |
((color.b * 255).round().clamp(0, 255) << 8) |
(color.a * 255).round().clamp(0, 255);
}
}
// 🐦 Flutter imports:
import 'package:flutter/material.dart';
// 📦 Package imports:
import 'package:flutter_test/flutter_test.dart';
// 🌎 Project imports:
import 'color_converter.dart';
void main() {
const withHash = ColorConverter();
const withoutHash = ColorConverter(containsHash: false);
group('ColorConverter – roundtrip (Color -> JSON -> Color)', () {
void expectRoundTrip(Color color, {bool withHashPrefix = true}) {
final converter = withHashPrefix ? withHash : withoutHash;
final json = converter.toJson(color);
final parsed = converter.fromJson(json);
expect(parsed, equals(color), reason: 'Roundtrip must preserve color');
}
test('opaque black, white, transparent black, max value', () {
expectRoundTrip(const Color(0xFF000000)); // opaque black
expectRoundTrip(const Color(0xFFFFFFFF)); // opaque white
expectRoundTrip(const Color(0x00000000)); // fully transparent black
expectRoundTrip(const Color(0xFFFFFFFF)); // max value (same as white)
});
test('example colors from doc', () {
expectRoundTrip(const Color(0x66FBEA37));
expectRoundTrip(const Color(0x1A000000));
expectRoundTrip(const Color(0x33585213));
});
test('roundtrip with and without "#" prefix', () {
const color = Color(0x66FBEA37);
final jsonWithHash = withHash.toJson(color);
final jsonWithoutHash = withoutHash.toJson(color);
expect(jsonWithHash, startsWith('#'));
expect(jsonWithoutHash, isNot(startsWith('#')));
expect(withHash.fromJson(jsonWithHash), equals(color));
expect(withoutHash.fromJson(jsonWithoutHash), equals(color));
});
});
group('ColorConverter – input formats', () {
test('accepts 6-digit hex (RRGGBB) with/without "#"', () {
final c1 = withHash.fromJson('#000000'); // black, alpha FF
final c2 = withHash.fromJson('000000');
final c3 = withHash.fromJson('#FFFFFF'); // white, alpha FF
final c4 = withHash.fromJson('FFFFFF');
expect(c1, equals(const Color(0xFF000000)));
expect(c2, equals(const Color(0xFF000000)));
expect(c3, equals(const Color(0xFFFFFFFF)));
expect(c4, equals(const Color(0xFFFFFFFF)));
});
test('accepts 8-digit hex (RRGGBBAA) with/without "#"', () {
final c1 = withHash.fromJson('#00000000');
final c2 = withHash.fromJson('00000000');
final c3 = withHash.fromJson('#FFFFFF80');
final c4 = withHash.fromJson('FFFFFF80');
expect(c1, equals(const Color(0x00000000))); // transparent black
expect(c2, equals(const Color(0x00000000)));
// RRGGBBAA => white(FF,FF,FF) with alpha 0x80
expect(c3, equals(const Color(0x80FFFFFF)));
expect(c4, equals(const Color(0x80FFFFFF)));
});
test('accepts shorthand 3-digit hex (RGB)', () {
// #000 -> 00 00 00, alpha FF
expect(withHash.fromJson('#000'), equals(const Color(0xFF000000)));
expect(withHash.fromJson('000'), equals(const Color(0xFF000000)));
// #FFF -> FF FF FF, alpha FF (white)
expect(withHash.fromJson('#FFF'), equals(const Color(0xFFFFFFFF)));
expect(withHash.fromJson('FFF'), equals(const Color(0xFFFFFFFF)));
// #ABC -> AA BB CC, alpha FF
final color = withHash.fromJson('#ABC');
expect(color, equals(const Color(0xFFAABBCC)));
});
test('accepts shorthand 4-digit hex (RGBA)', () {
// #0000 -> black with alpha 0x00
expect(withHash.fromJson('#0000'), equals(const Color(0x00000000)));
// #000F -> black with alpha 0xFF
expect(withHash.fromJson('#000F'), equals(const Color(0xFF000000)));
// #FFF8 -> white with alpha 0x88
final color = withHash.fromJson('#FFF8');
expect(color, equals(const Color(0x88FFFFFF)));
});
test('accepts lower/upper case, trims whitespace', () {
final c1 = withHash.fromJson(' #fbea3766 ');
// cspell:disable-next-line
final c2 = withHash.fromJson('\tFBEA3766\n');
expect(c1, equals(const Color(0x66FBEA37)));
expect(c2, equals(const Color(0x66FBEA37)));
});
test('parses correctly with containsHash = false (input is tolerant)', () {
const converter = ColorConverter(containsHash: false);
// 입력에서는 # 유무 둘 다 허용
final c1 = converter.fromJson('#000000');
final c2 = converter.fromJson('000000');
expect(c1, equals(const Color(0xFF000000)));
expect(c2, equals(const Color(0xFF000000)));
// 출력 포맷만 #이 없다
final json = converter.toJson(const Color(0xFF000000));
expect(json, '000000FF');
});
});
group('ColorConverter – ARGB <-> RGBA mapping', () {
test('example mapping: 0x66FBEA37 <-> #FBEA3766', () {
const color = Color(0x66FBEA37);
final json = withHash.toJson(color);
expect(json, '#FBEA3766');
final parsed = withHash.fromJson('#FBEA3766');
expect(parsed, equals(color));
});
test('alpha channel is last byte in JSON (RRGGBBAA)', () {
// A=0x12, R=0x34, G=0x56, B=0x78 => JSON: 34567812
const color = Color(0x12345678);
final json = withoutHash.toJson(color);
expect(json, '34567812');
final parsed = withoutHash.fromJson('34567812');
expect(parsed, equals(color));
});
});
group('ColorConverter – error cases / validation', () {
test('empty or whitespace-only string throws ArgumentError', () {
expect(
() => withHash.fromJson(''),
throwsA(isA<ArgumentError>()),
);
expect(
() => withHash.fromJson(' '),
throwsA(isA<ArgumentError>()),
);
expect(
() => withHash.fromJson('#'),
throwsA(isA<ArgumentError>()),
);
expect(
() => withHash.fromJson(' # '),
throwsA(isA<ArgumentError>()),
);
});
test('invalid length throws FormatException', () {
// 1, 2, 5, 7, 9, etc.
for (final input in ['0', '00', '00000', '12345', '1234567', '123456789']) {
expect(
() => withHash.fromJson(input),
throwsA(isA<FormatException>()),
reason: 'Length of "${input.length}" should be invalid',
);
}
});
test('invalid characters throw FormatException', () {
// cspell:ignore XYZXYZ
for (final input in ['#ZZZZZZ', 'GGG', '#12345G', 'XYZXYZ']) {
expect(
() => withHash.fromJson(input),
throwsA(isA<FormatException>()),
reason: 'Invalid hex string "$input" must throw',
);
}
});
});
group('ColorConverter – toJson format details', () {
test('always outputs 8 hex digits (RRGGBBAA)', () {
final json1 = withHash.toJson(const Color(0xFF000000));
final json2 = withHash.toJson(const Color(0x00000000));
final json3 = withHash.toJson(const Color(0x0A010203));
expect(json1, matches(r'^#[0-9A-F]{8}$'));
expect(json2, matches(r'^#[0-9A-F]{8}$'));
expect(json3, matches(r'^#[0-9A-F]{8}$'));
});
test('outputs uppercase hex', () {
final json = withHash.toJson(const Color(0xAaBbCcDd));
// 제거 후 비교
final hex = json.replaceFirst('#', '');
expect(hex, equals(hex.toUpperCase()));
});
test('containsHash = false omits "#"', () {
final json = withoutHash.toJson(const Color(0xFF112233));
expect(json, '112233FF');
});
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment