Last active
November 22, 2025 00:27
-
-
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 '#'.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 🐦 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); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 🐦 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